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 711fb31206 feat(plc4go): decode BACnet array/bit-string properties, 
multi-target WhoIs, and codec receive fixes
711fb31206 is described below

commit 711fb312069e2f6c3dd60cdbb706d9949fe5c370
Author: Sebastian Rühl <[email protected]>
AuthorDate: Wed Jun 24 13:05:38 2026 +0200

    feat(plc4go): decode BACnet array/bit-string properties, multi-target 
WhoIs, and codec receive fixes
    
    Further hardening of the pure-Go BACnet/IP driver so it can drive real
    device discovery, capability detection, and COV.
    
    - ValueDecoder: decode array-valued ConstructedData (OBJECT_LIST, ...) to a
      PlcList — and an array-index-0 read to the element count — instead of
      stringifying it; decode tagged bit-strings (PROTOCOL_SERVICES_SUPPORTED,
      STATUS_FLAGS, ...) to a packed byte array. Object enumeration and
      services-supported detection now work. (+ValueDecoder_test)
    
    - Discoverer: add the `remote-addresses` option — a broadcast WhoIs plus a
      directed unicast WhoIs to each listed host from a single socket — for
      environments where a broadcast IAm is not routed back to the sender. New
      DiscovererControl_{linux,other} tunes the socket
      (SO_REUSEADDR/REUSEPORT/BROADCAST/BINDTODEVICE). (+Discoverer_test)
    
    - udp TransportInstance: set a fresh short read deadline before peeking the
      buffer; the request-context deadline is sticky, so a deadline-less peek 
kept
      failing with i/o timeout and stranded late/unsolicited responses.
    
    - Connection: block with a 100ms timeout in the incoming-message poll loop
      instead of a non-blocking default that pegged a CPU core and starved the
      codec receive worker, so responses were never read.
    
    - Add ReadRoundtrip_test covering a read against a fake UDP device.
---
 plc4go/internal/bacnetip/Connection.go             |   7 +-
 plc4go/internal/bacnetip/Discoverer.go             | 177 ++++++++++++-----
 .../internal/bacnetip/DiscovererControl_linux.go   |  57 ++++++
 .../internal/bacnetip/DiscovererControl_other.go   |  32 +++
 plc4go/internal/bacnetip/Discoverer_test.go        |  50 +++++
 plc4go/internal/bacnetip/ReadRoundtrip_test.go     | 220 +++++++++++++++++++++
 plc4go/internal/bacnetip/ValueDecoder.go           |  86 ++++++++
 plc4go/internal/bacnetip/ValueDecoder_test.go      |  74 +++++++
 plc4go/spi/transports/udp/TransportInstance.go     |   7 +
 9 files changed, 657 insertions(+), 53 deletions(-)

