Skip to content

Add standalone admin schedule editor and per-vacuum MQTT recovery - #214

Draft
pponce wants to merge 33 commits into
Python-roborock:mainfrom
pponce:upstream/admin-schedules
Draft

pponce wants to merge 33 commits into
Python-roborock:mainfrom
pponce:upstream/admin-schedules

Conversation

@pponce

@pponce pponce commented Oct 25, 2026 •

Copy link
Copy Markdown

Problem and result

The standalone dashboard needs a readable schedule view and a way to recover a previously paired vacuum whose old MQTT credentials block onboarding. This adds a responsive dashboard, an editor for existing jobs and a scoped credential-reset action.

Dependencies, companion work and review order

Depends on #213, which builds on the scheduler introduced in #179. Merge the server schedule support and hardening first.

This branch is stacked on #213. Until that work merges, the comparison against upstream main includes the prerequisite server changes. The isolated admin diff is:
pponce/local_roborock_server@upstream/schedule-support...upstream/admin-schedules

Please coordinate the dashboard changes with the existing #176 proposal. This PR does not replace its broader setup-wizard and environment-configuration work.

The companion LocalRock schedule proposal is:
DonSidro/LocalRock#60

LocalRock and this dashboard both use the server scheduling functionality, but neither depends on the other's UI. Once the server work is merged, they can proceed independently.

Changes

  • Packaged HTML/CSS/JavaScript, responsive layout, dark mode and refresh behavior that retains focus and unsaved drafts; no frontend runtime dependency.
  • Compact schedules sorted by start time, repeat options, Full/named rooms, cleaning modes and an editor for existing jobs.
  • Explicit map loading with live map/room validation; stale-update protection and preservation of IDs, native links and unedited settings.
  • Native enablement reads the existing timer, updates it only when necessary, verifies it, then saves the server job. Report conflicts and partial saves without recreating timers or blindly rolling back. Jobs without native names remain server-only.
  • Per-vacuum MQTT credential reset with session revocation and protection against stale messages restoring cleared credentials.
  • API/browser regression tests, wheel asset checks and an existing-Compose update helper.

Why MQTT reset was needed

After returning an S7 to the Roborock cloud, I could not add that previously paired vacuum back to the local server while its previous MQTT login remained stored. Clearing that vacuum's credentials allowed re-pairing. The action preserves inventory, local/recovered keys, schedules, certificates, other vacuums and app logins.

Validation

I used LocalRock and the OEM Roborock app for schedule control on an S7 (a15, firmware 02.16.12) and observed the local server start it at its scheduled time. This does not establish every dashboard control or failure case on hardware. Python 3.11/3.13, browser, packaging and Docker checks passed at c22b4d9.
https://github.com/pponce/local_roborock_server/actions/runs/40214168860
https://github.com/pponce/local_roborock_server/actions/runs/40214168864

The dashboard uses the server scheduling engine; it does not add a second scheduler. LocalRock changes are separate.
Screenshot 2026-10-25 at 10 27 57 AM
Screenshot 2026-10-25 at 10 30 42 AM

@virtitnerd

Copy link
Copy Markdown
Contributor

Thanks for this. Before it gets reviewed I want to flag an overlap with #176. #214 replaces most of standalone_admin.py with its own hand-written dashboard (admin_ui/*), while #176 rewrites the same file with a shared theme, a browser setup wizard, env-var configuration, an activity panel and a one-vacuum-at-a-time layout. The two will conflict, and from the file list #214 does not include the wizard, the env-var bootstrap or the activity panel, so merging it first would drop those or leave two dashboards.

The two changes complement each other: #176 says schedules are out of scope, and #214 adds the schedule editor and the per-vacuum MQTT credential reset that #176 does not have. Could the editor and the reset be rebased onto #176's dashboard, using its theme and layout, so there is one dashboard? The schedule API, the credential reset logic and their tests should carry over unchanged.

I'll bring #176 up to date with main first, since it conflicts now, so there is a stable base to build on. This one also depends on #213 and #179, so it makes sense for it to wait for those anyway.

