btsnoop-parser 0.2.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. btsnoop_parser-0.4.0/PKG-INFO +164 -0
  2. btsnoop_parser-0.4.0/README.md +135 -0
  3. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/__init__.py +4 -1
  4. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/analysis.py +64 -37
  5. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/cli.py +38 -3
  6. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/core.py +80 -1
  7. btsnoop_parser-0.4.0/btsnoop_parser/pcap.py +99 -0
  8. btsnoop_parser-0.4.0/btsnoop_parser/utils.py +0 -0
  9. btsnoop_parser-0.4.0/btsnoop_parser.egg-info/PKG-INFO +164 -0
  10. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/SOURCES.txt +3 -0
  11. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/pyproject.toml +2 -3
  12. btsnoop_parser-0.4.0/tests/test_analysis.py +164 -0
  13. btsnoop_parser-0.4.0/tests/test_filter_pcap.py +169 -0
  14. btsnoop_parser-0.2.2/PKG-INFO +0 -118
  15. btsnoop_parser-0.2.2/README.md +0 -88
  16. btsnoop_parser-0.2.2/btsnoop_parser/utils.py +0 -2
  17. btsnoop_parser-0.2.2/btsnoop_parser.egg-info/PKG-INFO +0 -118
  18. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/LICENSE +0 -0
  19. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/__main__.py +0 -0
  20. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/constants.py +0 -0
  21. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/hci_decoder.py +0 -0
  22. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/dependency_links.txt +0 -0
  23. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/entry_points.txt +0 -0
  24. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/requires.txt +0 -0
  25. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/top_level.txt +0 -0
  26. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/setup.cfg +0 -0
  27. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/tests/test_cli.py +0 -0
  28. {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/tests/test_parser.py +0 -0
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: btsnoop-parser
3
+ Version: 0.4.0
4
+ Summary: Parse and explore Bluetooth HCI btsnoop captures from Android devices.
5
+ Author-email: Kranthi <kdevprofile@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/devk-op/btsnoop-parser
8
+ Project-URL: Repository, https://github.com/devk-op/btsnoop-parser
9
+ Project-URL: Issues, https://github.com/devk-op/btsnoop-parser/issues
10
+ Keywords: bluetooth,btsnoop,hci,android,parser
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: System :: Networking
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.4; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # btsnoop-parser
31
+
32
+ [![CI](https://github.com/devk-op/btsnoop-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/devk-op/btsnoop-parser/actions?query=workflow%3A"CI%2FCD+Pipeline")
33
+ [![PyPI](https://img.shields.io/pypi/v/btsnoop-parser)](https://pypi.org/project/btsnoop-parser/)
34
+ [![Docs](https://readthedocs.org/projects/btsnoop-parser/badge/?version=latest)](https://btsnoop-parser.readthedocs.io)
35
+ [![Python](https://img.shields.io/pypi/pyversions/btsnoop-parser)](https://pypi.org/project/btsnoop-parser/)
36
+
37
+ `btsnoop-parser` is a small library and CLI for exploring Bluetooth `btsnoop_hci.log`
38
+ captures produced on Android devices. It is a lightweight alternative to Wireshark
39
+ when you need a quick look at packet metadata or want to script over captures in Python.
40
+
41
+ ```
42
+ $ btsnoop_parser capture.log --stats
43
+
44
+ Capture Statistics ───
45
+ Duration: 4m 32.871s
46
+ Total Packets: 1,842
47
+ Data Volume: 142.67 KB
48
+
49
+ Packet Types:
50
+ ACL Data 1,204
51
+ Event 512
52
+ Command 126
53
+
54
+ Detected Devices:
55
+ AA:BB:CC:DD:EE:FF Unknown
56
+ 11:22:33:44:55:66 Unknown
57
+
58
+ Connection History:
59
+ 2024-06-01 09:12:03.441 Connected (LE) 0x001 -> Device: AA:BB:CC:DD:EE:FF
60
+ 2024-06-01 09:14:21.009 Disconnected 0x001 -> Reason: Remote User Terminated Connection (Remote Device)
61
+ 2024-06-01 09:15:44.230 Connect Failed (LE) 0x002 -> Device: 11:22:33:44:55:66 — Page Timeout
62
+
63
+ Potential Issues (1):
64
+ [WARN] 2024-06-01 09:15:44.230 - LE Connection Failed: Failed to connect to 11:22:33:44:55:66: Page Timeout
65
+ ```
66
+
67
+ ## Features
68
+
69
+ - Parses BTSnoop HCI logs into friendly Python dicts — zero dependencies.
70
+ - Wireshark-style CLI table with direction colouring.
71
+ - **`--filter`** — filter by packet type and direction before processing.
72
+ - **`--pcap`** — export to PCAP (link type 201) for Wireshark / tshark.
73
+ - **`--stats`** — connection history, device list, and issue detection.
74
+ - Decodes common HCI command/event payloads.
75
+ - Corrects the Android ±378-day timestamp bug automatically.
76
+
77
+ ## Installation
78
+
79
+ ```bash
80
+ pip install btsnoop-parser
81
+ ```
82
+
83
+ ## CLI Usage
84
+
85
+ ```bash
86
+ # Wireshark-style table, first 20 packets
87
+ btsnoop_parser capture.log --limit 20
88
+
89
+ # Show only HCI events
90
+ btsnoop_parser capture.log --filter type:event
91
+
92
+ # Show only TX commands
93
+ btsnoop_parser capture.log --filter type:command --filter dir:tx
94
+
95
+ # Export filtered records to a PCAP file — open directly in Wireshark
96
+ btsnoop_parser capture.log --filter type:event --pcap events.pcap
97
+
98
+ # Convert the whole capture to PCAP
99
+ btsnoop_parser capture.log --pcap full.pcap
100
+
101
+ # Capture statistics and issue detection
102
+ btsnoop_parser capture.log --stats
103
+
104
+ # JSON output for scripting
105
+ btsnoop_parser capture.log --json | jq '[.[] | select(.direction=="RX")]'
106
+ ```
107
+
108
+ Run `btsnoop_parser --help` for the full option list.
109
+
110
+ ### `--filter` expressions
111
+
112
+ | Key | Values | Example |
113
+ |--------|-------------------------------------------------|--------------------------|
114
+ | `type` | `command`, `acl`, `event`, `sco`, `iso`, `0xNN` | `--filter type:event` |
115
+ | `dir` | `tx`, `rx` | `--filter dir:tx` |
116
+
117
+ Comma-separate types for OR logic: `--filter type:command,event`
118
+ Repeat the flag to AND filters: `--filter type:event --filter dir:rx`
119
+
120
+ ## Python API
121
+
122
+ ```python
123
+ from btsnoop_parser import (
124
+ parse_btsnoop_file,
125
+ iter_records,
126
+ filter_records,
127
+ write_pcap,
128
+ decode_hci_packet,
129
+ )
130
+
131
+ # Load all records
132
+ records = parse_btsnoop_file("btsnoop_hci.log")
133
+
134
+ # Filter to HCI events only
135
+ events = filter_records(records, ["type:event"])
136
+
137
+ # Export to Wireshark-compatible PCAP
138
+ write_pcap(records, "capture.pcap")
139
+
140
+ # Stream large files without loading everything into memory
141
+ for record in iter_records("btsnoop_hci.log"):
142
+ decoded = decode_hci_packet(record["packet_type"], record["payload"])
143
+ if decoded.get("type") == "COMMAND":
144
+ print(record["timestamp"], decoded["name"])
145
+ ```
146
+
147
+ Full API reference: **[btsnoop-parser.readthedocs.io](https://btsnoop-parser.readthedocs.io)**
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ pip install -e ".[dev]"
153
+ pytest
154
+ ruff check
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT © Kranthi
160
+
161
+ ## Contributing
162
+
163
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on setting up your
164
+ environment, running tests, and submitting pull requests.
@@ -0,0 +1,135 @@
1
+ # btsnoop-parser
2
+
3
+ [![CI](https://github.com/devk-op/btsnoop-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/devk-op/btsnoop-parser/actions?query=workflow%3A"CI%2FCD+Pipeline")
4
+ [![PyPI](https://img.shields.io/pypi/v/btsnoop-parser)](https://pypi.org/project/btsnoop-parser/)
5
+ [![Docs](https://readthedocs.org/projects/btsnoop-parser/badge/?version=latest)](https://btsnoop-parser.readthedocs.io)
6
+ [![Python](https://img.shields.io/pypi/pyversions/btsnoop-parser)](https://pypi.org/project/btsnoop-parser/)
7
+
8
+ `btsnoop-parser` is a small library and CLI for exploring Bluetooth `btsnoop_hci.log`
9
+ captures produced on Android devices. It is a lightweight alternative to Wireshark
10
+ when you need a quick look at packet metadata or want to script over captures in Python.
11
+
12
+ ```
13
+ $ btsnoop_parser capture.log --stats
14
+
15
+ Capture Statistics ───
16
+ Duration: 4m 32.871s
17
+ Total Packets: 1,842
18
+ Data Volume: 142.67 KB
19
+
20
+ Packet Types:
21
+ ACL Data 1,204
22
+ Event 512
23
+ Command 126
24
+
25
+ Detected Devices:
26
+ AA:BB:CC:DD:EE:FF Unknown
27
+ 11:22:33:44:55:66 Unknown
28
+
29
+ Connection History:
30
+ 2024-06-01 09:12:03.441 Connected (LE) 0x001 -> Device: AA:BB:CC:DD:EE:FF
31
+ 2024-06-01 09:14:21.009 Disconnected 0x001 -> Reason: Remote User Terminated Connection (Remote Device)
32
+ 2024-06-01 09:15:44.230 Connect Failed (LE) 0x002 -> Device: 11:22:33:44:55:66 — Page Timeout
33
+
34
+ Potential Issues (1):
35
+ [WARN] 2024-06-01 09:15:44.230 - LE Connection Failed: Failed to connect to 11:22:33:44:55:66: Page Timeout
36
+ ```
37
+
38
+ ## Features
39
+
40
+ - Parses BTSnoop HCI logs into friendly Python dicts — zero dependencies.
41
+ - Wireshark-style CLI table with direction colouring.
42
+ - **`--filter`** — filter by packet type and direction before processing.
43
+ - **`--pcap`** — export to PCAP (link type 201) for Wireshark / tshark.
44
+ - **`--stats`** — connection history, device list, and issue detection.
45
+ - Decodes common HCI command/event payloads.
46
+ - Corrects the Android ±378-day timestamp bug automatically.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install btsnoop-parser
52
+ ```
53
+
54
+ ## CLI Usage
55
+
56
+ ```bash
57
+ # Wireshark-style table, first 20 packets
58
+ btsnoop_parser capture.log --limit 20
59
+
60
+ # Show only HCI events
61
+ btsnoop_parser capture.log --filter type:event
62
+
63
+ # Show only TX commands
64
+ btsnoop_parser capture.log --filter type:command --filter dir:tx
65
+
66
+ # Export filtered records to a PCAP file — open directly in Wireshark
67
+ btsnoop_parser capture.log --filter type:event --pcap events.pcap
68
+
69
+ # Convert the whole capture to PCAP
70
+ btsnoop_parser capture.log --pcap full.pcap
71
+
72
+ # Capture statistics and issue detection
73
+ btsnoop_parser capture.log --stats
74
+
75
+ # JSON output for scripting
76
+ btsnoop_parser capture.log --json | jq '[.[] | select(.direction=="RX")]'
77
+ ```
78
+
79
+ Run `btsnoop_parser --help` for the full option list.
80
+
81
+ ### `--filter` expressions
82
+
83
+ | Key | Values | Example |
84
+ |--------|-------------------------------------------------|--------------------------|
85
+ | `type` | `command`, `acl`, `event`, `sco`, `iso`, `0xNN` | `--filter type:event` |
86
+ | `dir` | `tx`, `rx` | `--filter dir:tx` |
87
+
88
+ Comma-separate types for OR logic: `--filter type:command,event`
89
+ Repeat the flag to AND filters: `--filter type:event --filter dir:rx`
90
+
91
+ ## Python API
92
+
93
+ ```python
94
+ from btsnoop_parser import (
95
+ parse_btsnoop_file,
96
+ iter_records,
97
+ filter_records,
98
+ write_pcap,
99
+ decode_hci_packet,
100
+ )
101
+
102
+ # Load all records
103
+ records = parse_btsnoop_file("btsnoop_hci.log")
104
+
105
+ # Filter to HCI events only
106
+ events = filter_records(records, ["type:event"])
107
+
108
+ # Export to Wireshark-compatible PCAP
109
+ write_pcap(records, "capture.pcap")
110
+
111
+ # Stream large files without loading everything into memory
112
+ for record in iter_records("btsnoop_hci.log"):
113
+ decoded = decode_hci_packet(record["packet_type"], record["payload"])
114
+ if decoded.get("type") == "COMMAND":
115
+ print(record["timestamp"], decoded["name"])
116
+ ```
117
+
118
+ Full API reference: **[btsnoop-parser.readthedocs.io](https://btsnoop-parser.readthedocs.io)**
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ pip install -e ".[dev]"
124
+ pytest
125
+ ruff check
126
+ ```
127
+
128
+ ## License
129
+
130
+ MIT © Kranthi
131
+
132
+ ## Contributing
133
+
134
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on setting up your
135
+ environment, running tests, and submitting pull requests.
@@ -1,11 +1,14 @@
1
- from .core import iter_records, parse_btsnoop_file, print_table, slice_records
1
+ from .core import filter_records, iter_records, parse_btsnoop_file, print_table, slice_records
2
2
  from .hci_decoder import decode_hci_packet
3
+ from .pcap import write_pcap
3
4
 
4
5
  __all__ = [
5
6
  "decode_hci_packet",
7
+ "filter_records",
6
8
  "iter_records",
7
9
  "parse_btsnoop_file",
8
10
  "print_table",
9
11
  "slice_records",
12
+ "write_pcap",
10
13
  ]
11
14
 
@@ -6,7 +6,7 @@ import datetime
6
6
  import logging
7
7
  from typing import Any, Optional
8
8
 
9
- from .constants import HCI_ERROR_CODES
9
+ from .constants import HCI_ERROR_CODES, HCI_OPCODE_NAMES
10
10
 
11
11
  LOG = logging.getLogger(__name__)
12
12
 
@@ -75,15 +75,18 @@ class CaptureStats:
75
75
  elif reason == 0x13:
76
76
  reason_str += " (Remote Device)"
77
77
 
78
+ # 0x13=Remote User Terminated, 0x14=Remote Low Resources,
79
+ # 0x15=Remote Power Off, 0x16=Local Terminated
80
+ _normal_reasons = (0x00, 0x13, 0x14, 0x15, 0x16)
78
81
  self.lifecycle_events.append({
79
82
  "timestamp": ts,
80
83
  "event": "Disconnected",
81
84
  "handle": f"0x{handle:03X}",
82
85
  "details": f"Reason: {reason_str}",
83
- "is_error": reason not in (0x13, 0x16, 0x00) # 0x13=Remote User Terminated, 0x16=Local Terminated
86
+ "is_error": reason not in _normal_reasons,
84
87
  })
85
-
86
- if reason not in (0x13, 0x16, 0x00):
88
+
89
+ if reason not in _normal_reasons:
87
90
  self.issues.append({
88
91
  "timestamp": ts,
89
92
  "level": "WARN",
@@ -112,11 +115,18 @@ class CaptureStats:
112
115
  })
113
116
  else:
114
117
  err_str = HCI_ERROR_CODES.get(status, f"0x{status:02X}")
118
+ self.lifecycle_events.append({
119
+ "timestamp": ts,
120
+ "event": "Connect Failed (LE)",
121
+ "handle": f"0x{handle:03X}",
122
+ "details": f"Device: {bd_addr} — {err_str}",
123
+ "is_error": True,
124
+ })
115
125
  self.issues.append({
116
- "timestamp": ts,
117
- "level": "WARN",
118
- "title": "LE Connection Failed",
119
- "detail": f"Failed to connect to {bd_addr}: {err_str}"
126
+ "timestamp": ts,
127
+ "level": "WARN",
128
+ "title": "LE Connection Failed",
129
+ "detail": f"Failed to connect to {bd_addr}: {err_str}",
120
130
  })
121
131
 
122
132
  # LE Enhanced Connection Complete (0x0A) - used in Bluetooth 5.x
@@ -153,12 +163,19 @@ class CaptureStats:
153
163
  })
154
164
  else:
155
165
  err_str = HCI_ERROR_CODES.get(status, f"0x{status:02X}")
166
+ self.lifecycle_events.append({
167
+ "timestamp": ts,
168
+ "event": "Connect Failed (Classic)",
169
+ "handle": f"0x{handle:03X}",
170
+ "details": f"Device: {bd_addr} — {err_str}",
171
+ "is_error": True,
172
+ })
156
173
  self.issues.append({
157
- "timestamp": ts,
158
- "level": "WARN",
159
- "title": "Connection Failed",
160
- "detail": f"Failed to connect to {bd_addr}: {err_str}"
161
- })
174
+ "timestamp": ts,
175
+ "level": "WARN",
176
+ "title": "Connection Failed",
177
+ "detail": f"Failed to connect to {bd_addr}: {err_str}",
178
+ })
162
179
 
163
180
  # Synchronous Connection Complete (0x2C)
164
181
  elif event_code == 0x2C and len(payload) >= 19:
@@ -181,16 +198,16 @@ class CaptureStats:
181
198
  # Command Complete (0x0E)
182
199
  elif event_code == 0x0E and len(payload) >= 6:
183
200
  opcode = payload[3] | (payload[4] << 8)
184
- if len(payload) > 5:
185
- status = payload[5]
186
- if status != 0x00:
187
- err_str = HCI_ERROR_CODES.get(status, f"0x{status:02X}")
188
- self.issues.append({
189
- "timestamp": ts,
190
- "level": "ERROR",
191
- "title": "Command Failure",
192
- "detail": f"Opcode 0x{opcode:04X} failed with status {err_str}"
193
- })
201
+ status = payload[5]
202
+ if status != 0x00:
203
+ err_str = HCI_ERROR_CODES.get(status, f"0x{status:02X}")
204
+ opcode_name = HCI_OPCODE_NAMES.get(opcode, f"0x{opcode:04X}")
205
+ self.issues.append({
206
+ "timestamp": ts,
207
+ "level": "ERROR",
208
+ "title": "Command Failure",
209
+ "detail": f"{opcode_name} failed: {err_str}",
210
+ })
194
211
 
195
212
  # Command Status (0x0F)
196
213
  elif event_code == 0x0F and len(payload) >= 6:
@@ -198,12 +215,13 @@ class CaptureStats:
198
215
  opcode = payload[4] | (payload[5] << 8)
199
216
  if status != 0x00:
200
217
  err_str = HCI_ERROR_CODES.get(status, f"0x{status:02X}")
218
+ opcode_name = HCI_OPCODE_NAMES.get(opcode, f"0x{opcode:04X}")
201
219
  self.issues.append({
202
- "timestamp": ts,
203
- "level": "ERROR",
204
- "title": "Command Status Error",
205
- "detail": f"Opcode 0x{opcode:04X} returned status {err_str}"
206
- })
220
+ "timestamp": ts,
221
+ "level": "ERROR",
222
+ "title": "Command Status Error",
223
+ "detail": f"{opcode_name} returned status {err_str}",
224
+ })
207
225
 
208
226
  # Hardware Error (0x10)
209
227
  elif event_code == 0x10 and len(payload) >= 2:
@@ -229,24 +247,33 @@ class CaptureStats:
229
247
  duration = "N/A"
230
248
  if self.start_time and self.end_time:
231
249
  diff = self.end_time - self.start_time
232
- duration = str(diff)
250
+ total_s = int(diff.total_seconds())
251
+ h, rem = divmod(total_s, 3600)
252
+ m, s = divmod(rem, 60)
253
+ ms = diff.microseconds // 1000
254
+ if h:
255
+ duration = f"{h}h {m:02d}m {s:02d}.{ms:03d}s"
256
+ elif m:
257
+ duration = f"{m}m {s:02d}.{ms:03d}s"
258
+ else:
259
+ duration = f"{s}.{ms:03d}s"
233
260
 
234
- print(f"\\n{BOLD} Capture Statistics ───{RESET}")
261
+ print(f"\n{BOLD} Capture Statistics ───{RESET}")
235
262
  print(f" {BOLD}Duration:{RESET} {duration}")
236
263
  print(f" {BOLD}Total Packets:{RESET} {self.total_packets:,}")
237
264
  print(f" {BOLD}Data Volume:{RESET} {self.total_bytes / 1024:.2f} KB")
238
-
239
- print(f"\\n{BOLD}Packet Types:{RESET}")
265
+
266
+ print(f"\n{BOLD}Packet Types:{RESET}")
240
267
  for ptype, count in self.packets_by_type.most_common():
241
268
  print(f" {ptype:<15} {count:>8,}")
242
269
 
243
270
  if self.devices:
244
- print(f"\\n{BOLD}Detected Devices:{RESET}")
271
+ print(f"\n{BOLD}Detected Devices:{RESET}")
245
272
  for addr, name in self.devices.items():
246
273
  print(f" {CYAN}{addr}{RESET} {name}")
247
-
274
+
248
275
  if self.lifecycle_events:
249
- print(f"\\n{BOLD}Connection History:{RESET}")
276
+ print(f"\n{BOLD}Connection History:{RESET}")
250
277
  # Sort by timestamp just in case
251
278
  self.lifecycle_events.sort(key=lambda x: x["timestamp"])
252
279
 
@@ -265,7 +292,7 @@ class CaptureStats:
265
292
  print(f" {t_str} {c_evt}{etype:<20}{RESET} {handle} -> {details}")
266
293
 
267
294
  if self.issues:
268
- print(f"\\n{BOLD}Potential Issues ({len(self.issues)}):{RESET}")
295
+ print(f"\n{BOLD}Potential Issues ({len(self.issues)}):{RESET}")
269
296
  for issue in self.issues:
270
297
  color = RED if issue["level"] in ("ERROR", "CRITICAL") else YELLOW
271
298
  # Convert to local time and include date for context
@@ -277,4 +304,4 @@ class CaptureStats:
277
304
  t_str = local_ts.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
278
305
  print(f" {color}[{issue['level']}] {t_str} - {issue['title']}{RESET}: {issue['detail']}")
279
306
  else:
280
- print(f"\\n{BOLD}{GREEN}No obvious issues detected.{RESET}")
307
+ print(f"\n{BOLD}{GREEN}No obvious issues detected.{RESET}")
@@ -2,7 +2,7 @@ import argparse
2
2
  import json
3
3
  from typing import Any
4
4
 
5
- from .core import parse_btsnoop_file, print_table, slice_records
5
+ from .core import filter_records, parse_btsnoop_file, print_table, slice_records
6
6
  from .hci_decoder import decode_hci_packet
7
7
 
8
8
 
@@ -25,6 +25,18 @@ def main() -> None:
25
25
  parser = argparse.ArgumentParser(description="Parse Android btsnoop_hci.log captures.")
26
26
  parser.add_argument("file", help="Path to a btsnoop_hci.log file")
27
27
  parser.add_argument("--limit", type=int, help="Limit the number of records shown")
28
+ parser.add_argument(
29
+ "--filter",
30
+ dest="filters",
31
+ metavar="EXPR",
32
+ action="append",
33
+ default=[],
34
+ help=(
35
+ "Filter expression as key:value. May be repeated. "
36
+ "Supported keys: type (command/acl/event/sco/iso or 0xNN), dir (tx/rx). "
37
+ "Example: --filter type:event --filter dir:rx"
38
+ ),
39
+ )
28
40
  parser.add_argument(
29
41
  "--json",
30
42
  action="store_true",
@@ -45,6 +57,11 @@ def main() -> None:
45
57
  action="store_true",
46
58
  help="Analyze capture and show high-level statistics and issues",
47
59
  )
60
+ parser.add_argument(
61
+ "--pcap",
62
+ metavar="OUTPUT.pcap",
63
+ help="Write records to a PCAP file (Wireshark-compatible, link type 201)",
64
+ )
48
65
  parser.add_argument(
49
66
  "--no-color",
50
67
  action="store_true",
@@ -52,7 +69,26 @@ def main() -> None:
52
69
  )
53
70
  args = parser.parse_args()
54
71
 
55
- records = parse_btsnoop_file(args.file)
72
+ try:
73
+ records = parse_btsnoop_file(args.file)
74
+ except FileNotFoundError:
75
+ parser.error(f"File not found: {args.file}")
76
+ except PermissionError:
77
+ parser.error(f"Permission denied: {args.file}")
78
+
79
+ # Apply filters before any other processing
80
+ if args.filters:
81
+ try:
82
+ records = filter_records(records, args.filters)
83
+ except ValueError as exc:
84
+ parser.error(str(exc))
85
+
86
+ # --pcap: write and exit (can combine with --filter)
87
+ if args.pcap:
88
+ from .pcap import write_pcap
89
+ n = write_pcap(records, args.pcap)
90
+ print(f"Wrote {n} packets to {args.pcap}")
91
+ return
56
92
 
57
93
  if args.stats:
58
94
  from .analysis import CaptureStats
@@ -76,4 +112,3 @@ def main() -> None:
76
112
  for record in limited:
77
113
  decoded = decode_hci_packet(record["packet_type"], record["payload"])
78
114
  print(f"{record['index']:>4} {decoded}")
79
-
@@ -189,7 +189,7 @@ def iter_records(
189
189
  ts = ts_corrected
190
190
  # -----------------------------------------------------------
191
191
 
192
- except Exception as exc:
192
+ except (ValueError, OverflowError, OSError) as exc:
193
193
  LOG.error("Skipping record with invalid timestamp %s: %s", timestamp, exc)
194
194
  continue
195
195
 
@@ -288,3 +288,82 @@ def slice_records(records: Sequence[Mapping[str, object]], limit: Optional[int])
288
288
  if limit is None or limit >= len(records):
289
289
  return records
290
290
  return records[:limit]
291
+
292
+
293
+ # Normalised aliases accepted by filter_records for the "type" key.
294
+ _TYPE_ALIASES: dict[str, set[int]] = {
295
+ "command": {0x01},
296
+ "cmd": {0x01},
297
+ "acl": {0x02},
298
+ "sco": {0x03},
299
+ "event": {0x04},
300
+ "evt": {0x04},
301
+ "iso": {0x05},
302
+ }
303
+
304
+
305
+ def filter_records(
306
+ records: Sequence[Mapping[str, object]],
307
+ filters: Sequence[str],
308
+ ) -> list[Mapping[str, object]]:
309
+ """Return records that match *all* of the supplied filter expressions.
310
+
311
+ Each expression is a ``key:value`` string. Supported keys:
312
+
313
+ * ``type`` — packet type name or number, e.g. ``type:event``, ``type:acl``,
314
+ ``type:0x04``. Multiple values may be comma-separated:
315
+ ``type:command,event``.
316
+ * ``dir`` — direction: ``dir:tx`` or ``dir:rx``.
317
+
318
+ Examples::
319
+
320
+ filter_records(records, ["type:event"])
321
+ filter_records(records, ["type:command,event", "dir:tx"])
322
+ """
323
+ if not filters:
324
+ return list(records)
325
+
326
+ # Pre-parse filter expressions once
327
+ type_ids: Optional[set[int]] = None
328
+ dir_value: Optional[str] = None
329
+
330
+ for expr in filters:
331
+ if ":" not in expr:
332
+ raise ValueError(f"Invalid filter expression {expr!r} — expected 'key:value'")
333
+ key, _, value = expr.partition(":")
334
+ key = key.strip().lower()
335
+ value = value.strip().lower()
336
+
337
+ if key == "type":
338
+ ids: set[int] = set()
339
+ for part in value.split(","):
340
+ part = part.strip()
341
+ if part.startswith("0x"):
342
+ ids.add(int(part, 16))
343
+ elif part.isdigit():
344
+ ids.add(int(part))
345
+ elif part in _TYPE_ALIASES:
346
+ ids.update(_TYPE_ALIASES[part])
347
+ else:
348
+ raise ValueError(
349
+ f"Unknown packet type {part!r}. "
350
+ f"Valid values: {', '.join(sorted(_TYPE_ALIASES))}, or a hex/int."
351
+ )
352
+ type_ids = ids if type_ids is None else type_ids & ids
353
+
354
+ elif key == "dir":
355
+ if value not in ("tx", "rx"):
356
+ raise ValueError(f"Invalid dir value {value!r} — use 'tx' or 'rx'")
357
+ dir_value = value.upper()
358
+
359
+ else:
360
+ raise ValueError(f"Unknown filter key {key!r}. Supported keys: type, dir")
361
+
362
+ result = []
363
+ for record in records:
364
+ if type_ids is not None and record["packet_type"] not in type_ids:
365
+ continue
366
+ if dir_value is not None and record["direction"] != dir_value:
367
+ continue
368
+ result.append(record)
369
+ return result