66import logging
77from abc import ABC , abstractmethod
88from dataclasses import dataclass , fields
9- from typing import ClassVar , Self
9+ from typing import Any , ClassVar , Self
1010
11+ from roborock .callbacks import CallbackList
1112from roborock .data import RoborockBase
1213from roborock .protocols .v1_protocol import V1RpcChannel
1314from roborock .roborock_typing import RoborockCommand
15+ from roborock .devices .traits .common import TraitDataConverter , TraitUpdateListener
1416
1517_LOGGER = logging .getLogger (__name__ )
1618
1719V1ResponseData = dict | list | int | str
1820
21+ class V1TraitDataConverter :
22+ """Utility to handle the transformation and merging of data into models.
23+
24+ This has some special handling for v1 responses, then uses the common data
25+ converters for updating the target objects.
26+ """
27+
28+ def __init__ (self , dataclass_type : type [RoborockBase ]):
29+ """Initialize the converter for a specific RoborockBase-derived class."""
30+ self ._dataclass_type = dataclass_type
31+ self ._converter = TraitDataConverter (dataclass_type )
32+
33+ def update_from_data (self , target : RoborockBase , data : V1ResponseData ) -> None :
34+ """Update the target object from a dictionary of raw values.
35+
36+ Returns True if any values were updated.
37+ """
38+ response = self ._convert_to_dict (data )
39+ return self ._converter .update_from_dict (target , response )
40+
41+ def _convert_to_dict (self , response : V1ResponseData ) -> None :
42+ """Convert the values to a dict that can be parsed as a RoborockBase.
43+
44+ Subclasses can override to implement custom parsing logic
45+ """
46+ if isinstance (response , list ):
47+ response = response [0 ]
48+ if not isinstance (response , dict ):
49+ raise ValueError (f"Unexpected { self ._dataclass_type .__name__ } response format: { response !r} " )
50+ return response
51+
1952
20- @dataclass
2153class V1TraitMixin (ABC ):
2254 """Base model that supports v1 traits.
2355
@@ -42,38 +74,13 @@ class V1TraitMixin(ABC):
4274 """
4375
4476 command : ClassVar [RoborockCommand ]
77+ converter : ClassVar [V1TraitDataConverter ]
4578
46- @classmethod
47- def _parse_type_response (cls , response : V1ResponseData ) -> RoborockBase :
48- """Parse the response from the device into a a RoborockBase.
49-
50- Subclasses should override this method to implement custom parsing
51- logic as needed.
52- """
53- if not issubclass (cls , RoborockBase ):
54- raise NotImplementedError (f"Trait { cls } does not implement RoborockBase" )
55- # Subclasses can override to implement custom parsing logic
56- if isinstance (response , list ):
57- response = response [0 ]
58- if not isinstance (response , dict ):
59- raise ValueError (f"Unexpected { cls } response format: { response !r} " )
60- return cls .from_dict (response )
61-
62- def _parse_response (self , response : V1ResponseData ) -> RoborockBase :
63- """Parse the response from the device into a a RoborockBase.
64-
65- This is used by subclasses that want to override the class
66- behavior with instance-specific data.
67- """
68- return self ._parse_type_response (response )
69-
70- def __post_init__ (self ) -> None :
71- """Post-initialization to set up the RPC channel.
72-
73- This is called automatically after the dataclass is initialized by the
74- device setup code.
75- """
79+ def __init__ (self ) -> None :
80+ """Initialize the V1TraitMixin."""
7681 self ._rpc_channel = None
82+ self ._update_listener = TraitUpdateListener (_LOGGER )
83+
7784
7885 @property
7986 def rpc_channel (self ) -> V1RpcChannel :
@@ -85,17 +92,23 @@ def rpc_channel(self) -> V1RpcChannel:
8592 async def refresh (self ) -> None :
8693 """Refresh the contents of this trait."""
8794 response = await self .rpc_channel .send_command (self .command )
88- new_data = self ._parse_response (response )
89- if not isinstance (new_data , RoborockBase ):
90- raise ValueError (f"Internal error, unexpected response type: { new_data !r} " )
91- _LOGGER .debug ("Refreshed %s: %s" , self .__class__ .__name__ , new_data )
92- self ._update_trait_values (new_data )
95+ self ._update_data (response )
9396
94- def _update_trait_values (self , new_data : RoborockBase ) -> None :
95- """Update the values of this trait from another instance."""
96- for field in fields (new_data ):
97- new_value = getattr (new_data , field .name , None )
98- setattr (self , field .name , new_value )
97+ def _update_data (self , response : Any ) -> None :
98+ """Applies the new data to the object and notifies listeners.
99+
100+ This can be overridden by subclasses to change the behavior, but it is
101+ preferred to just use the `converter`.
102+ """
103+ if self .converter .update_from_data (self , response ):
104+ self ._update_listener ._notify_update ()
105+
106+ def add_update_listener (self , callback : Callable [[], None ]) -> Callable [[], None ]:
107+ """Register a callback when the trait has been updated.
108+
109+ Returns a callable to remove the listener.
110+ """
111+ self ._update_listener .add_update_listener (callback )
99112
100113
101114def _get_value_field (clazz : type [V1TraitMixin ]) -> str :
@@ -108,9 +121,7 @@ def _get_value_field(clazz: type[V1TraitMixin]) -> str:
108121 )
109122 return value_fields [0 ]
110123
111-
112- @dataclass (init = False , kw_only = True )
113- class RoborockValueBase (V1TraitMixin , RoborockBase ):
124+ class SingleValueConverter (V1TraitDataConverter ):
114125 """Base class for traits that represent a single value.
115126
116127 This class is intended to be subclassed by traits that represent a single
@@ -119,6 +130,11 @@ class RoborockValueBase(V1TraitMixin, RoborockBase):
119130 represents the main value of the trait.
120131 """
121132
133+ def __init__ (self , dataclass_type : type [RoborockBase ], value_field : str ) -> None :
134+ """Initialize the converter."""
135+ super ().__init__ (dataclass_type )
136+ self ._value_field = value_field
137+
122138 @classmethod
123139 def _parse_response (cls , response : V1ResponseData ) -> Self :
124140 """Parse the response from the device into a RoborockValueBase."""
@@ -127,8 +143,7 @@ def _parse_response(cls, response: V1ResponseData) -> Self:
127143 if not isinstance (response , int ):
128144 raise ValueError (f"Unexpected response format: { response !r} " )
129145 value_field = _get_value_field (cls )
130- return cls (** {value_field : response })
131-
146+ return cls (** {self .__value_field : response })
132147
133148class RoborockSwitchBase (ABC ):
134149 """Base class for traits that represent a boolean switch."""
0 commit comments