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 <[email protected]> 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=