virtitnerd and others added 27 commits October 26, 2026 16:40
Lets Docker Compose deployments generate config.toml from environment
variables alone, covering the network/broker/storage/tls sections plus
the admin credentials needed for a bootable config. Mirrors the existing
Home Assistant add-on options.json -> config.toml flow in ha_addon.py,
but supports external broker mode and external_tls reverse-proxy setups
that the add-on intentionally doesn't need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 2c64eef)
Falls back to ROBOROCK_SERVER_* env vars only when no config.toml
(compose-mounted or previously generated) and no HA add-on options.json
are present, so an existing hand-edited config is never clobbered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 19acdea)
Explains the required/optional ROBOROCK_SERVER_* variables and the
config.toml bind-mount gotcha (Docker creates an empty file for a
nonexistent host path, which the entrypoint then treats as an existing
but invalid config instead of falling through to env vars).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 94262cc)
The CLI wizard has always left broker.host blank for external brokers,
requiring a manual config.toml edit afterward. Add an optional
ConfigureAnswers.broker_host so callers that already have the host (the
upcoming web setup wizard) can write a ready-to-serve config in one
step; the CLI wizard's own behavior is unchanged since it still passes
broker_host="".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 017e9e0)
New always-reachable /admin: when config.toml doesn't exist yet, it
serves a browser-based setup wizard (the same fields as `configure`,
reusing write_config_setup so both paths produce identical output)
over plain HTTP on the same https_port the real stack uses once
configured. Once ReleaseSupervisor's own TLS'd /admin takes over that
port after a restart, this is out of the picture entirely - there is
never a second, separate admin surface running at the same time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 3db3e96)
run_server() now catches load_config() failures (missing file, bad
TOML, failed validation) and serves only the bootstrap /admin wizard
instead of crashing, exiting once the wizard writes a config so
Docker's restart policy brings up the full stack. The container
entrypoint no longer errors out when no config source is found at all
- it always execs into serve, which now handles that case gracefully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 3146e37)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit e0e3dd0)
Neither the CLI configure wizard nor the browser Setup Wizard can
generate listener_mode, advertised_*_port, or trusted_proxies - they
only produce the simple local_tls case. Those are exactly the
deployment-topology settings the ROBOROCK_SERVER_* env vars already
cover (external_tls, advertised ports, trusted proxies), so a
SWAG/Caddy/Traefik reverse-proxy setup needs no manual config.toml
edits: just set the equivalent env vars instead of running either
wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit e7cb420)
STACK_FQDN plus network/broker/tls env vars alone now produce a valid
partial config.toml with no [admin] section, so a deployment can set
just the infra/TLS side (including external_tls reverse-proxy setups)
and finish credentials in the Setup Wizard - rather than requiring all
of ADMIN_PASSWORD/PROTOCOL_LOGIN_EMAIL/PROTOCOL_LOGIN_PIN up front or
not being able to use env vars at all. Setting some but not all three
is rejected with a clear error, since a half-set credential is never
what was intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 15bddb9)
Extracts network/broker/storage/tls parsing+validation into its own
function so it can succeed or fail on its own, separately from [admin].
Adds diagnose_config(), which classifies a config.toml as "ok",
"missing_admin" (everything but [admin] validates - e.g. a partial
env-var-generated config still waiting on the Setup Wizard for
credentials), or "invalid" (something else is wrong, or the file is
missing). load_config()'s own behavior for existing valid/invalid
configs is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit e39dd21)
Uses diagnose_config() to tell three states apart at /admin: "ok"
(status page), "missing_admin" (a trimmed wizard asking only for admin
credentials, appending just [admin] to the existing config.toml rather
than regenerating it), and anything else (the full wizard, as before -
this also covers a corrupt hand-edited file, which is correctly
refused rather than silently overwritten).

This is what makes the env-var path from feat/env-var-config and this
wizard actually compose: set only FQDN/TLS/network vars and finish
credentials in the browser, instead of an all-or-nothing choice
between the two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit c93a28b)
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)
Timer RPCs carry each schedule's vac and mop settings, and a schedule keeps
the official app's link to its server_timer entry (the robot-side id in its
name) so writing it back goes to the right robot entry.

