Skip to content

Commit 9674e77

Browse files
committed
Handle battery going down mid clean
1 parent 9cebfa8 commit 9674e77

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

src/roborock_local_server/bundled_backend/shared/routine_runner.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def _ensure_local_python_roborock_on_path() -> None:
5151
_STEP_START_POLL_INTERVAL_SECONDS = 0.5
5252
_STATUS_POLL_INTERVAL_SECONDS = 5.0
5353
_ROUTINE_READY_STATES = {3, 8, 100}
54+
_RESUME_BATTERY_THRESHOLD = 80
5455
_POST_STEP_SETTLE_SECONDS = 15.0
5556
_POST_STEP_SETTLE_TIMEOUT_SECONDS = 10 * 60
5657
from .inventory_io import WEB_API_INVENTORY_FILE
@@ -59,6 +60,11 @@ def _ensure_local_python_roborock_on_path() -> None:
5960
"do_scenes_segments",
6061
"do_scenes_zones",
6162
}
63+
_RESUME_COMMAND_BY_IN_CLEANING: dict[int, RoborockCommand] = {
64+
RoborockInCleaning.global_clean_not_complete.value: RoborockCommand.APP_START,
65+
RoborockInCleaning.zone_clean_not_complete.value: RoborockCommand.RESUME_ZONED_CLEAN,
66+
RoborockInCleaning.segment_clean_not_complete.value: RoborockCommand.RESUME_SEGMENT_CLEAN,
67+
}
6268

6369

6470
class RoutineExecutionError(RuntimeError):
@@ -553,6 +559,7 @@ async def wait_for_step_complete(self) -> None:
553559
start_deadline = loop.time() + _STEP_START_TIMEOUT_SECONDS
554560
saw_activity = False
555561
saw_cleaning = False
562+
sent_resume = False
556563

557564
try:
558565
while True:
@@ -563,6 +570,7 @@ async def wait_for_step_complete(self) -> None:
563570
status = await asyncio.wait_for(self.get_status(), timeout=remaining)
564571
state = _enum_or_int_value(status.state)
565572
in_cleaning = _enum_or_int_value(status.in_cleaning)
573+
battery = int(status.battery) if status.battery is not None else -1
566574
observed = (state, in_cleaning)
567575
if observed != last_observed:
568576
self._logger.info(
@@ -579,8 +587,35 @@ async def wait_for_step_complete(self) -> None:
579587
in_cleaning == RoborockInCleaning.complete.value
580588
and state in _ROUTINE_READY_STATES
581589
)
590+
591+
charging_mid_clean = (
592+
state == 8
593+
and in_cleaning != RoborockInCleaning.complete.value
594+
and saw_cleaning
595+
and not sent_resume
596+
and battery >= _RESUME_BATTERY_THRESHOLD
597+
)
598+
if charging_mid_clean:
599+
resume_cmd = _RESUME_COMMAND_BY_IN_CLEANING.get(in_cleaning)
600+
if resume_cmd is not None:
601+
self._logger.info(
602+
"Routine wait: robot charging mid-clean (battery=%s%%), "
603+
"sending %s",
604+
battery,
605+
resume_cmd.value,
606+
)
607+
try:
608+
await self.send_command(resume_cmd, [])
609+
sent_resume = True
610+
except Exception as exc: # noqa: BLE001
611+
self._logger.warning(
612+
"Routine wait: resume command failed: %s", exc
613+
)
614+
582615
if not is_ready:
583616
saw_activity = True
617+
if sent_resume and state != 8:
618+
sent_resume = False
584619
elif saw_cleaning:
585620
return
586621
elif saw_activity:

tests/test_routine_runner.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ def __init__(self, status_sequence: list[dict]) -> None:
276276
self._statuses = [StatusV2.from_dict(s) for s in status_sequence]
277277
self._index = 0
278278
self._logger = logging.getLogger("test-wait")
279+
self.sent_commands: list[tuple[RoborockCommand, list | dict | None]] = []
279280

280281
async def get_status(self) -> StatusV2:
281282
if self._index < len(self._statuses):
@@ -284,6 +285,9 @@ async def get_status(self) -> StatusV2:
284285
return status
285286
return self._statuses[-1]
286287

288+
async def send_command(self, command: RoborockCommand, params=None) -> None:
289+
self.sent_commands.append((command, params))
290+
287291

