blob: 76ccd8f3b3d6bfcf53f6af634a355dba9811c653 [file]
/*
* 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 abeth
import (
"context"
"fmt"
"runtime/debug"
"slices"
"sync"
"github.com/rs/zerolog"
plc4go "github.com/apache/plc4x/plc4go/pkg/api"
apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
readWriteModel "github.com/apache/plc4x/plc4go/protocols/abeth/readwrite/model"
"github.com/apache/plc4x/plc4go/spi"
_default "github.com/apache/plc4x/plc4go/spi/default"
"github.com/apache/plc4x/plc4go/spi/errors"
spiModel "github.com/apache/plc4x/plc4go/spi/model"
"github.com/apache/plc4x/plc4go/spi/options"
"github.com/apache/plc4x/plc4go/spi/tracer"
"github.com/apache/plc4x/plc4go/spi/transactions"
"github.com/apache/plc4x/plc4go/spi/utils"
)
// connectionRequestSenderContext is the sender context the connect handshake sends, kept byte for
// byte from plc4j's AbEthConnection (which kept it from the legacy driver). The PLC echoes it back
// and nothing looks at it, but real PLCs have been observed to be picky about the handshake.
var connectionRequestSenderContext = []uint8{0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00}
// Connection is a connection to an Allen-Bradley PLC speaking CIP encapsulation over TCP, the port
// of plc4j's AbEthConnection.
//
// The protocol is read-only in plc4x: after a CIPEncapsulationConnectionRequest / -Response
// handshake that yields a session handle, the only thing the driver ever sends is a DF1 "protected
// typed logical read". plc4j's AbEthDriver implements canRead and nothing else, so neither does
// this driver - what it does add on top of reading is subscriptions, which plc4j gets for free from
// PollingSubscriptionConnectionBase and this connection gets from the SPI's polling subscriber.
type Connection struct {
_default.DefaultConnection
configuration Configuration
driverContext DriverContext
messageCodec spi.MessageCodec
tm transactions.RequestTransactionManager
session *session
subscriber _default.DefaultPollingSubscriber
options map[string][]string
connectionId string
tracer tracer.Tracer
// lifecycleMutex guards the stray-message drain's handles so Close can be called concurrently
// with, and without, a preceding Connect.
lifecycleMutex sync.Mutex
drainCancel context.CancelFunc
drainDone chan struct{}
wg sync.WaitGroup // use to track spawned go routines
log zerolog.Logger
_options []options.WithOption // Used to pass them downstream
}
var (
_ spi.TransportInstanceExposer = (*Connection)(nil)
_ _default.DefaultPollingSubscriberRequirements = (*Connection)(nil)
)
func NewConnection(
messageCodec spi.MessageCodec,
configuration Configuration,
driverContext DriverContext,
tagHandler spi.PlcTagHandler,
tm transactions.RequestTransactionManager,
connectionOptions map[string][]string,
_options ...options.WithOption,
) *Connection {
customLogger := options.ExtractCustomLoggerOrDefaultToGlobal(_options...)
// Clipping the option list is what keeps the reader and the polling subscriber from racing: both
// hand their own logger option downstream with append, and on a slice that still has spare
// capacity those two appends write into the very same backing array.
_options = slices.Clip(_options)
connection := &Connection{
configuration: configuration,
driverContext: driverContext,
messageCodec: messageCodec,
tm: tm,
session: newSession(),
options: connectionOptions,
log: customLogger,
_options: _options,
}
if traceEnabledOption, ok := connectionOptions["traceEnabled"]; ok {
if len(traceEnabledOption) == 1 {
connection.tracer = tracer.NewTracer(connection.connectionId, _options...)
}
}
connection.DefaultConnection = _default.NewDefaultConnection(connection,
append(_options,
_default.WithPlcTagHandler(tagHandler),
_default.WithPlcValueHandler(NewValueHandler(_options...)),
)...,
)
// The subscriber emulates subscriptions by polling the read path, which is exactly what plc4j's
// PollingSubscriptionConnectionBase does for this driver.
connection.subscriber = _default.NewDefaultPollingSubscriber(connection, _options...)
return connection
}
func (c *Connection) GetConnectionId() string {
return c.connectionId
}
func (c *Connection) IsTraceEnabled() bool {
return c.tracer != nil
}
func (c *Connection) GetTracer() tracer.Tracer {
return c.tracer
}
func (c *Connection) GetConnection() plc4go.PlcConnection {
return c
}
func (c *Connection) GetMessageCodec() spi.MessageCodec {
return c.messageCodec
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Connect / close
////////////////////////////////////////////////////////////////////////////////////////////////////
// Connect opens the transport and runs the connect handshake, which is the only way to learn the
// session handle every later packet has to carry.
func (c *Connection) Connect(ctx context.Context) error {
c.log.Trace().Msg("Connecting")
if err := c.messageCodec.Connect(ctx); err != nil {
return errors.Wrap(err, "error connecting message codec")
}
c.startStrayMessageDrain()
// For testing purposes we can skip waiting for a complete connection.
if !c.driverContext.awaitSetupComplete {
// The caller's ctx is canceled as soon as GetConnection returns, so the background
// handshake must not inherit that cancellation.
setupCtx := context.WithoutCancel(ctx)
c.wg.Go(func() {
setupCtx, cancel := utils.WithNamedTimeout(setupCtx, "abeth setup timeout", c.configuration.requestTimeout)
defer cancel()
if err := c.handshake(setupCtx); err != nil {
c.log.Error().Err(err).Msg("error during the ab-eth connect handshake")
}
})
c.log.Warn().Msg("Connection used in an unsafe way. !!!DON'T USE IN PRODUCTION!!!")
// Here we mark connected without waiting for the handshake to have happened.
c.SetConnected(true)
return nil
}
if err := c.handshake(ctx); err != nil {
// The caller gets an error instead of a connection and will never call Close on it, so the
// drain has to be reeled back in here or its goroutine outlives the failed attempt.
c.stopStrayMessageDrain()
if disconnectErr := c.messageCodec.Disconnect(); disconnectErr != nil {
c.log.Debug().Err(disconnectErr).Msg("error disconnecting after a failed handshake")
}
return errors.Wrap(err, "error during the ab-eth connect handshake")
}
return nil
}
// handshake sends the CIPEncapsulationConnectionRequest and remembers the session handle the PLC
// answers with. Ported from plc4j's AbEthConnection.doHandshake.
func (c *Connection) handshake(ctx context.Context) error {
requestCtx, cancelRequest := context.WithTimeout(ctx, c.configuration.requestTimeout)
defer cancelRequest()
request := readWriteModel.NewCIPEncapsulationConnectionRequest(0, 0, connectionRequestSenderContext, 0)
message, err := sendRequestAndWait(requestCtx, c.messageCodec, "connect", request,
func(message spi.Message) bool {
_, ok := message.(readWriteModel.CIPEncapsulationConnectionResponse)
return ok
})
if err != nil {
return errors.Wrap(err, "error exchanging the connection request")
}
response, ok := message.(readWriteModel.CIPEncapsulationConnectionResponse)
if !ok {
return errors.Errorf("expected a CIPEncapsulationConnectionResponse, got %T", message)
}
c.session.setSessionHandle(response.GetSessionHandle())
c.log.Info().
Uint32("sessionHandle", response.GetSessionHandle()).
Uint32("status", response.GetStatus()).
Msg("Connected to an ab-eth host")
c.SetConnected(true)
return nil
}
// Close stops the pollers and drops the transport. ab-eth has no disconnect packet - plc4j's
// AbEthConnection just closes the socket - so there is nothing to send here.
func (c *Connection) Close() error {
c.log.Trace().Msg("Closing")
c.stopStrayMessageDrain()
if c.subscriber != nil {
c.subscriber.Close()
}
err := c.DefaultConnection.Close()
c.wg.Wait()
return err
}
// startStrayMessageDrain keeps the codec's default incoming message channel empty. ab-eth is
// strictly request/response and every response is matched to its request by transaction counter, so
// anything the codec can't match - a response which arrived after its request timed out, a
// duplicate, an unsolicited packet - is pushed into that 100 slot buffer and would stay there for
// the life of the connection. Once the buffer is full the codec logs a warning per further packet,
// so on a flaky link a handful of late responses turn into permanent log noise. Draining costs one
// goroutine and turns that into a single debug line per stray packet.
func (c *Connection) startStrayMessageDrain() {
// A second Connect on the same connection must not leave the first drain running.
c.stopStrayMessageDrain()
drainCtx, drainCancel := context.WithCancel(context.Background())
drainDone := make(chan struct{})
c.lifecycleMutex.Lock()
c.drainCancel = drainCancel
c.drainDone = drainDone
c.lifecycleMutex.Unlock()
incomingMessageChannel := c.messageCodec.GetDefaultIncomingMessageChannel()
c.wg.Go(func() {
// Closing this last, after the recover below, is what lets stopStrayMessageDrain wait for
// the goroutine to really be gone.
defer close(drainDone)
defer func() {
if err := recover(); err != nil {
c.log.Error().
Str("stack", string(debug.Stack())).
Interface("err", err).
Msg("panic-ed")
}
}()
for {
select {
case <-drainCtx.Done():
c.log.Trace().Msg("Ending the stray message drain")
return
case message := <-incomingMessageChannel:
c.log.Debug().
Type("message", message).
Msg("Discarding a packet no request was waiting for")
}
}
})
}
// stopStrayMessageDrain ends the drain and waits for its goroutine to be gone, so that a caller
// which stops the drain can rely on nothing reading the channel any more. Calling it without a
// running drain, or twice, is harmless.
func (c *Connection) stopStrayMessageDrain() {
c.lifecycleMutex.Lock()
drainCancel, drainDone := c.drainCancel, c.drainDone
c.drainCancel, c.drainDone = nil, nil
c.lifecycleMutex.Unlock()
if drainCancel == nil {
return
}
drainCancel()
<-drainDone
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// Metadata and builders
////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *Connection) GetMetadata() apiModel.PlcConnectionMetadata {
return &_default.DefaultConnectionMetadata{
// ab-eth is read-only in plc4x: the mspec has no write command at all, and plc4j's
// AbEthDriver overrides canRead and nothing else. Subscribing works because it is emulated
// by polling the read path, the same way plc4j's PollingSubscriptionConnectionBase does it.
ProvidesReading: true,
ProvidesWriting: false,
ProvidesSubscribing: true,
ProvidesBrowsing: false,
}
}
func (c *Connection) ReadRequestBuilder() apiModel.PlcReadRequestBuilder {
return spiModel.NewDefaultPlcReadRequestBuilder(
c.GetPlcTagHandler(),
NewReader(
c.messageCodec,
c.tm,
c.configuration,
c.session,
append(c._options, options.WithCustomLogger(c.log))...,
),
)
}
// PollingReadRequestBuilder feeds the polling subscriber, which turns every poll cycle into an
// ordinary read request.
func (c *Connection) PollingReadRequestBuilder() apiModel.PlcReadRequestBuilder {
return c.ReadRequestBuilder()
}
func (c *Connection) SubscriptionRequestBuilder() apiModel.PlcSubscriptionRequestBuilder {
return spiModel.NewDefaultPlcSubscriptionRequestBuilder(
c.GetPlcTagHandler(),
c.GetPlcValueHandler(),
c.subscriber,
)
}
// UnsubscriptionRequestBuilder hands out the default builder: the handles carry the subscriber they
// belong to, so nothing driver specific is needed to take them back.
func (c *Connection) UnsubscriptionRequestBuilder() apiModel.PlcUnsubscriptionRequestBuilder {
return spiModel.NewDefaultPlcUnsubscriptionRequestBuilder()
}
func (c *Connection) String() string {
return fmt.Sprintf("abeth.Connection{%s, %s}", c.configuration, c.session)
}