roborock.devices.traits.v1.customize_clean_mode
Trait for per-room (segment) cleaning mode overrides on Roborock devices.
Rooms without an override are simply absent from modes - the robot falls
back to the global settings (fan power, water box mode, mop mode and
clean-then-mop) for those. An empty list means no room currently has one.
A per-room override set via set_clean_then_mop is persisted on the device:
it is a preference attached to that room and is re-applied whenever the room
is cleaned, until overwritten or cleared from the vendor app.
1"""Trait for per-room (segment) cleaning mode overrides on Roborock devices. 2 3Rooms without an override are simply absent from `modes` - the robot falls 4back to the global settings (fan power, water box mode, mop mode and 5clean-then-mop) for those. An empty list means no room currently has one. 6 7A per-room override set via `set_clean_then_mop` is persisted on the device: 8it is a preference attached to that room and is re-applied whenever the room 9is cleaned, until overwritten or cleared from the vendor app. 10""" 11 12import asyncio 13 14from roborock.data import CustomizeCleanModes, SegmentCleanMode 15from roborock.data.v1.v1_clean_modes import CleanRoutes, VacuumModes, WaterModes 16from roborock.data.v1.v1_containers import SEQ_TYPE_CLEAN_THEN_MOP 17from roborock.devices.traits.v1 import common 18from roborock.devices.traits.v1.device_features import DeviceFeaturesTrait 19from roborock.exceptions import RoborockParsingException, RoborockUnsupportedFeature 20from roborock.roborock_typing import RoborockCommand 21 22from .common import V1ResponseData 23 24_WRITE_KEYS = ("segment", "fan_power", "water_box_mode", "mop_mode", "repeat", "seq_type") 25 26 27class CustomizeCleanModeConverter(common.V1TraitDataConverter): 28 """Converts get_customize_clean_mode responses to CustomizeCleanModes.""" 29 30 def convert(self, response: V1ResponseData) -> CustomizeCleanModes: 31 """Parse the response from the device into a CustomizeCleanModes.""" 32 if not isinstance(response, list) or not all(isinstance(entry, dict) for entry in response): 33 raise ValueError(f"Unexpected CustomizeCleanModes response format: {response!r}") 34 return CustomizeCleanModes(modes=[SegmentCleanMode.from_dict(entry) for entry in response]) 35 36 37class CustomizeCleanModeTrait(CustomizeCleanModes, common.V1TraitMixin): 38 """Trait for reading and writing per-room clean mode overrides. 39 40 Gated on `is_customized_clean_supported` - the feature that covers 41 `get/set_customize_clean_mode` in general, not `is_clean_then_mop_mode_supported` 42 (which only gates the narrower `seq_type` axis within it). A device can 43 support per-room custom modes without supporting clean-then-mop 44 specifically; on such a device `SegmentCleanMode.seq_type` (and therefore 45 `.clean_then_mop`) is simply `None` for every room, same as when the 46 device never reports it. `set_clean_then_mop` additionally checks 47 `is_clean_then_mop_mode_supported` itself before writing, since writing 48 `seq_type` to firmware that doesn't support it is not just a no-op read. 49 """ 50 51 command = RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE 52 converter = CustomizeCleanModeConverter() 53 requires_feature = "is_customized_clean_supported" 54 55 def __init__(self, device_features: DeviceFeaturesTrait) -> None: 56 super().__init__() 57 common.V1TraitMixin.__init__(self) 58 self._device_features = device_features 59 self._raw_by_segment: dict[int, dict] = {} 60 self._write_lock = asyncio.Lock() 61 62 async def refresh(self) -> None: 63 """Refresh the contents of this trait. 64 65 Overrides `V1TraitMixin.refresh()` to additionally retain each 66 room's raw wire dict, by segment - `set_clean_then_mop` round-trips 67 untouched rooms from this raw data rather than the typed 68 `SegmentCleanMode` fields, so per-model keys this library doesn't 69 model (or a value it failed to parse) are preserved on write instead 70 of being silently dropped. 71 """ 72 response = await self.rpc_channel.send_command(self.command) 73 try: 74 new_data = self.converter.convert(response) 75 except (TypeError, ValueError) as err: 76 raise RoborockParsingException( 77 trait_name=type(self).__name__, 78 command=self.command, 79 payload=response, 80 inner_error=err, 81 ) from err 82 assert isinstance(response, list) and new_data.modes is not None # converter.convert already validated this 83 # Key the raw cache off the canonical TYPED segment id (int, already 84 # decamelized/coerced by SegmentCleanMode.from_dict), not the raw 85 # dict's own "segment" key - a device that ever sent a differently 86 # cased key (e.g. "Segment") or a stringified id ("7" instead of 7) 87 # would otherwise desync the raw cache from `by_segment`, so this 88 # same room would be silently missing from (or duplicated in) a 89 # later write. `response` and `new_data.modes` are the same length, 90 # in the same order, by construction (one SegmentCleanMode per raw 91 # entry, no filtering) - zip(strict=True) turns any future change to 92 # that invariant into a loud error instead of silent misalignment. 93 raw_by_segment: dict[int, dict] = {} 94 for raw_entry, mode in zip(response, new_data.modes, strict=True): 95 if mode.segment in raw_by_segment: 96 raise RoborockParsingException( 97 trait_name=type(self).__name__, 98 command=self.command, 99 payload=response, 100 inner_error=ValueError(f"Duplicate segment {mode.segment} in response, cannot safely write"), 101 ) 102 raw_by_segment[mode.segment] = raw_entry 103 common.merge_trait_values(self, new_data) 104 self._raw_by_segment = raw_by_segment 105 106 async def set_clean_then_mop( 107 self, 108 segment: int, 109 enabled: bool, 110 *, 111 fan_power: VacuumModes | int, 112 water_box_mode: WaterModes | int, 113 mop_mode: CleanRoutes | int, 114 repeat: int = 1, 115 ) -> None: 116 """Set whether a single room is vacuumed fully before it is mopped. 117 118 The firmware requires the room's full clean mode alongside the 119 sequence type, not the flag on its own, so callers pass through the 120 fan power / water box mode / mop mode that room already cleans with 121 - typically read from the room's existing `SegmentCleanMode` (via 122 `by_segment`) - so this call only changes the order, not the 123 settings. `fan_power`, `water_box_mode` and `mop_mode` accept either 124 the typed mode enum or a raw wire int (for codes this library 125 doesn't model, e.g. water-slide values). 126 127 Do NOT source these from `StatusTrait` as a fallback when a room has 128 no override yet: while the device is in a per-room custom cleaning 129 mode, `StatusTrait` itself reports the sentinel "customized" codes 130 (`fan_power=106`, `water_box_mode=204`, `mop_mode=302`) rather than 131 real settings, and those sentinels are not valid values to echo back 132 here. 133 134 `fan_power`, `water_box_mode` and `mop_mode` are all required: the 135 device rejects the request outright (`Param error, no mop_mode`) if 136 `mop_mode` is left out, confirmed against a real device - this isn't 137 a case where a missing field is silently defaulted. 138 139 `repeat` defaults to `1` rather than being left out when the caller 140 doesn't have an opinion: omitting it from the wire request is NOT a 141 no-op on the device, which resets it to `1` instead of preserving 142 whatever the room's `repeat` was set to before this call - confirmed 143 against a real device. Pass the room's current `repeat` explicitly if 144 you want a different value preserved. 145 146 Raises `RoborockUnsupportedFeature` if the device doesn't support 147 clean-then-mop at all (`is_clean_then_mop_mode_supported`), even if 148 it does support per-room custom modes in general. 149 150 This whole read-merge-write sequence is serialized per trait 151 instance (concurrent calls queue rather than racing): the device's 152 SET_CUSTOMIZE_CLEAN_MODE replaces the whole per-room table rather 153 than patching one entry (see below), so two overlapping calls each 154 building their own full table from the same stale read would 155 otherwise silently revert each other's room. 156 """ 157 if not self._device_features.is_clean_then_mop_mode_supported: 158 raise RoborockUnsupportedFeature("Device does not support clean-then-mop") 159 160 async with self._write_lock: 161 # Always refresh immediately before building the write, even if 162 # `self.modes` is already populated: this merges-and-replaces 163 # the whole table (see below), so writing from a stale cache 164 # could silently revert another room's override changed since 165 # the last refresh (e.g. from the vendor app) back to its old 166 # value. 167 await self.refresh() 168 169 seq_type = SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 170 new_entry = { 171 "segment": segment, 172 "fan_power": fan_power.code if isinstance(fan_power, VacuumModes) else fan_power, 173 "water_box_mode": water_box_mode.code if isinstance(water_box_mode, WaterModes) else water_box_mode, 174 "mop_mode": mop_mode.code if isinstance(mop_mode, CleanRoutes) else mop_mode, 175 "repeat": repeat, 176 "seq_type": seq_type, 177 } 178 # Start from this room's raw wire dict (if it already had an 179 # override) so any field this library doesn't model - or 180 # couldn't parse - survives the write, and only overlay the keys 181 # this call actually changes. A room with no prior override 182 # gets only the known fields; the device fills in its own 183 # per-model defaults for the rest, same as the original 184 # single-entry write did. 185 raw_entries = dict(self._raw_by_segment) 186 raw_entries[segment] = {**raw_entries.get(segment, {}), **new_entry} 187 188 # Send the full known table, not just the changed room: on at 189 # least some V1 firmware (confirmed first-hand for a Saros 20 190 # Sonic, see https://github.com/orgs/home-assistant/discussions/3998), 191 # SET_CUSTOMIZE_CLEAN_MODE replaces the customized segment table 192 # rather than patching a single entry, so sending only 193 # `[new_entry]` would silently wipe every other room's 194 # override. This has not been re-verified against real hardware 195 # for THIS device since this fix was written (no second 196 # customized room available to test with); please confirm on a 197 # device with two or more customized rooms before relying on 198 # it. 199 await self.rpc_channel.send_command( 200 RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=list(raw_entries.values()) 201 ) 202 await self.refresh()
28class CustomizeCleanModeConverter(common.V1TraitDataConverter): 29 """Converts get_customize_clean_mode responses to CustomizeCleanModes.""" 30 31 def convert(self, response: V1ResponseData) -> CustomizeCleanModes: 32 """Parse the response from the device into a CustomizeCleanModes.""" 33 if not isinstance(response, list) or not all(isinstance(entry, dict) for entry in response): 34 raise ValueError(f"Unexpected CustomizeCleanModes response format: {response!r}") 35 return CustomizeCleanModes(modes=[SegmentCleanMode.from_dict(entry) for entry in response])
Converts get_customize_clean_mode responses to CustomizeCleanModes.
31 def convert(self, response: V1ResponseData) -> CustomizeCleanModes: 32 """Parse the response from the device into a CustomizeCleanModes.""" 33 if not isinstance(response, list) or not all(isinstance(entry, dict) for entry in response): 34 raise ValueError(f"Unexpected CustomizeCleanModes response format: {response!r}") 35 return CustomizeCleanModes(modes=[SegmentCleanMode.from_dict(entry) for entry in response])
Parse the response from the device into a CustomizeCleanModes.
38class CustomizeCleanModeTrait(CustomizeCleanModes, common.V1TraitMixin): 39 """Trait for reading and writing per-room clean mode overrides. 40 41 Gated on `is_customized_clean_supported` - the feature that covers 42 `get/set_customize_clean_mode` in general, not `is_clean_then_mop_mode_supported` 43 (which only gates the narrower `seq_type` axis within it). A device can 44 support per-room custom modes without supporting clean-then-mop 45 specifically; on such a device `SegmentCleanMode.seq_type` (and therefore 46 `.clean_then_mop`) is simply `None` for every room, same as when the 47 device never reports it. `set_clean_then_mop` additionally checks 48 `is_clean_then_mop_mode_supported` itself before writing, since writing 49 `seq_type` to firmware that doesn't support it is not just a no-op read. 50 """ 51 52 command = RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE 53 converter = CustomizeCleanModeConverter() 54 requires_feature = "is_customized_clean_supported" 55 56 def __init__(self, device_features: DeviceFeaturesTrait) -> None: 57 super().__init__() 58 common.V1TraitMixin.__init__(self) 59 self._device_features = device_features 60 self._raw_by_segment: dict[int, dict] = {} 61 self._write_lock = asyncio.Lock() 62 63 async def refresh(self) -> None: 64 """Refresh the contents of this trait. 65 66 Overrides `V1TraitMixin.refresh()` to additionally retain each 67 room's raw wire dict, by segment - `set_clean_then_mop` round-trips 68 untouched rooms from this raw data rather than the typed 69 `SegmentCleanMode` fields, so per-model keys this library doesn't 70 model (or a value it failed to parse) are preserved on write instead 71 of being silently dropped. 72 """ 73 response = await self.rpc_channel.send_command(self.command) 74 try: 75 new_data = self.converter.convert(response) 76 except (TypeError, ValueError) as err: 77 raise RoborockParsingException( 78 trait_name=type(self).__name__, 79 command=self.command, 80 payload=response, 81 inner_error=err, 82 ) from err 83 assert isinstance(response, list) and new_data.modes is not None # converter.convert already validated this 84 # Key the raw cache off the canonical TYPED segment id (int, already 85 # decamelized/coerced by SegmentCleanMode.from_dict), not the raw 86 # dict's own "segment" key - a device that ever sent a differently 87 # cased key (e.g. "Segment") or a stringified id ("7" instead of 7) 88 # would otherwise desync the raw cache from `by_segment`, so this 89 # same room would be silently missing from (or duplicated in) a 90 # later write. `response` and `new_data.modes` are the same length, 91 # in the same order, by construction (one SegmentCleanMode per raw 92 # entry, no filtering) - zip(strict=True) turns any future change to 93 # that invariant into a loud error instead of silent misalignment. 94 raw_by_segment: dict[int, dict] = {} 95 for raw_entry, mode in zip(response, new_data.modes, strict=True): 96 if mode.segment in raw_by_segment: 97 raise RoborockParsingException( 98 trait_name=type(self).__name__, 99 command=self.command, 100 payload=response, 101 inner_error=ValueError(f"Duplicate segment {mode.segment} in response, cannot safely write"), 102 ) 103 raw_by_segment[mode.segment] = raw_entry 104 common.merge_trait_values(self, new_data) 105 self._raw_by_segment = raw_by_segment 106 107 async def set_clean_then_mop( 108 self, 109 segment: int, 110 enabled: bool, 111 *, 112 fan_power: VacuumModes | int, 113 water_box_mode: WaterModes | int, 114 mop_mode: CleanRoutes | int, 115 repeat: int = 1, 116 ) -> None: 117 """Set whether a single room is vacuumed fully before it is mopped. 118 119 The firmware requires the room's full clean mode alongside the 120 sequence type, not the flag on its own, so callers pass through the 121 fan power / water box mode / mop mode that room already cleans with 122 - typically read from the room's existing `SegmentCleanMode` (via 123 `by_segment`) - so this call only changes the order, not the 124 settings. `fan_power`, `water_box_mode` and `mop_mode` accept either 125 the typed mode enum or a raw wire int (for codes this library 126 doesn't model, e.g. water-slide values). 127 128 Do NOT source these from `StatusTrait` as a fallback when a room has 129 no override yet: while the device is in a per-room custom cleaning 130 mode, `StatusTrait` itself reports the sentinel "customized" codes 131 (`fan_power=106`, `water_box_mode=204`, `mop_mode=302`) rather than 132 real settings, and those sentinels are not valid values to echo back 133 here. 134 135 `fan_power`, `water_box_mode` and `mop_mode` are all required: the 136 device rejects the request outright (`Param error, no mop_mode`) if 137 `mop_mode` is left out, confirmed against a real device - this isn't 138 a case where a missing field is silently defaulted. 139 140 `repeat` defaults to `1` rather than being left out when the caller 141 doesn't have an opinion: omitting it from the wire request is NOT a 142 no-op on the device, which resets it to `1` instead of preserving 143 whatever the room's `repeat` was set to before this call - confirmed 144 against a real device. Pass the room's current `repeat` explicitly if 145 you want a different value preserved. 146 147 Raises `RoborockUnsupportedFeature` if the device doesn't support 148 clean-then-mop at all (`is_clean_then_mop_mode_supported`), even if 149 it does support per-room custom modes in general. 150 151 This whole read-merge-write sequence is serialized per trait 152 instance (concurrent calls queue rather than racing): the device's 153 SET_CUSTOMIZE_CLEAN_MODE replaces the whole per-room table rather 154 than patching one entry (see below), so two overlapping calls each 155 building their own full table from the same stale read would 156 otherwise silently revert each other's room. 157 """ 158 if not self._device_features.is_clean_then_mop_mode_supported: 159 raise RoborockUnsupportedFeature("Device does not support clean-then-mop") 160 161 async with self._write_lock: 162 # Always refresh immediately before building the write, even if 163 # `self.modes` is already populated: this merges-and-replaces 164 # the whole table (see below), so writing from a stale cache 165 # could silently revert another room's override changed since 166 # the last refresh (e.g. from the vendor app) back to its old 167 # value. 168 await self.refresh() 169 170 seq_type = SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 171 new_entry = { 172 "segment": segment, 173 "fan_power": fan_power.code if isinstance(fan_power, VacuumModes) else fan_power, 174 "water_box_mode": water_box_mode.code if isinstance(water_box_mode, WaterModes) else water_box_mode, 175 "mop_mode": mop_mode.code if isinstance(mop_mode, CleanRoutes) else mop_mode, 176 "repeat": repeat, 177 "seq_type": seq_type, 178 } 179 # Start from this room's raw wire dict (if it already had an 180 # override) so any field this library doesn't model - or 181 # couldn't parse - survives the write, and only overlay the keys 182 # this call actually changes. A room with no prior override 183 # gets only the known fields; the device fills in its own 184 # per-model defaults for the rest, same as the original 185 # single-entry write did. 186 raw_entries = dict(self._raw_by_segment) 187 raw_entries[segment] = {**raw_entries.get(segment, {}), **new_entry} 188 189 # Send the full known table, not just the changed room: on at 190 # least some V1 firmware (confirmed first-hand for a Saros 20 191 # Sonic, see https://github.com/orgs/home-assistant/discussions/3998), 192 # SET_CUSTOMIZE_CLEAN_MODE replaces the customized segment table 193 # rather than patching a single entry, so sending only 194 # `[new_entry]` would silently wipe every other room's 195 # override. This has not been re-verified against real hardware 196 # for THIS device since this fix was written (no second 197 # customized room available to test with); please confirm on a 198 # device with two or more customized rooms before relying on 199 # it. 200 await self.rpc_channel.send_command( 201 RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=list(raw_entries.values()) 202 ) 203 await self.refresh()
Trait for reading and writing per-room clean mode overrides.
Gated on is_customized_clean_supported - the feature that covers
get/set_customize_clean_mode in general, not is_clean_then_mop_mode_supported
(which only gates the narrower seq_type axis within it). A device can
support per-room custom modes without supporting clean-then-mop
specifically; on such a device SegmentCleanMode.seq_type (and therefore
.clean_then_mop) is simply None for every room, same as when the
device never reports it. set_clean_then_mop additionally checks
is_clean_then_mop_mode_supported itself before writing, since writing
seq_type to firmware that doesn't support it is not just a no-op read.
56 def __init__(self, device_features: DeviceFeaturesTrait) -> None: 57 super().__init__() 58 common.V1TraitMixin.__init__(self) 59 self._device_features = device_features 60 self._raw_by_segment: dict[int, dict] = {} 61 self._write_lock = asyncio.Lock()
Initialize the V1TraitMixin.
The RoborockCommand used to fetch the trait data from the device (internal only).
The converter used to parse the response from the device (internal only).
63 async def refresh(self) -> None: 64 """Refresh the contents of this trait. 65 66 Overrides `V1TraitMixin.refresh()` to additionally retain each 67 room's raw wire dict, by segment - `set_clean_then_mop` round-trips 68 untouched rooms from this raw data rather than the typed 69 `SegmentCleanMode` fields, so per-model keys this library doesn't 70 model (or a value it failed to parse) are preserved on write instead 71 of being silently dropped. 72 """ 73 response = await self.rpc_channel.send_command(self.command) 74 try: 75 new_data = self.converter.convert(response) 76 except (TypeError, ValueError) as err: 77 raise RoborockParsingException( 78 trait_name=type(self).__name__, 79 command=self.command, 80 payload=response, 81 inner_error=err, 82 ) from err 83 assert isinstance(response, list) and new_data.modes is not None # converter.convert already validated this 84 # Key the raw cache off the canonical TYPED segment id (int, already 85 # decamelized/coerced by SegmentCleanMode.from_dict), not the raw 86 # dict's own "segment" key - a device that ever sent a differently 87 # cased key (e.g. "Segment") or a stringified id ("7" instead of 7) 88 # would otherwise desync the raw cache from `by_segment`, so this 89 # same room would be silently missing from (or duplicated in) a 90 # later write. `response` and `new_data.modes` are the same length, 91 # in the same order, by construction (one SegmentCleanMode per raw 92 # entry, no filtering) - zip(strict=True) turns any future change to 93 # that invariant into a loud error instead of silent misalignment. 94 raw_by_segment: dict[int, dict] = {} 95 for raw_entry, mode in zip(response, new_data.modes, strict=True): 96 if mode.segment in raw_by_segment: 97 raise RoborockParsingException( 98 trait_name=type(self).__name__, 99 command=self.command, 100 payload=response, 101 inner_error=ValueError(f"Duplicate segment {mode.segment} in response, cannot safely write"), 102 ) 103 raw_by_segment[mode.segment] = raw_entry 104 common.merge_trait_values(self, new_data) 105 self._raw_by_segment = raw_by_segment
Refresh the contents of this trait.
Overrides V1TraitMixin.refresh() to additionally retain each
room's raw wire dict, by segment - set_clean_then_mop round-trips
untouched rooms from this raw data rather than the typed
SegmentCleanMode fields, so per-model keys this library doesn't
model (or a value it failed to parse) are preserved on write instead
of being silently dropped.
107 async def set_clean_then_mop( 108 self, 109 segment: int, 110 enabled: bool, 111 *, 112 fan_power: VacuumModes | int, 113 water_box_mode: WaterModes | int, 114 mop_mode: CleanRoutes | int, 115 repeat: int = 1, 116 ) -> None: 117 """Set whether a single room is vacuumed fully before it is mopped. 118 119 The firmware requires the room's full clean mode alongside the 120 sequence type, not the flag on its own, so callers pass through the 121 fan power / water box mode / mop mode that room already cleans with 122 - typically read from the room's existing `SegmentCleanMode` (via 123 `by_segment`) - so this call only changes the order, not the 124 settings. `fan_power`, `water_box_mode` and `mop_mode` accept either 125 the typed mode enum or a raw wire int (for codes this library 126 doesn't model, e.g. water-slide values). 127 128 Do NOT source these from `StatusTrait` as a fallback when a room has 129 no override yet: while the device is in a per-room custom cleaning 130 mode, `StatusTrait` itself reports the sentinel "customized" codes 131 (`fan_power=106`, `water_box_mode=204`, `mop_mode=302`) rather than 132 real settings, and those sentinels are not valid values to echo back 133 here. 134 135 `fan_power`, `water_box_mode` and `mop_mode` are all required: the 136 device rejects the request outright (`Param error, no mop_mode`) if 137 `mop_mode` is left out, confirmed against a real device - this isn't 138 a case where a missing field is silently defaulted. 139 140 `repeat` defaults to `1` rather than being left out when the caller 141 doesn't have an opinion: omitting it from the wire request is NOT a 142 no-op on the device, which resets it to `1` instead of preserving 143 whatever the room's `repeat` was set to before this call - confirmed 144 against a real device. Pass the room's current `repeat` explicitly if 145 you want a different value preserved. 146 147 Raises `RoborockUnsupportedFeature` if the device doesn't support 148 clean-then-mop at all (`is_clean_then_mop_mode_supported`), even if 149 it does support per-room custom modes in general. 150 151 This whole read-merge-write sequence is serialized per trait 152 instance (concurrent calls queue rather than racing): the device's 153 SET_CUSTOMIZE_CLEAN_MODE replaces the whole per-room table rather 154 than patching one entry (see below), so two overlapping calls each 155 building their own full table from the same stale read would 156 otherwise silently revert each other's room. 157 """ 158 if not self._device_features.is_clean_then_mop_mode_supported: 159 raise RoborockUnsupportedFeature("Device does not support clean-then-mop") 160 161 async with self._write_lock: 162 # Always refresh immediately before building the write, even if 163 # `self.modes` is already populated: this merges-and-replaces 164 # the whole table (see below), so writing from a stale cache 165 # could silently revert another room's override changed since 166 # the last refresh (e.g. from the vendor app) back to its old 167 # value. 168 await self.refresh() 169 170 seq_type = SEQ_TYPE_CLEAN_THEN_MOP if enabled else 0 171 new_entry = { 172 "segment": segment, 173 "fan_power": fan_power.code if isinstance(fan_power, VacuumModes) else fan_power, 174 "water_box_mode": water_box_mode.code if isinstance(water_box_mode, WaterModes) else water_box_mode, 175 "mop_mode": mop_mode.code if isinstance(mop_mode, CleanRoutes) else mop_mode, 176 "repeat": repeat, 177 "seq_type": seq_type, 178 } 179 # Start from this room's raw wire dict (if it already had an 180 # override) so any field this library doesn't model - or 181 # couldn't parse - survives the write, and only overlay the keys 182 # this call actually changes. A room with no prior override 183 # gets only the known fields; the device fills in its own 184 # per-model defaults for the rest, same as the original 185 # single-entry write did. 186 raw_entries = dict(self._raw_by_segment) 187 raw_entries[segment] = {**raw_entries.get(segment, {}), **new_entry} 188 189 # Send the full known table, not just the changed room: on at 190 # least some V1 firmware (confirmed first-hand for a Saros 20 191 # Sonic, see https://github.com/orgs/home-assistant/discussions/3998), 192 # SET_CUSTOMIZE_CLEAN_MODE replaces the customized segment table 193 # rather than patching a single entry, so sending only 194 # `[new_entry]` would silently wipe every other room's 195 # override. This has not been re-verified against real hardware 196 # for THIS device since this fix was written (no second 197 # customized room available to test with); please confirm on a 198 # device with two or more customized rooms before relying on 199 # it. 200 await self.rpc_channel.send_command( 201 RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, params=list(raw_entries.values()) 202 ) 203 await self.refresh()
Set whether a single room is vacuumed fully before it is mopped.
The firmware requires the room's full clean mode alongside the sequence type, not the flag on its own, so callers pass through the fan power / water box mode / mop mode that room already cleans with
- typically read from the room's existing
SegmentCleanMode(viaby_segment) - so this call only changes the order, not the settings.fan_power,water_box_modeandmop_modeaccept either the typed mode enum or a raw wire int (for codes this library doesn't model, e.g. water-slide values).
Do NOT source these from StatusTrait as a fallback when a room has
no override yet: while the device is in a per-room custom cleaning
mode, StatusTrait itself reports the sentinel "customized" codes
(fan_power=106, water_box_mode=204, mop_mode=302) rather than
real settings, and those sentinels are not valid values to echo back
here.
fan_power, water_box_mode and mop_mode are all required: the
device rejects the request outright (Param error, no mop_mode) if
mop_mode is left out, confirmed against a real device - this isn't
a case where a missing field is silently defaulted.
repeat defaults to 1 rather than being left out when the caller
doesn't have an opinion: omitting it from the wire request is NOT a
no-op on the device, which resets it to 1 instead of preserving
whatever the room's repeat was set to before this call - confirmed
against a real device. Pass the room's current repeat explicitly if
you want a different value preserved.
Raises RoborockUnsupportedFeature if the device doesn't support
clean-then-mop at all (is_clean_then_mop_mode_supported), even if
it does support per-room custom modes in general.
This whole read-merge-write sequence is serialized per trait instance (concurrent calls queue rather than racing): the device's SET_CUSTOMIZE_CLEAN_MODE replaces the whole per-room table rather than patching one entry (see below), so two overlapping calls each building their own full table from the same stale read would otherwise silently revert each other's room.