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 b257386f0714952175c3dd039a6b0dfa140040fa
Author: Sebastian Rühl <[email protected]>
AuthorDate: Mon Jul 6 14:49:47 2026 +0200

    feat(plc4go): full serial option support in connection strings
    
    The serial transport now parses data-bits, stop-bits, parity (incl.
    mark/space), flow-control (rts-cts, xon-xoff), dtr, rts, read-timeout
    and write-timeout. Timeouts act as fallback deadlines: an explicit
    context deadline always wins; deadline-less reads/writes are bounded by
    the configured timeout (default 1000 ms, 0 = blocking). Invalid option
    values fail connection creation naming the option; enum values are
    case-insensitive with "-"/"_" interchangeable.
    
    Breaking: default baud-rate changed from 115200 to 9600; deadline-less
    I/O no longer blocks indefinitely by default (see RELEASE_NOTES).
---
 RELEASE_NOTES                                      |  12 ++
 plc4go/spi/transports/serial/Transport.go          |  24 +--
 plc4go/spi/transports/serial/TransportInstance.go  |  55 +++++--
 .../serial/TransportInstance_pty_test.go           |  68 +++++++++
 .../transports/serial/TransportInstance_test.go    |  87 +++++++++++
 plc4go/spi/transports/serial/deadline_reader.go    |  80 ++++++++++
 .../spi/transports/serial/deadline_reader_test.go  | 110 ++++++++++++++
 plc4go/spi/transports/serial/options.go            | 167 +++++++++++++++++++++
 plc4go/spi/transports/serial/options_test.go       | 160 ++++++++++++++++++++
 9 files changed, 734 insertions(+), 29 deletions(-)

diff --git a/RELEASE_NOTES b/RELEASE_NOTES
index 8742246cdd..8278a7be72 100644
--- a/RELEASE_NOTES
+++ b/RELEASE_NOTES
@@ -16,12 +16,24 @@ New Features
   requires mandatory username/password authentication
   (configured via the new "username" and "password" connection
   parameters).
+- The Go serial transport now supports the full set of serial
+  options in the connection string: data-bits, stop-bits, parity,
+  flow-control, dtr, rts, read-timeout and write-timeout.
 
 Incompatible changes
 --------------------
 
 - Dropped support for Java 11, new baseline Java version is
   Java 21.
+- The Go serial transport's default baud-rate changed from 115200
+  to 9600 (aligning with common serial defaults and the Java
+  transport). Specify baud-rate explicitly if you relied on the
+  previous default.
+- Go serial reads/writes without an explicit context deadline are
+  now bounded by the new read-timeout/write-timeout options
+  (default 1000 ms; set to 0 for the previous blocking behavior),
+  and invalid serial option values now fail connection creation
+  instead of being silently ignored.
 - The 'plc4x' proxy driver now defaults to the TLS transport
   instead of plaintext TCP. Existing plaintext connections must
   switch to an explicit transport prefix (e.g. "plc4x:tcp://...").
