Skip to content

Commit 69c61b0

Browse files
fix: Restore the engine user's saved theme after dashboard screenshots (#1912)
* fix: Restore the engine user's saved theme after dashboard screenshots Stock Puppet dispatches a settheme event on every cold-browser render, and its dark query flag is presence-based, so a default screenshot reaches the frontend as an explicit light selection. Home Assistant persists that per-user (frontend/set_user_data, key "theme") and syncs it to every real session of the engine token's user — flipping a dark-mode user's web and mobile UI to light on each capture (#1909). Bracket every capture batch with a ThemeGuard: read the engine user's saved theme before rendering and write it back afterwards when the render changed it. In add-on mode the guard authenticates with the Puppet add-on's own access_token/home_assistant_url options (already fetched during Supervisor discovery, kept in process memory only); in sidecar/standalone/OAuth mode it falls back to ha-mcp's direct HA credentials, which protect the user whenever both tokens belong to the same account. Guard failures never break a capture; an attempted but failed snapshot/restore surfaces as a tool-response warning. Fixes #1909 * fix: Harden the theme guard per review findings Review-toolkit and bot findings, batched: - Surface guard warnings on the error path too: a failing batch may already have rendered (and clobbered the theme), so the raised ToolError payload now carries them, and the set-path degraded-warning handler merges them into the response warnings (codex P2 + toolkit). - Gate the client-credential fallback on is_running_in_addon() instead of a raw SUPERVISOR_TOKEN check so embedded mode (HA core container env, plain admin client token) keeps theme protection (codex P2). - Restore a never-configured baseline as {} rather than null: live frontend sessions ignore a null subscription push and would stay flipped until reload; an empty settings object re-applies default behavior immediately and means the same thing on the next boot (codex P2, verified against frontend themes-mixin). - Carry a narrow frozen EngineCredential through EngineTarget instead of the raw secret-bearing Supervisor options dict; privatize the guard's snapshot lifecycle state; type the WebSocket handle. - Drop the production-dead resolve_engine_url wrapper. - Defensively tolerate malformed get_user_data responses (Gemini). - Note the settheme-before-response ordering assumption; disambiguate 'standalone' wording in docs/beta.md. - Tests: guard-warning propagation into both tool responses, the client-credential fallback through the full capture bracket, embedded mode, error-path warning delivery, and malformed-response tolerance. * fix: Close theme-guard gaps from the second review round - Discover the Puppet add-on credential best-effort even when HAMCP_DASHBOARD_SCREENSHOT_ENGINE_URL is set on HA OS, so an explicit engine URL no longer disables the theme guard in add-on mode. - Wait RESTORE_SETTLE_SECONDS before the post-capture read so a low-wait_ms render cannot race the frontend's async user-data write (a stale read would skip the restore and let the late write survive). - Keep theme-guard warnings visible when image packaging fails after a successful render, on both the standalone and config tool paths. --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com>
1 parent f1de58a commit 69c61b0

11 files changed

Lines changed: 1258 additions & 209 deletions

docs/beta.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,19 @@ page and (by its design) restarts; ha-mcp surfaces this as a clear "set the
175175
engine's access token" error rather than a silent failure.
176176

177177
Puppet's theme and dark-mode renderer controls dispatch Home Assistant's
178-
`settheme` event and can persist preferences on the frontend profile used by
179-
that token; even a fresh Puppet browser's first default render may save the
180-
default theme/dark selection. Use a dedicated Puppet account so screenshot QA
181-
does not alter a person's normal frontend preferences. Language selection is
182-
local to Puppet's browser session.
178+
`settheme` event, which Home Assistant persists on the frontend profile of
179+
the user whose token the engine runs with — and syncs to that user's real
180+
web and mobile sessions. Even a fresh Puppet browser's first default render
181+
saves a "light" selection, which used to flip a dark-mode user's whole UI to
182+
light on every screenshot. ha-mcp now brackets every capture: it reads that
183+
user's saved theme before rendering and writes it back afterwards if the
184+
render changed it (in add-on mode it authenticates with the Puppet add-on's
185+
own configured token; in sidecar / self-hosted non-add-on mode with ha-mcp's
186+
own HA credentials, which protects the user whenever both tokens belong to the
187+
same account). The restore is best-effort — a failed restore surfaces as a
188+
`warnings` entry on the tool response. A dedicated Puppet account remains a
189+
sound belt-and-suspenders setup. Language selection is local to Puppet's
190+
browser session.
183191