288292
_ScriptedStatusClient.wait_for_step_complete = (
289293
routine_runner_module._RoutineMqttClient.wait_for_step_complete
@@ -368,3 +372,91 @@ async def exercise() -> None:
368372
await client.wait_for_step_complete()
369373

370374
asyncio.run(exercise())
375+
376+
377+
def test_wait_for_step_complete_resume_after_mid_clean_charge(monkeypatch) -> None:
378+
"""Robot returns to dock mid-clean with low battery, charges, gets resumed, completes."""
379+
monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0)
380+
monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0)
381+
monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80)
382+
383+
async def exercise() -> None:
384+
client = _ScriptedStatusClient([
385+
{"state": 18, "in_cleaning": 3, "battery": 55}, # segment cleaning
386+
{"state": 26, "in_cleaning": 3, "battery": 30}, # going to wash mop
387+
{"state": 6, "in_cleaning": 3, "battery": 14}, # returning home (low battery)
388+
{"state": 8, "in_cleaning": 3, "battery": 14}, # charging mid-clean, too low
389+
{"state": 8, "in_cleaning": 3, "battery": 50}, # still too low
390+
{"state": 8, "in_cleaning": 3, "battery": 80}, # threshold reached → resume sent
391+
{"state": 18, "in_cleaning": 3, "battery": 80}, # cleaning resumes
392+
{"state": 18, "in_cleaning": 3, "battery": 60},
393+
{"state": 6, "in_cleaning": 3, "battery": 40}, # returning home
394+
{"state": 8, "in_cleaning": 0, "battery": 40}, # step complete
395+
])
396+
await client.wait_for_step_complete()
397+
assert len(client.sent_commands) == 1
398+
assert client.sent_commands[0] == (RoborockCommand.RESUME_SEGMENT_CLEAN, [])
399+
400+
asyncio.run(exercise())
401+
402+
403+
def test_wait_for_step_complete_resume_zoned_clean(monkeypatch) -> None:
404+
"""Resume uses correct command for zone cleaning."""
405+
monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0)
406+
monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0)
407+
monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80)
408+
409+
async def exercise() -> None:
410+
client = _ScriptedStatusClient([
411+
{"state": 18, "in_cleaning": 2, "battery": 50}, # zone cleaning
412+
{"state": 8, "in_cleaning": 2, "battery": 80}, # charging mid-clean → resume
413+
{"state": 18, "in_cleaning": 2, "battery": 80}, # resumes
414+
{"state": 8, "in_cleaning": 0, "battery": 60}, # step complete
415+
])
416+
await client.wait_for_step_complete()
417+
assert len(client.sent_commands) == 1
418+
assert client.sent_commands[0] == (RoborockCommand.RESUME_ZONED_CLEAN, [])
419+
420+
asyncio.run(exercise())
421+
422+
423+
def test_wait_for_step_complete_no_resume_when_battery_low(monkeypatch) -> None:
424+
"""No resume sent while battery is below threshold."""
425+
monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0)
426+
monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0)
427+
monkeypatch.setattr(routine_runner_module, "_STEP_COMPLETE_TIMEOUT_SECONDS", 0.1)
428+
monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80)
429+
430+
async def exercise() -> None:
431+
client = _ScriptedStatusClient([
432+
{"state": 18, "in_cleaning": 3, "battery": 50}, # cleaning
433+
{"state": 8, "in_cleaning": 3, "battery": 14}, # charging, below threshold
434+
{"state": 8, "in_cleaning": 3, "battery": 50}, # still below
435+
{"state": 8, "in_cleaning": 3, "battery": 79}, # still below
436+
])
437+
with pytest.raises(routine_runner_module.RoutineExecutionError, match="Timed out"):
438+
await client.wait_for_step_complete()
439+
assert len(client.sent_commands) == 0
440+
441+
asyncio.run(exercise())
442+
443+
444+
def test_wait_for_step_complete_resume_only_sent_once(monkeypatch) -> None:
445+
"""Resume command is only sent once even if robot returns to dock again."""
446+
monkeypatch.setattr(routine_runner_module, "_STEP_START_POLL_INTERVAL_SECONDS", 0.0)
447+
monkeypatch.setattr(routine_runner_module, "_STATUS_POLL_INTERVAL_SECONDS", 0.0)
448+
monkeypatch.setattr(routine_runner_module, "_RESUME_BATTERY_THRESHOLD", 80)
449+
450+
async def exercise() -> None:
451+
client = _ScriptedStatusClient([
452+
{"state": 18, "in_cleaning": 3, "battery": 95}, # cleaning
453+
{"state": 8, "in_cleaning": 3, "battery": 80}, # charging → resume sent
454+
{"state": 18, "in_cleaning": 3, "battery": 80}, # cleaning resumes
455+
{"state": 8, "in_cleaning": 3, "battery": 80}, # back to charging again → second resume sent
456+
{"state": 18, "in_cleaning": 3, "battery": 80}, # cleaning resumes again
457+
{"state": 8, "in_cleaning": 0, "battery": 60}, # step complete
458+
])
459+
await client.wait_for_step_complete()
460+
assert len(client.sent_commands) == 2
461+
462+
asyncio.run(exercise())

0 commit comments

Comments
 (0)