The four get/set/upd/del_server_timer methods are left to the robot: a robot
that speaks server_timer stores those schedules itself, and answering them
from this store hid the robot's real schedules and, tried once, caused data
loss (the official app deletes server_timer entries it does not recognize when
its schedule screen opens). A parametrized test over all four methods keeps
the interceptor's method list from regressing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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)
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>
…d setup flow

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
decompiled_http.jsonl / decompiled_mqtt.jsonl have no rotation and can
carry sensitive data (auth headers, onboarding localKey/passwords in
HTTP bodies, fully-decrypted device RPC payloads in MQTT entries). This
tails both files (they grow unbounded, so a full read is out) and
returns metadata-only summaries by default - method/path/topic/RPC
method name, sizes, timestamps, never raw headers/bodies/payloads.

ROBOROCK_SERVER_ACTIVITY_RAW=1 switches to complete, unredacted
entries - an explicit deployment-time opt-in, matching how rethink
exposes its own traffic view, rather than a per-view UI toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 9c9943d)
Adds ReleaseSupervisor._activity_payload() and GET /admin/api/activity
(auth-gated like every other admin route, ?limit= clamped to 1-500),
plus an Activity section on the dashboard following the existing
2s-polling pattern - there's no real-time push mechanism in this app
today (websockets/SSE), so this matches how every other panel already
refreshes.

Note: a full-suite run flagged test_admin_onboarding_start_is_blocked_when_new_connections_disabled
as failing once (256 passed/1 failed), but it passes reliably alone,
with just this file plus test_activity_log.py, and on a clean rerun of
the entire suite (257/257). AdminSessionManager (security.py) is
stateless and per-instance with no shared/global auth state for this
change to touch, so this looks like a pre-existing flaky/timing-
sensitive test that more total tests nudged into failing once, not a
regression from this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit dd825d2)
New /internal/protocol/mitm-activity, gated by the same
admin.session_secret-based auth as the existing /internal/protocol/user-data
sync - no new trust mechanism. Appends posted entries to a new
mitm_activity.jsonl (AppPaths.mitm_activity_jsonl_path). The sync
request's own body is now also redacted in the regular HTTP activity
log, the same way the existing user-data sync already is, since it can
carry real Roborock account tokens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5181022)
read_recent_activity() gains an optional mitm_jsonl_path, tailed and
merged alongside the existing HTTP/MQTT sources with the same
redact-by-default / ROBOROCK_SERVER_ACTIVITY_RAW behavior. Dashboard
gets a distinct color/label for [MITM] entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit c9647f4)
Off by default. When enabled (and a sync secret is configured), each
redirected Roborock-domain request/response is posted to the server's
new mitm-activity endpoint, so the official app's traffic is visible
in the admin dashboard's Activity panel without a separate viewer.
Sync failures are caught and logged, never allowed to interrupt the
actual MITM redirect/rewrite - this is telemetry, not part of the
redirect's own correctness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 6318cc1)
Same recipe as rethink's own management panel: Materialize CSS/JS and
Google Fonts loaded from CDN (no build step - this project has none
and isn't taking one on for this), plus one small theme stylesheet on
top for branding, dark mode (prefers-color-scheme), and a couple of
Materialize defaults that need overriding (fixed-height nav, focus
rings suppressed by its own box-shadow reset, etc).

Served from one route (register_theme_routes) so both the Setup
Wizard and the real dashboard pull the same theme, not two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 47f9a54)
Real Materialize form fields/radios/select/buttons instead of hand-
built fieldset/input markup, using the same nav/theme as the real
dashboard. All required page text and element IDs the wizard's own
JS/tests depend on are unchanged; only markup and styling moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 6b1b00b)
Login page and dashboard now use the same Materialize theme as the
Setup Wizard - real form fields, buttons, nav, cards - instead of raw
inline-styled markup. Every element ID the existing JS (refresh(),
renderAuth(), renderActivity(), etc.) depends on is unchanged; only
markup/styling moved. Dynamically-created cards (vacuum summary,
session list, activity feed) now use theme CSS variables instead of
hardcoded light-mode hex colors, so they stay readable in dark mode.

