Skip to content

Commit 08d5ecb

Browse files
mosandltclaude
andcommitted
v10.4.9 — revert v10.4.8 part 2: privacy-mode RCP override was wrong
The "Bosch cloud privacyMode lies, RCP is authoritative" claim from v10.4.8 was based on a wrong byte mapping. A/B test 2026-04-27: privacy switch ON → RCP 0x0d00 = 00 01 00 00, byte[1]=1 privacy switch OFF → RCP 0x0d00 = 00 01 00 00, byte[1]=1 byte[1] is static 1 regardless of the user-toggle, so 0x0d00 does NOT represent the privacy-mode state. rcp_findings.txt's "PRIVACY MASK" label means a separate static configuration (some kind of mask/region indicator), not the mode flag. Cloud /v11/video_inputs.privacyMode was never the lie I claimed — it was the correct source of truth all along. v10.4.8 part 2 forced a permanent false-positive on the privacy switch (showed ON whenever the camera had any mask config, regardless of user-toggle), and it shipped to GitHub + the blog post before being caught. Removed: - _async_update_data: cloud-tick override block (lines that re-checked RCP cache <120s and re-corrected after the cloud value was written). - _refresh_rcp_state: the 0x0d00 / 0x0c22 reads, the SHC-cache override, the async_update_listeners() trigger. - camera.py extra_state_attributes: rcp_privacy_mode, rcp_led_dimmer, rcp_state_age, rcp_state_source. - local_rcp.py: parse_privacy_state(), parse_led_dimmer_percent() — with a comment explaining why and what to verify before re-adding. Kept: - local_rcp.py rcp_read_local_sync / rcp_read_remote_sync (generic helpers, mechanically correct, just lacked the right semantics). - _rcp_state_cache scaffolding and the post-stream-start hook (extension point for future verified RCP+ uses). Lesson encoded as a Force-Rule in memory feedback_self_verification.md: never ship code that overrides one authoritative source with another without first running a controlled A/B toggle and confirming the new source actually changes with the toggled state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d020cb2 commit 08d5ecb

5 files changed

Lines changed: 33 additions & 132 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,7 @@ Everything renders automatically when the integration detects a Gen2 Indoor II.
857857

