Coverage for src / bluetooth_sig / gatt / characteristics / templates / string.py: 95%

66 statements  

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

1"""String templates for UTF-8 and UTF-16LE variable-length parsing. 

2 

3Covers Utf8StringTemplate and Utf16StringTemplate. 

4""" 

5 

6from __future__ import annotations 

7 

8from ...context import CharacteristicContext 

9from .base import CodingTemplate 

10 

11 

12class Utf8StringTemplate(CodingTemplate[str]): 

13 """Template for UTF-8 string parsing with variable length.""" 

14 

15 def __init__(self, max_length: int = 256) -> None: 

16 """Initialize with maximum string length. 

17 

18 Args: 

19 max_length: Maximum string length in bytes 

20 

21 """ 

22 self.max_length = max_length 

23 

24 @property 

25 def data_size(self) -> int: 

26 """Size: Variable (0 to max_length).""" 

27 return self.max_length 

28 

29 def decode_value( 

30 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

31 ) -> str: 

32 """Parse UTF-8 string from remaining data.""" 

33 if offset >= len(data): 

34 return "" 

35 

36 # Take remaining data from offset 

37 string_data = data[offset:] 

38 

39 # Remove null terminator if present 

40 if b"\x00" in string_data: 

41 null_index = string_data.index(b"\x00") 

42 string_data = string_data[:null_index] 

43 

44 try: 

45 return string_data.decode("utf-8") 

46 except UnicodeDecodeError as e: 

47 if validate: 

48 raise ValueError(f"Invalid UTF-8 string data: {e}") from e 

49 return "" 

50 

51 def encode_value(self, value: str, *, validate: bool = True) -> bytearray: 

52 """Encode string to UTF-8 bytes.""" 

53 encoded = value.encode("utf-8") 

54 if validate and len(encoded) > self.max_length: 

55 raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") 

56 return bytearray(encoded) 

57 

58 

59class Utf16StringTemplate(CodingTemplate[str]): 

60 """Template for UTF-16LE string parsing with variable length.""" 

61 

62 # Unicode constants for UTF-16 validation 

63 UNICODE_SURROGATE_START = 0xD800 

64 UNICODE_SURROGATE_END = 0xDFFF 

65 UNICODE_BOM = "\ufeff" 

66 

67 def __init__(self, max_length: int = 256) -> None: 

68 """Initialize with maximum string length. 

69 

70 Args: 

71 max_length: Maximum string length in bytes (must be even) 

72 

73 """ 

74 if max_length % 2 != 0: 

75 raise ValueError("max_length must be even for UTF-16 strings") 

76 self.max_length = max_length 

77 

78 @property 

79 def data_size(self) -> int: 

80 """Size: Variable (0 to max_length, even bytes only).""" 

81 return self.max_length 

82 

83 def decode_value( 

84 self, data: bytearray, offset: int = 0, ctx: CharacteristicContext | None = None, *, validate: bool = True 

85 ) -> str: 

86 """Parse UTF-16LE string from remaining data.""" 

87 if offset >= len(data): 

88 return "" 

89 

90 # Take remaining data from offset 

91 string_data = data[offset:] 

92 

93 # Find null terminator at even positions (UTF-16 alignment) 

94 null_index = len(string_data) 

95 for i in range(0, len(string_data) - 1, 2): 

96 if string_data[i : i + 2] == bytearray(b"\x00\x00"): 

97 null_index = i 

98 break 

99 string_data = string_data[:null_index] 

100 

101 # UTF-16 requires even number of bytes 

102 if validate and len(string_data) % 2 != 0: 

103 raise ValueError(f"UTF-16 data must have even byte count, got {len(string_data)}") 

104 

105 try: 

106 decoded = string_data.decode("utf-16-le") 

107 # Strip BOM if present (robustness) 

108 if decoded.startswith(self.UNICODE_BOM): 

109 decoded = decoded[1:] 

110 # Check for invalid surrogate pairs 

111 if validate and any(self.UNICODE_SURROGATE_START <= ord(c) <= self.UNICODE_SURROGATE_END for c in decoded): 

112 raise ValueError("Invalid UTF-16LE string data: contains unpaired surrogates") 

113 except UnicodeDecodeError as e: 

114 if validate: 

115 raise ValueError(f"Invalid UTF-16LE string data: {e}") from e 

116 return "" 

117 else: 

118 return decoded 

119 

120 def encode_value(self, value: str, *, validate: bool = True) -> bytearray: 

121 """Encode string to UTF-16LE bytes.""" 

122 encoded = value.encode("utf-16-le") 

123 if validate and len(encoded) > self.max_length: 

124 raise ValueError(f"String too long: {len(encoded)} > {self.max_length}") 

125 return bytearray(encoded)