Also fixes a real XSS-shaped bug introduced partway through this
redesign and caught before commit: the activity feed's per-entry badge
was briefly built via innerHTML with server-reported traffic fields
(method/path/topic/host) interpolated directly in - the same bug class
already found and fixed in rethink's own device table. Rewritten with
DOM APIs (createElement/textContent) only; no interpolated HTML
anywhere in this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit c045dca)
virtitnerd and others added 3 commits October 26, 2026 16:56
STACK_FQDN alone is enough to trigger has_env_config(), but the
default tls_mode (cloudflare_acme) then requires more vars that may
not be set - previously this ValueError propagated raw out of
write_config_from_env, uncaught, crashing the container outright and
crash-looping it forever under restart:unless-stopped. This only shows
up once both env-var config generation (feat/env-var-config) and the
Setup Wizard (feat/setup-wizard) exist together, so it's fixed here on
their combined branch rather than either alone.

Now the failure is caught and falls through into the Setup Wizard at
/admin, same as if no env vars had been set at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 6a6760d)
… set

Directly motivated by the fallback above: if env-var generation fails
partway through, whatever the operator *did* set (stack_fqdn, ports,
broker mode/host, tls_mode, base domain, email, acme_server) shouldn't
have to be retyped in the browser. Reveals the matching conditional
sections (external broker host, provided-cert vs Cloudflare fields,
Actalis fields) based on the prefilled values too, rather than always
defaulting to embedded/cloudflare_acme/zerossl regardless of intent.

Deliberately never pre-fills secrets (cloudflare_token, admin_password,
protocol_login_pin) - those are exactly what the wizard exists to
collect fresh in the browser rather than a compose file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit eb7659d)
With several vacuums the page stacked every card for every device into
one long feed. It now has a row of device chips plus a Server chip.
Picking a vacuum shows only its facts, onboarding state and traffic;
the global cards (Cloud Import, New Connections, Health) live under
Server. Selection is kept in the URL hash.

/admin/api/activity takes ?device=<duid or did> and keeps entries that
name the vacuum's did, duid or MQTT user as a whole path or topic
segment, reading a deeper tail so a quiet vacuum isn't pushed out by a
noisy one. The raw view no longer prints the local key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit a4328d7)
@pponce

pponce commented Oct 26, 2026

Copy link
Copy Markdown
Author

Agreed, @virtitnerd. I’ll hold this PR while you rebase #176.

Once that provides a stable base, I’ll integrate the schedule editor and per-vacuum MQTT credential reset into your dashboard, preserving its setup wizard, environment configuration, shared theme/layout and activity panel. The underlying schedule API, credential-reset logic and tests will carry over.

That will give us one dashboard with the combined functionality. This work will also wait for the scheduler dependencies in #179, including the hardening contributed from #213.

@virtitnerd

Copy link
Copy Markdown
Contributor

Thanks. #176 is rebased onto current main now (23 commits, no conflicts, tests passing), so it is a stable base to build on. The branch is feat/admin-dashboard-clean on my fork. The dashboard pieces to hook into are standalone_admin.py for the page, web_theme.py for the shared theme and management_app.py for the wizard. The dashboard now shows one vacuum at a time, so the schedule editor and the credential reset fit as per-vacuum sections.

I'll keep #176 as it is until you have had a look, so you are not building on a moving branch. If you need something changed on it, say so here.

Add schedule editing and credential recovery to the selected vacuum's dashboard sections. Preserve the shared theme, setup wizard, environment configuration, activity view, schedule API and reset backend.

Keep unsaved schedule drafts across refresh and navigation, retain pending credential resets, and reject stale dashboard snapshots. Cover the combined page with API, packaging and browser regression checks.
@pponce
pponce force-pushed the upstream/admin-schedules branch from ead65ce to 32e0631 Compare October 26, 2026 22:56
@pponce

pponce commented Oct 26, 2026

Copy link
Copy Markdown
Author

Thanks for rebasing, @virtitnerd. I've updated this PR with the schedule editor and MQTT credential reset integrated into your dashboard as per-vacuum sections using the shared theme.

The setup wizard, environment configuration and activity panel are preserved. The schedule API, credential-reset logic and their backend tests carried over.

This now builds on #176 and the current #179 branch, including the hardening accepted from #213. Until those dependencies merge, the overall PR diff includes their changes. The dashboard additions are isolated in [this commit](pponce@32e0631).

[Combined validation passed](https://github.com/pponce/local_roborock_server/actions/runs/40411911457): 730 tests on each of Python 3.11 and 3.13, 35 browser tests, wheel packaging and Docker. Live testing of this combined build remains pending.

I'll keep this PR in draft and prepare #214 for merging once #176 and #179 are 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