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

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


The following commit(s) were added to refs/heads/develop by this push:
     new 8e5f347013 feat(plc4go/bacnetip): routed BACnet addressing via 
RemoteNetwork/RemoteAddress connection options
8e5f347013 is described below

commit 8e5f34701387a9803d363a24db63e55a4d6a2267
Author: Sebastian Rühl <[email protected]>
AuthorDate: Thu Jul 23 16:20:06 2026 +0200

    feat(plc4go/bacnetip): routed BACnet addressing via 
RemoteNetwork/RemoteAddress connection options
    
    A connection whose target device sits behind a BACnet router (ASHRAE 135
    clause 6) can now reach it: connect to the ROUTER's IP and set
    RemoteNetwork=<dnet>&RemoteAddress=<ip:port|0xhex>. Every outgoing NPDU
    (Reader, Writer, Subscriber, segment acks) then carries the destination
    specifier (DNET/DLEN/DADR, hop count 255) so the router forwards it onto
    the target's network; replies match on invoke id as before.
    
    The discovery side mirrors the same vocabulary: a routed I-Am's NPDU
    source specifier (SNET/SADR) is surfaced on the PlcDiscoveryItem as
    RemoteNetwork/RemoteAddress options — copy-paste ready for the routed
    connection URL, with 6-octet SADRs rendered as B/IP <ip>:<port> and other
    datalink MACs as 0x hex.
    
    This wires the first slice of the Phase 6 routed-addressing plan; the
    DeviceInfoCache/StaticDevices scaffolding stays available for multi-device
    connections later.
---
 plc4go/internal/bacnetip/Configuration.go          |  15 +++
 plc4go/internal/bacnetip/Configuration_plc4xgen.go |   8 ++
 plc4go/internal/bacnetip/Connection.go             |  12 ++
 plc4go/internal/bacnetip/Discoverer.go             |   9 +-
 plc4go/internal/bacnetip/Framing.go                | 127 +++++++++++++++++++--
 plc4go/internal/bacnetip/Framing_test.go           | 122 +++++++++++++++++++-
 .../internal/bacnetip/IdleWriteRoundtrip_test.go   |   2 +-
 plc4go/internal/bacnetip/MessageCodec_test.go      |   2 +-
 plc4go/internal/bacnetip/ReadRoundtrip_test.go     |   2 +-
 plc4go/internal/bacnetip/Reader.go                 |   6 +-
 .../internal/bacnetip/ReaderResultDelivery_test.go |   2 +-
 plc4go/internal/bacnetip/ReaderSegmentation.go     |   2 +-
 plc4go/internal/bacnetip/Subscriber.go             |   2 +-
 plc4go/internal/bacnetip/WriteRoundtrip_test.go    |   6 +-
 plc4go/internal/bacnetip/Writer.go                 |   5 +-
 15 files changed, 293 insertions(+), 29 deletions(-)

diff --git a/plc4go/internal/bacnetip/Configuration.go 
b/plc4go/internal/bacnetip/Configuration.go
index dfe282339f..424e212ef9 100644
--- a/plc4go/internal/bacnetip/Configuration.go
+++ b/plc4go/internal/bacnetip/Configuration.go
@@ -88,6 +88,21 @@ type Configuration struct {
        // WhoIs (e.g. routed devices on a network with no BBMD). 
Comma-separated entries
        // of the form "<deviceId>@<network>:<host>:<port>".
        StaticDevices string
+
+       // RemoteNetwork is the BACnet network number of this connection's 
target device
+       // when it sits behind a BACnet router (ASHRAE 135 clause 6). The 
connection's
+       // transport host is then the ROUTER's IP; every outgoing NPDU carries a
+       // destination specifier (DNET=RemoteNetwork, DADR=RemoteAddress, hop 
count 255)
+       // so the router forwards it onto that network, and replies arrive with 
the
+       // device's source specifier mirrored back. 0 (default) means the 
target is on
+       // the local segment and NPDUs stay specifier-free.
+       RemoteNetwork uint16
+
+       // RemoteAddress is the target device's MAC address on RemoteNetwork, 
required
+       // when RemoteNetwork is set. For BACnet/IP-to-BACnet/IP routing use
+       // "<ip>:<port>" (encoded as the 6-byte B/IP DADR); for other datalinks 
a hex
+       // string ("0x0C") supplies the raw MAC octets.
+       RemoteAddress string
 }
 
 // ParseFromOptions populates a Configuration from the connection-URL query 
