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.
Files changed (23) hide show
  1. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/PKG-INFO +2 -3
  2. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/analysis.py +64 -37
  3. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/cli.py +6 -1
  4. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/core.py +1 -1
  5. btsnoop_parser-0.3.0/btsnoop_parser/utils.py +0 -0
  6. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/PKG-INFO +2 -3
  7. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/SOURCES.txt +1 -0
  8. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/pyproject.toml +2 -3
  9. btsnoop_parser-0.3.0/tests/test_analysis.py +164 -0
  10. btsnoop_parser-0.2.2/btsnoop_parser/utils.py +0 -2
  11. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/LICENSE +0 -0
  12. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/README.md +0 -0
  13. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/__init__.py +0 -0
  14. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/__main__.py +0 -0
  15. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/constants.py +0 -0
  16. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser/hci_decoder.py +0 -0
  17. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/dependency_links.txt +0 -0
  18. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/entry_points.txt +0 -0
  19. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/requires.txt +0 -0
  20. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/btsnoop_parser.egg-info/top_level.txt +0 -0
  21. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/setup.cfg +0 -0
  22. {btsnoop_parser-0.2.2 → btsnoop_parser-0.3.0}/tests/test_cli.py +0 -0
  23. {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.2.2
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 (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}")
@@ -52,7 +52,12 @@ def main() -> None:
52
52
  )
53
53
  args = parser.parse_args()
54
54
 
55
- records = parse_btsnoop_file(args.file)
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 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
 
File without changes
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: btsnoop-parser
3
- Version: 0.2.2
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
@@ -15,5 +15,6 @@ btsnoop_parser.egg-info/dependency_links.txt
15
15
  btsnoop_parser.egg-info/entry_points.txt
16
16
  btsnoop_parser.egg-info/requires.txt
17
17
  btsnoop_parser.egg-info/top_level.txt
18
+ tests/test_analysis.py
18
19
  tests/test_cli.py
19
20
  tests/test_parser.py
@@ -1,16 +1,15 @@
1
1
  [project]
2
2
  name = "btsnoop-parser"
3
- version = "0.2.2"
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 = { text = "MIT" }
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()
@@ -1,2 +0,0 @@
1
- def hexify(data):
2
- return ' '.join(f'{b:02X}' for b in data)
File without changes
File without changes
File without changes