From 3fdd7ee45ecf994f77d07ecb0b458006c54ef97a Mon Sep 17 00:00:00 2001 From: Frieder Steinmetz Date: Wed, 14 Jan 2026 23:40:59 +0100 Subject: [PATCH 1/5] Added the PcapSnooper class. The class implements a bumble snooper that writes PCAP records. It can write to either a file or a named pipe. The latter is useful to bridge with wireshark extcap for live logging. --- bumble/snoop.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/bumble/snoop.py b/bumble/snoop.py index 401bf56f..ef307d93 100644 --- a/bumble/snoop.py +++ b/bumble/snoop.py @@ -110,6 +110,46 @@ def snoop(self, hci_packet: bytes, direction: Snooper.Direction) -> None: ) +# ----------------------------------------------------------------------------- +class PcapSnooper(Snooper): + """ + Snooper that saves or streames HCI packets using the PCAP format. + """ + + PCAP_MAGIC = 0xa1b2c3d4 + DLT_BLUETOOTH_HCI_H4_WITH_PHDR = 201 + + def __init__(self, fifo): + self.output = fifo + + # Write the header + self.output.write(struct.pack("I", int(direction)) # ...thats being added here + + hci_packet + ) + self.output.flush() # flush after every packet for live logging + # ----------------------------------------------------------------------------- _SNOOPER_INSTANCE_COUNT = 0 @@ -139,10 +179,39 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: utcnow: the value of `datetime.now(tz=datetime.timezone.utc)` pid: the current process ID. instance: the instance ID in the current process. + + pcapsnoop + The syntax for the type-specific arguments for this type is: + : + + Supported I/O types are: + + file + The type-specific arguments for this I/O type is a string that is converted + to a file path using the python `str.format()` string formatting. The log + records will be written to that file if it can be opened/created. + The keyword args that may be referenced by the string pattern are: + now: the value of `datetime.now()` + utcnow: the value of `datetime.now(tz=datetime.timezone.utc)` + pid: the current process ID. + instance: the instance ID in the current process. + + pipe + The type-specific arguments for this I/O type is a string that is converted + to a path using the python `str.format()` string formatting. The log + records will be written to the named pipe referenced by this path + if it can be opened. The keyword args that may be referenced by the + string pattern are: + now: the value of `datetime.now()` + utcnow: the value of `datetime.now(tz=datetime.timezone.utc)` + pid: the current process ID. + instance: the instance ID in the current process. Examples: btsnoop:file:my_btsnoop.log btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log + pcapsnoop:pipe:/tmp/bumble-extcap + """ if ':' not in spec: @@ -173,6 +242,36 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: _SNOOPER_INSTANCE_COUNT -= 1 return + elif snooper_type == 'pcapsnoop': + if ':' not in snooper_args: + raise core.InvalidArgumentError( + 'I/O type for pcapsnoop snooper type missing' + ) + + io_type, io_name = snooper_args.split(':', maxsplit=1) + if io_type in {'pipe', 'file'}: + # Process the file name string pattern. + file_path = io_name.format( + now=datetime.datetime.now(), + utcnow=datetime.datetime.now(tz=datetime.timezone.utc), + pid=os.getpid(), + instance=_SNOOPER_INSTANCE_COUNT, + ) + + # Pipes we have to open with unbuffered binary I/O + kwargs = {} + if io_type == 'pipe': + kwargs["buffering"] = 0 + + # Open a file or pipe + logger.debug(f'PCAP file: {file_path}') + # Pass ``buffering`` for pipes but not for files + with open(file_path, 'wb', **kwargs) as snoop_file: + _SNOOPER_INSTANCE_COUNT += 1 + yield PcapSnooper(snoop_file) + _SNOOPER_INSTANCE_COUNT -= 1 + return + raise core.InvalidArgumentError(f'I/O type {io_type} not supported') raise core.InvalidArgumentError(f'snooper type {snooper_type} not found') From f95b2054c8cb07b933ffb60c49e83f0c51751e6f Mon Sep 17 00:00:00 2001 From: Frieder Steinmetz Date: Thu, 15 Jan 2026 10:50:33 +0100 Subject: [PATCH 2/5] Formatted with --- bumble/snoop.py | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/bumble/snoop.py b/bumble/snoop.py index ef307d93..8b3b4a6e 100644 --- a/bumble/snoop.py +++ b/bumble/snoop.py @@ -116,21 +116,26 @@ class PcapSnooper(Snooper): Snooper that saves or streames HCI packets using the PCAP format. """ - PCAP_MAGIC = 0xa1b2c3d4 + PCAP_MAGIC = 0xA1B2C3D4 DLT_BLUETOOTH_HCI_H4_WITH_PHDR = 201 def __init__(self, fifo): self.output = fifo # Write the header - self.output.write(struct.pack("I", int(direction)) # ...thats being added here + + struct.pack(">I", int(direction)) # ...thats being added here + hci_packet ) - self.output.flush() # flush after every packet for live logging + self.output.flush() # flush after every packet for live logging + # ----------------------------------------------------------------------------- _SNOOPER_INSTANCE_COUNT = 0 @@ -179,7 +186,7 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: utcnow: the value of `datetime.now(tz=datetime.timezone.utc)` pid: the current process ID. instance: the instance ID in the current process. - + pcapsnoop The syntax for the type-specific arguments for this type is: : @@ -195,11 +202,11 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: utcnow: the value of `datetime.now(tz=datetime.timezone.utc)` pid: the current process ID. instance: the instance ID in the current process. - + pipe The type-specific arguments for this I/O type is a string that is converted to a path using the python `str.format()` string formatting. The log - records will be written to the named pipe referenced by this path + records will be written to the named pipe referenced by this path if it can be opened. The keyword args that may be referenced by the string pattern are: now: the value of `datetime.now()` From c69c1532cc1607a04d405f3590c6d76d96d250d6 Mon Sep 17 00:00:00 2001 From: Frieder Steinmetz Date: Thu, 15 Jan 2026 19:06:03 +0100 Subject: [PATCH 3/5] Fix comments that were messed up by black --- bumble/snoop.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bumble/snoop.py b/bumble/snoop.py index 8b3b4a6e..8b569749 100644 --- a/bumble/snoop.py +++ b/bumble/snoop.py @@ -127,10 +127,10 @@ def __init__(self, fifo): struct.pack( " Date: Thu, 22 Jan 2026 10:40:08 +0100 Subject: [PATCH 4/5] Please mypy.\n\nTwo calls to open(), some more annotations and a rescoped global were needed. --- bumble/snoop.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/bumble/snoop.py b/bumble/snoop.py index 8b569749..cb16ef53 100644 --- a/bumble/snoop.py +++ b/bumble/snoop.py @@ -119,8 +119,8 @@ class PcapSnooper(Snooper): PCAP_MAGIC = 0xA1B2C3D4 DLT_BLUETOOTH_HCI_H4_WITH_PHDR = 201 - def __init__(self, fifo): - self.output = fifo + def __init__(self, output: BinaryIO): + self.output = output # Write the header self.output.write( @@ -226,6 +226,8 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: snooper_type, snooper_args = spec.split(':', maxsplit=1) + global _SNOOPER_INSTANCE_COUNT + if snooper_type == 'btsnoop': if ':' not in snooper_args: raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing') @@ -233,7 +235,6 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: io_type, io_name = snooper_args.split(':', maxsplit=1) if io_type == 'file': # Process the file name string pattern. - global _SNOOPER_INSTANCE_COUNT file_path = io_name.format( now=datetime.datetime.now(), utcnow=datetime.datetime.now(tz=datetime.timezone.utc), @@ -265,17 +266,20 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: instance=_SNOOPER_INSTANCE_COUNT, ) - # Pipes we have to open with unbuffered binary I/O - kwargs = {} - if io_type == 'pipe': - kwargs["buffering"] = 0 - # Open a file or pipe logger.debug(f'PCAP file: {file_path}') - # Pass ``buffering`` for pipes but not for files - with open(file_path, 'wb', **kwargs) as snoop_file: + + # Pipes we have to open with unbuffered binary I/O + # so we pass ``buffering`` for pipes but not for files + pcap_file: BinaryIO + if io_type == 'pipe': + pcap_file = open(file_path, 'wb', buffering=0) + else: + pcap_file = open(file_path, 'wb') + + with pcap_file: _SNOOPER_INSTANCE_COUNT += 1 - yield PcapSnooper(snoop_file) + yield PcapSnooper(pcap_file) _SNOOPER_INSTANCE_COUNT -= 1 return From a8396e6cce546c1643dbc6f687f88da219952093 Mon Sep 17 00:00:00 2001 From: Frieder Steinmetz Date: Thu, 22 Jan 2026 17:49:58 +0100 Subject: [PATCH 5/5] Formatted with black again. --- bumble/snoop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bumble/snoop.py b/bumble/snoop.py index cb16ef53..335796bc 100644 --- a/bumble/snoop.py +++ b/bumble/snoop.py @@ -272,7 +272,7 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]: # Pipes we have to open with unbuffered binary I/O # so we pass ``buffering`` for pipes but not for files pcap_file: BinaryIO - if io_type == 'pipe': + if io_type == 'pipe': pcap_file = open(file_path, 'wb', buffering=0) else: pcap_file = open(file_path, 'wb')