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

80 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +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.encode_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: int | 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 if isinstance(value, float): 

47 formatted = f"{value:.{precision}f}" 

48 else: 

49 formatted = str(value) 

50 

51 if unit: 

52 return f"{formatted} {unit}" 

53 return formatted 

54 

55 @staticmethod 

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

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

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

59 

60 @staticmethod 

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

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

63 flags: list[str] = [] 

64 for i, name in enumerate(bit_names): 

65 if BitFieldUtils.test_bit(value, i): 

66 flags.append(name) 

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

68 

69 @staticmethod 

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

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

72 expected_size = struct.calcsize(format_string) 

73 actual_size = len(data) 

74 if actual_size != expected_size: 

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

76 

77 @staticmethod 

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

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

80 

81 Args: 

82 error: The ParseFieldError to format 

83 data: Complete raw data for context 

84 

85 Returns: 

86 Formatted error message with hex dump and field context 

87 

88 """ 

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

90 

91 if error.offset is not None: 

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

93 

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

95 if error.raw_slice: 

96 hex_dump = DebugUtils.format_hex_data(error.raw_slice) 

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

98 elif data: 

99 # Show context around the error 

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

101 # Show a few bytes before and after the offset 

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

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

104 context_slice = data[start:end] 

105 hex_dump = DebugUtils.format_hex_data(context_slice) 

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

107 else: 

108 # Show all data if offset not available 

109 hex_dump = DebugUtils.format_hex_data(data) 

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

111 

112 return " | ".join(parts) 

113 

114 @staticmethod 

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

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

117 

118 Args: 

119 errors: List of ParseFieldError objects 

120 data: Complete raw data for context 

121 

122 Returns: 

123 Formatted string with all field errors and context 

124 

125 """ 

126 if not errors: 

127 return "No field errors" 

128 

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

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

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

132 

133 return "\n".join(lines) 

134 

135 @staticmethod 

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

137 """Format parse trace as readable steps. 

138 

139 Args: 

140 trace: List of parse trace entries 

141 

142 Returns: 

143 Formatted trace string 

144 

145 """ 

146 if not trace: 

147 return "No parse trace available" 

148 

149 lines = ["Parse trace:"] 

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

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

152 

153 return "\n".join(lines)