You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/03 08:14:19 UTC

(plc4x-extras) 02/09: fix: Make every BACnet route into the pcap analyzer work, and add the first tests

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-extras.git

commit 939ea14a552dce08eadaf052615d31e4871a5b07
Author: Sebastian Rühl <sr...@apache.org>
AuthorDate: Wed Sep 2 16:41:45 2026 +0200

    fix: Make every BACnet route into the pcap analyzer work, and add the first tests
    
    The pcap analyzer disagreed with itself about protocol names, so BACnet
    analysis could not work at all:
    
      - cmd/analyze.go accepted "bacnet" and "c-bus"
      - internal/analyzer switched on "bacnetip", so "bacnet" fell through to
        its default branch and returned an error
      - cmd/bacnet.go passed the literal "bacnet" into that switch, so the
        dedicated subcommand was broken too, not just the positional form
      - internal/extractor switched on "bacnet", disagreeing with the analyzer
        in the opposite direction, so "extract bacnetip" was rejected
    
    Because the commands called panic(err), the result was a goroutine dump:
    
      $ plc4xpcapanalyzer analyze bacnet capture.pcap
      panic: Unsupported protocol type bacnet
    
    Introduce internal/protocol as the single source of truth. Names resolve
    through it, case-insensitively and via aliases, so "bacnet" and "bacnetip"
    both reach the analyzer and the extractor. The unknown-protocol message now
    names the real options instead of printing a Go map ("map[bacnet:<nil>
    c-bus:<nil>]"). All five panic(err) sites in cmd/ become RunE, so a failure
    is reported as a CLI error.
    
    Also fixes a goroutine leak and the data race it caused. The C-Bus
    Analyzer.MapPackets producer sent to its channel unguarded, so when a
    consumer stopped early -- which the package-number limit does -- it blocked
    forever and kept writing to the global logger. MapPackets now takes a
    context and stops when it is cancelled, and Analyze cancels and waits for it
    before returning. Found by running the new tests under -race.
    
    Adds the module's first real tests (it previously had none; the only
    _test.go file was a generated mock):
    
      - internal/protocol: name resolution, aliases, registry consistency
      - cmd: argument validation for every advertised spelling, and that the
        commands report errors rather than panicking
      - internal/pcapfixture: a new package that builds small deterministic
        captures from real protocol bytes, taken from plc4x's own protocol
        tests and each verified to parse and re-serialize byte-identically
        through the real plc4x codec. It uses the pure-Go pcapgo writer, so
        building a fixture needs no cgo, and it serves both tests and the
        upcoming demo mode.
      - internal/analyzer: characterisation tests that drive the real analyzer
        over those fixtures and pin today's counters, so the upcoming port has
        a baseline to be checked against.
    
    Verified: go build, go vet ./..., go test -race ./... all pass, gofmt clean,
    and "go fix -diff ./..." is empty.
---
 plc4go/go.mod                                      |  24 +-
 plc4go/go.sum                                      |  54 +----
 plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go      |  18 +-
 plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go       |  11 +-
 plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go         |  11 +-
 plc4go/tools/plc4xpcapanalyzer/cmd/extract.go      |  13 +-
 .../plc4xpcapanalyzer/cmd/protocolargs_test.go     | 110 ++++++++++
 plc4go/tools/plc4xpcapanalyzer/cmd/ui.go           |   6 +-
 .../internal/analyzer/analyzer.go                  |  35 ++-
 .../analyzer/analyzer_characterisation_test.go     | 241 +++++++++++++++++++++
 .../internal/cbusanalyzer/analyzer.go              |  54 ++++-
 .../internal/extractor/extractor.go                |  11 +-
 .../internal/pcapfixture/pcapfixture.go            | 239 ++++++++++++++++++++
 .../internal/pcapfixture/pcapfixture_test.go       | 216 ++++++++++++++++++
 .../internal/protocol/protocol.go                  | 124 +++++++++++
 .../internal/protocol/protocol_test.go             | 130 +++++++++++
 16 files changed, 1185 insertions(+), 112 deletions(-)

