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.
- btsnoop_parser-0.4.0/PKG-INFO +164 -0
- btsnoop_parser-0.4.0/README.md +135 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/__init__.py +4 -1
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/analysis.py +64 -37
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/cli.py +38 -3
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/core.py +80 -1
- btsnoop_parser-0.4.0/btsnoop_parser/pcap.py +99 -0
- btsnoop_parser-0.4.0/btsnoop_parser/utils.py +0 -0
- btsnoop_parser-0.4.0/btsnoop_parser.egg-info/PKG-INFO +164 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/SOURCES.txt +3 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/pyproject.toml +2 -3
- btsnoop_parser-0.4.0/tests/test_analysis.py +164 -0
- btsnoop_parser-0.4.0/tests/test_filter_pcap.py +169 -0
- btsnoop_parser-0.2.2/PKG-INFO +0 -118
- btsnoop_parser-0.2.2/README.md +0 -88
- btsnoop_parser-0.2.2/btsnoop_parser/utils.py +0 -2
- btsnoop_parser-0.2.2/btsnoop_parser.egg-info/PKG-INFO +0 -118
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/LICENSE +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/__main__.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/constants.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser/hci_decoder.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/dependency_links.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/entry_points.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/requires.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/btsnoop_parser.egg-info/top_level.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/setup.cfg +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.4.0}/tests/test_cli.py +0 -0
- {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
|
+
[](https://github.com/devk-op/btsnoop-parser/actions?query=workflow%3A"CI%2FCD+Pipeline")
|
|
33
|
+
[](https://pypi.org/project/btsnoop-parser/)
|
|
34
|
+
[](https://btsnoop-parser.readthedocs.io)
|
|
35
|
+
[](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
|
+
[](https://github.com/devk-op/btsnoop-parser/actions?query=workflow%3A"CI%2FCD+Pipeline")
|
|
4
|
+
[](https://pypi.org/project/btsnoop-parser/)
|
|
5
|
+
[](https://btsnoop-parser.readthedocs.io)
|
|
6
|
+
[](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
|
|
86
|
+
"is_error": reason not in _normal_reasons,
|
|
84
87
|
})
|
|
85
|
-
|
|
86
|
-
if reason not in
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|