You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/25 08:17:34 UTC

(plc4x) branch develop updated: feat(plc4net): add .NET 8 API and SPI foundation (#2771)

This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new 676de7ee3e feat(plc4net): add .NET 8 API and SPI foundation (#2771)
676de7ee3e is described below

commit 676de7ee3e0b8c70f96b04636d1a5e544b548a18
Author: TomNewChao <ch...@gmail.com>
AuthorDate: Fri Sep 25 16:17:29 2026 +0800

    feat(plc4net): add .NET 8 API and SPI foundation (#2771)
    
    * feat(plc4net): add .NET 8 API and SPI foundation
    
    * fix(plc4net): resolve Copilot review findings on nullable and numeric safety
    
    Consolidates three related fixes into a single commit:
    
    1. Enable nullable analysis (Directory.Build.props) and correct nullable contracts
       - Switches from 'annotations' to 'enable' for full null-safety checking
       - Annotates feature-gated builders, transports, authentication as nullable
       - Makes PlcValueAdapter reference-type accessors throw instead of returning null
    
    2. Align DefaultDotNetType table to native .NET storage types
       - WORD/DWORD/LWORD: short/int/long → ushort/uint/ulong (unsigned)
       - USINT/UINT/UDINT: ushort/uint/ulong → byte/ushort/uint (unsigned)
       - WCHAR: short → char (actual storage)
       - DATE/LDATE: DateTime → DateOnly (actual storage)
       - TIME_OF_DAY/LTIME_OF_DAY: TimeSpan → TimeOnly (actual storage)
    
    3. Add numeric bounds validation for temporal fields
       - PlcLTIME.GetDuration(): check nanoseconds overflow
       - PlcDATE_AND_LTIME: validate nanosecondsOfSecond in [0, 999_999_999]
       - PlcDATE_AND_LTIME.OfSegments(): validate nanoseconds parameter
       - PlcLTIME_OF_DAY: guard against overflow on large values
    
    Tests: 138/138 passing
    Build: 0 errors, 0 warnings
---
 .github/workflows/dotnet-platform.yml              |  53 +++
 plc4net/Directory.Build.props                      |  77 +++++
 plc4net/PACKAGE.md                                 |  40 +++
 plc4net/api/IPlcConnection.cs                      |  59 ++++
 plc4net/api/IPlcDriver.cs                          |  47 +++
 plc4net/api/PlcDriverManager.cs                    | 151 ++++----
 plc4net/api/{plc4net-api.csproj => api.csproj}     |  24 +-
 plc4net/api/api/IPlcConnection.cs                  |  89 -----
 plc4net/api/api/IPlcDriver.cs                      |  57 ---
 plc4net/api/api/model/IPlcField.cs                 |  39 ---
 plc4net/api/api/types/PlcValueType.cs              |  98 ------
 .../{api => }/authentication/IPlcAuthentication.cs |   0
 .../PlcUsernamePasswordAuthentication.cs           |   0
 .../{api => }/exceptions/PlcConnectionException.cs |   8 -
 plc4net/api/{api => }/exceptions/PlcException.cs   |  12 +-
 .../PlcIncompatibleDatatypeException.cs}           |  22 +-
 .../exceptions/PlcInvalidFieldException.cs         |  10 +-
 .../extensions/Plc4NetServiceExtensions.cs}        |  41 +--
 plc4net/api/{api => }/messages/IPlcMessage.cs      |   2 +-
 plc4net/api/{api => }/messages/IPlcReadRequest.cs  |   4 +-
 .../{api => }/messages/IPlcReadRequestBuilder.cs   |  10 +-
 plc4net/api/{api => }/messages/IPlcReadResponse.cs |   0
 plc4net/api/{api => }/messages/IPlcRequest.cs      |   0
 .../api/{api => }/messages/IPlcRequestBuilder.cs   |   0
 plc4net/api/{api => }/messages/IPlcResponse.cs     |   2 +-
 .../messages/IPlcSubscriptionEventArgs.cs          |   0
 .../{api => }/messages/IPlcSubscriptionRequest.cs  |   2 +-
 .../messages/IPlcSubscriptionRequestBuilder.cs     |   2 +-
 .../{api => }/messages/IPlcSubscriptionResponse.cs |   2 +-
 .../IPlcTagRequest.cs}                             |  24 +-
 .../IPlcTagResponse.cs}                            |  18 +-
 .../messages/IPlcUnsubscriptionRequest.cs          |   4 +-
 .../messages/IPlcUnsubscriptionRequestBuilder.cs   |   2 +-
 .../messages/IPlcUnsubscriptionResponse.cs         |   4 +-
 plc4net/api/{api => }/messages/IPlcWriteRequest.cs |   2 +-
 .../{api => }/messages/IPlcWriteRequestBuilder.cs  |  72 ++--
 .../IPlcWriteResponse.cs}                          |   5 +-
 .../metadata/DefaultPlcConnectionMetadata.cs}      |  32 +-
 .../{api => }/metadata/IPlcConnectionMetadata.cs   |   0
 .../api/{api => }/model/IPlcSubscriptionHandle.cs  |   0
 .../values/PlcDINT.cs => api/model/IPlcTag.cs}     |  17 +-
 plc4net/api/{api => }/types/PlcResponseCode.cs     |   9 +-
 plc4net/api/types/PlcValueType.cs                  |  98 ++++++
 plc4net/api/{api => }/value/IPlcValue.cs           |  13 +-
 plc4net/plc4net.sln                                | 103 ++++--
 plc4net/pom.xml                                    |  30 +-
 plc4net/spi/drivers/ConnectionBase.cs              | 148 ++++++++
 plc4net/spi/drivers/ConnectionString.cs            | 217 ++++++++++++
 plc4net/spi/drivers/DriverBase.cs                  | 294 ++++++++++++++++
 plc4net/spi/drivers/MessageCodecBase.cs            | 206 +++++++++++
 plc4net/spi/drivers/ScanExtensions.cs              | 117 +++++++
 .../drivers/functions/PlcReader.cs}                |  15 +-
 .../drivers/functions/PlcWriter.cs}                |  15 +-
 .../spi/drivers/messages/DefaultPlcReadRequest.cs  |  61 ++++
 .../messages/DefaultPlcReadRequestBuilder.cs       |  68 ++++
 .../spi/drivers/messages/DefaultPlcReadResponse.cs |  73 ++++
 .../spi/drivers/messages/DefaultPlcWriteRequest.cs |  69 ++++
 .../messages/DefaultPlcWriteRequestBuilder.cs      | 143 ++++++++
 .../drivers/messages/DefaultPlcWriteResponse.cs    |  54 +++
 .../messages/items/DefaultPlcResponseItem.cs}      |  16 +-
 .../messages/items/DefaultPlcTagErrorItem.cs}      |  16 +-
 .../drivers/messages/items/DefaultPlcTagItem.cs}   |  16 +-
 .../messages/items/DefaultPlcTagValueItem.cs}      |  19 +-
 .../drivers/messages/items/PlcResponseItem.cs}     |  13 +-
 .../drivers/messages/items/PlcTagItem.cs}          |  13 +-
 .../drivers/messages/items/PlcTagValueItem.cs}     |  12 +-
 .../drivers/tags/PlcTagHandler.cs}                 |  27 +-
 plc4net/spi/generation/BitReader.cs                | 146 ++++++++
 plc4net/spi/generation/BitWriter.cs                |  93 +++++
 plc4net/spi/{spi => }/generation/ByteOrder.cs      |   2 +-
 .../spi/{spi => }/generation/EvaluationHelper.cs   |  17 +-
 .../ParseException.cs}                             |  29 +-
 plc4net/spi/generation/ReadBuffer.cs               | 222 ++++++++++++
 plc4net/spi/generation/WriteBuffer.cs              | 236 +++++++++++++
 .../values/PlcWORD.cs => model/values/PlcBOOL.cs}  |  66 ++--
 .../values/PlcNULL.cs => model/values/PlcBYTE.cs}  |  17 +-
 plc4net/spi/model/values/PlcBitString.cs           | 188 ++++++++++
 plc4net/spi/{spi => }/model/values/PlcCHAR.cs      |  18 +-
 plc4net/spi/{spi => }/model/values/PlcDATE.cs      |  45 ++-
 plc4net/spi/model/values/PlcDATE_AND_LTIME.cs      | 127 +++++++
 .../spi/{spi => }/model/values/PlcDATE_AND_TIME.cs |  41 ++-
 plc4net/spi/{spi => }/model/values/PlcDINT.cs      |   0
 .../values/PlcDWORD.cs}                            |  20 +-
 plc4net/spi/{spi => }/model/values/PlcINT.cs       |   0
 plc4net/spi/{spi => }/model/values/PlcLINT.cs      |   0
 plc4net/spi/{spi => }/model/values/PlcLREAL.cs     |   0
 plc4net/spi/{spi => }/model/values/PlcLTIME.cs     |  44 ++-
 plc4net/spi/model/values/PlcLTIME_OF_DAY.cs        |  89 +++++
 .../values/PlcBOOL.cs => model/values/PlcLWORD.cs} |  20 +-
 .../PlcPlcList.cs => model/values/PlcList.cs}      |  43 ++-
 .../values/PlcNULL.cs}                             |  40 ++-
 plc4net/spi/{spi => }/model/values/PlcREAL.cs      |   0
 .../PlcBYTE.cs => model/values/PlcRawByteArray.cs} |  54 ++-
 plc4net/spi/{spi => }/model/values/PlcSINT.cs      |   0
 plc4net/spi/{spi => }/model/values/PlcSTRING.cs    |  18 +-
 .../model/values/PlcSimpleNumericValueAdapter.cs   | 253 ++++++++++++++
 .../model/values/PlcSimpleValueAdapter.cs          |   8 +-
 plc4net/spi/model/values/PlcStruct.cs              |  91 +++++
 plc4net/spi/{spi => }/model/values/PlcTIME.cs      |  24 +-
 .../values/PlcTIME_OF_DAY.cs}                      |  39 ++-
 plc4net/spi/{spi => }/model/values/PlcUDINT.cs     |   0
 plc4net/spi/{spi => }/model/values/PlcUINT.cs      |   0
 plc4net/spi/{spi => }/model/values/PlcULINT.cs     |   0
 plc4net/spi/{spi => }/model/values/PlcUSINT.cs     |   0
 plc4net/spi/model/values/PlcValueAdapter.cs        | 281 +++++++++++++++
 plc4net/spi/{spi => }/model/values/PlcWCHAR.cs     |  18 +-
 .../values/PlcNULL.cs => model/values/PlcWORD.cs}  |  17 +-
 plc4net/spi/{spi => }/model/values/PlcWSTRING.cs   |  18 +-
 plc4net/{api/plc4net-api.csproj => spi/spi.csproj} |  17 +-
 plc4net/spi/spi/generation/ParseException.cs       |  26 --
 plc4net/spi/spi/generation/ReadBuffer.cs           | 206 -----------
 plc4net/spi/spi/generation/WriteBuffer.cs          | 178 ----------
 plc4net/spi/spi/model/values/PlcDWORD.cs           |  90 -----
 plc4net/spi/spi/model/values/PlcLWORD.cs           | 106 ------
 .../model/values/PlcSimpleNumericValueAdapter.cs   | 212 ------------
 plc4net/spi/spi/model/values/PlcStruct.cs          |  54 ---
 plc4net/spi/spi/model/values/PlcValueAdapter.cs    | 242 -------------
 plc4net/spi/transports/BaseTransportInstance.cs    |  96 +++++
 plc4net/spi/transports/ITransport.cs               | 108 ++++++
 plc4net/spi/transports/ITransportInstance.cs       |  95 +++++
 plc4net/spi/transports/RingBuffer.cs               | 153 ++++++++
 .../TransportException.cs}                         |  25 +-
 .../test/spi-test/drivers/ConnectionStringTests.cs | 196 +++++++++++
 plc4net/test/spi-test/drivers/DriverBaseTests.cs   | 385 +++++++++++++++++++++
 .../test/spi-test/drivers/MessageCodecBaseTests.cs | 176 ++++++++++
 plc4net/test/spi-test/drivers/MessagesTests.cs     | 126 +++++++
 .../test/spi-test/drivers/PlcDriverManagerTests.cs | 128 +++++++
 plc4net/test/spi-test/generation/BufferTests.cs    | 230 ++++++++++++
 .../test/spi-test/model/values/PlcValueTests.cs    | 256 ++++++++++++++
 .../spi-test/model/values/PlcValueTypeTests.cs     | 113 ++++++
 .../spi-test/spi-test.csproj}                      |  31 +-
 .../test/spi-test/transports/RingBufferTests.cs    | 162 +++++++++
 .../test/spi-test/transports/TestTransportTests.cs | 143 ++++++++
 plc4net/transports/test/TestTransport.cs           |  72 ++++
 .../test/TestTransportConfiguration.cs}            |  36 +-
 plc4net/transports/test/TestTransportInstance.cs   | 238 +++++++++++++
 .../test/test.csproj}                              |  30 +-
 137 files changed, 7132 insertions(+), 2018 deletions(-)

