Skip to content

Build on #179: verify native schedule dispatch and persist occurrence claims - #213

Draft
pponce wants to merge 12 commits into
Python-roborock:mainfrom
pponce:upstream/schedule-support
Draft

pponce wants to merge 12 commits into
Python-roborock:mainfrom
pponce:upstream/schedule-support

Conversation

@pponce

@pponce pponce commented Oct 25, 2026 •

Copy link
Copy Markdown

Problem and result

Imported schedules need local execution while preserving their native timer links and cleaning settings. This change builds on virtitnerd's scheduler in #179 and adds native timer verification, durable occurrence claims and capture-compatible job handling.

Dependency and review order

This branch includes the original #179 commits with attribution preserved. Please coordinate with #179 rather than treating this as an independent replacement. Preferred order: merge #179, then review/merge the remaining hardening here. If #179 is squash-merged or changed, I will update this branch so its diff contains only the remaining changes.

What this adds beyond #179

#179 supplies the schedule store, job API, local scheduler and native start command. My additions are:

  • Check the matching native timer is on before dispatch and recheck the job/deadline after the network wait.
  • Remove native-start fallback to an ordinary clean on refusal, so a rejected timer cannot be bypassed.
  • Persist occurrence claims across restart/cloud refresh and skip missed recurring occurrences rather than draining historical work.
  • Preserve the complete imported settings payload and unknown fields.
  • Add explicit DST policy, persistence/error validation and regression tests.
  • Synchronize native schedule enablement across clients, preserving job IDs, cleaning settings and occurrence claims. Reject stale or ambiguous updates and avoid re-enabling consumed one-time schedules during background polling.

These include behavioral choices (at-most-once dispatch, missed-run and DST policy) for maintainer review, not claims of complete cloud parity.

Changes

  • Job CRUD APIs, timezone-aware next-run calculation and local dispatch from Add server-side scheduling that runs jobs the way the cloud does #179.
  • Preserve complete settings, unknown fields and the distinction between HTTP job IDs and native timer names.
  • Forward native server_timer RPCs unchanged; keep the legacy timer compatibility view separate.
  • Read the native timer before dispatch; off, missing, ambiguous or unreachable state prevents a start.
  • Never substitute an ordinary clean after a refused or timed-out native scheduled start.
  • Persist occurrence claims before sending, retain claims across cloud refresh, and recheck edits/deadlines after network waits.
  • Add API, persistence, clock/DST, dispatcher and timer regression coverage.

Validation

I tested with a Roborock S7 (roborock.vacuum.a15, firmware 02.16.12). The local server successfully started the vacuum at its scheduled time. I used LocalRock and the OEM Roborock app for schedule control. This is a confirmed scheduled-start scenario.

Python 3.11/3.13 tests and the Docker build passed at d5ada08d: [passing checks](https://github.com/pponce/local_roborock_server/actions/runs/40226190127).

The new enablement synchronization has automated coverage for native MQTT forwarding, HTTP updates, concurrent edits, lost acknowledgements and one-time schedule handling.

Scope and companion PRs

Preferred merge order: coordinate and merge #179, then the remaining hardening in this PR. Afterward, #214 and the agreed LocalRock integration can proceed independently.

The local server now synchronizes native schedule enablement across clients: changes made through Homebridge, LocalRock, or the OEM app for example are reconciled with the saved server job after a fresh robot timer read. Background reads recover missed updates, while HTTP job updates that supply enabled verify the native timer before saving.

virtitnerd and others added 11 commits October 17, 2026 22:29
Split _run_scene so a scheduled job can run the same step executor
without syncing scene IDs to the robot, and add start_job(), which
refuses to interrupt a routine that is already running on the device.
get_routine_runner() gives other callers the per-context instance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit c657648)
Roborock's cloud fires server_scheduled_start jobs at nextFireTime. With
the vacuum pointed at this server nothing did, so imported schedules
never ran. ScheduleRunner evaluates each enabled job's cron in its own
timezone and hands due jobs to the routine runner, carrying fan_power,
water_box_mode and mop_mode so vac-only and vac+mop schedules behave as
configured. One-shot jobs disable themselves after firing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit b5203b2)
Start the scheduler alongside the other supervisor tasks and stop it on
shutdown. GET /user/devices/{id}/jobs now computes nextFireTime from the
cron instead of returning whatever value was captured at import time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 14ddb5f)
schedule_store holds create/update/delete for the per-device jobs kept in
the inventory, so the timer RPC handler, HTTP routes and dashboard can
share one writer. Crons without their own timeZoneId now evaluate in the
zone the robot is told by /location (America/New_York unless configured)
rather than UTC, so a 11:00 schedule fires at 11:00 local.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 0751a7b)
Cloud-managed robots keep their schedules server-side, so a client such
as LocalRock that asks the robot for its timers saw nothing and could not
change or delete the schedules imported from the cloud. The topic bridge
now offers each rr/m request to an interceptor, and timer requests are
answered from the store (keeping a schedule's vac/mop settings on edit)
instead of being forwarded to the robot.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 2f6f666)
POST /user/devices/{duid}/jobs creates a job (the call python-roborock
makes), and PUT/PATCH and DELETE on /jobs/{id} edit or remove one. The
GET matcher is now method-aware so a write no longer falls through to
the list route. Update and delete paths are not confirmed against the
official app; they follow the scene routes' conventions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 5b83853)
The legacy set_timer action is ["start_clean", {}]. The object now
accepts repeat, fan_power, water_box_mode, mop_mode, mop_template_id,
segments and clean_mop, and get_timer returns them, so a client can show
and change them. Keys are merged into the job's existing settings, null
clears one, and an empty object (what current clients send) leaves the
job alone, so editing a schedule's time never resets its vac/mop choice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit dd2d3d1)
A robot that speaks server_timer stores those schedules itself, and the
official app reads them back from it. Answering get/set/upd/del_server_timer
from this store instead hides the robot's real schedules from every client
reading through the server and, tried once, caused real data loss: the
official app deletes server_timer entries it does not recognize when its
schedule screen opens.