diff --git a/plc4go/spi/transports/serial/Transport.go 
b/plc4go/spi/transports/serial/Transport.go
index 6716b54434..700e4ee04b 100644
--- a/plc4go/spi/transports/serial/Transport.go
+++ b/plc4go/spi/transports/serial/Transport.go
@@ -22,7 +22,6 @@ package serial
 import (
        "net"
        "net/url"
-       "strconv"
 
        "github.com/rs/zerolog"
 
@@ -57,27 +56,12 @@ func (m *Transport) CreateTransportInstance(transportUrl 
url.URL, options map[st
 func (m *Transport) CreateTransportInstanceForLocalAddress(transportUrl 
url.URL, options map[string][]string, _ *net.UDPAddr, _options 
...options.WithOption) (transports.TransportInstance, error) {
        var serialPortName = transportUrl.Path
 
-       var baudRate = uint(115200)
-       if val, ok := options["baud-rate"]; ok {
-               parsedBaudRate, err := strconv.ParseUint(val[0], 10, 32)
-               if err != nil {
-                       return nil, errors.Wrap(err, "error setting 
connect-timeout")
-               } else {
-                       baudRate = uint(parsedBaudRate)
-               }
+       cfg, err := parseSerialOptions(options)
+       if err != nil {
+               return nil, errors.Wrap(err, "error parsing serial options")
        }
 
-       var connectTimeout uint32 = 1000
-       if val, ok := options["connect-timeout"]; ok {
-               parsedConnectTimeout, err := strconv.ParseUint(val[0], 10, 32)
-               if err != nil {
-                       return nil, errors.Wrap(err, "error setting 
connect-timeout")
-               } else {
-                       connectTimeout = uint32(parsedConnectTimeout)
-               }
-       }
-
-       return NewTransportInstance(serialPortName, baudRate, connectTimeout, 
m, _options...), nil
+       return NewTransportInstanceWithConfig(serialPortName, cfg, m, 
_options...), nil
 }
 
 func (m *Transport) Close() error {
diff --git a/plc4go/spi/transports/serial/TransportInstance.go 
b/plc4go/spi/transports/serial/TransportInstance.go
index 772ae3b9f4..562fc43b0a 100644
--- a/plc4go/spi/transports/serial/TransportInstance.go
+++ b/plc4go/spi/transports/serial/TransportInstance.go
@@ -45,12 +45,14 @@ type TransportInstance struct {
        SerialPortName string
        BaudRate       uint
        ConnectTimeout uint32
+       cfg            serialConfig
 
        connected        atomic.Bool
        stateChangeMutex sync.Mutex
 
        transport  *Transport
        serialPort serialport.Port
+       armer      *deadlineReader
        reader     *bufio.Reader
 
        log zerolog.Logger
@@ -59,11 +61,19 @@ type TransportInstance struct {
 var _ transports.TransportInstance = (*TransportInstance)(nil)
 
 func NewTransportInstance(serialPortName string, baudRate uint, connectTimeout 
uint32, transport *Transport, _options ...options.WithOption) 
*TransportInstance {
+       cfg := defaultSerialConfig()
+       cfg.port.BaudRate = baudRate
+       cfg.connectTimeout = connectTimeout
+       return NewTransportInstanceWithConfig(serialPortName, cfg, transport, 
_options...)
+}
+
+func NewTransportInstanceWithConfig(serialPortName string, cfg serialConfig, 
transport *Transport, _options ...options.WithOption) *TransportInstance {
        customLogger := 
options.ExtractCustomLoggerOrDefaultToGlobal(_options...)
        transportInstance := &TransportInstance{
                SerialPortName: serialPortName,
-               BaudRate:       baudRate,
-               ConnectTimeout: connectTimeout,
+               BaudRate:       cfg.port.BaudRate,
+               ConnectTimeout: cfg.connectTimeout,
+               cfg:            cfg,
                transport:      transport,
 
                log: customLogger,
@@ -79,17 +89,38 @@ func (m *TransportInstance) Connect(ctx context.Context) 
error {
                return errors.New("Already connected")
        }
 
-       serialPort, err := serialport.Open(m.SerialPortName, 
serialport.Config{BaudRate: m.BaudRate})
+       serialPort, err := serialport.Open(m.SerialPortName, m.cfg.port)
        if err != nil {
                return errors.Wrap(err, "error connecting to serial port")
        }
        m.serialPort = serialPort
-       m.reader = bufio.NewReader(m.serialPort)
+       m.applyModemLines(serialPort)
+       m.armer = newDeadlineReader(serialPort, m.cfg.readTimeout)
+       m.reader = bufio.NewReader(m.armer)
        m.connected.Store(true)
-
        return nil
 }
 
+// applyModemLines asserts DTR/RTS when configured. Failures are warnings,
+// not connection errors: ptys and adapters without modem lines must stay
+// usable, and a missing line rarely prevents communication.
+func (m *TransportInstance) applyModemLines(port serialport.Port) {
+       controlPort, ok := port.(serialport.ControlPort)
+       if !ok {
+               return
+       }
+       if m.cfg.dtr {
+               if err := controlPort.SetDTR(true); err != nil {
+                       m.log.Warn().Err(err).Msg("could not assert DTR")
+               }
+       }
+       if m.cfg.rts {
+               if err := controlPort.SetRTS(true); err != nil {
+                       m.log.Warn().Err(err).Msg("could not assert RTS")
+               }
+       }
+}
+
 func (m *TransportInstance) Reset() {
        // No-Op
 }
@@ -107,6 +138,7 @@ func (m *TransportInstance) Close() error {
                return errors.Wrap(err, "error closing serial port")
        }
        m.serialPort = nil
+       m.armer = nil
 
        m.connected.Store(false)
        return nil
@@ -127,6 +159,10 @@ func (m *TransportInstance) Write(ctx context.Context, 
data []byte) error {
                if err := m.serialPort.SetWriteDeadline(deadline); err != nil {
                        return errors.Wrap(err, "error setting write deadline")
                }
+       } else if m.cfg.writeTimeout > 0 {
+               if err := 
m.serialPort.SetWriteDeadline(time.Now().Add(m.cfg.writeTimeout)); err != nil {
+                       return errors.Wrap(err, "error setting write deadline")
+               }
        } else if err := m.serialPort.SetWriteDeadline(time.Time{}); err != nil 
{
                return errors.Wrap(err, "error clearing write deadline")
        }
@@ -145,11 +181,12 @@ func (m *TransportInstance) GetReader() 
transports.ExtendedReader {
 }
 
 func (m *TransportInstance) SetReadDeadline(deadline time.Time) error {
-       serialPort := m.serialPort
-       if serialPort == nil {
-               return errors.New("error setting read deadline. No serial port 
available")
+       armer := m.armer
+       if armer == nil {
+               return errors.New("error setting read deadline. Not connected")
        }
-       return serialPort.SetReadDeadline(deadline)
+       armer.setExplicitDeadline(deadline)
+       return nil
 }
 
 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
index 04b3255e9a..d5a94bae8b 100644
--- a/plc4go/spi/transports/serial/TransportInstance_pty_test.go
+++ b/plc4go/spi/transports/serial/TransportInstance_pty_test.go
@@ -24,6 +24,7 @@ package serial
 import (
        "context"
        "fmt"
+       "net/url"
        "os"
        "testing"
        "time"
@@ -105,3 +106,70 @@ func TestTransportInstance_WriteDeadlineDoesNotStick(t 
*testing.T) {
        // A deadline-less write must not inherit the lapsed deadline.
        require.NoError(t, instance.Write(context.Background(), []byte{0x02}))
 }
+
+func TestTransportInstance_OptionsAppliedOnPTY(t *testing.T) {
+       master, slavePath := openPTY(t)
+
+       transport := NewTransport()
+       instance, err := transport.CreateTransportInstance(
+               url.URL{Scheme: "serial", Path: slavePath},
+               map[string][]string{
+                       "data-bits": {"7"},
+                       "parity":    {"EVEN"}, // deliberately non-canonical 
case
+                       "stop-bits": {"2"},
+                       "dtr":       {"true"}, // must warn, not fail, on a pty
+               },
+       )
+       require.NoError(t, err)
+       require.NoError(t, instance.Connect(context.Background()))
+       t.Cleanup(func() { _ = instance.Close() })
+
+       // The pty pair shares one termios; reading it back on the MASTER side
+       // proves the options reached the kernel.
+       readBack, err := unix.IoctlGetTermios(int(master.Fd()), unix.TCGETS)
+       require.NoError(t, err)
+       assert.NotZero(t, readBack.Cflag&unix.CS7, "CS7")
+       assert.NotZero(t, readBack.Cflag&unix.CSTOPB, "CSTOPB")
+       // PARENB deliberately not asserted (known pty parity quirk).
+}
+
+func TestTransportInstance_FallbackReadDeadlineBoundsSilentRead(t *testing.T) {
+       _, slavePath := openPTY(t)
+       transport := NewTransport()
+       instance, err := transport.CreateTransportInstance(
+               url.URL{Scheme: "serial", Path: slavePath},
+               map[string][]string{"read-timeout": {"200"}},
+       )
+       require.NoError(t, err)
+       require.NoError(t, instance.Connect(context.Background()))
+       t.Cleanup(func() { _ = instance.Close() })
+
+       start := time.Now()
+       _, err = instance.Read(context.Background(), 1) // no ctx deadline
+       elapsed := time.Since(start)
+
+       require.Error(t, err, "silent line must time out via the fallback 
deadline")
+       assert.GreaterOrEqual(t, elapsed, 200*time.Millisecond)
+       assert.Less(t, elapsed, 5*time.Second)
+}
+
+func TestTransportInstance_ExplicitCtxDeadlineBeatsFallback(t *testing.T) {
+       _, slavePath := openPTY(t)
+       transport := NewTransport()
+       instance, err := transport.CreateTransportInstance(
+               url.URL{Scheme: "serial", Path: slavePath},
+               map[string][]string{"read-timeout": {"60000"}},
+       )
+       require.NoError(t, err)
+       require.NoError(t, instance.Connect(context.Background()))
+       t.Cleanup(func() { _ = instance.Close() })
+
+       ctx, cancel := context.WithTimeout(context.Background(), 
100*time.Millisecond)
+       defer cancel()
+       start := time.Now()
+       _, err = instance.Read(ctx, 1)
+       elapsed := time.Since(start)
+
+       require.Error(t, err)
+       assert.Less(t, elapsed, 5*time.Second, "the 100ms ctx deadline must win 
over the 60s fallback")
+}
diff --git a/plc4go/spi/transports/serial/TransportInstance_test.go 
b/plc4go/spi/transports/serial/TransportInstance_test.go
index 6690958468..33e93cd013 100644
--- a/plc4go/spi/transports/serial/TransportInstance_test.go
+++ b/plc4go/spi/transports/serial/TransportInstance_test.go
@@ -21,10 +21,13 @@ package serial
 
 import (
        "bufio"
+       "context"
+       "net/url"
        "testing"
        "time"
 
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
 
        "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
        transportUtils "github.com/apache/plc4x/plc4go/spi/transports/utils"
@@ -277,6 +280,17 @@ func (stubPort) Close() error                     { return 
nil }
 func (stubPort) SetReadDeadline(time.Time) error  { return nil }
 func (stubPort) SetWriteDeadline(time.Time) error { return nil }
 
+// recordingWritePort records SetWriteDeadline calls.
+type recordingWritePort struct {
+       stubPort
+       writeDeadlines []time.Time
+}
+
+func (r *recordingWritePort) SetWriteDeadline(t time.Time) error {
+       r.writeDeadlines = append(r.writeDeadlines, t)
+       return nil
+}
+
 func TestTransportInstance_IsConnected_keysOnConnectedFlag(t *testing.T) {
        m := &TransportInstance{}
        m.serialPort = stubPort{}
@@ -284,3 +298,76 @@ func 
TestTransportInstance_IsConnected_keysOnConnectedFlag(t *testing.T) {
        m.connected.Store(true)
        assert.True(t, m.IsConnected())
 }
+
+func TestParseAndCreate_OptionsReachInstance(t *testing.T) {
+       transport := NewTransport()
+       instance, err := transport.CreateTransportInstance(
+               url.URL{Scheme: "serial", Path: "/dev/ttyTest0"},
+               map[string][]string{
+                       "baud-rate":    {"19200"},
+                       "data-bits":    {"7"},
+                       "parity":       {"even"},
+                       "stop-bits":    {"2"},
+                       "read-timeout": {"250"},
+               },
+       )
+       require.NoError(t, err)
+       serialInstance := instance.(*TransportInstance)
+       assert.Equal(t, "/dev/ttyTest0", serialInstance.SerialPortName)
+       assert.Equal(t, uint(19200), serialInstance.BaudRate, "legacy mirror 
field")
+       assert.Equal(t, uint(19200), serialInstance.cfg.port.BaudRate)
+       assert.Equal(t, uint(7), serialInstance.cfg.port.DataBits)
+       assert.Equal(t, serialport.ParityEven, serialInstance.cfg.port.Parity)
+       assert.Equal(t, serialport.StopBitsTwo, 
serialInstance.cfg.port.StopBits)
+       assert.Equal(t, 250*time.Millisecond, serialInstance.cfg.readTimeout)
+}
+
+func TestParseAndCreate_InvalidOptionFailsFast(t *testing.T) {
+       transport := NewTransport()
+       _, err := transport.CreateTransportInstance(
+               url.URL{Scheme: "serial", Path: "/dev/ttyTest0"},
+               map[string][]string{"parity": {"strong"}},
+       )
+       require.Error(t, err)
+       assert.Contains(t, err.Error(), `"parity"`)
+}
+
+func TestWrite_FallbackWriteDeadline(t *testing.T) {
+       port := &recordingWritePort{}
+       m := NewTransportInstanceWithConfig("test", func() serialConfig {
+               c := defaultSerialConfig()
+               c.writeTimeout = 300 * time.Millisecond
+               return c
+       }(), NewTransport())
+       m.serialPort = port
+       m.connected.Store(true)
+
+       // No ctx deadline: fallback must be armed.
+       before := time.Now()
+       require.NoError(t, m.Write(context.Background(), []byte{0x01}))
+       require.Len(t, port.writeDeadlines, 1)
+       assert.False(t, 
port.writeDeadlines[0].Before(before.Add(300*time.Millisecond)))
+
+       // Explicit ctx deadline wins.
+       deadline := time.Now().Add(42 * time.Second)
+       ctx, cancel := context.WithDeadline(context.Background(), deadline)
+       defer cancel()
+       require.NoError(t, m.Write(ctx, []byte{0x02}))
+       require.Len(t, port.writeDeadlines, 2)
+       assert.Equal(t, deadline, port.writeDeadlines[1])
+}
+
+func TestWrite_ZeroWriteTimeoutClearsDeadline(t *testing.T) {
+       port := &recordingWritePort{}
+       m := NewTransportInstanceWithConfig("test", func() serialConfig {
+               c := defaultSerialConfig()
+               c.writeTimeout = 0
+               return c
+       }(), NewTransport())
+       m.serialPort = port
+       m.connected.Store(true)
+
+       require.NoError(t, m.Write(context.Background(), []byte{0x01}))
+       require.Len(t, port.writeDeadlines, 1)
+       assert.True(t, port.writeDeadlines[0].IsZero())
+}
diff --git a/plc4go/spi/transports/serial/deadline_reader.go 
b/plc4go/spi/transports/serial/deadline_reader.go
new file mode 100644
index 0000000000..78e6cd5080
--- /dev/null
+++ b/plc4go/spi/transports/serial/deadline_reader.go
@@ -0,0 +1,80 @@
+/*
+ * 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 (
+       "sync/atomic"
+       "time"
+
+       "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
+)
+
+// deadlineReader arms the port's read deadline before every Read. An
+// explicit deadline set via setExplicitDeadline (the context deadline of
+// the current transport operation) always wins; an explicit deadline
+// observed as already expired is honored for exactly one read — so the
+// bounded caller gets its timeout error — and then auto-clears. Without an
+// active explicit deadline the configured fallback applies (0 = blocking),
+// which keeps bare buffered-layer polls bounded instead of hanging forever
+// on a silent line.
+type deadlineReader struct {
+       port     serialport.Port
+       fallback time.Duration
+       explicit atomic.Value // time.Time; zero value = none
+}
+
+func newDeadlineReader(port serialport.Port, fallback time.Duration) 
*deadlineReader {
+       r := &deadlineReader{port: port, fallback: fallback}
+       r.explicit.Store(time.Time{})
+       return r
+}
+
+// setExplicitDeadline sets (or, with a zero time, clears) the explicit
+// read deadline. Safe for concurrent use with Read.
+func (r *deadlineReader) setExplicitDeadline(t time.Time) {
+       r.explicit.Store(t)
+}
+
+func (r *deadlineReader) Read(p []byte) (int, error) {
+       deadline, _ := r.explicit.Load().(time.Time)
+       switch {
+       case !deadline.IsZero():
+               if err := r.port.SetReadDeadline(deadline); err != nil {
+                       return 0, err
+               }
+               if !time.Now().Before(deadline) {
+                       // Expired: honored for this read, cleared for the 
next. CAS so
+                       // a concurrent setExplicitDeadline is never 
overwritten. The old
+                       // value MUST be the exact Load()ed time.Time (never 
reconstructed
+                       // or derived): CompareAndSwap uses interface ==, which 
includes
+                       // time.Time's monotonic-clock reading.
+                       r.explicit.CompareAndSwap(deadline, time.Time{})
+               }
+       case r.fallback > 0:
+               if err := r.port.SetReadDeadline(time.Now().Add(r.fallback)); 
err != nil {
+                       return 0, err
+               }
+       default:
+               if err := r.port.SetReadDeadline(time.Time{}); err != nil {
+                       return 0, err
+               }
+       }
+       return r.port.Read(p)
+}
diff --git a/plc4go/spi/transports/serial/deadline_reader_test.go 
b/plc4go/spi/transports/serial/deadline_reader_test.go
new file mode 100644
index 0000000000..cb5aa3e247
--- /dev/null
+++ b/plc4go/spi/transports/serial/deadline_reader_test.go
@@ -0,0 +1,110 @@
+/*
+ * 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 (
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// recordingPort implements serialport.Port and records SetReadDeadline calls.
+type recordingPort struct {
+       stubPort      // from TransportInstance_test.go: no-op serialport.Port
+       readDeadlines []time.Time
+}
+
+func (r *recordingPort) SetReadDeadline(t time.Time) error {
+       r.readDeadlines = append(r.readDeadlines, t)
+       return nil
+}
+
+func TestDeadlineReader_FallbackArmsRelativeDeadline(t *testing.T) {
+       port := &recordingPort{}
+       reader := newDeadlineReader(port, 200*time.Millisecond)
+
+       before := time.Now()
+       _, err := reader.Read(make([]byte, 1))
+       require.NoError(t, err)
+
+       require.Len(t, port.readDeadlines, 1)
+       armed := port.readDeadlines[0]
+       assert.False(t, armed.Before(before.Add(200*time.Millisecond)), 
"deadline must be >= now+fallback")
+       assert.True(t, armed.Before(before.Add(2*time.Second)), "deadline must 
be near now+fallback")
+}
+
+func TestDeadlineReader_ZeroFallbackClearsDeadline(t *testing.T) {
+       port := &recordingPort{}
+       reader := newDeadlineReader(port, 0)
+
+       _, err := reader.Read(make([]byte, 1))
+       require.NoError(t, err)
+
+       require.Len(t, port.readDeadlines, 1)
+       assert.True(t, port.readDeadlines[0].IsZero(), "no fallback means the 
deadline is cleared")
+}
+
+func TestDeadlineReader_ExplicitDeadlineWins(t *testing.T) {
+       port := &recordingPort{}
+       reader := newDeadlineReader(port, time.Hour)
+
+       explicit := time.Now().Add(30 * time.Millisecond)
+       reader.setExplicitDeadline(explicit)
+       _, err := reader.Read(make([]byte, 1))
+       require.NoError(t, err)
+
+       require.Len(t, port.readDeadlines, 1)
+       assert.Equal(t, explicit, port.readDeadlines[0])
+}
+
+func TestDeadlineReader_ExpiredExplicitHonoredOnceThenFallback(t *testing.T) {
+       port := &recordingPort{}
+       reader := newDeadlineReader(port, time.Hour)
+
+       expired := time.Now().Add(-time.Second)
+       reader.setExplicitDeadline(expired)
+
+       // First read still sees the expired explicit deadline (the ctx-bounded
+       // caller must get its timeout), ...
+       _, _ = reader.Read(make([]byte, 1))
+       require.Len(t, port.readDeadlines, 1)
+       assert.Equal(t, expired, port.readDeadlines[0])
+
+       // ... the next read falls back.
+       _, _ = reader.Read(make([]byte, 1))
+       require.Len(t, port.readDeadlines, 2)
+       assert.False(t, port.readDeadlines[1].Equal(expired), "expired explicit 
deadline must auto-clear")
+       assert.False(t, port.readDeadlines[1].IsZero(), "fallback must be 
armed")
+}
+
+func TestDeadlineReader_ClearingExplicitRestoresFallback(t *testing.T) {
+       port := &recordingPort{}
+       reader := newDeadlineReader(port, time.Minute)
+
+       reader.setExplicitDeadline(time.Now().Add(time.Hour))
+       _, _ = reader.Read(make([]byte, 1))
+       reader.setExplicitDeadline(time.Time{})
+       _, _ = reader.Read(make([]byte, 1))
+
+       require.Len(t, port.readDeadlines, 2)
+       assert.False(t, port.readDeadlines[1].Equal(port.readDeadlines[0]), 
"cleared explicit must not persist")
+}
diff --git a/plc4go/spi/transports/serial/options.go 
b/plc4go/spi/transports/serial/options.go
new file mode 100644
index 0000000000..82bd9f34d4
--- /dev/null
+++ b/plc4go/spi/transports/serial/options.go
@@ -0,0 +1,167 @@
+/*
+ * 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 (
+       "fmt"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
+)
+
+// serialConfig bundles everything the serial transport configures on a
+// connection: the port settings handed to serialport.Open, the modem lines
+// asserted after open, and the transport-level timeouts.
+type serialConfig struct {
+       port serialport.Config
+       dtr  bool
+       rts  bool
+       // readTimeout bounds reads that carry no explicit context deadline;
+       // 0 means blocking reads.
+       readTimeout time.Duration
+       // writeTimeout bounds writes that carry no explicit context deadline;
+       // 0 means no write timeout.
+       writeTimeout   time.Duration
+       connectTimeout uint32 // milliseconds
+}
+
+func defaultSerialConfig() serialConfig {
+       return serialConfig{
+               port:           serialport.Config{BaudRate: 9600}, // 8N1 via 
serialport defaults
+               readTimeout:    time.Second,
+               writeTimeout:   time.Second,
+               connectTimeout: 1000,
+       }
+}
+
+// parseSerialOptions translates the connection-string options map into a
+// serialConfig. Enum values are case-insensitive and accept "-" or "_" as
+// separator; invalid values are hard errors naming the option; unknown
+// options and empty value slices are ignored.
+func parseSerialOptions(options map[string][]string) (serialConfig, error) {
+       cfg := defaultSerialConfig()
+
+       if raw, ok := firstValue(options, "baud-rate"); ok {
+               value, err := strconv.ParseUint(raw, 10, 32)
+               if err != nil || value == 0 {
+                       return cfg, optionError("baud-rate", raw, "must be a 
positive integer")
+               }
+               cfg.port.BaudRate = uint(value)
+       }
+       if raw, ok := firstValue(options, "data-bits"); ok {
+               value, err := strconv.ParseUint(raw, 10, 8)
+               if err != nil || value < 5 || value > 8 {
+                       return cfg, optionError("data-bits", raw, "must be 
5..8")
+               }
+               cfg.port.DataBits = uint(value)
+       }
+       if raw, ok := firstValue(options, "stop-bits"); ok {
+               switch raw {
+               case "1":
+                       cfg.port.StopBits = serialport.StopBitsOne
+               case "2":
+                       cfg.port.StopBits = serialport.StopBitsTwo
+               default:
+                       return cfg, optionError("stop-bits", raw, "must be 1 or 
2")
+               }
+       }
+       if raw, ok := firstValue(options, "parity"); ok {
+               switch normalizeEnum(raw) {
+               case "none":
+                       cfg.port.Parity = serialport.ParityNone
+               case "odd":
+                       cfg.port.Parity = serialport.ParityOdd
+               case "even":
+                       cfg.port.Parity = serialport.ParityEven
+               case "mark":
+                       cfg.port.Parity = serialport.ParityMark
+               case "space":
+                       cfg.port.Parity = serialport.ParitySpace
+               default:
+                       return cfg, optionError("parity", raw, "must be one of 
none, odd, even, mark, space")
+               }
+       }
+       if raw, ok := firstValue(options, "flow-control"); ok {
+               switch normalizeEnum(raw) {
+               case "none":
+                       // defaults already off
+               case "rts-cts":
+                       cfg.port.RTSCTSFlowControl = true
+               case "xon-xoff":
+                       cfg.port.XONXOFFFlowControl = true
+               default:
+                       return cfg, optionError("flow-control", raw, "must be 
one of none, rts-cts, xon-xoff")
+               }
+       }
+       if raw, ok := firstValue(options, "dtr"); ok {
+               value, err := strconv.ParseBool(raw)
+               if err != nil {
+                       return cfg, optionError("dtr", raw, "must be true or 
false")
+               }
+               cfg.dtr = value
+       }
+       if raw, ok := firstValue(options, "rts"); ok {
+               value, err := strconv.ParseBool(raw)
+               if err != nil {
+                       return cfg, optionError("rts", raw, "must be true or 
false")
+               }
+               cfg.rts = value
+       }
+       if raw, ok := firstValue(options, "read-timeout"); ok {
+               millis, err := strconv.ParseUint(raw, 10, 32)
+               if err != nil {
+                       return cfg, optionError("read-timeout", raw, "must be a 
non-negative integer (milliseconds)")
+               }
+               cfg.readTimeout = time.Duration(millis) * time.Millisecond
+       }
+       if raw, ok := firstValue(options, "write-timeout"); ok {
+               millis, err := strconv.ParseUint(raw, 10, 32)
+               if err != nil {
+                       return cfg, optionError("write-timeout", raw, "must be 
a non-negative integer (milliseconds)")
+               }
+               cfg.writeTimeout = time.Duration(millis) * time.Millisecond
+       }
+       if raw, ok := firstValue(options, "connect-timeout"); ok {
+               millis, err := strconv.ParseUint(raw, 10, 32)
+               if err != nil {
+                       return cfg, optionError("connect-timeout", raw, "must 
be a non-negative integer (milliseconds)")
+               }
+               cfg.connectTimeout = uint32(millis)
+       }
+       return cfg, nil
+}
+
+func firstValue(options map[string][]string, key string) (string, bool) {
+       values, ok := options[key]
+       if !ok || len(values) == 0 {
+               return "", false
+       }
+       return values[0], true
+}
+
+func normalizeEnum(value string) string {
+       return strings.ReplaceAll(strings.ToLower(value), "_", "-")
+}
+
+func optionError(option, value, requirement string) error {
+       return fmt.Errorf("error parsing option %q: invalid value %q (%s)", 
option, value, requirement)
+}
diff --git a/plc4go/spi/transports/serial/options_test.go 
b/plc4go/spi/transports/serial/options_test.go
new file mode 100644
index 0000000000..5c22ad6253
--- /dev/null
+++ b/plc4go/spi/transports/serial/options_test.go
@@ -0,0 +1,160 @@
+/*
+ * 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 (
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       "github.com/apache/plc4x/plc4go/spi/transports/serial/serialport"
+)
+
+func TestParseSerialOptions(t *testing.T) {
+       tests := []struct {
+               name    string
+               options map[string][]string
+               want    serialConfig
+               wantErr string // substring; empty = expect success
+       }{
+               {
+                       name:    "empty map yields all defaults",
+                       options: map[string][]string{},
+                       want:    defaultSerialConfig(),
+               },
+               {
+                       name: "full happy path",
+                       options: map[string][]string{
+                               "baud-rate":       {"19200"},
+                               "data-bits":       {"7"},
+                               "stop-bits":       {"2"},
+                               "parity":          {"even"},
+                               "flow-control":    {"rts-cts"},
+                               "dtr":             {"true"},
+                               "rts":             {"true"},
+                               "read-timeout":    {"250"},
+                               "write-timeout":   {"0"},
+                               "connect-timeout": {"5000"},
+                       },
+                       want: serialConfig{
+                               port: serialport.Config{
+                                       BaudRate: 19200, DataBits: 7,
+                                       StopBits: serialport.StopBitsTwo, 
Parity: serialport.ParityEven,
+                                       RTSCTSFlowControl: true,
+                               },
+                               dtr: true, rts: true,
+                               readTimeout: 250 * time.Millisecond, 
writeTimeout: 0,
+                               connectTimeout: 5000,
+                       },
+               },
+               {
+                       name:    "enum values are case-insensitive and accept 
underscores",
+                       options: map[string][]string{"parity": {"EVEN"}, 
"flow-control": {"XON_XOFF"}},
+                       want: func() serialConfig {
+                               c := defaultSerialConfig()
+                               c.port.Parity = serialport.ParityEven
+                               c.port.XONXOFFFlowControl = true
+                               return c
+                       }(),
+               },
+               {
+                       name:    "mark and space parity",
+                       options: map[string][]string{"parity": {"Mark"}},
+                       want: func() serialConfig {
+                               c := defaultSerialConfig()
+                               c.port.Parity = serialport.ParityMark
+                               return c
+                       }(),
+               },
+               {
+                       name:    "read-timeout zero means blocking",
+                       options: map[string][]string{"read-timeout": {"0"}},
+                       want: func() serialConfig {
+                               c := defaultSerialConfig()
+                               c.readTimeout = 0
+                               return c
+                       }(),
+               },
+               {
+                       name:    "invalid baud rate",
+                       options: map[string][]string{"baud-rate": {"fast"}},
+                       wantErr: `"baud-rate"`,
+               },
+               {
+                       name:    "zero baud rate rejected",
+                       options: map[string][]string{"baud-rate": {"0"}},
+                       wantErr: `"baud-rate"`,
+               },
+               {
+                       name:    "data-bits out of range",
+                       options: map[string][]string{"data-bits": {"9"}},
+                       wantErr: `"data-bits"`,
+               },
+               {
+                       name:    "stop-bits out of range",
+                       options: map[string][]string{"stop-bits": {"3"}},
+                       wantErr: `"stop-bits"`,
+               },
+               {
+                       name:    "unknown parity value",
+                       options: map[string][]string{"parity": {"strong"}},
+                       wantErr: `"parity"`,
+               },
+               {
+                       name:    "combined flow control value rejected",
+                       options: map[string][]string{"flow-control": 
{"rts-cts-xon-xoff"}},
+                       wantErr: `"flow-control"`,
+               },
+               {
+                       name:    "invalid dtr boolean",
+                       options: map[string][]string{"dtr": {"yes-please"}},
+                       wantErr: `"dtr"`,
+               },
+               {
+                       name:    "invalid read-timeout",
+                       options: map[string][]string{"read-timeout": {"-5"}},
+                       wantErr: `"read-timeout"`,
+               },
+               {
+                       name:    "empty value slice ignored like absent option",
+                       options: map[string][]string{"parity": {}},
+                       want:    defaultSerialConfig(),
+               },
+               {
+                       name:    "unknown options are ignored",
+                       options: map[string][]string{"break-enabled": {"true"}, 
"no-such-thing": {"1"}},
+                       want:    defaultSerialConfig(),
+               },
+       }
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       got, err := parseSerialOptions(tt.options)
+                       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)
+               })
+       }
+}

Reply via email to