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/07/06 18:53:47 UTC
(plc4x) 02/09: feat(plc4go): replace jacobsa/go-serial with internal serialport package
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
commit c91211bbc3fe6c1569529ab8593ea6df43e62074
Author: Sebastian Rühl <sr...@apache.org>
AuthorDate: Mon Jul 6 11:21:09 2026 +0200
feat(plc4go): replace jacobsa/go-serial with internal serialport package
Internalize the unmaintained (2018) github.com/jacobsa/go-serial dependency
as spi/transports/serial/serialport, a rewrite with net.Conn-like semantics:
- POSIX: non-blocking *os.File on the runtime poller, so
SetReadDeadline/SetWriteDeadline work natively (Linux TCSETS2+BOTHER,
Darwin TIOCSETA+IOSSIOSPEED, FreeBSD TIOCSETA; stub elsewhere)
- Windows: DCB/COMMTIMEOUTS-based port with deadline emulation in bounded
slices and a cancellation-safe Close (compile-gated, logic spec-tested
portably)
- Tested without hardware: spec-pinned tables for termios and DCB values,
PTY integration tests for the full open/configure/read/write/deadline/
close path, race-checked, cross-compiled for windows/darwin/freebsd/openbsd
The serial TransportInstance now wires real read/write deadlines (removes
both "TODO: big oof" no-ops) and fixes a latent bug where Connect never set
the connected flag, so Write always failed with "Not connected".
Also: attribution entry in LICENSE for the two derived files, dependency
removed from go.mod/go.sum/tools.mod/tools.sum and the code-generation Go
test fixture.
---
LICENSE | 6 +
.../language/go/src/test/resources/plc4go/go.mod | 1 -
.../language/go/src/test/resources/plc4go/go.sum | 2 -
plc4go/go.mod | 1 -
plc4go/go.sum | 2 -
plc4go/internal/bacnetip/Connection_test.go | 4 +-
plc4go/internal/bacnetip/ReadRoundtrip_test.go | 2 +-
plc4go/spi/transports/serial/TransportInstance.go | 32 +-
.../serial/TransportInstance_pty_test.go | 91 ++++++
.../transports/serial/TransportInstance_test.go | 14 +-
.../transports/serial/serialport/apply_darwin.go | 66 +++++
.../transports/serial/serialport/apply_freebsd.go | 39 +++
.../transports/serial/serialport/apply_linux.go | 45 +++
plc4go/spi/transports/serial/serialport/dcb.go | 86 ++++++
.../spi/transports/serial/serialport/dcb_test.go | 102 +++++++
.../spi/transports/serial/serialport/mocks_test.go | 323 ++++++++++++++++++++
.../spi/transports/serial/serialport/port_unix.go | 71 +++++
.../serial/serialport/port_unsupported.go | 31 ++
.../transports/serial/serialport/port_windows.go | 326 +++++++++++++++++++++
.../transports/serial/serialport/pty_linux_test.go | 147 ++++++++++
.../spi/transports/serial/serialport/serialport.go | 120 ++++++++
.../serial/serialport/serialport_test.go | 107 +++++++
.../transports/serial/serialport/termios_unix.go | 77 +++++
.../serial/serialport/termios_unix_test.go | 99 +++++++
.../spi/transports/serial/serialport/timeouts.go | 47 +++
.../transports/serial/serialport/timeouts_test.go | 51 ++++
plc4go/tools.mod | 5 +-
plc4go/tools.sum | 5 -
28 files changed, 1860 insertions(+), 42 deletions(-)
diff --git a/LICENSE b/LICENSE
index 4b39e66ae2..711f75f3ed 100644
--- a/LICENSE
+++ b/LICENSE
@@ -208,6 +208,12 @@ plc4j/transports/test/src/main/java/io/netty/channel/embedded/Plc4xEmbeddedChann
plc4j/transports/test/src/main/java/io/netty/channel/embedded/Plc4xEmbeddedEventLoop.java
Are copyrighted by the The Netty Project which is distributed under the Apache 2.0 license.
+The files:
+plc4go/spi/transports/serial/serialport/apply_linux.go
+plc4go/spi/transports/serial/serialport/apply_darwin.go
+Contain logic derived from github.com/jacobsa/go-serial, copyrighted by Aaron Jacobs,
+which is distributed under the Apache 2.0 license.
+
The file:
code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/expression/Expression.g4
is licensed under the Category A: "UNLICENSE" which is available here:
diff --git a/code-generation/language/go/src/test/resources/plc4go/go.mod b/code-generation/language/go/src/test/resources/plc4go/go.mod
index 5880bc91e5..8900a7b3e6 100644
--- a/code-generation/language/go/src/test/resources/plc4go/go.mod
+++ b/code-generation/language/go/src/test/resources/plc4go/go.mod
@@ -28,7 +28,6 @@ require (
github.com/fatih/color v1.19.0
github.com/google/uuid v1.6.0
github.com/gopacket/gopacket v1.5.0
- github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
github.com/libp2p/go-reuseport v0.4.0
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
diff --git a/code-generation/language/go/src/test/resources/plc4go/go.sum b/code-generation/language/go/src/test/resources/plc4go/go.sum
index 42ad20b66c..28be973e55 100644
--- a/code-generation/language/go/src/test/resources/plc4go/go.sum
+++ b/code-generation/language/go/src/test/resources/plc4go/go.sum
@@ -11,8 +11,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopacket/gopacket v1.5.0 h1:9s9fcSUVKFlRV97B77Bq9XNV3ly2gvvsneFMQUGjc+M=
github.com/gopacket/gopacket v1.5.0/go.mod h1:i3NaGaqfoWKAr1+g7qxEdWsmfT+MXuWkAe9+THv8LME=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
diff --git a/plc4go/go.mod b/plc4go/go.mod
index 60f974c9dc..1d8a034cfb 100644
--- a/plc4go/go.mod
+++ b/plc4go/go.mod
@@ -28,7 +28,6 @@ require (
github.com/fatih/color v1.19.0
github.com/google/uuid v1.6.0
github.com/gopacket/gopacket v1.7.0
- github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.56.0
diff --git a/plc4go/go.sum b/plc4go/go.sum
index 67241d04da..2bb63966bd 100644
--- a/plc4go/go.sum
+++ b/plc4go/go.sum
@@ -11,8 +11,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopacket/gopacket v1.7.0 h1:GdmF8ytGnjtSvyy30CTZhIwX1ybDWH3Q0MNK0blIKzA=
github.com/gopacket/gopacket v1.7.0/go.mod h1:QKowPlTLrQU2rqV5C5I14Aoaid3l8da3kbddibc/Wgk=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
diff --git a/plc4go/internal/bacnetip/Connection_test.go b/plc4go/internal/bacnetip/Connection_test.go
index f6f260e2c5..7559a8930d 100644
--- a/plc4go/internal/bacnetip/Connection_test.go
+++ b/plc4go/internal/bacnetip/Connection_test.go
@@ -23,11 +23,11 @@ import (
"math"
"testing"
- "github.com/apache/plc4x/plc4go/spi/options"
- "github.com/apache/plc4x/plc4go/spi/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/apache/plc4x/plc4go/spi/options"
+ "github.com/apache/plc4x/plc4go/spi/testutils"
"github.com/apache/plc4x/plc4go/spi/transactions"
)
diff --git a/plc4go/internal/bacnetip/ReadRoundtrip_test.go b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
index 870687893f..bf2332cbc7 100644
--- a/plc4go/internal/bacnetip/ReadRoundtrip_test.go
+++ b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
@@ -27,7 +27,6 @@ import (
"testing"
"time"
- "github.com/apache/plc4x/plc4go/spi/testutils"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -38,6 +37,7 @@ import (
apiTransports "github.com/apache/plc4x/plc4go/pkg/api/transports"
model "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
"github.com/apache/plc4x/plc4go/spi/options"
+ "github.com/apache/plc4x/plc4go/spi/testutils"
)
// fakeBacnetDevice is a minimal UDP BACnet/IP device used to exercise the real
diff --git a/plc4go/spi/transports/serial/TransportInstance.go b/plc4go/spi/transports/serial/TransportInstance.go
index be8cfa0070..5868efbc25 100644
--- a/plc4go/spi/transports/serial/TransportInstance.go
+++ b/plc4go/spi/transports/serial/TransportInstance.go
@@ -29,12 +29,12 @@ import (
"sync/atomic"
"time"
- "github.com/jacobsa/go-serial/serial"
"github.com/rs/zerolog"
"github.com/apache/plc4x/plc4go/spi/errors"
"github.com/apache/plc4x/plc4go/spi/options"
"github.com/apache/plc4x/plc4go/spi/transports"
+ "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
transportUtils "github.com/apache/plc4x/plc4go/spi/transports/utils"
"github.com/apache/plc4x/plc4go/spi/utils"
)
@@ -50,7 +50,7 @@ type TransportInstance struct {
stateChangeMutex sync.Mutex
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
log zerolog.Logger
@@ -79,22 +79,13 @@ func (m *TransportInstance) Connect(ctx context.Context) error {
return errors.New("Already connected")
}
- var err error
- config := serial.OpenOptions{PortName: m.SerialPortName, BaudRate: m.BaudRate, DataBits: 8, StopBits: 1, MinimumReadSize: 0, InterCharacterTimeout: 100 /*, RTSCTSFlowControl: true*/}
- m.serialPort, err = serial.Open(config)
+ serialPort, err := serialport.Open(m.SerialPortName, serialport.Config{BaudRate: m.BaudRate})
if err != nil {
return errors.Wrap(err, "error connecting to serial port")
}
- // Add a logging layer ...
- /*logFile, err := ioutil.TempFile(os.TempDir(), "transport-logger")
- if err != nil {
- m.log.Error().Msg("Error creating file for logging transport requests")
- } else {
- fileLogger := zerolog.New(logFile).With().Logger()
- m.serialPort = utils.NewTransportLogger(m.serialPort, utils.WithLogger(fileLogger))
- m.log.Trace().Msg("Logging Transport to file %s", logFile.Name())
- }*/
+ m.serialPort = serialPort
m.reader = bufio.NewReader(m.serialPort)
+ m.connected.Store(true)
return nil
}
@@ -132,7 +123,11 @@ func (m *TransportInstance) Write(ctx context.Context, data []byte) error {
if m.serialPort == nil {
return errors.New("error writing to transport. No writer available")
}
- // TODO: big oof.... there is no way to set a timeout on the write operation.
+ if deadline, ok := ctx.Deadline(); ok {
+ if err := m.serialPort.SetWriteDeadline(deadline); err != nil {
+ return errors.Wrap(err, "error setting write deadline")
+ }
+ }
num, err := m.serialPort.Write(data)
if err != nil {
return errors.Wrap(err, "error writing")
@@ -148,8 +143,11 @@ func (m *TransportInstance) GetReader() transports.ExtendedReader {
}
func (m *TransportInstance) SetReadDeadline(deadline time.Time) error {
- // TODO: big oof.... there is no way to set a timeout
- return nil
+ serialPort := m.serialPort
+ if serialPort == nil {
+ return errors.New("error setting read deadline. No serial port available")
+ }
+ return serialPort.SetReadDeadline(deadline)
}
func (m *TransportInstance) String() string {
diff --git a/plc4go/spi/transports/serial/TransportInstance_pty_test.go b/plc4go/spi/transports/serial/TransportInstance_pty_test.go
new file mode 100644
index 0000000000..b33456eaa3
--- /dev/null
+++ b/plc4go/spi/transports/serial/TransportInstance_pty_test.go
@@ -0,0 +1,91 @@
+//go:build linux
+
+/*
+ * 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.
+ */
+
+package serial
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+// openPTY allocates a pseudo-terminal pair; the slave path stands in for a
+// real serial device (same pattern as the serialport package's own tests).
+func openPTY(t *testing.T) (master *os.File, slavePath string) {
+ t.Helper()
+ master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0)
+ require.NoError(t, err, "opening /dev/ptmx")
+ t.Cleanup(func() { _ = master.Close() })
+ ptn, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
+ require.NoError(t, err, "TIOCGPTN")
+ require.NoError(t, unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0), "unlocking pty slave")
+ return master, fmt.Sprintf("/dev/pts/%d", ptn)
+}
+
+func TestTransportInstance_EndToEndOnPTY(t *testing.T) {
+ master, slavePath := openPTY(t)
+
+ instance := NewTransportInstance(slavePath, 115200, 1, NewTransport())
+ require.NoError(t, instance.Connect(context.Background()))
+ t.Cleanup(func() { _ = instance.Close() })
+ assert.True(t, instance.IsConnected())
+
+ // Write goes out on the wire (fixes the historic "Not connected" bug:
+ // Connect previously never flipped the connected flag).
+ require.NoError(t, instance.Write(context.Background(), []byte{0x01, 0x02, 0x03}))
+ wire := make([]byte, 3)
+ _, err := master.Read(wire)
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0x01, 0x02, 0x03}, wire)
+
+ // Reads work and honor context deadlines through the buffered layer.
+ _, err = master.Write([]byte{0xCA, 0xFE})
+ require.NoError(t, err)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ data, err := instance.Read(ctx, 2)
+ require.NoError(t, err)
+ assert.Equal(t, []byte{0xCA, 0xFE}, data)
+
+ // A read into silence now actually times out instead of hanging:
+ // SetReadDeadline is no longer a no-op.
+ shortCtx, shortCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer shortCancel()
+ start := time.Now()
+ _, err = instance.Read(shortCtx, 1)
+ require.Error(t, err)
+ assert.Less(t, time.Since(start), 5*time.Second)
+}
+
+func TestTransportInstance_CloseIsIdempotentOnPTY(t *testing.T) {
+ _, slavePath := openPTY(t)
+ instance := NewTransportInstance(slavePath, 9600, 1, NewTransport())
+ require.NoError(t, instance.Connect(context.Background()))
+ require.NoError(t, instance.Close())
+ assert.False(t, instance.IsConnected())
+ require.NoError(t, instance.Close(), "second close must be a no-op")
+}
diff --git a/plc4go/spi/transports/serial/TransportInstance_test.go b/plc4go/spi/transports/serial/TransportInstance_test.go
index 132489cad9..e3f5f76145 100644
--- a/plc4go/spi/transports/serial/TransportInstance_test.go
+++ b/plc4go/spi/transports/serial/TransportInstance_test.go
@@ -21,12 +21,12 @@ package serial
import (
"bufio"
- "io"
"testing"
"time"
"github.com/stretchr/testify/assert"
+ "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
transportUtils "github.com/apache/plc4x/plc4go/spi/transports/utils"
)
@@ -60,7 +60,7 @@ func TestTransportInstance_Close(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
tests := []struct {
@@ -95,7 +95,7 @@ func TestTransportInstance_Connect(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
tests := []struct {
@@ -130,7 +130,7 @@ func TestTransportInstance_GetReader(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
tests := []struct {
@@ -165,7 +165,7 @@ func TestTransportInstance_IsConnected(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
tests := []struct {
@@ -200,7 +200,7 @@ func TestTransportInstance_String(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
tests := []struct {
@@ -235,7 +235,7 @@ func TestTransportInstance_Write(t *testing.T) {
BaudRate uint
ConnectTimeout uint32
transport *Transport
- serialPort io.ReadWriteCloser
+ serialPort serialport.Port
reader *bufio.Reader
}
type args struct {
diff --git a/plc4go/spi/transports/serial/serialport/apply_darwin.go b/plc4go/spi/transports/serial/serialport/apply_darwin.go
new file mode 100644
index 0000000000..bc3135c1e8
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/apply_darwin.go
@@ -0,0 +1,66 @@
+//go:build darwin
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+// darwinStandardBaudRates are the rates TIOCSETA accepts directly
+// (Darwin speed_t values are numeric, so Bxxx == the rate itself).
+var darwinStandardBaudRates = map[uint]bool{
+ 50: true, 75: true, 110: true, 134: true, 150: true, 200: true,
+ 300: true, 600: true, 1200: true, 1800: true, 2400: true, 4800: true,
+ 7200: true, 9600: true, 14400: true, 19200: true, 28800: true,
+ 38400: true, 57600: true, 76800: true, 115200: true, 230400: true,
+}
+
+// ioctlIOSSIOSPEED is IOSSIOSPEED from IOKit's serial/ioss.h, which sets
+// arbitrary baud rates. Not defined in x/sys/unix; the value (with its
+// 4-byte payload encoding) is the one established in the wild by
+// github.com/jacobsa/go-serial (Apache License 2.0).
+const ioctlIOSSIOSPEED = 0x80045402
+
+// applySettings derived from github.com/jacobsa/go-serial (Apache License 2.0).
+func applySettings(fd int, t *unix.Termios, baud uint) error {
+ if darwinStandardBaudRates[baud] {
+ t.Ispeed = uint64(baud)
+ t.Ospeed = uint64(baud)
+ if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, t); err != nil {
+ return os.NewSyscallError("ioctl TIOCSETA", err)
+ }
+ return nil
+ }
+ // Non-standard rate: configure everything else with a placeholder
+ // standard rate first, then set the real rate via IOSSIOSPEED.
+ t.Ispeed = 19200
+ t.Ospeed = 19200
+ if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, t); err != nil {
+ return os.NewSyscallError("ioctl TIOCSETA", err)
+ }
+ if err := unix.IoctlSetPointerInt(fd, ioctlIOSSIOSPEED, int(baud)); err != nil {
+ return os.NewSyscallError("ioctl IOSSIOSPEED", err)
+ }
+ return nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/apply_freebsd.go b/plc4go/spi/transports/serial/serialport/apply_freebsd.go
new file mode 100644
index 0000000000..04293587cd
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/apply_freebsd.go
@@ -0,0 +1,39 @@
+//go:build freebsd
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+// applySettings sets the rate directly: FreeBSD speed_t values are numeric
+// and the driver rejects rates it cannot provide.
+func applySettings(fd int, t *unix.Termios, baud uint) error {
+ t.Ispeed = uint32(baud)
+ t.Ospeed = uint32(baud)
+ if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, t); err != nil {
+ return os.NewSyscallError("ioctl TIOCSETA", err)
+ }
+ return nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/apply_linux.go b/plc4go/spi/transports/serial/serialport/apply_linux.go
new file mode 100644
index 0000000000..2d5e85b5de
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/apply_linux.go
@@ -0,0 +1,45 @@
+//go:build linux
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "os"
+
+ "golang.org/x/sys/unix"
+)
+
+// applySettings configures the port via the kernel termios2 interface
+// (TCSETS2), which accepts arbitrary baud rates through BOTHER + explicit
+// Ispeed/Ospeed — so standard and non-standard rates share one code path.
+// Approach derived from github.com/jacobsa/go-serial (Apache License 2.0);
+// unlike that library we use the per-architecture unix.BOTHER constant
+// (e.g. 0x1000 on amd64 but 0x1f on ppc64) instead of a hardcoded value.
+func applySettings(fd int, t *unix.Termios, baud uint) error {
+ t.Cflag &^= unix.CBAUD
+ t.Cflag |= unix.BOTHER
+ t.Ispeed = uint32(baud)
+ t.Ospeed = uint32(baud)
+ if err := unix.IoctlSetTermios(fd, unix.TCSETS2, t); err != nil {
+ return os.NewSyscallError("ioctl TCSETS2", err)
+ }
+ return nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/dcb.go b/plc4go/spi/transports/serial/serialport/dcb.go
new file mode 100644
index 0000000000..f507101cea
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/dcb.go
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+package serialport
+
+// Windows DCB field values, kept in a portable file (no x/sys/windows
+// types) so the mapping is unit-testable on every development platform.
+// Values per Microsoft's documentation of the DCB structure (winbase.h).
+const (
+ dcbNoParity = 0 // NOPARITY
+ dcbOddParity = 1 // ODDPARITY
+ dcbEvenParity = 2 // EVENPARITY
+
+ dcbOneStopBit = 0 // ONESTOPBIT
+ dcbOne5StopBits = 1 // ONE5STOPBITS
+ dcbTwoStopBits = 2 // TWOSTOPBITS
+
+ // Bit positions inside DCB.Flags (the C bitfield block starting at fBinary).
+ dcbFlagBinary = 0x00000001 // fBinary: binary mode, required
+ dcbFlagParity = 0x00000002 // fParity: enable parity checking
+ dcbFlagOutxCtsFlow = 0x00000004 // fOutxCtsFlow: output suspended while CTS low
+ dcbFlagDtrControlEnable = 0x00000010 // fDtrControl = DTR_CONTROL_ENABLE (1 << 4)
+ dcbFlagRtsControlEnable = 0x00001000 // fRtsControl = RTS_CONTROL_ENABLE (1 << 12)
+ dcbFlagRtsControlHandshake = 0x00002000 // fRtsControl = RTS_CONTROL_HANDSHAKE (2 << 12)
+)
+
+// dcbSettings carries the computed DCB field values; port_windows.go copies
+// them into a windows.DCB.
+type dcbSettings struct {
+ BaudRate uint32
+ ByteSize uint8
+ Parity uint8
+ StopBits uint8
+ Flags uint32
+}
+
+// makeDCBSettings translates an already-normalized Config into DCB field
+// values. DTR is asserted on open (DTR_CONTROL_ENABLE); RTS is asserted
+// unless hardware flow control hands it to the driver.
+func makeDCBSettings(cfg Config) dcbSettings {
+ s := dcbSettings{
+ BaudRate: uint32(cfg.BaudRate),
+ ByteSize: uint8(cfg.DataBits),
+ Flags: dcbFlagBinary | dcbFlagDtrControlEnable,
+ }
+ switch cfg.Parity {
+ case ParityOdd:
+ s.Parity = dcbOddParity
+ s.Flags |= dcbFlagParity
+ case ParityEven:
+ s.Parity = dcbEvenParity
+ s.Flags |= dcbFlagParity
+ default:
+ s.Parity = dcbNoParity
+ }
+ switch cfg.StopBits {
+ case StopBitsOnePointFive:
+ s.StopBits = dcbOne5StopBits
+ case StopBitsTwo:
+ s.StopBits = dcbTwoStopBits
+ default:
+ s.StopBits = dcbOneStopBit
+ }
+ if cfg.RTSCTSFlowControl {
+ s.Flags |= dcbFlagOutxCtsFlow | dcbFlagRtsControlHandshake
+ } else {
+ s.Flags |= dcbFlagRtsControlEnable
+ }
+ return s
+}
diff --git a/plc4go/spi/transports/serial/serialport/dcb_test.go b/plc4go/spi/transports/serial/serialport/dcb_test.go
new file mode 100644
index 0000000000..e601aea3a1
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/dcb_test.go
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// Expected values quoted from Microsoft's winbase.h documentation:
+//
+// Parity: NOPARITY=0, ODDPARITY=1, EVENPARITY=2
+// StopBits: ONESTOPBIT=0, ONE5STOPBITS=1, TWOSTOPBITS=2
+// Flags bits: fBinary=1<<0, fParity=1<<1, fOutxCtsFlow=1<<2,
+// fDtrControl(2 bits)@4 (DTR_CONTROL_ENABLE=1),
+// fRtsControl(2 bits)@12 (RTS_CONTROL_ENABLE=1, RTS_CONTROL_HANDSHAKE=2)
+func TestMakeDCBSettings(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg Config
+ want dcbSettings
+ }{
+ {
+ name: "9600 8N1 no flow control",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityNone},
+ want: dcbSettings{
+ BaudRate: 9600,
+ ByteSize: 8,
+ Parity: 0, // NOPARITY
+ StopBits: 0, // ONESTOPBIT
+ Flags: 0x1 | 0x10 | 0x1000, // fBinary | DTR_CONTROL_ENABLE | RTS_CONTROL_ENABLE
+ },
+ },
+ {
+ name: "19200 7E2",
+ cfg: Config{BaudRate: 19200, DataBits: 7, StopBits: StopBitsTwo, Parity: ParityEven},
+ want: dcbSettings{
+ BaudRate: 19200,
+ ByteSize: 7,
+ Parity: 2, // EVENPARITY
+ StopBits: 2, // TWOSTOPBITS
+ Flags: 0x1 | 0x2 | 0x10 | 0x1000, // ... | fParity | ...
+ },
+ },
+ {
+ name: "odd parity",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityOdd},
+ want: dcbSettings{
+ BaudRate: 9600,
+ ByteSize: 8,
+ Parity: 1, // ODDPARITY
+ StopBits: 0,
+ Flags: 0x1 | 0x2 | 0x10 | 0x1000,
+ },
+ },
+ {
+ name: "1.5 stop bits with 5 data bits",
+ cfg: Config{BaudRate: 9600, DataBits: 5, StopBits: StopBitsOnePointFive, Parity: ParityNone},
+ want: dcbSettings{
+ BaudRate: 9600,
+ ByteSize: 5,
+ Parity: 0,
+ StopBits: 1, // ONE5STOPBITS
+ Flags: 0x1 | 0x10 | 0x1000,
+ },
+ },
+ {
+ name: "RTS/CTS flow control switches RTS to handshake and enables CTS sensitivity",
+ cfg: Config{BaudRate: 115200, DataBits: 8, StopBits: StopBitsOne, Parity: ParityNone, RTSCTSFlowControl: true},
+ want: dcbSettings{
+ BaudRate: 115200,
+ ByteSize: 8,
+ Parity: 0,
+ StopBits: 0,
+ Flags: 0x1 | 0x4 | 0x10 | 0x2000, // fBinary | fOutxCtsFlow | DTR_CONTROL_ENABLE | RTS_CONTROL_HANDSHAKE
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, makeDCBSettings(tt.cfg))
+ })
+ }
+}
diff --git a/plc4go/spi/transports/serial/serialport/mocks_test.go b/plc4go/spi/transports/serial/serialport/mocks_test.go
new file mode 100644
index 0000000000..7d0e108f4e
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/mocks_test.go
@@ -0,0 +1,323 @@
+/*
+ * 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.
+ */
+
+// Code generated by mockery; DO NOT EDIT.
+// github.com/vektra/mockery
+// template: testify
+
+package serialport
+
+import (
+ "time"
+
+ mock "github.com/stretchr/testify/mock"
+)
+
+// NewMockPort creates a new instance of MockPort. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockPort(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *MockPort {
+ mock := &MockPort{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
+
+// MockPort is an autogenerated mock type for the Port type
+type MockPort struct {
+ mock.Mock
+}
+
+type MockPort_Expecter struct {
+ mock *mock.Mock
+}
+
+func (_m *MockPort) EXPECT() *MockPort_Expecter {
+ return &MockPort_Expecter{mock: &_m.Mock}
+}
+
+// Close provides a mock function for the type MockPort
+func (_mock *MockPort) Close() error {
+ ret := _mock.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Close")
+ }
+
+ var r0 error
+ if returnFunc, ok := ret.Get(0).(func() error); ok {
+ r0 = returnFunc()
+ } else {
+ r0 = ret.Error(0)
+ }
+ return r0
+}
+
+// MockPort_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
+type MockPort_Close_Call struct {
+ *mock.Call
+}
+
+// Close is a helper method to define mock.On call
+func (_e *MockPort_Expecter) Close() *MockPort_Close_Call {
+ return &MockPort_Close_Call{Call: _e.mock.On("Close")}
+}
+
+func (_c *MockPort_Close_Call) Run(run func()) *MockPort_Close_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *MockPort_Close_Call) Return(err error) *MockPort_Close_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockPort_Close_Call) RunAndReturn(run func() error) *MockPort_Close_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Read provides a mock function for the type MockPort
+func (_mock *MockPort) Read(p []byte) (int, error) {
+ ret := _mock.Called(p)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Read")
+ }
+
+ var r0 int
+ var r1 error
+ if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return returnFunc(p)
+ }
+ if returnFunc, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = returnFunc(p)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = returnFunc(p)
+ } else {
+ r1 = ret.Error(1)
+ }
+ return r0, r1
+}
+
+// MockPort_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
+type MockPort_Read_Call struct {
+ *mock.Call
+}
+
+// Read is a helper method to define mock.On call
+// - p []byte
+func (_e *MockPort_Expecter) Read(p interface{}) *MockPort_Read_Call {
+ return &MockPort_Read_Call{Call: _e.mock.On("Read", p)}
+}
+
+func (_c *MockPort_Read_Call) Run(run func(p []byte)) *MockPort_Read_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 []byte
+ if args[0] != nil {
+ arg0 = args[0].([]byte)
+ }
+ run(
+ arg0,
+ )
+ })
+ return _c
+}
+
+func (_c *MockPort_Read_Call) Return(n int, err error) *MockPort_Read_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockPort_Read_Call) RunAndReturn(run func(p []byte) (int, error)) *MockPort_Read_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetReadDeadline provides a mock function for the type MockPort
+func (_mock *MockPort) SetReadDeadline(t time.Time) error {
+ ret := _mock.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetReadDeadline")
+ }
+
+ var r0 error
+ if returnFunc, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = returnFunc(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+ return r0
+}
+
+// MockPort_SetReadDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetReadDeadline'
+type MockPort_SetReadDeadline_Call struct {
+ *mock.Call
+}
+
+// SetReadDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockPort_Expecter) SetReadDeadline(t interface{}) *MockPort_SetReadDeadline_Call {
+ return &MockPort_SetReadDeadline_Call{Call: _e.mock.On("SetReadDeadline", t)}
+}
+
+func (_c *MockPort_SetReadDeadline_Call) Run(run func(t time.Time)) *MockPort_SetReadDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 time.Time
+ if args[0] != nil {
+ arg0 = args[0].(time.Time)
+ }
+ run(
+ arg0,
+ )
+ })
+ return _c
+}
+
+func (_c *MockPort_SetReadDeadline_Call) Return(err error) *MockPort_SetReadDeadline_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockPort_SetReadDeadline_Call) RunAndReturn(run func(t time.Time) error) *MockPort_SetReadDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// SetWriteDeadline provides a mock function for the type MockPort
+func (_mock *MockPort) SetWriteDeadline(t time.Time) error {
+ ret := _mock.Called(t)
+
+ if len(ret) == 0 {
+ panic("no return value specified for SetWriteDeadline")
+ }
+
+ var r0 error
+ if returnFunc, ok := ret.Get(0).(func(time.Time) error); ok {
+ r0 = returnFunc(t)
+ } else {
+ r0 = ret.Error(0)
+ }
+ return r0
+}
+
+// MockPort_SetWriteDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWriteDeadline'
+type MockPort_SetWriteDeadline_Call struct {
+ *mock.Call
+}
+
+// SetWriteDeadline is a helper method to define mock.On call
+// - t time.Time
+func (_e *MockPort_Expecter) SetWriteDeadline(t interface{}) *MockPort_SetWriteDeadline_Call {
+ return &MockPort_SetWriteDeadline_Call{Call: _e.mock.On("SetWriteDeadline", t)}
+}
+
+func (_c *MockPort_SetWriteDeadline_Call) Run(run func(t time.Time)) *MockPort_SetWriteDeadline_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 time.Time
+ if args[0] != nil {
+ arg0 = args[0].(time.Time)
+ }
+ run(
+ arg0,
+ )
+ })
+ return _c
+}
+
+func (_c *MockPort_SetWriteDeadline_Call) Return(err error) *MockPort_SetWriteDeadline_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockPort_SetWriteDeadline_Call) RunAndReturn(run func(t time.Time) error) *MockPort_SetWriteDeadline_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
+// Write provides a mock function for the type MockPort
+func (_mock *MockPort) Write(p []byte) (int, error) {
+ ret := _mock.Called(p)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Write")
+ }
+
+ var r0 int
+ var r1 error
+ if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok {
+ return returnFunc(p)
+ }
+ if returnFunc, ok := ret.Get(0).(func([]byte) int); ok {
+ r0 = returnFunc(p)
+ } else {
+ r0 = ret.Get(0).(int)
+ }
+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
+ r1 = returnFunc(p)
+ } else {
+ r1 = ret.Error(1)
+ }
+ return r0, r1
+}
+
+// MockPort_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
+type MockPort_Write_Call struct {
+ *mock.Call
+}
+
+// Write is a helper method to define mock.On call
+// - p []byte
+func (_e *MockPort_Expecter) Write(p interface{}) *MockPort_Write_Call {
+ return &MockPort_Write_Call{Call: _e.mock.On("Write", p)}
+}
+
+func (_c *MockPort_Write_Call) Run(run func(p []byte)) *MockPort_Write_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 []byte
+ if args[0] != nil {
+ arg0 = args[0].([]byte)
+ }
+ run(
+ arg0,
+ )
+ })
+ return _c
+}
+
+func (_c *MockPort_Write_Call) Return(n int, err error) *MockPort_Write_Call {
+ _c.Call.Return(n, err)
+ return _c
+}
+
+func (_c *MockPort_Write_Call) RunAndReturn(run func(p []byte) (int, error)) *MockPort_Write_Call {
+ _c.Call.Return(run)
+ return _c
+}
diff --git a/plc4go/spi/transports/serial/serialport/port_unix.go b/plc4go/spi/transports/serial/serialport/port_unix.go
new file mode 100644
index 0000000000..5db37c362f
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/port_unix.go
@@ -0,0 +1,71 @@
+//go:build linux || darwin || freebsd
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// openPort opens the device non-blocking so the Go runtime poller manages
+// it — that is what makes SetReadDeadline/SetWriteDeadline work on a plain
+// *os.File (which therefore is the Port implementation on POSIX).
+//
+// The termios configuration is applied through SyscallConn().Control and
+// never via Fd(): calling Fd() would switch the file to blocking mode and
+// de-register it from the poller, silently breaking deadline support.
+func openPort(portName string, cfg Config) (Port, error) {
+ t, err := makeTermios(cfg)
+ if err != nil {
+ return nil, err
+ }
+ f, err := os.OpenFile(portName, os.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, fmt.Errorf("serialport: opening %s: %w", portName, err)
+ }
+ rawConn, err := f.SyscallConn()
+ if err != nil {
+ _ = f.Close()
+ return nil, fmt.Errorf("serialport: accessing raw connection of %s: %w", portName, err)
+ }
+ var applyErr error
+ if err := rawConn.Control(func(fd uintptr) {
+ applyErr = applySettings(int(fd), t, cfg.BaudRate)
+ }); err != nil {
+ _ = f.Close()
+ return nil, fmt.Errorf("serialport: raw control on %s: %w", portName, err)
+ }
+ if applyErr != nil {
+ _ = f.Close()
+ return nil, fmt.Errorf("serialport: configuring %s: %w", portName, applyErr)
+ }
+ // Deadlines are part of the Port contract: fail fast if the runtime
+ // poller could not adopt this fd instead of degrading silently.
+ if err := f.SetReadDeadline(time.Time{}); err != nil {
+ _ = f.Close()
+ return nil, fmt.Errorf("serialport: %s does not support I/O deadlines: %w", portName, err)
+ }
+ return f, nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/port_unsupported.go b/plc4go/spi/transports/serial/serialport/port_unsupported.go
new file mode 100644
index 0000000000..01d6e8988c
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/port_unsupported.go
@@ -0,0 +1,31 @@
+//go:build !(linux || darwin || freebsd || windows)
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "fmt"
+ "runtime"
+)
+
+func openPort(portName string, cfg Config) (Port, error) {
+ return nil, fmt.Errorf("serialport: %w: %s", ErrUnsupportedPlatform, runtime.GOOS)
+}
diff --git a/plc4go/spi/transports/serial/serialport/port_windows.go b/plc4go/spi/transports/serial/serialport/port_windows.go
new file mode 100644
index 0000000000..a75668a860
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/port_windows.go
@@ -0,0 +1,326 @@
+//go:build windows
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "time"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+// winPort implements Port on Windows with synchronous I/O. Deadlines are
+// emulated with COMMTIMEOUTS recomputed before every blocking attempt:
+// each ReadFile/WriteFile call is bounded by the remaining time to the
+// deadline (capped at maxIOSliceMillis so Close and deadline changes are
+// observed even on a port with no traffic).
+//
+// Reads use the documented COMMTIMEOUTS pattern for "return as soon as at
+// least one byte is available, or after the total timeout": interval and
+// multiplier MAXDWORD with a bounded positive total constant.
+//
+// COMMTIMEOUTS is one flat struct per handle governing both directions, so
+// every SetCommTimeouts call (whether issued by Read or Write) repopulates
+// BOTH directions from a single snapshot of both deadlines - leaving the
+// other direction's fields untouched or zeroed would silently corrupt it
+// (a zero write timeout means "wait forever"). Because the two directions
+// recompute independently, a deadline change made while the other
+// direction is mid-attempt can leave that attempt's view of the change
+// stale by at most one slice iteration; it self-corrects on that
+// direction's next attempt.
+//
+// Close() drains in-flight Read/Write attempts (tracked via ioCount)
+// before releasing the handle, repeatedly cancelling outstanding I/O with
+// CancelIoEx, so a syscall can never start on, or run past, a closed/
+// recycled handle.
+type winPort struct {
+ mu sync.Mutex
+ handle windows.Handle
+ closed bool
+ ioCount int // number of Read/Write attempts currently between beginIO and endIO
+ readDeadline time.Time
+ writeDeadline time.Time
+}
+
+const winMaxDword = 0xFFFFFFFF
+
+func openPort(portName string, cfg Config) (Port, error) {
+ // The \\.\ prefix is required for COM10 and above and harmless below.
+ path := portName
+ if !strings.HasPrefix(path, `\\.\`) {
+ path = `\\.\` + path
+ }
+ path16, err := windows.UTF16PtrFromString(path)
+ if err != nil {
+ return nil, fmt.Errorf("serialport: invalid port name %q: %w", portName, err)
+ }
+ handle, err := windows.CreateFile(path16,
+ windows.GENERIC_READ|windows.GENERIC_WRITE,
+ 0, // exclusive access
+ nil, // default security attributes
+ windows.OPEN_EXISTING,
+ 0, // synchronous I/O
+ 0)
+ if err != nil {
+ return nil, fmt.Errorf("serialport: opening %s: %w", portName, os.NewSyscallError("CreateFile", err))
+ }
+ p := &winPort{handle: handle}
+ if err := p.configure(cfg); err != nil {
+ _ = windows.CloseHandle(handle)
+ return nil, fmt.Errorf("serialport: configuring %s: %w", portName, err)
+ }
+ return p, nil
+}
+
+func (p *winPort) configure(cfg Config) error {
+ if err := windows.SetupComm(p.handle, 4096, 4096); err != nil {
+ return os.NewSyscallError("SetupComm", err)
+ }
+ var dcb windows.DCB
+ dcb.DCBlength = uint32(unsafe.Sizeof(dcb))
+ if err := windows.GetCommState(p.handle, &dcb); err != nil {
+ return os.NewSyscallError("GetCommState", err)
+ }
+ s := makeDCBSettings(cfg)
+ dcb.BaudRate = s.BaudRate
+ dcb.ByteSize = s.ByteSize
+ dcb.Parity = s.Parity
+ dcb.StopBits = s.StopBits
+ dcb.Flags = s.Flags
+ if err := windows.SetCommState(p.handle, &dcb); err != nil {
+ return os.NewSyscallError("SetCommState", err)
+ }
+ return nil
+}
+
+// commTimeouts builds a COMMTIMEOUTS value covering both directions from
+// the two slice lengths already computed for this instant. The read
+// fields always use the MAXDWORD/MAXDWORD/constant "return on first byte
+// or after the constant" pattern; the write multiplier stays 0 so the
+// constant alone bounds the write.
+func commTimeouts(readSliceMs, writeSliceMs uint32) windows.CommTimeouts {
+ return windows.CommTimeouts{
+ ReadIntervalTimeout: winMaxDword,
+ ReadTotalTimeoutMultiplier: winMaxDword,
+ ReadTotalTimeoutConstant: readSliceMs,
+ WriteTotalTimeoutConstant: writeSliceMs,
+ }
+}
+
+// sliceMillisForOtherDirection is like ioSliceMillis but never reports an
+// expired deadline as 0: a 0 total timeout constant means "wait forever"
+// to COMMTIMEOUTS (for the write side) or is at best ambiguous (for the
+// read side), which is exactly the cross-contamination this helper exists
+// to avoid. An already-expired deadline instead gets the smallest positive
+// slice so the other direction times out promptly rather than blocking;
+// that direction's own attempt independently detects the expiry as an
+// error.
+func sliceMillisForOtherDirection(now, deadline time.Time) uint32 {
+ ms, ok := ioSliceMillis(now, deadline)
+ if !ok {
+ return 1
+ }
+ return ms
+}
+
+// beginIO validates the port is still open, registers an in-flight
+// attempt (so Close knows to wait for it), and returns a single
+// consistent snapshot of the handle and both deadlines. Every attempt
+// that successfully calls beginIO must call endIO exactly once when it is
+// done, regardless of outcome.
+func (p *winPort) beginIO() (handle windows.Handle, readDeadline, writeDeadline time.Time, err error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.closed {
+ return 0, time.Time{}, time.Time{}, os.ErrClosed
+ }
+ p.ioCount++
+ return p.handle, p.readDeadline, p.writeDeadline, nil
+}
+
+// endIO retires an in-flight attempt registered by beginIO.
+func (p *winPort) endIO() {
+ p.mu.Lock()
+ p.ioCount--
+ p.mu.Unlock()
+}
+
+func (p *winPort) isClosed() bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.closed
+}
+
+func (p *winPort) Read(buf []byte) (int, error) {
+ if len(buf) == 0 {
+ return 0, nil
+ }
+ for {
+ n, done, err := p.readAttempt(buf)
+ if done {
+ return n, err
+ }
+ }
+}
+
+// readAttempt performs one bounded ReadFile call. done is true when the
+// result should be returned to the caller as-is; false means the read
+// deadline has not yet expired and Read should loop for another attempt.
+func (p *winPort) readAttempt(buf []byte) (n int, done bool, err error) {
+ handle, readDeadline, writeDeadline, err := p.beginIO()
+ if err != nil {
+ return 0, true, err
+ }
+ defer p.endIO()
+
+ now := time.Now()
+ readSliceMs, ok := ioSliceMillis(now, readDeadline)
+ if !ok {
+ return 0, true, os.ErrDeadlineExceeded
+ }
+ timeouts := commTimeouts(readSliceMs, sliceMillisForOtherDirection(now, writeDeadline))
+ if err := windows.SetCommTimeouts(handle, &timeouts); err != nil {
+ return 0, true, os.NewSyscallError("SetCommTimeouts", err)
+ }
+ var got uint32
+ if err := windows.ReadFile(handle, buf, &got, nil); err != nil {
+ if errors.Is(err, windows.ERROR_OPERATION_ABORTED) && p.isClosed() {
+ return int(got), true, os.ErrClosed
+ }
+ return int(got), true, os.NewSyscallError("ReadFile", err)
+ }
+ if got > 0 {
+ return int(got), true, nil
+ }
+ // Timed out with no data: expired deadline is an error, absence of
+ // a deadline means keep waiting in bounded slices.
+ if !readDeadline.IsZero() && !time.Now().Before(readDeadline) {
+ return 0, true, os.ErrDeadlineExceeded
+ }
+ return 0, false, nil
+}
+
+func (p *winPort) Write(buf []byte) (int, error) {
+ written := 0
+ for {
+ n, done, err := p.writeAttempt(buf, written)
+ written += n
+ if done {
+ return written, err
+ }
+ }
+}
+
+// writeAttempt performs one bounded WriteFile call for the remainder of
+// buf starting at offset written. n is the number of bytes written by
+// this attempt alone. done is true when the result should be returned to
+// the caller; false means the write deadline has not yet expired and
+// Write should loop for another attempt.
+func (p *winPort) writeAttempt(buf []byte, written int) (n int, done bool, err error) {
+ handle, readDeadline, writeDeadline, err := p.beginIO()
+ if err != nil {
+ return 0, true, err
+ }
+ defer p.endIO()
+
+ now := time.Now()
+ writeSliceMs, ok := ioSliceMillis(now, writeDeadline)
+ if !ok {
+ return 0, true, os.ErrDeadlineExceeded
+ }
+ timeouts := commTimeouts(sliceMillisForOtherDirection(now, readDeadline), writeSliceMs)
+ if err := windows.SetCommTimeouts(handle, &timeouts); err != nil {
+ return 0, true, os.NewSyscallError("SetCommTimeouts", err)
+ }
+ var got uint32
+ err = windows.WriteFile(handle, buf[written:], &got, nil)
+ if err != nil {
+ if errors.Is(err, windows.ERROR_OPERATION_ABORTED) && p.isClosed() {
+ return int(got), true, os.ErrClosed
+ }
+ return int(got), true, os.NewSyscallError("WriteFile", err)
+ }
+ if written+int(got) == len(buf) {
+ return int(got), true, nil
+ }
+ // Partial write: the slice timed out before the buffer drained.
+ if !writeDeadline.IsZero() && !time.Now().Before(writeDeadline) {
+ return int(got), true, os.ErrDeadlineExceeded
+ }
+ return int(got), false, nil
+}
+
+func (p *winPort) SetReadDeadline(t time.Time) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.closed {
+ return os.ErrClosed
+ }
+ p.readDeadline = t
+ return nil
+}
+
+func (p *winPort) SetWriteDeadline(t time.Time) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.closed {
+ return os.ErrClosed
+ }
+ p.writeDeadline = t
+ return nil
+}
+
+func (p *winPort) Close() error {
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ return nil
+ }
+ p.closed = true
+ handle := p.handle
+ // Drain attempts already registered via beginIO before releasing the
+ // handle: closed is now true, so no new attempt can increment
+ // ioCount past this point (beginIO checks closed under mu), but an
+ // attempt that snapshotted the handle just before we set closed may
+ // not have issued its ReadFile/WriteFile yet. Repeatedly cancel
+ // outstanding I/O until every registered attempt has reported back
+ // via endIO; CancelIoEx is a no-op if nothing is in flight yet, so we
+ // retry on a short poll until the racing attempt's syscall has
+ // actually started (and been cancelled) or completed.
+ for p.ioCount > 0 {
+ p.mu.Unlock()
+ _ = windows.CancelIoEx(handle, nil)
+ time.Sleep(time.Millisecond)
+ p.mu.Lock()
+ }
+ err := windows.CloseHandle(handle)
+ p.mu.Unlock()
+ if err != nil {
+ return os.NewSyscallError("CloseHandle", err)
+ }
+ return nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/pty_linux_test.go b/plc4go/spi/transports/serial/serialport/pty_linux_test.go
new file mode 100644
index 0000000000..df2ee516ef
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/pty_linux_test.go
@@ -0,0 +1,147 @@
+//go:build linux
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+// openPTY allocates a pseudo-terminal pair and returns the master side plus
+// the slave device path. A PTY slave accepts the same termios ioctls as a
+// real serial device (baud is faked but accepted), which lets the full
+// open→configure→read/write→deadline→close path run without hardware.
+func openPTY(t *testing.T) (master *os.File, slavePath string) {
+ t.Helper()
+ master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0)
+ require.NoError(t, err, "opening /dev/ptmx")
+ t.Cleanup(func() { _ = master.Close() })
+ ptn, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
+ require.NoError(t, err, "TIOCGPTN")
+ require.NoError(t, unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0), "unlocking pty slave")
+ return master, fmt.Sprintf("/dev/pts/%d", ptn)
+}
+
+func TestOpenOnPTY_ReadAndWrite(t *testing.T) {
+ master, slavePath := openPTY(t)
+
+ port, err := Open(slavePath, Config{BaudRate: 115200})
+ require.NoError(t, err)
+ defer port.Close()
+
+ // master -> port
+ _, err = master.Write([]byte("hello"))
+ require.NoError(t, err)
+ require.NoError(t, port.SetReadDeadline(time.Now().Add(5*time.Second)))
+ buf := make([]byte, 16)
+ n, err := port.Read(buf)
+ require.NoError(t, err)
+ assert.Equal(t, "hello", string(buf[:n]))
+
+ // port -> master
+ _, err = port.Write([]byte("world"))
+ require.NoError(t, err)
+ n, err = master.Read(buf)
+ require.NoError(t, err)
+ assert.Equal(t, "world", string(buf[:n]))
+}
+
+func TestOpenOnPTY_FullConfigAccepted(t *testing.T) {
+ // A PTY accepts arbitrary termios settings; this proves applySettings
+ // round-trips a non-default configuration without error.
+ _, slavePath := openPTY(t)
+ port, err := Open(slavePath, Config{
+ BaudRate: 2500000, // non-standard on purpose: exercises the BOTHER path
+ DataBits: 7, StopBits: StopBitsTwo, Parity: ParityEven, RTSCTSFlowControl: true,
+ })
+ require.NoError(t, err)
+ require.NoError(t, port.Close())
+}
+
+func TestOpenOnPTY_ReadDeadlineExpires(t *testing.T) {
+ _, slavePath := openPTY(t)
+ port, err := Open(slavePath, Config{BaudRate: 9600})
+ require.NoError(t, err)
+ defer port.Close()
+
+ require.NoError(t, port.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
+ start := time.Now()
+ _, err = port.Read(make([]byte, 1))
+ elapsed := time.Since(start)
+
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, os.ErrDeadlineExceeded), "want os.ErrDeadlineExceeded, got %v", err)
+ assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond)
+ assert.Less(t, elapsed, 5*time.Second)
+}
+
+func TestOpenOnPTY_ClearingDeadlineRestoresBlockingRead(t *testing.T) {
+ master, slavePath := openPTY(t)
+ port, err := Open(slavePath, Config{BaudRate: 9600})
+ require.NoError(t, err)
+ defer port.Close()
+
+ // Expired deadline: read fails immediately.
+ require.NoError(t, port.SetReadDeadline(time.Now().Add(-time.Second)))
+ _, err = port.Read(make([]byte, 1))
+ require.True(t, errors.Is(err, os.ErrDeadlineExceeded))
+
+ // Cleared deadline: read blocks until data arrives.
+ require.NoError(t, port.SetReadDeadline(time.Time{}))
+ go func() {
+ time.Sleep(20 * time.Millisecond)
+ _, _ = master.Write([]byte{0x42})
+ }()
+ buf := make([]byte, 1)
+ n, err := port.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+ assert.Equal(t, byte(0x42), buf[0])
+}
+
+func TestOpenOnPTY_UseAfterCloseFails(t *testing.T) {
+ _, slavePath := openPTY(t)
+ port, err := Open(slavePath, Config{BaudRate: 9600})
+ require.NoError(t, err)
+ require.NoError(t, port.Close())
+
+ _, err = port.Read(make([]byte, 1))
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, os.ErrClosed), "read after close: want os.ErrClosed, got %v", err)
+
+ _, err = port.Write([]byte{0x01})
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, os.ErrClosed), "write after close: want os.ErrClosed, got %v", err)
+}
+
+func TestOpenNonexistentDeviceFails(t *testing.T) {
+ _, err := Open("/dev/plc4x-does-not-exist", Config{BaudRate: 9600})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "plc4x-does-not-exist")
+}
diff --git a/plc4go/spi/transports/serial/serialport/serialport.go b/plc4go/spi/transports/serial/serialport/serialport.go
new file mode 100644
index 0000000000..1b7b0413c4
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/serialport.go
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+// Package serialport provides access to serial ports (RS-232/RS-485 style
+// UART devices) with net.Conn-like read/write deadline semantics.
+//
+// It is an internal replacement for the unmaintained
+// github.com/jacobsa/go-serial dependency, informed by that library
+// (Apache License 2.0) and the underlying platform specifications
+// (POSIX termios, Windows DCB/COMMTIMEOUTS).
+package serialport
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "time"
+)
+
+// Parity configures the parity bit generated and checked by the UART.
+type Parity int
+
+const (
+ ParityNone Parity = iota
+ ParityOdd
+ ParityEven
+)
+
+// StopBits configures the number of stop bits.
+type StopBits int
+
+const (
+ StopBitsOne StopBits = iota
+ StopBitsOnePointFive // only supported on Windows, and only with 5 data bits
+ StopBitsTwo
+)
+
+// Config describes how a serial port is configured on Open. The zero value
+// of every field except BaudRate is a valid default (8 data bits, 1 stop
+// bit, no parity, no flow control).
+//
+// There are deliberately no VMIN/VTIME-style read-threshold options: ports
+// are opened non-blocking and Read returns as soon as at least one byte is
+// available, bounded by SetReadDeadline — the same semantics as net.Conn.
+type Config struct {
+ BaudRate uint
+ DataBits uint // 5..8; 0 defaults to 8
+ StopBits StopBits
+ Parity Parity
+ RTSCTSFlowControl bool
+}
+
+// Port is an open serial port. Read blocks until at least one byte is
+// available, the read deadline expires (os.ErrDeadlineExceeded), or the
+// port is closed.
+type Port interface {
+ io.ReadWriteCloser
+ SetReadDeadline(t time.Time) error
+ SetWriteDeadline(t time.Time) error
+}
+
+// ErrUnsupportedPlatform is returned by Open on operating systems without
+// a serial port implementation.
+var ErrUnsupportedPlatform = errors.New("serial ports are not supported on this platform")
+
+// Open opens and configures the named serial port (e.g. "/dev/ttyUSB0" or
+// "COM3").
+func Open(portName string, cfg Config) (Port, error) {
+ if portName == "" {
+ return nil, errors.New("serialport: port name must not be empty")
+ }
+ normalized, err := cfg.normalize()
+ if err != nil {
+ return nil, err
+ }
+ return openPort(portName, normalized)
+}
+
+// normalize validates cfg and fills in defaults.
+func (c Config) normalize() (Config, error) {
+ if c.BaudRate == 0 {
+ return c, errors.New("serialport: baud rate must be greater than 0")
+ }
+ if c.DataBits == 0 {
+ c.DataBits = 8
+ }
+ if c.DataBits < 5 || c.DataBits > 8 {
+ return c, fmt.Errorf("serialport: invalid data bits %d (must be 5..8)", c.DataBits)
+ }
+ switch c.StopBits {
+ case StopBitsOne, StopBitsOnePointFive, StopBitsTwo:
+ default:
+ return c, fmt.Errorf("serialport: invalid stop bits value %d", c.StopBits)
+ }
+ switch c.Parity {
+ case ParityNone, ParityOdd, ParityEven:
+ default:
+ return c, fmt.Errorf("serialport: invalid parity value %d", c.Parity)
+ }
+ if c.StopBits == StopBitsOnePointFive && c.DataBits != 5 {
+ return c, errors.New("serialport: 1.5 stop bits require 5 data bits")
+ }
+ return c, nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/serialport_test.go b/plc4go/spi/transports/serial/serialport/serialport_test.go
new file mode 100644
index 0000000000..10c949e22c
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/serialport_test.go
@@ -0,0 +1,107 @@
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigNormalize(t *testing.T) {
+ tests := []struct {
+ name string
+ in Config
+ want Config
+ wantErr string
+ }{
+ {
+ name: "defaults applied: zero data bits become 8",
+ in: Config{BaudRate: 9600},
+ want: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityNone},
+ },
+ {
+ name: "explicit full config kept",
+ in: Config{BaudRate: 115200, DataBits: 7, StopBits: StopBitsTwo, Parity: ParityEven, RTSCTSFlowControl: true},
+ want: Config{BaudRate: 115200, DataBits: 7, StopBits: StopBitsTwo, Parity: ParityEven, RTSCTSFlowControl: true},
+ },
+ {
+ name: "zero baud rate rejected",
+ in: Config{},
+ wantErr: "baud rate",
+ },
+ {
+ name: "data bits below 5 rejected",
+ in: Config{BaudRate: 9600, DataBits: 4},
+ wantErr: "data bits",
+ },
+ {
+ name: "data bits above 8 rejected",
+ in: Config{BaudRate: 9600, DataBits: 9},
+ wantErr: "data bits",
+ },
+ {
+ name: "invalid stop bits enum rejected",
+ in: Config{BaudRate: 9600, StopBits: StopBits(99)},
+ wantErr: "stop bits",
+ },
+ {
+ name: "invalid parity enum rejected",
+ in: Config{BaudRate: 9600, Parity: Parity(99)},
+ wantErr: "parity",
+ },
+ {
+ // Windows rule (only platform supporting 1.5): 1.5 stop bits require 5 data bits.
+ name: "1.5 stop bits with 8 data bits rejected",
+ in: Config{BaudRate: 9600, StopBits: StopBitsOnePointFive},
+ wantErr: "1.5 stop bits",
+ },
+ {
+ name: "1.5 stop bits with 5 data bits accepted",
+ in: Config{BaudRate: 9600, DataBits: 5, StopBits: StopBitsOnePointFive},
+ want: Config{BaudRate: 9600, DataBits: 5, StopBits: StopBitsOnePointFive, Parity: ParityNone},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := tt.in.normalize()
+ if tt.wantErr != "" {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestOpenRejectsEmptyPortName(t *testing.T) {
+ _, err := Open("", Config{BaudRate: 9600})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "port name")
+}
+
+func TestOpenRejectsInvalidConfigBeforeTouchingPort(t *testing.T) {
+ _, err := Open("/dev/does-not-matter", Config{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "baud rate")
+}
diff --git a/plc4go/spi/transports/serial/serialport/termios_unix.go b/plc4go/spi/transports/serial/serialport/termios_unix.go
new file mode 100644
index 0000000000..00d11328da
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/termios_unix.go
@@ -0,0 +1,77 @@
+//go:build linux || darwin || freebsd
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "errors"
+
+ "golang.org/x/sys/unix"
+)
+
+// makeTermios translates an already-normalized Config into a raw-mode
+// termios structure per POSIX: non-canonical, no echo, no signal or
+// flow-control character processing, receiver enabled, modem control
+// lines ignored (CLOCAL). Baud rate is set separately by the per-OS
+// applySettings, because speed handling differs between kernels.
+func makeTermios(cfg Config) (*unix.Termios, error) {
+ t := &unix.Termios{}
+ t.Cflag |= unix.CLOCAL | unix.CREAD
+
+ switch cfg.DataBits {
+ case 5:
+ t.Cflag |= unix.CS5
+ case 6:
+ t.Cflag |= unix.CS6
+ case 7:
+ t.Cflag |= unix.CS7
+ case 8:
+ t.Cflag |= unix.CS8
+ }
+
+ switch cfg.StopBits {
+ case StopBitsOne:
+ // CSTOPB cleared
+ case StopBitsTwo:
+ t.Cflag |= unix.CSTOPB
+ case StopBitsOnePointFive:
+ return nil, errors.New("serialport: 1.5 stop bits are not supported on this platform")
+ }
+
+ switch cfg.Parity {
+ case ParityNone:
+ case ParityOdd:
+ t.Cflag |= unix.PARENB | unix.PARODD
+ case ParityEven:
+ t.Cflag |= unix.PARENB
+ }
+
+ if cfg.RTSCTSFlowControl {
+ t.Cflag |= unix.CRTSCTS
+ }
+
+ // The port is opened O_NONBLOCK and driven by the runtime poller, which
+ // makes the kernel ignore VMIN/VTIME; set the conventional raw-mode
+ // values anyway to document intent (and for any future blocking use).
+ t.Cc[unix.VMIN] = 1
+ t.Cc[unix.VTIME] = 0
+ return t, nil
+}
diff --git a/plc4go/spi/transports/serial/serialport/termios_unix_test.go b/plc4go/spi/transports/serial/serialport/termios_unix_test.go
new file mode 100644
index 0000000000..ac6307c003
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/termios_unix_test.go
@@ -0,0 +1,99 @@
+//go:build linux || darwin || freebsd
+
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/sys/unix"
+)
+
+// Expected flag values per POSIX (IEEE Std 1003.1) termios: raw,
+// non-canonical mode with receiver enabled and modem status ignored.
+func TestMakeTermios(t *testing.T) {
+ base := uint64(unix.CLOCAL | unix.CREAD)
+ tests := []struct {
+ name string
+ cfg Config
+ wantCflag uint64
+ }{
+ {
+ name: "8N1",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityNone},
+ wantCflag: base | unix.CS8,
+ },
+ {
+ name: "7E1",
+ cfg: Config{BaudRate: 9600, DataBits: 7, StopBits: StopBitsOne, Parity: ParityEven},
+ wantCflag: base | unix.CS7 | unix.PARENB,
+ },
+ {
+ name: "odd parity sets PARENB and PARODD",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityOdd},
+ wantCflag: base | unix.CS8 | unix.PARENB | unix.PARODD,
+ },
+ {
+ name: "two stop bits set CSTOPB",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsTwo, Parity: ParityNone},
+ wantCflag: base | unix.CS8 | unix.CSTOPB,
+ },
+ {
+ name: "5 data bits",
+ cfg: Config{BaudRate: 9600, DataBits: 5, StopBits: StopBitsOne, Parity: ParityNone},
+ wantCflag: base | unix.CS5,
+ },
+ {
+ name: "6 data bits",
+ cfg: Config{BaudRate: 9600, DataBits: 6, StopBits: StopBitsOne, Parity: ParityNone},
+ wantCflag: base | unix.CS6,
+ },
+ {
+ name: "hardware flow control sets CRTSCTS",
+ cfg: Config{BaudRate: 9600, DataBits: 8, StopBits: StopBitsOne, Parity: ParityNone, RTSCTSFlowControl: true},
+ wantCflag: base | unix.CS8 | unix.CRTSCTS,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := makeTermios(tt.cfg)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantCflag, uint64(got.Cflag), "Cflag")
+ // Raw mode: input/output/local processing fully disabled per POSIX
+ // (no ICANON/ECHO/ISIG, no IXON/ICRNL/INPCK, no OPOST).
+ assert.Zero(t, got.Iflag, "Iflag must be 0 (raw)")
+ assert.Zero(t, got.Oflag, "Oflag must be 0 (raw)")
+ assert.Zero(t, got.Lflag, "Lflag must be 0 (raw)")
+ // VMIN/VTIME are documented intent only: the fd is non-blocking, so
+ // the runtime poller (deadlines) governs read blocking behavior.
+ assert.EqualValues(t, 1, got.Cc[unix.VMIN], "VMIN")
+ assert.EqualValues(t, 0, got.Cc[unix.VTIME], "VTIME")
+ })
+ }
+}
+
+func TestMakeTermiosRejectsOnePointFiveStopBits(t *testing.T) {
+ _, err := makeTermios(Config{BaudRate: 9600, DataBits: 5, StopBits: StopBitsOnePointFive})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "1.5 stop bits")
+}
diff --git a/plc4go/spi/transports/serial/serialport/timeouts.go b/plc4go/spi/transports/serial/serialport/timeouts.go
new file mode 100644
index 0000000000..ebd969b21f
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/timeouts.go
@@ -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.
+ */
+
+package serialport
+
+import "time"
+
+// maxIOSliceMillis caps how long a single blocking I/O attempt may last on
+// platforms where blocking calls cannot be interrupted (Windows), so that
+// Close and deadline changes are observed within this bound.
+const maxIOSliceMillis = 60_000
+
+// ioSliceMillis returns how many milliseconds the next blocking I/O attempt
+// may last, given the current time and an absolute deadline (zero = none).
+// ok is false when the deadline has already expired; ms is then 0.
+// A non-expired deadline always yields ms >= 1 (sub-millisecond remainders
+// round up) because a 0 would mean "no timeout" to COMMTIMEOUTS.
+func ioSliceMillis(now, deadline time.Time) (ms uint32, ok bool) {
+ if deadline.IsZero() {
+ return maxIOSliceMillis, true
+ }
+ remaining := deadline.Sub(now)
+ if remaining <= 0 {
+ return 0, false
+ }
+ millis := (remaining + time.Millisecond - 1) / time.Millisecond
+ if millis > maxIOSliceMillis {
+ return maxIOSliceMillis, true
+ }
+ return uint32(millis), true
+}
diff --git a/plc4go/spi/transports/serial/serialport/timeouts_test.go b/plc4go/spi/transports/serial/serialport/timeouts_test.go
new file mode 100644
index 0000000000..ff52402dd8
--- /dev/null
+++ b/plc4go/spi/transports/serial/serialport/timeouts_test.go
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+
+package serialport
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIOSliceMillis(t *testing.T) {
+ now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ deadline time.Time
+ wantMs uint32
+ wantOK bool
+ }{
+ {"no deadline uses the max slice", time.Time{}, maxIOSliceMillis, true},
+ {"future deadline: exact milliseconds", now.Add(1500 * time.Millisecond), 1500, true},
+ {"sub-millisecond remainder rounds up, never 0", now.Add(100 * time.Microsecond), 1, true},
+ {"far deadline capped to max slice", now.Add(time.Hour), maxIOSliceMillis, true},
+ {"deadline exactly now: expired", now, 0, false},
+ {"past deadline: expired", now.Add(-time.Second), 0, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ms, ok := ioSliceMillis(now, tt.deadline)
+ assert.Equal(t, tt.wantOK, ok)
+ assert.Equal(t, tt.wantMs, ms)
+ })
+ }
+}
diff --git a/plc4go/tools.mod b/plc4go/tools.mod
index 082dd60a47..082e5c9239 100644
--- a/plc4go/tools.mod
+++ b/plc4go/tools.mod
@@ -33,11 +33,10 @@ require (
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gopacket/gopacket v1.5.0
- github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4
- github.com/libp2p/go-reuseport v0.4.0
github.com/rs/zerolog v1.33.0
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.43.0
+ golang.org/x/sys v0.36.0
golang.org/x/text v0.28.0
golang.org/x/tools v0.36.0
)
@@ -67,7 +66,6 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cobra v1.8.1 // indirect
@@ -80,7 +78,6 @@ require (
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sync v0.17.0 // indirect
- golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/gotestsum v1.13.0 // indirect
diff --git a/plc4go/tools.sum b/plc4go/tools.sum
index 0c421ca546..d6efe80f51 100644
--- a/plc4go/tools.sum
+++ b/plc4go/tools.sum
@@ -34,8 +34,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/incu6us/goimports-reviser/v3 v3.11.0 h1:ApPsIVH/pT1XFwA8ewJR1o6u5y4sqcVZvz4dITCN6Nk=
github.com/incu6us/goimports-reviser/v3 v3.11.0/go.mod h1:9PrNQlAiw8j+Z6xKOtheaC9N4T5J4EmwJkmbEQ3+KCQ=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0=
-github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs=
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
@@ -56,8 +54,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s=
-github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -70,7 +66,6 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=