diff --git a/.github/workflows/dotnet-platform.yml b/.github/workflows/dotnet-platform.yml
new file mode 100644
index 0000000000..a6d3906a26
--- /dev/null
+++ b/.github/workflows/dotnet-platform.yml
@@ -0,0 +1,53 @@
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#    https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+name: .NET Platform Compatibility
+
+on:
+  push:
+    branches: [develop, plc4net]
+    paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+  pull_request:
+    branches: [develop, plc4net]
+    paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    name: Build and test (${{ matrix.os }})
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v7
+      - uses: actions/setup-dotnet@v5
+        with:
+          dotnet-version: 8.0.x
+      - name: Restore
+        run: dotnet restore plc4net/plc4net.sln
+      - name: Build
+        run: dotnet build plc4net/plc4net.sln --configuration Debug --no-restore --no-incremental
+      - name: Test
+        run: dotnet test plc4net/plc4net.sln --configuration Debug --no-build
+      - name: Check vulnerable dependencies
+        run: dotnet list plc4net/plc4net.sln package --vulnerable --include-transitive
diff --git a/plc4net/Directory.Build.props b/plc4net/Directory.Build.props
new file mode 100644
index 0000000000..87945b8953
--- /dev/null
+++ b/plc4net/Directory.Build.props
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+<Project>
+
+  <!--
+    Properties shared by every PLC4Net project. Keeping them here means a
+    framework or version bump is a one-line change instead of a sweep over
+    every .csproj.
+  -->
+  <PropertyGroup>
+    <!--
+      net8.0 (LTS) replaces the previous net452 target. .NET Framework 4.5.2
+      went out of support in April 2022 and cannot be built on the Linux and
+      macOS CI runners, which is what kept PLC4Net off the CI matrix.
+    -->
+    <TargetFramework>net8.0</TargetFramework>
+    <LangVersion>latest</LangVersion>
+    <!-- Full nullable analysis: warnings stay on so a false non-null contract
+         cannot ship. Public members whose documented contract allows null
+         (e.g. feature-gated request builders, unknown transport codes) must
+         annotate that with `?`. -->
+    <Nullable>enable</Nullable>
+    <!-- The module builds warning-clean today; keep it that way in CI so a
+         later change cannot quietly regress it. -->
+    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+    <RootNamespace>org.apache.plc4net</RootNamespace>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <Authors>Apache PLC4X</Authors>
+    <Copyright>The Apache Software Foundation</Copyright>
+    <PackageProjectUrl>https://plc4x.apache.org</PackageProjectUrl>
+    <!-- PackageLicenseUrl is deprecated (NU5125); the SPDX expression replaces it. -->
+    <PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
+    <!-- Overridden by the Maven build via -p:Version=${project.version}. -->
+    <Version>1.1.0-SNAPSHOT</Version>
+    <!--
+      AssemblyVersion is pinned to MAJOR.0.0.0 so it stays stable across every
+      1.x build; the NuGet package version and FileVersion still track -p:Version.
+      Without this a consumer that restored two packages built at different
+      -p:Version values gets a binding mismatch (driver-s7 refs plc4net-spi
+      1.1.0.0, the packed plc4net-spi.dll is 0.0.1.0).
+    -->
+    <AssemblyVersion>1.0.0.0</AssemblyVersion>
+    <!-- Per-project <Title> stays specific; this is the fallback prose NuGet wants. -->
+    <Description>Apache PLC4X for .NET (plc4net): API, SPI, and transport foundations for industrial protocol drivers.</Description>
+    <PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
+    <RepositoryUrl>https://github.com/apache/plc4x</RepositoryUrl>
+    <RepositoryType>git</RepositoryType>
+    <!-- Embed the PDB in the assembly so a symbol server is not needed and every
+         feed (GitHub Packages does not take .snupkg) carries usable symbols. -->
+    <DebugType>embedded</DebugType>
+  </PropertyGroup>
+
+  <!-- Ship the package readme in every packable project. -->
+  <ItemGroup Condition="'$(IsPackable)' != 'false'">
+    <None Include="$(MSBuildThisFileDirectory)PACKAGE.md" Pack="true" PackagePath="\" Visible="false" />
+  </ItemGroup>
+
+</Project>
diff --git a/plc4net/PACKAGE.md b/plc4net/PACKAGE.md
new file mode 100644
index 0000000000..a066055ac2
--- /dev/null
+++ b/plc4net/PACKAGE.md
@@ -0,0 +1,40 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+  -->
+# Apache PLC4X for .NET (plc4net)
+
+The .NET implementation of [Apache PLC4X](https://plc4x.apache.org). This
+package set currently provides the API, SPI runtime, and a loopback test
+transport. Protocol drivers and production transports are separate follow-up
+contributions.
+
+## Packages
+
+| Package | Contents |
+|---|---|
+| `plc4net-api` | connection / driver / value interfaces |
+| `plc4net-spi` | driver runtime, value model, buffers, `ConnectionString` |
+| `plc4net-transports-test` | in-memory loopback transport for tests |
+
+## Status
+
+This is the first buildable slice of the plc4net revival described in
+[apache/plc4x#2656](https://github.com/apache/plc4x/pull/2656). It does not
+yet include a production transport or a protocol driver. Pre-release builds only.
+
+Licensed under the Apache License 2.0.
diff --git a/plc4net/api/IPlcConnection.cs b/plc4net/api/IPlcConnection.cs
new file mode 100644
index 0000000000..0e19e3830b
--- /dev/null
+++ b/plc4net/api/IPlcConnection.cs
@@ -0,0 +1,59 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.api.metadata;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+
+namespace org.apache.plc4net.api
+{
+    /// <summary>
+    /// A live connection to a device.
+    /// </summary>
+    public interface IPlcConnection : IDisposable
+    {
+        bool IsConnected { get; }
+
+        void Close();
+
+        IPlcConnectionMetadata PlcConnectionMetadata { get; }
+
+        /// <summary>
+        /// Parses a protocol-specific tag string, e.g. "%DB1.DBB0:INT".
+        /// </summary>
+        IPlcTag Parse(string tagQuery);
+
+        /// <summary>null when reading is not supported.</summary>
+        IPlcReadRequestBuilder? ReadRequestBuilder { get; }
+
+        /// <summary>null when writing is not supported.</summary>
+        IPlcWriteRequestBuilder? WriteRequestBuilder { get; }
+
+        /// <summary>null when subscriptions are not supported.</summary>
+        IPlcSubscriptionRequestBuilder? SubscriptionRequestBuilder { get; }
+
+        /// <summary>null when subscriptions are not supported.</summary>
+        IPlcUnsubscriptionRequestBuilder? UnsubscriptionRequestBuilder { get; }
+
+        /// <summary>null when the connection was established without authentication.</summary>
+        IPlcAuthentication? Authentication { get; }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/api/IPlcDriver.cs b/plc4net/api/IPlcDriver.cs
new file mode 100644
index 0000000000..48cfcf0ede
--- /dev/null
+++ b/plc4net/api/IPlcDriver.cs
@@ -0,0 +1,47 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.exceptions;
+
+namespace org.apache.plc4net.api
+{
+    /// <summary>
+    /// A PLC protocol driver.
+    /// </summary>
+    /// <remarks>
+    /// This is the SPI3-aligned contract: drivers are synchronous factories that
+    /// open connections. The transport selection and connection-string parsing
+    /// is handled by <see cref="org.apache.plc4net.spi.drivers.DriverBase"/>.
+    /// </remarks>
+    public interface IPlcDriver
+    {
+        string ProtocolCode { get; }
+
+        string ProtocolName { get; }
+
+        /// <summary>
+        /// Opens a connection to the device described by <paramref name="connectionString"/>.
+        /// </summary>
+        /// <exception cref="PlcConnectionException">on connection failure</exception>
+        IPlcConnection Connect(string connectionString);
+
+        IPlcConnection Connect(string connectionString, IPlcAuthentication? authentication);
+    }
+}
\ No newline at end of file
diff --git a/plc4net/api/PlcDriverManager.cs b/plc4net/api/PlcDriverManager.cs
index 294cbb1c1e..367c74ba8e 100644
--- a/plc4net/api/PlcDriverManager.cs
+++ b/plc4net/api/PlcDriverManager.cs
@@ -1,25 +1,25 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
 
 using System;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
-using System.Threading.Tasks;
 using org.apache.plc4net.api;
 using org.apache.plc4net.api.authentication;
 using org.apache.plc4net.exceptions;
@@ -27,78 +27,89 @@ using org.apache.plc4net.exceptions;
 namespace org.apache.plc4net
 {
     /// <summary>
-    /// Manages connections to PLCs
+    /// Registry of PLC protocol drivers. A driver registers itself here;
+    /// connection requests are dispatched to the driver whose protocol code
+    /// matches the connection string scheme.
     /// </summary>
+    /// <remarks>
+    /// All read and write paths through the driver dictionary are safe for
+    /// concurrent use so that a background initialisation task can register
+    /// a driver without racing against an incoming connection request.
+    /// </remarks>
     public class PlcDriverManager
     {
-        /// <summary>
-        /// Singleton instance of the manager
-        /// </summary>
-        private static PlcDriverManager _instance;
+        // Static initializer is thread-safe; the CLR guarantees it runs exactly once.
+        private static readonly PlcDriverManager _instance = new PlcDriverManager();
 
-        /// <summary>
-        /// Get the singleton instance
-        /// </summary>
-        public static PlcDriverManager Instance => _instance ?? (_instance = new PlcDriverManager());
+        // Protocol codes are matched case-insensitively, the same way
+        // DefaultTransportManager matches transport codes.
+        private readonly ConcurrentDictionary<string, IPlcDriver> _drivers
+            = new ConcurrentDictionary<string, IPlcDriver>(StringComparer.OrdinalIgnoreCase);
 
-        /// <summary>
-        /// Dictionary for the drivers
-        /// </summary>
-        private readonly Dictionary<string, IPlcDriver> _drivers;
-
-        /// <summary>
-        /// Private constructor for the singleton driver manager.
-        /// </summary>
         private PlcDriverManager()
         {
-            _drivers = new Dictionary<string, IPlcDriver>();
-
-            /*
-             * TODO: Implement some mechanism to provide drivers -> MEF?
-             */
         }
 
-        /// <summary>
-        /// Get the connection to the a PLC identified by the URL
-        /// </summary>
-        /// <param name="url">URL including the schema to connect to the PLC</param>
-        /// <param name="authentication">Authentication to use</param>
-        /// <returns>Created PLC connection</returns>
-        public async Task<IPlcConnection> GetConnection(string url, IPlcAuthentication authentication)
+        public static PlcDriverManager Instance => _instance;
+
+        public void RegisterDriver(IPlcDriver driver)
         {
-            var plcDriver = GetDriver(url);
-            var connection = await plcDriver.ConnectAsync(url, authentication);
-            
-            //TODO: Does the driver method already connect or is a separate connect needed?
-            //TODO: Should we do it like this?
-            if (!connection.IsConnected)
+            if (driver == null)
             {
-                await connection.ConnectAsync();
+                throw new ArgumentNullException(nameof(driver));
             }
-
-            return connection;
+            _drivers[driver.ProtocolCode] = driver;
         }
 
         public IPlcDriver GetDriver(string url)
         {
-            try
-            {
-                Uri plcUri = new Uri(url);
-                var proto = plcUri.Scheme;
+            // Extract the protocol code from the connection string without relying
+            // on System.Uri, which may reject or misinterpret non-standard schemes
+            // like "s7:cotp". The pattern is always {protocol}(:{transport})?://...
+            if (string.IsNullOrWhiteSpace(url))
+                throw new PlcConnectionException("Connection string must not be null or empty.");
 
-                _drivers.TryGetValue(proto, out var plcDriver);
+            var schemeEnd = url.IndexOf("://", StringComparison.Ordinal);
+            if (schemeEnd < 0)
+                throw new PlcConnectionException(
+                    $"Invalid connection string '{url}': no :// separator found.");
 
-                if (plcDriver == null)
-                {
-                    throw new PlcConnectionException($"Unknown driver for protocol '{proto}'");
-                }
+            var scheme = schemeEnd > 0 ? url.Substring(0, schemeEnd) : string.Empty;
+            if (scheme.Length == 0)
+                throw new PlcConnectionException(
+                    $"Invalid connection string '{url}': scheme is empty.");
 
-                return plcDriver;
-            }
-            catch (UriFormatException invalidUriException)
+            // For two-scheme forms like s7:cotp://host, take the protocol
+            // portion before the colon (s7), matching what Java's
+            // URI.getScheme() returns for the same input.
+            var colon = scheme.IndexOf(':');
+            if (colon >= 0)
+                scheme = scheme.Substring(0, colon);
+
+            return GetDriverByCode(scheme);
+        }
+
+        public IPlcDriver GetDriverByCode(string? protocolCode)
+        {
+            if (protocolCode != null
+                && _drivers.TryGetValue(protocolCode, out var driver)
+                && driver != null)
             {
-                throw new PlcConnectionException($"Provided connection string '{url}' is invalid", invalidUriException);
+                return driver;
             }
+            throw new PlcConnectionException(
+                $"No driver registered for protocol '{protocolCode}'.");
+        }
+
+        public IPlcConnection GetConnection(string connectionString)
+        {
+            return GetConnection(connectionString, null);
+        }
+
+        public IPlcConnection GetConnection(string connectionString, IPlcAuthentication? authentication)
+        {
+            var driver = GetDriver(connectionString);
+            return driver.Connect(connectionString, authentication);
         }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/plc4net-api.csproj b/plc4net/api/api.csproj
similarity index 57%
copy from plc4net/api/plc4net-api.csproj
copy to plc4net/api/api.csproj
index ee7f65ff08..43d813fa51 100644
--- a/plc4net/api/plc4net-api.csproj
+++ b/plc4net/api/api.csproj
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <!--
   Licensed to the Apache Software Foundation (ASF) under one
   or more contributor license agreements.  See the NOTICE file
@@ -18,18 +18,12 @@
   under the License.
 -->
 <Project Sdk="Microsoft.NET.Sdk">
-
-    <PropertyGroup>
-        <TargetFramework>net452</TargetFramework>
-        <RootNamespace>org.apache.plc4net</RootNamespace>
-        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
-        <Authors>Apache PLC4X</Authors>
-        <Copyright>The Apache Software Foundation</Copyright>
-        <PackageProjectUrl>https://plc4x.apache.org</PackageProjectUrl>
-        <Title>PLC4Net: API</Title>
-        <PackageLicenseUrl>https://www.apache.org/licenses/LICENSE-2.0</PackageLicenseUrl>
-        <PackageVersion>0.8.0-SNAPSHOT</PackageVersion>
-        <LangVersion>11.0</LangVersion>
+  <PropertyGroup>
+    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
+    <AssemblyName>plc4net-api</AssemblyName>
+    <Title>PLC4Net: API</Title>
   </PropertyGroup>
-
-</Project>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
+  </ItemGroup>
+</Project>
\ No newline at end of file
diff --git a/plc4net/api/api/IPlcConnection.cs b/plc4net/api/api/IPlcConnection.cs
deleted file mode 100644
index cbbacf86df..0000000000
--- a/plc4net/api/api/IPlcConnection.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-using System;
-using System.Threading.Tasks;
-using org.apache.plc4net.api.metadata;
-using org.apache.plc4net.messages;
-using org.apache.plc4net.model;
-
-namespace org.apache.plc4net.api
-{
-    /// <summary>
-    /// Interface for generalized PLC connections providing
-    /// functionality for basic operations like connect / disconnect etc.
-    /// </summary>
-    public interface IPlcConnection: IDisposable
-    {
-        /// <summary>
-        /// Connect to the PLC asynchronously
-        /// </summary>
-        /// <returns>Awaitable task</returns>
-        /// <exception cref="org.apache.plc4net.exceptions.PlcConnectionException">Thrown if the connection to the PLC fails</exception>
-        Task ConnectAsync();
-
-        /// <summary>
-        /// Indicates the connection state
-        /// </summary>
-        bool IsConnected { get; }
-
-        /// <summary>
-        /// Close the PLC connection asynchronously
-        /// </summary>
-        /// <returns>Awaitable task</returns>
-        Task CloseAsync();
-
-        /// <summary>
-        /// Get the metadata for the connection
-        /// </summary>
-        IPlcConnectionMetadata PlcConnectionMetadata { get; }
-
-        /// <summary>
-        /// Parse the given field query
-        /// </summary>
-        /// <param name="fieldQuery">Query for the field</param>
-        /// <returns>Field parsed from the query string</returns>
-        /// <exception cref="org.apache.plc4net.exceptions.PlcInvalidFieldException">Thrown when the query can not be parsed</exception>
-        IPlcField Parse(string fieldQuery);
-
-        /// <summary>
-        /// Request builder for constructing read requests
-        /// </summary>
-        /// <returns>null if the connection does not support reading</returns>
-        IPlcReadRequestBuilder ReadRequestBuilder { get; }
-
-        /// <summary>
-        /// Request builder for constructing write requests
-        /// </summary>
-        /// <returns>null if the connection does not support writing</returns>
-        IPlcWriteRequestBuilder WriteRequestBuilder { get; }
-
-        /// <summary>
-        /// Request builder for constructing subscription requests
-        /// </summary>
-        /// <returns>null if the connection does not support subscriptions</returns>
-        IPlcSubscriptionRequestBuilder SubscriptionRequestBuilder { get; }
-
-        /// <summary>
-        /// Request builder for unsubscribing
-        /// </summary>
-        /// <returns>null if the connection does not support subscriptions</returns>
-        IPlcUnsubscriptionRequestBuilder UnsubscriptionRequestBuilder { get; }
-    }
-}
\ No newline at end of file
diff --git a/plc4net/api/api/IPlcDriver.cs b/plc4net/api/api/IPlcDriver.cs
deleted file mode 100644
index aef20bdd21..0000000000
--- a/plc4net/api/api/IPlcDriver.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-using System.Threading.Tasks;
-using org.apache.plc4net.api.authentication;
-
-namespace org.apache.plc4net.api
-{
-    /// <summary>
-    /// Interface for PLC drivers to be implemented
-    /// </summary>
-    public interface IPlcDriver
-    {
-        /// <summary>
-        /// Get the code of the implemented protocol
-        /// </summary>
-        string ProtocolCode { get; }
-
-        /// <summary>
-        /// Full Name of the implemented protocol
-        /// </summary>
-        string ProtocolName { get; }
-
-        /// <summary>
-        /// Connects to the PLC identified by the connection string
-        /// </summary>
-        /// <param name="connectionString">Connection string identifying the PLC to connect to</param>
-        /// <returns>Awaitable task returning the <see cref="IPlcConnection"/> to which the connection was established</returns>
-        /// <exception cref="org.apache.plc4net.exceptions.PlcConnectionException">Thrown on connection failure</exception>
-        Task<IPlcConnection> ConnectAsync(string connectionString);
-
-        /// <summary>
-        /// Connects to the PLC identified by the connection string and using the 
-        /// </summary>
-        /// <param name="connectionString"></param>
-        /// <param name="authentication"></param>
-        /// <returns>Awaitable task returning the <see cref="IPlcConnection"/> to which the connection was established</returns>
-        /// <exception cref="org.apache.plc4net.exceptions.PlcConnectionException">Thrown on connection failure</exception>
-        Task<IPlcConnection> ConnectAsync(string connectionString, IPlcAuthentication authentication);
-    }
-}
\ No newline at end of file
diff --git a/plc4net/api/api/model/IPlcField.cs b/plc4net/api/api/model/IPlcField.cs
deleted file mode 100644
index e880a38eda..0000000000
--- a/plc4net/api/api/model/IPlcField.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-
-namespace org.apache.plc4net.model
-{
-    /// <summary>
-    /// Base type for all field types.
-    /// Typically every driver provides an implementation of this interface in order
-    /// to be able to describe the fields of a resource.As this is completely tied to
-    /// the implemented protocol, this base interface makes absolutely no assumption to
-    /// any information it should provide.
-
-    /// In order to stay platform and protocol independent every driver connection implementation
-    /// provides a prepareField(String) method that is able to parse a string representation of
-    /// a resource into it's individual field type. Manually constructing PlcField objects
-    /// manually makes the solution less independent from the protocol, but might be faster.
-    /// </summary>
-    public interface IPlcField
-    {
-
-    }
-}
\ No newline at end of file
diff --git a/plc4net/api/api/types/PlcValueType.cs b/plc4net/api/api/types/PlcValueType.cs
deleted file mode 100644
index 9e2b9ebc67..0000000000
--- a/plc4net/api/api/types/PlcValueType.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-namespace org.apache.plc4net.types
-{
-    using System;
-    using System.Collections.Generic;
-    
-    /// <summary>
-    /// The supported types of PlcValues.
-    /// </summary>
-    public class PlcValueType
-{
-    public static readonly PlcValueType NULL = new PlcValueType(0x00, null);
-    public static readonly PlcValueType BOOL = new PlcValueType(0x01, typeof(bool));
-    public static readonly PlcValueType BYTE = new PlcValueType(0x02, typeof(byte));
-    public static readonly PlcValueType WORD = new PlcValueType(0x03, typeof(short));
-    public static readonly PlcValueType DWORD = new PlcValueType(0x04, typeof(int));
-    public static readonly PlcValueType LWORD = new PlcValueType(0x05, typeof(long));
-    public static readonly PlcValueType USINT = new PlcValueType(0x11, typeof(ushort));
-    public static readonly PlcValueType UINT = new PlcValueType(0x12, typeof(uint));
-    public static readonly PlcValueType UDINT = new PlcValueType(0x13, typeof(ulong));
-    public static readonly PlcValueType ULINT = new PlcValueType(0x14, typeof(System.Numerics.BigInteger));
-    public static readonly PlcValueType SINT = new PlcValueType(0x21, typeof(sbyte));
-    public static readonly PlcValueType INT = new PlcValueType(0x22, typeof(short));
-    public static readonly PlcValueType DINT = new PlcValueType(0x23, typeof(int));
-    public static readonly PlcValueType LINT = new PlcValueType(0x24, typeof(long));
-    public static readonly PlcValueType REAL = new PlcValueType(0x31, typeof(float));
-    public static readonly PlcValueType LREAL = new PlcValueType(0x32, typeof(double));
-    public static readonly PlcValueType CHAR = new PlcValueType(0x41, typeof(char));
-    public static readonly PlcValueType WCHAR = new PlcValueType(0x42, typeof(short));
-    public static readonly PlcValueType STRING = new PlcValueType(0x43, typeof(string));
-    public static readonly PlcValueType WSTRING = new PlcValueType(0x44, typeof(string));
-    public static readonly PlcValueType TIME = new PlcValueType(0x51, typeof(TimeSpan));
-    public static readonly PlcValueType LTIME = new PlcValueType(0x52, typeof(TimeSpan));
-    public static readonly PlcValueType DATE = new PlcValueType(0x53, typeof(DateTime));
-    public static readonly PlcValueType LDATE = new PlcValueType(0x54, typeof(DateTime));
-    public static readonly PlcValueType TIME_OF_DAY = new PlcValueType(0x55, typeof(TimeSpan));
-    public static readonly PlcValueType LTIME_OF_DAY = new PlcValueType(0x56, typeof(TimeSpan));
-    public static readonly PlcValueType DATE_AND_TIME = new PlcValueType(0x57, typeof(DateTime));
-    public static readonly PlcValueType DATE_AND_LTIME = new PlcValueType(0x58, typeof(DateTime));
-    public static readonly PlcValueType LDATE_AND_TIME = new PlcValueType(0x59, typeof(DateTime));
-    public static readonly PlcValueType Struct = new PlcValueType(0x61, typeof(Dictionary<string, object>));
-    public static readonly PlcValueType List = new PlcValueType(0x62, typeof(List<object>));
-    public static readonly PlcValueType RAW_BYTE_ARRAY = new PlcValueType(0x71, typeof(byte[]));
-
-    private static readonly Dictionary<short, PlcValueType> map = new Dictionary<short, PlcValueType>();
-
-    static PlcValueType()
-    {
-        foreach (var field in typeof(PlcValueType).GetFields(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public))
-        {
-            if (field.GetValue(null) is PlcValueType value)
-            {
-                map[value.Value] = value;
-            }
-        }
-    }
-
-    public short Value { get; }
-    public Type DefaultDotNetType { get; }
-
-    private PlcValueType(short value, Type defaultDotNetType)
-    {
-        Value = value;
-        DefaultDotNetType = defaultDotNetType;
-    }
-
-    public static PlcValueType EnumForValue(short value)
-    {
-        map.TryGetValue(value, out var result);
-        return result;
-    }
-
-    public static bool IsDefined(short value)
-    {
-        return map.ContainsKey(value);
-    }
-
-    public override string ToString() => $"PlcValueType(0x{Value:X}, {DefaultDotNetType?.Name ?? "null"})";
-}
-}
\ No newline at end of file
diff --git a/plc4net/api/api/authentication/IPlcAuthentication.cs b/plc4net/api/authentication/IPlcAuthentication.cs
similarity index 100%
rename from plc4net/api/api/authentication/IPlcAuthentication.cs
rename to plc4net/api/authentication/IPlcAuthentication.cs
diff --git a/plc4net/api/api/authentication/PlcUsernamePasswordAuthentication.cs b/plc4net/api/authentication/PlcUsernamePasswordAuthentication.cs
similarity index 100%
rename from plc4net/api/api/authentication/PlcUsernamePasswordAuthentication.cs
rename to plc4net/api/authentication/PlcUsernamePasswordAuthentication.cs
diff --git a/plc4net/api/api/exceptions/PlcConnectionException.cs b/plc4net/api/exceptions/PlcConnectionException.cs
similarity index 85%
copy from plc4net/api/api/exceptions/PlcConnectionException.cs
copy to plc4net/api/exceptions/PlcConnectionException.cs
index 491275af10..463bd90713 100644
--- a/plc4net/api/api/exceptions/PlcConnectionException.cs
+++ b/plc4net/api/exceptions/PlcConnectionException.cs
@@ -18,7 +18,6 @@
  */
 
 using System;
-using System.Runtime.Serialization;
 
 namespace org.apache.plc4net.exceptions
 {
@@ -26,7 +25,6 @@ namespace org.apache.plc4net.exceptions
     /// Exception raised when the connection to a
     /// PLC fails
     /// </summary>
-    [Serializable]
     public class PlcConnectionException : PlcException
     {
         public PlcConnectionException()
@@ -40,11 +38,5 @@ namespace org.apache.plc4net.exceptions
         public PlcConnectionException(string message, Exception inner) : base(message, inner)
         {
         }
-
-        protected PlcConnectionException(
-            SerializationInfo info,
-            StreamingContext context) : base(info, context)
-        {
-        }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/exceptions/PlcException.cs b/plc4net/api/exceptions/PlcException.cs
similarity index 84%
rename from plc4net/api/api/exceptions/PlcException.cs
rename to plc4net/api/exceptions/PlcException.cs
index c631347ffe..0307cf8ffe 100644
--- a/plc4net/api/api/exceptions/PlcException.cs
+++ b/plc4net/api/exceptions/PlcException.cs
@@ -18,7 +18,6 @@
  */
 
 using System;
-using System.Runtime.Serialization;
 
 namespace org.apache.plc4net.exceptions
 {
@@ -26,9 +25,8 @@ namespace org.apache.plc4net.exceptions
     /// General exception that gets thrown when errors while communicating
     /// with PLC
     /// </summary>
-    [Serializable]
     public class PlcException : Exception
-    {        
+    {
         public PlcException()
         {
         }
@@ -40,11 +38,5 @@ namespace org.apache.plc4net.exceptions
         public PlcException(string message, Exception inner) : base(message, inner)
         {
         }
-
-        protected PlcException(
-            SerializationInfo info,
-            StreamingContext context) : base(info, context)
-        {
-        }
-    }              
+    }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/exceptions/PlcConnectionException.cs b/plc4net/api/exceptions/PlcIncompatibleDatatypeException.cs
similarity index 62%
rename from plc4net/api/api/exceptions/PlcConnectionException.cs
rename to plc4net/api/exceptions/PlcIncompatibleDatatypeException.cs
index 491275af10..a67fbbe682 100644
--- a/plc4net/api/api/exceptions/PlcConnectionException.cs
+++ b/plc4net/api/exceptions/PlcIncompatibleDatatypeException.cs
@@ -18,32 +18,26 @@
  */
 
 using System;
-using System.Runtime.Serialization;
 
 namespace org.apache.plc4net.exceptions
 {
     /// <summary>
-    /// Exception raised when the connection to a
-    /// PLC fails
+    /// Thrown when a <c>PlcValue</c> accessor is used for a type the value
+    /// cannot represent, e.g. calling <c>GetString()</c> on a numeric value.
+    /// Mirrors the Java SPI3 <c>PlcIncompatibleDatatypeException</c>: check the
+    /// <c>IsXxx()</c> guards instead of catching this.
     /// </summary>
-    [Serializable]
-    public class PlcConnectionException : PlcException
+    public class PlcIncompatibleDatatypeException : PlcException
     {
-        public PlcConnectionException()
+        public PlcIncompatibleDatatypeException()
         {
         }
 
-        public PlcConnectionException(string message) : base(message)
+        public PlcIncompatibleDatatypeException(string message) : base(message)
         {
         }
 
-        public PlcConnectionException(string message, Exception inner) : base(message, inner)
-        {
-        }
-
-        protected PlcConnectionException(
-            SerializationInfo info,
-            StreamingContext context) : base(info, context)
+        public PlcIncompatibleDatatypeException(string message, Exception inner) : base(message, inner)
         {
         }
     }
diff --git a/plc4net/api/api/exceptions/PlcInvalidFieldException.cs b/plc4net/api/exceptions/PlcInvalidFieldException.cs
similarity index 83%
rename from plc4net/api/api/exceptions/PlcInvalidFieldException.cs
rename to plc4net/api/exceptions/PlcInvalidFieldException.cs
index 1ead422ff5..bf3837f30c 100644
--- a/plc4net/api/api/exceptions/PlcInvalidFieldException.cs
+++ b/plc4net/api/exceptions/PlcInvalidFieldException.cs
@@ -18,13 +18,11 @@
  */
 
 using System;
-using System.Runtime.Serialization;
 
 namespace org.apache.plc4net.exceptions
 {
-    [Serializable]
     public class PlcInvalidFieldException : PlcException
-    {        
+    {
         public PlcInvalidFieldException()
         {
         }
@@ -36,11 +34,5 @@ namespace org.apache.plc4net.exceptions
         public PlcInvalidFieldException(string message, Exception inner) : base(message, inner)
         {
         }
-
-        protected PlcInvalidFieldException(
-            SerializationInfo info,
-            StreamingContext context) : base(info, context)
-        {
-        }
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcLTIME.cs b/plc4net/api/extensions/Plc4NetServiceExtensions.cs
similarity index 51%
copy from plc4net/spi/spi/model/values/PlcLTIME.cs
copy to plc4net/api/extensions/Plc4NetServiceExtensions.cs
index 7a425d3abd..9edfb5c31e 100644
--- a/plc4net/spi/spi/model/values/PlcLTIME.cs
+++ b/plc4net/api/extensions/Plc4NetServiceExtensions.cs
@@ -18,36 +18,25 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
 
-namespace org.apache.plc4net.spi.model.values
+namespace org.apache.plc4net
 {
-    public class PlcLTIME : PlcSimpleValueAdapter
+    /// <summary>
+    /// Extension methods for registering PLC4Net drivers via dependency injection.
+    /// Assembly-scanning helpers live in the SPI layer (see
+    /// <c>org.apache.plc4net.spi.drivers.Plc4NetScanExtensions</c>).
+    /// </summary>
+    public static class Plc4NetServiceExtensions
     {
-        private DateTime value;
-        
-        public PlcLTIME(DateTime value)
+        /// <summary>
+        /// Registers a driver with the global <see cref="PlcDriverManager"/>.
+        /// </summary>
+        public static PlcDriverManager RegisterDriver(this PlcDriverManager manager, api.IPlcDriver driver)
         {
-            this.value = value;
+            if (manager == null) throw new ArgumentNullException(nameof(manager));
+            if (driver == null) throw new ArgumentNullException(nameof(driver));
+            manager.RegisterDriver(driver);
+            return manager;
         }
-
-        protected bool Equals(PlcLTIME other)
-        {
-            return value.Equals(other.value);
-        }
-
-        public override bool Equals(object obj)
-        {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcLTIME) obj);
-        }
-
-        public override int GetHashCode()
-        {
-            return value.GetHashCode();
-        }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcMessage.cs b/plc4net/api/messages/IPlcMessage.cs
similarity index 98%
rename from plc4net/api/api/messages/IPlcMessage.cs
rename to plc4net/api/messages/IPlcMessage.cs
index 8604c34c57..6b72f761e9 100644
--- a/plc4net/api/api/messages/IPlcMessage.cs
+++ b/plc4net/api/messages/IPlcMessage.cs
@@ -24,6 +24,6 @@ namespace org.apache.plc4net.messages
     /// </summary>
     public interface IPlcMessage
     {
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadRequest.cs b/plc4net/api/messages/IPlcReadRequest.cs
similarity index 94%
rename from plc4net/api/api/messages/IPlcReadRequest.cs
rename to plc4net/api/messages/IPlcReadRequest.cs
index 5ba4243b2b..eb41a737a1 100644
--- a/plc4net/api/api/messages/IPlcReadRequest.cs
+++ b/plc4net/api/messages/IPlcReadRequest.cs
@@ -22,8 +22,8 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for requests reading data from a PLC
     /// </summary>
-    public interface IPlcReadRequest: IPlcRequest
+    public interface IPlcReadRequest : IPlcRequest
     {
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadRequestBuilder.cs b/plc4net/api/messages/IPlcReadRequestBuilder.cs
similarity index 75%
rename from plc4net/api/api/messages/IPlcReadRequestBuilder.cs
rename to plc4net/api/messages/IPlcReadRequestBuilder.cs
index d9bda46b8d..723bebb463 100644
--- a/plc4net/api/api/messages/IPlcReadRequestBuilder.cs
+++ b/plc4net/api/messages/IPlcReadRequestBuilder.cs
@@ -22,14 +22,14 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Builds requests for reading values from a PLC
     /// </summary>
-    public interface IPlcReadRequestBuilder: IPlcRequestBuilder<IPlcReadRequest>
+    public interface IPlcReadRequestBuilder : IPlcRequestBuilder<IPlcReadRequest>
     {
         /// <summary>
-        /// Add an item to the request
+        /// Add a tag address to the read request.
         /// </summary>
-        /// <param name="name">Name of the field to add</param>
-        /// <param name="fieldQuery">Query string for the field parameter</param>
+        /// <param name="name">Name of the tag to read</param>
+        /// <param name="tagAddress">Address string for the tag</param>
         /// <returns>Request builder to allow fluent API calls</returns>
-        IPlcReadRequestBuilder AddItem(string name, string fieldQuery);
+        IPlcReadRequestBuilder AddTagAddress(string name, string tagAddress);
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/api/messages/IPlcReadResponse.cs
similarity index 100%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/api/messages/IPlcReadResponse.cs
diff --git a/plc4net/api/api/messages/IPlcRequest.cs b/plc4net/api/messages/IPlcRequest.cs
similarity index 100%
rename from plc4net/api/api/messages/IPlcRequest.cs
rename to plc4net/api/messages/IPlcRequest.cs
diff --git a/plc4net/api/api/messages/IPlcRequestBuilder.cs b/plc4net/api/messages/IPlcRequestBuilder.cs
similarity index 100%
rename from plc4net/api/api/messages/IPlcRequestBuilder.cs
rename to plc4net/api/messages/IPlcRequestBuilder.cs
diff --git a/plc4net/api/api/messages/IPlcResponse.cs b/plc4net/api/messages/IPlcResponse.cs
similarity index 95%
rename from plc4net/api/api/messages/IPlcResponse.cs
rename to plc4net/api/messages/IPlcResponse.cs
index 2665bef47e..0a650c0965 100644
--- a/plc4net/api/api/messages/IPlcResponse.cs
+++ b/plc4net/api/messages/IPlcResponse.cs
@@ -23,7 +23,7 @@ namespace org.apache.plc4net.messages
     /// Interface for responses received from
     /// PLCs
     /// </summary>
-    public interface IPlcResponse: IPlcMessage 
+    public interface IPlcResponse : IPlcMessage
     {
         /// <summary>
         /// Get the request that generated the response
diff --git a/plc4net/api/api/messages/IPlcSubscriptionEventArgs.cs b/plc4net/api/messages/IPlcSubscriptionEventArgs.cs
similarity index 100%
rename from plc4net/api/api/messages/IPlcSubscriptionEventArgs.cs
rename to plc4net/api/messages/IPlcSubscriptionEventArgs.cs
diff --git a/plc4net/api/api/messages/IPlcSubscriptionRequest.cs b/plc4net/api/messages/IPlcSubscriptionRequest.cs
similarity index 93%
rename from plc4net/api/api/messages/IPlcSubscriptionRequest.cs
rename to plc4net/api/messages/IPlcSubscriptionRequest.cs
index b7b7d51256..d6f65cf38a 100644
--- a/plc4net/api/api/messages/IPlcSubscriptionRequest.cs
+++ b/plc4net/api/messages/IPlcSubscriptionRequest.cs
@@ -22,7 +22,7 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for requests to subscribe to value changes
     /// </summary>
-    public interface IPlcSubscriptionRequest: IPlcFieldRequest
+    public interface IPlcSubscriptionRequest : IPlcTagRequest
     {
 
     }
diff --git a/plc4net/api/api/messages/IPlcSubscriptionRequestBuilder.cs b/plc4net/api/messages/IPlcSubscriptionRequestBuilder.cs
similarity index 95%
rename from plc4net/api/api/messages/IPlcSubscriptionRequestBuilder.cs
rename to plc4net/api/messages/IPlcSubscriptionRequestBuilder.cs
index bf5745950e..93ffdd8674 100644
--- a/plc4net/api/api/messages/IPlcSubscriptionRequestBuilder.cs
+++ b/plc4net/api/messages/IPlcSubscriptionRequestBuilder.cs
@@ -24,7 +24,7 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for classes building requests for subscriptions
     /// </summary>
-    public interface IPlcSubscriptionRequestBuilder: IPlcRequestBuilder<IPlcSubscriptionRequest>
+    public interface IPlcSubscriptionRequestBuilder : IPlcRequestBuilder<IPlcSubscriptionRequest>
     {
         /// <summary>
         /// Add a field that gets polled cyclically
diff --git a/plc4net/api/api/messages/IPlcSubscriptionResponse.cs b/plc4net/api/messages/IPlcSubscriptionResponse.cs
similarity index 95%
copy from plc4net/api/api/messages/IPlcSubscriptionResponse.cs
copy to plc4net/api/messages/IPlcSubscriptionResponse.cs
index c7ff66a34d..213ebd28cf 100644
--- a/plc4net/api/api/messages/IPlcSubscriptionResponse.cs
+++ b/plc4net/api/messages/IPlcSubscriptionResponse.cs
@@ -25,7 +25,7 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Response to a <see cref="IPlcSubscriptionRequest"/>
     /// </summary>
-    public interface IPlcSubscriptionResponse: IPlcFieldResponse
+    public interface IPlcSubscriptionResponse : IPlcTagResponse
     {
         /// <summary>
         /// Get the handle for the subscription with the given name
diff --git a/plc4net/api/api/messages/IPlcFieldRequest.cs b/plc4net/api/messages/IPlcTagRequest.cs
similarity index 65%
rename from plc4net/api/api/messages/IPlcFieldRequest.cs
rename to plc4net/api/messages/IPlcTagRequest.cs
index a4f65be694..ec0f11ef07 100644
--- a/plc4net/api/api/messages/IPlcFieldRequest.cs
+++ b/plc4net/api/messages/IPlcTagRequest.cs
@@ -23,30 +23,30 @@ using org.apache.plc4net.model;
 namespace org.apache.plc4net.messages
 {
     /// <summary>
-    /// Request for certain fields inside the PLC
+    /// Request for certain tags inside the PLC
     /// </summary>
-    public interface IPlcFieldRequest: IPlcRequest
+    public interface IPlcTagRequest : IPlcRequest
     {
         /// <summary>
-        /// Number of fields in the request
+        /// Number of tags in the request
         /// </summary>
-        int FieldCount { get; }
+        int TagCount { get; }
 
         /// <summary>
-        /// Enumeration of field names
+        /// Enumeration of tag names
         /// </summary>
-        IEnumerable<string> FieldNames { get; }
+        IEnumerable<string> TagNames { get; }
 
         /// <summary>
-        /// Get a field inside the request by its name
+        /// Get a tag inside the request by its name
         /// </summary>
-        /// <param name="name">Name of the PLC field to return</param>
-        /// <returns></returns>
-        IPlcField GetFieldByName(string name);
+        /// <param name="name">Name of the PLC tag to return</param>
+        /// <returns>null when no tag with that name is in the request</returns>
+        IPlcTag? GetTagByName(string name);
 
         /// <summary>
-        /// Returns all fields inside the request
+        /// Returns all tags inside the request
         /// </summary>
-        IEnumerable<IPlcField> Fields { get; }
+        IEnumerable<IPlcTag> Tags { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcFieldResponse.cs b/plc4net/api/messages/IPlcTagResponse.cs
similarity index 73%
rename from plc4net/api/api/messages/IPlcFieldResponse.cs
rename to plc4net/api/messages/IPlcTagResponse.cs
index 3dcfa0e68e..b662ef52b7 100644
--- a/plc4net/api/api/messages/IPlcFieldResponse.cs
+++ b/plc4net/api/messages/IPlcTagResponse.cs
@@ -24,22 +24,22 @@ using org.apache.plc4net.types;
 namespace org.apache.plc4net.messages
 {
     /// <summary>
-    /// Interface for responses to requests realted to
-    /// a specific PLC field
+    /// Interface for responses to requests related to
+    /// a specific PLC tag
     /// </summary>
-    public interface IPlcFieldResponse: IPlcResponse
+    public interface IPlcTagResponse : IPlcResponse
     {
         /// <summary>
-        /// Enumeration of fields in the response
+        /// Enumeration of tags in the response
         /// </summary>
-        IEnumerable<string> FieldNames { get; }
+        IEnumerable<string> TagNames { get; }
 
         /// <summary>
-        /// Get a field by name
+        /// Get a tag by name
         /// </summary>
-        /// <param name="name">Name of the field to retrieve</param>
-        /// <returns>Field with the given name</returns>
-        IPlcField GetFieldByName(string name);
+        /// <param name="name">Name of the tag to retrieve</param>
+        /// <returns>Tag with the given name, or null when absent</returns>
+        IPlcTag? GetTagByName(string name);
 
         /// <summary>
         /// Get the response code from the PLC
diff --git a/plc4net/api/api/messages/IPlcUnsubscriptionRequest.cs b/plc4net/api/messages/IPlcUnsubscriptionRequest.cs
similarity index 93%
rename from plc4net/api/api/messages/IPlcUnsubscriptionRequest.cs
rename to plc4net/api/messages/IPlcUnsubscriptionRequest.cs
index 921e6edff4..5409755c77 100644
--- a/plc4net/api/api/messages/IPlcUnsubscriptionRequest.cs
+++ b/plc4net/api/messages/IPlcUnsubscriptionRequest.cs
@@ -22,8 +22,8 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Request for releasing a subscription
     /// </summary>
-    public interface IPlcUnsubscriptionRequest: IPlcRequest
+    public interface IPlcUnsubscriptionRequest : IPlcRequest
     {
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcUnsubscriptionRequestBuilder.cs b/plc4net/api/messages/IPlcUnsubscriptionRequestBuilder.cs
similarity index 96%
rename from plc4net/api/api/messages/IPlcUnsubscriptionRequestBuilder.cs
rename to plc4net/api/messages/IPlcUnsubscriptionRequestBuilder.cs
index c053cfe31f..43869151bf 100644
--- a/plc4net/api/api/messages/IPlcUnsubscriptionRequestBuilder.cs
+++ b/plc4net/api/messages/IPlcUnsubscriptionRequestBuilder.cs
@@ -25,7 +25,7 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Constructs requests for unsubscribing from PLC fields
     /// </summary>
-    public interface IPlcUnsubscriptionRequestBuilder: 
+    public interface IPlcUnsubscriptionRequestBuilder :
         IPlcRequestBuilder<IPlcUnsubscriptionRequest>
     {
         /// <summary>
diff --git a/plc4net/api/api/messages/IPlcUnsubscriptionResponse.cs b/plc4net/api/messages/IPlcUnsubscriptionResponse.cs
similarity index 92%
rename from plc4net/api/api/messages/IPlcUnsubscriptionResponse.cs
rename to plc4net/api/messages/IPlcUnsubscriptionResponse.cs
index 6478a15a5a..c3ff196792 100644
--- a/plc4net/api/api/messages/IPlcUnsubscriptionResponse.cs
+++ b/plc4net/api/messages/IPlcUnsubscriptionResponse.cs
@@ -22,8 +22,8 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for requests to release a subscription
     /// </summary>
-    public interface IPlcUnsubscriptionResponse: IPlcResponse
+    public interface IPlcUnsubscriptionResponse : IPlcResponse
     {
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcWriteRequest.cs b/plc4net/api/messages/IPlcWriteRequest.cs
similarity index 95%
rename from plc4net/api/api/messages/IPlcWriteRequest.cs
rename to plc4net/api/messages/IPlcWriteRequest.cs
index df0f372ce6..8097aa419d 100644
--- a/plc4net/api/api/messages/IPlcWriteRequest.cs
+++ b/plc4net/api/messages/IPlcWriteRequest.cs
@@ -22,7 +22,7 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for writing values to PLCs
     /// </summary>
-    public interface IPlcWriteRequest: IPlcRequest
+    public interface IPlcWriteRequest : IPlcRequest
     {
         /// <summary>
         /// Number of values in the request
diff --git a/plc4net/api/api/messages/IPlcWriteRequestBuilder.cs b/plc4net/api/messages/IPlcWriteRequestBuilder.cs
similarity index 56%
rename from plc4net/api/api/messages/IPlcWriteRequestBuilder.cs
rename to plc4net/api/messages/IPlcWriteRequestBuilder.cs
index aa176b4f47..161e423bf0 100644
--- a/plc4net/api/api/messages/IPlcWriteRequestBuilder.cs
+++ b/plc4net/api/messages/IPlcWriteRequestBuilder.cs
@@ -30,105 +30,105 @@ namespace org.apache.plc4net.messages
     /// <summary>
     /// Interface for a builder that constructs write requests
     /// </summary>
-    public interface IPlcWriteRequestBuilder: IPlcRequestBuilder<IPlcWriteRequest>
+    public interface IPlcWriteRequestBuilder : IPlcRequestBuilder<IPlcWriteRequest>
     {
         /// <summary>
         /// Add item with bool value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params bool[] values);
-        
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params bool[] values);
+
         /// <summary>
         /// Add item with byte value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params byte[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params byte[] values);
 
         /// <summary>
         /// Add item with short value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params short[] values);
-        
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params short[] values);
+
         /// <summary>
         /// Add item with int value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params int[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params int[] values);
 
         /// <summary>
         /// Add item with long value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params long[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params long[] values);
 
         /// <summary>
         /// Add item with float value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params float[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params float[] values);
 
         /// <summary>
         /// Add item with double value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params double[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params double[] values);
 
         /// <summary>
         /// Add item with Decimal value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params Decimal[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params Decimal[] values);
 
         /// <summary>
         /// Add item with string value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params string[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params string[] values);
 
         /// <summary>
         /// Add item with DateTime value
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem(string name, string fieldQuery, params DateTime[] values);
+        IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params DateTime[] values);
 
         /// <summary>
         /// Add item with value of generic type
         /// </summary>
-        /// <param name="name">Identifying name of the field</param>
-        /// <param name="fieldQuery">Query string for the field</param>
+        /// <param name="name">Identifying name of the tag</param>
+        /// <param name="fieldQuery">Query string for the tag</param>
         /// <param name="values">Values to set</param>        
         /// <returns>Own instance to allow Fluid API calls</returns>
-        IPlcWriteRequestBuilder AddItem<T>(string name, string fieldQuery, params T[] values);
+        IPlcWriteRequestBuilder AddTag<T>(string name, string tagAddress, params T[] values);
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/api/messages/IPlcWriteResponse.cs
similarity index 88%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/api/messages/IPlcWriteResponse.cs
index c1d1719c8d..8c7668255f 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/api/messages/IPlcWriteResponse.cs
@@ -20,10 +20,9 @@
 namespace org.apache.plc4net.messages
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// Response for write requests <see cref="IPlcWriteRequest"/>
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public interface IPlcWriteResponse : IPlcResponse
     {
-
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcBitString.cs b/plc4net/api/metadata/DefaultPlcConnectionMetadata.cs
similarity index 56%
copy from plc4net/spi/spi/model/values/PlcBitString.cs
copy to plc4net/api/metadata/DefaultPlcConnectionMetadata.cs
index 42beac3bd3..97aab6ecb4 100644
--- a/plc4net/spi/spi/model/values/PlcBitString.cs
+++ b/plc4net/api/metadata/DefaultPlcConnectionMetadata.cs
@@ -17,29 +17,21 @@
 // under the License.
 //
 
-namespace org.apache.plc4net.spi.model.values
+namespace org.apache.plc4net.api.metadata
 {
-    public class PlcBitString : PlcValueAdapter
+    /// <summary>
+    /// Default implementation of <see cref="IPlcConnectionMetadata"/>
+    /// so drivers don't return null.
+    /// </summary>
+    public class DefaultPlcConnectionMetadata : IPlcConnectionMetadata
     {
-        public PlcBitString()
-        {
-        }
+        /// <summary>Whether the connection supports reading values.</summary>
+        public bool CanRead { get; set; }
 
-        public PlcBitString(byte value)
-        {
-        }
-
-        public PlcBitString(ushort value)
-        {
-        }
-
-        public PlcBitString(uint value)
-        {
-        }
-
-        public PlcBitString(ulong value)
-        {
-        }
+        /// <summary>Whether the connection supports writing values.</summary>
+        public bool CanWrite { get; set; }
 
+        /// <summary>Whether the connection supports subscriptions.</summary>
+        public bool CanSubscribe { get; set; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/metadata/IPlcConnectionMetadata.cs b/plc4net/api/metadata/IPlcConnectionMetadata.cs
similarity index 100%
rename from plc4net/api/api/metadata/IPlcConnectionMetadata.cs
rename to plc4net/api/metadata/IPlcConnectionMetadata.cs
diff --git a/plc4net/api/api/model/IPlcSubscriptionHandle.cs b/plc4net/api/model/IPlcSubscriptionHandle.cs
similarity index 100%
copy from plc4net/api/api/model/IPlcSubscriptionHandle.cs
copy to plc4net/api/model/IPlcSubscriptionHandle.cs
diff --git a/plc4net/spi/spi/model/values/PlcDINT.cs b/plc4net/api/model/IPlcTag.cs
similarity index 64%
copy from plc4net/spi/spi/model/values/PlcDINT.cs
copy to plc4net/api/model/IPlcTag.cs
index 3e3f6beecc..143b518f79 100644
--- a/plc4net/spi/spi/model/values/PlcDINT.cs
+++ b/plc4net/api/model/IPlcTag.cs
@@ -7,7 +7,7 @@
 // "License"); you may not use this file except in compliance
 // with the License.  You may obtain a copy of the License at
 //
-//      https://www.apache.org/licenses/LICENSE-2.0
+//   https://www.apache.org/licenses/LICENSE-2.0
 //
 // Unless required by applicable law or agreed to in writing,
 // software distributed under the License is distributed on an
@@ -17,14 +17,15 @@
 // under the License.
 //
 
-using org.apache.plc4net.api.value;
-
-namespace org.apache.plc4net.spi.model.values
+namespace org.apache.plc4net.model
 {
-    public class PlcDINT : SimpleNumericValueAdapter<int>
+    /// <summary>
+    /// An addressable item on a device. Matches the SPI3 convention: plc4j uses
+    /// <c>PlcTag</c>, and the Field→Tag rename happened during the SPI3 refactoring.
+    /// The legacy <c>IPlcField</c> interface has been removed.
+    /// during the SPI3 refactoring.
+    /// </summary>
+    public interface IPlcTag
     {
-        public PlcDINT(int value) : base(value)
-        {
-        }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/types/PlcResponseCode.cs b/plc4net/api/types/PlcResponseCode.cs
similarity index 79%
rename from plc4net/api/api/types/PlcResponseCode.cs
rename to plc4net/api/types/PlcResponseCode.cs
index 42dee4cdf9..1f5c74bd14 100644
--- a/plc4net/api/api/types/PlcResponseCode.cs
+++ b/plc4net/api/types/PlcResponseCode.cs
@@ -30,6 +30,13 @@ namespace org.apache.plc4net.types
         InvalidAddress,
         InvalidDatatype,
         InternalError,
-        ResponsePending
+        ResponsePending,
+
+        /// <summary>The device did not answer within the request timeout.</summary>
+        RequestTimeout,
+
+        /// <summary>The device rejected the request as unsupported (e.g. an
+        /// illegal function code).</summary>
+        Unsupported
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/types/PlcValueType.cs b/plc4net/api/types/PlcValueType.cs
new file mode 100644
index 0000000000..cb723f945c
--- /dev/null
+++ b/plc4net/api/types/PlcValueType.cs
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+namespace org.apache.plc4net.types
+{
+    using System;
+    using System.Collections.Generic;
+
+    /// <summary>
+    /// The supported types of PlcValues.
+    /// </summary>
+    public class PlcValueType
+    {
+        public static readonly PlcValueType NULL = new PlcValueType(0x00, null);
+        public static readonly PlcValueType BOOL = new PlcValueType(0x01, typeof(bool));
+        public static readonly PlcValueType BYTE = new PlcValueType(0x02, typeof(byte));
+        public static readonly PlcValueType WORD = new PlcValueType(0x03, typeof(ushort));
+        public static readonly PlcValueType DWORD = new PlcValueType(0x04, typeof(uint));
+        public static readonly PlcValueType LWORD = new PlcValueType(0x05, typeof(ulong));
+        public static readonly PlcValueType USINT = new PlcValueType(0x11, typeof(byte));
+        public static readonly PlcValueType UINT = new PlcValueType(0x12, typeof(ushort));
+        public static readonly PlcValueType UDINT = new PlcValueType(0x13, typeof(uint));
+        public static readonly PlcValueType ULINT = new PlcValueType(0x14, typeof(System.Numerics.BigInteger));
+        public static readonly PlcValueType SINT = new PlcValueType(0x21, typeof(sbyte));
+        public static readonly PlcValueType INT = new PlcValueType(0x22, typeof(short));
+        public static readonly PlcValueType DINT = new PlcValueType(0x23, typeof(int));
+        public static readonly PlcValueType LINT = new PlcValueType(0x24, typeof(long));
+        public static readonly PlcValueType REAL = new PlcValueType(0x31, typeof(float));
+        public static readonly PlcValueType LREAL = new PlcValueType(0x32, typeof(double));
+        public static readonly PlcValueType CHAR = new PlcValueType(0x41, typeof(char));
+        public static readonly PlcValueType WCHAR = new PlcValueType(0x42, typeof(char));
+        public static readonly PlcValueType STRING = new PlcValueType(0x43, typeof(string));
+        public static readonly PlcValueType WSTRING = new PlcValueType(0x44, typeof(string));
+        public static readonly PlcValueType TIME = new PlcValueType(0x51, typeof(TimeSpan));
+        public static readonly PlcValueType LTIME = new PlcValueType(0x52, typeof(TimeSpan));
+        public static readonly PlcValueType DATE = new PlcValueType(0x53, typeof(DateOnly));
+        public static readonly PlcValueType LDATE = new PlcValueType(0x54, typeof(DateOnly));
+        public static readonly PlcValueType TIME_OF_DAY = new PlcValueType(0x55, typeof(TimeOnly));
+        public static readonly PlcValueType LTIME_OF_DAY = new PlcValueType(0x56, typeof(TimeOnly));
+        public static readonly PlcValueType DATE_AND_TIME = new PlcValueType(0x57, typeof(DateTime));
+        public static readonly PlcValueType DATE_AND_LTIME = new PlcValueType(0x58, typeof(DateTime));
+        public static readonly PlcValueType LDATE_AND_TIME = new PlcValueType(0x59, typeof(DateTime));
+        public static readonly PlcValueType Struct = new PlcValueType(0x61, typeof(Dictionary<string, object>));
+        public static readonly PlcValueType List = new PlcValueType(0x62, typeof(List<object>));
+        public static readonly PlcValueType RAW_BYTE_ARRAY = new PlcValueType(0x71, typeof(byte[]));
+
+        private static readonly Dictionary<short, PlcValueType> map = new Dictionary<short, PlcValueType>();
+
+        static PlcValueType()
+        {
+            foreach (var field in typeof(PlcValueType).GetFields(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public))
+            {
+                if (field.GetValue(null) is PlcValueType value)
+                {
+                    map[value.Value] = value;
+                }
+            }
+        }
+
+        public short Value { get; }
+        public Type? DefaultDotNetType { get; }
+
+        private PlcValueType(short value, Type? defaultDotNetType)
+        {
+            Value = value;
+            DefaultDotNetType = defaultDotNetType;
+        }
+
+        public static PlcValueType? EnumForValue(short value)
+        {
+            map.TryGetValue(value, out var result);
+            return result;
+        }
+
+        public static bool IsDefined(short value)
+        {
+            return map.ContainsKey(value);
+        }
+
+        public override string ToString() => $"PlcValueType(0x{Value:X}, {DefaultDotNetType?.Name ?? "null"})";
+    }
+}
\ No newline at end of file
diff --git a/plc4net/api/api/value/IPlcValue.cs b/plc4net/api/value/IPlcValue.cs
similarity index 80%
rename from plc4net/api/api/value/IPlcValue.cs
rename to plc4net/api/value/IPlcValue.cs
index bd0ef73f74..76dae3d967 100644
--- a/plc4net/api/api/value/IPlcValue.cs
+++ b/plc4net/api/value/IPlcValue.cs
@@ -64,7 +64,14 @@ namespace org.apache.plc4net.api.value
         bool IsString();
         string GetString();
 
-        // Time
+        // Temporal — mirrors the java.time split plc4j uses: a bare duration,
+        // a date with no time, a time-of-day with no date, and a full instant.
+        bool IsDuration();
+        TimeSpan GetDuration();
+        bool IsDate();
+        DateOnly GetDate();
+        bool IsTime();
+        TimeOnly GetTime();
         bool IsDateTime();
         DateTime GetDateTime();
 
@@ -81,7 +88,9 @@ namespace org.apache.plc4net.api.value
         bool IsStruct();
         string[] GetKeys();
         bool HasKey(string key);
-        IPlcValue GetValue(string key);
+        /// <summary>null when no value is stored under <paramref name="key"/>
+        /// (mirrors Java's <c>PlcStruct.getValue</c> returning <c>map.get(key)</c>).</summary>
+        IPlcValue? GetValue(string key);
         Dictionary<string, IPlcValue> GetStruct();
     }
 }
\ No newline at end of file
diff --git a/plc4net/plc4net.sln b/plc4net/plc4net.sln
index 464f265d05..700e338a62 100644
--- a/plc4net/plc4net.sln
+++ b/plc4net/plc4net.sln
@@ -1,41 +1,82 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#     https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-#
-
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plc4net-api", "api\plc4net-api.csproj", "{7906988A-C7CA-4886-A343-EBC17C0BAB59}"
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "plc4net-spi", "spi\plc4net-spi.csproj", "{A92DD9F9-1688-4E70-A95E-709E12DBB8C5}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spi", "spi\spi.csproj", "{B5604A88-3345-4E14-8D47-C6B0501B4753}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "transports", "transports", "{6D81D0D1-7A60-1ADD-C689-B1528D5CD9EE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test", "transports\test\test.csproj", "{D6401677-CDF1-493D-8B37-0B53C4085FC4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "spi-test", "test\spi-test\spi-test.csproj", "{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
+		Debug|x64 = Debug|x64
+		Debug|x86 = Debug|x86
 		Release|Any CPU = Release|Any CPU
+		Release|x64 = Release|x64
+		Release|x86 = Release|x86
 	EndGlobalSection
 	GlobalSection(ProjectConfigurationPlatforms) = postSolution
-		{7906988A-C7CA-4886-A343-EBC17C0BAB59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{7906988A-C7CA-4886-A343-EBC17C0BAB59}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{7906988A-C7CA-4886-A343-EBC17C0BAB59}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{7906988A-C7CA-4886-A343-EBC17C0BAB59}.Release|Any CPU.Build.0 = Release|Any CPU
-		{A92DD9F9-1688-4E70-A95E-709E12DBB8C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{A92DD9F9-1688-4E70-A95E-709E12DBB8C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{A92DD9F9-1688-4E70-A95E-709E12DBB8C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{A92DD9F9-1688-4E70-A95E-709E12DBB8C5}.Release|Any CPU.Build.0 = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|x64.Build.0 = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Debug|x86.Build.0 = Debug|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|Any CPU.Build.0 = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|x64.ActiveCfg = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|x64.Build.0 = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|x86.ActiveCfg = Release|Any CPU
+		{006BAC0D-D969-4CE8-A6D8-73EDAB8CEA9C}.Release|x86.Build.0 = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|x64.Build.0 = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Debug|x86.Build.0 = Debug|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|x64.ActiveCfg = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|x64.Build.0 = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|x86.ActiveCfg = Release|Any CPU
+		{B5604A88-3345-4E14-8D47-C6B0501B4753}.Release|x86.Build.0 = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|x64.Build.0 = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Debug|x86.Build.0 = Debug|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|x64.ActiveCfg = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|x64.Build.0 = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|x86.ActiveCfg = Release|Any CPU
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4}.Release|x86.Build.0 = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|x64.Build.0 = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Debug|x86.Build.0 = Debug|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|x64.ActiveCfg = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|x64.Build.0 = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|x86.ActiveCfg = Release|Any CPU
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD}.Release|x86.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(NestedProjects) = preSolution
+		{D6401677-CDF1-493D-8B37-0B53C4085FC4} = {6D81D0D1-7A60-1ADD-C689-B1528D5CD9EE}
+		{5CDFBE07-1721-4D36-8F1A-B5533B6E28AD} = {6D81D0D1-7A60-1ADD-C689-B1528D5CD9EE}
 	EndGlobalSection
 EndGlobal
diff --git a/plc4net/pom.xml b/plc4net/pom.xml
index e8102eef5a..c73a8ece23 100644
--- a/plc4net/pom.xml
+++ b/plc4net/pom.xml
@@ -42,6 +42,16 @@
 
   <build>
     <plugins>
+      <plugin>
+        <groupId>org.apache.rat</groupId>
+        <artifactId>apache-rat-plugin</artifactId>
+        <configuration>
+          <inputExcludes combine.children="append">
+            <!-- A Visual Studio solution cannot carry a license header. -->
+            <inputExclude>**/*.sln</inputExclude>
+          </inputExcludes>
+        </configuration>
+      </plugin>
       <!-- Build the project -->
       <plugin>
         <groupId>org.codehaus.mojo</groupId>
@@ -60,14 +70,30 @@
                 <argument>build</argument>
                 <argument>plc4net.sln</argument>
                 <argument>--configuration=Debug</argument>
-                <argument>--output=${project.build.directory}/build</argument>
                 <argument>-p:Version=${project.version}</argument>
               </arguments>
             </configuration>
           </execution>
+          <execution>
+            <id>dotnet-test</id>
+            <phase>test</phase>
+            <goals>
+              <goal>exec</goal>
+            </goals>
+            <configuration>
+              <skip>${skipTests}</skip>
+              <executable>dotnet</executable>
+              <workingDirectory>${project.basedir}</workingDirectory>
+              <arguments>
+                <argument>test</argument>
+                <argument>plc4net.sln</argument>
+                <argument>--configuration=Debug</argument>
+              </arguments>
+            </configuration>
+          </execution>
         </executions>
       </plugin>
     </plugins>
   </build>
 
-</project>
\ No newline at end of file
+</project>
diff --git a/plc4net/spi/drivers/ConnectionBase.cs b/plc4net/spi/drivers/ConnectionBase.cs
new file mode 100644
index 0000000000..ad5b102d55
--- /dev/null
+++ b/plc4net/spi/drivers/ConnectionBase.cs
@@ -0,0 +1,148 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using org.apache.plc4net.api;
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.api.metadata;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.transports;
+
+namespace org.apache.plc4net.spi.drivers
+{
+    /// <summary>
+    /// A connection that wraps a transport instance.
+    /// </summary>
+    /// <remarks>
+    /// Mirrors the Java SPI3 <c>ConnectionBase</c>. Concrete driver connections extend
+    /// this and wire a <see cref="MessageCodecBase{TMessage}"/> over the transport.
+    /// </remarks>
+    public abstract class ConnectionBase : IPlcConnection
+    {
+        private readonly ITransportInstance _transportInstance;
+        private readonly ConnectionString _connectionString;
+        private IPlcAuthentication? _authentication;
+        private ILogger _logger = NullLogger.Instance;
+
+        protected ConnectionBase(ConnectionString connectionString, ITransportInstance transportInstance)
+        {
+            _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
+            _transportInstance = transportInstance ?? throw new ArgumentNullException(nameof(transportInstance));
+        }
+
+        /// <summary>
+        /// Optional logger. Assigning a real <c>ILogger&lt;T&gt;</c> (for example
+        /// via DI injection) adds diagnostic output to Read/Write error paths.
+        /// Defaults to <c>NullLogger.Instance</c> (zero overhead when unset).
+        /// </summary>
+        public ILogger Logger
+        {
+            get => _logger;
+            set => _logger = value ?? NullLogger.Instance;
+        }
+
+        protected ITransportInstance TransportInstance => _transportInstance;
+
+        protected ConnectionString ConnectionInfo => _connectionString;
+
+        public IPlcAuthentication? Authentication
+        {
+            get => _authentication;
+            set => _authentication = value;
+        }
+
+        public bool IsConnected => _transportInstance.IsOpen;
+
+        /// <summary>
+        /// Establishes the protocol-level session on top of the already-open transport.
+        /// Called by <see cref="DriverBase.Connect(string, IPlcAuthentication)"/> after the
+        /// connection object has been constructed, so that construction itself stays free of
+        /// blocking I/O. Mirrors the Java SPI3 split between <c>getConnection()</c> and
+        /// <c>connect()</c>: the constructor only records configuration, and every network
+        /// round-trip a protocol needs before it can carry payload happens here.
+        /// </summary>
+        public void Connect()
+        {
+            OnConnect();
+        }
+
+        /// <summary>
+        /// Async equivalent of <see cref="Connect"/>. The default implementation
+        /// delegates to the synchronous hook; drivers whose handshake performs I/O
+        /// (COTP CR/CC) may override this to run it on a background thread.
+        /// </summary>
+        public virtual async System.Threading.Tasks.Task ConnectAsync()
+        {
+            await System.Threading.Tasks.Task.Run(() => OnConnect())
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Protocol-specific connection setup. The default implementation does nothing, which
+        /// is correct for protocols that carry payload directly over the raw transport
+        /// (Modbus TCP). Protocols requiring a handshake before their first PDU (S7 over COTP)
+        /// override this. Mirrors the Java SPI3 <c>ConnectionBase.onConnect()</c> hook.
+        /// </summary>
+        protected virtual void OnConnect()
+        {
+        }
+
+        public virtual void Close()
+        {
+            _transportInstance.Close();
+        }
+
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        protected virtual void Dispose(bool disposing)
+        {
+            if (!disposing)
+            {
+                return;
+            }
+            try
+            {
+                Close();
+            }
+            catch (TransportException)
+            {
+                // Dispose must not throw.
+            }
+        }
+
+        public abstract IPlcConnectionMetadata PlcConnectionMetadata { get; }
+
+        public abstract IPlcTag Parse(string tagQuery);
+
+        public abstract IPlcReadRequestBuilder? ReadRequestBuilder { get; }
+
+        public abstract IPlcWriteRequestBuilder? WriteRequestBuilder { get; }
+
+        public abstract IPlcSubscriptionRequestBuilder? SubscriptionRequestBuilder { get; }
+
+        public abstract IPlcUnsubscriptionRequestBuilder? UnsubscriptionRequestBuilder { get; }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/ConnectionString.cs b/plc4net/spi/drivers/ConnectionString.cs
new file mode 100644
index 0000000000..3b533eb4ba
--- /dev/null
+++ b/plc4net/spi/drivers/ConnectionString.cs
@@ -0,0 +1,217 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net;
+using System.Text.RegularExpressions;
+using org.apache.plc4net.exceptions;
+
+namespace org.apache.plc4net.spi.drivers
+{
+    /// <summary>
+    /// A parsed PLC4X connection string:
+    /// <c>{protocol-code}(:{transport-code})?://{transport-config}(?{parameter-string})?</c>
+    /// </summary>
+    /// <remarks>
+    /// The grammar is kept identical to the Java SPI3 <c>DriverBase.URI_PATTERN</c> so the
+    /// same connection string addresses the same device from either language. Examples:
+    /// <c>s7://192.168.0.1</c>, <c>s7:cotp://10.0.0.5:102?remote-rack=0&amp;remote-slot=1</c>,
+    /// <c>modbus-tcp://10.0.0.9:502?unit-identifier=1</c>.
+    /// </remarks>
+    public sealed class ConnectionString
+    {
+        private static readonly Regex UriPattern = new Regex(
+            @"^(?<protocolCode>[a-z0-9\-]*)(:(?<transportCode>[a-z0-9\-]*))?://(?<transportConfig>[^?]*)(\?(?<paramString>.*))?$",
+            RegexOptions.Compiled);
+
+        // Masks the value of any query parameter whose name looks like it carries a
+        // credential, so connection strings can be logged safely.
+        private static readonly Regex SecretParamPattern = new Regex(
+            @"([?&][^=&]*(?:password|passwd|secret|token)[^=&]*=)[^&]*",
+            RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+        private ConnectionString(
+            string protocolCode,
+            string? transportCode,
+            string transportConfig,
+            string paramString,
+            IReadOnlyDictionary<string, string> parameters)
+        {
+            ProtocolCode = protocolCode;
+            TransportCode = transportCode;
+            TransportConfig = transportConfig;
+            ParamString = paramString;
+            Parameters = parameters;
+        }
+
+        /// <summary>e.g. "s7", "modbus-tcp".</summary>
+        public string ProtocolCode { get; }
+
+        /// <summary>e.g. "tcp", "cotp". Null when the string relies on the driver's default.</summary>
+        public string? TransportCode { get; }
+
+        /// <summary>The address the transport consumes, e.g. "192.168.0.1:102".</summary>
+        public string TransportConfig { get; }
+
+        /// <summary>The raw query string, without the leading '?'. Empty when absent.</summary>
+        public string ParamString { get; }
+
+        /// <summary>Query parameters, parsed. Keys are case-insensitive.</summary>
+        public IReadOnlyDictionary<string, string> Parameters { get; }
+
+        public static ConnectionString Parse(string? connectionString)
+        {
+            if (string.IsNullOrWhiteSpace(connectionString))
+            {
+                throw new PlcConnectionException("Connection string must not be empty.");
+            }
+
+            var match = UriPattern.Match(connectionString);
+            if (!match.Success)
+            {
+                throw new PlcConnectionException(
+                    "Connection string doesn't match the format " +
+                    "'{protocol-code}(:{transport-code})?://{transport-config}(?{parameter-string})?'");
+            }
+
+            var protocolCode = match.Groups["protocolCode"].Value;
+            if (string.IsNullOrEmpty(protocolCode))
+            {
+                throw new PlcConnectionException("Connection string is missing the protocol code.");
+            }
+
+            var transportGroup = match.Groups["transportCode"];
+            var transportCode = transportGroup.Success && transportGroup.Value.Length > 0
+                ? transportGroup.Value
+                : null;
+
+            var paramString = match.Groups["paramString"].Success
+                ? match.Groups["paramString"].Value
+                : string.Empty;
+
+            return new ConnectionString(
+                protocolCode,
+                transportCode,
+                match.Groups["transportConfig"].Value,
+                paramString,
+                ParseParameters(paramString));
+        }
+
+        private static IReadOnlyDictionary<string, string> ParseParameters(string paramString)
+        {
+            var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            if (string.IsNullOrEmpty(paramString))
+            {
+                return result;
+            }
+
+            foreach (var pair in paramString.Split('&'))
+            {
+                if (pair.Length == 0)
+                {
+                    continue;
+                }
+                var separator = pair.IndexOf('=');
+                if (separator < 0)
+                {
+                    // A bare flag, e.g. "?verbose" — treat as present-and-true.
+                    result[Decode(pair)] = "true";
+                    continue;
+                }
+                var key = Decode(pair.Substring(0, separator));
+                var value = Decode(pair.Substring(separator + 1));
+                result[key] = value;
+            }
+            return result;
+        }
+
+        /// <summary>
+        /// Decodes one parameter name or value.
+        /// </summary>
+        /// <remarks>
+        /// Java decodes parameter values with <c>URLDecoder.decode(value, UTF_8)</c>, which
+        /// is the application/x-www-form-urlencoded decoder: it turns '+' into a space as
+        /// well as resolving %XX escapes. <c>Uri.UnescapeDataString</c> resolves the escapes
+        /// but leaves '+' alone, so "?name=a+b" would mean different things in each
+        /// language. <c>WebUtility.UrlDecode</c> is the .NET equivalent of Java's decoder.
+        /// </remarks>
+        private static string Decode(string raw)
+        {
+            return WebUtility.UrlDecode(raw);
+        }
+
+        /// <summary>
+        /// Looks up a parameter, returning <paramref name="defaultValue"/> when absent.
+        /// </summary>
+        public string? GetParameter(string name, string? defaultValue = null)
+        {
+            return Parameters.TryGetValue(name, out var value) ? value : defaultValue;
+        }
+
+        public int GetIntParameter(string name, int defaultValue)
+        {
+            var raw = GetParameter(name);
+            // Invariant culture: a connection string means the same thing regardless of
+            // the machine's locale, matching Java's Integer.parseInt.
+            return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+                ? value
+                : defaultValue;
+        }
+
+        public bool GetBoolParameter(string name, bool defaultValue)
+        {
+            var raw = GetParameter(name);
+            if (string.IsNullOrEmpty(raw))
+                return defaultValue;
+            // Accept .NET bool literals + numeric and on/off forms for
+            // compatibility with common connection-string conventions.
+            if (raw == "1" || raw.Equals("on", StringComparison.OrdinalIgnoreCase)
+                || raw.Equals("yes", StringComparison.OrdinalIgnoreCase))
+                return true;
+            if (raw == "0" || raw.Equals("off", StringComparison.OrdinalIgnoreCase)
+                || raw.Equals("no", StringComparison.OrdinalIgnoreCase))
+                return false;
+            return bool.TryParse(raw, out var value) ? value : defaultValue;
+        }
+
+        /// <summary>
+        /// Replaces credential-bearing parameter values with '***' so a connection string
+        /// can be written to a log.
+        /// </summary>
+        public static string? RedactSecrets(string? connectionString)
+        {
+            if (connectionString == null)
+            {
+                return null;
+            }
+            return SecretParamPattern.Replace(connectionString, "$1***");
+        }
+
+        public override string ToString()
+        {
+            var transport = TransportCode == null ? string.Empty : ":" + TransportCode;
+            var parameters = ParamString.Length == 0 ? string.Empty : "?" + ParamString;
+            // The argument is an interpolated string, so RedactSecrets cannot
+            // return null here; the coalesce only satisfies its nullable return.
+            return RedactSecrets($"{ProtocolCode}{transport}://{TransportConfig}{parameters}") ?? string.Empty;
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/DriverBase.cs b/plc4net/spi/drivers/DriverBase.cs
new file mode 100644
index 0000000000..954ac6a139
--- /dev/null
+++ b/plc4net/spi/drivers/DriverBase.cs
@@ -0,0 +1,294 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using org.apache.plc4net.api;
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.exceptions;
+using org.apache.plc4net.spi.transports;
+
+namespace org.apache.plc4net.spi.drivers
+{
+    /// <summary>
+    /// Shared logic for every driver: connection-string dispatch, transport selection,
+    /// configuration parsing, supported-transport enforcement.
+    /// </summary>
+    /// <remarks>
+    /// Mirrors the Java SPI3 <c>DriverBase</c>. A concrete driver implements
+    /// <see cref="GetProtocolCode"/>, <see cref="GetProtocolName"/>, and
+    /// <see cref="CreateConnection"/>; everything else is handled here.
+    /// </remarks>
+    public abstract class DriverBase : IPlcDriver
+    {
+        private readonly ITransportManager _transportManager;
+
+        protected DriverBase(ITransportManager transportManager)
+        {
+            _transportManager = transportManager ?? throw new ArgumentNullException(nameof(transportManager));
+        }
+
+        protected void RegisterTransport(ITransport transport)
+        {
+            if (_transportManager is DefaultTransportManager defaultManager)
+            {
+                defaultManager.Register(transport);
+            }
+        }
+
+        // ----- abstract members a driver must implement -----
+
+        /// <summary>e.g. "s7", "modbus-tcp".</summary>
+        public abstract string ProtocolCode { get; }
+
+        /// <summary>Human-readable name, e.g. "Siemens S7".</summary>
+        public abstract string ProtocolName { get; }
+
+        /// <summary>
+        /// Optional default transport when the connection string omits one
+        /// (e.g. "s7" defaults to "cotp").
+        /// </summary>
+        public abstract string DefaultTransportCode { get; }
+
+        /// <summary>
+        /// The transports this driver supports. A connection string that requests a
+        /// different transport is rejected unless <c>allow-unsupported-transport=true</c>
+        /// is set.
+        /// </summary>
+        protected abstract string[] SupportedTransportCodes { get; }
+
+        /// <summary>
+        /// Constructs a protocol-specific connection. Called after the transport has
+        /// been opened and validated. Implementations record configuration only; the
+        /// protocol handshake belongs in <see cref="ConnectionBase.OnConnect"/>, which
+        /// <see cref="Connect(string, IPlcAuthentication)"/> invokes on the result.
+        /// </summary>
+        protected abstract ConnectionBase CreateConnection(
+            ConnectionString connectionString,
+            ITransportInstance transportInstance,
+            IPlcAuthentication? authentication);
+
+        // ----- public driver API -----
+
+        public IPlcConnection Connect(string connectionString)
+        {
+            return Connect(connectionString, null);
+        }
+
+        public IPlcConnection Connect(string connectionString, IPlcAuthentication? authentication)
+        {
+            if (string.IsNullOrWhiteSpace(connectionString))
+            {
+                throw new PlcConnectionException("Connection string must not be empty.");
+            }
+
+            var parsed = ConnectionString.Parse(connectionString);
+
+            if (!string.Equals(parsed.ProtocolCode, ProtocolCode, StringComparison.OrdinalIgnoreCase))
+            {
+                throw new PlcConnectionException(
+                    $"This driver ({ProtocolCode}) is not suited for protocol '{parsed.ProtocolCode}'.");
+            }
+
+            var transportCode = ResolveTransportCode(parsed);
+
+            var transport = _transportManager.GetTransport(transportCode);
+            if (transport == null)
+            {
+                throw new PlcConnectionException(
+                    $"No transport registered for code '{transportCode}'. " +
+                    $"Registered transports: {string.Join(", ", _transportManager.GetTransportCodes())}");
+            }
+
+            var rawTransportConfig = parsed.TransportConfig;
+            // Per SPI3, the driver-config portion of the URL is the part after the
+            // transport's parsed host:port. The TCP transport returns it as /milo in
+            // opcua:tcp://localhost:4840/milo; for drivers that don't consume one it
+            // stays empty.
+            var transportConfig = transport.CreateConfiguration(parsed.Parameters);
+            var transportInstance = transport.CreateTransportInstance(rawTransportConfig, transportConfig);
+
+            // The transport is already open by this point, so a driver constructor that
+            // throws (e.g. parameter validation) must not leak it: nothing else holds a
+            // reference. Closing the instance is chain-safe — a wrapper transport (COTP
+            // over TCP) closes its inner one.
+            ConnectionBase connection;
+            try
+            {
+                connection = CreateConnection(parsed, transportInstance, authentication);
+            }
+            catch
+            {
+                try
+                {
+                    transportInstance.Close();
+                }
+                catch
+                {
+                    // Preserve the original failure; cleanup errors must not mask it.
+                }
+                throw;
+            }
+
+            // Establish the protocol-level session. Construction above only records
+            // configuration; anything that talks to the device happens here, so that a
+            // driver's constructor never blocks on I/O. If setup fails the connection is
+            // closed before the exception escapes — the caller never receives a reference,
+            // so nobody else can release the socket.
+            //
+            // Closing goes through the connection rather than the transport instance
+            // created above: a driver may wrap it (S7 puts COTP on top of TCP) and it is
+            // the wrapper the connection holds. Closing the inner one directly would leave
+            // the wrapper's own state untouched.
+            try
+            {
+                connection.Connect();
+            }
+            catch (PlcConnectionException)
+            {
+                try
+                {
+                    connection.Close();
+                }
+                catch
+                {
+                    // Preserve the original failure; cleanup errors must not mask it.
+                }
+                throw;
+            }
+            catch (Exception ex)
+            {
+                try
+                {
+                    connection.Close();
+                }
+                catch
+                {
+                    // Preserve the original failure; cleanup errors must not mask it.
+                }
+
+                // Every other Connect() failure path reports PlcConnectionException;
+                // a handshake failure that surfaces as TransportException must not
+                // escape that contract. Java's SPI3 wraps both getConnection() and
+                // connect() failures the same way.
+                throw new PlcConnectionException(
+                    $"Failed to establish the connection: {ex.Message}", ex);
+            }
+
+            return connection;
+        }
+
+        /// <summary>
+        /// Async equivalent of <see cref="Connect(string, IPlcAuthentication)"/>.
+        /// Runs the transport creation and protocol handshake on a background thread
+        /// so that UI threads and ASP.NET request threads are not blocked.
+        /// </summary>
+        public async System.Threading.Tasks.Task<IPlcConnection> ConnectAsync(
+            string connectionString,
+            IPlcAuthentication? authentication = null)
+        {
+            var parsed = ConnectionString.Parse(connectionString);
+
+            if (!string.Equals(parsed.ProtocolCode, ProtocolCode, StringComparison.OrdinalIgnoreCase))
+            {
+                throw new PlcConnectionException(
+                    $"This driver ({ProtocolCode}) is not suited for protocol '{parsed.ProtocolCode}'.");
+            }
+
+            var transportCode = ResolveTransportCode(parsed);
+
+            var transport = _transportManager.GetTransport(transportCode);
+            if (transport == null)
+            {
+                throw new PlcConnectionException(
+                    $"No transport registered for code '{transportCode}'. " +
+                    $"Registered transports: {string.Join(", ", _transportManager.GetTransportCodes())}");
+            }
+
+            var transportConfig = transport.CreateConfiguration(parsed.Parameters);
+            // Opening a socket can block, so keep this step off the caller's thread too.
+            var transportInstance = await System.Threading.Tasks.Task.Run(() =>
+                transport.CreateTransportInstance(parsed.TransportConfig, transportConfig))
+                .ConfigureAwait(false);
+
+            ConnectionBase connection;
+            try
+            {
+                connection = CreateConnection(parsed, transportInstance, authentication);
+            }
+            catch
+            {
+                try { transportInstance.Close(); }
+                catch { }
+                throw;
+            }
+
+            try
+            {
+                await connection.ConnectAsync().ConfigureAwait(false);
+            }
+            catch (PlcConnectionException)
+            {
+                try { connection.Close(); }
+                catch { }
+                throw;
+            }
+            catch (Exception ex)
+            {
+                try { connection.Close(); }
+                catch { }
+                throw new PlcConnectionException(
+                    $"Failed to establish the connection: {ex.Message}", ex);
+            }
+
+            return connection;
+        }
+
+        private string ResolveTransportCode(ConnectionString parsed)
+        {
+            if (!string.IsNullOrWhiteSpace(parsed.TransportCode))
+            {
+                var requested = parsed.TransportCode;
+                if (!SupportedTransportCodes.Contains(requested, StringComparer.OrdinalIgnoreCase))
+                {
+                    var allowUnsupported = parsed.GetBoolParameter("allow-unsupported-transport", false);
+                    if (!allowUnsupported)
+                    {
+                        throw new PlcConnectionException(
+                            $"Transport '{requested}' is not supported by driver '{ProtocolCode}'. " +
+                            $"Supported transports: {string.Join(", ", SupportedTransportCodes)}. " +
+                            "Set 'allow-unsupported-transport=true' to bypass this check.",
+                            new Exception("Unsupported transport"));
+                    }
+                }
+                return requested;
+            }
+
+            if (!string.IsNullOrWhiteSpace(DefaultTransportCode))
+            {
+                return DefaultTransportCode;
+            }
+
+            throw new PlcConnectionException(
+                $"Driver '{ProtocolCode}' has no default transport and the connection string did not specify one.");
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/MessageCodecBase.cs b/plc4net/spi/drivers/MessageCodecBase.cs
new file mode 100644
index 0000000000..b06a81204c
--- /dev/null
+++ b/plc4net/spi/drivers/MessageCodecBase.cs
@@ -0,0 +1,206 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.spi.generation;
+using org.apache.plc4net.spi.transports;
+
+namespace org.apache.plc4net.spi.drivers
+{
+    /// <summary>
+    /// Raised when a protocol message cannot be encoded, decoded or transmitted.
+    /// </summary>
+    public class MessageCodecException : Exception
+    {
+        public MessageCodecException(string message) : base(message)
+        {
+        }
+
+        public MessageCodecException(string message, Exception innerException)
+            : base(message, innerException)
+        {
+        }
+    }
+
+    /// <summary>
+    /// A protocol message that can report its own wire length and serialize itself.
+    /// </summary>
+    /// <remarks>
+    /// Generated message classes are intended to implement this. Note that the C#
+    /// code generator does not yet emit serialization logic (see the port notes in
+    /// the PLC4Net README), so today this is implemented by hand-written messages
+    /// and by test fixtures.
+    /// </remarks>
+    public interface IMessage
+    {
+        /// <summary>Length of this message once serialized, in bytes.</summary>
+        int GetLengthInBytes();
+
+        /// <summary>Writes this message into the supplied buffer.</summary>
+        void Serialize(WriteBuffer writeBuffer);
+    }
+
+    /// <summary>
+    /// Turns a transport's byte stream into whole protocol messages and back.
+    /// </summary>
+    /// <remarks>
+    /// Mirrors the Java SPI3 <c>MessageCodecBase</c>. The framing contract is the
+    /// three abstract members: a driver states the smallest header it can act on,
+    /// derives the total frame length from that header, and parses a complete frame.
+    /// Everything else — partial reads, several messages arriving in one packet,
+    /// waiting for the rest of a frame — is handled here.
+    /// </remarks>
+    /// <typeparam name="TMessage">the message type this codec handles</typeparam>
+    public abstract class MessageCodecBase<TMessage> where TMessage : class
+    {
+        private readonly string _protocolName;
+        private readonly ITransportInstance _transportInstance;
+        private readonly Action<TMessage> _messageHandler;
+
+        protected MessageCodecBase(
+            string protocolName,
+            ITransportInstance transportInstance,
+            Action<TMessage> messageHandler)
+        {
+            _protocolName = protocolName ?? throw new ArgumentNullException(nameof(protocolName));
+            _transportInstance = transportInstance ?? throw new ArgumentNullException(nameof(transportInstance));
+            _messageHandler = messageHandler ?? throw new ArgumentNullException(nameof(messageHandler));
+        }
+
+        protected ITransportInstance TransportInstance => _transportInstance;
+
+        /// <summary>
+        /// Smallest number of bytes needed before <see cref="CalculateTotalMessageSize"/>
+        /// can say anything useful.
+        /// </summary>
+        protected abstract int GetMinimumHeaderSize();
+
+        /// <summary>
+        /// Total frame length in bytes, derived from the peeked header. Return a negative
+        /// value to signal "cannot tell yet, wait for more bytes".
+        /// </summary>
+        protected abstract int CalculateTotalMessageSize(byte[] header, int availableBytes);
+
+        /// <summary>Parses exactly one complete frame.</summary>
+        protected abstract TMessage ParseMessage(ReadBuffer readBuffer);
+
+        public void Send(TMessage message)
+        {
+            if (message == null)
+            {
+                throw new ArgumentNullException(nameof(message));
+            }
+            if (!(message is IMessage serializable))
+            {
+                throw new MessageCodecException(
+                    $"{typeof(TMessage).Name} does not implement IMessage and cannot be serialized.");
+            }
+
+            try
+            {
+                var writeBuffer = new WriteBuffer();
+                serializable.Serialize(writeBuffer);
+                _transportInstance.Write(writeBuffer.GetBytes());
+            }
+            catch (TransportException e)
+            {
+                throw new MessageCodecException($"Failed to send {_protocolName} message", e);
+            }
+            catch (ParseException e)
+            {
+                throw new MessageCodecException($"Failed to serialize {_protocolName} message", e);
+            }
+        }
+
+        /// <summary>
+        /// Drains as many complete messages as the buffer currently holds, handing each
+        /// to the message handler. Returns without consuming anything when the next
+        /// frame has not fully arrived.
+        /// </summary>
+        public void ProcessIncomingData()
+        {
+            try
+            {
+                while (true)
+                {
+                    var minimumHeaderSize = GetMinimumHeaderSize();
+
+                    var availableBytes = _transportInstance.GetNumBytesAvailable();
+                    if (availableBytes < minimumHeaderSize)
+                    {
+                        return;
+                    }
+
+                    var header = _transportInstance.PeekReadableBytes(minimumHeaderSize);
+
+                    int totalMessageSize;
+                    try
+                    {
+                        totalMessageSize = CalculateTotalMessageSize(header, availableBytes);
+                    }
+                    catch (MessageCodecException)
+                    {
+                        // The header does not describe a valid frame - the byte stream
+                        // is misaligned (a datagram dropped mid-frame, a truncated or
+                        // malformed frame). Discard one byte and resync on the next
+                        // pass rather than wedging on the same bad header forever.
+                        _transportInstance.Read(1);
+                        continue;
+                    }
+                    if (totalMessageSize < 0)
+                    {
+                        return;
+                    }
+
+                    if (availableBytes < totalMessageSize)
+                    {
+                        // The frame is still in flight; leave the bytes buffered.
+                        return;
+                    }
+
+                    var messageBytes = _transportInstance.Read(totalMessageSize);
+                    var message = ParseMessage(new ReadBuffer(messageBytes));
+                    _messageHandler(message);
+                }
+            }
+            catch (TransportException e)
+            {
+                throw new MessageCodecException($"Failed to receive {_protocolName} message", e);
+            }
+            catch (ParseException e)
+            {
+                throw new MessageCodecException($"Failed to parse {_protocolName} message", e);
+            }
+        }
+
+        public bool IsOpen => _transportInstance.IsOpen;
+
+        public void Close()
+        {
+            try
+            {
+                _transportInstance.Close();
+            }
+            catch (TransportException e)
+            {
+                throw new MessageCodecException("Failed to close transport", e);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/ScanExtensions.cs b/plc4net/spi/drivers/ScanExtensions.cs
new file mode 100644
index 0000000000..7689377fe9
--- /dev/null
+++ b/plc4net/spi/drivers/ScanExtensions.cs
@@ -0,0 +1,117 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Reflection;
+using org.apache.plc4net.api;
+using org.apache.plc4net.spi.transports;
+
+namespace org.apache.plc4net.spi.drivers
+{
+    /// <summary>
+    /// Assembly scanning helpers for auto-registering drivers. These live
+    /// in the SPI layer (not api) so they have access to <c>ITransportManager</c>
+    /// without creating a circular dependency.
+    /// </summary>
+    public static class Plc4NetScanExtensions
+    {
+        /// <summary>
+        /// Scans <paramref name="assembly"/> for types extending <c>DriverBase</c>,
+        /// instantiates each one with the given <paramref name="transportManager"/>,
+        /// and registers the resulting driver with the global <c>PlcDriverManager</c>.
+        /// </summary>
+        public static int ScanAndRegisterDrivers(
+            this PlcDriverManager manager,
+            Assembly assembly,
+            ITransportManager transportManager)
+        {
+            if (manager == null) throw new ArgumentNullException(nameof(manager));
+            if (assembly == null) throw new ArgumentNullException(nameof(assembly));
+            if (transportManager == null) throw new ArgumentNullException(nameof(transportManager));
+
+            var count = 0;
+            foreach (var type in assembly.GetExportedTypes())
+            {
+                if (type.IsAbstract || type.IsInterface) continue;
+
+                if (!IsDriverBase(type)) continue;
+
+                var ctors = type.GetConstructors(
+                    BindingFlags.Public | BindingFlags.Instance);
+                if (ctors.Length == 0) continue;
+
+                foreach (var ctor in ctors)
+                {
+                    var parameters = ctor.GetParameters();
+                    var args = new object[parameters.Length];
+                    var matched = true;
+                    for (int i = 0; i < parameters.Length; i++)
+                    {
+                        if (typeof(ITransportManager)
+                            .IsAssignableFrom(parameters[i].ParameterType))
+                        {
+                            args[i] = transportManager;
+                        }
+                        else if (parameters[i].HasDefaultValue)
+                        {
+                            args[i] = parameters[i].DefaultValue!;
+                        }
+                        else if (parameters[i].ParameterType.IsValueType)
+                        {
+                            args[i] = Activator.CreateInstance(
+                                parameters[i].ParameterType)!;
+                        }
+                        else
+                        {
+                            matched = false;
+                            break;
+                        }
+                    }
+                    if (!matched) continue;
+
+                    try
+                    {
+                        var driver = (IPlcDriver)ctor.Invoke(args);
+                        manager.RegisterDriver(driver);
+                        count++;
+                        break;
+                    }
+                    catch
+                    {
+                        // Try the next constructor.
+                    }
+                }
+            }
+            return count;
+        }
+
+        private static bool IsDriverBase(Type type)
+        {
+            var baseType = type.BaseType;
+            while (baseType != null)
+            {
+                if (baseType.FullName ==
+                    "org.apache.plc4net.spi.drivers.DriverBase")
+                    return true;
+                baseType = baseType.BaseType;
+            }
+            return false;
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/api/api/model/IPlcSubscriptionHandle.cs b/plc4net/spi/drivers/functions/PlcReader.cs
similarity index 66%
copy from plc4net/api/api/model/IPlcSubscriptionHandle.cs
copy to plc4net/spi/drivers/functions/PlcReader.cs
index e605d29083..2008ea448c 100644
--- a/plc4net/api/api/model/IPlcSubscriptionHandle.cs
+++ b/plc4net/spi/drivers/functions/PlcReader.cs
@@ -17,19 +17,22 @@
  * under the License.
  */
 
-using System;
+using System.Threading;
+using System.Threading.Tasks;
 using org.apache.plc4net.messages;
+using org.apache.plc4net.spi.drivers.messages;
 
-namespace org.apache.plc4net.model
+namespace org.apache.plc4net.spi.drivers.functions
 {
     /// <summary>
-    /// Handle to a subscription
+    /// The interface a driver implements to execute a read request.
+    /// Mirrors Java SPI3 <c>PlcReader</c>.
     /// </summary>
-    public interface IPlcSubscriptionHandle
+    public interface PlcReader
     {
         /// <summary>
-        /// Event that can be subscribed to handle events raised by the subscription
+        /// Executes a read request, returning the response.
         /// </summary>
-        event EventHandler<IPlcSubscriptionEventArgs> PlcSubscriptionEvent;
+        Task<IPlcReadResponse> Read(DefaultPlcReadRequest request, CancellationToken cancellationToken = default);
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/model/IPlcSubscriptionHandle.cs b/plc4net/spi/drivers/functions/PlcWriter.cs
similarity index 66%
rename from plc4net/api/api/model/IPlcSubscriptionHandle.cs
rename to plc4net/spi/drivers/functions/PlcWriter.cs
index e605d29083..8bb38d78db 100644
--- a/plc4net/api/api/model/IPlcSubscriptionHandle.cs
+++ b/plc4net/spi/drivers/functions/PlcWriter.cs
@@ -17,19 +17,22 @@
  * under the License.
  */
 
-using System;
+using System.Threading;
+using System.Threading.Tasks;
 using org.apache.plc4net.messages;
+using org.apache.plc4net.spi.drivers.messages;
 
-namespace org.apache.plc4net.model
+namespace org.apache.plc4net.spi.drivers.functions
 {
     /// <summary>
-    /// Handle to a subscription
+    /// The interface a driver implements to execute a write request.
+    /// Mirrors Java SPI3 <c>PlcWriter</c>.
     /// </summary>
-    public interface IPlcSubscriptionHandle
+    public interface PlcWriter
     {
         /// <summary>
-        /// Event that can be subscribed to handle events raised by the subscription
+        /// Executes a write request, returning the response.
         /// </summary>
-        event EventHandler<IPlcSubscriptionEventArgs> PlcSubscriptionEvent;
+        Task<IPlcWriteResponse> Write(DefaultPlcWriteRequest request, CancellationToken cancellationToken = default);
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcReadRequest.cs b/plc4net/spi/drivers/messages/DefaultPlcReadRequest.cs
new file mode 100644
index 0000000000..0e4924c965
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcReadRequest.cs
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.drivers.functions;
+using org.apache.plc4net.spi.drivers.messages.items;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default implementation of a read request.
+    /// Mirrors the Java SPI3 <c>DefaultPlcReadRequest</c>.
+    /// </summary>
+    public class DefaultPlcReadRequest : IPlcReadRequest, IPlcTagRequest
+    {
+        private readonly PlcReader _reader;
+        private readonly Dictionary<string, PlcTagItem<IPlcTag>> _tags;
+
+        public DefaultPlcReadRequest(PlcReader reader, Dictionary<string, PlcTagItem<IPlcTag>> tags)
+        {
+            _reader = reader;
+            _tags = tags ?? new Dictionary<string, PlcTagItem<IPlcTag>>();
+        }
+
+        public int TagCount => _tags.Count;
+
+        public IEnumerable<string> TagNames => _tags.Keys;
+
+        public IPlcTag? GetTagByName(string name)
+        {
+            return _tags.TryGetValue(name, out var item) ? item.Tag : null;
+        }
+
+        public IEnumerable<IPlcTag> Tags => _tags.Values.Select(v => v.Tag);
+
+        async Task<IPlcResponse> IPlcRequest.ExecuteAsync()
+        {
+            return (IPlcResponse)await _reader.Read(this).ConfigureAwait(false);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcReadRequestBuilder.cs b/plc4net/spi/drivers/messages/DefaultPlcReadRequestBuilder.cs
new file mode 100644
index 0000000000..ac22bfa3e9
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcReadRequestBuilder.cs
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.drivers.functions;
+using org.apache.plc4net.spi.drivers.messages.items;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default builder for read requests.
+    /// </summary>
+    public class DefaultPlcReadRequestBuilder : IPlcReadRequestBuilder
+    {
+        private readonly PlcReader _reader;
+        private readonly Func<string, IPlcTag> _tagParser;
+        private readonly Dictionary<string, PlcTagItem<IPlcTag>> _tags
+            = new Dictionary<string, PlcTagItem<IPlcTag>>();
+
+        public DefaultPlcReadRequestBuilder(PlcReader reader, Func<string, IPlcTag>? tagParser)
+        {
+            _reader = reader;
+            _tagParser = tagParser ?? (s => new GenericTag(s));
+        }
+
+        public IPlcReadRequestBuilder AddTagAddress(string name, string tagAddress)
+        {
+            var tag = _tagParser(tagAddress);
+            _tags[name] = new DefaultPlcTagItem<IPlcTag>(name, tag);
+            return this;
+        }
+
+        public IPlcReadRequest Build()
+        {
+            return new DefaultPlcReadRequest(_reader, _tags);
+        }
+    }
+
+    /// <summary>
+    /// Fallback tag carrying an address string — used when no protocol-specific
+    /// parser is available.
+    /// </summary>
+    internal class GenericTag : IPlcTag
+    {
+        public GenericTag(string address) { Address = address; }
+        public string Address { get; }
+        public override string ToString() => Address;
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcReadResponse.cs b/plc4net/spi/drivers/messages/DefaultPlcReadResponse.cs
new file mode 100644
index 0000000000..59f64bb79f
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcReadResponse.cs
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System.Collections.Generic;
+using org.apache.plc4net.api.value;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.spi.drivers.messages.items;
+using org.apache.plc4net.types;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default implementation of a read response.
+    /// Mirrors the Java SPI3 <c>DefaultPlcReadResponse</c>.
+    /// </summary>
+    public class DefaultPlcReadResponse : IPlcReadResponse
+    {
+        private readonly IPlcReadRequest _request;
+        private readonly Dictionary<string, PlcResponseItem<IPlcValue>> _values;
+
+        public DefaultPlcReadResponse(
+            IPlcReadRequest request,
+            Dictionary<string, PlcResponseItem<IPlcValue>> values)
+        {
+            _request = request;
+            _values = values ?? new Dictionary<string, PlcResponseItem<IPlcValue>>();
+        }
+
+        // Explicit interface implementation: IPlcResponse.Request returns IPlcRequest.
+        IPlcRequest IPlcResponse.Request => _request;
+
+        /// <summary>The originating read request.</summary>
+        public IPlcReadRequest Request => _request;
+
+        /// <summary>
+        /// Returns the response code for a named tag.
+        /// </summary>
+        public PlcResponseCode GetResponseCode(string name)
+        {
+            return _values.TryGetValue(name, out var item)
+                ? item.Code
+                : PlcResponseCode.NotFound;
+        }
+
+        /// <summary>
+        /// Returns the value for a named tag, or null if the tag is absent or in error.
+        /// </summary>
+        public IPlcValue? GetValue(string name)
+        {
+            return _values.TryGetValue(name, out var item) && item.Code == PlcResponseCode.Ok
+                ? item.Value
+                : null;
+        }
+
+        public IEnumerable<string> TagNames => _values.Keys;
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcWriteRequest.cs b/plc4net/spi/drivers/messages/DefaultPlcWriteRequest.cs
new file mode 100644
index 0000000000..ea9083df14
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcWriteRequest.cs
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.drivers.functions;
+using org.apache.plc4net.spi.drivers.messages.items;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default implementation of a write request.
+    /// Mirrors the Java SPI3 <c>DefaultPlcWriteRequest</c>.
+    /// </summary>
+    public class DefaultPlcWriteRequest : IPlcWriteRequest, IPlcTagRequest
+    {
+        private readonly PlcWriter _writer;
+        private readonly Dictionary<string, PlcTagValueItem<IPlcTag>> _tags;
+
+        public DefaultPlcWriteRequest(PlcWriter writer, Dictionary<string, PlcTagValueItem<IPlcTag>> tags)
+        {
+            _writer = writer;
+            _tags = tags ?? new Dictionary<string, PlcTagValueItem<IPlcTag>>();
+        }
+
+        public int NumberOfValues => _tags.Count;
+
+        public int TagCount => _tags.Count;
+
+        public IEnumerable<string> TagNames => _tags.Keys;
+
+        public IPlcTag? GetTagByName(string name)
+        {
+            return _tags.TryGetValue(name, out var item) ? item.Tag : null;
+        }
+
+        public IEnumerable<IPlcTag> Tags => _tags.Values.Select(v => v.Tag);
+
+        /// <summary>Returns the value to write to the named tag, or null.</summary>
+        public object? GetValue(string name)
+        {
+            return _tags.TryGetValue(name, out var item) ? item.Value : null;
+        }
+
+        async Task<IPlcResponse> IPlcRequest.ExecuteAsync()
+        {
+            return (IPlcResponse)await _writer.Write(this).ConfigureAwait(false);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcWriteRequestBuilder.cs b/plc4net/spi/drivers/messages/DefaultPlcWriteRequestBuilder.cs
new file mode 100644
index 0000000000..02385981bd
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcWriteRequestBuilder.cs
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.drivers.functions;
+using org.apache.plc4net.spi.drivers.messages.items;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default builder for write requests.
+    /// </summary>
+    public class DefaultPlcWriteRequestBuilder : IPlcWriteRequestBuilder
+    {
+        private readonly PlcWriter _writer;
+        private readonly Func<string, IPlcTag> _tagParser;
+        private readonly Dictionary<string, PlcTagValueItem<IPlcTag>> _tags
+            = new Dictionary<string, PlcTagValueItem<IPlcTag>>();
+
+        public DefaultPlcWriteRequestBuilder(PlcWriter writer, Func<string, IPlcTag>? tagParser)
+        {
+            _writer = writer;
+            _tagParser = tagParser ?? (s => new GenericTag(s));
+        }
+
+        // For each typed overload, unwrap a single-element params array so the
+        // driver's Write method receives a scalar value rather than an array.
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params bool[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params byte[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params short[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params int[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params long[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params float[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params double[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params Decimal[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params string[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag(string name, string tagAddress, params DateTime[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequestBuilder AddTag<T>(string name, string tagAddress, params T[] values)
+        {
+            _tags[name] = new DefaultPlcTagValueItem<IPlcTag>(
+                name, _tagParser(tagAddress), Unwrap(values));
+            return this;
+        }
+
+        public IPlcWriteRequest Build()
+        {
+            return new DefaultPlcWriteRequest(_writer, _tags);
+        }
+
+        /// <summary>
+        /// If the array has one element, return it directly; if more than one,
+        /// throw because multi-value writes are not yet supported.
+        /// </summary>
+        private static object? Unwrap<T>(T[] values)
+        {
+            if (values == null || values.Length == 0) return null;
+            if (values.Length == 1) return values[0];
+            throw new NotSupportedException(
+                $"Multi-value writes (passed {values.Length} values of type {typeof(T).Name}) " +
+                "are not yet supported. Write each tag individually.");
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/drivers/messages/DefaultPlcWriteResponse.cs b/plc4net/spi/drivers/messages/DefaultPlcWriteResponse.cs
new file mode 100644
index 0000000000..f95c2ca805
--- /dev/null
+++ b/plc4net/spi/drivers/messages/DefaultPlcWriteResponse.cs
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+using System.Collections.Generic;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.types;
+
+namespace org.apache.plc4net.spi.drivers.messages
+{
+    /// <summary>
+    /// Default implementation of a write response.
+    /// </summary>
+    public class DefaultPlcWriteResponse : IPlcWriteResponse
+    {
+        private readonly IPlcWriteRequest _request;
+        private readonly Dictionary<string, PlcResponseCode> _codes;
+
+        public DefaultPlcWriteResponse(IPlcWriteRequest request, Dictionary<string, PlcResponseCode> codes)
+        {
+            _request = request;
+            _codes = codes ?? new Dictionary<string, PlcResponseCode>();
+        }
+
+        // Explicit interface implementation: IPlcResponse.Request returns IPlcRequest.
+        IPlcRequest IPlcResponse.Request => _request;
+
+        /// <summary>The originating write request.</summary>
+        public IPlcWriteRequest Request => _request;
+
+        /// <summary>Returns the response code for a named tag.</summary>
+        public PlcResponseCode GetResponseCode(string name)
+        {
+            return _codes.TryGetValue(name, out var code) ? code : PlcResponseCode.NotFound;
+        }
+
+        public IEnumerable<string> TagNames => _codes.Keys;
+    }
+}
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/DefaultPlcResponseItem.cs
similarity index 66%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/DefaultPlcResponseItem.cs
index c1d1719c8d..8501ba9f59 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/DefaultPlcResponseItem.cs
@@ -17,13 +17,23 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.types;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// Default implementation of <see cref="PlcResponseItem{T}"/>.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public class DefaultPlcResponseItem<T> : PlcResponseItem<T>
     {
+        public DefaultPlcResponseItem(PlcResponseCode code, T? value)
+        {
+            Code = code;
+            Value = value;
+        }
+
+        public PlcResponseCode Code { get; }
 
+        public T? Value { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/DefaultPlcTagErrorItem.cs
similarity index 66%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/DefaultPlcTagErrorItem.cs
index c1d1719c8d..a7b6f6bd97 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/DefaultPlcTagErrorItem.cs
@@ -17,13 +17,23 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.types;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// A response item that represents an error for a specific tag.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public class DefaultPlcTagErrorItem<T> : PlcResponseItem<T>
     {
+        public DefaultPlcTagErrorItem(PlcResponseCode code)
+        {
+            Code = code;
+            Value = default;
+        }
+
+        public PlcResponseCode Code { get; }
 
+        public T? Value { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/DefaultPlcTagItem.cs
similarity index 66%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/DefaultPlcTagItem.cs
index c1d1719c8d..c79abc6103 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/DefaultPlcTagItem.cs
@@ -17,13 +17,23 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.model;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// Default implementation of <see cref="PlcTagItem{T}"/> for read requests.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public class DefaultPlcTagItem<T> : PlcTagItem<T> where T : IPlcTag
     {
+        public DefaultPlcTagItem(string name, T tag)
+        {
+            Name = name;
+            Tag = tag;
+        }
+
+        public string Name { get; }
 
+        public T Tag { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/DefaultPlcTagValueItem.cs
similarity index 61%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/DefaultPlcTagValueItem.cs
index c1d1719c8d..8e98943b67 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/DefaultPlcTagValueItem.cs
@@ -17,13 +17,26 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.model;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// Default implementation of <see cref="PlcTagValueItem{T}"/> for write requests.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public class DefaultPlcTagValueItem<T> : PlcTagValueItem<T> where T : IPlcTag
     {
+        public DefaultPlcTagValueItem(string name, T tag, object? value)
+        {
+            Name = name;
+            Tag = tag;
+            Value = value;
+        }
+
+        public string Name { get; }
+
+        public T Tag { get; }
 
+        public object? Value { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/PlcResponseItem.cs
similarity index 63%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/PlcResponseItem.cs
index c1d1719c8d..39441b8140 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/PlcResponseItem.cs
@@ -17,13 +17,20 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.types;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// An item inside a response — either a value or an error.
+    /// Mirrors Java SPI3 <c>PlcResponseItem&lt;T&gt;</c>.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public interface PlcResponseItem<T>
     {
+        /// <summary>The response code for this item.</summary>
+        PlcResponseCode Code { get; }
 
+        /// <summary>The value, valid only when <see cref="Code"/> is <see cref="PlcResponseCode.OK"/>.</summary>
+        T? Value { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/PlcTagItem.cs
similarity index 64%
copy from plc4net/api/api/messages/IPlcReadResponse.cs
copy to plc4net/spi/drivers/messages/items/PlcTagItem.cs
index c1d1719c8d..4afd2c36d7 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/PlcTagItem.cs
@@ -17,13 +17,20 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.model;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// A tag item in a request — a named pair of a tag address.
+    /// Mirrors Java SPI3 <c>PlcTagItem&lt;T extends PlcTag&gt;</c>.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public interface PlcTagItem<T> where T : IPlcTag
     {
+        /// <summary>Name the caller gave this tag.</summary>
+        string Name { get; }
 
+        /// <summary>The tag with its protocol-specific address.</summary>
+        T Tag { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcReadResponse.cs b/plc4net/spi/drivers/messages/items/PlcTagValueItem.cs
similarity index 68%
rename from plc4net/api/api/messages/IPlcReadResponse.cs
rename to plc4net/spi/drivers/messages/items/PlcTagValueItem.cs
index c1d1719c8d..e95c29f802 100644
--- a/plc4net/api/api/messages/IPlcReadResponse.cs
+++ b/plc4net/spi/drivers/messages/items/PlcTagValueItem.cs
@@ -17,13 +17,17 @@
  * under the License.
  */
 
-namespace org.apache.plc4net.messages
+using org.apache.plc4net.model;
+
+namespace org.apache.plc4net.spi.drivers.messages.items
 {
     /// <summary>
-    /// Response for read requests <see cref="IPlcReadRequest"/>
+    /// A tag item with a value — used by write requests.
+    /// Mirrors Java SPI3 <c>PlcTagValueItem&lt;T extends PlcTag&gt;</c>.
     /// </summary>
-    public interface IPlcReadResponse : IPlcResponse
+    public interface PlcTagValueItem<T> : PlcTagItem<T> where T : IPlcTag
     {
-
+        /// <summary>The value to write to this tag.</summary>
+        object? Value { get; }
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/api/messages/IPlcSubscriptionResponse.cs b/plc4net/spi/drivers/tags/PlcTagHandler.cs
similarity index 55%
rename from plc4net/api/api/messages/IPlcSubscriptionResponse.cs
rename to plc4net/spi/drivers/tags/PlcTagHandler.cs
index c7ff66a34d..8691c987b3 100644
--- a/plc4net/api/api/messages/IPlcSubscriptionResponse.cs
+++ b/plc4net/spi/drivers/tags/PlcTagHandler.cs
@@ -17,26 +17,27 @@
  * under the License.
  */
 
-using System.Collections.Generic;
 using org.apache.plc4net.model;
 
-namespace org.apache.plc4net.messages
+namespace org.apache.plc4net.spi.drivers.tags
 {
     /// <summary>
-    /// Response to a <see cref="IPlcSubscriptionRequest"/>
+    /// A driver implements this to parse its protocol-specific tag address syntax.
+    /// Mirrors Java SPI3 <c>PlcTagHandler</c>.
     /// </summary>
-    public interface IPlcSubscriptionResponse: IPlcFieldResponse
+    /// <remarks>
+    /// Each driver registers its own handler. The framework calls
+    /// <see cref="ParseTag"/> when the user writes something like
+    /// <c>builder.AddTagAddress("myTag", "%DB1.DBW10")</c>.
+    /// </remarks>
+    public interface PlcTagHandler
     {
         /// <summary>
-        /// Get the handle for the subscription with the given name
+        /// Parses a tag address string into the driver's concrete tag type.
         /// </summary>
-        /// <param name="name">Name of the subscription</param>
-        /// <returns>Subscription handle</returns>
-        IPlcSubscriptionHandle GetSubscriptionHandle(string name);
-
-        /// <summary>
-        /// Get all subscription handles
-        /// </summary>
-        IEnumerable<IPlcSubscriptionHandle> SubscriptionHandles { get; }
+        /// <param name="tagAddress">The address string, e.g. "%DB1.DBW10" for S7
+        /// or "coil:0" for Modbus.</param>
+        /// <returns>A protocol-specific tag.</returns>
+        IPlcTag ParseTag(string tagAddress);
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/generation/BitReader.cs b/plc4net/spi/generation/BitReader.cs
new file mode 100644
index 0000000000..d3e5637bae
--- /dev/null
+++ b/plc4net/spi/generation/BitReader.cs
@@ -0,0 +1,146 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+
+namespace org.apache.plc4net.spi.generation
+{
+    /// <summary>
+    /// Reads big-endian (MSB-first) bit fields out of a byte array.
+    /// </summary>
+    /// <remarks>
+    /// This replaces the third-party Ayx.BitIO package, which is a .NET Framework
+    /// only assembly, unmaintained since 2016, and which threw
+    /// ArgumentOutOfRangeException when asked for a 32 bit field because it
+    /// round-tripped every read through a StringBuilder of '0'/'1' characters.
+    ///
+    /// MSB-first is the order every mspec-described protocol uses: the first bit of
+    /// a frame is the high bit of byte 0.
+    /// </remarks>
+    public class BitReader
+    {
+        private readonly byte[] _data;
+        private int _bitPosition;
+
+        public BitReader(byte[] data)
+        {
+            _data = data ?? throw new ArgumentNullException(nameof(data));
+        }
+
+        /// <summary>Current read offset, in bits.</summary>
+        public int Position
+        {
+            get => _bitPosition;
+            set
+            {
+                if (value < 0 || value > TotalBits)
+                {
+                    throw new ArgumentOutOfRangeException(nameof(value));
+                }
+                _bitPosition = value;
+            }
+        }
+
+        public int TotalBits => _data.Length * 8;
+
+        /// <summary>Number of bits not yet consumed.</summary>
+        public int Remaining => TotalBits - _bitPosition;
+
+        public bool ReadBool()
+        {
+            return ReadBits(1) != 0;
+        }
+
+        /// <summary>
+        /// Reads <paramref name="bitCount"/> bits and returns them right-aligned.
+        /// </summary>
+        public ulong ReadBits(int bitCount)
+        {
+            if (bitCount < 0 || bitCount > 64)
+            {
+                throw new ArgumentOutOfRangeException(
+                    nameof(bitCount), bitCount, "Can read between 0 and 64 bits at a time.");
+            }
+            if (bitCount > Remaining)
+            {
+                throw new ParseException(
+                    $"Not enough data: tried to read {bitCount} bits, {Remaining} available.");
+            }
+
+            ulong result = 0;
+            for (var i = 0; i < bitCount; i++)
+            {
+                var byteIndex = _bitPosition >> 3;
+                var bitOffset = 7 - (_bitPosition & 7); // MSB-first within the byte
+                var bit = (ulong)((_data[byteIndex] >> bitOffset) & 1);
+                result = (result << 1) | bit;
+                _bitPosition++;
+            }
+            return result;
+        }
+
+        /// <summary>
+        /// Reads <paramref name="bitCount"/> bits and sign-extends them from that width.
+        /// </summary>
+        public long ReadSignedBits(int bitCount)
+        {
+            var raw = ReadBits(bitCount);
+            if (bitCount == 0 || bitCount == 64)
+            {
+                return unchecked((long)raw);
+            }
+            var signMask = 1UL << (bitCount - 1);
+            if ((raw & signMask) != 0)
+            {
+                // Set every bit above the field width so the value stays negative.
+                raw |= ulong.MaxValue << bitCount;
+            }
+            return unchecked((long)raw);
+        }
+
+        /// <summary>Reads whole bytes. Requires the reader to be byte aligned.</summary>
+        public byte[] ReadBytes(int byteCount)
+        {
+            if (byteCount < 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(byteCount));
+            }
+            if (byteCount * 8 > Remaining)
+            {
+                throw new ParseException(
+                    $"Not enough data: tried to read {byteCount} bytes, {Remaining / 8} available.");
+            }
+
+            var result = new byte[byteCount];
+            if ((_bitPosition & 7) == 0)
+            {
+                Array.Copy(_data, _bitPosition >> 3, result, 0, byteCount);
+                _bitPosition += byteCount * 8;
+            }
+            else
+            {
+                for (var i = 0; i < byteCount; i++)
+                {
+                    result[i] = (byte)ReadBits(8);
+                }
+            }
+            return result;
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/generation/BitWriter.cs b/plc4net/spi/generation/BitWriter.cs
new file mode 100644
index 0000000000..809058ffc9
--- /dev/null
+++ b/plc4net/spi/generation/BitWriter.cs
@@ -0,0 +1,93 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+
+namespace org.apache.plc4net.spi.generation
+{
+    /// <summary>
+    /// Writes big-endian (MSB-first) bit fields into a growing byte buffer.
+    /// Mirror image of <see cref="BitReader"/>; see that type for why the
+    /// third-party bit codec was dropped.
+    /// </summary>
+    public class BitWriter
+    {
+        private readonly List<byte> _data = new List<byte>();
+        private int _bitPosition;
+
+        /// <summary>Number of bits written so far.</summary>
+        public int Length => _bitPosition;
+
+        public void WriteBool(bool value)
+        {
+            WriteBits(value ? 1UL : 0UL, 1);
+        }
+
+        /// <summary>
+        /// Writes the low <paramref name="bitCount"/> bits of <paramref name="value"/>,
+        /// most significant bit first.
+        /// </summary>
+        public void WriteBits(ulong value, int bitCount)
+        {
+            if (bitCount < 0 || bitCount > 64)
+            {
+                throw new ArgumentOutOfRangeException(
+                    nameof(bitCount), bitCount, "Can write between 0 and 64 bits at a time.");
+            }
+
+            for (var i = bitCount - 1; i >= 0; i--)
+            {
+                var bit = (value >> i) & 1;
+
+                if ((_bitPosition & 7) == 0)
+                {
+                    _data.Add(0);
+                }
+                if (bit != 0)
+                {
+                    var byteIndex = _bitPosition >> 3;
+                    var bitOffset = 7 - (_bitPosition & 7);
+                    _data[byteIndex] |= (byte)(1 << bitOffset);
+                }
+                _bitPosition++;
+            }
+        }
+
+        public void WriteBytes(byte[] value)
+        {
+            if (value == null)
+            {
+                throw new ArgumentNullException(nameof(value));
+            }
+            foreach (var b in value)
+            {
+                WriteBits(b, 8);
+            }
+        }
+
+        /// <summary>
+        /// The bytes written so far. A trailing partial byte is zero padded.
+        /// </summary>
+        public byte[] GetBytes()
+        {
+            return _data.ToArray();
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/generation/ByteOrder.cs b/plc4net/spi/generation/ByteOrder.cs
similarity index 99%
rename from plc4net/spi/spi/generation/ByteOrder.cs
rename to plc4net/spi/generation/ByteOrder.cs
index 1f65cd93ca..9295914023 100644
--- a/plc4net/spi/spi/generation/ByteOrder.cs
+++ b/plc4net/spi/generation/ByteOrder.cs
@@ -1,4 +1,4 @@
-/*
+/*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
diff --git a/plc4net/spi/spi/generation/EvaluationHelper.cs b/plc4net/spi/generation/EvaluationHelper.cs
similarity index 90%
rename from plc4net/spi/spi/generation/EvaluationHelper.cs
rename to plc4net/spi/generation/EvaluationHelper.cs
index 30e3c78e4d..de9ae414fd 100644
--- a/plc4net/spi/spi/generation/EvaluationHelper.cs
+++ b/plc4net/spi/generation/EvaluationHelper.cs
@@ -1,4 +1,4 @@
-/*
+/*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
@@ -51,23 +51,28 @@ namespace org.apache.plc4net.spi.generation
             return false;
         }*/
 
-        public static bool notEquals(Object val1, Object val2) {
+        public static bool notEquals(Object val1, Object val2)
+        {
             return true;
         }
 
-        public static bool greater(Object val1, Object val2) {
+        public static bool greater(Object val1, Object val2)
+        {
             return true;
         }
 
-        public static bool greaterEquals(Object val1, Object val2) {
+        public static bool greaterEquals(Object val1, Object val2)
+        {
             return true;
         }
 
-        public static bool smaller(Object val1, Object val2) {
+        public static bool smaller(Object val1, Object val2)
+        {
             return true;
         }
 
-        public static bool smallerEquals(Object val1, Object val2) {
+        public static bool smallerEquals(Object val1, Object val2)
+        {
             return true;
         }
     }
diff --git a/plc4net/spi/spi/model/values/PlcBitString.cs b/plc4net/spi/generation/ParseException.cs
similarity index 58%
copy from plc4net/spi/spi/model/values/PlcBitString.cs
copy to plc4net/spi/generation/ParseException.cs
index 42beac3bd3..33ab1b8792 100644
--- a/plc4net/spi/spi/model/values/PlcBitString.cs
+++ b/plc4net/spi/generation/ParseException.cs
@@ -17,29 +17,30 @@
 // under the License.
 //
 
-namespace org.apache.plc4net.spi.model.values
+using System;
+
+namespace org.apache.plc4net.spi.generation
 {
-    public class PlcBitString : PlcValueAdapter
+    /// <summary>
+    /// Raised when a byte stream cannot be interpreted as the expected message.
+    /// </summary>
+    /// <remarks>
+    /// This used to be an empty class that did not derive from Exception, so it
+    /// could not be thrown or caught and no parsing error was ever reportable.
+    /// </remarks>
+    public class ParseException : Exception
     {
-        public PlcBitString()
-        {
-        }
-
-        public PlcBitString(byte value)
+        public ParseException()
         {
         }
 
-        public PlcBitString(ushort value)
+        public ParseException(string message) : base(message)
         {
         }
 
-        public PlcBitString(uint value)
+        public ParseException(string message, Exception innerException)
+            : base(message, innerException)
         {
         }
-
-        public PlcBitString(ulong value)
-        {
-        }
-
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/generation/ReadBuffer.cs b/plc4net/spi/generation/ReadBuffer.cs
new file mode 100644
index 0000000000..092708a163
--- /dev/null
+++ b/plc4net/spi/generation/ReadBuffer.cs
@@ -0,0 +1,222 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Text;
+
+namespace org.apache.plc4net.spi.generation
+{
+
+    public class ReadBuffer
+    {
+        private readonly byte[] _data;
+        private readonly BitReader _reader;
+
+        public ReadBuffer(byte[] data)
+        {
+            this._data = data;
+            _reader = new BitReader(data);
+        }
+
+        public void Reset()
+        {
+            _reader.Position = 0;
+        }
+
+        public int GetPos()
+        {
+            return _reader.Position;
+        }
+
+        public byte[] GetBytes()
+        {
+            return _data;
+        }
+
+        public int GetTotalBytes()
+        {
+            return _data.Length;
+        }
+
+        public bool HasMore(int bitLength)
+        {
+            // A negative bit length is nonsensical and should not silently return true
+            // just because the comparison below would hold for any non-negative remaining.
+            if (bitLength < 0)
+            {
+                return false;
+            }
+            // Inclusive: a buffer with exactly bitLength bits left can still serve
+            // a read of that size.
+            return bitLength <= _reader.Remaining;
+        }
+
+        public byte PeekByte(int offset)
+        {
+            return _data[(_reader.Position / 8) + offset];
+        }
+
+        public bool ReadBit(String logicalName)
+        {
+            return _reader.ReadBool();
+        }
+
+        public byte ReadByte(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 8))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (byte)_reader.ReadBits(bitLength);
+        }
+
+        public ushort ReadUshort(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 16))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (ushort)_reader.ReadBits(bitLength);
+        }
+
+        public uint ReadUint(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 32))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (uint)_reader.ReadBits(bitLength);
+        }
+
+        public ulong ReadUlong(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 64))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            // The previous implementation read (bitLength - 32) bits and then a
+            // further bitLength bits, consuming 2*bitLength-32 bits instead of
+            // bitLength, so it never round-tripped against WriteBuffer.WriteUlong.
+            return _reader.ReadBits(bitLength);
+        }
+
+        public sbyte ReadSbyte(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 8))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (sbyte)_reader.ReadSignedBits(bitLength);
+        }
+
+        public short ReadShort(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 16))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (short)_reader.ReadSignedBits(bitLength);
+        }
+
+        public int ReadInt(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 32))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            return (int)_reader.ReadSignedBits(bitLength);
+        }
+
+        public long ReadLong(String logicalName, int bitLength)
+        {
+            if ((bitLength < 0) || (bitLength > 64))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            // Same over-read as ReadUlong had.
+            return _reader.ReadSignedBits(bitLength);
+        }
+
+        public float ReadFloat(String logicalName, int bitLength)
+        {
+            if (bitLength == 32)
+            {
+                // Reinterpret the 32 raw bits as IEEE-754. Going via
+                // BitConverter.GetBytes would reorder them on a little-endian host.
+                return BitConverter.Int32BitsToSingle((int)_reader.ReadBits(32));
+            }
+            // KNX DPT 9.x: a 16 bit fixed-point form, not IEEE-754 half precision.
+            if (bitLength == 16)
+            {
+                var sign = _reader.ReadBool();
+                var exp = (int)_reader.ReadBits(4);
+                var mantissa = (int)_reader.ReadBits(11);
+                if (sign)
+                {
+                    // Negative values are stored as a two's complement mantissa.
+                    mantissa -= 2048;
+                }
+                return (float)(0.01 * mantissa * Math.Pow(2, exp));
+            }
+            throw new NotImplementedException("This encoding is currently not supported");
+        }
+
+        public double ReadDouble(String logicalName, int bitLength)
+        {
+            if (bitLength == 32)
+            {
+                // Was BitConverter.ToDouble over a 4 byte array, which always threw.
+                return ReadFloat(logicalName, 32);
+            }
+            if (bitLength == 64)
+            {
+                return BitConverter.Int64BitsToDouble((long)_reader.ReadBits(64));
+            }
+            throw new NotImplementedException("This encoding is currently not supported");
+        }
+
+        public string ReadString(String logicalName, int bitLength, Encoding encoding)
+        {
+            if (encoding == null)
+            {
+                throw new ArgumentNullException(nameof(encoding));
+            }
+            if (bitLength < 0 || bitLength % 8 != 0)
+            {
+                throw new ArgumentOutOfRangeException(
+                    nameof(bitLength), bitLength, "String lengths must be a whole number of bytes.");
+            }
+
+            var raw = _reader.ReadBytes(bitLength / 8);
+            // PLC strings are commonly padded to a fixed field width with NULs.
+            return encoding.GetString(raw).TrimEnd('\0');
+        }
+
+        public byte[] ReadByteArray(String logicalName, int bitLength)
+        {
+            if (bitLength < 0 || bitLength % 8 != 0)
+            {
+                throw new ArgumentOutOfRangeException(
+                    nameof(bitLength), bitLength, "Byte arrays must be a whole number of bytes.");
+            }
+            return _reader.ReadBytes(bitLength / 8);
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/generation/WriteBuffer.cs b/plc4net/spi/generation/WriteBuffer.cs
new file mode 100644
index 0000000000..e7dff34b53
--- /dev/null
+++ b/plc4net/spi/generation/WriteBuffer.cs
@@ -0,0 +1,236 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Text;
+
+namespace org.apache.plc4net.spi.generation
+{
+    public class WriteBuffer
+    {
+        private readonly BitWriter _writer;
+
+        public WriteBuffer()
+        {
+            _writer = new BitWriter();
+        }
+
+        public int GetPos()
+        {
+            return _writer.Length;
+        }
+
+        public byte[] GetBytes()
+        {
+            return _writer.GetBytes();
+        }
+
+        public int GetTotalBytes()
+        {
+            return _writer.Length / 8 + (_writer.Length % 8 != 0 ? 1 : 0);
+        }
+
+        public void WriteBit(String logicalName, bool value)
+        {
+            _writer.WriteBool(value);
+        }
+
+        public void WriteByte(String logicalName, int bitLength, byte value)
+        {
+            if ((bitLength < 0) || (bitLength > 8))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(value, bitLength);
+        }
+
+        public void WriteUshort(String logicalName, int bitLength, ushort value)
+        {
+            if ((bitLength < 0) || (bitLength > 16))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(value, bitLength);
+        }
+
+        public void WriteUint(String logicalName, int bitLength, uint value)
+        {
+            if ((bitLength < 0) || (bitLength > 32))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(value, bitLength);
+        }
+
+        public void WriteUlong(String logicalName, int bitLength, ulong value)
+        {
+            if ((bitLength < 0) || (bitLength > 64))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(value, bitLength);
+        }
+
+        public void WriteSbyte(String logicalName, int bitLength, sbyte value)
+        {
+            if ((bitLength < 0) || (bitLength > 8))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(unchecked((ulong)value), bitLength);
+        }
+
+        public void WriteShort(String logicalName, int bitLength, short value)
+        {
+            if ((bitLength < 0) || (bitLength > 16))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(unchecked((ulong)value), bitLength);
+        }
+
+        public void WriteInt(String logicalName, int bitLength, int value)
+        {
+            if ((bitLength < 0) || (bitLength > 32))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(unchecked((ulong)value), bitLength);
+        }
+
+        public void WriteLong(String logicalName, int bitLength, long value)
+        {
+            if ((bitLength < 0) || (bitLength > 64))
+            {
+                throw new ArgumentOutOfRangeException(nameof(bitLength));
+            }
+            _writer.WriteBits(unchecked((ulong)value), bitLength);
+        }
+
+        public void WriteFloat(String logicalName, int bitLength, float value)
+        {
+            if (bitLength == 32)
+            {
+                // The previous version called WriteByte(8, bytes[i]) against a
+                // (value, bitLength) signature, so it wrote the constant 8 with a
+                // bit width taken from the data.
+                _writer.WriteBits(unchecked((uint)BitConverter.SingleToInt32Bits(value)), 32);
+                return;
+            }
+            if (bitLength == 16)
+            {
+                // KNX DPT 9.x fixed point. The previous version had an empty branch
+                // here and silently emitted nothing.
+                WriteKnxFloat16(value);
+                return;
+            }
+            throw new NotImplementedException("This encoding is currently not supported");
+        }
+
+        /// <summary>
+        /// Encodes a float in the KNX 16 bit fixed-point form: sign, 4 bit exponent,
+        /// 11 bit two's complement mantissa, where value = 0.01 * mantissa * 2^exp.
+        /// </summary>
+        private void WriteKnxFloat16(float value)
+        {
+            var scaled = value / 0.01;
+            var exp = 0;
+            while (scaled < -2048 || scaled > 2047)
+            {
+                scaled /= 2;
+                exp++;
+                if (exp > 15)
+                {
+                    throw new ArgumentOutOfRangeException(
+                        nameof(value), value, "Value cannot be represented as a KNX 16 bit float.");
+                }
+            }
+
+            var mantissa = (int)Math.Round(scaled);
+            var sign = mantissa < 0;
+            if (sign)
+            {
+                mantissa += 2048;
+            }
+
+            _writer.WriteBool(sign);
+            _writer.WriteBits((ulong)exp, 4);
+            _writer.WriteBits((ulong)(mantissa & 0x7FF), 11);
+        }
+
+        public void WriteDouble(String logicalName, int bitLength, double value)
+        {
+            if (bitLength == 32)
+            {
+                WriteFloat(logicalName, 32, (float)value);
+                return;
+            }
+            if (bitLength == 64)
+            {
+                _writer.WriteBits(unchecked((ulong)BitConverter.DoubleToInt64Bits(value)), 64);
+                return;
+            }
+            throw new NotImplementedException("This encoding is currently not supported");
+        }
+
+        public void WriteString(String logicalName, int bitLength, string encoding, string value)
+        {
+            if (bitLength < 0 || bitLength % 8 != 0)
+            {
+                throw new ArgumentOutOfRangeException(
+                    nameof(bitLength), bitLength, "String lengths must be a whole number of bytes.");
+            }
+
+            var raw = ResolveEncoding(encoding).GetBytes(value ?? string.Empty);
+            var fieldBytes = bitLength / 8;
+
+            // Truncate or NUL-pad to the declared field width.
+            for (var i = 0; i < fieldBytes; i++)
+            {
+                _writer.WriteBits(i < raw.Length ? raw[i] : (byte)0, 8);
+            }
+        }
+
+        public void WriteByteArray(String logicalName, byte[] value)
+        {
+            _writer.WriteBytes(value);
+        }
+
+        private static Encoding ResolveEncoding(string encoding)
+        {
+            // mspec spells these without the separator, e.g. "UTF8" rather than "UTF-8".
+            switch ((encoding ?? "UTF8").Replace("-", "").ToUpperInvariant())
+            {
+                case "":
+                case "UTF8":
+                    return Encoding.UTF8;
+                case "ASCII":
+                    return Encoding.ASCII;
+                case "UTF16":
+                case "UTF16LE":
+                    return Encoding.Unicode;
+                case "UTF16BE":
+                    return Encoding.BigEndianUnicode;
+                default:
+                    throw new NotImplementedException($"Unsupported string encoding '{encoding}'");
+            }
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcWORD.cs b/plc4net/spi/model/values/PlcBOOL.cs
similarity index 56%
rename from plc4net/spi/spi/model/values/PlcWORD.cs
rename to plc4net/spi/model/values/PlcBOOL.cs
index 82ae20fec0..674dbe855c 100644
--- a/plc4net/spi/spi/model/values/PlcWORD.cs
+++ b/plc4net/spi/model/values/PlcBOOL.cs
@@ -21,62 +21,68 @@ using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcWORD : PlcBitString
+    public class PlcBOOL : PlcSimpleValueAdapter
     {
-        private ushort value;
-        
-        public PlcWORD(ushort value)
+        private bool value;
+
+        public PlcBOOL(bool value)
         {
             this.value = value;
         }
-        
-        public new int GetBoolLength()
+
+        public override bool IsBool()
         {
-            return 16;
+            return true;
         }
-        
-        public new bool GetBoolAt(int index)
+
+        public override bool GetBool()
         {
-            if (index > 15)
-            {
-                return false;
-            }
+            return value;
+        }
 
-            return ((value >> index) & 1) == 1;
+        public override bool[] GetBoolArray()
+        {
+            return new[] { value };
         }
 
-        public new bool[] GetBoolArray()
+        // A BOOL is also 1/0 as a number and "true"/"false" as text (matching plc4j).
+
+        public override bool IsByte() => true;
+        public override byte GetByte() => (byte)(value ? 1 : 0);
+
+        public override bool IsUshort() => true;
+        public override ushort GetUshort() => (ushort)(value ? 1 : 0);
+
+        public override bool IsInt() => true;
+        public override int GetInt() => value ? 1 : 0;
+
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
         {
-            return new[]
-            {
-                GetBoolAt(0), GetBoolAt(1),
-                GetBoolAt(2), GetBoolAt(3),
-                GetBoolAt(4), GetBoolAt(5),
-                GetBoolAt(6), GetBoolAt(7),
-                GetBoolAt(8), GetBoolAt(9),
-                GetBoolAt(10), GetBoolAt(11),
-                GetBoolAt(12), GetBoolAt(13),
-                GetBoolAt(14), GetBoolAt(15)
-            };
+            return value ? "true" : "false";
         }
 
-        protected bool Equals(PlcWORD other)
+        protected bool Equals(PlcBOOL other)
         {
             return value == other.value;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcWORD) obj);
+            return Equals((PlcBOOL)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcNULL.cs b/plc4net/spi/model/values/PlcBYTE.cs
similarity index 76%
copy from plc4net/spi/spi/model/values/PlcNULL.cs
copy to plc4net/spi/model/values/PlcBYTE.cs
index 4ec8ec3547..1c572f4367 100644
--- a/plc4net/spi/spi/model/values/PlcNULL.cs
+++ b/plc4net/spi/model/values/PlcBYTE.cs
@@ -17,33 +17,30 @@
 // under the License.
 //
 
-using org.apache.plc4net.api.value;
-
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcNULL : PlcValueAdapter
+    public class PlcBYTE : PlcBitString
     {
-        public PlcNULL()
+        public PlcBYTE(byte value) : base(value)
         {
         }
 
-        protected bool Equals(PlcNULL other)
+        protected bool Equals(PlcBYTE other)
         {
-            throw new System.NotImplementedException();
+            return GetByte() == other.GetByte();
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcNULL) obj);
+            return Equals((PlcBYTE)obj);
         }
 
         public override int GetHashCode()
         {
-            throw new System.NotImplementedException();
+            return GetByte().GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/model/values/PlcBitString.cs b/plc4net/spi/model/values/PlcBitString.cs
new file mode 100644
index 0000000000..ad89abbc1c
--- /dev/null
+++ b/plc4net/spi/model/values/PlcBitString.cs
@@ -0,0 +1,188 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Globalization;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    /// <summary>
+    /// Base for the IEC bit-string values (<c>BYTE</c>, <c>WORD</c>, <c>DWORD</c>,
+    /// <c>LWORD</c>). Keeps the pattern as a right-aligned <see cref="ulong"/> and
+    /// serves it both as individual bits and as the (unsigned) integer of the
+    /// matching width, so a value survives a parse/serialize round trip and a
+    /// consumer holding it as <see cref="IPlcValue"/> can still read a number.
+    /// </summary>
+    public class PlcBitString : PlcSimpleValueAdapter
+    {
+        private readonly ulong _bits;
+        private readonly int _byteWidth;
+
+        public PlcBitString()
+        {
+        }
+
+        public PlcBitString(byte value)
+        {
+            _bits = value;
+            _byteWidth = 1;
+        }
+
+        public PlcBitString(ushort value)
+        {
+            _bits = value;
+            _byteWidth = 2;
+        }
+
+        public PlcBitString(uint value)
+        {
+            _bits = value;
+            _byteWidth = 4;
+        }
+
+        public PlcBitString(ulong value)
+        {
+            _bits = value;
+            _byteWidth = 8;
+        }
+
+        public override bool IsByte()
+        {
+            return _byteWidth == 1;
+        }
+
+        public override byte GetByte()
+        {
+            return (byte)_bits;
+        }
+
+        public override bool IsUshort()
+        {
+            return _byteWidth <= 2;
+        }
+
+        public override ushort GetUshort()
+        {
+            return (ushort)_bits;
+        }
+
+        public override bool IsUint()
+        {
+            return _byteWidth <= 4;
+        }
+
+        public override uint GetUint()
+        {
+            return (uint)_bits;
+        }
+
+        public override bool IsUlong()
+        {
+            return true;
+        }
+
+        public override ulong GetUlong()
+        {
+            return _bits;
+        }
+
+        // ── signed-integer + string views (a BYTE/WORD is also a plain number) ──
+
+        public override bool IsShort()
+        {
+            return _bits <= (ulong)short.MaxValue;
+        }
+
+        public override short GetShort()
+        {
+            if (!IsShort())
+            {
+                throw new ArgumentOutOfRangeException(nameof(GetShort),
+                    $"bit-string value {_bits} does not fit in a short");
+            }
+            return (short)_bits;
+        }
+
+        public override bool IsInt()
+        {
+            return _bits <= int.MaxValue;
+        }
+
+        public override int GetInt()
+        {
+            if (!IsInt())
+            {
+                throw new ArgumentOutOfRangeException(nameof(GetInt),
+                    $"bit-string value {_bits} does not fit in an int");
+            }
+            return (int)_bits;
+        }
+
+        public override bool IsLong()
+        {
+            return _bits <= long.MaxValue;
+        }
+
+        public override long GetLong()
+        {
+            if (!IsLong())
+            {
+                throw new ArgumentOutOfRangeException(nameof(GetLong),
+                    $"bit-string value {_bits} does not fit in a long");
+            }
+            return (long)_bits;
+        }
+
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return _bits.ToString(CultureInfo.InvariantCulture);
+        }
+
+        public override int GetBoolLength()
+        {
+            return _byteWidth * 8;
+        }
+
+        public override bool GetBoolAt(int index)
+        {
+            if (index < 0 || index >= _byteWidth * 8)
+            {
+                return false;
+            }
+
+            return ((_bits >> index) & 1UL) == 1UL;
+        }
+
+        public override bool[] GetBoolArray()
+        {
+            var array = new bool[_byteWidth * 8];
+            for (var i = 0; i < array.Length; i++)
+            {
+                array[i] = GetBoolAt(i);
+            }
+
+            return array;
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcCHAR.cs b/plc4net/spi/model/values/PlcCHAR.cs
similarity index 84%
rename from plc4net/spi/spi/model/values/PlcCHAR.cs
rename to plc4net/spi/model/values/PlcCHAR.cs
index 6e1194392b..b80bd57181 100644
--- a/plc4net/spi/spi/model/values/PlcCHAR.cs
+++ b/plc4net/spi/model/values/PlcCHAR.cs
@@ -24,29 +24,39 @@ namespace org.apache.plc4net.spi.model.values
     public class PlcCHAR : PlcSimpleValueAdapter
     {
         private char value;
-        
+
         public PlcCHAR(char value)
         {
             this.value = value;
         }
 
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return value.ToString();
+        }
+
         protected bool Equals(PlcCHAR other)
         {
             return value == other.value;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcCHAR) obj);
+            return Equals((PlcCHAR)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcDATE.cs b/plc4net/spi/model/values/PlcDATE.cs
similarity index 57%
rename from plc4net/spi/spi/model/values/PlcDATE.cs
rename to plc4net/spi/model/values/PlcDATE.cs
index 008e6839d7..91cd520ce0 100644
--- a/plc4net/spi/spi/model/values/PlcDATE.cs
+++ b/plc4net/spi/model/values/PlcDATE.cs
@@ -18,36 +18,65 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcDATE : PlcValueAdapter
+    /// <summary>IEC 61131 <c>DATE</c> - a calendar date, no time of day.</summary>
+    public class PlcDATE : PlcSimpleValueAdapter
     {
-        private DateTime value;
-        
-        public PlcDATE(DateTime value)
+        private static readonly DateOnly Epoch = new DateOnly(1970, 1, 1);
+
+        private readonly DateOnly value;
+
+        public PlcDATE(DateOnly value)
         {
             this.value = value;
         }
 
+        public static PlcDATE OfDaysSinceEpoch(int daysSinceEpoch) =>
+            new PlcDATE(Epoch.AddDays(daysSinceEpoch));
+
+        public int GetDaysSinceEpoch()
+        {
+            return value.DayNumber - Epoch.DayNumber;
+        }
+
+        public override bool IsDate()
+        {
+            return true;
+        }
+
+        public override DateOnly GetDate()
+        {
+            return value;
+        }
+
+        public override bool IsDateTime()
+        {
+            return true;
+        }
+
+        public override DateTime GetDateTime()
+        {
+            return value.ToDateTime(TimeOnly.MinValue);
+        }
+
         protected bool Equals(PlcDATE other)
         {
             return value.Equals(other.value);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcDATE) obj);
+            return Equals((PlcDATE)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/model/values/PlcDATE_AND_LTIME.cs b/plc4net/spi/model/values/PlcDATE_AND_LTIME.cs
new file mode 100644
index 0000000000..98bfc27f4e
--- /dev/null
+++ b/plc4net/spi/model/values/PlcDATE_AND_LTIME.cs
@@ -0,0 +1,127 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.spi.generation;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    /// <summary>
+    /// IEC 61131 <c>DATE_AND_LTIME</c> (S7 <c>DTL</c>) - a full instant with
+    /// nanosecond-of-second precision. The whole-second part is a
+    /// <see cref="DateTime"/>; the sub-second part is kept exactly as
+    /// <see cref="GetNanosecondsOfSecond"/> so both the segmented (DTL) and the
+    /// nanoseconds-since-epoch wire forms round-trip.
+    /// </summary>
+    public class PlcDATE_AND_LTIME : PlcSimpleValueAdapter
+    {
+        private const long NanosPerSecond = 1_000_000_000L;
+
+        private readonly DateTime wholeSeconds;
+        private readonly uint nanosecondsOfSecond;
+
+        public PlcDATE_AND_LTIME(DateTime dateTime, uint nanosecondsOfSecond)
+        {
+            // Split the instant into whole seconds + a nanoseconds-of-second so
+            // both wire forms round-trip; any sub-second part carried by the
+            // DateTime is folded in rather than dropped. Kind is normalised to
+            // Unspecified to match plc4j's zoneless LocalDateTime semantics -
+            // otherwise the two factories below disagree (UnixEpoch is Utc,
+            // `new DateTime(y,m,d,…)` is Unspecified).
+            if (nanosecondsOfSecond > 999_999_999)
+                throw new ArgumentOutOfRangeException(nameof(nanosecondsOfSecond),
+                    $"nanosecondsOfSecond must be in [0, 999_999_999], got {nanosecondsOfSecond}.");
+            var subSecondTicks = dateTime.Ticks % TimeSpan.TicksPerSecond;
+            var nanoFromTicks = (uint)(subSecondTicks * 100);
+            if (nanosecondsOfSecond + nanoFromTicks > 999_999_999)
+                throw new ArgumentOutOfRangeException(nameof(nanosecondsOfSecond),
+                    $"nanosecondsOfSecond ({nanosecondsOfSecond}) + subsecond ticks ({nanoFromTicks}) exceeds 999_999_999.");
+            wholeSeconds = DateTime.SpecifyKind(
+                new DateTime(dateTime.Ticks - subSecondTicks), DateTimeKind.Unspecified);
+            this.nanosecondsOfSecond = nanosecondsOfSecond + nanoFromTicks;
+        }
+
+        public static PlcDATE_AND_LTIME OfNanosecondsSinceEpoch(ulong nanosecondsSinceEpoch)
+        {
+            var seconds = (long)(nanosecondsSinceEpoch / NanosPerSecond);
+            var nanos = (uint)(nanosecondsSinceEpoch % NanosPerSecond);
+            return new PlcDATE_AND_LTIME(DateTime.UnixEpoch.AddSeconds(seconds), nanos);
+        }
+
+        public static PlcDATE_AND_LTIME OfSegments(
+            int year, int month, int day, int hour, int minutes, int seconds, long nanoseconds)
+        {
+            try
+            {
+                if (nanoseconds < 0 || nanoseconds > 999_999_999)
+                    throw new ArgumentOutOfRangeException(nameof(nanoseconds),
+                        $"nanoseconds must be in [0, 999_999_999], got {nanoseconds}.");
+                return new PlcDATE_AND_LTIME(
+                    new DateTime(year, month == 0 ? 1 : month, day == 0 ? 1 : day, hour, minutes, seconds),
+                    (uint)nanoseconds);
+            }
+            catch (ArgumentOutOfRangeException e)
+            {
+                throw new ParseException(
+                    $"DATE_AND_LTIME {year:D4}-{month:D2}-{day:D2} "
+                    + $"{hour:D2}:{minutes:D2}:{seconds:D2} is not a valid instant.", e);
+            }
+        }
+
+        public uint GetNanosecondsOfSecond()
+        {
+            return nanosecondsOfSecond;
+        }
+
+        public ulong GetNanosecondsSinceEpoch()
+        {
+            var seconds = (wholeSeconds.Ticks - DateTime.UnixEpoch.Ticks) / TimeSpan.TicksPerSecond;
+            return (ulong)seconds * (ulong)NanosPerSecond + nanosecondsOfSecond;
+        }
+
+        public override bool IsDateTime()
+        {
+            return true;
+        }
+
+        public override DateTime GetDateTime()
+        {
+            return wholeSeconds.AddTicks(nanosecondsOfSecond / 100);
+        }
+
+        protected bool Equals(PlcDATE_AND_LTIME other)
+        {
+            return wholeSeconds.Equals(other.wholeSeconds)
+                && nanosecondsOfSecond == other.nanosecondsOfSecond;
+        }
+
+        public override bool Equals(object? obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != this.GetType()) return false;
+            return Equals((PlcDATE_AND_LTIME)obj);
+        }
+
+        public override int GetHashCode()
+        {
+            return HashCode.Combine(wholeSeconds, nanosecondsOfSecond);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs b/plc4net/spi/model/values/PlcDATE_AND_TIME.cs
similarity index 53%
copy from plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs
copy to plc4net/spi/model/values/PlcDATE_AND_TIME.cs
index c1a478ed2a..2594ca7522 100644
--- a/plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs
+++ b/plc4net/spi/model/values/PlcDATE_AND_TIME.cs
@@ -18,35 +18,64 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
+using org.apache.plc4net.spi.generation;
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcDATE_AND_TIME : PlcValueAdapter
+    /// <summary>IEC 61131 <c>DATE_AND_TIME</c> (S7 <c>DT</c>) - a full instant,
+    /// millisecond resolution.</summary>
+    public class PlcDATE_AND_TIME : PlcSimpleValueAdapter
     {
-        private DateTime value;
+        private readonly DateTime value;
+
         public PlcDATE_AND_TIME(DateTime value)
         {
             this.value = value;
         }
 
+        public static PlcDATE_AND_TIME OfSegments(
+            int year, int month, int day, int hour, int minutes, int seconds, int nanoseconds)
+        {
+            try
+            {
+                return new PlcDATE_AND_TIME(new DateTime(
+                        year, month == 0 ? 1 : month, day == 0 ? 1 : day, hour, minutes, seconds)
+                    .AddTicks(nanoseconds / 100));
+            }
+            catch (ArgumentOutOfRangeException e)
+            {
+                throw new ParseException(
+                    $"DATE_AND_TIME {year:D4}-{month:D2}-{day:D2} "
+                    + $"{hour:D2}:{minutes:D2}:{seconds:D2} is not a valid instant.", e);
+            }
+        }
+
+        public override bool IsDateTime()
+        {
+            return true;
+        }
+
+        public override DateTime GetDateTime()
+        {
+            return value;
+        }
+
         protected bool Equals(PlcDATE_AND_TIME other)
         {
             return value.Equals(other.value);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcDATE_AND_TIME) obj);
+            return Equals((PlcDATE_AND_TIME)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcDINT.cs b/plc4net/spi/model/values/PlcDINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcDINT.cs
rename to plc4net/spi/model/values/PlcDINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcRawByteArray.cs b/plc4net/spi/model/values/PlcDWORD.cs
similarity index 74%
rename from plc4net/spi/spi/model/values/PlcRawByteArray.cs
rename to plc4net/spi/model/values/PlcDWORD.cs
index 3585bfccd7..322b167fa2 100644
--- a/plc4net/spi/spi/model/values/PlcRawByteArray.cs
+++ b/plc4net/spi/model/values/PlcDWORD.cs
@@ -19,32 +19,28 @@
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcRawByteArray : PlcValueAdapter
+    public class PlcDWORD : PlcBitString
     {
-        private byte[] value;
-
-        public PlcRawByteArray(byte[] value)
+        public PlcDWORD(uint value) : base(value)
         {
-            this.value = value;
         }
-        
-        protected bool Equals(PlcRawByteArray other)
+
+        protected bool Equals(PlcDWORD other)
         {
-            return value == other.value;
+            return GetUint() == other.GetUint();
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return value.Equals(obj as byte[]);
+            return Equals((PlcDWORD)obj);
         }
 
         public override int GetHashCode()
         {
-            return value.GetHashCode();
+            return GetUint().GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcINT.cs b/plc4net/spi/model/values/PlcINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcINT.cs
rename to plc4net/spi/model/values/PlcINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcLINT.cs b/plc4net/spi/model/values/PlcLINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcLINT.cs
rename to plc4net/spi/model/values/PlcLINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcLREAL.cs b/plc4net/spi/model/values/PlcLREAL.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcLREAL.cs
rename to plc4net/spi/model/values/PlcLREAL.cs
diff --git a/plc4net/spi/spi/model/values/PlcLTIME.cs b/plc4net/spi/model/values/PlcLTIME.cs
similarity index 51%
rename from plc4net/spi/spi/model/values/PlcLTIME.cs
rename to plc4net/spi/model/values/PlcLTIME.cs
index 7a425d3abd..504dceb660 100644
--- a/plc4net/spi/spi/model/values/PlcLTIME.cs
+++ b/plc4net/spi/model/values/PlcLTIME.cs
@@ -18,36 +18,60 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
+    /// <summary>
+    /// IEC 61131 <c>LTIME</c> - a duration in nanoseconds. The raw
+    /// <see cref="GetNanoseconds"/> is exact; <see cref="GetDuration"/> rounds
+    /// to the 100 ns <see cref="TimeSpan"/> tick.
+    /// </summary>
     public class PlcLTIME : PlcSimpleValueAdapter
     {
-        private DateTime value;
-        
-        public PlcLTIME(DateTime value)
+        private readonly ulong nanoseconds;
+
+        public PlcLTIME(ulong nanoseconds)
+        {
+            this.nanoseconds = nanoseconds;
+        }
+
+        public static PlcLTIME OfNanoseconds(ulong nanoseconds) => new PlcLTIME(nanoseconds);
+
+        public ulong GetNanoseconds()
+        {
+            return nanoseconds;
+        }
+
+        public override bool IsDuration()
+        {
+            return true;
+        }
+
+        public override TimeSpan GetDuration()
         {
-            this.value = value;
+            const long MaxTicks = long.MaxValue;
+            if (nanoseconds > unchecked((ulong)MaxTicks * 100))
+                throw new OverflowException(
+                    $"LTIME duration {nanoseconds} ns exceeds TimeSpan.MaxValue ({MaxTicks} ticks).");
+            return TimeSpan.FromTicks((long)(nanoseconds / 100));
         }
 
         protected bool Equals(PlcLTIME other)
         {
-            return value.Equals(other.value);
+            return nanoseconds == other.nanoseconds;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcLTIME) obj);
+            return Equals((PlcLTIME)obj);
         }
 
         public override int GetHashCode()
         {
-            return value.GetHashCode();
+            return nanoseconds.GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/model/values/PlcLTIME_OF_DAY.cs b/plc4net/spi/model/values/PlcLTIME_OF_DAY.cs
new file mode 100644
index 0000000000..98292af2f5
--- /dev/null
+++ b/plc4net/spi/model/values/PlcLTIME_OF_DAY.cs
@@ -0,0 +1,89 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.spi.generation;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    /// <summary>
+    /// IEC 61131 <c>LTIME_OF_DAY</c> - a wall-clock time in nanoseconds since
+    /// midnight. The raw <see cref="GetNanosecondsSinceMidnight"/> is exact;
+    /// <see cref="GetTime"/> rounds to the 100 ns <see cref="TimeOnly"/> tick.
+    /// </summary>
+    public class PlcLTIME_OF_DAY : PlcSimpleValueAdapter
+    {
+        private readonly ulong nanosecondsSinceMidnight;
+
+        public PlcLTIME_OF_DAY(ulong nanosecondsSinceMidnight)
+        {
+            if (nanosecondsSinceMidnight >= NanosPerDay)
+                throw new ArgumentOutOfRangeException(nameof(nanosecondsSinceMidnight),
+                    $"nanosecondsSinceMidnight must be < {NanosPerDay} ns/day, got {nanosecondsSinceMidnight}.");
+            this.nanosecondsSinceMidnight = nanosecondsSinceMidnight;
+        }
+
+        private const ulong NanosPerDay = 86_400_000_000_000UL;
+
+        public static PlcLTIME_OF_DAY OfNanosecondsSinceMidnight(ulong nanosecondsSinceMidnight)
+        {
+            if (nanosecondsSinceMidnight >= NanosPerDay)
+            {
+                throw new ParseException(
+                    $"LTIME_OF_DAY {nanosecondsSinceMidnight} ns is not within a 24-hour day.");
+            }
+            return new PlcLTIME_OF_DAY(nanosecondsSinceMidnight);
+        }
+
+        public ulong GetNanosecondsSinceMidnight()
+        {
+            return nanosecondsSinceMidnight;
+        }
+
+        public override bool IsTime()
+        {
+            return true;
+        }
+
+        public override TimeOnly GetTime()
+        {
+            var maxTicksAsNanos = unchecked((ulong)long.MaxValue * 100);
+            var ns = Math.Min(nanosecondsSinceMidnight, Math.Min(NanosPerDay - 1, maxTicksAsNanos));
+            return TimeOnly.FromTimeSpan(TimeSpan.FromTicks((long)(ns / 100)));
+        }
+
+        protected bool Equals(PlcLTIME_OF_DAY other)
+        {
+            return nanosecondsSinceMidnight == other.nanosecondsSinceMidnight;
+        }
+
+        public override bool Equals(object? obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != this.GetType()) return false;
+            return Equals((PlcLTIME_OF_DAY)obj);
+        }
+
+        public override int GetHashCode()
+        {
+            return nanosecondsSinceMidnight.GetHashCode();
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcBOOL.cs b/plc4net/spi/model/values/PlcLWORD.cs
similarity index 73%
rename from plc4net/spi/spi/model/values/PlcBOOL.cs
rename to plc4net/spi/model/values/PlcLWORD.cs
index cea3965767..a611c4a6f1 100644
--- a/plc4net/spi/spi/model/values/PlcBOOL.cs
+++ b/plc4net/spi/model/values/PlcLWORD.cs
@@ -17,36 +17,30 @@
 // under the License.
 //
 
-using org.apache.plc4net.api.value;
-
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcBOOL : PlcSimpleValueAdapter
+    public class PlcLWORD : PlcBitString
     {
-        private bool value;
-        
-        public PlcBOOL(bool value)
+        public PlcLWORD(ulong value) : base(value)
         {
-            this.value = value;
         }
 
-        protected bool Equals(PlcBOOL other)
+        protected bool Equals(PlcLWORD other)
         {
-            return value == other.value;
+            return GetUlong() == other.GetUlong();
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcBOOL) obj);
+            return Equals((PlcLWORD)obj);
         }
 
         public override int GetHashCode()
         {
-            return value.GetHashCode();
+            return GetUlong().GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcPlcList.cs b/plc4net/spi/model/values/PlcList.cs
similarity index 58%
rename from plc4net/spi/spi/model/values/PlcPlcList.cs
rename to plc4net/spi/model/values/PlcList.cs
index 156ae5e360..847536bc94 100644
--- a/plc4net/spi/spi/model/values/PlcPlcList.cs
+++ b/plc4net/spi/model/values/PlcList.cs
@@ -18,35 +18,64 @@
 //
 
 using System.Collections.Generic;
+using System.Linq;
 using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
+    /// <summary>An ordered list of <see cref="IPlcValue"/> - a KNX group of
+    /// datapoints, a Modbus multi-register read.</summary>
     public class PlcList : PlcValueAdapter
     {
-        private List<IPlcValue> values;
-        
+        private readonly List<IPlcValue> values;
+
         public PlcList(List<IPlcValue> values)
         {
-            this.values = values;
+            this.values = values ?? new List<IPlcValue>();
+        }
+
+        public override bool IsList()
+        {
+            return true;
+        }
+
+        public override int GetLength()
+        {
+            return values.Count;
+        }
+
+        public override IPlcValue GetIndex(int index)
+        {
+            return values[index];
+        }
+
+        public override List<IPlcValue> GetList()
+        {
+            return values;
         }
 
         protected bool Equals(PlcList other)
         {
-            return Equals(values, other.values);
+            return values.SequenceEqual(other.values);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcList) obj);
+            return Equals((PlcList)obj);
         }
 
         public override int GetHashCode()
         {
-            return (values != null ? values.GetHashCode() : 0);
+            var hash = 17;
+            foreach (var value in values)
+            {
+                hash = hash * 31 + (value != null ? value.GetHashCode() : 0);
+            }
+
+            return hash;
         }
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs b/plc4net/spi/model/values/PlcNULL.cs
similarity index 59%
rename from plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs
rename to plc4net/spi/model/values/PlcNULL.cs
index c1a478ed2a..d238047dc0 100644
--- a/plc4net/spi/spi/model/values/PlcDATE_AND_TIME.cs
+++ b/plc4net/spi/model/values/PlcNULL.cs
@@ -17,36 +17,48 @@
 // under the License.
 //
 
-using System;
 using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcDATE_AND_TIME : PlcValueAdapter
+    /// <summary>
+    /// The absence of a value — the fall-through result of a <c>[dataIo]</c>
+    /// parse and of an unresolved lookup. All instances are equal.
+    /// </summary>
+    public class PlcNULL : PlcValueAdapter
     {
-        private DateTime value;
-        public PlcDATE_AND_TIME(DateTime value)
+        public PlcNULL()
         {
-            this.value = value;
         }
 
-        protected bool Equals(PlcDATE_AND_TIME other)
+        public override bool IsNull()
         {
-            return value.Equals(other.value);
+            return true;
         }
 
-        public override bool Equals(object obj)
+        public override bool IsNullable()
         {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcDATE_AND_TIME) obj);
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return "null";
+        }
+
+        public override bool Equals(object? obj)
+        {
+            return obj is PlcNULL;
         }
 
         public override int GetHashCode()
         {
-            return value.GetHashCode();
+            return 0;
+        }
+
+        public override string ToString()
+        {
+            return "PlcNULL";
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcREAL.cs b/plc4net/spi/model/values/PlcREAL.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcREAL.cs
rename to plc4net/spi/model/values/PlcREAL.cs
diff --git a/plc4net/spi/spi/model/values/PlcBYTE.cs b/plc4net/spi/model/values/PlcRawByteArray.cs
similarity index 58%
rename from plc4net/spi/spi/model/values/PlcBYTE.cs
rename to plc4net/spi/model/values/PlcRawByteArray.cs
index b96869e9d1..678bcce26a 100644
--- a/plc4net/spi/spi/model/values/PlcBYTE.cs
+++ b/plc4net/spi/model/values/PlcRawByteArray.cs
@@ -17,62 +17,54 @@
 // under the License.
 //
 
-using org.apache.plc4net.api.value;
+using System;
+using System.Linq;
 
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcBYTE : PlcBitString
+    /// <summary>An opaque run of bytes - a KNX property's raw group address, a
+    /// datapoint field with no finer structure.</summary>
+    public class PlcRawByteArray : PlcValueAdapter
     {
-        private byte value;
+        private readonly byte[] value;
 
-        public PlcBYTE(byte value)
+        public PlcRawByteArray(byte[] value)
         {
-            this.value = value;
+            this.value = value ?? Array.Empty<byte>();
         }
 
-        public new int GetBoolLength()
+        public override byte[] GetRaw()
         {
-            return 8;
+            return value;
         }
 
-        public new bool GetBoolAt(int index)
+        public override int GetLength()
         {
-            if (index > 7)
-            {
-                return false;
-            }
-
-            return ((value >> index) & 1) == 1;
-        }
-
-        public new bool[] GetBoolArray()
-        {
-            return new[]
-            {
-                GetBoolAt(0), GetBoolAt(1),
-                GetBoolAt(2), GetBoolAt(3),
-                GetBoolAt(4), GetBoolAt(5),
-                GetBoolAt(6), GetBoolAt(7)
-            };
+            return value.Length;
         }
 
-        protected bool Equals(PlcBYTE other)
+        protected bool Equals(PlcRawByteArray other)
         {
-            return value == other.value;
+            return value.SequenceEqual(other.value);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcBYTE) obj);
+            return Equals((PlcRawByteArray)obj);
         }
 
         public override int GetHashCode()
         {
-            return value.GetHashCode();
+            var hash = 17;
+            foreach (var b in value)
+            {
+                hash = hash * 31 + b;
+            }
+
+            return hash;
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcSINT.cs b/plc4net/spi/model/values/PlcSINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcSINT.cs
rename to plc4net/spi/model/values/PlcSINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcSTRING.cs b/plc4net/spi/model/values/PlcSTRING.cs
similarity index 84%
rename from plc4net/spi/spi/model/values/PlcSTRING.cs
rename to plc4net/spi/model/values/PlcSTRING.cs
index 1362c86c25..00f6a4e5c2 100644
--- a/plc4net/spi/spi/model/values/PlcSTRING.cs
+++ b/plc4net/spi/model/values/PlcSTRING.cs
@@ -24,29 +24,39 @@ namespace org.apache.plc4net.spi.model.values
     public class PlcSTRING : PlcSimpleValueAdapter
     {
         private string value;
-        
+
         public PlcSTRING(string value)
         {
             this.value = value;
         }
 
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return value;
+        }
+
         protected bool Equals(PlcSTRING other)
         {
             return value == other.value;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcSTRING) obj);
+            return Equals((PlcSTRING)obj);
         }
 
         public override int GetHashCode()
         {
             return (value != null ? value.GetHashCode() : 0);
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/model/values/PlcSimpleNumericValueAdapter.cs b/plc4net/spi/model/values/PlcSimpleNumericValueAdapter.cs
new file mode 100644
index 0000000000..beb0a96687
--- /dev/null
+++ b/plc4net/spi/model/values/PlcSimpleNumericValueAdapter.cs
@@ -0,0 +1,253 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Globalization;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    public abstract class SimpleNumericValueAdapter<T> : PlcSimpleValueAdapter where T : IComparable
+    {
+        private readonly IComparable value;
+
+        protected SimpleNumericValueAdapter(IComparable value)
+        {
+            this.value = value;
+        }
+
+        /// <summary>
+        /// The boxed value, widened to decimal for range checks.
+        /// </summary>
+        /// <remarks>
+        /// Range checks cannot go through <see cref="IComparable.CompareTo"/>: comparing a
+        /// boxed Int32 against a UInt64 bound throws ArgumentException, because CompareTo
+        /// requires both operands to be the same runtime type. Widening to a common type
+        /// first is what makes the comparison well defined.
+        ///
+        /// decimal covers every integral PLC type exactly, including ulong.MaxValue. It
+        /// cannot hold the outer range of float/double, so an out-of-range REAL/LREAL
+        /// reports false rather than throwing.
+        /// </remarks>
+        private bool TryAsDecimal(out decimal result)
+        {
+            try
+            {
+                result = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
+                return true;
+            }
+            catch (OverflowException)
+            {
+                result = default;
+                return false;
+            }
+            catch (InvalidCastException)
+            {
+                result = default;
+                return false;
+            }
+        }
+
+        private bool InRange(decimal min, decimal max)
+        {
+            return TryAsDecimal(out var d) && d >= min && d <= max;
+        }
+
+        public override bool IsBool()
+        {
+            return true;
+        }
+
+        public override bool GetBool()
+        {
+            return TryAsDecimal(out var d) ? d != 0m : false;
+        }
+
+        public override bool IsByte()
+        {
+            return InRange(byte.MinValue, byte.MaxValue);
+        }
+
+        public override byte GetByte()
+        {
+            if (!IsByte())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToByte(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsUshort()
+        {
+            return InRange(ushort.MinValue, ushort.MaxValue);
+        }
+
+        public override ushort GetUshort()
+        {
+            if (!IsUshort())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToUInt16(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsUint()
+        {
+            return InRange(uint.MinValue, uint.MaxValue);
+        }
+
+        public override uint GetUint()
+        {
+            if (!IsUint())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToUInt32(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsUlong()
+        {
+            return InRange(ulong.MinValue, ulong.MaxValue);
+        }
+
+        public override ulong GetUlong()
+        {
+            if (!IsUlong())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToUInt64(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsSbyte()
+        {
+            return InRange(sbyte.MinValue, sbyte.MaxValue);
+        }
+
+        public override sbyte GetSbyte()
+        {
+            if (!IsSbyte())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToSByte(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsShort()
+        {
+            return InRange(short.MinValue, short.MaxValue);
+        }
+
+        public override short GetShort()
+        {
+            if (!IsShort())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToInt16(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsInt()
+        {
+            return InRange(int.MinValue, int.MaxValue);
+        }
+
+        public override int GetInt()
+        {
+            if (!IsInt())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToInt32(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsLong()
+        {
+            return InRange(long.MinValue, long.MaxValue);
+        }
+
+        public override long GetLong()
+        {
+            if (!IsLong())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToInt64(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsFloat()
+        {
+            var d = Convert.ToDouble(value, CultureInfo.InvariantCulture);
+            // NaN / ±Infinity are representable as a float; only a finite value
+            // outside the float range is not.
+            return double.IsNaN(d) || double.IsInfinity(d)
+                   || (d >= -float.MaxValue && d <= float.MaxValue);
+        }
+
+        public override float GetFloat()
+        {
+            if (!IsFloat())
+            {
+                throw new ArgumentOutOfRangeException(nameof(value));
+            }
+            return Convert.ToSingle(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsDouble()
+        {
+            return true;
+        }
+
+        public override double GetDouble()
+        {
+            return Convert.ToDouble(value, CultureInfo.InvariantCulture);
+        }
+
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            // Convert.ToString only yields null for a null reference. Every subclass
+            // stores a numeric value type, but the IComparable constraint on T does
+            // not enforce that, so fall back to empty to keep the non-null contract.
+            return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
+        }
+
+        protected bool Equals(SimpleNumericValueAdapter<T> other)
+        {
+            return Equals(value, other.value);
+        }
+
+        public override bool Equals(object? obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != this.GetType()) return false;
+            return Equals((SimpleNumericValueAdapter<T>)obj);
+        }
+
+        public override int GetHashCode()
+        {
+            return (value != null ? value.GetHashCode() : 0);
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcSimpleValueAdapter.cs b/plc4net/spi/model/values/PlcSimpleValueAdapter.cs
similarity index 92%
rename from plc4net/spi/spi/model/values/PlcSimpleValueAdapter.cs
rename to plc4net/spi/model/values/PlcSimpleValueAdapter.cs
index 4cd0a10b97..1be08f64e7 100644
--- a/plc4net/spi/spi/model/values/PlcSimpleValueAdapter.cs
+++ b/plc4net/spi/model/values/PlcSimpleValueAdapter.cs
@@ -21,16 +21,16 @@ namespace org.apache.plc4net.spi.model.values
 {
     public abstract class PlcSimpleValueAdapter : PlcValueAdapter
     {
-       
-        public  bool IsSimple()
+
+        public override bool IsSimple()
         {
             return true;
         }
 
-        public int GetLength()
+        public override int GetLength()
         {
             return 1;
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/model/values/PlcStruct.cs b/plc4net/spi/model/values/PlcStruct.cs
new file mode 100644
index 0000000000..94368066db
--- /dev/null
+++ b/plc4net/spi/model/values/PlcStruct.cs
@@ -0,0 +1,91 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.api.value;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    /// <summary>A named group of <see cref="IPlcValue"/> - the shape a KNX
+    /// composite datapoint (<c>DPT_Switch_Control</c>, <c>DPT_DateTime</c>)
+    /// parses to.</summary>
+    public class PlcStruct : PlcValueAdapter
+    {
+        private readonly Dictionary<string, IPlcValue> values;
+
+        public PlcStruct(Dictionary<string, IPlcValue> values)
+        {
+            this.values = values ?? new Dictionary<string, IPlcValue>();
+        }
+
+        public override bool IsStruct()
+        {
+            return true;
+        }
+
+        public override string[] GetKeys()
+        {
+            return values.Keys.ToArray();
+        }
+
+        public override bool HasKey(string key)
+        {
+            return values.ContainsKey(key);
+        }
+
+        public override IPlcValue? GetValue(string key)
+        {
+            return values.TryGetValue(key, out var value) ? value : null;
+        }
+
+        public override Dictionary<string, IPlcValue> GetStruct()
+        {
+            return values;
+        }
+
+        protected bool Equals(PlcStruct other)
+        {
+            return values.Count == other.values.Count
+                   && values.All(kv => other.values.TryGetValue(kv.Key, out var v)
+                                       && Equals(kv.Value, v));
+        }
+
+        public override bool Equals(object? obj)
+        {
+            if (ReferenceEquals(null, obj)) return false;
+            if (ReferenceEquals(this, obj)) return true;
+            if (obj.GetType() != this.GetType()) return false;
+            return Equals((PlcStruct)obj);
+        }
+
+        public override int GetHashCode()
+        {
+            var hash = 17;
+            foreach (var kv in values.OrderBy(kv => kv.Key, System.StringComparer.Ordinal))
+            {
+                hash = hash * 31 + kv.Key.GetHashCode();
+                hash = hash * 31 + (kv.Value != null ? kv.Value.GetHashCode() : 0);
+            }
+
+            return hash;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcTIME.cs b/plc4net/spi/model/values/PlcTIME.cs
similarity index 72%
rename from plc4net/spi/spi/model/values/PlcTIME.cs
rename to plc4net/spi/model/values/PlcTIME.cs
index 41a412b20a..984b3820f1 100644
--- a/plc4net/spi/spi/model/values/PlcTIME.cs
+++ b/plc4net/spi/model/values/PlcTIME.cs
@@ -18,36 +18,48 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
 
 namespace org.apache.plc4net.spi.model.values
 {
+    /// <summary>IEC 61131 <c>TIME</c> - a duration, millisecond resolution.</summary>
     public class PlcTIME : PlcSimpleValueAdapter
     {
-        private TimeSpan value;
-         
+        private readonly TimeSpan value;
+
         public PlcTIME(TimeSpan value)
         {
             this.value = value;
         }
 
+        public static PlcTIME OfMilliseconds(long milliseconds) =>
+            new PlcTIME(TimeSpan.FromMilliseconds(milliseconds));
+
+        public override bool IsDuration()
+        {
+            return true;
+        }
+
+        public override TimeSpan GetDuration()
+        {
+            return value;
+        }
+
         protected bool Equals(PlcTIME other)
         {
             return value.Equals(other.value);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcTIME) obj);
+            return Equals((PlcTIME)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcTIME _OF_DAY.cs b/plc4net/spi/model/values/PlcTIME_OF_DAY.cs
similarity index 53%
rename from plc4net/spi/spi/model/values/PlcTIME _OF_DAY.cs
rename to plc4net/spi/model/values/PlcTIME_OF_DAY.cs
index 5144bc683d..442e5c0999 100644
--- a/plc4net/spi/spi/model/values/PlcTIME _OF_DAY.cs	
+++ b/plc4net/spi/model/values/PlcTIME_OF_DAY.cs
@@ -18,36 +18,61 @@
 //
 
 using System;
-using org.apache.plc4net.api.value;
+using org.apache.plc4net.spi.generation;
 
 namespace org.apache.plc4net.spi.model.values
 {
+    /// <summary>IEC 61131 <c>TIME_OF_DAY</c> - a wall-clock time, no date.</summary>
     public class PlcTIME_OF_DAY : PlcSimpleValueAdapter
     {
-        private DateTime value;
-        
-        public PlcTIME_OF_DAY(DateTime value)
+        private const long MillisPerDay = 86_400_000L;
+
+        private readonly TimeOnly value;
+
+        public PlcTIME_OF_DAY(TimeOnly value)
         {
             this.value = value;
         }
 
+        public static PlcTIME_OF_DAY OfMillisecondsSinceMidnight(long millisecondsSinceMidnight)
+        {
+            if (millisecondsSinceMidnight < 0 || millisecondsSinceMidnight >= MillisPerDay)
+            {
+                // A corrupt frame must surface as a ParseException the codec can
+                // catch, not a framework ArgumentOutOfRangeException that escapes
+                // the receive loop.
+                throw new ParseException(
+                    $"TIME_OF_DAY {millisecondsSinceMidnight} ms is not within a 24-hour day.");
+            }
+            return new PlcTIME_OF_DAY(TimeOnly.FromTimeSpan(TimeSpan.FromMilliseconds(millisecondsSinceMidnight)));
+        }
+
+        public override bool IsTime()
+        {
+            return true;
+        }
+
+        public override TimeOnly GetTime()
+        {
+            return value;
+        }
+
         protected bool Equals(PlcTIME_OF_DAY other)
         {
             return value.Equals(other.value);
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcTIME_OF_DAY) obj);
+            return Equals((PlcTIME_OF_DAY)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcUDINT.cs b/plc4net/spi/model/values/PlcUDINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcUDINT.cs
rename to plc4net/spi/model/values/PlcUDINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcUINT.cs b/plc4net/spi/model/values/PlcUINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcUINT.cs
rename to plc4net/spi/model/values/PlcUINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcULINT.cs b/plc4net/spi/model/values/PlcULINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcULINT.cs
rename to plc4net/spi/model/values/PlcULINT.cs
diff --git a/plc4net/spi/spi/model/values/PlcUSINT.cs b/plc4net/spi/model/values/PlcUSINT.cs
similarity index 100%
rename from plc4net/spi/spi/model/values/PlcUSINT.cs
rename to plc4net/spi/model/values/PlcUSINT.cs
diff --git a/plc4net/spi/model/values/PlcValueAdapter.cs b/plc4net/spi/model/values/PlcValueAdapter.cs
new file mode 100644
index 0000000000..11753852e5
--- /dev/null
+++ b/plc4net/spi/model/values/PlcValueAdapter.cs
@@ -0,0 +1,281 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using org.apache.plc4net.api.value;
+using org.apache.plc4net.exceptions;
+
+namespace org.apache.plc4net.spi.model.values
+{
+    public abstract class PlcValueAdapter : IPlcValue
+    {
+        public virtual bool IsSimple()
+        {
+            return false;
+        }
+
+        public virtual bool IsNullable()
+        {
+            return false;
+        }
+
+        public virtual bool IsNull()
+        {
+            return false;
+        }
+
+        public virtual bool IsBool()
+        {
+            return false;
+        }
+
+        public virtual int GetBoolLength()
+        {
+            return 1;
+        }
+
+        public virtual bool GetBool()
+        {
+            return default;
+        }
+
+        public virtual bool GetBoolAt(int index)
+        {
+            if (index == 0)
+            {
+                return GetBool();
+            }
+            return default;
+        }
+
+        public virtual bool[] GetBoolArray()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} has no boolean array (check IsBool()).");
+        }
+
+        public virtual bool IsByte()
+        {
+            return false;
+        }
+
+        public virtual byte GetByte()
+        {
+            return default;
+        }
+
+        public virtual bool IsUshort()
+        {
+            return false;
+        }
+
+        public virtual ushort GetUshort()
+        {
+            return default;
+        }
+
+        public virtual bool IsUint()
+        {
+            return false;
+        }
+
+        public virtual uint GetUint()
+        {
+            return default;
+        }
+
+        public virtual bool IsUlong()
+        {
+            return false;
+        }
+
+        public virtual ulong GetUlong()
+        {
+            return default;
+        }
+
+        public virtual bool IsSbyte()
+        {
+            return false;
+        }
+
+        public virtual sbyte GetSbyte()
+        {
+            return default;
+        }
+
+        public virtual bool IsShort()
+        {
+            return false;
+        }
+
+        public virtual short GetShort()
+        {
+            return default;
+        }
+
+        public virtual bool IsInt()
+        {
+            return false;
+        }
+
+        public virtual int GetInt()
+        {
+            return default;
+        }
+
+        public virtual bool IsLong()
+        {
+            return false;
+        }
+
+        public virtual long GetLong()
+        {
+            return default;
+        }
+
+        public virtual bool IsFloat()
+        {
+            return false;
+        }
+
+        public virtual float GetFloat()
+        {
+            return default;
+        }
+
+        public virtual bool IsDouble()
+        {
+            return false;
+        }
+
+        public virtual double GetDouble()
+        {
+            return default;
+        }
+
+        public virtual bool IsString()
+        {
+            return false;
+        }
+
+        public virtual string GetString()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} has no string representation (check IsString()).");
+        }
+
+        public virtual bool IsDuration()
+        {
+            return false;
+        }
+
+        public virtual TimeSpan GetDuration()
+        {
+            return default;
+        }
+
+        public virtual bool IsDate()
+        {
+            return false;
+        }
+
+        public virtual DateOnly GetDate()
+        {
+            return default;
+        }
+
+        public virtual bool IsTime()
+        {
+            return false;
+        }
+
+        public virtual TimeOnly GetTime()
+        {
+            return default;
+        }
+
+        public virtual bool IsDateTime()
+        {
+            return false;
+        }
+
+        public virtual DateTime GetDateTime()
+        {
+            return default;
+        }
+
+        public virtual byte[] GetRaw()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} has no raw representation.");
+        }
+
+        public virtual bool IsList()
+        {
+            return false;
+        }
+
+        public virtual int GetLength()
+        {
+            return 1;
+        }
+
+        public virtual IPlcValue GetIndex(int index)
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} is not indexable (check IsList()).");
+        }
+
+        public virtual List<IPlcValue> GetList()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} is not a list (check IsList()).");
+        }
+
+        public virtual bool IsStruct()
+        {
+            return false;
+        }
+
+        public virtual string[] GetKeys()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} is not a struct (check IsStruct()).");
+        }
+
+        public virtual bool HasKey(string key)
+        {
+            return false;
+        }
+
+        public virtual IPlcValue? GetValue(string key)
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} is not a struct (check IsStruct()).");
+        }
+
+        public virtual Dictionary<string, IPlcValue> GetStruct()
+        {
+            throw new PlcIncompatibleDatatypeException(
+                $"Value of type {GetType().Name} is not a struct (check IsStruct()).");
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcWCHAR.cs b/plc4net/spi/model/values/PlcWCHAR.cs
similarity index 84%
rename from plc4net/spi/spi/model/values/PlcWCHAR.cs
rename to plc4net/spi/model/values/PlcWCHAR.cs
index e5589a8e66..73c44ddb01 100644
--- a/plc4net/spi/spi/model/values/PlcWCHAR.cs
+++ b/plc4net/spi/model/values/PlcWCHAR.cs
@@ -24,29 +24,39 @@ namespace org.apache.plc4net.spi.model.values
     public class PlcWCHAR : PlcSimpleValueAdapter
     {
         private char value;
-        
+
         public PlcWCHAR(char value)
         {
             this.value = value;
         }
 
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return value.ToString();
+        }
+
         protected bool Equals(PlcWCHAR other)
         {
             return value == other.value;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcWCHAR) obj);
+            return Equals((PlcWCHAR)obj);
         }
 
         public override int GetHashCode()
         {
             return value.GetHashCode();
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcNULL.cs b/plc4net/spi/model/values/PlcWORD.cs
similarity index 76%
rename from plc4net/spi/spi/model/values/PlcNULL.cs
rename to plc4net/spi/model/values/PlcWORD.cs
index 4ec8ec3547..6c6e1f8cd1 100644
--- a/plc4net/spi/spi/model/values/PlcNULL.cs
+++ b/plc4net/spi/model/values/PlcWORD.cs
@@ -17,33 +17,30 @@
 // under the License.
 //
 
-using org.apache.plc4net.api.value;
-
 namespace org.apache.plc4net.spi.model.values
 {
-    public class PlcNULL : PlcValueAdapter
+    public class PlcWORD : PlcBitString
     {
-        public PlcNULL()
+        public PlcWORD(ushort value) : base(value)
         {
         }
 
-        protected bool Equals(PlcNULL other)
+        protected bool Equals(PlcWORD other)
         {
-            throw new System.NotImplementedException();
+            return GetUshort() == other.GetUshort();
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcNULL) obj);
+            return Equals((PlcWORD)obj);
         }
 
         public override int GetHashCode()
         {
-            throw new System.NotImplementedException();
+            return GetUshort().GetHashCode();
         }
-        
     }
 }
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcWSTRING.cs b/plc4net/spi/model/values/PlcWSTRING.cs
similarity index 84%
rename from plc4net/spi/spi/model/values/PlcWSTRING.cs
rename to plc4net/spi/model/values/PlcWSTRING.cs
index 27766cd196..3c153208e1 100644
--- a/plc4net/spi/spi/model/values/PlcWSTRING.cs
+++ b/plc4net/spi/model/values/PlcWSTRING.cs
@@ -24,29 +24,39 @@ namespace org.apache.plc4net.spi.model.values
     public class PlcWSTRING : PlcSimpleValueAdapter
     {
         private string value;
-        
+
         public PlcWSTRING(string value)
         {
             this.value = value;
         }
 
+        public override bool IsString()
+        {
+            return true;
+        }
+
+        public override string GetString()
+        {
+            return value;
+        }
+
         protected bool Equals(PlcWSTRING other)
         {
             return value == other.value;
         }
 
-        public override bool Equals(object obj)
+        public override bool Equals(object? obj)
         {
             if (ReferenceEquals(null, obj)) return false;
             if (ReferenceEquals(this, obj)) return true;
             if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcWSTRING) obj);
+            return Equals((PlcWSTRING)obj);
         }
 
         public override int GetHashCode()
         {
             return (value != null ? value.GetHashCode() : 0);
         }
-        
+
     }
 }
\ No newline at end of file
diff --git a/plc4net/api/plc4net-api.csproj b/plc4net/spi/spi.csproj
similarity index 64%
copy from plc4net/api/plc4net-api.csproj
copy to plc4net/spi/spi.csproj
index ee7f65ff08..5ec0f8cc6c 100644
--- a/plc4net/api/plc4net-api.csproj
+++ b/plc4net/spi/spi.csproj
@@ -20,16 +20,13 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
     <PropertyGroup>
-        <TargetFramework>net452</TargetFramework>
-        <RootNamespace>org.apache.plc4net</RootNamespace>
         <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
-        <Authors>Apache PLC4X</Authors>
-        <Copyright>The Apache Software Foundation</Copyright>
-        <PackageProjectUrl>https://plc4x.apache.org</PackageProjectUrl>
-        <Title>PLC4Net: API</Title>
-        <PackageLicenseUrl>https://www.apache.org/licenses/LICENSE-2.0</PackageLicenseUrl>
-        <PackageVersion>0.8.0-SNAPSHOT</PackageVersion>
-        <LangVersion>11.0</LangVersion>
-  </PropertyGroup>
+        <AssemblyName>plc4net-spi</AssemblyName>
+        <Title>PLC4Net: SPI</Title>
+    </PropertyGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\api\api.csproj" />
+    </ItemGroup>
 
 </Project>
diff --git a/plc4net/spi/spi/generation/ParseException.cs b/plc4net/spi/spi/generation/ParseException.cs
deleted file mode 100644
index 786c9cdf25..0000000000
--- a/plc4net/spi/spi/generation/ParseException.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-namespace org.apache.plc4net.spi.generation
-{
-    public class ParseException
-    {
-        
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/generation/ReadBuffer.cs b/plc4net/spi/spi/generation/ReadBuffer.cs
deleted file mode 100644
index 28140d2902..0000000000
--- a/plc4net/spi/spi/generation/ReadBuffer.cs
+++ /dev/null
@@ -1,206 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using System;
-using System.Text;
-using Ayx.BitIO;
-
-namespace org.apache.plc4net.spi.generation
-{
-    
-    public class ReadBuffer
-    {
-        private readonly byte[] _data;
-        private readonly BitReader _reader;
-
-        public ReadBuffer(byte[] data)
-        {
-            this._data = data;
-            _reader = new BitReader(data);
-        }
-
-        public void Reset()
-        {
-            _reader.Position = 0;
-        }
-
-        public int GetPos()
-        {
-            return _reader.Position;
-        }
-
-        public byte[] GetBytes()
-        {
-            return _data;
-        }
-    
-        public int GetTotalBytes() 
-        {
-            return _data.Length;
-        }
-
-        public bool HasMore(int bitLength)
-        {
-            return bitLength < _reader.Remain;
-        }
-
-        public byte PeekByte(int offset)
-        {
-            return _data[(_reader.Position / 8) + offset];
-        }
-
-        public bool ReadBit(String logicalName)
-        {
-            return _reader.ReadBool();
-        }
-
-        public byte ReadByte(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 8)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (byte) _reader.ReadInt(bitLength);
-        }
-        
-        public ushort ReadUshort(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 16)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (ushort) _reader.ReadInt(bitLength);
-        }
-
-        public uint ReadUint(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 32)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (uint) _reader.ReadInt(bitLength);
-        }
-
-        public ulong ReadUlong(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 64)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-
-            ulong firstInt = 0;
-            if (bitLength > 32)
-            {
-                firstInt = (ulong) _reader.ReadInt(bitLength - 32) << 32;
-            }
-            return firstInt | (ulong) _reader.ReadInt(bitLength);
-        }
-
-        public sbyte ReadSbyte(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 8)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (sbyte) _reader.ReadInt(bitLength);
-        }
-        
-        public short ReadShort(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 16)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (short) _reader.ReadInt(bitLength);
-        }
-
-        public int ReadInt(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 32)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return _reader.ReadInt(bitLength);
-        }
-
-        public long ReadLong(String logicalName, int bitLength)
-        {
-            if ((bitLength < 0) || (bitLength > 64)) 
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-
-            long firstInt = 0;
-            if (bitLength > 32)
-            {
-                firstInt = (long) _reader.ReadInt(bitLength - 32) << 32;
-            }
-            return firstInt | (long) _reader.ReadInt(bitLength);
-        }
-
-        public float ReadFloat(String logicalName, int bitLength)
-        {
-            if (bitLength == 32)
-            {
-                return BitConverter.ToSingle(BitConverter.GetBytes(ReadInt(logicalName, 32)), 0);
-            }
-            // This is the format as described in the KNX spec ... it's not a real half precision floating point.
-            if (bitLength == 16)
-            {
-                bool sign = true;
-                sign = _reader.ReadBool();
-
-                var exp = _reader.ReadInt(4);
-                var mantissa = _reader.ReadInt(11);
-                // In the mantissa notation actually the first bit is omitted, we need to add it back
-                var f = 0.01 * mantissa * Math.Pow(2, exp);
-                if (sign)
-                {
-                    return (float) f * -1;
-                }
-                return (float) f;
-            }
-            throw new NotImplementedException("This encoding is currently not supported");
-        }
-
-        public double ReadDouble(String logicalName, int bitLength)
-        {
-            if (bitLength == 32)
-            {
-                return BitConverter.ToDouble(BitConverter.GetBytes(ReadInt(logicalName, 32)), 0);
-            }
-            if (bitLength == 64)
-            {
-                return BitConverter.ToDouble(BitConverter.GetBytes(ReadLong(logicalName, 64)), 0);
-            }
-            throw new NotImplementedException("This encoding is currently not supported");
-        }
-
-        public string ReadString(String logicalName, int bitLength, Encoding encoding)
-        {
-            throw new NotImplementedException("This encoding is currently not supported");
-        }
-
-        public byte[] ReadByteArray(String logicalName, int bitLength)
-        {
-            throw new NotImplementedException("This function is currently not supported");
-        }
-        
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/generation/WriteBuffer.cs b/plc4net/spi/spi/generation/WriteBuffer.cs
deleted file mode 100644
index 4bd9de1e43..0000000000
--- a/plc4net/spi/spi/generation/WriteBuffer.cs
+++ /dev/null
@@ -1,178 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using System;
-using Ayx.BitIO;
-
-namespace org.apache.plc4net.spi.generation
-{
-    public class WriteBuffer
-    {
-        private readonly BitWriter _writer;
-
-        public WriteBuffer()
-        {
-            _writer = new BitWriter();
-        }
-
-        public int GetPos()
-        {
-            return _writer.Length;
-        }
-
-        public byte[] GetBytes()
-        {
-            return _writer.GetBytes();
-        }
-    
-        public int GetTotalBytes() 
-        {
-            return _writer.Length / 8 + (_writer.Length % 8 != 0 ? 1 : 0);
-        }
-
-        public void WriteBit(String logicalName, bool value)
-        {
-            _writer.WriteBool(value);
-        }
-
-        public void WriteByte(String logicalName, int bitLength, byte value)
-        {
-            if (bitLength > 8)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteByte(value, bitLength);
-        }
-        
-        public void WriteUshort(String logicalName, int bitLength, ushort value)
-        {
-            if (bitLength > 16)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteInt(value, bitLength);
-        }
-
-        public void WriteUint(String logicalName, int bitLength, uint value)
-        {
-            if (bitLength > 32)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteInt((int) value, bitLength);
-        }
-
-        public void WriteUlong(String logicalName, int bitLength, ulong value)
-        {
-            if (bitLength > 64)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            var highInt = (int) ((value & 0xFFFFFFFF00000000) >> 32);
-            var lowInt = (int) (value & 0xFFFFFFFF);
-            if (bitLength > 32)
-            {
-                _writer.WriteInt(highInt, bitLength - 32);
-                _writer.WriteInt(lowInt, 32);
-            }
-            else
-            {
-                _writer.WriteInt(lowInt, bitLength);
-            }
-        }
-
-        public void WriteSbyte(String logicalName, int bitLength, sbyte value)
-        {
-            if (bitLength > 8)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteInt(value, bitLength);
-        }
-        
-        public void WriteShort(String logicalName, int bitLength, short value)
-        {
-            if (bitLength > 16)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteInt(value, bitLength);
-        }
-
-        public void WriteInt(String logicalName, int bitLength, int value)
-        {
-            if (bitLength > 32)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            _writer.WriteInt(value, bitLength);
-        }
-
-        public void WriteLong(String logicalName, int bitLength, long value)
-        {
-            if (bitLength > 64)
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            WriteUlong(logicalName, bitLength, (ulong) value);
-        }
-
-        public void WriteFloat(String logicalName, int bitLength, float value)
-        {
-            if (bitLength == 32)
-            {
-                var bytes = BitConverter.GetBytes(value);
-                for (var i = 0; i < bytes.Length; i++)
-                {
-                    _writer.WriteByte(8, bytes[i]);
-                }
-            }
-            else if (bitLength == 16)
-            {
-                
-            }
-            else
-            {
-                throw new NotImplementedException("This encoding is currently not supported");
-            }
-        }
-
-        public void WriteDouble(String logicalName, int bitLength, double value)
-        {
-            if (bitLength == 64)
-            {
-                var bytes = BitConverter.GetBytes(value);
-                for (var i = 0; i < bytes.Length; i++)
-                {
-                    _writer.WriteByte(8, bytes[i]);
-                }
-            }
-            else
-            {
-                throw new NotImplementedException("This encoding is currently not supported");
-            }
-        }
-
-        public void WriteString(String logicalName, int bitLength, string encoding, string value)
-        {
-            throw new NotImplementedException("This encoding is currently not supported");
-        }
-
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcDWORD.cs b/plc4net/spi/spi/model/values/PlcDWORD.cs
deleted file mode 100644
index 8999178afd..0000000000
--- a/plc4net/spi/spi/model/values/PlcDWORD.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using org.apache.plc4net.api.value;
-
-namespace org.apache.plc4net.spi.model.values
-{
-    public class PlcDWORD : PlcBitString
-    {
-        private uint value;
-        
-        public PlcDWORD(uint value)
-        {
-            this.value = value;
-        }
-        
-        public new int GetBoolLength()
-        {
-            return 32;
-        }
-        
-        public new bool GetBoolAt(int index)
-        {
-            if (index > 31)
-            {
-                return false;
-            }
-
-            return ((value >> index) & 1) == 1;
-        }
-
-        public new bool[] GetBoolArray()
-        {
-            return new[]
-            {
-                GetBoolAt(0), GetBoolAt(1),
-                GetBoolAt(2), GetBoolAt(3),
-                GetBoolAt(4), GetBoolAt(5),
-                GetBoolAt(6), GetBoolAt(7),
-                GetBoolAt(8), GetBoolAt(9),
-                GetBoolAt(10), GetBoolAt(11),
-                GetBoolAt(12), GetBoolAt(13),
-                GetBoolAt(14), GetBoolAt(15),
-                GetBoolAt(16), GetBoolAt(17),
-                GetBoolAt(18), GetBoolAt(19),
-                GetBoolAt(20), GetBoolAt(21),
-                GetBoolAt(22), GetBoolAt(23),
-                GetBoolAt(24), GetBoolAt(25),
-                GetBoolAt(26), GetBoolAt(27),
-                GetBoolAt(28), GetBoolAt(29),
-                GetBoolAt(30), GetBoolAt(31)
-            };
-        }
-
-        protected bool Equals(PlcDWORD other)
-        {
-            return value == other.value;
-        }
-
-        public override bool Equals(object obj)
-        {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcDWORD) obj);
-        }
-
-        public override int GetHashCode()
-        {
-            return (int) value;
-        }
-        
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcLWORD.cs b/plc4net/spi/spi/model/values/PlcLWORD.cs
deleted file mode 100644
index f4e21eca05..0000000000
--- a/plc4net/spi/spi/model/values/PlcLWORD.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using org.apache.plc4net.api.value;
-
-namespace org.apache.plc4net.spi.model.values
-{
-    public class PlcLWORD : PlcBitString
-    {
-        private ulong value;
-        
-        public PlcLWORD(ulong value)
-        {
-            this.value = value;
-        }
-        
-        public new int GetBoolLength()
-        {
-            return 64;
-        }
-        
-        public new bool GetBoolAt(int index)
-        {
-            if (index > 63)
-            {
-                return false;
-            }
-
-            return ((value >> index) & 1) == 1;
-        }
-
-        public new bool[] GetBoolArray()
-        {
-            return new[]
-            {
-                GetBoolAt(0), GetBoolAt(1),
-                GetBoolAt(2), GetBoolAt(3),
-                GetBoolAt(4), GetBoolAt(5),
-                GetBoolAt(6), GetBoolAt(7),
-                GetBoolAt(8), GetBoolAt(9),
-                GetBoolAt(10), GetBoolAt(11),
-                GetBoolAt(12), GetBoolAt(13),
-                GetBoolAt(14), GetBoolAt(15),
-                GetBoolAt(16), GetBoolAt(17),
-                GetBoolAt(18), GetBoolAt(19),
-                GetBoolAt(20), GetBoolAt(21),
-                GetBoolAt(22), GetBoolAt(23),
-                GetBoolAt(24), GetBoolAt(25),
-                GetBoolAt(26), GetBoolAt(27),
-                GetBoolAt(28), GetBoolAt(29),
-                GetBoolAt(30), GetBoolAt(31),
-                GetBoolAt(32), GetBoolAt(33),
-                GetBoolAt(34), GetBoolAt(35),
-                GetBoolAt(36), GetBoolAt(37),
-                GetBoolAt(38), GetBoolAt(39),
-                GetBoolAt(40), GetBoolAt(41),
-                GetBoolAt(42), GetBoolAt(43),
-                GetBoolAt(44), GetBoolAt(45),
-                GetBoolAt(46), GetBoolAt(47),
-                GetBoolAt(48), GetBoolAt(49),
-                GetBoolAt(50), GetBoolAt(51),
-                GetBoolAt(52), GetBoolAt(53),
-                GetBoolAt(54), GetBoolAt(55),
-                GetBoolAt(56), GetBoolAt(57),
-                GetBoolAt(58), GetBoolAt(59),
-                GetBoolAt(60), GetBoolAt(61),
-                GetBoolAt(62), GetBoolAt(63)
-            };
-        }
-
-        protected bool Equals(PlcLWORD other)
-        {
-            return value == other.value;
-        }
-
-        public override bool Equals(object obj)
-        {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcLWORD) obj);
-        }
-
-        public override int GetHashCode()
-        {
-            return value.GetHashCode();
-        }
-        
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcSimpleNumericValueAdapter.cs b/plc4net/spi/spi/model/values/PlcSimpleNumericValueAdapter.cs
deleted file mode 100644
index 8e8c1597fa..0000000000
--- a/plc4net/spi/spi/model/values/PlcSimpleNumericValueAdapter.cs
+++ /dev/null
@@ -1,212 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using System;
-
-namespace org.apache.plc4net.spi.model.values
-{
-    public abstract class SimpleNumericValueAdapter<T> : PlcSimpleValueAdapter where T : IComparable
-    {
-        private IComparable value;
-
-        public SimpleNumericValueAdapter(IComparable value)
-        {
-            this.value = value;
-        }
-        
-        public new bool IsBool()
-        {
-            return true;
-        }
-        
-        public new bool GetBool()
-        {
-            return value.CompareTo(0) != 0;
-        }
-        
-        public new bool IsByte()
-        {
-            return (value.CompareTo(byte.MinValue) >= 0) && (value.CompareTo(byte.MaxValue) <= 0);
-        }
-
-        public new byte GetByte()
-        {
-            if (!IsByte())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (byte) value;
-        }
-
-        public new bool IsUshort()
-        {
-            return (value.CompareTo(ushort.MinValue) >= 0) && (value.CompareTo(ushort.MaxValue) <= 0);
-        }
-
-        public new ushort GetUshort()
-        {
-            if (!IsUshort())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (ushort) value;
-        }
-
-        public new bool IsUint()
-        {
-            return (value.CompareTo(uint.MinValue) >= 0) && (value.CompareTo(uint.MaxValue) <= 0);
-        }
-
-        public new uint GetUint()
-        {
-            if (!IsUint())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (uint) value;
-        }
-
-        public new bool IsUlong()
-        {
-            return (value.CompareTo(ulong.MinValue) >= 0) && (value.CompareTo(ulong.MaxValue) <= 0);
-        }
-
-        public new ulong GetUlong()
-        {
-            if (!IsUlong())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (ulong) value;
-        }
-
-        public new bool IsSbyte()
-        {
-            return (value.CompareTo(sbyte.MinValue) >= 0) && (value.CompareTo(sbyte.MaxValue) <= 0);
-        }
-
-        public new sbyte GetSbyte()
-        {
-            if (!IsSbyte())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (sbyte) value;
-        }
-
-        public new bool IsShort()
-        {
-            return (value.CompareTo(short.MinValue) >= 0) && (value.CompareTo(short.MaxValue) <= 0);
-        }
-
-        public new short GetShort()
-        {
-            if (!IsShort())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (short) value;
-        }
-
-        public new bool IsInt()
-        {
-            return (value.CompareTo(int.MinValue) >= 0) && (value.CompareTo(int.MaxValue) <= 0);
-        }
-
-        public new int GetInt()
-        {
-            if (!IsInt())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (int) value;
-        }
-
-        public new bool IsLong()
-        {
-            return (value.CompareTo(long.MinValue) >= 0) && (value.CompareTo(long.MaxValue) <= 0);
-        }
-
-        public new long GetLong()
-        {
-            if (!IsLong())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (long) value;
-        }
-
-        public new bool IsFloat()
-        {
-            return (value.CompareTo(-float.MaxValue) >= 0) && (value.CompareTo(float.MaxValue) <= 0);
-        }
-
-        public new float GetFloat()
-        {
-            if (!IsFloat())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (float) value;
-        }
-
-        public new bool IsDouble()
-        {
-            return (value.CompareTo(-double.MaxValue) >= 0) && (value.CompareTo(double.MaxValue) <= 0);
-        }
-
-        public new double GetDouble()
-        {
-            if (!IsDouble())
-            {
-                throw new ArgumentOutOfRangeException();
-            }
-            return (double) value;
-        }
-
-        public new bool IsString()
-        {
-            return true;
-        }
-
-        public new String GetString()
-        {
-            return value.ToString();
-        }
-
-        protected bool Equals(SimpleNumericValueAdapter<T> other)
-        {
-            return Equals(value, other.value);
-        }
-
-        public override bool Equals(object obj)
-        {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((SimpleNumericValueAdapter<T>) obj);
-        }
-
-        public override int GetHashCode()
-        {
-            return (value != null ? value.GetHashCode() : 0);
-        }
-        
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcStruct.cs b/plc4net/spi/spi/model/values/PlcStruct.cs
deleted file mode 100644
index 781afd5785..0000000000
--- a/plc4net/spi/spi/model/values/PlcStruct.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using System.Collections.Generic;
-using org.apache.plc4net.api.value;
-
-namespace org.apache.plc4net.spi.model.values
-{
-    
-    public class PlcStruct : PlcValueAdapter
-    {
-        private Dictionary<string, IPlcValue> values;
-        
-        public PlcStruct(Dictionary<string, IPlcValue> values)
-        {
-            this.values = values;
-        }
-
-        protected bool Equals(PlcStruct other)
-        {
-            return Equals(values, other.values);
-        }
-
-        public override bool Equals(object obj)
-        {
-            if (ReferenceEquals(null, obj)) return false;
-            if (ReferenceEquals(this, obj)) return true;
-            if (obj.GetType() != this.GetType()) return false;
-            return Equals((PlcStruct) obj);
-        }
-
-        public override int GetHashCode()
-        {
-            return (values != null ? values.GetHashCode() : 0);
-        }
-    }
-    
-}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcValueAdapter.cs b/plc4net/spi/spi/model/values/PlcValueAdapter.cs
deleted file mode 100644
index 8ead9812a4..0000000000
--- a/plc4net/spi/spi/model/values/PlcValueAdapter.cs
+++ /dev/null
@@ -1,242 +0,0 @@
-//
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//      https://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-//
-
-using System;
-using System.Collections.Generic;
-using org.apache.plc4net.api.value;
-
-namespace org.apache.plc4net.spi.model.values
-{
-    public abstract class PlcValueAdapter : IPlcValue
-    {
-        public bool IsSimple()
-        {
-            return false;
-        }
-
-        public bool IsNullable()
-        {
-            return false;
-        }
-
-        public bool IsNull()
-        {
-            return false;
-        }
-
-        public bool IsBool()
-        {
-            return false;
-        }
-
-        public int GetBoolLength()
-        {
-            return 1;
-        }
-
-        public bool GetBool()
-        {
-            return default;
-        }
-
-        public bool GetBoolAt(int index)
-        {
-            if (index == 0)
-            {
-                return GetBool();
-            }
-            return default;
-        }
-
-        public bool[] GetBoolArray()
-        {
-            return default;
-        }
-
-        public bool IsByte()
-        {
-            return false;
-        }
-
-        public byte GetByte()
-        {
-            return default;
-        }
-
-        public bool IsUshort()
-        {
-            return false;
-        }
-
-        public ushort GetUshort()
-        {
-            return default;
-        }
-
-        public bool IsUint()
-        {
-            return false;
-        }
-
-        public uint GetUint()
-        {
-            return default;
-        }
-
-        public bool IsUlong()
-        {
-            return false;
-        }
-
-        public ulong GetUlong()
-        {
-            return default;
-        }
-
-        public bool IsSbyte()
-        {
-            return false;
-        }
-
-        public sbyte GetSbyte()
-        {
-            return default;
-        }
-
-        public bool IsShort()
-        {
-            return false;
-        }
-
-        public short GetShort()
-        {
-            return default;
-        }
-
-        public bool IsInt()
-        {
-            return false;
-        }
-
-        public int GetInt()
-        {
-            return default;
-        }
-
-        public bool IsLong()
-        {
-            return false;
-        }
-
-        public long GetLong()
-        {
-            return default;
-        }
-
-        public bool IsFloat()
-        {
-            return false;
-        }
-
-        public float GetFloat()
-        {
-            return default;
-        }
-
-        public bool IsDouble()
-        {
-            return false;
-        }
-
-        public double GetDouble()
-        {
-            return default;
-        }
-
-        public bool IsString()
-        {
-            return false;
-        }
-
-        public string GetString()
-        {
-            return default;
-        }
-
-        public bool IsDateTime()
-        {
-            return false;
-        }
-
-        public DateTime GetDateTime()
-        {
-            return default;
-        }
-
-        public byte[] GetRaw()
-        {
-            return default;
-        }
-
-        public bool IsList()
-        {
-            return false;
-        }
-
-        public int GetLength()
-        {
-            return 1;
-        }
-
-        public IPlcValue GetIndex(int index)
-        {
-            return default;
-        }
-
-        public List<IPlcValue> GetList()
-        {
-            return default;
-        }
-
-        public bool IsStruct()
-        {
-            return false;
-        }
-
-        public string[] GetKeys()
-        {
-            return default;
-        }
-
-        public bool HasKey(string key)
-        {
-            return false;
-        }
-
-        public IPlcValue GetValue(string key)
-        {
-            return default;
-        }
-
-        public Dictionary<string, IPlcValue> GetStruct()
-        {
-            return default;
-        }
-    }
-}
\ No newline at end of file
diff --git a/plc4net/spi/transports/BaseTransportInstance.cs b/plc4net/spi/transports/BaseTransportInstance.cs
new file mode 100644
index 0000000000..70489c3045
--- /dev/null
+++ b/plc4net/spi/transports/BaseTransportInstance.cs
@@ -0,0 +1,96 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+
+namespace org.apache.plc4net.spi.transports
+{
+    /// <summary>
+    /// Shared state for transport implementations: the configuration and the
+    /// driver-config carved off the connection URL.
+    /// </summary>
+    public abstract class BaseTransportInstance : ITransportInstance
+    {
+        private string _driverConfig = string.Empty;
+
+        protected BaseTransportInstance(ITransportConfiguration configuration)
+        {
+            Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+        }
+
+        public ITransportConfiguration Configuration { get; }
+
+        public string DriverConfig
+        {
+            get => _driverConfig;
+            protected set => _driverConfig = value ?? string.Empty;
+        }
+
+        /// <summary>
+        /// Records the portion of the address the transport did not consume. Transports
+        /// call this immediately after construction.
+        /// </summary>
+        public void SetDriverConfig(string driverConfig)
+        {
+            DriverConfig = driverConfig;
+        }
+
+        public abstract bool IsOpen { get; }
+
+        public abstract int GetNumBytesAvailable();
+
+        public abstract byte[] PeekReadableBytes(int numBytes);
+
+        public abstract byte[] Read(int numBytes);
+
+        public abstract void Write(byte[] bytes);
+
+        public abstract void Close();
+
+        protected void EnsureOpen()
+        {
+            if (!IsOpen)
+            {
+                throw new TransportException("Transport is closed");
+            }
+        }
+
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        protected virtual void Dispose(bool disposing)
+        {
+            if (!disposing)
+            {
+                return;
+            }
+            try
+            {
+                Close();
+            }
+            catch (TransportException)
+            {
+                // Dispose must not throw; a failure to close is already terminal.
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/transports/ITransport.cs b/plc4net/spi/transports/ITransport.cs
new file mode 100644
index 0000000000..472f5ea31d
--- /dev/null
+++ b/plc4net/spi/transports/ITransport.cs
@@ -0,0 +1,108 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+
+namespace org.apache.plc4net.spi.transports
+{
+    /// <summary>
+    /// Factory for a kind of transport (tcp, udp, serial, cotp, ...).
+    /// </summary>
+    public interface ITransport
+    {
+        /// <summary>The code used in a connection string to select this transport, e.g. "tcp".</summary>
+        string TransportCode { get; }
+
+        /// <summary>Human-readable name.</summary>
+        string TransportName { get; }
+
+        /// <summary>
+        /// Builds a configuration for this transport from the connection string's
+        /// parameters, applying this transport's defaults.
+        /// </summary>
+        ITransportConfiguration CreateConfiguration(IReadOnlyDictionary<string, string> parameters);
+
+        /// <summary>
+        /// Opens a connection.
+        /// </summary>
+        /// <param name="transportConfig">the address portion of the URL, e.g. "192.168.0.1:102"</param>
+        /// <param name="configuration">configuration produced by <see cref="CreateConfiguration"/></param>
+        ITransportInstance CreateTransportInstance(string transportConfig, ITransportConfiguration configuration);
+    }
+
+    /// <summary>Registry of the transports available to drivers.</summary>
+    public interface ITransportManager
+    {
+        /// <summary>Returns the transport for a code, or null when none is registered.</summary>
+        ITransport? GetTransport(string transportCode);
+
+        IReadOnlyCollection<string> GetTransportCodes();
+    }
+
+    /// <summary>
+    /// In-memory <see cref="ITransportManager"/>. Transports register themselves here
+    /// rather than being discovered, which keeps the .NET port free of the assembly
+    /// scanning the Java side does via ServiceLoader.
+    /// </summary>
+    public class DefaultTransportManager : ITransportManager
+    {
+        private readonly Dictionary<string, ITransport> _transports =
+            new Dictionary<string, ITransport>(StringComparer.OrdinalIgnoreCase);
+
+        public DefaultTransportManager()
+        {
+        }
+
+        public DefaultTransportManager(IEnumerable<ITransport> transports)
+        {
+            if (transports == null)
+            {
+                return;
+            }
+            foreach (var transport in transports)
+            {
+                Register(transport);
+            }
+        }
+
+        public void Register(ITransport transport)
+        {
+            if (transport == null)
+            {
+                throw new ArgumentNullException(nameof(transport));
+            }
+            _transports[transport.TransportCode] = transport;
+        }
+
+        public ITransport? GetTransport(string transportCode)
+        {
+            if (transportCode == null)
+            {
+                return null;
+            }
+            return _transports.TryGetValue(transportCode, out var transport) ? transport : null;
+        }
+
+        public IReadOnlyCollection<string> GetTransportCodes()
+        {
+            return new List<string>(_transports.Keys);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/transports/ITransportInstance.cs b/plc4net/spi/transports/ITransportInstance.cs
new file mode 100644
index 0000000000..a62e777a4f
--- /dev/null
+++ b/plc4net/spi/transports/ITransportInstance.cs
@@ -0,0 +1,95 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+
+namespace org.apache.plc4net.spi.transports
+{
+    /// <summary>
+    /// Marker for a transport's configuration object. Concrete transports define their
+    /// own (host, port, timeouts, buffer sizes, ...).
+    /// </summary>
+    public interface ITransportConfiguration
+    {
+    }
+
+    /// <summary>
+    /// A live connection to a device, exposed as a byte stream.
+    /// </summary>
+    /// <remarks>
+    /// Mirrors the Java SPI3 <c>TransportInstance</c> contract. Deliberately byte-oriented
+    /// and peekable: most industrial protocols put the message length inside a fixed-size
+    /// header, so a codec peeks the header, works out the full length, and only then
+    /// consumes the whole frame.
+    /// </remarks>
+    public interface ITransportInstance : IDisposable
+    {
+        /// <summary>The transport's configuration.</summary>
+        ITransportConfiguration Configuration { get; }
+
+        /// <summary>
+        /// The part of the connection URL the transport did not consume, which is
+        /// meaningful only to the driver. For <c>opcua:tcp://localhost:4840/milo</c> the
+        /// transport takes <c>localhost:4840</c> and this returns <c>/milo</c>. Empty
+        /// when the address carries no trailing part.
+        /// </summary>
+        string DriverConfig { get; }
+
+        /// <summary>Whether the underlying transport is still usable.</summary>
+        bool IsOpen { get; }
+
+        /// <summary>Number of bytes buffered and ready to read.</summary>
+        int GetNumBytesAvailable();
+
+        /// <summary>Returns the next bytes without advancing the read position.</summary>
+        byte[] PeekReadableBytes(int numBytes);
+
+        /// <summary>Returns the next bytes and marks them consumed.</summary>
+        byte[] Read(int numBytes);
+
+        /// <summary>Writes the given bytes to the transport.</summary>
+        void Write(byte[] bytes);
+
+        /// <summary>Closes the transport. Safe to call more than once.</summary>
+        void Close();
+    }
+
+    /// <summary>
+    /// A transport that can push a notification when bytes arrive, so the driver does not
+    /// have to poll <see cref="ITransportInstance.GetNumBytesAvailable"/>.
+    /// </summary>
+    public interface IAsyncTransportInstance : ITransportInstance
+    {
+        /// <summary>
+        /// Called after new bytes land in the buffer. Invoked on the transport's read
+        /// thread, so implementations should hand off rather than block.
+        /// </summary>
+        void RegisterDataListener(Action listener);
+
+        void RemoveDataListener();
+
+        /// <summary>
+        /// Called when the connection drops. The argument is the causing exception, or
+        /// null for an orderly close by the remote end.
+        /// </summary>
+        void RegisterDisconnectListener(Action<Exception> listener);
+
+        void RemoveDisconnectListener();
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/transports/RingBuffer.cs b/plc4net/spi/transports/RingBuffer.cs
new file mode 100644
index 0000000000..d550b999f3
--- /dev/null
+++ b/plc4net/spi/transports/RingBuffer.cs
@@ -0,0 +1,153 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+
+namespace org.apache.plc4net.spi.transports
+{
+    /// <summary>
+    /// Fixed-capacity byte ring buffer sitting between a transport's read loop and the
+    /// protocol codec that drains it.
+    /// </summary>
+    /// <remarks>
+    /// The codec needs to inspect a message header before it knows how long the message
+    /// is, so this supports <see cref="Peek"/> (look without consuming) alongside
+    /// <see cref="Read"/>. Callers are expected to hold the transport's read lock; this
+    /// type is not internally synchronised.
+    /// </remarks>
+    public class RingBuffer
+    {
+        private readonly byte[] _buffer;
+        private int _readPosition;
+        private int _writePosition;
+        private int _count;
+
+        public RingBuffer(int capacity)
+        {
+            if (capacity <= 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Capacity must be positive.");
+            }
+            _buffer = new byte[capacity];
+        }
+
+        public int Capacity => _buffer.Length;
+
+        public int AvailableForReading => _count;
+
+        public int RemainingForWriting => _buffer.Length - _count;
+
+        public void Write(byte[] data)
+        {
+            if (data == null)
+            {
+                throw new ArgumentNullException(nameof(data));
+            }
+            Write(data, 0, data.Length);
+        }
+
+        public void Write(byte[] data, int offset, int length)
+        {
+            if (data == null)
+            {
+                throw new ArgumentNullException(nameof(data));
+            }
+            if (offset < 0 || length < 0 || offset + length > data.Length)
+            {
+                throw new ArgumentOutOfRangeException(nameof(length));
+            }
+            if (length > RemainingForWriting)
+            {
+                throw new TransportException(
+                    $"Ring buffer overflow: tried to write {length} bytes, {RemainingForWriting} free.");
+            }
+
+            // At most two segments: up to the end of the backing array, then the
+            // wrapped remainder. Copying in bulk keeps the transport's read lock held
+            // for as short a time as possible.
+            var untilEnd = Math.Min(length, _buffer.Length - _writePosition);
+            Array.Copy(data, offset, _buffer, _writePosition, untilEnd);
+            if (untilEnd < length)
+            {
+                Array.Copy(data, offset + untilEnd, _buffer, 0, length - untilEnd);
+            }
+
+            _writePosition = Advance(_writePosition, length);
+            _count += length;
+        }
+
+        /// <summary>Returns the next <paramref name="numBytes"/> bytes without consuming them.</summary>
+        public byte[] Peek(int numBytes)
+        {
+            return Copy(numBytes, consume: false);
+        }
+
+        /// <summary>Returns the next <paramref name="numBytes"/> bytes and consumes them.</summary>
+        public byte[] Read(int numBytes)
+        {
+            return Copy(numBytes, consume: true);
+        }
+
+        private byte[] Copy(int numBytes, bool consume)
+        {
+            if (numBytes < 0)
+            {
+                throw new ArgumentOutOfRangeException(nameof(numBytes));
+            }
+            if (numBytes > _count)
+            {
+                throw new TransportException(
+                    $"Requested {numBytes} bytes but only {_count} available.");
+            }
+
+            var result = new byte[numBytes];
+            var untilEnd = Math.Min(numBytes, _buffer.Length - _readPosition);
+            Array.Copy(_buffer, _readPosition, result, 0, untilEnd);
+            if (untilEnd < numBytes)
+            {
+                Array.Copy(_buffer, 0, result, untilEnd, numBytes - untilEnd);
+            }
+
+            if (consume)
+            {
+                _readPosition = Advance(_readPosition, numBytes);
+                _count -= numBytes;
+            }
+            return result;
+        }
+
+        /// <summary>
+        /// Moves a position forward, wrapping at the end of the backing array.
+        /// <paramref name="delta"/> never exceeds the capacity, so the sum cannot
+        /// overflow in practice (.NET arrays are capped well below
+        /// <c>int.MaxValue/2</c>). The modulo is correct and clear.
+        /// </summary>
+        private int Advance(int position, int delta)
+        {
+            return (position + delta) % _buffer.Length;
+        }
+
+        public void Clear()
+        {
+            _readPosition = 0;
+            _writePosition = 0;
+            _count = 0;
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/spi/spi/model/values/PlcBitString.cs b/plc4net/spi/transports/TransportException.cs
similarity index 66%
copy from plc4net/spi/spi/model/values/PlcBitString.cs
copy to plc4net/spi/transports/TransportException.cs
index 42beac3bd3..482a68ae9e 100644
--- a/plc4net/spi/spi/model/values/PlcBitString.cs
+++ b/plc4net/spi/transports/TransportException.cs
@@ -17,29 +17,26 @@
 // under the License.
 //
 
-namespace org.apache.plc4net.spi.model.values
+using System;
+
+namespace org.apache.plc4net.spi.transports
 {
-    public class PlcBitString : PlcValueAdapter
+    /// <summary>
+    /// Raised when the underlying transport fails: connect, read, write or close.
+    /// </summary>
+    public class TransportException : Exception
     {
-        public PlcBitString()
-        {
-        }
-
-        public PlcBitString(byte value)
+        public TransportException()
         {
         }
 
-        public PlcBitString(ushort value)
+        public TransportException(string message) : base(message)
         {
         }
 
-        public PlcBitString(uint value)
+        public TransportException(string message, Exception innerException)
+            : base(message, innerException)
         {
         }
-
-        public PlcBitString(ulong value)
-        {
-        }
-
     }
 }
\ No newline at end of file
diff --git a/plc4net/test/spi-test/drivers/ConnectionStringTests.cs b/plc4net/test/spi-test/drivers/ConnectionStringTests.cs
new file mode 100644
index 0000000000..c9926345b8
--- /dev/null
+++ b/plc4net/test/spi-test/drivers/ConnectionStringTests.cs
@@ -0,0 +1,196 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Globalization;
+using org.apache.plc4net.exceptions;
+using org.apache.plc4net.spi.drivers;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.drivers
+{
+    public class ConnectionStringTests
+    {
+        [Fact]
+        public void Parses_the_simple_form()
+        {
+            var cs = ConnectionString.Parse("s7://192.168.0.1");
+
+            Assert.Equal("s7", cs.ProtocolCode);
+            Assert.Null(cs.TransportCode);
+            Assert.Equal("192.168.0.1", cs.TransportConfig);
+            Assert.Equal(string.Empty, cs.ParamString);
+        }
+
+        [Fact]
+        public void Parses_the_form_with_an_explicit_transport()
+        {
+            var cs = ConnectionString.Parse("s7:cotp://10.0.0.5:102");
+
+            Assert.Equal("s7", cs.ProtocolCode);
+            Assert.Equal("cotp", cs.TransportCode);
+            Assert.Equal("10.0.0.5:102", cs.TransportConfig);
+        }
+
+        [Fact]
+        public void Parses_query_parameters()
+        {
+            var cs = ConnectionString.Parse("modbus-tcp://10.0.0.9:502?unit-identifier=1&retry=true");
+
+            Assert.Equal("1", cs.GetParameter("unit-identifier"));
+            Assert.Equal("true", cs.GetParameter("retry"));
+        }
+
+        [Fact]
+        public void URL_decodes_parameter_values()
+        {
+            var cs = ConnectionString.Parse("s7://host?remote-rack=0&remote-slot=1");
+
+            Assert.Equal("0", cs.GetParameter("remote-rack"));
+            Assert.Equal("1", cs.GetParameter("remote-slot"));
+        }
+
+        [Fact]
+        public void Percent_escapes_are_resolved()
+        {
+            var cs = ConnectionString.Parse("s7://host?path=%2Fsome%2Fpath&name=a%20b");
+
+            Assert.Equal("/some/path", cs.GetParameter("path"));
+            Assert.Equal("a b", cs.GetParameter("name"));
+        }
+
+        [Fact]
+        public void A_plus_sign_decodes_to_a_space()
+        {
+            // Java hands parameter values to URLDecoder.decode(value, UTF_8), the
+            // form-urlencoded decoder, so '+' means a space there. Uri.UnescapeDataString
+            // would leave it as a literal '+' and the same connection string would
+            // address a different device from each language.
+            var cs = ConnectionString.Parse("s7://host?name=a+b");
+
+            Assert.Equal("a b", cs.GetParameter("name"));
+        }
+
+        [Fact]
+        public void Parameters_are_case_insensitive()
+        {
+            var cs = ConnectionString.Parse("x://h?Key=val");
+
+            Assert.Equal("val", cs.GetParameter("KEY"));
+            Assert.Equal("val", cs.GetParameter("key"));
+        }
+
+        [Fact]
+        public void Bare_flags_are_read_as_true()
+        {
+            var cs = ConnectionString.Parse("s7://h?verbose");
+
+            Assert.Equal("true", cs.GetParameter("verbose"));
+        }
+
+        [Fact]
+        public void GetIntParameter_parses()
+        {
+            var cs = ConnectionString.Parse("s7://h?timeout=5000");
+
+            Assert.Equal(5000, cs.GetIntParameter("timeout", 1000));
+        }
+
+        [Fact]
+        public void GetIntParameter_does_not_depend_on_the_current_culture()
+        {
+            // A locale whose number formatting differs must not change how a connection
+            // string is read. de-DE groups with '.', so a culture-sensitive parse would
+            // read "5.000" as 5000 and could misread plain digits elsewhere.
+            var previous = CultureInfo.CurrentCulture;
+            try
+            {
+                CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
+                var cs = ConnectionString.Parse("s7://h?timeout=5000");
+
+                Assert.Equal(5000, cs.GetIntParameter("timeout", 1000));
+                Assert.Equal(1000, ConnectionString.Parse("s7://h?timeout=5.000")
+                    .GetIntParameter("timeout", 1000));
+            }
+            finally
+            {
+                CultureInfo.CurrentCulture = previous;
+            }
+        }
+
+        [Fact]
+        public void GetIntParameter_falls_back_when_absent()
+        {
+            var cs = ConnectionString.Parse("s7://h");
+
+            Assert.Equal(1000, cs.GetIntParameter("timeout", 1000));
+        }
+
+        [Fact]
+        public void GetBoolParameter_works()
+        {
+            var cs = ConnectionString.Parse("s7://h?enabled=true&flag=false");
+
+            Assert.True(cs.GetBoolParameter("enabled", false));
+            Assert.False(cs.GetBoolParameter("flag", true));
+            Assert.True(cs.GetBoolParameter("missing", true));
+        }
+
+        [Fact]
+        public void RedactSecrets_masks_password_tokens_and_secrets()
+        {
+            var input = "s7://192.168.0.1?remote-rack=0&password=hunter2&token=abc123&secret=shh";
+
+            var redacted = ConnectionString.RedactSecrets(input);
+
+            Assert.Contains("password=***", redacted);
+            Assert.Contains("token=***", redacted);
+            Assert.Contains("secret=***", redacted);
+            // Non-secret parameters pass through.
+            Assert.Contains("remote-rack=0", redacted);
+        }
+
+        [Theory]
+        [InlineData("")]
+        [InlineData("   ")]
+        [InlineData(null)]
+        public void Empty_connection_strings_throw(string? input)
+        {
+            Assert.Throws<PlcConnectionException>(() => ConnectionString.Parse(input));
+        }
+
+        [Fact]
+        public void Missing_protocol_code_throws()
+        {
+            Assert.Throws<PlcConnectionException>(
+                () => ConnectionString.Parse("://host"));
+        }
+
+        [Fact]
+        public void ToString_redacts_secrets()
+        {
+            var cs = ConnectionString.Parse("s7://192.168.0.1?password=secret");
+
+            var s = cs.ToString();
+
+            Assert.DoesNotContain("secret", s);
+            Assert.Contains("password=***", s);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/spi-test/drivers/DriverBaseTests.cs b/plc4net/test/spi-test/drivers/DriverBaseTests.cs
new file mode 100644
index 0000000000..90a062e173
--- /dev/null
+++ b/plc4net/test/spi-test/drivers/DriverBaseTests.cs
@@ -0,0 +1,385 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using org.apache.plc4net.api;
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.api.metadata;
+using org.apache.plc4net.exceptions;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.model;
+using org.apache.plc4net.spi.drivers;
+using org.apache.plc4net.spi.transports;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.drivers
+{
+    /// <summary>
+    /// A minimal transport for testing driver logic without a real network.
+    /// </summary>
+    internal class FakeTransport : ITransport
+    {
+        public FakeTransport(string transportCode = "fake")
+        {
+            TransportCode = transportCode;
+        }
+
+        public string TransportCode { get; }
+        public string TransportName => "Fake Transport";
+
+        public ITransportConfiguration CreateConfiguration(IReadOnlyDictionary<string, string> parameters)
+        {
+            return new FakeTransportConfiguration();
+        }
+
+        public ITransportInstance CreateTransportInstance(
+            string transportConfig, ITransportConfiguration configuration)
+        {
+            return new FakeTransportInstance(configuration);
+        }
+    }
+
+    /// <summary>
+    /// A transport whose instance creation blocks until the test releases it, so a test can
+    /// observe what the caller does while the transport is still being created.
+    /// </summary>
+    internal class GatedTransport : ITransport
+    {
+        private readonly ManualResetEventSlim _entered;
+        private readonly ManualResetEventSlim _release;
+
+        public GatedTransport(ManualResetEventSlim entered, ManualResetEventSlim release)
+        {
+            _entered = entered;
+            _release = release;
+        }
+
+        public string TransportCode => "fake";
+        public string TransportName => "Gated Transport";
+
+        public ITransportConfiguration CreateConfiguration(IReadOnlyDictionary<string, string> parameters)
+            => new FakeTransportConfiguration();
+
+        public ITransportInstance CreateTransportInstance(
+            string transportConfig, ITransportConfiguration configuration)
+        {
+            _entered.Set();
+
+            // Bounded, so that a regression which creates the transport on the caller
+            // fails the test instead of hanging it.
+            if (!_release.Wait(TimeSpan.FromSeconds(10)))
+            {
+                throw new TimeoutException("The test never released the transport.");
+            }
+
+            return new FakeTransportInstance(configuration);
+        }
+    }
+
+    internal class FakeTransportConfiguration : ITransportConfiguration
+    {
+    }
+
+    internal class FakeTransportInstance : BaseTransportInstance
+    {
+        public FakeTransportInstance(ITransportConfiguration config) : base(config) { }
+        public override bool IsOpen => true;
+        public override int GetNumBytesAvailable() => 0;
+        public override byte[] PeekReadableBytes(int numBytes) => Array.Empty<byte>();
+        public override byte[] Read(int numBytes) => Array.Empty<byte>();
+        public override void Write(byte[] bytes) { }
+        public override void Close() { }
+    }
+
+    /// <summary>
+    /// A stub connection returned by the test driver.
+    /// </summary>
+    internal class FakeConnection : ConnectionBase
+    {
+        public FakeConnection(ConnectionString cs, ITransportInstance transport)
+            : base(cs, transport) { }
+
+        public override IPlcConnectionMetadata PlcConnectionMetadata => null!;
+        public override IPlcTag Parse(string tagQuery) => null!;
+        public override IPlcReadRequestBuilder? ReadRequestBuilder => null;
+        public override IPlcWriteRequestBuilder? WriteRequestBuilder => null;
+        public override IPlcSubscriptionRequestBuilder? SubscriptionRequestBuilder => null;
+        public override IPlcUnsubscriptionRequestBuilder? UnsubscriptionRequestBuilder => null;
+    }
+
+    /// <summary>
+    /// A stub driver wired to the "fake" transport.
+    /// </summary>
+    internal class StubDriver : DriverBase
+    {
+        private readonly string _protocolCode;
+
+        public StubDriver(ITransportManager transportManager, string protocolCode = "stub")
+            : base(transportManager)
+        {
+            _protocolCode = protocolCode;
+        }
+
+        public override string ProtocolCode => _protocolCode;
+        public override string ProtocolName => "Stub Driver";
+        public override string DefaultTransportCode => "fake";
+        protected override string[] SupportedTransportCodes => new[] { "fake" };
+
+        protected override ConnectionBase CreateConnection(
+            ConnectionString connectionString,
+            ITransportInstance transportInstance,
+            IPlcAuthentication? authentication)
+        {
+            return new FakeConnection(connectionString, transportInstance);
+        }
+    }
+
+    public class DriverBaseTests
+    {
+        [Fact]
+        public async System.Threading.Tasks.Task ConnectAsync_opens_transport_off_the_caller_thread()
+        {
+            using var entered = new ManualResetEventSlim();
+            using var release = new ManualResetEventSlim();
+            var transport = new GatedTransport(entered, release);
+            var driver = new StubDriver(new DefaultTransportManager(new[] { transport }));
+
+            // The transport cannot finish until it is released below, so ConnectAsync can only
+            // hand back a pending task if it creates the transport somewhere other than on the
+            // caller. Comparing thread ids would not show that: when the caller is a thread-pool
+            // thread, work it queues and then awaits can be picked up again by that same thread.
+            var connecting = driver.ConnectAsync("stub://192.168.0.1");
+            try
+            {
+                Assert.False(
+                    connecting.IsCompleted,
+                    "ConnectAsync finished while the transport was still being created.");
+                Assert.True(
+                    entered.Wait(TimeSpan.FromSeconds(30)),
+                    "The transport was never created.");
+            }
+            finally
+            {
+                release.Set();
+            }
+
+            using var connection = await connecting;
+            Assert.True(connection.IsConnected);
+        }
+
+        [Fact]
+        public void Connect_resolves_the_default_transport()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new StubDriver(transportManager);
+
+            var connection = driver.Connect("stub://192.168.0.1");
+            Assert.True(connection.IsConnected);
+        }
+
+        [Fact]
+        public void Connect_rejects_an_unsupported_transport()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new StubDriver(transportManager);
+
+            var ex = Assert.Throws<PlcConnectionException>(
+                () => driver.Connect("stub:unregistered://host"));
+            Assert.Contains("unregistered", ex.Message);
+        }
+
+        [Fact]
+        public void Connect_rejects_a_registered_but_unsupported_transport()
+        {
+            // "other" is registered, so it exists — but StubDriver does not list it in
+            // SupportedTransportCodes, so the driver's own check has to reject it.
+            var transportManager = new DefaultTransportManager(
+                new[] { new FakeTransport(), new FakeTransport("other") });
+            var driver = new StubDriver(transportManager);
+
+            var ex = Assert.Throws<PlcConnectionException>(
+                () => driver.Connect("stub:other://host"));
+            Assert.Contains("allow-unsupported-transport", ex.Message);
+        }
+
+        [Fact]
+        public void Connect_allows_unsupported_transport_when_opted_in()
+        {
+            var transportManager = new DefaultTransportManager(
+                new[] { new FakeTransport(), new FakeTransport("other") });
+            var driver = new StubDriver(transportManager);
+
+            // Same connection string as above, plus the opt-in flag.
+            var connection = driver.Connect("stub:other://host?allow-unsupported-transport=true");
+            Assert.True(connection.IsConnected);
+        }
+
+        [Fact]
+        public void Connect_rejects_a_wrong_protocol_code()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new StubDriver(transportManager);
+
+            Assert.Throws<PlcConnectionException>(
+                () => driver.Connect("wrong-protocol://host"));
+        }
+
+        [Fact]
+        public void Connect_matches_the_protocol_code_case_insensitively()
+        {
+            // Connection strings are lowercase by grammar, but a driver is free to
+            // declare its code in any casing.
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new StubDriver(transportManager, "Stub");
+
+            var connection = driver.Connect("stub://192.168.0.1");
+            Assert.True(connection.IsConnected);
+        }
+
+        [Fact]
+        public void Connect_runs_the_protocol_level_setup_hook()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new HookedDriver(transportManager);
+
+            var connection = (HookedConnection)driver.Connect("hooked://192.168.0.1");
+
+            // The hook must have run by the time Connect() returns, so callers never
+            // receive a connection whose protocol session is still unestablished.
+            Assert.Equal(1, connection.OnConnectCalls);
+        }
+
+        [Fact]
+        public void Connect_closes_the_transport_when_the_setup_hook_fails()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            var driver = new HookedDriver(transportManager, failOnConnect: true);
+
+            Assert.Throws<PlcConnectionException>(() => driver.Connect("hooked://192.168.0.1"));
+
+            // The caller never gets a reference to the connection, so if Connect() did not
+            // close the transport itself the socket would stay bound with no way to reach it.
+            Assert.True(driver.LastTransportInstance!.Closed);
+        }
+
+        [Fact]
+        public void Connect_wraps_a_non_connection_exception_and_still_closes()
+        {
+            var transportManager = new DefaultTransportManager(new[] { new FakeTransport() });
+            // A handshake failure surfaces as TransportException, not PlcConnectionException
+            // (this is exactly what a real S7 COTP timeout does). The driver API promises
+            // PlcConnectionException on every failure path, so the wrap must kick in and
+            // the transport must still be closed.
+            var driver = new HookedDriver(transportManager, failOnConnect: true, failWithTransportException: true);
+
+            var ex = Assert.Throws<PlcConnectionException>(() => driver.Connect("hooked://192.168.0.1"));
+            Assert.IsType<TransportException>(ex.InnerException);
+            Assert.True(driver.LastTransportInstance!.Closed);
+        }
+    }
+
+    /// <summary>
+    /// A connection that records whether the protocol-level setup hook ran, and can be
+    /// told to fail inside it.
+    /// </summary>
+    internal class HookedConnection : ConnectionBase
+    {
+        private readonly bool _failOnConnect;
+        private readonly bool _failWithTransportException;
+
+        public HookedConnection(ConnectionString cs, ITransportInstance transport,
+            bool failOnConnect, bool failWithTransportException)
+            : base(cs, transport)
+        {
+            _failOnConnect = failOnConnect;
+            _failWithTransportException = failWithTransportException;
+        }
+
+        public int OnConnectCalls { get; private set; }
+
+        protected override void OnConnect()
+        {
+            OnConnectCalls++;
+            if (_failOnConnect)
+            {
+                throw _failWithTransportException
+                    ? new TransportException("Simulated handshake failure.")
+                    : new PlcConnectionException("Simulated handshake failure.");
+            }
+        }
+
+        public override IPlcConnectionMetadata PlcConnectionMetadata => null!;
+        public override IPlcTag Parse(string tagQuery) => null!;
+        public override IPlcReadRequestBuilder? ReadRequestBuilder => null;
+        public override IPlcWriteRequestBuilder? WriteRequestBuilder => null;
+        public override IPlcSubscriptionRequestBuilder? SubscriptionRequestBuilder => null;
+        public override IPlcUnsubscriptionRequestBuilder? UnsubscriptionRequestBuilder => null;
+    }
+
+    /// <summary>
+    /// A transport instance that reports whether it was closed.
+    /// </summary>
+    internal class ClosableTransportInstance : BaseTransportInstance
+    {
+        public ClosableTransportInstance(ITransportConfiguration config) : base(config) { }
+
+        public bool Closed { get; private set; }
+
+        public override bool IsOpen => !Closed;
+        public override int GetNumBytesAvailable() => 0;
+        public override byte[] PeekReadableBytes(int numBytes) => Array.Empty<byte>();
+        public override byte[] Read(int numBytes) => Array.Empty<byte>();
+        public override void Write(byte[] bytes) { }
+        public override void Close() => Closed = true;
+    }
+
+    internal class HookedDriver : DriverBase
+    {
+        private readonly bool _failOnConnect;
+        private readonly bool _failWithTransportException;
+
+        public HookedDriver(ITransportManager transportManager,
+            bool failOnConnect = false, bool failWithTransportException = false)
+            : base(transportManager)
+        {
+            _failOnConnect = failOnConnect;
+            _failWithTransportException = failWithTransportException;
+        }
+
+        public ClosableTransportInstance? LastTransportInstance { get; private set; }
+
+        public override string ProtocolCode => "hooked";
+        public override string ProtocolName => "Hooked Driver";
+        public override string DefaultTransportCode => "fake";
+        protected override string[] SupportedTransportCodes => new[] { "fake" };
+
+        protected override ConnectionBase CreateConnection(
+            ConnectionString connectionString,
+            ITransportInstance transportInstance,
+            IPlcAuthentication? authentication)
+        {
+            // Swap in an instance that reports closure, so the failure path can be asserted.
+            LastTransportInstance = new ClosableTransportInstance(transportInstance.Configuration);
+            return new HookedConnection(connectionString, LastTransportInstance,
+                _failOnConnect, _failWithTransportException);
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/spi-test/drivers/MessageCodecBaseTests.cs b/plc4net/test/spi-test/drivers/MessageCodecBaseTests.cs
new file mode 100644
index 0000000000..fe1968647f
--- /dev/null
+++ b/plc4net/test/spi-test/drivers/MessageCodecBaseTests.cs
@@ -0,0 +1,176 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using org.apache.plc4net.spi.drivers;
+using org.apache.plc4net.spi.generation;
+using org.apache.plc4net.spi.transports;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.drivers
+{
+    /// <summary>
+    /// The framing loop shared by every codec: partial frames, several frames in one
+    /// read, and - the point of these tests - resynchronising after the byte stream is
+    /// left misaligned (a dropped datagram, a truncated or malformed frame).
+    /// </summary>
+    public class MessageCodecBaseTests
+    {
+        // 4-byte header: 0xAA 0xBB then a big-endian total length (header + body).
+        private sealed class ToyCodec : MessageCodecBase<byte[]>
+        {
+            public ToyCodec(ITransportInstance transport, Action<byte[]> handler)
+                : base("toy", transport, handler)
+            {
+            }
+
+            protected override int GetMinimumHeaderSize() => 4;
+
+            protected override int CalculateTotalMessageSize(byte[] header, int availableBytes)
+            {
+                if (header[0] != 0xAA || header[1] != 0xBB)
+                {
+                    throw new MessageCodecException("bad magic");
+                }
+                var total = (header[2] << 8) | header[3];
+                if (total < 4)
+                {
+                    throw new MessageCodecException("length below header size");
+                }
+                return total;
+            }
+
+            protected override byte[] ParseMessage(ReadBuffer readBuffer) => readBuffer.GetBytes();
+        }
+
+        private static byte[] Frame(params byte[] body)
+        {
+            var total = 4 + body.Length;
+            var frame = new byte[total];
+            frame[0] = 0xAA;
+            frame[1] = 0xBB;
+            frame[2] = (byte)(total >> 8);
+            frame[3] = (byte)total;
+            Array.Copy(body, 0, frame, 4, body.Length);
+            return frame;
+        }
+
+        private static (ToyCodec codec, List<byte[]> received) Build(byte[] buffered)
+        {
+            var transport = new BufferBackedTransport(buffered);
+            var received = new List<byte[]>();
+            return (new ToyCodec(transport, received.Add), received);
+        }
+
+        [Fact]
+        public void Delivers_a_whole_frame()
+        {
+            var (codec, received) = Build(Frame(1, 2, 3));
+            codec.ProcessIncomingData();
+            Assert.Single(received);
+            Assert.Equal(new byte[] { 0xAA, 0xBB, 0x00, 0x07, 1, 2, 3 }, received[0]);
+        }
+
+        [Fact]
+        public void Leaves_a_partial_frame_buffered()
+        {
+            var whole = Frame(1, 2, 3);
+            var (codec, received) = Build(new[] { whole[0], whole[1], whole[2], whole[3], whole[4] });
+            codec.ProcessIncomingData();
+            Assert.Empty(received);
+        }
+
+        [Fact]
+        public void Resyncs_past_leading_garbage_to_the_next_valid_frame()
+        {
+            var junk = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 };
+            var good = Frame(9, 9);
+            var buffer = new byte[junk.Length + good.Length];
+            Array.Copy(junk, buffer, junk.Length);
+            Array.Copy(good, 0, buffer, junk.Length, good.Length);
+
+            var (codec, received) = Build(buffer);
+            codec.ProcessIncomingData();
+
+            Assert.Single(received);
+            Assert.Equal(good, received[0]);
+        }
+
+        [Fact]
+        public void Resyncs_past_a_frame_with_an_impossible_length()
+        {
+            // A valid magic but a length of 2 (below the 4-byte header): the stream is
+            // misaligned. The codec must skip it, not consume 2 bytes and stay off.
+            var bad = new byte[] { 0xAA, 0xBB, 0x00, 0x02 };
+            var good = Frame(7);
+            var buffer = new byte[bad.Length + good.Length];
+            Array.Copy(bad, buffer, bad.Length);
+            Array.Copy(good, 0, buffer, bad.Length, good.Length);
+
+            var (codec, received) = Build(buffer);
+            codec.ProcessIncomingData();
+
+            Assert.Single(received);
+            Assert.Equal(good, received[0]);
+        }
+
+        /// <summary>An <see cref="ITransportInstance"/> that just serves a fixed byte buffer.</summary>
+        private sealed class BufferBackedTransport : ITransportInstance
+        {
+            private readonly List<byte> _buffer;
+
+            public BufferBackedTransport(byte[] initial) => _buffer = new List<byte>(initial);
+
+            public ITransportConfiguration Configuration => null!;
+            public string DriverConfig => string.Empty;
+            public bool IsOpen => true;
+
+            public int GetNumBytesAvailable() => _buffer.Count;
+
+            public byte[] PeekReadableBytes(int numBytes)
+            {
+                if (_buffer.Count < numBytes)
+                {
+                    throw new TransportException("not enough buffered");
+                }
+                return _buffer.GetRange(0, numBytes).ToArray();
+            }
+
+            public byte[] Read(int numBytes)
+            {
+                var slice = PeekReadableBytes(numBytes);
+                _buffer.RemoveRange(0, numBytes);
+                return slice;
+            }
+
+            public void Write(byte[] bytes)
+            {
+            }
+
+            public void Close()
+            {
+            }
+
+            public void Dispose()
+            {
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/spi-test/drivers/MessagesTests.cs b/plc4net/test/spi-test/drivers/MessagesTests.cs
new file mode 100644
index 0000000000..b5afad3213
--- /dev/null
+++ b/plc4net/test/spi-test/drivers/MessagesTests.cs
@@ -0,0 +1,126 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Threading;
+using System.Threading.Tasks;
+using org.apache.plc4net.api.value;
+using org.apache.plc4net.messages;
+using org.apache.plc4net.spi.drivers.functions;
+using org.apache.plc4net.spi.drivers.messages;
+using org.apache.plc4net.types;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.drivers
+{
+    /// <summary>
+    /// Covers the read/write request builders and their default implementations.
+    /// </summary>
+    public class MessagesTests
+    {
+        private class NoOpReader : PlcReader
+        {
+            public Task<IPlcReadResponse> Read(DefaultPlcReadRequest request,
+                CancellationToken cancellationToken = default)
+            {
+                var values = new System.Collections.Generic.Dictionary<string,
+                    spi.drivers.messages.items.PlcResponseItem<IPlcValue>>();
+                foreach (var name in request.TagNames)
+                {
+                    values[name] = new spi.drivers.messages.items.DefaultPlcResponseItem<IPlcValue>(
+                        PlcResponseCode.Ok, null);
+                }
+                return Task.FromResult<IPlcReadResponse>(new DefaultPlcReadResponse(request, values));
+            }
+        }
+
+        private class NoOpWriter : PlcWriter
+        {
+            public Task<IPlcWriteResponse> Write(DefaultPlcWriteRequest request,
+                CancellationToken cancellationToken = default)
+            {
+                var codes = new System.Collections.Generic.Dictionary<string, PlcResponseCode>();
+                foreach (var name in request.TagNames)
+                {
+                    codes[name] = PlcResponseCode.Ok;
+                }
+                return Task.FromResult<IPlcWriteResponse>(new DefaultPlcWriteResponse(request, codes));
+            }
+        }
+
+        [Fact]
+        public void A_read_request_can_be_built_with_tag_addresses()
+        {
+            var builder = new DefaultPlcReadRequestBuilder(new NoOpReader(), null);
+
+            var request = builder
+                .AddTagAddress("a", "coil:1")
+                .AddTagAddress("b", "coil:2")
+                .Build();
+
+            Assert.NotNull(request);
+            var tagRequest = (IPlcTagRequest)request;
+            Assert.Equal(2, tagRequest.TagCount);
+            Assert.Contains("a", tagRequest.TagNames);
+            Assert.Contains("b", tagRequest.TagNames);
+        }
+
+        [Fact]
+        public async Task A_read_request_executes_and_returns_a_response()
+        {
+            var request = new DefaultPlcReadRequestBuilder(new NoOpReader(), null)
+                .AddTagAddress("x", "holding:10")
+                .Build();
+
+            var response = (DefaultPlcReadResponse)await request.ExecuteAsync();
+
+            Assert.NotNull(response);
+            Assert.NotNull(response.Request);
+            Assert.Equal(PlcResponseCode.Ok, response.GetResponseCode("x"));
+        }
+
+        [Fact]
+        public void A_write_request_can_be_built_with_tag_values()
+        {
+            var builder = new DefaultPlcWriteRequestBuilder(new NoOpWriter(), null);
+
+            var request = builder
+                .AddTag("tag1", "coil:0", true)
+                .AddTag("tag2", "holding:1", (short)42)
+                .Build();
+
+            Assert.NotNull(request);
+            Assert.Equal(2, request.NumberOfValues);
+            var tagRequest = (IPlcTagRequest)request;
+            Assert.Equal(2, tagRequest.TagCount);
+        }
+
+        [Fact]
+        public async Task A_write_request_executes_and_returns_a_response()
+        {
+            var request = new DefaultPlcWriteRequestBuilder(new NoOpWriter(), null)
+                .AddTag("tag1", "coil:0", true)
+                .Build();
+
+            var response = (DefaultPlcWriteResponse)await request.ExecuteAsync();
+
+            Assert.NotNull(response);
+            Assert.Equal(PlcResponseCode.Ok, response.GetResponseCode("tag1"));
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/spi-test/drivers/PlcDriverManagerTests.cs b/plc4net/test/spi-test/drivers/PlcDriverManagerTests.cs
new file mode 100644
index 0000000000..e9eda44dcf
--- /dev/null
+++ b/plc4net/test/spi-test/drivers/PlcDriverManagerTests.cs
@@ -0,0 +1,128 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.api;
+using org.apache.plc4net.api.authentication;
+using org.apache.plc4net.exceptions;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.drivers
+{
+    /// <summary>
+    /// A driver that only carries a protocol code; connecting is out of scope here.
+    /// </summary>
+    internal class RegistryOnlyDriver : IPlcDriver
+    {
+        public RegistryOnlyDriver(string protocolCode)
+        {
+            ProtocolCode = protocolCode;
+        }
+
+        public string ProtocolCode { get; }
+
+        public string ProtocolName => "Registry Only Driver";
+
+        public IPlcConnection Connect(string connectionString)
+        {
+            throw new NotSupportedException();
+        }
+
+        public IPlcConnection Connect(string connectionString, IPlcAuthentication? authentication)
+        {
+            throw new NotSupportedException();
+        }
+    }
+
+    /// <summary>
+    /// The manager is a process-wide singleton, so each test registers under its own
+    /// protocol code rather than trying to reset shared state.
+    /// </summary>
+    public class PlcDriverManagerTests
+    {
+        [Fact]
+        public void A_driver_is_found_under_the_code_it_registered_with()
+        {
+            var driver = new RegistryOnlyDriver("registry-plain");
+            PlcDriverManager.Instance.RegisterDriver(driver);
+
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriverByCode("registry-plain"));
+        }
+
+        [Fact]
+        public void A_mixed_case_driver_code_is_found_by_a_lowercase_lookup()
+        {
+            // Connection strings are lowercase by grammar, so a driver declaring
+            // "Registry-Mixed" still has to be reachable via "registry-mixed".
+            var driver = new RegistryOnlyDriver("Registry-Mixed");
+            PlcDriverManager.Instance.RegisterDriver(driver);
+
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriverByCode("registry-mixed"));
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriverByCode("REGISTRY-MIXED"));
+        }
+
+        [Fact]
+        public void An_unknown_code_reports_which_protocol_was_asked_for()
+        {
+            var ex = Assert.Throws<PlcConnectionException>(
+                () => PlcDriverManager.Instance.GetDriverByCode("registry-absent"));
+
+            Assert.Contains("registry-absent", ex.Message);
+        }
+
+        [Fact]
+        public void A_null_code_is_rejected_like_an_unknown_one()
+        {
+            Assert.Throws<PlcConnectionException>(
+                () => PlcDriverManager.Instance.GetDriverByCode(null));
+        }
+
+        [Fact]
+        public void Registering_null_is_rejected()
+        {
+            Assert.Throws<ArgumentNullException>(
+                () => PlcDriverManager.Instance.RegisterDriver(null!));
+        }
+
+        [Fact]
+        public void GetDriver_extracts_the_protocol_code_from_a_connection_string()
+        {
+            var driver = new RegistryOnlyDriver("s7");
+            PlcDriverManager.Instance.RegisterDriver(driver);
+
+            // The api layer only needs the scheme to dispatch to the right driver.
+            // This mirrors Java's DefaultPlcDriverManager.getDriverForUrl(), which
+            // uses new URI(url).getScheme().
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriver("s7://192.168.0.1"));
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriver("s7:cotp://10.0.0.5:102"));
+        }
+
+        [Fact]
+        public void GetDriver_extracts_the_protocol_code_from_a_transport_shorthand()
+        {
+            // The code must not collide with a real driver's: the manager is a global
+            // singleton, so registering a stub under "modbus-tcp" would make whichever
+            // test runs second see the other's driver.
+            var driver = new RegistryOnlyDriver("registry-shorthand");
+            PlcDriverManager.Instance.RegisterDriver(driver);
+
+            Assert.Same(driver, PlcDriverManager.Instance.GetDriver("registry-shorthand://10.0.0.9:502?unit-identifier=1"));
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/spi-test/generation/BufferTests.cs b/plc4net/test/spi-test/generation/BufferTests.cs
new file mode 100644
index 0000000000..d7bee9897c
--- /dev/null
+++ b/plc4net/test/spi-test/generation/BufferTests.cs
@@ -0,0 +1,230 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Text;
+using org.apache.plc4net.spi.generation;
+using Xunit;
+
+namespace org.apache.plc4net.spi.test.generation
+{
+    /// <summary>
+    /// Round-trip coverage for the bit-level codec every generated driver depends on.
+    /// </summary>
+    public class BufferTests
+    {
+        private static ReadBuffer Reader(Action<WriteBuffer> write)
+        {
+            var wb = new WriteBuffer();
+            write(wb);
+            return new ReadBuffer(wb.GetBytes());
+        }
+
+        [Fact]
+        public void Bits_are_written_and_read_most_significant_first()
+        {
+            var wb = new WriteBuffer();
+            wb.WriteBit("", true);
+            wb.WriteBit("", false);
+            wb.WriteBit("", true);
+            wb.WriteBit("", false);
+            wb.WriteBit("", false);
+            wb.WriteBit("", false);
+            wb.WriteBit("", false);
+            wb.WriteBit("", true);
+
+            Assert.Equal(new byte[] { 0xA1 }, wb.GetBytes());
... 1347 lines suppressed ...