858858
| Version | Changes |
859859
|---------|---------|
860+
| **v10.4.9** | **Revert of v10.4.8 part 2 — privacy-mode RCP override was based on a wrong byte mapping.** A/B testing 2026-04-27 showed that RCP `0x0d00` byte[1] stays `1` regardless of the user-facing privacy-mode toggle (verified by toggling privacy ON↔OFF in HA and reading 0x0d00 before and after — no change). That byte therefore does **not** represent the privacy mode; rcp_findings.txt's "PRIVACY MASK state" label refers to a separate static configuration. The Bosch cloud `/v11/video_inputs.privacyMode` field was never the lie I claimed in v10.4.8 — it was the correct source of truth all along. **Removed:** the override block in `_async_update_data`, the mismatch override in `_refresh_rcp_state`, the `async_update_listeners()` trigger, the camera-entity attributes `rcp_privacy_mode` / `rcp_led_dimmer` / `rcp_state_age` / `rcp_state_source` (since the underlying cache is no longer populated for those keys), and the helper functions `parse_privacy_state` / `parse_led_dimmer_percent` from `local_rcp.py`. **Kept:** the generic `rcp_read_local_sync` / `rcp_read_remote_sync` helpers (correct), the `_rcp_state_cache` dict scaffolding, and the post-stream-start `_refresh_rcp_state` hook (now a marker, ready for future verified RCP+ reads). The lesson: never ship a feature that overrides authoritative state from one source with another, without first confirming via a controlled toggle that the new source actually reflects the toggled value. |
860861
| **v10.4.8** | **Local RCP+ READ via the ad-hoc `cbs-…`-user from `PUT /connection`** + **Bosch Cloud `privacyMode` correction.** Two parts: **(1) RCP+ reads.** New module `local_rcp.py` issues HTTP Digest reads against `https://<cam>:443/rcp.xml` (LOCAL session) and HTTP Basic-empty against `https://proxy-XX:42090/{hash}/rcp.xml` (REMOTE session — Cloud-Proxy fallback when HA is not on the LAN). Verified on Gen2 Outdoor FW 9.40.25: 10 reads/10 s did not rotate creds or kill the running stream — only `PUT /connection` rotates, normal RCP reads are safe. Two fields pulled opportunistically after each successful stream start: `rcp_privacy_mode` (from `0x0d00` P_OCTET, byte[1]==1 means ON) and `rcp_led_dimmer` (from `0x0c22` T_WORD, 0–100 %). Exposed as camera entity diagnostic attributes plus `rcp_state_age` (seconds since last read) and `rcp_state_source` (`local` / `remote`). **(2) Privacy-mode correction.** Diagnosed live 2026-04-27: Bosch Cloud `/v11/video_inputs.privacyMode` returned `'OFF'` for the Terrasse (Gen2 Outdoor, ONLINE, physically in privacy) while every offline camera and the camera's own RCP read correctly returned `ON`. The HA `switch.bosch_<cam>_privacy_mode` entity, the `BoschLiveStreamSwitch.available` gate, the snapshot-fetch short-circuit, and `try_live_connection`'s privacy guard all read `_shc_state_cache.privacy_mode` — so the cloud lie propagated everywhere. **Fix:** RCP+ now refines the SHC cache aggressively when (a) SHC is None (unconfigured, was already the v10.4.8-part-1 behavior), or (b) SHC and RCP disagree and no user-write lock is active — RCP wins because it reads camera hardware directly. Two override sites: `_refresh_rcp_state` corrects on each stream start, and the Cloud-Coordinator-Tick re-checks the RCP cache (≤120 s old) and re-corrects after every cloud refresh, so the cloud lie cannot resurface. `async_update_listeners()` is fired on each correction so the privacy switch flips immediately, without waiting for the next 60 s tick. The local `/rcp.xml` endpoint returns XML (not the binary TLV the Cloud-Proxy uses on the same path), so the parser is XML-based. Read-only — writes still need the `service`-account credentials Bosch will release with the Sommer 2026 local-user feature. |
861862
| **v10.4.7** | **New option: HLS player buffer profile (`live_buffer_mode`).** Adds an integration-options dropdown to choose how aggressively the Lovelace card pre-buffers video before showing it. Three modes: **Latency** (~4-6 s lag, may stutter on flaky Wi-Fi), **Balanced** (~8-10 s lag, default — robust against typical Wi-Fi hiccups), **Stable** (~12-15 s lag, smooth even on weak links). Mapping is hardcoded client-side in the card: each mode sets `liveSyncDurationCount`, `liveMaxLatencyDurationCount`, `maxBufferLength`, `maxMaxBufferLength`, and `lowLatencyMode` on the hls.js instance. The previous values (`3 / 6 / 10 / 20 / true`) corresponded roughly to "Latency"; the new default is "Balanced" (`4 / 8 / 14 / 22 / false`), which is why existing users may see slightly more lag (~2 s) but fewer stutters out of the box. The `maxBufferLength` cap stays well below HA's 30 s `OUTPUT_IDLE_TIMEOUT` for all three modes, so FFmpeg is never killed by the idle watchdog. Audio quality is higher than the official Bosch app — the mobile app downsamples audio for cellular bandwidth, while this integration delivers the unmodified AAC-LC stream. **Also fixed a UX confusion:** the card's "Reaktion" info field now has a tooltip clarifying that the `500 ms` / `1000 ms` value shown is the Bosch-API response hint (`bufferingTime` from `PUT /connection`), not the player buffer — the latter is now controlled by the new `live_buffer_mode` option in integration settings. |
862863
| **v10.4.6** | **Three hardening changes. (1) Privacy enforcement — stream cannot be started when Privacy Mode is ON.** Four bypass paths existed: `BoschLiveStreamSwitch.available` returned `True` while privacy was active (entity appeared clickable); `async_turn_on` used a fragile string comparison (`str(…).upper() in ("ON", "TRUE", "1")`) and issued a `persistent_notification` on the old code path; `BoschAudioSwitch._apply_audio_change` called `try_live_connection` without checking privacy; and `coordinator.try_live_connection()` had no guard at all. Fixes: `available` now gates on `bool(_shc_state_cache.get(cam_id, {}).get("privacy_mode"))` (entity greys out); `async_turn_on` raises `ServiceValidationError` (HA toast in UI, clean exception — no more persistent notification); `_apply_audio_change` logs a warning and returns early; `try_live_connection` has an early-exit guard (fail-open when cache not yet populated at boot). **(2) Icon — no changes needed.** Legal assessment confirmed the current SVG does not reproduce the Bosch trademark (uses Bosch red as a color only, not the circular wordmark). **(3) Translation fixes (EN + DE).** DE: standardised formality to informal "du" throughout (`user.description` heading); added missing `debug_logging` label (was in EN, absent in DE); corrected `alert_save_snapshots` path `/www/bosch_alerts/` → `/media/bosch_alerts/`. EN: already consistent, no changes. |