184192
To change the Puppet engine add-on's own options (such as `keep_browser_open`)
185193
or to restart it, use `ha_manage_addon`; the screenshot tools only render and
@@ -254,8 +262,9 @@ render failure to a warning so it never breaks a write that already committed.
254262
screenshot *is* the requested payload, so a total render failure surfaces as
255263
an error (matching the standalone `ha_get_dashboard_screenshot` tool) rather
256264
than a warning a caller might miss. Because Puppet can persist theme/dark
257-
preferences, screenshot operations are blocked in server Read Only Mode;
258-
ordinary dashboard get/list/search calls remain available.
265+
preferences (and the theme-restore bracket writes frontend user data to undo
266+
that), screenshot operations are blocked in server Read Only Mode; ordinary
267+
dashboard get/list/search calls remain available.
259268

260269
**Raw rendered paths remain constrained.** `ha_get_dashboard_screenshot`
261270
validates legacy `dashboard_path` values (rejects URLs, query strings,

src/ha_mcp/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ class AdvancedField(NamedTuple):
836836
AdvancedField("enable_websocket", "ENABLE_WEBSOCKET", bool, "operations", True),
837837
# Dashboard-screenshot engine URL (#1538): docker/.env users could set
838838
# HAMCP_DASHBOARD_SCREENSHOT_ENGINE_URL, but add-on users had no path to
839-
# it. It is resolved live per capture (resolve_engine_url), so unlike the
839+
# it. It is resolved live per capture (resolve_engine), so unlike the
840840
# time budgets it takes effect without a restart. Blank = auto-discover
841841
# the Puppet add-on via the Supervisor.
842842
AdvancedField(

src/ha_mcp/dashboard_screenshot/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
DashboardImageCapture,
2020
capture_dashboard_images,
2121
)
22-
from .provision import resolve_engine_url
22+
from .provision import EngineTarget, resolve_engine
23+
from .theme_guard import EngineCredential, ThemeGuard
2324

2425
__all__ = [
2526
"DEFAULT_HEIGHT",
2627
"DEFAULT_RENDER_TIMEOUT_SECONDS",
2728
"DEFAULT_WAIT_MS",
2829
"DEFAULT_WIDTH",
2930
"DashboardImageCapture",
31+
"EngineCredential",
32+
"EngineTarget",
33+
"ThemeGuard",
3034
"capture_dashboard_images",
31-
"resolve_engine_url",
35+
"resolve_engine",
3236
]

src/ha_mcp/dashboard_screenshot/capture.py

Lines changed: 158 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
33
The engine (balloob's Puppet add-on, or a docker-compose sidecar)
44
authenticates to Home Assistant with its OWN configured long-lived token, so
5-
this client passes only the dashboard path + render parameters — no HA token
6-
ever flows through ha-mcp or the LLM for screenshots.
5+
this client passes only the dashboard path + render parameters to the engine
6+
— no HA token is ever sent to the engine or exposed to the LLM. The
7+
:mod:`theme_guard` bracket around each capture batch may hold the engine's
8+
token in server memory to restore the engine user's saved theme (issue
9+
#1909); see that module's docstring for the containment rules.
710
"""
811

912
from __future__ import annotations
@@ -20,7 +23,8 @@
2023

2124
from ..errors import ErrorCode, create_error_response
2225
from ..tools.helpers import raise_tool_error
23-
from .provision import TOKEN_HINT, resolve_engine_url
26+
from .provision import TOKEN_HINT, resolve_engine
27+
from .theme_guard import ThemeGuard
2428

2529
logger = logging.getLogger(__name__)
2630

@@ -757,9 +761,30 @@ async def _request_or_collect_failure(
757761
return None
758762

759763

764+
def _viewport_params(
765+
options: _CaptureOptions, viewport: _ViewportRequest
766+
) -> dict[str, str]:
767+
"""Build one viewport's engine query parameters."""
768+
params: dict[str, str] = {
769+
"viewport": f"{viewport.width}x{viewport.height}",
770+
"zoom": str(options.zoom),
771+
"wait": str(options.wait_ms),
772+
"format": options.image_format,
773+
}
774+
if options.theme is not None:
775+
params["theme"] = options.theme
776+
if options.dark_mode:
777+
# Puppet checks only for query-key presence.
778+
params["dark"] = ""
779+
if options.language is not None:
780+
params["lang"] = options.language
781+
return params
782+
783+
760784
def _complete_capture_batch(
761785
captures: list[DashboardImageCapture],
762786
partial_failures: list[dict[str, Any]] | None,
787+
guard_warnings: list[str],
763788
) -> list[DashboardImageCapture]:
764789
"""Return partial successes, but raise when every requested capture failed."""
765790
if not captures and partial_failures:
@@ -769,10 +794,41 @@ def _complete_capture_batch(
769794
"failure_count": len(partial_failures),
770795
"screenshot_failures": partial_failures,
771796
}
797+
if guard_warnings:
798+
aggregate_failure["warnings"] = [
799+
*aggregate_failure.get("warnings", []),
800+
*guard_warnings,
801+
]
772802
raise_tool_error(aggregate_failure)
773803
return captures
774804

775805

806+
def _reraise_with_guard_warnings(error: ToolError, warnings: list[str]) -> NoReturn:
807+
"""Re-raise a capture ToolError with the theme guard's warnings attached.
808+
809+
A batch that raises may already have rendered (and clobbered the engine
810+
user's theme) before failing, so a failed restore must stay visible on
811+
the error path too — otherwise the tool contract ("an attempted but
812+
failed restore surfaces as a warning") silently breaks exactly when the
813+
theme is most likely left flipped.
814+
"""
815+
if not warnings:
816+
raise error
817+
try:
818+
payload = json.loads(str(error))
819+
except (json.JSONDecodeError, TypeError):
820+
payload = None
821+
if not isinstance(payload, dict):
822+
raise error
823+
existing = payload.get("warnings")
824+
payload["warnings"] = [
825+
*(existing if isinstance(existing, list) else []),
826+
*warnings,
827+
]
828+
raise_tool_error(payload)
829+
raise AssertionError("unreachable: raise_tool_error always raises")
830+
831+
776832
async def capture_dashboard_images(
777833
dashboard_path: str,
778834
*,
@@ -789,13 +845,21 @@ async def capture_dashboard_images(
789845
image_format: ScreenshotFormat = "png",
790846
render_timeout_seconds: float = DEFAULT_RENDER_TIMEOUT_SECONDS,
791847
partial_failures: list[dict[str, Any]] | None = None,
848+
client: Any | None = None,
849+
capture_warnings: list[str] | None = None,
792850
) -> list[DashboardImageCapture]:
793851
"""Render one or more ordered dashboard images via the screenshot engine.
794852
795853
Named presets override explicit dimensions. An explicit orientation swaps
796854
preset or custom dimensions when needed; omitting it preserves each
797855
preset's native orientation. ``full_page`` is a compatibility alias for
798856
requesting the engine's native ``WIDTHxauto`` viewport.
857+
858+
The batch is bracketed by a :class:`ThemeGuard` that restores the engine
859+
user's saved frontend theme if rendering changed it (issue #1909).
860+
``client`` supplies the guard's non-add-on credential fallback and
861+
``capture_warnings`` collects the guard's non-fatal warnings; both are
862+
optional and never affect the captures themselves.
799863
"""
800864
path = _validate_dashboard_path(dashboard_path)
801865
options = validate_capture_parameters(
@@ -814,94 +878,102 @@ async def capture_dashboard_images(
814878
)
815879
viewports = _capture_viewports(options)
816880

817-
engine = await resolve_engine_url()
881+
engine_target = await resolve_engine()
882+
engine = engine_target.url
818883
url = f"{engine}/{path}"
819884
mime_type = _MIME_TYPES[options.image_format]
820885
captures: list[DashboardImageCapture] = []
821886

822-
async with httpx.AsyncClient(
823-
timeout=httpx.Timeout(options.render_timeout_seconds)
824-
) as http_client:
825-
for capture_index, viewport in enumerate(viewports):
826-
params: dict[str, str] = {
827-
"viewport": f"{viewport.width}x{viewport.height}",
828-
"zoom": str(options.zoom),
829-
"wait": str(options.wait_ms),
830-
"format": options.image_format,
831-
}
832-
if options.theme is not None:
833-
params["theme"] = options.theme
834-
if options.dark_mode:
835-
# Puppet checks only for query-key presence.
836-
params["dark"] = ""
837-
if options.language is not None:
838-
params["lang"] = options.language
839-
840-
request_context = {
841-
"path": path,
842-
"preset": viewport.preset,
843-
"width": viewport.width,
844-
"height": viewport.height,
845-
"requested_format": options.image_format,
846-
"capture_index": capture_index,
847-
"capture_count": len(viewports),
848-
"completed_count": len(captures),
849-
}
850-
aggregate_bytes = sum(capture.size_bytes for capture in captures)
851-
remaining_batch_bytes = MAX_BATCH_PAYLOAD_BYTES - aggregate_bytes
852-
if remaining_batch_bytes <= 0:
853-
assert partial_failures is not None
854-
partial_failures.append(
855-
create_error_response(
856-
ErrorCode.IMAGE_PAYLOAD_TOO_LARGE,
857-
"Screenshot image batch reached the server's safe "
858-
"inline-image limit.",
859-
context={
860-
**request_context,
861-
"aggregate_bytes_before_capture": aggregate_bytes,
862-
"limit_kind": "batch",
863-
"limit_bytes": MAX_BATCH_PAYLOAD_BYTES,
864-
},
887+
# The engine dispatches a theme write into the authenticated frontend on
888+
# cold renders, which Home Assistant persists to the engine user's real
889+
# profile (issue #1909). Snapshot before, restore after — including when
890+
# the batch fails, since the engine may already have rendered (and
891+
# written) before the failure.
892+
guard = ThemeGuard.for_capture(engine_target.addon_credential, client)
893+
await guard.take_snapshot()
894+
batch_error: ToolError | None = None
895+
try:
896+
async with httpx.AsyncClient(
897+
timeout=httpx.Timeout(options.render_timeout_seconds)
898+
) as http_client:
899+
for capture_index, viewport in enumerate(viewports):
900+
params = _viewport_params(options, viewport)
901+
902+
request_context = {
903+
"path": path,
904+
"preset": viewport.preset,
905+
"width": viewport.width,
906+
"height": viewport.height,
907+
"requested_format": options.image_format,
908+
"capture_index": capture_index,
909+
"capture_count": len(viewports),
910+
"completed_count": len(captures),
911+
}
912+
aggregate_bytes = sum(capture.size_bytes for capture in captures)
913+
remaining_batch_bytes = MAX_BATCH_PAYLOAD_BYTES - aggregate_bytes
914+
if remaining_batch_bytes <= 0:
915+
assert partial_failures is not None
916+
partial_failures.append(
917+
create_error_response(
918+
ErrorCode.IMAGE_PAYLOAD_TOO_LARGE,
919+
"Screenshot image batch reached the server's safe "
920+
"inline-image limit.",
921+
context={
922+
**request_context,
923+
"aggregate_bytes_before_capture": aggregate_bytes,
924+
"limit_kind": "batch",
925+
"limit_bytes": MAX_BATCH_PAYLOAD_BYTES,
926+
},
927+
)
865928
)
866-
)
867-
break
868-
869-
outcome = await _request_or_collect_failure(
870-
http_client,
871-
url=url,
872-
path=path,
873-
engine=engine,
874-
params=params,
875-
options=options,
876-
viewport=viewport,
877-
mime_type=mime_type,
878-
request_context=request_context,
879-
aggregate_bytes=aggregate_bytes,
880-
remaining_batch_bytes=remaining_batch_bytes,
881-
partial_failures=partial_failures,
882-
)
883-
if outcome is None:
884-
if (
885-
partial_failures
886-
and partial_failures[-1].get("limit_kind") == "batch"
887-
):
888929
break
889-
continue
890-
image_data, capture_height, fallback_used = outcome
891-
892-
captures.append(
893-
DashboardImageCapture(
894-
data=image_data,
895-
width=viewport.width,
896-
height=capture_height,
897-
preset=viewport.preset,
898-
orientation=viewport.orientation,
899-
image_format=options.image_format,
900-
mime_type=mime_type,
901-
size_bytes=len(image_data),
930+
931+
outcome = await _request_or_collect_failure(
932+
http_client,
933+
url=url,
934+
path=path,
935+
engine=engine,
936+
params=params,
902937
options=options,
903-
legacy_full_page_fallback=fallback_used,
938+
viewport=viewport,
939+
mime_type=mime_type,
940+
request_context=request_context,
941+
aggregate_bytes=aggregate_bytes,
942+
remaining_batch_bytes=remaining_batch_bytes,
943+
partial_failures=partial_failures,
904944
)
905-
)
906-
907-
return _complete_capture_batch(captures, partial_failures)
945+
if outcome is None:
946+
if (
947+
partial_failures
948+
and partial_failures[-1].get("limit_kind") == "batch"
949+
):
950+
break
951+
continue
952+
image_data, capture_height, fallback_used = outcome
953+
954+
captures.append(
955+
DashboardImageCapture(
956+
data=image_data,
957+
width=viewport.width,
958+
height=capture_height,
959+
preset=viewport.preset,
960+
orientation=viewport.orientation,
961+
image_format=options.image_format,
962+
mime_type=mime_type,
963+
size_bytes=len(image_data),
964+
options=options,
965+
legacy_full_page_fallback=fallback_used,
966+
)
967+
)
968+
except ToolError as exc:
969+
# Held (not re-raised here) so the restore in ``finally`` runs first
970+
# and its outcome can be attached to the error payload below.
971+
batch_error = exc
972+
finally:
973+
await guard.restore()
974+
if capture_warnings is not None:
975+
capture_warnings.extend(guard.warnings)
976+
977+
if batch_error is not None:
978+
_reraise_with_guard_warnings(batch_error, guard.warnings)
979+
return _complete_capture_batch(captures, partial_failures, guard.warnings)

0 commit comments

Comments
 (0)