diff --git a/plc4go/internal/bacnetip/Connection.go 
b/plc4go/internal/bacnetip/Connection.go
index 2277ad0d14..d995a7d329 100644
--- a/plc4go/internal/bacnetip/Connection.go
+++ b/plc4go/internal/bacnetip/Connection.go
@@ -25,6 +25,7 @@ import (
        "runtime/debug"
        "slices"
        "sync"
+       "time"
 
        "github.com/rs/zerolog"
 
@@ -128,10 +129,14 @@ func (c *Connection) Connect(ctx context.Context) error {
 
 func (c *Connection) passToDefaultIncomingMessageChannel() {
        incomingMessageChannel := 
c.messageCodec.GetDefaultIncomingMessageChannel()
+       // Block (with a short timeout so the Connect loop can re-check 
IsConnected
+       // for shutdown) rather than busy-spinning with a default case. The 
previous
+       // non-blocking select pegged a CPU core and starved the codec's receive
+       // worker, so request responses were never read from the socket.
        select {
        case message := <-incomingMessageChannel:
                c.routeIncomingMessage(message)
-       default:
+       case <-time.After(100 * time.Millisecond):
                c.log.Trace().Msg("no incoming message")
        }
 }
diff --git a/plc4go/internal/bacnetip/Discoverer.go 
b/plc4go/internal/bacnetip/Discoverer.go
index f7926a1eb7..d7616c56c5 100644
--- a/plc4go/internal/bacnetip/Discoverer.go
+++ b/plc4go/internal/bacnetip/Discoverer.go
@@ -27,6 +27,7 @@ import (
        "strconv"
        "strings"
        "sync"
+       "syscall"
        "time"
 
        "github.com/rs/zerolog"
@@ -98,7 +99,13 @@ func (d *Discoverer) Discover(ctx context.Context, callback 
func(event apiModel.
        if err != nil {
                return errors.Wrap(err, "error broadcasting and discovering")
        }
-       d.handleIncomingBVLCs(ctx, callback, incomingBVLCChannel)
+       // Dispatch received IAm/IHave to the callback in the background so this
+       // function can enforce the discovery window and tear down cleanly. 
Running
+       // it synchronously here would block until ctx cancellation AND its 
select
+       // had no ctx.Done branch, so Discover never returned.
+       d.wg.Go(func() {
+               d.handleIncomingBVLCs(ctx, callback, incomingBVLCChannel)
+       })
        // Wait for the discovery window to elapse OR for the caller to cancel.
        select {
        case <-time.After(timeout):
@@ -111,7 +118,7 @@ func (d *Discoverer) Discover(ctx context.Context, callback 
func(event apiModel.
 }
 
 func (d *Discoverer) broadcastAndDiscover(ctx context.Context, 
communicationChannels []communicationChannel, specificOptions 
*protocolSpecificOptions) (chan receivedBvlcMessage, error) {
-       incomingBVLCChannel := make(chan receivedBvlcMessage)
+       incomingBVLCChannel := make(chan receivedBvlcMessage, 32)
        for _, communicationChannelInstance := range communicationChannels {
                if err := ctx.Err(); err != nil {
                        return incomingBVLCChannel, err
@@ -124,32 +131,55 @@ func (d *Discoverer) broadcastAndDiscover(ctx 
context.Context, communicationChan
                                lowLimit = 
driverModel.CreateBACnetContextTagUnsignedInteger(0, whoIsOptions.limits.low)
                                highLimit = 
driverModel.CreateBACnetContextTagUnsignedInteger(1, whoIsOptions.limits.high)
                        }
-                       requestWhoIs := 
driverModel.NewBACnetUnconfirmedServiceRequestWhoIs(lowLimit, highLimit)
-                       apdu := 
driverModel.NewAPDUUnconfirmedRequest(requestWhoIs)
 
-                       control := driverModel.NewNPDUControl(false, false, 
false, false, driverModel.NPDUNetworkPriority_NORMAL_MESSAGE)
-                       npdu := driverModel.NewNPDU(1, control, nil, nil, nil, 
nil, nil, nil, nil, nil, apdu)
-                       bvlc := driverModel.NewBVLCOriginalUnicastNPDU(npdu)
+                       // serializeWhoIs frames a WhoIs BVLC: an 
Original-Broadcast-NPDU for a
+                       // broadcast (so peers reply with a broadcast IAm), an
+                       // Original-Unicast-NPDU for a directed WhoIs.
+                       serializeWhoIs := func(directed bool) ([]byte, error) {
+                               requestWhoIs := 
driverModel.NewBACnetUnconfirmedServiceRequestWhoIs(lowLimit, highLimit)
+                               apdu := 
driverModel.NewAPDUUnconfirmedRequest(requestWhoIs)
+                               control := driverModel.NewNPDUControl(false, 
false, false, false, driverModel.NPDUNetworkPriority_NORMAL_MESSAGE)
+                               npdu := driverModel.NewNPDU(1, control, nil, 
nil, nil, nil, nil, nil, nil, nil, apdu)
+                               var bvlc driverModel.BVLC
+                               if directed {
+                                       bvlc = 
driverModel.NewBVLCOriginalUnicastNPDU(npdu)
+                               } else {
+                                       bvlc = 
driverModel.NewBVLCOriginalBroadcastNPDU(npdu)
+                               }
+                               return bvlc.Serialize()
+                       }
 
-                       // Send the search request.
-                       theBytes, err := bvlc.Serialize()
-                       if err != nil {
+                       // Always broadcast on the interface (standard 
discovery on a local
+                       // subnet).
+                       if theBytes, err := serializeWhoIs(false); err != nil {
                                return nil, err
+                       } else if _, err := 
communicationChannelInstance.broadcastConnection.WriteTo(theBytes, 
communicationChannelInstance.broadcastTarget); err != nil {
+                               d.log.Debug().Err(err).Msg("Error sending 
broadcast WhoIs")
                        }
-                       // Directed (unicast) WhoIs when a remote address is 
supplied,
-                       // otherwise broadcast on the interface.
-                       sendConn := 
communicationChannelInstance.broadcastConnection
-                       target := 
communicationChannelInstance.broadcastConnection.LocalAddr()
+
+                       // Additionally send a directed unicast WhoIs to each 
explicit target.
+                       // See remoteAddress / remoteAddresses for why this is 
needed where a
+                       // broadcast IAm is not routed back to the sender.
+                       var directedTargets []string
                        if specificOptions.remoteAddress != "" {
-                               if udpAddr, rerr := 
resolveBacnetUDPAddr(specificOptions.remoteAddress, 
specificOptions.bacNetPort); rerr == nil {
-                                       sendConn = 
communicationChannelInstance.unicastConnection
-                                       target = udpAddr
-                               } else {
-                                       
d.log.Warn().Err(rerr).Str("remoteAddress", 
specificOptions.remoteAddress).Msg("invalid remote-address; falling back to 
broadcast")
-                               }
+                               directedTargets = append(directedTargets, 
specificOptions.remoteAddress)
                        }
-                       if _, err := sendConn.WriteTo(theBytes, target); err != 
nil {
-                               d.log.Debug().Err(err).Msg("Error sending 
WhoIs")
+                       directedTargets = append(directedTargets, 
specificOptions.remoteAddresses...)
+                       if len(directedTargets) > 0 {
+                               theBytes, err := serializeWhoIs(true)
+                               if err != nil {
+                                       return nil, err
+                               }
+                               for _, ra := range directedTargets {
+                                       udpAddr, rerr := 
resolveBacnetUDPAddr(ra, specificOptions.bacNetPort)
+                                       if rerr != nil {
+                                               
d.log.Warn().Err(rerr).Str("remoteAddress", ra).Msg("invalid directed WhoIs 
target; skipping")
+                                               continue
+                                       }
+                                       if _, err := 
communicationChannelInstance.unicastConnection.WriteTo(theBytes, udpAddr); err 
!= nil {
+                                               
d.log.Debug().Err(err).Stringer("target", udpAddr).Msg("Error sending directed 
WhoIs")
+                                       }
+                               }
                        }
                }
                if whoHasOptions := specificOptions.whoHasOptions; 
whoHasOptions != nil {
@@ -196,7 +226,7 @@ func (d *Discoverer) broadcastAndDiscover(ctx 
context.Context, communicationChan
                        if err != nil {
                                return nil, err
                        }
-                       if _, err := 
communicationChannelInstance.broadcastConnection.WriteTo(theBytes, 
communicationChannelInstance.broadcastConnection.LocalAddr()); err != nil {
+                       if _, err := 
communicationChannelInstance.broadcastConnection.WriteTo(theBytes, 
communicationChannelInstance.broadcastTarget); err != nil {
                                d.log.Debug().Err(err).Msg("Error sending 
broadcast")
                        }
                }
@@ -216,7 +246,7 @@ func (d *Discoverer) broadcastAndDiscover(ctx 
context.Context, communicationChan
                                                blockingReadChan <- false
                                                return
                                        }
-                                       d.log.Debug().Stringer("addr", 
addr).Msg("Received broadcast bvlc")
+                                       d.log.Debug().Stringer("addr", 
addr).Msg("Received unicast bvlc")
                                        ctxForModel := 
options.GetLoggerContextForModel(ctx, d.log, 
options.WithPassLoggerToModel(d.passLogToModel))
                                        incomingBvlc, err := 
driverModel.BVLCParse[driverModel.BVLC](ctxForModel, buf[:n])
                                        if err != nil {
@@ -285,11 +315,10 @@ func (d *Discoverer) broadcastAndDiscover(ctx 
context.Context, communicationChan
 
 func (d *Discoverer) handleIncomingBVLCs(ctx context.Context, callback 
func(event apiModel.PlcDiscoveryItem), incomingBVLCChannel chan 
receivedBvlcMessage) {
        for {
-               if err := ctx.Err(); err != nil {
-                       // TODO: maybe we log something, but maybe it is fine
-                       return
-               }
                select {
+               case <-ctx.Done():
+                       d.log.Debug().Err(ctx.Err()).Msg("Ending incoming BVLC 
handling")
+                       return
                case receivedBvlc := <-incomingBVLCChannel:
                        var npdu driverModel.NPDU
                        if bvlc, ok := receivedBvlc.bvlc.(interface{ GetNpdu() 
driverModel.NPDU }); ok {
@@ -344,9 +373,6 @@ func (d *Discoverer) handleIncomingBVLCs(ctx 
context.Context, callback func(even
                                // Pass the event back to the callback
                                callback(discoveryEvent)
                        }
-               case <-ctx.Done():
-                       d.log.Debug().Err(ctx.Err()).Msg("Ending unicast 
receive")
-                       return
                }
        }
 }
@@ -400,30 +426,39 @@ func (d *Discoverer) buildupCommunicationChannels(ctx 
context.Context, interface
                        // requires a userland demultiplexer, not socket 
options. If you
                        // hit "address already in use" here, stop whatever 
else is bound
                        // to the BACnet/IP UDP port.
-                       var lc net.ListenConfig
-                       unicastConnection, err := lc.ListenPacket(ctx, "udp4", 
fmt.Sprintf("%v:%d", ipAddr, bacNetPort))
+                       // Bind to the wildcard address (0.0.0.0) rather than 
the specific
+                       // interface IP. A socket bound to a specific IP fails 
to receive the
+                       // unicast IAm replies on some interfaces (notably the 
virtual
+                       // interfaces used in tests) even though the packets 
arrive at that IP
+                       // — which is why the conventional BACnet/IP stack (and 
gobacnet) bind
+                       // the wildcard. SO_BROADCAST lets the same socket send 
the WhoIs to
+                       // the subnet broadcast address.
+                       ifName := networkInterface.Name
+                       lc := net.ListenConfig{Control: func(_ string, _ 
string, c syscall.RawConn) error {
+                               return controlDiscoverySocket(c, ifName)
+                       }}
+                       conn, err := lc.ListenPacket(ctx, "udp4", 
fmt.Sprintf("0.0.0.0:%d", bacNetPort))
                        if err != nil {
-                               d.log.Debug().Err(err).Msg("Error building 
unicast Port")
+                               d.log.Debug().Err(err).Msg("Error building 
discovery socket")
                                continue
                        }
 
                        _, cidr, _ := net.ParseCIDR(unicastAddress.String())
-                       broadcastAddr := make(net.IP, len(cidr.IP))
-                       for i := range broadcastAddr {
-                               broadcastAddr[i] = cidr.IP[i] | ^cidr.Mask[i]
-                       }
-                       broadcastConnection, err := lc.ListenPacket(ctx, 
"udp4", fmt.Sprintf("%v:%d", broadcastAddr, bacNetPort))
-                       if err != nil {
-                               if err := unicastConnection.Close(); err != nil 
{
-                                       d.log.Debug().Err(err).Msg("Error 
closing transport instance")
-                               }
-                               d.log.Debug().Err(err).Msg("Error building 
broadcast Port")
-                               continue
+                       broadcastIP := make(net.IP, len(cidr.IP))
+                       for i := range broadcastIP {
+                               broadcastIP[i] = cidr.IP[i] | ^cidr.Mask[i]
                        }
+                       broadcastTarget := &net.UDPAddr{IP: broadcastIP, Port: 
bacNetPort}
+                       d.log.Debug().
+                               Str("interface", networkInterface.Name).
+                               Stringer("local", conn.LocalAddr()).
+                               Stringer("broadcastTarget", broadcastTarget).
+                               Msg("discovery channel bound")
                        communicationChannels = append(communicationChannels, 
communicationChannel{
                                networkInterface:    networkInterface,
-                               unicastConnection:   unicastConnection,
-                               broadcastConnection: broadcastConnection,
+                               unicastConnection:   conn,
+                               broadcastConnection: conn,
+                               broadcastTarget:     broadcastTarget,
                                log:                 d.log,
                        })
                }
@@ -447,13 +482,20 @@ type communicationChannel struct {
        networkInterface    net.Interface
        unicastConnection   net.PacketConn
        broadcastConnection net.PacketConn
-       log                 zerolog.Logger
+       // broadcastTarget is the subnet broadcast address (e.g. 
192.168.100.255:47808)
+       // to which WhoIs is sent. The socket itself is bound to the wildcard 
address.
+       broadcastTarget net.Addr
+       log             zerolog.Logger
 }
 
 func (c communicationChannel) Close() error {
        defer utils.StopWarn(c.log)()
        _ = c.unicastConnection.Close()
-       _ = c.broadcastConnection.Close()
+       // unicastConnection and broadcastConnection are the same socket; 
closing
+       // twice is harmless (the second returns an already-closed error).
+       if c.broadcastConnection != c.unicastConnection {
+               _ = c.broadcastConnection.Close()
+       }
        return nil
 }
 
@@ -486,11 +528,19 @@ func extractInterfaces(discoveryOptions 
[]options.WithDiscoveryOption) ([]net.In
 type protocolSpecificOptions struct {
        bacNetPort int
        // remoteAddress, when set, sends the WhoIs as a directed unicast to 
this
-       // host (instead of the interface broadcast), enabling targeted 
discovery of
-       // a specific device or subnet. Host only or host:port; port defaults to
+       // host (in addition to the interface broadcast), enabling targeted 
discovery
+       // of a specific device or subnet. Host only or host:port; port 
defaults to
        // bacNetPort.
        remoteAddress string
-       whoIsOptions  *struct {
+       // remoteAddresses, when non-empty, sends a directed unicast WhoIs to 
each
+       // listed host (in addition to the broadcast and remoteAddress). This 
lets a
+       // caller sweep a list of candidate device IPs. A directed unicast 
forces the
+       // sender's OS to ARP each target first, which seeds the reverse path 
so the
+       // IAm reply can be delivered even where a broadcast IAm would not be 
routed
+       // back (through a router/BBMD, or on stacks that learn MACs only from 
ARP).
+       // Each entry is host only or host:port; port defaults to bacNetPort.
+       remoteAddresses []string
+       whoIsOptions    *struct {
                limits *struct {
                        low  uint
                        high uint
@@ -545,6 +595,13 @@ func remoteAddress(addr string) option {
        }
 }
 
+func remoteAddresses(addrs []string) option {
+       return func(specificOptions *protocolSpecificOptions) error {
+               specificOptions.remoteAddresses = addrs
+               return nil
+       }
+}
+
 func whoIsLimits(whoIsLowLimit, whoIsHighLimit uint) option {
        return func(specificOptions *protocolSpecificOptions) error {
                specificOptions.whoIsOptions = &struct {
@@ -691,6 +748,22 @@ func extractProtocolSpecificOptions(discoveryOptions 
[]options.WithDiscoveryOpti
                collectedOptions = append(collectedOptions, remoteAddress(addr))
        }
 
+       if _, ok := filteredOptionMap["remote-addresses"]; ok {
+               joined, err := OneString(filteredOptionMap, "remote-addresses")
+               if err != nil {
+                       return nil, err
+               }
+               var addrs []string
+               for _, a := range strings.Split(joined, ",") {
+                       if a = strings.TrimSpace(a); a != "" {
+                               addrs = append(addrs, a)
+                       }
+               }
+               if len(addrs) > 0 {
+                       collectedOptions = append(collectedOptions, 
remoteAddresses(addrs))
+               }
+       }
+
        if whoIsLow, whoIsHigh, ok, err := func() (whoIsLowLimit uint, 
whoIsHighLimit uint, ok bool, err error) {
                if _, limitPresent := filteredOptionMap["who-is-low-limit"]; 
!limitPresent {
                        return
diff --git a/plc4go/internal/bacnetip/DiscovererControl_linux.go 
b/plc4go/internal/bacnetip/DiscovererControl_linux.go
new file mode 100644
index 0000000000..f0c563d998
--- /dev/null
+++ b/plc4go/internal/bacnetip/DiscovererControl_linux.go
@@ -0,0 +1,57 @@
+//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 bacnetip
+
+import (
+       "syscall"
+
+       "golang.org/x/sys/unix"
+)
+
+// controlDiscoverySocket configures the discovery UDP socket. SO_BINDTODEVICE
+// ties the wildcard-bound socket to one NIC so it reliably receives unicast
+// IAm replies arriving on that interface (notably on virtual/test interfaces,
+// where a plain wildcard socket may not be handed the unicast datagrams).
+// SO_REUSEADDR/SO_REUSEPORT allow rebinding across discovery sweeps and
+// co-existing with other BACnet listeners; SO_BROADCAST permits sending the
+// WhoIs to the subnet broadcast address. This mirrors gobacnet's datalink.
+func controlDiscoverySocket(c syscall.RawConn, interfaceName string) error {
+       var sockErr error
+       ctrlErr := c.Control(func(fd uintptr) {
+               if sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, 
unix.SO_REUSEADDR, 1); sockErr != nil {
+                       return
+               }
+               if sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, 
unix.SO_REUSEPORT, 1); sockErr != nil {
+                       return
+               }
+               if sockErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, 
unix.SO_BROADCAST, 1); sockErr != nil {
+                       return
+               }
+               if interfaceName != "" {
+                       sockErr = unix.SetsockoptString(int(fd), 
unix.SOL_SOCKET, unix.SO_BINDTODEVICE, interfaceName)
+               }
+       })
+       if ctrlErr != nil {
+               return ctrlErr
+       }
+       return sockErr
+}
diff --git a/plc4go/internal/bacnetip/DiscovererControl_other.go 
b/plc4go/internal/bacnetip/DiscovererControl_other.go
new file mode 100644
index 0000000000..66d4e47d52
--- /dev/null
+++ b/plc4go/internal/bacnetip/DiscovererControl_other.go
@@ -0,0 +1,32 @@
+//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 bacnetip
+
+import "syscall"
+
+// controlDiscoverySocket is a no-op on non-Linux platforms. SO_BINDTODEVICE
+// (the option that makes the wildcard-bound socket reliably receive unicast 
IAm
+// replies on a specific NIC) is Linux-specific, so discovery socket tuning is
+// only applied there for now.
+func controlDiscoverySocket(_ syscall.RawConn, _ string) error {
+       return nil
+}
diff --git a/plc4go/internal/bacnetip/Discoverer_test.go 
b/plc4go/internal/bacnetip/Discoverer_test.go
index 655f4ed97a..bf773c8048 100644
--- a/plc4go/internal/bacnetip/Discoverer_test.go
+++ b/plc4go/internal/bacnetip/Discoverer_test.go
@@ -20,12 +20,16 @@
 package bacnetip
 
 import (
+       "context"
+       "net"
        "testing"
        "time"
 
        "github.com/stretchr/testify/assert"
        "github.com/stretchr/testify/require"
 
+       apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
+       driverModel 
"github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
        "github.com/apache/plc4x/plc4go/spi/options"
 )
 
@@ -52,6 +56,52 @@ func TestSetDiscoveryTimeout_NegativeFallsBackToDefault(t 
*testing.T) {
        assert.Equal(t, 5*time.Second, d.discoveryTimeout)
 }
 
+func TestHandleIncomingBVLCs_DispatchesIAm(t *testing.T) {
+       // Real IAm captured from a device advertising instance 3001 (vendor 
999):
+       // BVLC(Original-Broadcast-NPDU) / NPDU / APDU(unconfirmed, IAm).
+       iamBytes := []byte{
+               0x81, 0x0b, 0x00, 0x15, // BVLC
+               0x01, 0x00, // NPDU
+               0x10, 0x00, // APDU unconfirmed, service IAm
+               0xc4, 0x02, 0x00, 0x0b, 0xb9, // object-id: device 3001
+               0x22, 0x05, 0xc4, // max-apdu 1476
+               0x91, 0x00, // segmentation: both
+               0x22, 0x03, 0xe7, // vendor 999
+       }
+       bvlc, err := 
driverModel.BVLCParse[driverModel.BVLC](context.Background(), iamBytes)
+       require.NoError(t, err)
+
+       d := NewDiscoverer()
+       ch := make(chan receivedBvlcMessage, 1)
+       ch <- receivedBvlcMessage{bvlc: bvlc, addr: &net.UDPAddr{IP: 
net.IPv4(192, 168, 100, 2), Port: 47808}}
+
+       got := make(chan apiModel.PlcDiscoveryItem, 1)
+       ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+       defer cancel()
+       done := make(chan struct{})
+       go func() {
+               d.handleIncomingBVLCs(ctx, func(item apiModel.PlcDiscoveryItem) 
{ got <- item }, ch)
+               close(done)
+       }()
+
+       select {
+       case item := <-got:
+               assert.Contains(t, item.GetName(), "3001")
+               transportURL := item.GetTransportUrl()
+               assert.Equal(t, "192.168.100.2", transportURL.Hostname())
+       case <-time.After(2 * time.Second):
+               t.Fatal("callback was not invoked for IAm")
+       }
+
+       // Cancelling the context must make the handler return (no hang).
+       cancel()
+       select {
+       case <-done:
+       case <-time.After(2 * time.Second):
+               t.Fatal("handleIncomingBVLCs did not return after context 
cancellation")
+       }
+}
+
 func TestResolveBacnetUDPAddr(t *testing.T) {
        // Host only — default port applies.
        addr, err := resolveBacnetUDPAddr("192.168.1.50", 47808)
diff --git a/plc4go/internal/bacnetip/ReadRoundtrip_test.go 
b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
new file mode 100644
index 0000000000..28af60378d
--- /dev/null
+++ b/plc4go/internal/bacnetip/ReadRoundtrip_test.go
@@ -0,0 +1,220 @@
+/*
+ * 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 bacnetip
+
+import (
+       "context"
+       "fmt"
+       "net"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/rs/zerolog"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+
+       plc4go "github.com/apache/plc4x/plc4go/pkg/api"
+       "github.com/apache/plc4x/plc4go/pkg/api/cache"
+       apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
+       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"
+)
+
+// fakeBacnetDevice is a minimal UDP BACnet/IP device used to exercise the real
+// driver's read round-trip. On every confirmed request it replies with a
+// ReadProperty ComplexAck carrying ANALOG_INPUT,1/PRESENT_VALUE = 23.5, 
echoing
+// the request's invoke id so the Reader's expectation matches.
+type fakeBacnetDevice struct {
+       conn    *net.UDPConn
+       wg      sync.WaitGroup
+       log     zerolog.Logger
+       gotReqs int
+       mu      sync.Mutex
+}
+
+func startFakeBacnetDevice(t *testing.T, log zerolog.Logger) *fakeBacnetDevice 
{
+       t.Helper()
+       addr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
+       conn, err := net.ListenUDP("udp4", addr)
+       require.NoError(t, err)
+       d := &fakeBacnetDevice{conn: conn, log: log}
+       d.wg.Add(1)
+       go d.serve()
+       return d
+}
+
+func (d *fakeBacnetDevice) port() int { return 
d.conn.LocalAddr().(*net.UDPAddr).Port }
+
+func (d *fakeBacnetDevice) requestCount() int {
+       d.mu.Lock()
+       defer d.mu.Unlock()
+       return d.gotReqs
+}
+
+func (d *fakeBacnetDevice) serve() {
+       defer d.wg.Done()
+       buf := make([]byte, 4096)
+       for {
+               n, src, err := d.conn.ReadFromUDP(buf)
+               if err != nil {
+                       return // socket closed
+               }
+               data := make([]byte, n)
+               copy(data, buf[:n])
+               d.log.Info().Int("bytes", n).Stringer("src", src).Msg("fake 
device received packet")
+
+               invokeId, ok := d.extractInvokeId(data)
+               if !ok {
+                       d.log.Warn().Msg("fake device: could not extract invoke 
id; ignoring")
+                       continue
+               }
+               d.mu.Lock()
+               d.gotReqs++
+               d.mu.Unlock()
+
+               resp := d.buildReadPropertyAck(invokeId)
+               theBytes, err := resp.Serialize()
+               if err != nil {
+                       d.log.Error().Err(err).Msg("fake device: serialize 
response")
+                       continue
+               }
+               if _, err := d.conn.WriteToUDP(theBytes, src); err != nil {
+                       d.log.Error().Err(err).Msg("fake device: write 
response")
+                       continue
+               }
+               d.log.Info().Uint8("invokeId", invokeId).Int("bytes", 
len(theBytes)).Stringer("dst", src).Msg("fake device sent ComplexAck")
+       }
+}
+
+func (d *fakeBacnetDevice) extractInvokeId(data []byte) (uint8, bool) {
+       bvlc, err := model.BVLCParse[model.BVLC](context.Background(), data)
+       if err != nil {
+               d.log.Error().Err(err).Msg("fake device: parse BVLC")
+               return 0, false
+       }
+       npduRetriever, ok := bvlc.(interface{ GetNpdu() model.NPDU })
+       if !ok {
+               return 0, false
+       }
+       apdu := npduRetriever.GetNpdu().GetApdu()
+       cr, ok := apdu.(model.APDUConfirmedRequest)
+       if !ok {
+               d.log.Warn().Msgf("fake device: not a confirmed request: %T", 
apdu)
+               return 0, false
+       }
+       return cr.GetInvokeId(), true
+}
+
+func (d *fakeBacnetDevice) buildReadPropertyAck(invokeId uint8) model.BVLC {
+       serviceAck := model.NewBACnetServiceAckReadProperty(
+               0,
+               model.CreateBACnetContextTagObjectIdentifier(0, 
uint16(model.BACnetObjectType_ANALOG_INPUT), 1),
+               model.CreateBACnetPropertyIdentifierTagged(1, 
uint32(model.BACnetPropertyIdentifier_PRESENT_VALUE)),
+               nil,
+               
constructedDataFromTag(model.CreateBACnetApplicationTagReal(23.5)),
+       )
+       apdu := model.NewAPDUComplexAck(false, false, invokeId, nil, nil, 
serviceAck, nil, nil)
+       return wrapAPDU(apdu, false)
+}
+
+func (d *fakeBacnetDevice) stop() {
+       _ = d.conn.Close()
+       d.wg.Wait()
+}
+
+func traceLogger(t *testing.T) zerolog.Logger {
+       return 
zerolog.New(zerolog.NewTestWriter(t)).Level(zerolog.TraceLevel).With().Timestamp().Logger()
+}
+
+// readPresentValue issues a single ReadRequest for 
ANALOG_INPUT,1/PRESENT_VALUE
+// against the given connection and returns the response code + float value.
+func readPresentValue(t *testing.T, conn plc4go.PlcConnection) 
(apiModel.PlcResponseCode, float32, bool) {
+       t.Helper()
+       rr, err := conn.ReadRequestBuilder().AddTagAddress("pv", 
"ANALOG_INPUT,1/PRESENT_VALUE").Build()
+       require.NoError(t, err)
+       ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+       defer cancel()
+       select {
+       case <-ctx.Done():
+               return 0, 0, false
+       case res := <-rr.Execute(ctx):
+               if res.GetErr() != nil {
+                       t.Logf("read error: %v", res.GetErr())
+                       return 0, 0, false
+               }
+               resp := res.GetResponse()
+               code := resp.GetResponseCode("pv")
+               v := resp.GetValue("pv")
+               if v != nil && v.IsFloat32() {
+                       return code, v.GetFloat32(), true
+               }
+               return code, 0, true
+       }
+}
+
+func TestNativeBacnetRead_DirectConnection(t *testing.T) {
+       log := traceLogger(t)
+       device := startFakeBacnetDevice(t, log)
+       defer device.stop()
+
+       dm := plc4go.NewPlcDriverManager(options.WithCustomLogger(log))
+       dm.RegisterDriver(NewDriver(options.WithCustomLogger(log)))
+       apiTransports.RegisterUdpTransport(dm)
+
+       connStr := 
fmt.Sprintf("bacnet-ip:udp://127.0.0.1:%d?local-port=0&ApduTimeoutMs=3000", 
device.port())
+       ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+       defer cancel()
+       conn, err := dm.GetConnection(ctx, connStr)
+       require.NoError(t, err)
+       defer conn.Close()
+
+       code, val, ok := readPresentValue(t, conn)
+       t.Logf("device received %d request(s)", device.requestCount())
+       require.True(t, ok, "read did not complete (timed out waiting for 
response)")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+       assert.InDelta(t, 23.5, val, 0.001)
+}
+
+func TestNativeBacnetRead_ViaCache(t *testing.T) {
+       log := traceLogger(t)
+       device := startFakeBacnetDevice(t, log)
+       defer device.stop()
+
+       dm := plc4go.NewPlcDriverManager(options.WithCustomLogger(log))
+       dm.RegisterDriver(NewDriver(options.WithCustomLogger(log)))
+       apiTransports.RegisterUdpTransport(dm)
+       connCache := cache.NewPlcConnectionCache(dm, 
cache.WithCustomLogger(log))
+       defer connCache.Close()
+
+       connStr := 
fmt.Sprintf("bacnet-ip:udp://127.0.0.1:%d?local-port=0&ApduTimeoutMs=3000", 
device.port())
+       ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+       defer cancel()
+       conn, err := connCache.GetConnection(ctx, connStr)
+       require.NoError(t, err)
+       defer conn.Close()
+
+       code, val, ok := readPresentValue(t, conn)
+       t.Logf("device received %d request(s)", device.requestCount())
+       require.True(t, ok, "read via cache did not complete (timed out waiting 
for response)")
+       assert.Equal(t, apiModel.PlcResponseCode_OK, code)
+       assert.InDelta(t, 23.5, val, 0.001)
+}
diff --git a/plc4go/internal/bacnetip/ValueDecoder.go 
b/plc4go/internal/bacnetip/ValueDecoder.go
index 48f983ca0a..dcd0bc98bc 100644
--- a/plc4go/internal/bacnetip/ValueDecoder.go
+++ b/plc4go/internal/bacnetip/ValueDecoder.go
@@ -22,6 +22,7 @@ package bacnetip
 import (
        "fmt"
        "reflect"
+       "strings"
        "time"
 
        apiValues "github.com/apache/plc4x/plc4go/pkg/api/values"
@@ -119,10 +120,23 @@ func constructedDataToPlcValue(data 
model.BACnetConstructedData) apiValues.PlcVa
        if u, ok := data.(model.BACnetConstructedDataUnspecified); ok {
                return elementsToPlcValue(u.GetData())
        }
+       // Array-valued properties (OBJECT_LIST, PRIORITY_ARRAY, STATE_TEXT, 
...) have
+       // no single GetActualValue; decode them to a PlcList (or, for an 
array-index-0
+       // read, the element count) before falling through to the scalar path.
+       if pv, ok := arrayConstructedDataToPlcValue(data); ok {
+               return pv
+       }
        if v, ok := callGetActualValue(data); ok {
                if tag, ok := v.(model.BACnetApplicationTag); ok {
                        return appTagToPlcValue(tag)
                }
+               // Tagged bit strings (PROTOCOL_SERVICES_SUPPORTED, 
STATUS_FLAGS,
+               // LIMIT_ENABLE, OBJECT_TYPES_SUPPORTED, ...) carry a 
bit-string payload
+               // rather than a single scalar; surface the packed bytes 
(MSB-first), the
+               // same representation used for a BitString application tag.
+               if pv, ok := taggedBitStringToPlcValue(v); ok {
+                       return pv
+               }
                if pv, ok := taggedEnumToPlcValue(v); ok {
                        return pv
                }
@@ -133,6 +147,78 @@ func constructedDataToPlcValue(data 
model.BACnetConstructedData) apiValues.PlcVa
        return spiValues.NewPlcSTRING(fmt.Sprintf("%T:%v", data, data))
 }
 
+// arrayConstructedDataToPlcValue handles array-valued constructed data such as
+// OBJECT_LIST. BACnet array properties expose a NumberOfDataElements accessor
+// (set only when the client read array index 0, where the device returns just
+// the element count) plus a per-property element slice. A whole-array read
+// yields a PlcList of the decoded elements; an array-index-0 read yields the
+// count as an unsigned PlcValue. Returns ok=false for non-array constructed
+// data (no NumberOfDataElements accessor), so the caller falls through to the
+// scalar GetActualValue path.
+func arrayConstructedDataToPlcValue(data model.BACnetConstructedData) 
(apiValues.PlcValue, bool) {
+       rv := reflect.ValueOf(data)
+       if !rv.IsValid() {
+               return nil, false
+       }
+       countMethod := rv.MethodByName("GetNumberOfDataElements")
+       if !countMethod.IsValid() || countMethod.Type().NumIn() != 0 || 
countMethod.Type().NumOut() != 1 {
+               return nil, false // not an array-valued constructed data
+       }
+       // Array-index-0 read: only the element count is present.
+       if out := countMethod.Call(nil)[0]; out.IsValid() && !out.IsNil() {
+               if tag, ok := out.Interface().(model.BACnetApplicationTag); ok {
+                       return appTagToPlcValue(tag), true
+               }
+       }
+       // Whole-array read: find the element slice accessor and decode each 
entry.
+       for i := 0; i < rv.NumMethod(); i++ {
+               name := rv.Type().Method(i).Name
+               if !strings.HasPrefix(name, "Get") || name == 
"GetNumberOfDataElements" {
+                       continue
+               }
+               method := rv.Method(i)
+               mt := method.Type()
+               if mt.NumIn() != 0 || mt.NumOut() != 1 || mt.Out(0).Kind() != 
reflect.Slice {
+                       continue
+               }
+               out := method.Call(nil)[0]
+               elems := make([]apiValues.PlcValue, 0, out.Len())
+               for j := 0; j < out.Len(); j++ {
+                       switch el := out.Index(j).Interface().(type) {
+                       case model.BACnetApplicationTag:
+                               elems = append(elems, appTagToPlcValue(el))
+                       case model.BACnetConstructedData:
+                               elems = append(elems, 
constructedDataToPlcValue(el))
+                       default:
+                               elems = append(elems, 
spiValues.NewPlcSTRING(fmt.Sprintf("%v", el)))
+                       }
+               }
+               return spiValues.NewPlcList(elems), true
+       }
+       return nil, false
+}
+
+// taggedBitStringToPlcValue handles BACnet *Tagged bit-string wrappers
+// (BACnetServicesSupportedTagged, BACnetStatusFlagsTagged, 
BACnetLimitEnableTagged,
+// BACnetObjectTypesSupportedTagged, ...) returned by GetActualValue on typed
+// ConstructedData subtypes. These expose a GetPayload() returning a
+// BACnetTagPayloadBitString whose GetData() is the unpacked bit slice. We pack
+// it MSB-first into bytes and surface it as a PlcRawByteArray — identical to 
how
+// a BitString application tag is decoded — so callers can walk the bits.
+func taggedBitStringToPlcValue(v any) (apiValues.PlcValue, bool) {
+       bs, ok := v.(interface {
+               GetPayload() model.BACnetTagPayloadBitString
+       })
+       if !ok {
+               return nil, false
+       }
+       payload := bs.GetPayload()
+       if payload == nil {
+               return nil, false
+       }
+       return spiValues.NewPlcRawByteArray(bitsToBytes(payload.GetData())), 
true
+}
+
 // taggedEnumToPlcValue handles BACnet's *Tagged enum wrappers (BACnetBinaryPV,
 // BACnetReliability, BACnetEventState, ...) returned by GetActualValue on
 // typed ConstructedData subtypes. These don't satisfy BACnetApplicationTag
diff --git a/plc4go/internal/bacnetip/ValueDecoder_test.go 
b/plc4go/internal/bacnetip/ValueDecoder_test.go
index d793034c06..e39f194621 100644
--- a/plc4go/internal/bacnetip/ValueDecoder_test.go
+++ b/plc4go/internal/bacnetip/ValueDecoder_test.go
@@ -136,3 +136,77 @@ func TestTaggedEnumToPlcValue_RejectsUnsupportedKind(t 
*testing.T) {
 type complexReturnStub struct{}
 
 func (complexReturnStub) GetValue() []byte { return []byte{1, 2, 3} }
+
+// bitStringPayloadStub satisfies the `GetPayload() BACnetTagPayloadBitString`
+// shape taggedBitStringToPlcValue looks for, standing in for the generated
+// *Tagged bit-string wrappers (ServicesSupported, StatusFlags, ...).
+type bitStringPayloadStub struct {
+       p readWriteModel.BACnetTagPayloadBitString
+}
+
+func (s bitStringPayloadStub) GetPayload() 
readWriteModel.BACnetTagPayloadBitString { return s.p }
+
+func TestTaggedBitStringToPlcValue_PacksBitsMsbFirst(t *testing.T) {
+       // PROTOCOL_SERVICES_SUPPORTED arrives as a tagged bit string. Bit 5
+       // (subscribe-cov) and bit 15 (write-property) set must pack MSB-first 
to
+       // bytes [0x04, 0x01], so the agent's positional bit→service decode 
detects
+       // the right capabilities. A miss here is what made writes report 
"device is
+       // not writeable".
+       bits := make([]bool, 16)
+       bits[5] = true  // subscribe-cov
+       bits[15] = true // write-property
+       payload := 
readWriteModel.CreateBACnetApplicationTagBitString(bits).GetPayload()
+
+       got, ok := taggedBitStringToPlcValue(bitStringPayloadStub{payload})
+       require.True(t, ok, "a tagged bit-string payload should be recognized")
+       require.NotNil(t, got)
+       assert.Equal(t, []byte{0x04, 0x01}, got.GetRaw())
+}
+
+func TestTaggedBitStringToPlcValue_RejectsNonBitString(t *testing.T) {
+       got, ok := taggedBitStringToPlcValue(noGetValueStub{})
+       assert.False(t, ok)
+       assert.Nil(t, got)
+}
+
+func TestConstructedDataToPlcValue_ObjectList_WholeArray(t *testing.T) {
+       // A read of OBJECT_LIST (no array index) returns the full element list.
+       // It must decode to a PlcList of "<type>,<instance>" strings (not a
+       // stringified blob), so device discovery can enumerate the objects.
+       objs := []readWriteModel.BACnetApplicationTagObjectIdentifier{
+               
readWriteModel.CreateBACnetApplicationTagObjectIdentifier(uint16(readWriteModel.BACnetObjectType_DEVICE),
 3001),
+               
readWriteModel.CreateBACnetApplicationTagObjectIdentifier(uint16(readWriteModel.BACnetObjectType_ANALOG_OUTPUT),
 1),
+               
readWriteModel.CreateBACnetApplicationTagObjectIdentifier(uint16(readWriteModel.BACnetObjectType_ANALOG_INPUT),
 1),
+       }
+       data := readWriteModel.NewBACnetConstructedDataObjectList(
+               readWriteModel.CreateBACnetOpeningTag(1),
+               readWriteModel.NewBACnetTagHeader(9, 0, 1, nil, nil, nil, nil),
+               readWriteModel.CreateBACnetClosingTag(1),
+               nil, objs)
+
+       got := constructedDataToPlcValue(data)
+       require.NotNil(t, got)
+       require.True(t, got.IsList(), "OBJECT_LIST should decode to a PlcList, 
got %T", got)
+       list := got.GetList()
+       require.Len(t, list, 3)
+       assert.Equal(t, "DEVICE,3001", list[0].GetString())
+       assert.Equal(t, "ANALOG_OUTPUT,1", list[1].GetString())
+       assert.Equal(t, "ANALOG_INPUT,1", list[2].GetString())
+}
+
+func TestConstructedDataToPlcValue_ObjectList_Count(t *testing.T) {
+       // A read of OBJECT_LIST[0] returns only the element count as an 
unsigned
+       // integer. It must decode to a numeric PlcValue (not a string), so 
callers
+       // can read the array length for an indexed fallback.
+       count := readWriteModel.CreateBACnetApplicationTagUnsignedInteger(5)
+       data := readWriteModel.NewBACnetConstructedDataObjectList(
+               readWriteModel.CreateBACnetOpeningTag(1),
+               readWriteModel.NewBACnetTagHeader(9, 0, 1, nil, nil, nil, nil),
+               readWriteModel.CreateBACnetClosingTag(1),
+               count, nil)
+
+       got := constructedDataToPlcValue(data)
+       require.NotNil(t, got)
+       assert.True(t, got.IsUint32(), "OBJECT_LIST[0] count should be numeric, 
got %T", got)
+       assert.Equal(t, uint32(5), got.GetUint32())
+}
diff --git a/plc4go/spi/transports/udp/TransportInstance.go 
b/plc4go/spi/transports/udp/TransportInstance.go
index e29343a71d..f6a0f594da 100644
--- a/plc4go/spi/transports/udp/TransportInstance.go
+++ b/plc4go/spi/transports/udp/TransportInstance.go
@@ -160,6 +160,13 @@ func (m *TransportInstance) GetNumBytesAvailableInBuffer() 
(uint32, error) {
        if m.reader == nil {
                return 0, nil
        }
+       // Use a fresh, short read deadline for this poll. 
Read/PeekReadableBytes set
+       // a sticky SetReadDeadline from the request context; once that 
deadline has
+       // passed, a deadline-less Peek here would keep failing with i/o 
timeout and
+       // the codec would never observe further inbound datagrams.
+       if m.udpConn != nil {
+               _ = m.udpConn.SetReadDeadline(time.Now().Add(10 * 
time.Millisecond))
+       }
        _, _ = m.reader.Peek(1)
        return uint32(m.reader.Buffered()), nil
 }


Reply via email to