custom_components/bosch_shc_camera/__init__.py

Lines changed: 19 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,34 +1389,6 @@ async def _fetch_events(cam_id: str) -> tuple[str, list]:
13891389
)
13901390
if privacy_str and not privacy_locked:
13911391
cache["privacy_mode"] = (privacy_str.upper() == "ON")
1392-
# ── RCP override of Cloud-Coordinator privacy_mode ────────
1393-
# Bosch Cloud /v11/video_inputs.privacyMode has been observed
1394-
# to misreport `OFF` while the camera is physically in privacy
1395-
# (Gen2 Outdoor, FW 9.40.25, 2026-04-27). RCP+ reads directly
1396-
# from the camera hardware and is authoritative when it has
1397-
# a fresh value. Override Cloud only when:
1398-
# * RCP cache exists and is fresh (<120 s old)
1399-
# * RCP and Cloud disagree
1400-
# * No user-write lock currently active
1401-
rcp_entry = self._rcp_state_cache.get(cam_id_key, {})
1402-
rcp_priv = rcp_entry.get("privacy_mode")
1403-
rcp_age = (
1404-
time.monotonic() - rcp_entry["fetched_at"]
1405-
if "fetched_at" in rcp_entry else None
1406-
)
1407-
if (
1408-
rcp_priv is not None
1409-
and rcp_age is not None and rcp_age < 120
1410-
and not privacy_locked
1411-
and cache.get("privacy_mode") != rcp_priv
1412-
):
1413-
_LOGGER.info(
1414-
"privacy_mode mismatch %s: cloud=%r, RCP=%r (age %.0fs) — "
1415-
"trusting RCP (Bosch cloud /v11/video_inputs.privacyMode "
1416-
"occasionally lies for ONLINE cams in privacy)",
1417-
cam_id_key[:8], cache.get("privacy_mode"), rcp_priv, rcp_age,
1418-
)
1419-
cache["privacy_mode"] = rcp_priv
14201392
cache["has_light"] = has_light
14211393
# Use cloud featureStatus for light state; SHC supplements if available.
14221394
# Skip overwrite if a write happened within _WRITE_LOCK_SECS — the cloud
@@ -2708,83 +2680,33 @@ async def _rcp_read_active(self, cam_id: str, command: str, type_: str):
27082680
return None
27092681