Adds a parametrized test over all four server_timer methods so the
interceptor's method list can't regress into handling them again without a
test catching it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The official app writes every server_timer schedule to the robot and also to
POST/PUT /user/devices/{duid}/jobs, and that record's settings carry a "name"
field holding the robot-side server_timer id. job_settings dropped it, since it
is not one of the vac/mop settings, so a client reading the schedule back
through get_timer had no way to find the robot's entry to edit or delete. It
now rides along with the settings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A schedule's job (POST /user/devices/{duid}/jobs) carries a settings entry whose
"name" is the id of the matching timer the official app also saved on the robot.
The cloud runs such a job by sending server_scheduled_start with that entry, which
is the method name inside the job itself. The robot accepts it, runs the clean
(rooms or whole home) and records it as "Schedules" in its history; without the
entry it answers -10005 "Invalid timer", and with a bare id "Param should be name
+ fan + segment". python-roborock does not list the command, so it is sent through
a small SchedulerCommand enum.

A schedule with no robot-side id (one saved through the legacy set_timer), or one
the robot refuses, falls back to the plainer ways, since a refused command changes
nothing: a whole-home clean is one app_start carrying the schedule's fields plus
source 3, which the robot also records as "Schedules"; a room schedule is the
settings commands followed by a segment clean. Scenes and routines are untouched.
The new steps are produced only by schedules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…occurrence claims

Build on virtitnerd's schedule implementation from upstream PR Python-roborock#179,
preserving its commit history and attribution. Retain current upstream
fixes while adding capture-compatible payload preservation, timer-state
preflight, no native-start fallback, concurrency checks and regression tests.

Prepare an upstream-facing branch without personal deployment history.
Record the reported S7 scheduled-start result and remaining validation.
Reconcile verified robot timer state after forwarded native writes and periodic reads, and verify native enablement for direct job API updates. Preserve schedule identity, settings, and occurrence claims; reject stale or ambiguous updates. Add coverage for concurrent edits, missed acknowledgements, one-time schedules, HTTP updates, and MQTT pass-through.
@virtitnerd

Copy link
Copy Markdown
Contributor

Thanks for building on #179 and for keeping the original commits attributed. Since this branch carries the #179 commits and is meant to merge right after them, would you open it as a PR into the #179 branch (virtitnerd:feat/schedule-store) instead of a separate PR to main? I'll review it there and merge it, so your commits land in #179 with your authorship and the maintainers get one schedule PR to review, with one merge in the right order.

Nothing in the description needs to change for that. The native timer check before dispatch, no fallback to an ordinary clean after a refused start, persisted occurrence claims and the DST policy can all go in as the hardening section of #179.

Two small things while it moves: the schedule-tests.yml workflow probably should not ride along, since the LocalRock owner turned down per-PR workflows there and the same may apply here. And the LocalRock side (#37 and #60) will read the jobs API and need #213's behaviour, so I'll note #179 plus the hardening as the minimum server version over there.

@pponce

pponce commented Oct 26, 2026

Copy link
Copy Markdown
Author

I’ve submitted a separate PR into @virtitnerd’s feat/schedule-store branch so this hardening can land through #179 as one combined scheduling contribution. The tests are retained; the feature-specific schedule-tests.yml workflow is excluded.

I’m keeping #213 open as a reference until the combined work is merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants