This is an automated email from the ASF dual-hosted git repository. sruehl pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/plc4x.git
commit a9c5517298f796889237e086f76cf96402f72039 Author: Sebastian Rühl <[email protected]> AuthorDate: Fri Jul 24 11:39:32 2026 +0200 test(plc4go/bacnetip): routed-addressing roundtrip coverage Completes the test coverage for the RemoteNetwork/RemoteAddress routed addressing: a fake BACnet router asserting the outgoing destination specifiers and replying source-specified (TestNativeBacnetRead_RoutedViaRouter), plus a serialization pin that a source-specified NPDU carrying a ComplexACK round-trips with its APDU intact. --- .../internal/bacnetip/NpduSourceSpecified_test.go | 36 +++++ .../internal/bacnetip/RoutedReadRoundtrip_test.go | 170 +++++++++++++++++++++ 2 files changed, 206 insertions(+) diff --git a/plc4go/internal/bacnetip/NpduSourceSpecified_test.go b/plc4go/internal/bacnetip/NpduSourceSpecified_test.go new file mode 100644 index 0000000000..592d10df7b --- /dev/null +++ b/plc4go/internal/bacnetip/NpduSourceSpecified_test.go @@ -0,0 +1,36 @@ +package bacnetip + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + readWriteModel "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model" +) + +// Reproduces the routed-reply rejection: a source-specified NPDU carrying a +// ComplexACK must round-trip with its APDU intact. +func TestNPDU_SourceSpecified_APDUSurvives(t *testing.T) { + ack := readWriteModel.NewAPDUSimpleAck(0, readWriteModel.BACnetConfirmedServiceChoice_WRITE_PROPERTY) + control := readWriteModel.NewNPDUControl(false, false, true, false, readWriteModel.NPDUNetworkPriority_NORMAL_MESSAGE) + snet := uint16(3001) + slen := uint8(6) + sadr := []uint8{192, 168, 102, 2, 0xBA, 0xC0} + npdu := readWriteModel.NewNPDU(1, control, nil, nil, nil, &snet, &slen, sadr, nil, nil, ack) + bvlc := readWriteModel.NewBVLCOriginalUnicastNPDU(npdu) + data, err := bvlc.Serialize() + require.NoError(t, err) + t.Logf("frame: % x", data) + + reparsed, err := readWriteModel.BVLCParse[readWriteModel.BVLC](context.Background(), data) + require.NoError(t, err) + renpdu := reparsed.(interface{ GetNpdu() readWriteModel.NPDU }).GetNpdu() + require.NotNil(t, renpdu.GetSourceNetworkAddress()) + assert.Equal(t, uint16(3001), *renpdu.GetSourceNetworkAddress()) + require.NotNil(t, renpdu.GetApdu(), "APDU must survive a source-specified NPDU parse") + require.NoError(t, err) + _, err2 := getInvokeIdFromApdu(renpdu.GetApdu()) + assert.NoError(t, err2) +} diff --git a/plc4go/internal/bacnetip/RoutedReadRoundtrip_test.go b/plc4go/internal/bacnetip/RoutedReadRoundtrip_test.go new file mode 100644 index 0000000000..f43accfd02 --- /dev/null +++ b/plc4go/internal/bacnetip/RoutedReadRoundtrip_test.go @@ -0,0 +1,170 @@ +/* + * 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" + 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" + "github.com/apache/plc4x/plc4go/spi/testutils" +) + +// fakeBacnetRouter plays a BACnet network-layer router: it requires every +// confirmed request to carry the expected destination specifier (DNET/DADR) +// and answers with a ComplexAck whose NPDU carries the routed device's +// SOURCE specifier — exactly what a real router delivers back (ASHRAE 135 +// clause 6.2.4). +type fakeBacnetRouter struct { + conn *net.UDPConn + wg sync.WaitGroup + log zerolog.Logger + t *testing.T + wantDNET uint16 + wantDADR []uint8 + badRequests int + goodRequests int + mu sync.Mutex +} + +func startFakeBacnetRouter(t *testing.T, log zerolog.Logger, dnet uint16, dadr []uint8) *fakeBacnetRouter { + t.Helper() + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + r := &fakeBacnetRouter{conn: conn, log: log, t: t, wantDNET: dnet, wantDADR: dadr} + r.wg.Add(1) + go r.serve() + return r +} + +func (r *fakeBacnetRouter) port() int { return r.conn.LocalAddr().(*net.UDPAddr).Port } + +func (r *fakeBacnetRouter) counts() (good, bad int) { + r.mu.Lock() + defer r.mu.Unlock() + return r.goodRequests, r.badRequests +} + +func (r *fakeBacnetRouter) serve() { + defer r.wg.Done() + buf := make([]byte, 4096) + for { + n, src, err := r.conn.ReadFromUDP(buf) + if err != nil { + return + } + data := make([]byte, n) + copy(data, buf[:n]) + + bvlc, err := model.BVLCParse[model.BVLC](r.t.Context(), data) + if err != nil { + continue + } + npdu := bvlc.(interface{ GetNpdu() model.NPDU }).GetNpdu() + cr, ok := npdu.GetApdu().(model.APDUConfirmedRequest) + if !ok { + continue + } + + // A router can only forward specifier-carrying NPDUs. + dnet := npdu.GetDestinationNetworkAddress() + if !npdu.GetControl().GetDestinationSpecified() || dnet == nil || *dnet != r.wantDNET { + r.mu.Lock() + r.badRequests++ + r.mu.Unlock() + r.log.Warn().Msg("fake router: request without expected destination specifier — dropping (as a real router would)") + continue + } + r.mu.Lock() + r.goodRequests++ + r.mu.Unlock() + + // Reply as the routed device: ComplexAck with SOURCE specifier. + 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(42.25)), + ) + apdu := model.NewAPDUComplexAck(false, false, cr.GetInvokeId(), nil, nil, serviceAck, nil, nil) + control := model.NewNPDUControl(false, false, true, false, model.NPDUNetworkPriority_NORMAL_MESSAGE) + snet := r.wantDNET + slen := uint8(len(r.wantDADR)) + replyNpdu := model.NewNPDU(1, control, nil, nil, nil, &snet, &slen, r.wantDADR, nil, nil, apdu) + reply, err := model.NewBVLCOriginalUnicastNPDU(replyNpdu).Serialize() + if err != nil { + r.log.Error().Err(err).Msg("fake router: serialize reply") + continue + } + if _, err := r.conn.WriteToUDP(reply, src); err != nil { + return + } + } +} + +func (r *fakeBacnetRouter) stop() { + _ = r.conn.Close() + r.wg.Wait() +} + +// TestNativeBacnetRead_RoutedViaRouter pins the routed round trip end to end +// at driver level: a connection with RemoteNetwork/RemoteAddress emits +// destination-specified requests, and the source-specified reply a router +// delivers back must satisfy the read. +func TestNativeBacnetRead_RoutedViaRouter(t *testing.T) { + log := testutils.ProduceTestingLogger(t) + dadr := []uint8{192, 168, 102, 2, 0xBA, 0xC0} + router := startFakeBacnetRouter(t, log, 3001, dadr) + t.Cleanup(router.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&RemoteNetwork=3001&RemoteAddress=192.168.102.2:47808", router.port()) + connCtx, connCancel := context.WithTimeout(t.Context(), 10*time.Second) + t.Cleanup(connCancel) + conn, err := dm.GetConnection(connCtx, connStr) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, conn.Close()) + }) + + code, val, ok := readPresentValue(t, conn) + good, bad := router.counts() + t.Logf("router saw %d routed request(s), %d unroutable", good, bad) + assert.Zero(t, bad, "driver must never send specifier-less requests on a routed connection") + require.True(t, ok, "routed read did not complete (reply rejected or timed out)") + assert.Equal(t, apiModel.PlcResponseCode_OK, code) + assert.InDelta(t, 42.25, val, 0.001) +}