options,
diff --git a/plc4go/internal/bacnetip/Configuration_plc4xgen.go 
b/plc4go/internal/bacnetip/Configuration_plc4xgen.go
index 32a880cb31..4866962b39 100644
--- a/plc4go/internal/bacnetip/Configuration_plc4xgen.go
+++ b/plc4go/internal/bacnetip/Configuration_plc4xgen.go
@@ -106,6 +106,14 @@ func (d *Configuration) SerializeWithWriteBuffer(ctx 
context.Context, writeBuffe
        if err := writeBuffer.WriteString("staticDevices", 
uint32(len(d.StaticDevices)*8), d.StaticDevices); err != nil {
                return err
        }
+
+       if err := writeBuffer.WriteUint16("remoteNetwork", 16, 
d.RemoteNetwork); err != nil {
+               return err
+       }
+
+       if err := writeBuffer.WriteString("remoteAddress", 
uint32(len(d.RemoteAddress)*8), d.RemoteAddress); err != nil {
+               return err
+       }
        if err := writeBuffer.PopContext("Configuration"); err != nil {
                return err
        }
diff --git a/plc4go/internal/bacnetip/Connection.go 
b/plc4go/internal/bacnetip/Connection.go
index 227e30ae6c..881d3a00c2 100644
--- a/plc4go/internal/bacnetip/Connection.go
+++ b/plc4go/internal/bacnetip/Connection.go
@@ -47,6 +47,7 @@ type Connection struct {
        invokeIdGenerator InvokeIdGenerator
        messageCodec      spi.MessageCodec
        configuration     Configuration
+       routedDest        *routedDestination // non-nil when the target device 
is behind a BACnet router
        driverContext     DriverContext
        subscribers       []*Subscriber
        subscribersMu     sync.Mutex
@@ -72,11 +73,20 @@ func NewConnection(messageCodec spi.MessageCodec, 
tagHandler spi.PlcTagHandler,
                customLogger.Warn().Err(err).Msg("invalid driver options; 
falling back to defaults")
                configuration = createDefaultConfiguration()
        }
+       routedDest, err := routedDestinationFromConfiguration(configuration)
+       if err != nil {
+               // Fail closed on the routing options: silently ignoring them 
would
+               // unicast routed requests at the router without a destination
+               // specifier, which the router (correctly) cannot forward.
+               customLogger.Error().Err(err).Msg("invalid routed-destination 
options; connection will address the local segment only")
+               routedDest = nil
+       }
        connection := &Connection{
                invokeIdGenerator: InvokeIdGenerator{currentInvokeId: 0},
                messageCodec:      messageCodec,
                configuration:     configuration,
                driverContext:     NewDriverContext(configuration),
+               routedDest:        routedDest,
                tm:                tm,
                log:               customLogger,
                _options:          _options,
@@ -214,6 +224,7 @@ func (c *Connection) ReadRequestBuilder() 
apiModel.PlcReadRequestBuilder {
                        &c.invokeIdGenerator,
                        c.messageCodec,
                        c.tm,
+                       c.routedDest,
                        append(c._options, options.WithCustomLogger(c.log))...,
                ),
        )
@@ -228,6 +239,7 @@ func (c *Connection) WriteRequestBuilder() 
apiModel.PlcWriteRequestBuilder {
                        c.messageCodec,
                        c.tm,
                        c.driverContext,
+                       c.routedDest,
                        append(c._options, options.WithCustomLogger(c.log))...,
                ),
        )
diff --git a/plc4go/internal/bacnetip/Discoverer.go 
b/plc4go/internal/bacnetip/Discoverer.go
index d7616c56c5..e62c829fbe 100644
--- a/plc4go/internal/bacnetip/Discoverer.go
+++ b/plc4go/internal/bacnetip/Discoverer.go
@@ -344,11 +344,18 @@ func (d *Discoverer) handleIncomingBVLCs(ctx 
context.Context, callback func(even
                                if err != nil {
                                        d.log.Debug().Err(err).Msg("Error 
parsing url")
                                }
+                               // A routed I-Am carries the device's origin as 
an NPDU source
+                               // specifier (SNET/SADR, ASHRAE 135 clause 
6.2.4). Surface it as
+                               // ready-to-use connection options (the same 
RemoteNetwork /
+                               // RemoteAddress keys the connection URL 
accepts) so consumers can
+                               // reach the device through the relaying 
router: transport URL =
+                               // the router (the frame's UDP source), options 
= the routed hop.
+                               discoveryOptions := routedOriginOptions(npdu)
                                discoveryEvent := 
spiModel.NewDefaultPlcDiscoveryItem(
                                        "bacnet-ip",
                                        "udp",
                                        *remoteUrl,
-                                       nil,
+                                       discoveryOptions,
                                        fmt.Sprintf("device %v:%v", 
iAm.GetDeviceIdentifier().GetObjectType(), 
iAm.GetDeviceIdentifier().GetInstanceNumber()),
                                        nil,
                                )
diff --git a/plc4go/internal/bacnetip/Framing.go 
b/plc4go/internal/bacnetip/Framing.go
index de7124c8fb..7829050dbb 100644
--- a/plc4go/internal/bacnetip/Framing.go
+++ b/plc4go/internal/bacnetip/Framing.go
@@ -20,36 +20,143 @@
 package bacnetip
 
 import (
+       "encoding/binary"
+       "encoding/hex"
+       "fmt"
+       "net/netip"
+       "strconv"
+       "strings"
+
+       "github.com/apache/plc4x/plc4go/spi/errors"
+
        "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
 )
 
+// routedDestination is the NPDU destination specifier for a connection whose
+// target device sits behind a BACnet router (Configuration.RemoteNetwork /
+// RemoteAddress): DNET is the device's network number, DADR its MAC on that
+// network. nil means local-segment addressing (no specifier), the default.
+type routedDestination struct {
+       dnet uint16
+       dadr []uint8
+}
+
+// routedDestinationHopCount is the initial hop count of freshly originated
+// routed NPDUs (ASHRAE 135 clause 6.2.2 recommends starting at 255).
+const routedDestinationHopCount uint8 = 255
+
+// parseRemoteAddress turns Configuration.RemoteAddress into DADR octets:
+// "<ip>:<port>" becomes the 6-byte B/IP address (Annex J), and a "0x…" hex
+// string supplies raw MAC octets for non-IP datalinks.
+func parseRemoteAddress(remoteAddress string) ([]uint8, error) {
+       if s := strings.TrimPrefix(remoteAddress, "0x"); s != remoteAddress {
+               dadr, err := hex.DecodeString(s)
+               if err != nil {
+                       return nil, errors.Wrapf(err, "RemoteAddress %q is not 
valid hex", remoteAddress)
+               }
+               if len(dadr) == 0 {
+                       return nil, errors.Errorf("RemoteAddress %q decodes to 
zero octets", remoteAddress)
+               }
+               return dadr, nil
+       }
+       ap, err := netip.ParseAddrPort(remoteAddress)
+       if err != nil {
+               return nil, errors.Wrapf(err, "RemoteAddress %q is neither 
<ip>:<port> nor 0x-prefixed hex", remoteAddress)
+       }
+       if !ap.Addr().Is4() {
+               return nil, errors.Errorf("RemoteAddress %q must be IPv4 for a 
B/IP DADR", remoteAddress)
+       }
+       ip4 := ap.Addr().As4()
+       dadr := make([]uint8, 6)
+       copy(dadr[0:4], ip4[:])
+       binary.BigEndian.PutUint16(dadr[4:6], ap.Port())
+       return dadr, nil
+}
+
+// routedDestinationFromConfiguration derives the connection-scoped destination
+// specifier, or nil for local-segment connections. RemoteAddress without
+// RemoteNetwork (and vice versa) is a configuration error.
+func routedDestinationFromConfiguration(configuration Configuration) 
(*routedDestination, error) {
+       if configuration.RemoteNetwork == 0 && configuration.RemoteAddress == 
"" {
+               return nil, nil
+       }
+       if configuration.RemoteNetwork == 0 || configuration.RemoteAddress == 
"" {
+               return nil, errors.New("RemoteNetwork and RemoteAddress must be 
set together")
+       }
+       dadr, err := parseRemoteAddress(configuration.RemoteAddress)
+       if err != nil {
+               return nil, err
+       }
+       return &routedDestination{dnet: configuration.RemoteNetwork, dadr: 
dadr}, nil
+}
+
 // wrapAPDU encapsulates an APDU in the BACnet/IP NPDU + BVLC layers expected
 // by MessageCodec.Send (which type-asserts to model.BVLC). Without this
 // wrapping the codec panics on the cast and the request silently dies.
 //
 // expectingReply is set for confirmed requests; unconfirmed requests pass
-// false. The NPDU is intentionally local-only (no DNET/SNET) because routed
-// addressing happens at the Tag layer in Phase 6.
-func wrapAPDU(apdu model.APDU, expectingReply bool) model.BVLC {
+// false. With dest == nil the NPDU is local-only (no DNET/SNET); a non-nil
+// dest emits the destination specifier (DNET/DLEN/DADR + hop count) so the
+// first router on the connection's segment forwards the request onto the
+// target's network (ASHRAE 135 clause 6).
+func wrapAPDU(apdu model.APDU, expectingReply bool, dest *routedDestination) 
model.BVLC {
        control := model.NewNPDUControl(
-               false, // messageTypeFieldPresent
-               false, // destinationSpecified
-               false, // sourceSpecified
+               false,       // messageTypeFieldPresent
+               dest != nil, // destinationSpecified
+               false,       // sourceSpecified
                expectingReply,
                model.NPDUNetworkPriority_NORMAL_MESSAGE,
        )
+       var destNet *uint16
+       var destLen *uint8
+       var destAddr []uint8
+       var hopCount *uint8
+       if dest != nil {
+               dnet := dest.dnet
+               destNet = &dnet
+               dlen := uint8(len(dest.dadr))
+               destLen = &dlen
+               destAddr = dest.dadr
+               hops := routedDestinationHopCount
+               hopCount = &hops
+       }
        npdu := model.NewNPDU(
                1, // protocolVersionNumber
                control,
-               nil, // destinationNetworkAddress
-               nil, // destinationLength
-               nil, // destinationAddress
+               destNet,
+               destLen,
+               destAddr,
                nil, // sourceNetworkAddress
                nil, // sourceLength
                nil, // sourceAddress
-               nil, // hopCount
+               hopCount,
                nil, // nlm
                apdu,
        )
        return model.NewBVLCOriginalUnicastNPDU(npdu)
 }
+
+// routedOriginOptions extracts a routed frame's NPDU source specifier as
+// connection options (RemoteNetwork/RemoteAddress — the keys the connection
+// URL accepts), or nil for local frames. A 6-octet SADR is rendered as the
+// B/IP "<ip>:<port>" form; other datalink MACs as 0x-prefixed hex.
+func routedOriginOptions(npdu model.NPDU) map[string][]string {
+       if npdu == nil || npdu.GetControl() == nil || 
!npdu.GetControl().GetSourceSpecified() {
+               return nil
+       }
+       snet := npdu.GetSourceNetworkAddress()
+       sadr := npdu.GetSourceAddress()
+       if snet == nil || len(sadr) == 0 {
+               return nil
+       }
+       var remoteAddress string
+       if len(sadr) == 6 {
+               remoteAddress = fmt.Sprintf("%d.%d.%d.%d:%d", sadr[0], sadr[1], 
sadr[2], sadr[3], binary.BigEndian.Uint16(sadr[4:6]))
+       } else {
+               remoteAddress = "0x" + hex.EncodeToString(sadr)
+       }
+       return map[string][]string{
+               "RemoteNetwork": {strconv.FormatUint(uint64(*snet), 10)},
+               "RemoteAddress": {remoteAddress},
+       }
+}
diff --git a/plc4go/internal/bacnetip/Framing_test.go 
b/plc4go/internal/bacnetip/Framing_test.go
index 007f962890..df22d6733f 100644
--- a/plc4go/internal/bacnetip/Framing_test.go
+++ b/plc4go/internal/bacnetip/Framing_test.go
@@ -20,6 +20,7 @@
 package bacnetip
 
 import (
+       "context"
        "testing"
 
        "github.com/stretchr/testify/assert"
@@ -35,7 +36,7 @@ func newWhoIsAPDU(t *testing.T) readWriteModel.APDU {
 }
 
 func TestWrapAPDU_ProducesBVLCOriginalUnicastNPDU(t *testing.T) {
-       bvlc := wrapAPDU(newWhoIsAPDU(t), false)
+       bvlc := wrapAPDU(newWhoIsAPDU(t), false, nil)
        require.NotNil(t, bvlc)
        // MessageCodec.Send type-asserts to BVLCOriginalUnicastNPDU on the
        // send-path; wrapAPDU must produce exactly that type.
@@ -47,7 +48,7 @@ func TestWrapAPDU_NPDUProtocolVersionIs1(t *testing.T) {
        // BACnet stacks reject NPDUs with a wrong protocol version. Pin it
        // to 1 (the only spec-valid value) so an accidental change shows up
        // as a test failure rather than a wire-protocol incompatibility.
-       bvlc := wrapAPDU(newWhoIsAPDU(t), 
false).(readWriteModel.BVLCOriginalUnicastNPDU)
+       bvlc := wrapAPDU(newWhoIsAPDU(t), false, 
nil).(readWriteModel.BVLCOriginalUnicastNPDU)
        assert.Equal(t, uint8(1), bvlc.GetNpdu().GetProtocolVersionNumber())
 }
 
@@ -61,7 +62,7 @@ func TestWrapAPDU_ExpectingReplyPropagatesToControl(t 
*testing.T) {
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
-                       bvlc := wrapAPDU(newWhoIsAPDU(t), 
tc.expectingReply).(readWriteModel.BVLCOriginalUnicastNPDU)
+                       bvlc := wrapAPDU(newWhoIsAPDU(t), tc.expectingReply, 
nil).(readWriteModel.BVLCOriginalUnicastNPDU)
                        control := bvlc.GetNpdu().GetControl()
                        assert.Equal(t, tc.expectingReply, 
control.GetExpectingReply(),
                                "NPDU control.expectingReply must reflect the 
wrapAPDU argument")
@@ -73,7 +74,7 @@ func TestWrapAPDU_LocalScope_NoRouting(t *testing.T) {
        // We only support local (same-network) addressing in Phase 6's tag 
layer.
        // The NPDU control fields for routing must all be off so 
bacpypes3/Niagara
        // don't interpret the message as routed.
-       bvlc := wrapAPDU(newWhoIsAPDU(t), 
true).(readWriteModel.BVLCOriginalUnicastNPDU)
+       bvlc := wrapAPDU(newWhoIsAPDU(t), true, 
nil).(readWriteModel.BVLCOriginalUnicastNPDU)
        control := bvlc.GetNpdu().GetControl()
        assert.False(t, control.GetMessageTypeFieldPresent())
        assert.False(t, control.GetDestinationSpecified())
@@ -93,7 +94,7 @@ func TestWrapAPDU_LocalScope_NoRouting(t *testing.T) {
 
 func TestWrapAPDU_PreservesAPDU(t *testing.T) {
        apdu := newWhoIsAPDU(t)
-       bvlc := wrapAPDU(apdu, false).(readWriteModel.BVLCOriginalUnicastNPDU)
+       bvlc := wrapAPDU(apdu, false, 
nil).(readWriteModel.BVLCOriginalUnicastNPDU)
        // Same APDU identity should be reachable through the wrapper —
        // MessageCodec.Receive parses BVLC → NPDU → APDU and Reader/Writer
        // match expectations by walking that chain.
@@ -104,7 +105,7 @@ func TestWrapAPDU_SerializesToValidBVLC(t *testing.T) {
        // End-to-end sanity: the wrapped message round-trips through the
        // model serializer. Catches accidental nil-required-field changes
        // in wrapAPDU that would only show up at runtime under Send().
-       bvlc := wrapAPDU(newWhoIsAPDU(t), false)
+       bvlc := wrapAPDU(newWhoIsAPDU(t), false, nil)
        raw, err := bvlc.Serialize()
        require.NoError(t, err, "wrapAPDU output must serialize")
        // First byte is BVLC type 0x81; second is function 0x0a 
(OriginalUnicastNPDU).
@@ -112,3 +113,112 @@ func TestWrapAPDU_SerializesToValidBVLC(t *testing.T) {
        assert.Equal(t, byte(0x81), raw[0], "BVLC magic byte")
        assert.Equal(t, byte(0x0a), raw[1], "BVLC function = 
OriginalUnicastNPDU")
 }
+
+// TestWrapAPDU_RoutedDestination pins the routed framing (ASHRAE 135 clause
+// 6): a connection whose target sits behind a BACnet router emits a
+// destination specifier (DNET/DLEN/DADR) with a fresh hop count, and the
+// frame survives a serialize/parse round trip.
+func TestWrapAPDU_RoutedDestination(t *testing.T) {
+       dest := &routedDestination{dnet: 3001, dadr: []uint8{192, 168, 102, 20, 
0xBA, 0xC0}}
+       bvlc := wrapAPDU(newWhoIsAPDU(t), false, 
dest).(readWriteModel.BVLCOriginalUnicastNPDU)
+       npdu := bvlc.GetNpdu()
+
+       assert.True(t, npdu.GetControl().GetDestinationSpecified(), 
"control.destinationSpecified")
+       assert.False(t, npdu.GetControl().GetSourceSpecified(), "source must 
stay absent on originated frames")
+       require.NotNil(t, npdu.GetDestinationNetworkAddress())
+       assert.Equal(t, uint16(3001), *npdu.GetDestinationNetworkAddress())
+       require.NotNil(t, npdu.GetDestinationLength())
+       assert.Equal(t, uint8(6), *npdu.GetDestinationLength())
+       assert.Equal(t, []uint8{192, 168, 102, 20, 0xBA, 0xC0}, 
npdu.GetDestinationAddress())
+       require.NotNil(t, npdu.GetHopCount())
+       assert.Equal(t, routedDestinationHopCount, *npdu.GetHopCount())
+
+       // Round trip: the routed header must reparse byte-identically.
+       data, err := bvlc.Serialize()
+       require.NoError(t, err)
+       reparsed, err := 
readWriteModel.BVLCParse[readWriteModel.BVLC](context.Background(), data)
+       require.NoError(t, err)
+       renpdu := reparsed.(readWriteModel.BVLCOriginalUnicastNPDU).GetNpdu()
+       require.NotNil(t, renpdu.GetDestinationNetworkAddress())
+       assert.Equal(t, uint16(3001), *renpdu.GetDestinationNetworkAddress())
+       assert.Equal(t, []uint8{192, 168, 102, 20, 0xBA, 0xC0}, 
renpdu.GetDestinationAddress())
+}
+
+// TestParseRemoteAddress covers the two DADR syntaxes and rejects garbage.
+func TestParseRemoteAddress(t *testing.T) {
+       tests := []struct {
+               name    string
+               input   string
+               want    []uint8
+               wantErr bool
+       }{
+               {"bacnet-ip", "192.168.102.20:47808", []uint8{192, 168, 102, 
20, 0xBA, 0xC0}, false},
+               {"hex mac", "0x0C", []uint8{0x0C}, false},
+               {"hex multi-octet", "0x00fF", []uint8{0x00, 0xFF}, false},
+               {"ipv6 rejected", "[::1]:47808", nil, true},
+               {"empty hex", "0x", nil, true},
+               {"garbage", "not-an-address", nil, true},
+       }
+       for _, tc := range tests {
+               t.Run(tc.name, func(t *testing.T) {
+                       got, err := parseRemoteAddress(tc.input)
+                       if tc.wantErr {
+                               assert.Error(t, err)
+                               return
+                       }
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want, got)
+               })
+       }
+}
+
+// TestRoutedDestinationFromConfiguration pins the option pairing rules.
+func TestRoutedDestinationFromConfiguration(t *testing.T) {
+       dest, err := routedDestinationFromConfiguration(Configuration{})
+       require.NoError(t, err)
+       assert.Nil(t, dest, "no options -> local addressing")
+
+       dest, err = 
routedDestinationFromConfiguration(Configuration{RemoteNetwork: 3001, 
RemoteAddress: "192.168.102.20:47808"})
+       require.NoError(t, err)
+       require.NotNil(t, dest)
+       assert.Equal(t, uint16(3001), dest.dnet)
+       assert.Len(t, dest.dadr, 6)
+
+       _, err = 
routedDestinationFromConfiguration(Configuration{RemoteNetwork: 3001})
+       assert.Error(t, err, "network without address must fail")
+       _, err = 
routedDestinationFromConfiguration(Configuration{RemoteAddress: 
"192.168.102.20:47808"})
+       assert.Error(t, err, "address without network must fail")
+}
+
+// TestRoutedOriginOptions pins the discovery-side mirror of the routed
+// options: a routed I-Am's SNET/SADR comes back as the same
+// RemoteNetwork/RemoteAddress keys the connection URL accepts.
+func TestRoutedOriginOptions(t *testing.T) {
+       mkNPDU := func(src bool, snet uint16, sadr []uint8) readWriteModel.NPDU 
{
+               control := readWriteModel.NewNPDUControl(false, false, src, 
false, readWriteModel.NPDUNetworkPriority_NORMAL_MESSAGE)
+               var snetP *uint16
+               var slenP *uint8
+               if src {
+                       snetP = &snet
+                       slen := uint8(len(sadr))
+                       slenP = &slen
+               }
+               return readWriteModel.NewNPDU(1, control, nil, nil, nil, snetP, 
slenP, sadr, nil, nil, newWhoIsAPDU(t))
+       }
+
+       assert.Nil(t, routedOriginOptions(mkNPDU(false, 0, nil)), "local frame 
-> no options")
+
+       opts := routedOriginOptions(mkNPDU(true, 3001, []uint8{192, 168, 102, 
20, 0xBA, 0xC0}))
+       require.NotNil(t, opts)
+       assert.Equal(t, []string{"3001"}, opts["RemoteNetwork"])
+       assert.Equal(t, []string{"192.168.102.20:47808"}, opts["RemoteAddress"])
+
+       opts = routedOriginOptions(mkNPDU(true, 5, []uint8{0x0C}))
+       require.NotNil(t, opts)
+       assert.Equal(t, []string{"0x0c"}, opts["RemoteAddress"], "non-B/IP MAC 
renders as hex")
+
+       // Round trip: the discovery options must parse back into the same DADR.
+       dadr, err := parseRemoteAddress(opts["RemoteAddress"][0])
+       require.NoError(t, err)
+       assert.Equal(t, []uint8{0x0C}, dadr)
+}
diff --git a/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go 
b/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go
index 07f8d8007a..b9283ff78f 100644
--- a/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go
+++ b/plc4go/internal/bacnetip/IdleWriteRoundtrip_test.go
@@ -94,7 +94,7 @@ func (d *delayedWriteDevice) serve() {
                        if choice == 
model.BACnetConfirmedServiceChoice_READ_PROPERTY {
                                resp = buildReadAckFor(invokeId)
                        } else {
-                               resp = 
wrapAPDU(model.NewAPDUSimpleAck(invokeId, choice), false)
+                               resp = 
wrapAPDU(model.NewAPDUSimpleAck(invokeId, choice), false, nil)
                        }
                        theBytes, err := resp.Serialize()
                        if err != nil {
diff --git a/plc4go/internal/bacnetip/MessageCodec_test.go 
b/plc4go/internal/bacnetip/MessageCodec_test.go
index e55bb67d34..35a1857fef 100644
--- a/plc4go/internal/bacnetip/MessageCodec_test.go
+++ b/plc4go/internal/bacnetip/MessageCodec_test.go
@@ -144,7 +144,7 @@ func makeWhoIsBVLC(t *testing.T) readWriteModel.BVLC {
        t.Helper()
        whoIs := readWriteModel.NewBACnetUnconfirmedServiceRequestWhoIs(nil, 
nil)
        apdu := readWriteModel.NewAPDUUnconfirmedRequest(whoIs)
-       return wrapAPDU(apdu, false)
+       return wrapAPDU(apdu, false, nil)
 }
 
 func TestMessageCodec_Send_SerializesBVLCToTransport(t *testing.T) {
diff --git a/plc4go/internal/bacnetip/ReadRoundtrip_test.go 
b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
index bf2332cbc7..f7612bd2f0 100644
--- a/plc4go/internal/bacnetip/ReadRoundtrip_test.go
+++ b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
@@ -135,7 +135,7 @@ func (d *fakeBacnetDevice) buildReadPropertyAck(invokeId 
uint8) model.BVLC {
                
constructedDataFromTag(model.CreateBACnetApplicationTagReal(23.5)),
        )
        apdu := model.NewAPDUComplexAck(false, false, invokeId, nil, nil, 
serviceAck, nil, nil)
-       return wrapAPDU(apdu, false)
+       return wrapAPDU(apdu, false, nil)
 }
 
 func (d *fakeBacnetDevice) stop() {
diff --git a/plc4go/internal/bacnetip/Reader.go 
b/plc4go/internal/bacnetip/Reader.go
index 5487ca8e46..654160301d 100644
--- a/plc4go/internal/bacnetip/Reader.go
+++ b/plc4go/internal/bacnetip/Reader.go
@@ -42,6 +42,7 @@ type Reader struct {
        invokeIdGenerator *InvokeIdGenerator
        messageCodec      spi.MessageCodec
        tm                transactions.RequestTransactionManager
+       routedDest        *routedDestination // nil for local-segment 
connections
 
        maxSegmentsAccepted   readWriteModel.MaxSegmentsAccepted
        maxApduLengthAccepted readWriteModel.MaxApduLengthAccepted
@@ -51,12 +52,13 @@ type Reader struct {
        log zerolog.Logger
 }
 
-func NewReader(invokeIdGenerator *InvokeIdGenerator, messageCodec 
spi.MessageCodec, tm transactions.RequestTransactionManager, _options 
...options.WithOption) *Reader {
+func NewReader(invokeIdGenerator *InvokeIdGenerator, messageCodec 
spi.MessageCodec, tm transactions.RequestTransactionManager, routedDest 
*routedDestination, _options ...options.WithOption) *Reader {
        customLogger := 
options.ExtractCustomLoggerOrDefaultToGlobal(_options...)
        return &Reader{
                invokeIdGenerator: invokeIdGenerator,
                messageCodec:      messageCodec,
                tm:                tm,
+               routedDest:        routedDest,
 
                maxSegmentsAccepted:   
readWriteModel.MaxSegmentsAccepted_MORE_THAN_64_SEGMENTS,
                maxApduLengthAccepted: 
readWriteModel.MaxApduLengthAccepted_NUM_OCTETS_1476,
@@ -144,7 +146,7 @@ func (m *Reader) Read(ctx context.Context, readRequest 
apiModel.PlcReadRequest)
                        context.AfterFunc(transactionContext, cancel)
                        // Send the  over the wire
                        m.log.Trace().Msg("Send ")
-                       if err := m.messageCodec.SendRequest(ctx, "read", 
wrapAPDU(apdu, true), func(message spi.Message) bool {
+                       if err := m.messageCodec.SendRequest(ctx, "read", 
wrapAPDU(apdu, true, m.routedDest), func(message spi.Message) bool {
                                bvlc, ok := message.(readWriteModel.BVLC)
                                if !ok {
                                        m.log.Debug().Type("bvlc", 
bvlc).Msg("Received strange type")
diff --git a/plc4go/internal/bacnetip/ReaderResultDelivery_test.go 
b/plc4go/internal/bacnetip/ReaderResultDelivery_test.go
index b576771da2..a6a74918dc 100644
--- a/plc4go/internal/bacnetip/ReaderResultDelivery_test.go
+++ b/plc4go/internal/bacnetip/ReaderResultDelivery_test.go
@@ -76,7 +76,7 @@ func (c *captureCodec) GetDefaultIncomingMessageChannel() 
chan spi.Message { ret
 func TestReader_lateErrorHandlerAfterFailedSendMustNotBlock(t *testing.T) {
        codec := newCaptureCodec(errors.New("send failed: broken pipe"))
        tm := transactions.NewRequestTransactionManager(1)
-       reader := NewReader(&InvokeIdGenerator{}, codec, tm)
+       reader := NewReader(&InvokeIdGenerator{}, codec, tm, nil)
 
        objType := readWriteModel.BACnetObjectType_ANALOG_INPUT
        propId := readWriteModel.BACnetPropertyIdentifier_PRESENT_VALUE
diff --git a/plc4go/internal/bacnetip/ReaderSegmentation.go 
b/plc4go/internal/bacnetip/ReaderSegmentation.go
index e77ea331a1..c3a2ee375c 100644
--- a/plc4go/internal/bacnetip/ReaderSegmentation.go
+++ b/plc4go/internal/bacnetip/ReaderSegmentation.go
@@ -149,7 +149,7 @@ func (m *Reader) sendSegmentAck(ctx context.Context, ack 
readWriteModel.APDUSegm
        if ack == nil {
                return nil
        }
-       return m.messageCodec.Send(ctx, "segmentAck", wrapAPDU(ack, false))
+       return m.messageCodec.Send(ctx, "segmentAck", wrapAPDU(ack, false, 
m.routedDest))
 }
 
 // apduFromMessage safely extracts the APDU from a received BVLC message,
diff --git a/plc4go/internal/bacnetip/Subscriber.go 
b/plc4go/internal/bacnetip/Subscriber.go
index ee7ff04c47..9bc7715a9e 100644
--- a/plc4go/internal/bacnetip/Subscriber.go
+++ b/plc4go/internal/bacnetip/Subscriber.go
@@ -192,7 +192,7 @@ func (m *Subscriber) sendSubscribeCOV(ctx context.Context, 
handle *SubscriptionH
        // SimpleAck back through the codec. Phase 5 will replace this with a 
real
        // transaction-manager-backed retry loop honoring ApduTimeoutMs/Retries.
        done := make(chan apiModel.PlcResponseCode, 1)
-       err := m.connection.messageCodec.SendRequest(ctx, "subscribe-cov", 
wrapAPDU(apdu, true),
+       err := m.connection.messageCodec.SendRequest(ctx, "subscribe-cov", 
wrapAPDU(apdu, true, m.connection.routedDest),
                func(message spi.Message) bool {
                        return m.acceptsResponse(message, invokeId)
                },
diff --git a/plc4go/internal/bacnetip/WriteRoundtrip_test.go 
b/plc4go/internal/bacnetip/WriteRoundtrip_test.go
index 07c4828a3d..1448062fef 100644
--- a/plc4go/internal/bacnetip/WriteRoundtrip_test.go
+++ b/plc4go/internal/bacnetip/WriteRoundtrip_test.go
@@ -146,7 +146,7 @@ func (d *fakeWriteDevice) extractInvokeIdAndChoice(data 
[]byte) (uint8, model.BA
 
 func (d *fakeWriteDevice) buildSimpleAck(invokeId uint8, choice 
model.BACnetConfirmedServiceChoice) model.BVLC {
        apdu := model.NewAPDUSimpleAck(invokeId, choice)
-       return wrapAPDU(apdu, false)
+       return wrapAPDU(apdu, false, nil)
 }
 
 // buildReadAckFor mirrors fakeBacnetDevice.buildReadPropertyAck: a 
ReadProperty
@@ -160,13 +160,13 @@ func buildReadAckFor(invokeId uint8) model.BVLC {
                
constructedDataFromTag(model.CreateBACnetApplicationTagReal(23.5)),
        )
        apdu := model.NewAPDUComplexAck(false, false, invokeId, nil, nil, 
serviceAck, nil, nil)
-       return wrapAPDU(apdu, false)
+       return wrapAPDU(apdu, false, nil)
 }
 
 func (d *fakeWriteDevice) buildErrorReply(invokeId uint8, choice 
model.BACnetConfirmedServiceChoice) model.BVLC {
        base := buildErrorAPDU(model.ErrorClass_PROPERTY, 
model.ErrorCode_WRITE_ACCESS_DENIED)
        apdu := model.NewAPDUError(invokeId, choice, base.GetError())
-       return wrapAPDU(apdu, false)
+       return wrapAPDU(apdu, false, nil)
 }
 
 func (d *fakeWriteDevice) stop() {
diff --git a/plc4go/internal/bacnetip/Writer.go 
b/plc4go/internal/bacnetip/Writer.go
index 703304d485..a054e66aa7 100644
--- a/plc4go/internal/bacnetip/Writer.go
+++ b/plc4go/internal/bacnetip/Writer.go
@@ -44,6 +44,7 @@ type Writer struct {
        messageCodec      spi.MessageCodec
        tm                transactions.RequestTransactionManager
        driverContext     DriverContext
+       routedDest        *routedDestination // nil for local-segment 
connections
 
        wg sync.WaitGroup
 
@@ -55,6 +56,7 @@ func NewWriter(
        messageCodec spi.MessageCodec,
        tm transactions.RequestTransactionManager,
        driverContext DriverContext,
+       routedDest *routedDestination,
        _options ...options.WithOption,
 ) *Writer {
        customLogger := 
options.ExtractCustomLoggerOrDefaultToGlobal(_options...)
@@ -63,6 +65,7 @@ func NewWriter(
                messageCodec:      messageCodec,
                tm:                tm,
                driverContext:     driverContext,
+               routedDest:        routedDest,
                log:               customLogger,
        }
 }
@@ -108,7 +111,7 @@ func (m *Writer) Write(ctx context.Context, writeRequest 
apiModel.PlcWriteReques
                        ctx, cancel := context.WithCancel(ctx)
                        context.AfterFunc(transactionContext, cancel)
 
-                       err := m.messageCodec.SendRequest(ctx, "write", 
wrapAPDU(apdu, true), func(message spi.Message) bool {
+                       err := m.messageCodec.SendRequest(ctx, "write", 
wrapAPDU(apdu, true, m.routedDest), func(message spi.Message) bool {
                                return m.acceptsResponse(message, invokeId)
                        }, func(message spi.Message) error {
                                bvlc := message.(readWriteModel.BVLC)

Reply via email to