Coverage for src / bluetooth_sig / gatt / characteristics / utils / debug_utils.py: 72%

78 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Debug utility methods for characteristic parsing.""" 

2 

3from __future__ import annotations 

4 

5import struct 

6from typing import Any 

7 

8from bluetooth_sig.types.data_types import ParseFieldError 

9from bluetooth_sig.types.protocols import CharacteristicProtocol 

10 

11from .bit_field_utils import BitFieldUtils 

12 

13 

14class DebugUtils: 

15 """Utility class for debugging and testing support.""" 

16 

17 # Format constants 

18 HEX_FORMAT_WIDTH = 2 

19 DEFAULT_PRECISION = 2 

20 

21 @staticmethod 

22 def format_hex_dump(data: bytearray) -> str: 

23 """Format data as a hex dump for debugging.""" 

24 return " ".join(f"{byte:02X}" for byte in data) # HEX_FORMAT_WIDTH = 2 

25 

26 @staticmethod 

27 def validate_round_trip(characteristic: CharacteristicProtocol, original_data: bytearray) -> bool: 

28 """Validate that parse/encode operations preserve data integrity.""" 

29 try: 

30 parsed = characteristic.parse_value(original_data) 

31 encoded = characteristic.build_value(parsed) 

32 return bool(original_data == encoded) 

33 except Exception: # pylint: disable=broad-except 

34 return False 

35 

36 @staticmethod 

37 def format_measurement_value( 

38 value: float | str | None, 

39 unit: str | None = None, 

40 precision: int = 2, # DebugUtils.DEFAULT_PRECISION, 

41 ) -> str: 

42 """Format measurement value with unit for display.""" 

43 if value is None: 

44 return "N/A" 

45 

46 formatted = f"{value:.{precision}f}" if isinstance(value, float) else str(value) 

47 

48 if unit: 

49 return f"{formatted} {unit}" 

50 return formatted 

51 

52 @staticmethod 

53 def format_hex_data(data: bytes | bytearray, separator: str = " ") -> str: 

54 """Format binary data as hex string.""" 

55 return separator.join(f"{byte:02X}" for byte in data) # HEX_FORMAT_WIDTH = 2 

56 

57 @staticmethod 

58 def format_binary_flags(value: int, bit_names: list[str]) -> str: 

59 """Format integer as binary flags with names.""" 

60 flags: list[str] = [] 

61 for i, name in enumerate(bit_names): 

62 if BitFieldUtils.test_bit(value, i): 

63 flags.append(name) 

64 return ", ".join(flags) if flags else "None" 

65 

66 @staticmethod 

67 def validate_struct_format(data: bytes | bytearray, format_string: str) -> None: 

68 """Validate data length matches struct format requirements.""" 

69 expected_size = struct.calcsize(format_string) 

70 actual_size = len(data) 

71 if actual_size != expected_size: 

72 raise ValueError(f"Data size {actual_size} doesn't match format '{format_string}' size {expected_size}") 

73 

74 @staticmethod 

75 def format_field_error(error: ParseFieldError, data: bytes | bytearray) -> str: 

76 """Format a field-level parsing error with context. 

77 

78 Args: 

79 error: The ParseFieldError to format 

80 data: Complete raw data for context 

81 

82 Returns: 

83 Formatted error message with hex dump and field context 

84 

85 """ 

86 parts = [f"Field '{error.field}' failed: {error.reason}"] 

87 

88 if error.offset is not None: 

89 parts.append(f"Offset: {error.offset}") 

90 

91 # Show hex dump of the problematic slice or full data 

92 if error.raw_slice: 

93 hex_dump = DebugUtils.format_hex_data(error.raw_slice) 

94 parts.append(f"Data: [{hex_dump}]") 

95 elif data: 

96 # Show context around the error 

97 if error.offset is not None and error.offset < len(data): 

98 # Show a few bytes before and after the offset 

99 start = max(0, error.offset - 2) 

100 end = min(len(data), error.offset + 3) 

101 context_slice = data[start:end] 

102 hex_dump = DebugUtils.format_hex_data(context_slice) 

103 parts.append(f"Context at offset {start}: [{hex_dump}]") 

104 else: 

105 # Show all data if offset not available 

106 hex_dump = DebugUtils.format_hex_data(data) 

107 parts.append(f"Full data: [{hex_dump}]") 

108 

109 return " | ".join(parts) 

110 

111 @staticmethod 

112 def format_field_errors(errors: list[Any], data: bytes | bytearray) -> str: 

113 """Format multiple field errors into a readable message. 

114 

115 Args: 

116 errors: List of ParseFieldError objects 

117 data: Complete raw data for context 

118 

119 Returns: 

120 Formatted string with all field errors and context 

121 

122 """ 

123 if not errors: 

124 return "No field errors" 

125 

126 lines = [f"Found {len(errors)} field error(s):"] 

127 for i, error in enumerate(errors, 1): 

128 lines.append(f" {i}. {DebugUtils.format_field_error(error, data)}") 

129 

130 return "\n".join(lines) 

131 

132 @staticmethod 

133 def format_parse_trace(trace: list[str]) -> str: 

134 """Format parse trace as readable steps. 

135 

136 Args: 

137 trace: List of parse trace entries 

138 

139 Returns: 

140 Formatted trace string 

141 

142 """ 

143 if not trace: 

144 return "No parse trace available" 

145 

146 lines = ["Parse trace:"] 

147 for i, step in enumerate(trace, 1): 

148 lines.append(f" {i}. {step}") 

149 

150 return "\n".join(lines)