27102682
async def _refresh_rcp_state(self, cam_id: str) -> None:
2711-
"""Pull privacy_mode (0x0d00) + LED dimmer (0x0c22) via RCP+ and cache.
2712-
2713-
Hooked from `_try_live_connection_inner` after a successful stream start
2714-
(LOCAL post-warmup or REMOTE post-PUT). Never raises. SHC remains the
2715-
primary truth-source — this only refines `_shc_state_cache` entries that
2716-
are still None (SHC unreachable / unconfigured).
2683+
"""Hook fired after a successful stream start. Currently a no-op marker.
2684+
2685+
Earlier versions (v10.4.8) read RCP `0x0d00` and `0x0c22` here and
2686+
interpreted them as privacy-mode and LED-dimmer state. A/B testing
2687+
proved both interpretations were wrong (0x0d00 byte[1] stayed 1
2688+
independent of the privacy toggle, so it is NOT the mode flag), so
2689+
the reads were removed in v10.4.9. The hook itself is kept as a
2690+
cheap extension point for future verified RCP+ uses.
27172691
"""
2718-
from .local_rcp import parse_privacy_state, parse_led_dimmer_percent
2719-
27202692
live = self._live_connections.get(cam_id, {})
27212693
source = live.get("_connection_type", "?").lower()
27222694
cache = self._rcp_state_cache.setdefault(cam_id, {})
27232695

2724-
# Privacy state: 0x0d00 P_OCTET → 4 bytes, byte[1]==1 means ENABLED.
2725-
priv_payload = await self._rcp_read_active(cam_id, "0x0d00", "P_OCTET")
2726-
priv = parse_privacy_state(priv_payload)
2727-
if priv is not None:
2728-
cache["privacy_mode"] = priv
2729-
shc_entry = self._shc_state_cache.setdefault(cam_id, {
2730-
"camera_light": None, "privacy_mode": None,
2731-
})
2732-
shc_priv = shc_entry.get("privacy_mode")
2733-
# Override SHC cache with RCP value when:
2734-
# * SHC cache is None (unconfigured / unfilled), OR
2735-
# * SHC and RCP disagree AND no write-lock is active
2736-
# (Bosch cloud /v11/video_inputs.privacyMode has been observed
2737-
# to misreport `OFF` for ONLINE cams in privacy — Gen2 Outdoor
2738-
# FW 9.40.25, 2026-04-27. RCP reads camera hardware directly.)
2739-
privacy_locked = (
2740-
cam_id in self._privacy_set_at
2741-
and (time.monotonic() - self._privacy_set_at[cam_id]) < self._WRITE_LOCK_SECS
2742-
)
2743-
if shc_priv is None:
2744-
shc_entry["privacy_mode"] = priv
2745-
_LOGGER.debug(
2746-
"rcp refresh: %s privacy_mode=%s (filled SHC-cache via %s RCP)",
2747-
cam_id[:8], priv, source,
2748-
)
2749-
elif shc_priv != priv and not privacy_locked:
2750-
_LOGGER.info(
2751-
"rcp refresh: %s privacy_mode mismatch — SHC=%s, RCP=%s — "
2752-
"trusting RCP (camera hardware is authoritative; cloud can lag/lie)",
2753-
cam_id[:8], shc_priv, priv,
2754-
)
2755-
shc_entry["privacy_mode"] = priv
2756-
# Push the corrected state to all entities that read the cache
2757-
# (privacy switch, light gates, etc.) so the user sees the truth
2758-
# immediately, without waiting for the next coordinator tick.
2759-
try:
2760-
self.async_update_listeners()
2761-
except Exception as err: # noqa: BLE001
2762-
_LOGGER.debug("rcp refresh: %s listener update skipped (%s)", cam_id[:8], err)
2763-
else:
2764-
_LOGGER.debug(
2765-
"rcp refresh: %s privacy_mode=%s (SHC agrees, RCP cached only)",
2766-
cam_id[:8], priv,
2767-
)
2768-
2769-
# LED dimmer: 0x0c22 T_WORD → 0–100.
2770-
led_raw = await self._rcp_read_active(cam_id, "0x0c22", "T_WORD")
2771-
led = parse_led_dimmer_percent(led_raw)
2772-
if led is not None:
2773-
cache["led_dimmer"] = led
2774-
_LOGGER.debug("rcp refresh: %s led_dimmer=%d%% via %s RCP", cam_id[:8], led, source)
2775-
2696+
# NOTE: 0x0d00 P_OCTET (4 bytes) was previously read here as
2697+
# "privacy_mode" via byte[1]==1, but A/B testing 2026-04-27 proved
2698+
# this byte is NOT the privacy-mode toggle — it stays at 1 even
2699+
# when privacy is OFF, so it likely reflects a static mask-config
2700+
# or some other always-on indicator. The Bosch cloud
2701+
# `/v11/video_inputs.privacyMode` field is the correct source of
2702+
# truth and was never the lie I thought it was. Reverted in v10.4.9.
2703+
#
2704+
# 0x0c22 T_WORD was likewise read as "led_dimmer 0-100" but its
2705+
# semantics are unverified vs. the actual user-facing dimmer.
2706+
# Pulled out until properly mapped against ground-truth.
27762707
if cache:
27772708
cache["source"] = source
27782709
cache["fetched_at"] = time.monotonic()
2779-
# Push the new attribute values to HA state so the REST API,
2780-
# the Lovelace card, and any automations see the fresh data
2781-
# without waiting for the next coordinator tick.
2782-
cam_entity = self._camera_entities.get(cam_id)
2783-
if cam_entity is not None:
2784-
try:
2785-
cam_entity.async_write_ha_state()
2786-
except Exception as err: # noqa: BLE001
2787-
_LOGGER.debug("rcp refresh: %s state-write skipped (%s)", cam_id[:8], err)
27882710

27892711
async def _check_and_recover_webrtc(self, cam_id: str) -> None:
27902712
"""Watchdog for HA's bundled go2rtc WebRTCProvider stale-schemes bug.

