btsnoop-parser 0.2.2__tar.gz → 0.3.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.2.2 → btsnoop_parser-0.3.0}/PKG-INFO +2 -3
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/analysis.py +64 -37
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/cli.py +6 -1
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/core.py +1 -1
- btsnoop_parser-0.3.0/btsnoop_parser/utils.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/PKG-INFO +2 -3
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/SOURCES.txt +1 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/pyproject.toml +2 -3
- btsnoop_parser-0.3.0/tests/test_analysis.py +164 -0
- btsnoop_parser-0.2.2/btsnoop_parser/utils.py +0 -2
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/LICENSE +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/README.md +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/__init__.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/__main__.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/constants.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/hci_decoder.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/dependency_links.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/entry_points.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/requires.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/top_level.txt +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/setup.cfg +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/tests/test_cli.py +0 -0
- {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/tests/test_parser.py +0 -0
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: btsnoop-parser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Parse and explore Bluetooth HCI btsnoop captures from Android devices.
|
|
5
5
|
Author-email: Kranthi <kdevprofile@gmail.com>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/devk-op/btsnoop-parser
|
|
8
8
|
Project-URL: Repository, https://github.com/devk-op/btsnoop-parser
|
|
9
9
|
Project-URL: Issues, https://github.com/devk-op/btsnoop-parser/issues
|
|
10
10
|
Keywords: bluetooth,btsnoop,hci,android,parser
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -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}")
|
|
@@ -52,7 +52,12 @@ def main() -> None:
|
|
|
52
52
|
)
|
|
53
53
|
args = parser.parse_args()
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
try:
|
|
56
|
+
records = parse_btsnoop_file(args.file)
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
parser.error(f"File not found: {args.file}")
|
|
59
|
+
except PermissionError:
|
|
60
|
+
parser.error(f"Permission denied: {args.file}")
|
|
56
61
|
|
|
57
62
|
if args.stats:
|
|
58
63
|
from .analysis import CaptureStats
|
|
@@ -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
|
|
|
File without changes
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: btsnoop-parser
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Parse and explore Bluetooth HCI btsnoop captures from Android devices.
|
|
5
5
|
Author-email: Kranthi <kdevprofile@gmail.com>
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/devk-op/btsnoop-parser
|
|
8
8
|
Project-URL: Repository, https://github.com/devk-op/btsnoop-parser
|
|
9
9
|
Project-URL: Issues, https://github.com/devk-op/btsnoop-parser/issues
|
|
10
10
|
Keywords: bluetooth,btsnoop,hci,android,parser
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.9
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "btsnoop-parser"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Parse and explore Bluetooth HCI btsnoop captures from Android devices."
|
|
5
5
|
authors = [{ name = "Kranthi", email = "kdevprofile@gmail.com" }]
|
|
6
|
-
license =
|
|
6
|
+
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.9"
|
|
9
9
|
keywords = ["bluetooth", "btsnoop", "hci", "android", "parser"]
|
|
10
10
|
classifiers = [
|
|
11
11
|
"Development Status :: 4 - Beta",
|
|
12
12
|
"Intended Audience :: Developers",
|
|
13
|
-
"License :: OSI Approved :: MIT License",
|
|
14
13
|
"Programming Language :: Python :: 3",
|
|
15
14
|
"Programming Language :: Python :: 3.9",
|
|
16
15
|
"Programming Language :: Python :: 3.10",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Tests for the CaptureStats analysis module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import struct
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from btsnoop_parser.analysis import CaptureStats
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_UTC = datetime.timezone.utc
|
|
12
|
+
_BASE_TS = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=_UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _record(payload: bytes, ts: datetime.datetime = _BASE_TS) -> dict:
|
|
16
|
+
"""Build a minimal HCI EVENT record."""
|
|
17
|
+
return {
|
|
18
|
+
"packet_type": 0x04,
|
|
19
|
+
"packet_type_name": "EVENT",
|
|
20
|
+
"payload": payload,
|
|
21
|
+
"timestamp": ts,
|
|
22
|
+
"original_length": len(payload),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _disconnect_payload(handle: int, reason: int, status: int = 0x00) -> bytes:
|
|
27
|
+
# [Event(0x05), Len, Status, HandleLSB, HandleMSB, Reason]
|
|
28
|
+
return bytes([0x05, 0x04, status, handle & 0xFF, (handle >> 8) & 0xFF, reason])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _le_conn_complete_payload(status: int, handle: int = 0x001, addr: bytes = b"\x11\x22\x33\x44\x55\x66") -> bytes:
|
|
32
|
+
# [Event(0x3E), Len, Subevent(0x01), Status, HandleLSB, HandleMSB, Role, AddrType, Addr(6)]
|
|
33
|
+
return bytes([0x3E, 0x0D, 0x01, status, handle & 0xFF, (handle >> 8) & 0xFF, 0x00, 0x00]) + addr
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _classic_conn_complete_payload(status: int, handle: int = 0x001, addr: bytes = b"\x11\x22\x33\x44\x55\x66") -> bytes:
|
|
37
|
+
# [Event(0x03), Len, Status, HandleLSB, HandleMSB, Addr(6), LinkType, EncMode]
|
|
38
|
+
return bytes([0x03, 0x0B, status, handle & 0xFF, (handle >> 8) & 0xFF]) + addr + bytes([0x01, 0x00])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cmd_complete_payload(opcode: int, status: int) -> bytes:
|
|
42
|
+
# [Event(0x0E), Len, NumCmds, OpcodeLSB, OpcodeMSB, Status]
|
|
43
|
+
return bytes([0x0E, 0x04, 0x01, opcode & 0xFF, (opcode >> 8) & 0xFF, status])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestDisconnection(unittest.TestCase):
|
|
47
|
+
def _stats(self, reason: int) -> CaptureStats:
|
|
48
|
+
s = CaptureStats()
|
|
49
|
+
s.analyze_record(_record(_disconnect_payload(0x001, reason)))
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
def test_normal_remote_disconnect_no_issue(self):
|
|
53
|
+
s = self._stats(0x13) # Remote User Terminated
|
|
54
|
+
self.assertEqual(len(s.issues), 0)
|
|
55
|
+
self.assertFalse(s.lifecycle_events[0]["is_error"])
|
|
56
|
+
|
|
57
|
+
def test_normal_local_disconnect_no_issue(self):
|
|
58
|
+
s = self._stats(0x16) # Local Host Terminated
|
|
59
|
+
self.assertEqual(len(s.issues), 0)
|
|
60
|
+
|
|
61
|
+
def test_power_off_disconnect_not_abnormal(self):
|
|
62
|
+
"""0x15 (Remote Power Off) should not be flagged as abnormal."""
|
|
63
|
+
s = self._stats(0x15)
|
|
64
|
+
self.assertEqual(len(s.issues), 0)
|
|
65
|
+
self.assertFalse(s.lifecycle_events[0]["is_error"])
|
|
66
|
+
|
|
67
|
+
def test_low_resources_disconnect_not_abnormal(self):
|
|
68
|
+
"""0x14 (Remote Low Resources) should not be flagged as abnormal."""
|
|
69
|
+
s = self._stats(0x14)
|
|
70
|
+
self.assertEqual(len(s.issues), 0)
|
|
71
|
+
|
|
72
|
+
def test_unexpected_disconnect_raises_issue(self):
|
|
73
|
+
s = self._stats(0x08) # Connection Timeout
|
|
74
|
+
self.assertEqual(len(s.issues), 1)
|
|
75
|
+
self.assertEqual(s.issues[0]["title"], "Abnormal Disconnect")
|
|
76
|
+
self.assertTrue(s.lifecycle_events[0]["is_error"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestLEConnectionFailed(unittest.TestCase):
|
|
80
|
+
def test_failed_le_connection_appears_in_lifecycle(self):
|
|
81
|
+
"""A failed LE connection should appear in lifecycle_events, not just issues."""
|
|
82
|
+
s = CaptureStats()
|
|
83
|
+
s.analyze_record(_record(_le_conn_complete_payload(status=0x02))) # 0x02 = No Connection
|
|
84
|
+
self.assertEqual(len(s.lifecycle_events), 1)
|
|
85
|
+
self.assertEqual(s.lifecycle_events[0]["event"], "Connect Failed (LE)")
|
|
86
|
+
self.assertTrue(s.lifecycle_events[0]["is_error"])
|
|
87
|
+
self.assertEqual(len(s.issues), 1)
|
|
88
|
+
|
|
89
|
+
def test_successful_le_connection_no_issue(self):
|
|
90
|
+
s = CaptureStats()
|
|
91
|
+
s.analyze_record(_record(_le_conn_complete_payload(status=0x00)))
|
|
92
|
+
self.assertEqual(len(s.issues), 0)
|
|
93
|
+
self.assertEqual(s.lifecycle_events[0]["event"], "Connected (LE)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestClassicConnectionFailed(unittest.TestCase):
|
|
97
|
+
def test_failed_classic_connection_appears_in_lifecycle(self):
|
|
98
|
+
s = CaptureStats()
|
|
99
|
+
s.analyze_record(_record(_classic_conn_complete_payload(status=0x04))) # Page Timeout
|
|
100
|
+
self.assertEqual(len(s.lifecycle_events), 1)
|
|
101
|
+
self.assertEqual(s.lifecycle_events[0]["event"], "Connect Failed (Classic)")
|
|
102
|
+
self.assertTrue(s.lifecycle_events[0]["is_error"])
|
|
103
|
+
self.assertEqual(len(s.issues), 1)
|
|
104
|
+
|
|
105
|
+
def test_successful_classic_connection_no_issue(self):
|
|
106
|
+
s = CaptureStats()
|
|
107
|
+
s.analyze_record(_record(_classic_conn_complete_payload(status=0x00)))
|
|
108
|
+
self.assertEqual(len(s.issues), 0)
|
|
109
|
+
self.assertEqual(s.lifecycle_events[0]["event"], "Connected (Classic)")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestCommandFailure(unittest.TestCase):
|
|
113
|
+
def test_command_failure_uses_opcode_name(self):
|
|
114
|
+
"""Command errors should show the opcode name, not raw hex."""
|
|
115
|
+
s = CaptureStats()
|
|
116
|
+
# 0x0C03 = Reset
|
|
117
|
+
s.analyze_record(_record(_cmd_complete_payload(opcode=0x0C03, status=0x01)))
|
|
118
|
+
self.assertEqual(len(s.issues), 1)
|
|
119
|
+
self.assertIn("Reset", s.issues[0]["detail"])
|
|
120
|
+
self.assertNotIn("0x0C03", s.issues[0]["detail"])
|
|
121
|
+
|
|
122
|
+
def test_unknown_opcode_falls_back_to_hex(self):
|
|
123
|
+
s = CaptureStats()
|
|
124
|
+
s.analyze_record(_record(_cmd_complete_payload(opcode=0xFFFF, status=0x01)))
|
|
125
|
+
self.assertIn("0xFFFF", s.issues[0]["detail"])
|
|
126
|
+
|
|
127
|
+
def test_successful_command_no_issue(self):
|
|
128
|
+
s = CaptureStats()
|
|
129
|
+
s.analyze_record(_record(_cmd_complete_payload(opcode=0x0C03, status=0x00)))
|
|
130
|
+
self.assertEqual(len(s.issues), 0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestDurationFormat(unittest.TestCase):
|
|
134
|
+
def _stats_with_duration(self, seconds: float) -> CaptureStats:
|
|
135
|
+
s = CaptureStats()
|
|
136
|
+
s.start_time = _BASE_TS
|
|
137
|
+
s.end_time = _BASE_TS + datetime.timedelta(seconds=seconds)
|
|
138
|
+
return s
|
|
139
|
+
|
|
140
|
+
def test_seconds_only(self):
|
|
141
|
+
s = self._stats_with_duration(5.456)
|
|
142
|
+
# Trigger format by calling print_summary via capturing output
|
|
143
|
+
import io, unittest.mock
|
|
144
|
+
with unittest.mock.patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
|
145
|
+
s.print_summary()
|
|
146
|
+
self.assertIn("5.456s", mock_out.getvalue())
|
|
147
|
+
|
|
148
|
+
def test_minutes_and_seconds(self):
|
|
149
|
+
s = self._stats_with_duration(125.0)
|
|
150
|
+
import io, unittest.mock
|
|
151
|
+
with unittest.mock.patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
|
152
|
+
s.print_summary()
|
|
153
|
+
self.assertIn("2m", mock_out.getvalue())
|
|
154
|
+
|
|
155
|
+
def test_hours_minutes_seconds(self):
|
|
156
|
+
s = self._stats_with_duration(3661.0)
|
|
157
|
+
import io, unittest.mock
|
|
158
|
+
with unittest.mock.patch("sys.stdout", new_callable=io.StringIO) as mock_out:
|
|
159
|
+
s.print_summary()
|
|
160
|
+
self.assertIn("1h", mock_out.getvalue())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|