diff --git a/plc4go/go.mod b/plc4go/go.mod
index b2067315..a6cc5459 100644
--- a/plc4go/go.mod
+++ b/plc4go/go.mod
@@ -37,23 +37,8 @@ require (
 )
 
 require (
-	charm.land/bubbles/v2 v2.2.1 // indirect
-	charm.land/bubbletea/v2 v2.0.9 // indirect
-	charm.land/lipgloss/v2 v2.0.6 // indirect
-	github.com/aymanbagabas/go-udiff v0.4.1 // indirect
 	github.com/bitfield/gotestdox v0.2.2 // indirect
-	github.com/charmbracelet/colorprofile v0.4.3 // indirect
-	github.com/charmbracelet/fang v1.0.0 // indirect
-	github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
-	github.com/charmbracelet/x/ansi v0.11.8 // indirect
-	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
-	github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect
-	github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260901172002-a5dee49b2863 // indirect
-	github.com/charmbracelet/x/term v0.2.2 // indirect
-	github.com/charmbracelet/x/termios v0.1.1 // indirect
-	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/chigopher/pathlib v0.19.1 // indirect
-	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
 	github.com/dnephin/pflag v1.0.7 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -72,11 +57,6 @@ require (
 	github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
-	github.com/muesli/cancelreader v0.2.2 // indirect
-	github.com/muesli/mango v0.1.0 // indirect
-	github.com/muesli/mango-cobra v1.2.0 // indirect
-	github.com/muesli/mango-pflag v0.1.0 // indirect
-	github.com/muesli/roff v0.1.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
@@ -87,13 +67,13 @@ require (
 	github.com/stretchr/objx v0.5.3 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/vektra/mockery/v2 v2.53.2 // indirect
-	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	go.yaml.in/yaml/v3 v3.0.5 // indirect
 	golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
 	golang.org/x/mod v0.39.0 // indirect
+	golang.org/x/net v0.58.0 // indirect
 	golang.org/x/sync v0.22.0 // indirect
 	golang.org/x/sys v0.47.0 // indirect
-	golang.org/x/term v0.44.0 // indirect
+	golang.org/x/term v0.45.0 // indirect
 	golang.org/x/text v0.41.0 // indirect
 	golang.org/x/tools v0.49.0 // indirect
 	gotest.tools/gotestsum v1.13.0 // indirect
diff --git a/plc4go/go.sum b/plc4go/go.sum
index 344924a4..9b2c9317 100644
--- a/plc4go/go.sum
+++ b/plc4go/go.sum
@@ -1,43 +1,13 @@
-charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
-charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
-charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
-charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
-charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
-charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
 github.com/ajankovic/xdiff v0.0.1 h1:V1cj8t5xwYzm6ZGPqPOlAc9AIajXuTEn41D/1MJBWMM=
 github.com/ajankovic/xdiff v0.0.1/go.mod h1:SUmEZ67uB97I0zkiuQ+lb+LOms9ipn8X+p+2RdJV710=
 github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2 h1:AehpEo6hGU1fFryP9ZQtkwhiQom9jqbgB1Lhp1CBfjs=
 github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2/go.mod h1:eXckuqFuXTmT05hN7BrZUIthBY96xSoXMXJNt//fHXo=
-github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
-github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
 github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
 github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
-github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
-github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
-github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
-github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
-github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
-github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
-github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
-github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
-github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260901172002-a5dee49b2863 h1:wkh1r5Aj82JW8+oVYf+C108FQe1YJNq4v4NAm/SoQIY=
-github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260901172002-a5dee49b2863/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw=
-github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
-github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
-github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
-github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
-github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
 github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
 github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
 github.com/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A=
 github.com/chigopher/pathlib v0.19.1/go.mod h1:tzC1dZLW8o33UQpWkNkhvPwL5n4yyFRFm/jL1YGWFvY=
-github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
-github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -79,16 +49,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
 github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
 github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
 github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
@@ -97,16 +63,6 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
-github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
-github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
-github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
-github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
-github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
-github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
-github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -145,8 +101,10 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/vektra/mockery/v2 v2.53.2 h1:4G/4fl9x722Yb8hLqH1YU3XZNRJFwl5KUMvpkmAyuC8=
 github.com/vektra/mockery/v2 v2.53.2/go.mod h1:UJT+mgXhCcOCHXTnM5cJHCZL+d76BYB+EbY1sFztEB8=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
+github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
+github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg=
+github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
@@ -181,8 +139,8 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
index 8c966f12..beb3fb7e 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go
@@ -20,6 +20,7 @@
 package cmd
 
 import (
+	"fmt"
 	"math"
 	"os"
 
@@ -28,13 +29,9 @@ import (
 
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
-var validProtocolType = map[string]any{
-	"bacnet": nil,
-	"c-bus":  nil,
-}
-
 // analyzeCmd represents the analyze command
 var analyzeCmd = &cobra.Command{
 	Use:   "analyze [protocolType] [pcapfile]",
@@ -46,8 +43,8 @@ TODO: document me
 		if len(args) < 2 {
 			return errors.New("requires exactly two arguments")
 		}
-		if _, ok := validProtocolType[args[0]]; !ok {
-			return errors.Errorf("Only following protocols are supported %v", validProtocolType)
+		if _, err := protocol.Resolve(args[0]); err != nil {
+			return err
 		}
 		pcapFile := args[1]
 		if _, err := os.Stat(pcapFile); errors.Is(err, os.ErrNotExist) {
@@ -55,13 +52,14 @@ TODO: document me
 		}
 		return nil
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		protocolType := args[0]
 		pcapFile := args[1]
 		if err := analyzer.Analyze(pcapFile, protocolType); err != nil {
-			panic(err)
+			return err
 		}
-		println("Done")
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Done")
+		return nil
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go b/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go
index aca291b9..4a40ff01 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go
@@ -20,6 +20,7 @@
 package cmd
 
 import (
+	"fmt"
 	"os"
 
 	"github.com/apache/plc4x/plc4go/spi/errors"
@@ -27,6 +28,7 @@ import (
 
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
 // bacnetCmd represents the bacnet command
@@ -46,12 +48,13 @@ TODO: document me
 		}
 		return nil
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		pcapFile := args[0]
-		if err := analyzer.Analyze(pcapFile, "bacnet"); err != nil {
-			panic(err)
+		if err := analyzer.Analyze(pcapFile, protocol.BacnetIP.Name); err != nil {
+			return err
 		}
-		println("Done")
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Done")
+		return nil
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go b/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go
index ffd2e397..ec174b6e 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go
@@ -20,6 +20,7 @@
 package cmd
 
 import (
+	"fmt"
 	"os"
 
 	"github.com/apache/plc4x/plc4go/spi/errors"
@@ -27,6 +28,7 @@ import (
 
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
 // cbusCmd represents the cbus command
@@ -46,12 +48,13 @@ TODO: document me
 		}
 		return nil
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		pcapFile := args[0]
-		if err := analyzer.Analyze(pcapFile, "c-bus"); err != nil {
-			panic(err)
+		if err := analyzer.Analyze(pcapFile, protocol.CBus.Name); err != nil {
+			return err
 		}
-		println("Done")
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Done")
+		return nil
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
index 26756eee..b7ecc5b9 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go
@@ -20,6 +20,7 @@
 package cmd
 
 import (
+	"fmt"
 	"os"
 
 	"github.com/apache/plc4x/plc4go/spi/errors"
@@ -27,6 +28,7 @@ import (
 
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/extractor"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
 // extractCmd represents the extract command
@@ -40,8 +42,8 @@ TODO: document me
 		if len(args) < 2 {
 			return errors.New("requires exactly two arguments")
 		}
-		if _, ok := validProtocolType[args[0]]; !ok {
-			return errors.Errorf("Only following protocols are supported %v", validProtocolType)
+		if _, err := protocol.Resolve(args[0]); err != nil {
+			return err
 		}
 		pcapFile := args[1]
 		if _, err := os.Stat(pcapFile); errors.Is(err, os.ErrNotExist) {
@@ -49,13 +51,14 @@ TODO: document me
 		}
 		return nil
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		protocolType := args[0]
 		pcapFile := args[1]
 		if err := extractor.Extract(pcapFile, protocolType); err != nil {
-			panic(err)
+			return err
 		}
-		println("Done")
+		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Done")
+		return nil
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go b/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go
new file mode 100644
index 00000000..5f4a1608
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go
@@ -0,0 +1,110 @@
+/*
+ * 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 cmd
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// existingPcapPath creates an empty file so the argument validators, which only stat the path,
+// get past their existence check. No capture parsing happens at this layer.
+func existingPcapPath(t *testing.T) string {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "capture.pcap")
+	require.NoError(t, os.WriteFile(path, nil, 0o600))
+	return path
+}
+
+// TestAnalyzeArgsAcceptsEveryAdvertisedProtocolSpelling is the command-layer regression test for
+// the defect where "bacnet" was accepted here but rejected further down, so the run ended in a
+// panic instead of an analysis.
+func TestAnalyzeArgsAcceptsEveryAdvertisedProtocolSpelling(t *testing.T) {
+	pcapFile := existingPcapPath(t)
+	for _, name := range []string{"bacnet", "bacnetip", "c-bus", "cbus", "BACnet", "C-Bus"} {
+		t.Run(name, func(t *testing.T) {
+			require.NoError(t, analyzeCmd.Args(analyzeCmd, []string{name, pcapFile}))
+		})
+	}
+}
+
+func TestExtractArgsAcceptsEveryAdvertisedProtocolSpelling(t *testing.T) {
+	pcapFile := existingPcapPath(t)
+	for _, name := range []string{"bacnet", "bacnetip", "c-bus", "cbus"} {
+		t.Run(name, func(t *testing.T) {
+			require.NoError(t, extractCmd.Args(extractCmd, []string{name, pcapFile}))
+		})
+	}
+}
+
+func TestProtocolArgsRejectUnknownProtocolWithAnActionableMessage(t *testing.T) {
+	pcapFile := existingPcapPath(t)
+	for _, tc := range []struct {
+		name string
+		cmd  *cobra.Command
+	}{
+		{"analyze", analyzeCmd},
+		{"extract", extractCmd},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := tc.cmd.Args(tc.cmd, []string{"modbus", pcapFile})
+			require.Error(t, err)
+			// The old message printed a Go map of empty-interface values, e.g.
+			// "map[bacnet:<nil> c-bus:<nil>]". A user needs the real names instead.
+			assert.Contains(t, err.Error(), "bacnetip")
+			assert.Contains(t, err.Error(), "c-bus")
+			assert.NotContains(t, err.Error(), "<nil>", "the protocol list must not leak a Go map dump")
+		})
+	}
+}
+
+func TestProtocolArgsRejectMissingPcapFile(t *testing.T) {
+	err := analyzeCmd.Args(analyzeCmd, []string{"bacnet", filepath.Join(t.TempDir(), "absent.pcap")})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "not found")
+}
+
+func TestProtocolArgsRejectTooFewArguments(t *testing.T) {
+	require.Error(t, analyzeCmd.Args(analyzeCmd, []string{"bacnet"}))
+	require.Error(t, extractCmd.Args(extractCmd, []string{"bacnet"}))
+}
+
+// TestCommandsUseRunESoFailuresAreNotPanics pins the second half of the same defect: the
+// commands used to call panic(err), so any analysis error surfaced as a goroutine dump rather
+// than a CLI error message.
+func TestCommandsUseRunESoFailuresAreNotPanics(t *testing.T) {
+	for name, cmd := range map[string]*cobra.Command{
+		"analyze": analyzeCmd,
+		"extract": extractCmd,
+		"bacnet":  bacnetCmd,
+		"cbus":    cbusCmd,
+		"ui":      uiCmd,
+	} {
+		t.Run(name, func(t *testing.T) {
+			assert.NotNil(t, cmd.RunE, "%s must report failures as errors", name)
+			assert.Nil(t, cmd.Run, "%s must not also define Run, which would shadow RunE", name)
+		})
+	}
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
index 6cf12ecd..20b81dc8 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
@@ -46,7 +46,7 @@ TODO: document me
 		}
 		return nil
 	},
-	Run: func(cmd *cobra.Command, args []string) {
+	RunE: func(cmd *cobra.Command, args []string) error {
 		ui.LoadConfig()
 		application := ui.SetupApplication()
 		ui.InitSubsystem()
@@ -61,9 +61,7 @@ TODO: document me
 		}
 
 		defer ui.Shutdown()
-		if err := application.Run(); err != nil {
-			panic(err)
-		}
+		return application.Run()
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
index bd05472b..cc572e95 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go
@@ -42,6 +42,7 @@ import (
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
 func Analyze(pcapFile, protocolType string) error {
@@ -66,8 +67,14 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st
 		_, _ = fmt.Fprintf(stdout, "%v\n", item)
 	}
 	var byteOutput = hex.Dump
-	switch protocolType {
-	case "bacnetip":
+	// Resolve the caller-supplied name onto a canonical one before dispatching. The CLI accepts
+	// aliases such as "bacnet"; the branches below only ever see canonical names.
+	proto, err := protocol.Resolve(protocolType)
+	if err != nil {
+		return err
+	}
+	switch proto.Name {
+	case protocol.BacnetIP.Name:
 		if !config.AnalyzeConfigInstance.NoFilter {
 			if config.AnalyzeConfigInstance.Filter == "" && config.BacnetConfigInstance.BacnetFilter != "" {
 				log.Debug().Str("filter", config.BacnetConfigInstance.Filter).Msg("Setting bacnet filter")
@@ -78,7 +85,7 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st
 		}
 		packageParse = bacnetanalyzer.PackageParse
 		serializePackage = bacnetanalyzer.SerializePackage
-	case "c-bus":
+	case protocol.CBus.Name:
 		if !config.AnalyzeConfigInstance.NoFilter {
 			if config.AnalyzeConfigInstance.Filter == "" && config.CBusConfigInstance.CBusFilter != "" {
 				log.Debug().Str("filter", config.CBusConfigInstance.Filter).Msg("Setting cbus filter")
@@ -91,19 +98,35 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st
 		analyzer.Init()
 		packageParse = analyzer.PackageParse
 		serializePackage = analyzer.SerializePackage
-		mapPackets = analyzer.MapPackets
+		// mappingCtx is cancelled when this function returns, which is what lets the mapping
+		// goroutine exit on every early exit from the loop below - the package-number limit,
+		// a nil packet, or a cancelled caller context. Without it that goroutine blocks on a
+		// send forever and keeps logging.
+		mappingCtx, cancelMapping := context.WithCancel(ctx)
+		defer func() {
+			cancelMapping()
+			// Wait for the goroutine to actually finish, not merely to be told to stop, so
+			// that nothing is still logging once this function has returned.
+			analyzer.WaitForMapping()
+		}()
+		mapPackets = func(in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet {
+			return analyzer.MapPackets(mappingCtx, in, packetInformationCreator)
+		}
 		if !config.AnalyzeConfigInstance.NoCustomMapping {
 			byteOutput = analyzer.ByteOutput
 		} else {
 			log.Info().Msg("Custom mapping disabled")
 		}
 	default:
-		return errors.Errorf("Unsupported protocol type %s", protocolType)
+		// Unreachable: Resolve only returns protocols the registry knows. Kept so that adding a
+		// protocol to the registry without adding a branch here fails loudly instead of silently
+		// analysing nothing.
+		return errors.Errorf("protocol %s is registered but not implemented by the analyzer", proto.Name)
 	}
 
 	log.Info().
 		Str("pcapFile", pcapFile).
-		Str("protocolType", protocolType).
+		Str("protocolType", proto.Name).
 		Str("filterExpression", filterExpression).
 		Msg("Analyzing pcap file pcapFile with protocolType and filter filterExpression now")
 	handle, numberOfPackage, timestampToIndexMap, err := pcaphandler.GetIndexedPcapHandle(pcapFile, filterExpression)
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go
new file mode 100644
index 00000000..2616a3bf
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go
@@ -0,0 +1,241 @@
+/*
+ * 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 analyzer_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// These are characterisation tests. They describe what the analyzer does TODAY, driven by real
+// protocol bytes, so that the Bubble Tea port and the refactor that gives Analyze a real return
+// value can be checked against the behaviour they are supposed to preserve.
+//
+// The counters are currently only observable as fields on one zerolog line, so that is what
+// these tests read. When Analyze grows a Stats return value, these assertions should be
+// re-pointed at it and must produce the same numbers.
+
+// syncBuffer is a mutex-guarded log sink.
+//
+// It is guarded for a specific reason: the C-Bus analyzer's MapPackets starts a producer
+// goroutine and leaks it whenever the consumer stops early, which is exactly what the
+// package-number-limit test does. That orphaned goroutine keeps logging through the global
+// logger, so it can write into a later test's buffer. Locking keeps that from being a data
+// race; the stray lines themselves are harmless here because we select the summary line by
+// content. Removing the leak is part of the analyzer refactor.
+type syncBuffer struct {
+	mu  sync.Mutex
+	buf bytes.Buffer
+}
+
+func (b *syncBuffer) Write(p []byte) (int, error) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.buf.Write(p)
+}
+
+func (b *syncBuffer) String() string {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+	return b.buf.String()
+}
+
+// summary is the tail of the analyzer's final log line.
+type summary struct {
+	CurrentPackageNum int `json:"currentPackageNum"`
+	NumberOfPackage   int `json:"numberOfPackage"`
+	ParseFails        int `json:"parseFails"`
+	SerializeFails    int `json:"serializeFails"`
+	CompareFails      int `json:"compareFails"`
+}
+
+// runAnalyze runs the analyzer against a capture and returns its final counters.
+//
+// It has to save and restore the package-level config singletons and the global logger,
+// because the analyzer reads its entire configuration from them. That is precisely the
+// coupling the port is meant to remove; until then, tests have to work around it.
+func runAnalyze(t *testing.T, pcapFile, protocolName string, configure func()) (summary, error) {
+	got, found, err := runAnalyzeRaw(t, pcapFile, protocolName, configure)
+	if err == nil {
+		require.True(t, found, "a successful analysis must report a summary line")
+	}
+	return got, err
+}
+
+// runAnalyzeRaw is runAnalyze without the expectation that a summary was produced, for the
+// cases that fail before the analyzer reaches its summary.
+func runAnalyzeRaw(t *testing.T, pcapFile, protocolName string, configure func()) (summary, bool, error) {
+	t.Helper()
+
+	savedPcap := config.PcapConfigInstance
+	savedAnalyze := config.AnalyzeConfigInstance
+	savedRoot := config.RootConfigInstance
+	savedCBus := config.CBusConfigInstance
+	savedLogger := log.Logger
+	savedLevel := zerolog.GlobalLevel()
+	t.Cleanup(func() {
+		config.PcapConfigInstance = savedPcap
+		config.AnalyzeConfigInstance = savedAnalyze
+		config.RootConfigInstance = savedRoot
+		config.CBusConfigInstance = savedCBus
+		log.Logger = savedLogger
+		zerolog.SetGlobalLevel(savedLevel)
+	})
+
+	// A progress bar would write escape codes to the real stderr during the test run.
+	config.RootConfigInstance.HideProgressBar = true
+	configure()
+
+	logs := &syncBuffer{}
+	log.Logger = zerolog.New(logs).Level(zerolog.InfoLevel)
+	zerolog.SetGlobalLevel(zerolog.InfoLevel)
+
+	err := analyzer.AnalyzeWithOutput(pcapFile, protocolName, io.Discard, io.Discard)
+
+	got, found := lastSummary(t, logs.String())
+	return got, found, err
+}
+
+// lastSummary finds the analyzer's closing log line and decodes its counters. It reports
+// whether such a line was present, since a run that fails early never emits one.
+func lastSummary(t *testing.T, logOutput string) (summary, bool) {
+	t.Helper()
+	var got summary
+	var found bool
+	// Take the LAST matching line: a leaked goroutine from an earlier test may have written an
+	// older summary into this buffer.
+	for line := range strings.SplitSeq(logOutput, "\n") {
+		if !strings.Contains(line, "Done evaluating") {
+			continue
+		}
+		var candidate summary
+		require.NoError(t, json.Unmarshal([]byte(line), &candidate), "summary line should be JSON: %s", line)
+		got, found = candidate, true
+	}
+	return got, found
+}
+
+// TestAnalyzeCBusFixtureCharacterisation records the counters for a capture in which every
+// payload is known to parse and re-serialize identically.
+func TestAnalyzeCBusFixtureCharacterisation(t *testing.T) {
+	pcapFile := filepath.Join(t.TempDir(), "cbus.pcap")
+	session := pcapfixture.CBusSession()
+	require.NoError(t, pcapfixture.WriteCBus(pcapFile, session))
+
+	got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() {
+		config.PcapConfigInstance.Client = pcapfixture.ClientIP
+		config.AnalyzeConfigInstance.NoFilter = true
+		config.PcapConfigInstance.PackageNumberLimit = ^uint(0)
+	})
+	require.NoError(t, err)
+
+	assert.Equal(t, len(session), got.NumberOfPackage, "every fixture packet should be seen")
+	assert.Equal(t, len(session), got.CurrentPackageNum, "every packet should be walked")
+	assert.Zero(t, got.ParseFails, "every fixture payload is known to parse")
+	assert.Zero(t, got.SerializeFails, "every fixture payload is known to serialize")
+	assert.Zero(t, got.CompareFails, "every fixture payload is known to round-trip byte-identically")
+}
+
+// TestAnalyzeCBusCountsParseFailures proves the parse-failure counter actually counts, using
+// payloads verified not to parse. Without this, a refactor could zero the counter unnoticed.
+func TestAnalyzeCBusCountsParseFailures(t *testing.T) {
+	pcapFile := filepath.Join(t.TempDir(), "cbus-broken.pcap")
+	session := pcapfixture.CBusSessionWithFailures()
+	require.NoError(t, pcapfixture.WriteCBus(pcapFile, session))
+
+	got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() {
+		config.PcapConfigInstance.Client = pcapfixture.ClientIP
+		config.AnalyzeConfigInstance.NoFilter = true
+		config.PcapConfigInstance.PackageNumberLimit = ^uint(0)
+	})
+	require.NoError(t, err)
+
+	assert.Equal(t, len(session), got.NumberOfPackage)
+	assert.Positive(t, got.ParseFails, "the two unparseable payloads must be counted")
+}
+
+// TestAnalyzeBacnetFixtureCharacterisation is the regression test for the defect that made
+// every BACnet analysis fail. Both the canonical name and the alias must now analyse the
+// capture instead of returning an unsupported-protocol error.
+func TestAnalyzeBacnetFixtureCharacterisation(t *testing.T) {
+	for _, name := range []string{"bacnetip", "bacnet"} {
+		t.Run(name, func(t *testing.T) {
+			pcapFile := filepath.Join(t.TempDir(), "bacnet.pcap")
+			session := pcapfixture.BacnetSession()
+			require.NoError(t, pcapfixture.WriteBacnet(pcapFile, session))
+
+			got, err := runAnalyze(t, pcapFile, name, func() {
+				// Filtering off: the default BACnet filter drops packets whose UDP length is
+				// 29 bytes or less, which would silently discard the Who-Is fixtures.
+				config.AnalyzeConfigInstance.NoFilter = true
+				config.PcapConfigInstance.PackageNumberLimit = ^uint(0)
+			})
+			require.NoError(t, err, "%q must be analysable; it used to fail with an unsupported-protocol error", name)
+
+			assert.Equal(t, len(session), got.NumberOfPackage)
+			assert.Zero(t, got.ParseFails, "every BACnet fixture payload is known to parse")
+			assert.Zero(t, got.CompareFails, "every BACnet fixture payload round-trips byte-identically")
+		})
+	}
+}
+
+// TestAnalyzeRejectsUnknownProtocolWithoutTouchingTheCapture documents that protocol
+// resolution now happens before any file access, so a typo cannot look like a capture problem.
+func TestAnalyzeRejectsUnknownProtocolWithoutTouchingTheCapture(t *testing.T) {
+	_, _, err := runAnalyzeRaw(t, filepath.Join(t.TempDir(), "does-not-exist.pcap"), "modbus", func() {})
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "unsupported protocol")
+	assert.NotContains(t, err.Error(), "open offline", "the protocol should be rejected before the capture is opened")
+}
+
+// TestAnalyzePackageNumberLimitStopsEarly characterises the limit flag, which the TUI sets on
+// every run and which the port must keep honouring.
+func TestAnalyzePackageNumberLimitStopsEarly(t *testing.T) {
+	pcapFile := filepath.Join(t.TempDir(), "cbus.pcap")
+	session := pcapfixture.CBusSession()
+	require.NoError(t, pcapfixture.WriteCBus(pcapFile, session))
+
+	const limit = 3
+	got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() {
+		config.PcapConfigInstance.Client = pcapfixture.ClientIP
+		config.AnalyzeConfigInstance.NoFilter = true
+		config.PcapConfigInstance.PackageNumberLimit = limit
+	})
+	require.NoError(t, err)
+
+	assert.Equal(t, limit+1, got.CurrentPackageNum,
+		"the loop increments before testing the limit, so it stops one past it; pinned deliberately")
+	assert.Less(t, got.CurrentPackageNum, len(session), "the limit must actually cut the run short")
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go
index d4413d24..8dd5d98c 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go
@@ -44,6 +44,7 @@ type Analyzer struct {
 	currentInboundPayloads          map[string][]byte
 	currentPrefilterInboundPayloads map[string][]byte
 	mappedPacketChan                chan gopacket.Packet
+	mappingDone                     chan struct{}
 
 	lastParsePayload []byte
 	lastMapPayload   []byte
@@ -276,21 +277,33 @@ func (a *Analyzer) SerializePackage(message spi.Message) ([]byte, error) {
 	}
 }
 
-// MapPackets reorders the packages as they were not split
-func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet {
+// MapPackets reorders the packages as they were not split.
+//
+// The returned channel is fed by a goroutine, which stops as soon as ctx is done. That matters
+// because a consumer is allowed to stop reading early - the analyzer does exactly that when it
+// hits the package-number limit - and an unguarded send would then block this goroutine for
+// the lifetime of the process. A leaked goroutine here is not merely idle: it keeps writing to
+// the global logger, which is a data race against anyone replacing that logger.
+func (a *Analyzer) MapPackets(ctx context.Context, in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet {
 	if a.mappedPacketChan == nil {
 		a.mappedPacketChan = make(chan gopacket.Packet)
+		a.mappingDone = make(chan struct{})
 		go func() {
+			defer close(a.mappingDone)
 			defer close(a.mappedPacketChan)
 		mappingLoop:
 			for packet := range in {
 				switch {
 				case packet == nil:
 					log.Debug().Msg("Done reading packages. (nil returned)")
-					a.mappedPacketChan <- nil
+					// The nil marker tells the consumer the capture is exhausted. Whether or
+					// not it still reads, this goroutine is finished either way.
+					a.send(ctx, nil)
 					break mappingLoop
 				case packet.ApplicationLayer() == nil:
-					a.mappedPacketChan <- packet
+					if !a.send(ctx, packet) {
+						break mappingLoop
+					}
 				default:
 					packetInformation := packetInformationCreator(packet)
 					mergeCallback := func(index int) {
@@ -300,7 +313,9 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator
 					}
 					if payload, err := a.getCurrentPayload(packetInformation, packet.ApplicationLayer().Payload(), mergeCallback, a.currentPrefilterInboundPayloads, &a.lastMapPayload); err != nil {
 						log.Debug().Err(err).Stringer("packetInformation", packetInformation).Msg("Filtering message")
-						a.mappedPacketChan <- common.NewFilteredPackage(err, packet)
+						if !a.send(ctx, common.NewFilteredPackage(err, packet)) {
+							break mappingLoop
+						}
 					} else {
 						currentApplicationLayer := packet.ApplicationLayer()
 						newPayload := gopacket.Payload(payload)
@@ -312,7 +327,9 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator
 							packet = &manipulatedPackage{Packet: packet, newApplicationLayer: newPayload}
 						}
 						a.lastMapPayload = payload
-						a.mappedPacketChan <- packet
+						if !a.send(ctx, packet) {
+							break mappingLoop
+						}
 					}
 				}
 			}
@@ -321,6 +338,31 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator
 	return a.mappedPacketChan
 }
 
+// WaitForMapping blocks until the mapping goroutine has exited. Callers should cancel the
+// context they passed to MapPackets first.
+//
+// Waiting is not optional hygiene: the goroutine logs as it unwinds, so a caller that returns
+// without waiting leaves a goroutine writing to the global logger after the call is over. Any
+// later change to that logger is then a data race, which is how this was found.
+func (a *Analyzer) WaitForMapping() {
+	if a.mappingDone == nil {
+		return
+	}
+	<-a.mappingDone
+}
+
+// send delivers packet downstream, reporting false if ctx was cancelled first so the caller
+// can stop instead of blocking on a channel nobody is reading any more.
+func (a *Analyzer) send(ctx context.Context, packet gopacket.Packet) bool {
+	select {
+	case a.mappedPacketChan <- packet:
+		return true
+	case <-ctx.Done():
+		log.Debug().Err(ctx.Err()).Msg("Stopping packet mapping because the consumer went away")
+		return false
+	}
+}
+
 // ByteOutput returns the string representation as usually this is ASCII over serial... so this output is much more useful in that context
 func (a *Analyzer) ByteOutput(data []byte) string {
 	return fmt.Sprintf("%+q\n", data)
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go
index becbca7d..c411b5fd 100644
--- a/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go
@@ -35,6 +35,7 @@ import (
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
 func Extract(pcapFile, protocolType string) error {
@@ -45,10 +46,14 @@ func ExtractWithOutput(ctx context.Context, pcapFile, protocolType string, stdou
 	var printPayload = func(packetInformation common.PacketInformation, item []byte) {
 		_, _ = fmt.Fprintf(stdout, "%x\n", item)
 	}
-	switch protocolType {
-	case "bacnet":
+	proto, err := protocol.Resolve(protocolType)
+	if err != nil {
+		return err
+	}
+	switch proto.Name {
+	case protocol.BacnetIP.Name:
 		// nothing special as this is byte based
-	case "c-bus":
+	case protocol.CBus.Name:
 		// c-bus is string based so we consume the string and print it
 		clientIp := net.ParseIP(config.ExtractConfigInstance.Client)
 		serverResponseWriter := color.New(color.FgRed)
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go
new file mode 100644
index 00000000..3e249c35
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go
@@ -0,0 +1,239 @@
+/*
+ * 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 pcapfixture builds small, deterministic pcap captures containing real protocol
+// traffic.
+//
+// It has two consumers. Tests use it so the analyzers can be exercised against genuine
+// protocol bytes without shipping a binary capture or needing a device, and demo mode uses it
+// so the tool has something real to analyze when the user has no capture of their own.
+//
+// The payloads in this package are not invented. Each one was taken from plc4x's own protocol
+// tests, or is a well-known BACnet/IP service request, and each was verified to parse and then
+// re-serialize to byte-identical output through the real plc4x codec. That property is what
+// makes them useful: the analyzer's whole job is a parse, re-serialize and byte-compare loop,
+// so a fixture that round-trips exercises the success path, and the deliberately broken
+// payloads below exercise the failure counters.
+//
+// Captures are written with the pure-Go pcapgo writer rather than the libpcap binding, so
+// building a fixture needs no cgo even though reading one back through the analyzer does.
+package pcapfixture
+
+import (
+	"encoding/hex"
+	"net"
+	"os"
+	"time"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"github.com/gopacket/gopacket"
+	"github.com/gopacket/gopacket/layers"
+	"github.com/gopacket/gopacket/pcapgo"
+)
+
+// The synthetic endpoints. The c-bus analyzer decides whether a packet is a request or a
+// response by comparing its source address against the configured client address, so a
+// fixture and the configuration that reads it have to agree on these.
+const (
+	// ClientIP is the address that originates requests.
+	ClientIP = "192.168.178.101"
+	// ServerIP is the address that originates responses.
+	ServerIP = "192.168.178.10"
+
+	// CBusPort is the TCP port C-Bus runs on.
+	CBusPort = 10001
+	// BacnetPort is the UDP port BACnet/IP runs on.
+	BacnetPort = 47808
+)
+
+// BaseTimestamp is the capture time of the first packet. It is fixed, not time.Now(), so that
+// a generated capture is byte-identical from run to run and golden files stay stable.
+var BaseTimestamp = time.Unix(1700000000, 0).UTC()
+
+// PacketInterval is added to BaseTimestamp for each successive packet. The analyzer indexes
+// packets by timestamp, so two packets must never share one.
+const PacketInterval = time.Millisecond
+
+// Direction says which endpoint sent a packet.
+type Direction int
+
+const (
+	// FromClient is a request: ClientIP -> ServerIP.
+	FromClient Direction = iota
+	// FromServer is a response: ServerIP -> ClientIP.
+	FromServer
+)
+
+// Packet is one application payload and the direction it travelled.
+type Packet struct {
+	// Payload is the application-layer bytes, which is what the analyzer parses.
+	Payload []byte
+	// Direction selects the source and destination addresses.
+	Direction Direction
+	// Comment describes the packet for humans reading a fixture definition.
+	Comment string
+}
+
+// CBusSession returns a C-Bus exchange whose every packet parses and re-serializes to
+// identical bytes. Requests and responses are interleaved the way a real session runs.
+func CBusSession() []Packet {
+	return []Packet{
+		{Payload: []byte("~~~\r"), Direction: FromClient, Comment: "reset request"},
+		{Payload: []byte("322100AD\r\n"), Direction: FromServer, Comment: "reply"},
+		{Payload: []byte("@A32100FF\r"), Direction: FromClient, Comment: "direct command"},
+		{Payload: []byte("3230009E\r\n"), Direction: FromServer, Comment: "reply"},
+		{Payload: []byte("@A3300079\r"), Direction: FromClient, Comment: "direct command"},
+		{Payload: []byte("0531AC0079042F0401430316000011\r\n"), Direction: FromServer, Comment: "monitored SAL"},
+		{Payload: []byte("86FD0201078900434C495053414C20C2\r\n"), Direction: FromServer, Comment: "identify reply"},
+		{Payload: []byte("g.890050435F434E49454422\r\n"), Direction: FromServer, Comment: "MMI"},
+	}
+}
+
+// CBusSessionWithFailures returns the C-Bus session plus payloads that the codec cannot parse.
+// It exists so tests can assert the analyzer's parse-failure counter, which is otherwise only
+// reachable with a real broken capture.
+func CBusSessionWithFailures() []Packet {
+	return append(CBusSession(),
+		Packet{Payload: []byte("AFFE!!!\r"), Direction: FromClient, Comment: "unparseable"},
+		Packet{Payload: []byte("@A62120\r"), Direction: FromClient, Comment: "unparseable"},
+	)
+}
+
+// BacnetSession returns a BACnet/IP exchange whose every packet parses and re-serializes to
+// identical bytes.
+//
+// Note on filtering: the CLI's default BACnet filter is "udp port 47808 and udp[4:2] > 29",
+// which drops packets whose UDP length is 29 bytes or less. A bare Who-Is is 12 bytes of BVLC
+// and is therefore dropped by that filter. The longer payloads here survive it, and a test
+// that wants the short ones has to disable filtering.
+func BacnetSession() []Packet {
+	return []Packet{
+		{Payload: mustHex("810b00180120ffff00ff1000c40200271f2201e09100210f"), Direction: FromServer, Comment: "I-Am broadcast"},
+		{Payload: mustHex("810a001101040275010c0c020000271955"), Direction: FromClient, Comment: "ReadProperty request"},
+		{Payload: mustHex("810b000c0120ffff00ff1008"), Direction: FromClient, Comment: "Who-Is broadcast (short: needs no filter)"},
+		{Payload: mustHex("810a000801001008"), Direction: FromClient, Comment: "Who-Is unicast (short: needs no filter)"},
+	}
+}
+
+// WriteCBus writes packets to path as a C-Bus capture: Ethernet/IPv4/TCP on CBusPort.
+func WriteCBus(path string, packets []Packet) error {
+	return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) {
+		src, dst := layers.TCPPort(40000), layers.TCPPort(CBusPort)
+		if p.Direction == FromServer {
+			src, dst = dst, src
+		}
+		tcp := &layers.TCP{SrcPort: src, DstPort: dst, Seq: 1, Window: 4096, PSH: true, ACK: true}
+		if err := tcp.SetNetworkLayerForChecksum(ip); err != nil {
+			return nil, errors.Wrap(err, "error preparing tcp checksum")
+		}
+		return tcp, nil
+	})
+}
+
+// WriteBacnet writes packets to path as a BACnet/IP capture: Ethernet/IPv4/UDP on BacnetPort.
+func WriteBacnet(path string, packets []Packet) error {
+	return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) {
+		udp := &layers.UDP{SrcPort: layers.UDPPort(BacnetPort), DstPort: layers.UDPPort(BacnetPort)}
+		if err := udp.SetNetworkLayerForChecksum(ip); err != nil {
+			return nil, errors.Wrap(err, "error preparing udp checksum")
+		}
+		return udp, nil
+	})
+}
+
+// write assembles each packet and writes the capture. transportFor supplies the transport
+// layer, which is the only part that differs between the protocols.
+func write(path string, packets []Packet, transportFor func(Packet, *layers.IPv4) (gopacket.SerializableLayer, error)) error {
+	if len(packets) == 0 {
+		return errors.New("refusing to write a capture with no packets")
+	}
+	file, err := os.Create(path)
+	if err != nil {
+		return errors.Wrapf(err, "error creating capture %s", path)
+	}
+	defer func() { _ = file.Close() }()
+
+	writer := pcapgo.NewWriter(file)
+	// snaplen 65536 matches what a default libpcap capture would record.
+	if err := writer.WriteFileHeader(65536, layers.LinkTypeEthernet); err != nil {
+		return errors.Wrap(err, "error writing pcap file header")
+	}
+
+	for i, packet := range packets {
+		raw, err := serialize(packet, transportFor)
+		if err != nil {
+			return errors.Wrapf(err, "error serializing packet %d (%s)", i+1, packet.Comment)
+		}
+		info := gopacket.CaptureInfo{
+			Timestamp:     BaseTimestamp.Add(time.Duration(i) * PacketInterval),
+			CaptureLength: len(raw),
+			Length:        len(raw),
+		}
+		if err := writer.WritePacket(info, raw); err != nil {
+			return errors.Wrapf(err, "error writing packet %d", i+1)
+		}
+	}
+	return nil
+}
+
+// serialize builds the full Ethernet frame for one packet.
+func serialize(packet Packet, transportFor func(Packet, *layers.IPv4) (gopacket.SerializableLayer, error)) ([]byte, error) {
+	clientMAC := net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}
+	serverMAC := net.HardwareAddr{0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb}
+	srcMAC, dstMAC := clientMAC, serverMAC
+	srcIP, dstIP := net.ParseIP(ClientIP), net.ParseIP(ServerIP)
+	if packet.Direction == FromServer {
+		srcMAC, dstMAC = serverMAC, clientMAC
+		srcIP, dstIP = dstIP, srcIP
+	}
+
+	ethernet := &layers.Ethernet{SrcMAC: srcMAC, DstMAC: dstMAC, EthernetType: layers.EthernetTypeIPv4}
+	ip := &layers.IPv4{
+		Version:  4,
+		IHL:      5,
+		TTL:      64,
+		SrcIP:    srcIP,
+		DstIP:    dstIP,
+		Protocol: layers.IPProtocolTCP,
+	}
+	transport, err := transportFor(packet, ip)
+	if err != nil {
+		return nil, err
+	}
+	if _, isUDP := transport.(*layers.UDP); isUDP {
+		ip.Protocol = layers.IPProtocolUDP
+	}
+
+	buffer := gopacket.NewSerializeBuffer()
+	options := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
+	if err := gopacket.SerializeLayers(buffer, options, ethernet, ip, transport, gopacket.Payload(packet.Payload)); err != nil {
+		return nil, errors.Wrap(err, "error serializing layers")
+	}
+	return buffer.Bytes(), nil
+}
+
+// mustHex decodes a hex payload defined in this file. The inputs are compile-time constants
+// that are covered by this package's tests, so a decode failure is a programming error.
+func mustHex(s string) []byte {
+	decoded, err := hex.DecodeString(s)
+	if err != nil {
+		panic("pcapfixture: invalid hex literal " + s + ": " + err.Error())
+	}
+	return decoded
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go
new file mode 100644
index 00000000..0ebee439
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go
@@ -0,0 +1,216 @@
+/*
+ * 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 pcapfixture
+
+import (
+	"bytes"
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	bacnetModel "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model"
+	cbusModel "github.com/apache/plc4x/plc4go/protocols/cbus/readwrite/model"
+	"github.com/gopacket/gopacket"
+	"github.com/gopacket/gopacket/layers"
+	"github.com/gopacket/gopacket/pcapgo"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// readBack reads a written capture with the pure-Go reader, so this test needs no cgo, and
+// returns the application payloads in capture order.
+func readBack(t *testing.T, path string) []gopacket.Packet {
+	t.Helper()
+	file, err := os.Open(path)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = file.Close() })
+
+	reader, err := pcapgo.NewReader(file)
+	require.NoError(t, err)
+
+	var out []gopacket.Packet
+	for packet := range gopacket.NewPacketSource(reader, layers.LinkTypeEthernet).Packets() {
+		out = append(out, packet)
+	}
+	return out
+}
+
+// TestCBusFixturePayloadsSurviveTheCapture is the guard that the capture writer does not
+// corrupt or reorder what the analyzer will later parse.
+func TestCBusFixturePayloadsSurviveTheCapture(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "cbus.pcap")
+	want := CBusSession()
+	require.NoError(t, WriteCBus(path, want))
+
+	got := readBack(t, path)
+	require.Len(t, got, len(want), "every fixture packet must reach the capture")
+
+	for i, packet := range got {
+		app := packet.ApplicationLayer()
+		require.NotNil(t, app, "packet %d (%s) must carry an application layer", i+1, want[i].Comment)
+		assert.Equal(t, want[i].Payload, app.Payload(),
+			"packet %d (%s) payload must round-trip through the capture", i+1, want[i].Comment)
+	}
+}
+
+func TestBacnetFixturePayloadsSurviveTheCapture(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "bacnet.pcap")
+	want := BacnetSession()
+	require.NoError(t, WriteBacnet(path, want))
+
+	got := readBack(t, path)
+	require.Len(t, got, len(want))
+	for i, packet := range got {
+		app := packet.ApplicationLayer()
+		require.NotNil(t, app, "packet %d (%s)", i+1, want[i].Comment)
+		assert.Equal(t, want[i].Payload, app.Payload(), "packet %d (%s)", i+1, want[i].Comment)
+	}
+}
+
+// TestCBusFixtureDirectionsMapToAddresses pins the property the c-bus analyzer depends on to
+// tell a request from a response: it compares the packet's source IP against the configured
+// client address.
+func TestCBusFixtureDirectionsMapToAddresses(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "cbus.pcap")
+	want := CBusSession()
+	require.NoError(t, WriteCBus(path, want))
+
+	for i, packet := range readBack(t, path) {
+		ip, ok := packet.NetworkLayer().(*layers.IPv4)
+		require.True(t, ok, "packet %d must have an IPv4 layer", i+1)
+		if want[i].Direction == FromClient {
+			assert.Equal(t, ClientIP, ip.SrcIP.String(), "packet %d is a request", i+1)
+			assert.Equal(t, ServerIP, ip.DstIP.String(), "packet %d is a request", i+1)
+		} else {
+			assert.Equal(t, ServerIP, ip.SrcIP.String(), "packet %d is a response", i+1)
+			assert.Equal(t, ClientIP, ip.DstIP.String(), "packet %d is a response", i+1)
+		}
+	}
+}
+
+func TestCBusFixtureUsesTcpOnTheCBusPort(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "cbus.pcap")
+	require.NoError(t, WriteCBus(path, CBusSession()))
+	for i, packet := range readBack(t, path) {
+		tcp, ok := packet.TransportLayer().(*layers.TCP)
+		require.True(t, ok, "packet %d must be TCP", i+1)
+		assert.True(t, tcp.SrcPort == CBusPort || tcp.DstPort == CBusPort,
+			"packet %d must involve the c-bus port, got %v->%v", i+1, tcp.SrcPort, tcp.DstPort)
+	}
+}
+
+func TestBacnetFixtureUsesUdpOnTheBacnetPort(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "bacnet.pcap")
+	require.NoError(t, WriteBacnet(path, BacnetSession()))
+	for i, packet := range readBack(t, path) {
+		udp, ok := packet.TransportLayer().(*layers.UDP)
+		require.True(t, ok, "packet %d must be UDP", i+1)
+		assert.EqualValues(t, BacnetPort, uint16(udp.DstPort), "packet %d", i+1)
+	}
+}
+
+// TestCBusSessionPayloadsRoundTripThroughTheRealCodec is the heart of the fixture's value: it
+// drives plc4x's actual C-Bus codec with the fixture bytes and asserts every payload parses
+// and re-serializes byte-identically. If a plc4x upgrade changes C-Bus encoding, this fails.
+func TestCBusSessionPayloadsRoundTripThroughTheRealCodec(t *testing.T) {
+	for i, packet := range CBusSession() {
+		t.Run(packet.Comment, func(t *testing.T) {
+			isResponse := packet.Direction == FromServer
+			// Responses carry a checksum, which is what the srchk option selects.
+			options := cbusModel.NewCBusOptions(false, false, false, false, false, false, false, false, isResponse)
+			parsed, err := cbusModel.CBusMessageParse[cbusModel.CBusMessage](
+				context.Background(), packet.Payload, isResponse, cbusModel.NewRequestContext(false), options)
+			require.NoError(t, err, "fixture packet %d (%q) must parse", i+1, packet.Payload)
+
+			serialized, err := parsed.Serialize()
+			require.NoError(t, err)
+			assert.True(t, bytes.Equal(packet.Payload, serialized),
+				"fixture packet %d must re-serialize identically\n got: %q\nwant: %q", i+1, serialized, packet.Payload)
+		})
+	}
+}
+
+// TestBacnetSessionPayloadsRoundTripThroughTheRealCodec is the BACnet counterpart.
+func TestBacnetSessionPayloadsRoundTripThroughTheRealCodec(t *testing.T) {
+	for i, packet := range BacnetSession() {
+		t.Run(packet.Comment, func(t *testing.T) {
+			parsed, err := bacnetModel.BVLCParse[bacnetModel.BVLC](context.Background(), packet.Payload)
+			require.NoError(t, err, "fixture packet %d (%x) must parse", i+1, packet.Payload)
+
+			serialized, err := parsed.Serialize()
+			require.NoError(t, err)
+			assert.True(t, bytes.Equal(packet.Payload, serialized),
+				"fixture packet %d must re-serialize identically\n got: %x\nwant: %x", i+1, serialized, packet.Payload)
+		})
+	}
+}
+
+// TestCBusFailurePayloadsDoNotParse pins the negatives. The analyzer's parse-failure counter is
+// only meaningful if these really do fail, so if a plc4x upgrade starts accepting them the
+// failure-path tests built on this fixture would silently stop testing anything.
+func TestCBusFailurePayloadsDoNotParse(t *testing.T) {
+	session := CBusSession()
+	withFailures := CBusSessionWithFailures()
+	require.Greater(t, len(withFailures), len(session), "the failure fixture must add packets")
+
+	for _, packet := range withFailures[len(session):] {
+		t.Run(string(packet.Payload), func(t *testing.T) {
+			options := cbusModel.NewCBusOptions(false, false, false, false, false, false, false, false, false)
+			_, err := cbusModel.CBusMessageParse[cbusModel.CBusMessage](
+				context.Background(), packet.Payload, false, cbusModel.NewRequestContext(false), options)
+			assert.Error(t, err, "payload %q is a fixture negative and must not parse", packet.Payload)
+		})
+	}
+}
+
+// TestCapturesAreByteIdenticalAcrossRuns is what makes golden-file testing viable: the writer
+// must not embed a wall-clock timestamp.
+func TestCapturesAreByteIdenticalAcrossRuns(t *testing.T) {
+	dir := t.TempDir()
+	first, second := filepath.Join(dir, "a.pcap"), filepath.Join(dir, "b.pcap")
+	require.NoError(t, WriteCBus(first, CBusSession()))
+	require.NoError(t, WriteCBus(second, CBusSession()))
+
+	a, err := os.ReadFile(first)
+	require.NoError(t, err)
+	b, err := os.ReadFile(second)
+	require.NoError(t, err)
+	assert.True(t, bytes.Equal(a, b), "two writes of the same fixture must produce identical bytes")
+}
+
+// TestEveryPacketHasADistinctTimestamp matters because the analyzer indexes packets by
+// timestamp; duplicates would collapse its packet numbering.
+func TestEveryPacketHasADistinctTimestamp(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "cbus.pcap")
+	require.NoError(t, WriteCBus(path, CBusSession()))
+
+	seen := map[int64]bool{}
+	for i, packet := range readBack(t, path) {
+		ts := packet.Metadata().Timestamp.UnixNano()
+		assert.False(t, seen[ts], "packet %d reuses timestamp %d", i+1, ts)
+		seen[ts] = true
+	}
+}
+
+func TestWriteRejectsAnEmptyCapture(t *testing.T) {
+	err := WriteCBus(filepath.Join(t.TempDir(), "empty.pcap"), nil)
+	assert.Error(t, err, "an empty capture is a programming mistake, not a valid fixture")
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go
new file mode 100644
index 00000000..29bb1833
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go
@@ -0,0 +1,124 @@
+/*
+ * 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 protocol is the single source of truth for the protocol names the pcap analyzer
+// understands.
+//
+// It exists because the name accepted at the command line, the name the analyzer switched on
+// and the name the extractor switched on used to disagree: the CLI accepted "bacnet", the
+// analyzer only matched "bacnetip" and the extractor only matched "bacnet". Every route into
+// the analyzer for BACnet therefore failed. Resolving names here, once, keeps the CLI, the
+// analyzer, the extractor and the TUI from drifting apart again.
+package protocol
+
+import (
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+)
+
+// Protocol is a protocol the analyzer can parse, serialize and byte-compare.
+type Protocol struct {
+	// Name is the canonical name. It matches the plc4x driver protocol code, so it is also
+	// what a plc4x connection string would use.
+	Name string
+	// Aliases are additional names accepted from users, for convenience and for backwards
+	// compatibility with names earlier versions accepted.
+	Aliases []string
+	// Description is a short human-readable label used in help output.
+	Description string
+}
+
+// String returns the canonical name, so a Protocol can be passed anywhere a name is logged.
+func (p Protocol) String() string { return p.Name }
+
+// Matches reports whether name refers to this protocol, by canonical name or by alias.
+// Comparison is case-insensitive and ignores surrounding whitespace.
+func (p Protocol) Matches(name string) bool {
+	name = normalize(name)
+	if name == p.Name {
+		return true
+	}
+	return slices.Contains(p.Aliases, name)
+}
+
+// The supported protocols. BACnet/IP is canonically "bacnetip", matching the plc4x driver
+// protocol code, with "bacnet" accepted as an alias because the CLI has always advertised it.
+var (
+	BacnetIP = Protocol{
+		Name:        "bacnetip",
+		Aliases:     []string{"bacnet"},
+		Description: "BACnet/IP",
+	}
+	CBus = Protocol{
+		Name:        "c-bus",
+		Aliases:     []string{"cbus"},
+		Description: "Clipsal C-Bus",
+	}
+)
+
+// all is the registry. Keep it ordered by canonical name so help output is stable.
+var all = []Protocol{BacnetIP, CBus}
+
+// All returns every supported protocol.
+func All() []Protocol {
+	out := make([]Protocol, len(all))
+	copy(out, all)
+	return out
+}
+
+// Names returns the canonical names, sorted, for help and error messages.
+func Names() []string {
+	names := make([]string, 0, len(all))
+	for _, p := range all {
+		names = append(names, p.Name)
+	}
+	sort.Strings(names)
+	return names
+}
+
+// Resolve maps a user-supplied name, canonical or alias, onto a supported protocol.
+// The returned error names every accepted spelling, since that is what a user needs to
+// recover from a typo.
+func Resolve(name string) (Protocol, error) {
+	for _, p := range all {
+		if p.Matches(name) {
+			return p, nil
+		}
+	}
+	return Protocol{}, errors.Errorf("unsupported protocol %q, supported protocols are %s (accepted aliases: %s)",
+		name, strings.Join(Names(), ", "), strings.Join(aliases(), ", "))
+}
+
+// aliases returns every accepted alias, sorted, for error messages.
+func aliases() []string {
+	var out []string
+	for _, p := range all {
+		out = append(out, p.Aliases...)
+	}
+	sort.Strings(out)
+	return out
+}
+
+// normalize puts a user-supplied name into the form the registry compares against.
+func normalize(name string) string {
+	return strings.ToLower(strings.TrimSpace(name))
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go
new file mode 100644
index 00000000..ebde505f
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go
@@ -0,0 +1,130 @@
+/*
+ * 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 protocol
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestResolveAcceptsBacnetAlias is the regression test for the defect that made every BACnet
+// route into the analyzer fail: the CLI accepted "bacnet" while the analyzer only ever matched
+// "bacnetip", so the name never reached a branch that could handle it.
+func TestResolveAcceptsBacnetAlias(t *testing.T) {
+	for _, name := range []string{"bacnet", "bacnetip"} {
+		t.Run(name, func(t *testing.T) {
+			got, err := Resolve(name)
+			require.NoError(t, err, "the CLI has always advertised %q, so it must resolve", name)
+			assert.Equal(t, BacnetIP.Name, got.Name,
+				"both spellings must collapse onto the one canonical name the analyzer switches on")
+		})
+	}
+}
+
+func TestResolveAcceptsCBusSpellings(t *testing.T) {
+	for _, name := range []string{"c-bus", "cbus"} {
+		t.Run(name, func(t *testing.T) {
+			got, err := Resolve(name)
+			require.NoError(t, err)
+			assert.Equal(t, CBus.Name, got.Name)
+		})
+	}
+}
+
+func TestResolveIsCaseAndSpaceInsensitive(t *testing.T) {
+	tests := map[string]Protocol{
+		"BACnet":    BacnetIP,
+		"BACNETIP":  BacnetIP,
+		"  bacnet ": BacnetIP,
+		"C-Bus":     CBus,
+		"\tcbus\n":  CBus,
+	}
+	for input, want := range tests {
+		t.Run(input, func(t *testing.T) {
+			got, err := Resolve(input)
+			require.NoError(t, err)
+			assert.Equal(t, want.Name, got.Name)
+		})
+	}
+}
+
+func TestResolveRejectsUnknownProtocolAndSaysWhatIsAccepted(t *testing.T) {
+	_, err := Resolve("modbus")
+	require.Error(t, err)
+	// The message has to be actionable: a user who typo'd needs to see the real options.
+	assert.Contains(t, err.Error(), "modbus", "the rejected input should be echoed back")
+	assert.Contains(t, err.Error(), "bacnetip")
+	assert.Contains(t, err.Error(), "c-bus")
+}
+
+func TestResolveRejectsEmptyName(t *testing.T) {
+	_, err := Resolve("")
+	assert.Error(t, err, "an empty protocol must not silently resolve to the first entry")
+}
+
+func TestNamesAreCanonicalAndSorted(t *testing.T) {
+	assert.Equal(t, []string{"bacnetip", "c-bus"}, Names(),
+		"help output depends on this being stable and canonical")
+}
+
+func TestAllReturnsACopy(t *testing.T) {
+	first := All()
+	require.NotEmpty(t, first)
+	first[0] = Protocol{Name: "clobbered"}
+	assert.NotEqual(t, "clobbered", All()[0].Name, "All must not expose the package registry for mutation")
+}
+
+// TestEveryProtocolResolvesByItsOwnName guards against adding a protocol to the registry with
+// a name that Resolve cannot find.
+func TestEveryProtocolResolvesByItsOwnName(t *testing.T) {
+	for _, p := range All() {
+		t.Run(p.Name, func(t *testing.T) {
+			got, err := Resolve(p.Name)
+			require.NoError(t, err)
+			assert.Equal(t, p.Name, got.Name)
+			for _, alias := range p.Aliases {
+				gotAlias, err := Resolve(alias)
+				require.NoError(t, err, "alias %q must resolve", alias)
+				assert.Equal(t, p.Name, gotAlias.Name)
+			}
+		})
+	}
+}
+
+// TestNoDuplicateNamesOrAliases catches a copy-paste mistake in the registry that would make
+// Resolve's first-match-wins behaviour silently ambiguous.
+func TestNoDuplicateNamesOrAliases(t *testing.T) {
+	seen := map[string]string{}
+	for _, p := range All() {
+		for _, spelling := range append([]string{p.Name}, p.Aliases...) {
+			if owner, dup := seen[spelling]; dup {
+				t.Errorf("spelling %q is claimed by both %q and %q", spelling, owner, p.Name)
+			}
+			seen[spelling] = p.Name
+		}
+	}
+}
+
+func TestStringIsTheCanonicalName(t *testing.T) {
+	assert.Equal(t, "bacnetip", BacnetIP.String())
+	assert.Equal(t, "c-bus", CBus.String())
+}