Coverage for src / bluetooth_sig / types / appearance.py: 97%

32 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

1"""Appearance value types for Bluetooth device identification.""" 

2 

3from __future__ import annotations 

4 

5import msgspec 

6 

7from bluetooth_sig.registry.core.appearance_values import appearance_values_registry 

8from bluetooth_sig.types.registry.appearance_info import AppearanceInfo 

9 

10 

11class AppearanceData(msgspec.Struct, frozen=True, kw_only=True): 

12 """Appearance characteristic data with human-readable info. 

13 

14 Attributes: 

15 raw_value: Raw 16-bit appearance code from BLE 

16 info: Optional decoded appearance information from registry 

17 """ 

18 

19 raw_value: int 

20 info: AppearanceInfo | None = None 

21 

22 @classmethod 

23 def from_category(cls, category: str, subcategory: str | None = None) -> AppearanceData: 

24 """Create AppearanceData from category and subcategory strings. 

25 

26 This helper validates the strings and finds the correct raw_value by 

27 searching the registry. Useful when creating appearance data from 

28 user-provided human-readable names. 

29 

30 Args: 

31 category: Device category name (e.g., "Heart Rate Sensor") 

32 subcategory: Optional subcategory name (e.g., "Heart Rate Belt") 

33 

34 Returns: 

35 AppearanceData with validated info and correct raw_value 

36 

37 Raises: 

38 ValueError: If category/subcategory combination is not found in registry 

39 

40 Example: 

41 >>> data = AppearanceData.from_category("Heart Rate Sensor", "Heart Rate Belt") 

42 >>> data.raw_value 

43 833 

44 """ 

45 # Use public method to find appearance info 

46 info = appearance_values_registry.find_by_category_subcategory(category, subcategory) 

47 

48 if info is None: 

49 # If not found, raise error 

50 if subcategory: 

51 raise ValueError(f"Unknown appearance: {category}: {subcategory}") 

52 raise ValueError(f"Unknown appearance: {category}") 

53 

54 # Calculate raw_value from category and subcategory values 

55 subcategory_value = info.subcategory.value if info.subcategory else 0 

56 raw_value = (info.category_value << 6) | subcategory_value 

57 return cls(raw_value=raw_value, info=info) 

58 

59 @property 

60 def category(self) -> str | None: 

61 """Get device category name. 

62 

63 Returns: 

64 Category name or None if info not available 

65 """ 

66 return self.info.category if self.info else None 

67 

68 @property 

69 def subcategory(self) -> str | None: 

70 """Get device subcategory name. 

71 

72 Returns: 

73 Subcategory name or None if not available 

74 """ 

75 if self.info and self.info.subcategory: 

76 return self.info.subcategory.name 

77 return None 

78 

79 @property 

80 def full_name(self) -> str | None: 

81 """Get full human-readable name. 

82 

83 Returns: 

84 Full device type name or None if info not available 

85 """ 

86 return self.info.full_name if self.info else None 

87 

88 def __int__(self) -> int: 

89 """Allow casting to int for backward compatibility. 

90 

91 Returns: 

92 Raw appearance value as integer 

93 """ 

94 return self.raw_value 

95 

96 def __format__(self, format_spec: str) -> str: 

97 """Format appearance value for backward compatibility. 

98 

99 Supports hex formatting for backward compatibility with code that 

100 used raw int appearance values: f"{appearance:04X}" 

101 

102 Args: 

103 format_spec: Format specification (e.g., "04X" for zero-padded uppercase hex) 

104 

105 Returns: 

106 Formatted raw_value as string 

107 

108 Example: 

109 >>> data = AppearanceData(raw_value=833, info=None) 

110 >>> f"{data:04X}" 

111 '0341' 

112 """ 

113 return format(self.raw_value, format_spec)