From cbdbb34603c54a267fbddd652a68db8b4b352cd4 Mon Sep 17 00:00:00 2001 From: bartvanbos Date: Sun, 8 Nov 2026 13:17:16 +0100 Subject: [PATCH 1/2] feat(lldp): management address interface number and OID in lldp neighbors (needs go-profinet v0.8.0) Co-Authored-By: Claude Opus 5.5 --- cmd/lldp.go | 39 +++++++++++++++++++++++++++++++++++++++ cmd/records_test.go | 2 +- cmd/unit_test.go | 16 ++++++++++++++++ docs/json-output.md | 2 +- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/cmd/lldp.go b/cmd/lldp.go index eda9664..7353f58 100644 --- a/cmd/lldp.go +++ b/cmd/lldp.go @@ -32,6 +32,11 @@ type neighborView struct { SystemDescription string `json:"system_description,omitempty"` PortDescription string `json:"port_description,omitempty"` ManagementAddress string `json:"management_address,omitempty"` + // ManagementIfIndex and ManagementOID are the interface number and the + // object identifier of the management address TLV (Siemens devices + // send their port number and the PNO enterprise OID 1.3.6.1.4.1.24686). + ManagementIfIndex uint32 `json:"management_if_index,omitempty"` + ManagementOID string `json:"management_oid,omitempty"` ChassisMAC string `json:"chassis_mac,omitempty"` // Alias is the PROFINET AliasName of the device connected to this // neighbor's port: the name a device without station name answers to. @@ -80,6 +85,8 @@ func neighborViews(ns []lldp.Neighbor) []neighborView { SystemName: n.Frame.SystemName, SystemDescription: n.Frame.SystemDescription, PortDescription: n.Frame.PortDescription, Alias: n.Alias()} if n.Frame.ManagementAddr.IsValid() { v.ManagementAddress = n.Frame.ManagementAddr.String() + v.ManagementIfIndex = n.Frame.ManagementIfIndex + v.ManagementOID = berOID(n.Frame.ManagementOID) } if n.Frame.ChassisMAC != nil { v.ChassisMAC = n.Frame.ChassisMAC.String() @@ -382,3 +389,35 @@ without an IP address cannot be read. Exit code 7 when some could not be read.`, c.AddCommand(newTopologyCheckCmd(a)) return c } + +// berOID returns a BER-encoded object identifier (without tag and length) +// in dotted form, or the octets in hex when they are no valid OID. +func berOID(b []byte) string { + if len(b) == 0 { + return "" + } + var parts []string + var v uint64 + first := true + for i, c := range b { + if v > 1<<56 { + return fmt.Sprintf("%x", b) + } + v = v<<7 | uint64(c&0x7F) + if c&0x80 != 0 { + if i == len(b)-1 { + return fmt.Sprintf("%x", b) + } + continue + } + if first { + x := min(v/40, 2) + parts = append(parts, fmt.Sprint(x), fmt.Sprint(v-40*x)) + first = false + } else { + parts = append(parts, fmt.Sprint(v)) + } + v = 0 + } + return strings.Join(parts, ".") +} diff --git a/cmd/records_test.go b/cmd/records_test.go index c122ee5..943fd7e 100644 --- a/cmd/records_test.go +++ b/cmd/records_test.go @@ -252,7 +252,7 @@ func TestLLDPAndTopology(t *testing.T) { a = nb } } - if a.PortID != "port-001.dev-a" || a.ManagementAddress != "192.168.0.10" || a.TTL == 0 || a.Alias == "" { + if a.PortID != "port-001.dev-a" || a.ManagementAddress != "192.168.0.10" || a.ManagementIfIndex != 1 || a.ManagementOID != "" || a.TTL == 0 || a.Alias == "" { t.Fatalf("dev-a %+v", a) } out := n.ok("-i", "pn0", "lldp", "neighbors", "--wait", "400ms") diff --git a/cmd/unit_test.go b/cmd/unit_test.go index 3b00b5b..95a6185 100644 --- a/cmd/unit_test.go +++ b/cmd/unit_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" "context" + "encoding/hex" "errors" "fmt" "net" @@ -245,3 +246,18 @@ func TestHelpers(t *testing.T) { t.Error("target") } } + +func TestBerOID(t *testing.T) { + for in, want := range map[string]string{ + "": "", + "2b0601040181c06e": "1.3.6.1.4.1.24686", + "8837": "2.999", + "2b81": "2b81", // cut inside a sub-identifier + "2bffffffffffffffff7f": "2bffffffffffffffff7f", + } { + b, _ := hex.DecodeString(in) + if got := berOID(b); got != want { + t.Errorf("berOID(%s) = %q, want %q", in, got, want) + } + } +} diff --git a/docs/json-output.md b/docs/json-output.md index d4bfcc1..89a32a3 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -29,7 +29,7 @@ These conventions are a **stable contract** for scripting. Field names are snake | `diagnostics` | `{"mac", "station", "entries": [{"api", "slot", "subslot", "channel", "direction", "severity", "error_type", "error", "ext_error_type", "ext_add_value", "qualifier", "usi", "vendor_data"}]}` | | `device info` | `{"device", "im0", "modules": [{"api", "slot", "module_ident_number", "submodules": [{"subslot", "submodule_ident_number"}]}], "errors"}` | | `status` | `{"device", "dcp", "rpc", "diagnoses": {"fault": n, ...}, "ports": [{"subslot", "port_id", "link", "peer"}], "errors"}` | -| `lldp neighbors` | `{"interface", "neighbors": [{"mac", "chassis_id", "port_id", "ttl", "system_name", "system_description", "port_description", "management_address", "chassis_mac", "alias", "line_delay"}]}` | +| `lldp neighbors` | `{"interface", "neighbors": [{"mac", "chassis_id", "port_id", "ttl", "system_name", "system_description", "port_description", "management_address", "management_if_index", "management_oid", "chassis_mac", "alias", "line_delay"}]}` | | `topology` | `{"host_neighbors", "devices": [{"station", "mac", "ip", "ports": [{"subslot", "port_id", "link", "peers": [{"station", "port", "mac", "line_delay"}]}], "error"}], "links": [{"a", "a_port", "b", "b_port"}]}` | | `gsdml inspect` | `{"file", "schema_version", "vendor_id", "device_id", "vendor_name", "family", "device_access_points": [...], "valid", "issues"}` | | `gsdml validate` | `{"file", "valid", "issues": [{"path", "message", "severity", "rule"}], "scope"}`; `severity` is `"error"` (validate reports errors only), `rule` names the rule broken: `"structure"` (or absent), `"reference"` or `"io-data-length"` (input or output data too long for an RT frame) | From 48b91b1ccd2f15dcf88baf2e91b8289637562e78 Mon Sep 17 00:00:00 2001 From: bartvanbos Date: Sun, 8 Nov 2026 14:04:05 +0100 Subject: [PATCH 2/2] deps: go-profinet v0.8.0; v0.10.0 release notes go-profinet v0.8.0 was validated against recorded traffic of real equipment: NUL-trimmed DCP names, LLDP management interface number and OID, ERR-RTA answers. berOID bounds sub-identifiers to 32 bits. Co-Authored-By: Claude Opus 5.5 --- RELEASE.md | 37 +++++++++++++++++++++++++++++++++++++ cmd/lldp.go | 2 +- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 96fd677..b39e8fe 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -23,6 +23,43 @@ ending with a `---` line and no `---` line inside a section --- +## v0.10.0 + +**Date:** 2026-11-08 +**Previous release:** v0.9.0 + +### Summary + +profinetctl is built on go-profinet v0.8.0, which was validated against +traffic recorded from real equipment (a SIMATIC S7-1500 CPU 1515-2 PN and +older Siemens, GE and switch devices; go-profinet's testdata/captures). +`lldp neighbors` shows the management address's interface number and OID, +and the go-profinet fixes reach every command. + +### Added + +- `lldp neighbors` JSON: `management_if_index` and `management_oid` (dotted, + for example `1.3.6.1.4.1.24686`, the PNO enterprise OID Siemens devices + send), both omitted when absent ([docs/json-output.md](docs/json-output.md)). + +### Changed (from go-profinet v0.8.0) + +- `discover` and every DCP output: a NameOfStation or TypeOfStation padded + with NULs (an S7-300 pads its name to 108 octets) is shown without the + padding. +- `lldp neighbors`, `topology`: an LLDPDU with a second Chassis ID, Port ID + or TTL TLV is ignored (IEEE 802.1AB); a non-IPv4 management address no + longer hides the rest of the TLV list. +- `alarms`, `connect`, `io`: profinetctl as IO-Controller answers a + device's ERR-RTA with ERR-RTA "AR alarm.ind(err)" (`CF 81 FD 0B`), as a + CPU 1515-2 PN does. + +### Dependencies + +- go-profinet v0.7.0 → v0.8.0. + +--- + ## v0.9.0 **Date:** 2026-11-07 diff --git a/cmd/lldp.go b/cmd/lldp.go index 7353f58..68b112c 100644 --- a/cmd/lldp.go +++ b/cmd/lldp.go @@ -400,7 +400,7 @@ func berOID(b []byte) string { var v uint64 first := true for i, c := range b { - if v > 1<<56 { + if v > 0xFFFFFFFF>>7 { // sub-identifiers are 32-bit (SNMP) return fmt.Sprintf("%x", b) } v = v<<7 | uint64(c&0x7F) diff --git a/go.mod b/go.mod index 8f1538f..f546598 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/otfabric/profinetctl go 1.26 require ( - github.com/otfabric/go-profinet v0.7.0 + github.com/otfabric/go-profinet v0.8.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 ) diff --git a/go.sum b/go.sum index 314385c..9f7fc5e 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/otfabric/go-profinet v0.7.0 h1:3o8Kp5evYlyZWjD/PSQLoJmHoqDSbm3jIpTJacbIbUM= -github.com/otfabric/go-profinet v0.7.0/go.mod h1:eLxHQxabRs/OloPKSXKo/0OOgMjwjJUwnkQstdfE2wI= +github.com/otfabric/go-profinet v0.8.0 h1:GgFz2cBksm3ObT45IKGodaeFs7kNyq+bDiXIxQ1Hh9I= +github.com/otfabric/go-profinet v0.8.0/go.mod h1:eLxHQxabRs/OloPKSXKo/0OOgMjwjJUwnkQstdfE2wI= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=