custom_components/bosch_shc_camera/camera.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -369,21 +369,6 @@ def extra_state_attributes(self) -> dict:
369369
attrs["live_buffer_mode"] = get_options(self._entry).get(
370370
"live_buffer_mode", "balanced"
371371
)
372-
# Local-RCP+ derived state — refreshed opportunistically after each
373-
# successful PUT /connection. Used as fallback for SHC-driven values
374-
# (privacy_mode, camera_light/dimmer) when SHC is unreachable. Survives
375-
# past stream-end; "rcp_state_age" tells consumers if the value is
376-
# stale (in seconds since last successful read).
377-
rcp_cache = getattr(self.coordinator, "_rcp_state_cache", {}).get(
378-
self._cam_id, {}
379-
)
380-
if "privacy_mode" in rcp_cache:
381-
attrs["rcp_privacy_mode"] = rcp_cache["privacy_mode"]
382-
if "led_dimmer" in rcp_cache:
383-
attrs["rcp_led_dimmer"] = rcp_cache["led_dimmer"]
384-
if "fetched_at" in rcp_cache:
385-
attrs["rcp_state_age"] = round(time.monotonic() - rcp_cache["fetched_at"])
386-
attrs["rcp_state_source"] = rcp_cache.get("source", "?")
387372
return attrs
388373

389374
# ── Live stream ───────────────────────────────────────────────────────────

custom_components/bosch_shc_camera/local_rcp.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -124,22 +124,15 @@ def rcp_read_remote_sync(
124124
return None
125125

126126

127-
# ── Field-specific helpers ──────────────────────────────────────────────────
128-
129-
def parse_privacy_state(payload: bytes | None) -> bool | None:
130-
"""Decode 0x0d00 P_OCTET (4 bytes). byte[1]==1 means privacy ENABLED.
131-
132-
Verified 2026-04-27 vs known privacy-on state (Terrasse Gen2, FW 9.40.25).
133-
"""
134-
if not isinstance(payload, (bytes, bytearray)) or len(payload) < 2:
135-
return None
136-
return payload[1] == 1
137-
138-
139-
def parse_led_dimmer_percent(value: int | None) -> int | None:
140-
"""Decode 0x0c22 T_WORD — already a 0–100 int from the parser. Clamp & validate."""
141-
if not isinstance(value, int):
142-
return None
143-
if 0 <= value <= 100:
144-
return value
145-
return None
127+
# ── Field-specific helpers — RETIRED ────────────────────────────────────────
128+
# Earlier versions exported parse_privacy_state(0x0d00) and
129+
# parse_led_dimmer_percent(0x0c22) here. Both were removed in v10.4.9 after
130+
# A/B testing proved the byte mappings did NOT match the user-facing
131+
# privacy-mode toggle (0x0d00 byte[1] stayed 1 even with the mode OFF).
132+
# rcp_findings.txt's "PRIVACY MASK" label was taken to mean "privacy mode";
133+
# it doesn't. Don't add field-specific helpers here again without:
134+
# 1. Toggling the user-facing setting both ways
135+
# 2. Re-reading the RCP value after each toggle
136+
# 3. Confirming the RCP value actually changes
137+
# The generic rcp_read_local_sync / rcp_read_remote_sync helpers remain
138+
# correct and are kept for future verified uses.

custom_components/bosch_shc_camera/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"issue_tracker": "https://github.com/mosandlt/Bosch-Smart-Home-Camera-Tool-HomeAssistant/issues",
1111
"loggers": ["bosch_shc_camera"],
1212
"requirements": ["requests>=2.28.0", "firebase-messaging>=0.4.0", "smbprotocol>=1.10.0"],
13-
"version": "10.4.8"
13+
"version": "10.4.9"
1414
}

0 commit comments

Comments
 (0)