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()
class CustomizeCleanModeConverter(roborock.devices.traits.v1.common.V1TraitDataConverter):
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.

def convert( self, response: dict | list | int | str | bytes) -> roborock.data.v1.v1_containers.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.

CustomizeCleanModeTrait( device_features: roborock.devices.traits.v1.device_features.DeviceFeaturesTrait)
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.

command = <RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE: 'get_customize_clean_mode'>

The RoborockCommand used to fetch the trait data from the device (internal only).

converter = CustomizeCleanModeConverter

The converter used to parse the response from the device (internal only).

requires_feature = 'is_customized_clean_supported'
async def refresh(self) -> None:
 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.

async def set_clean_then_mop( self, segment: int, enabled: bool, *, fan_power: roborock.data.v1.v1_clean_modes.VacuumModes | int, water_box_mode: roborock.data.v1.v1_clean_modes.WaterModes | int, mop_mode: roborock.data.v1.v1_clean_modes.CleanRoutes | int, repeat: int = 1) -> None:
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 (via by_segment) - so this call only changes the order, not the settings. fan_power, water_box_mode and mop_mode accept 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.