Skip to content

Commit d7a49e1

Browse files
committed
feat: image rotation 180° switch for ceiling-mounted indoor cameras
Adds a per-camera switch (Indoor only — Gen1 360 + Gen2 Indoor II) that rotates the camera image 180° for upside-down ceiling mounting. Bosch firmware exposes no native rotation API, so this is implemented client- side at three layers: - Lovelace card: CSS transform rotate(180deg) on <video> + <img>. Zero CPU, zero latency, GPU-composited; toggle is instant with no stream restart and no re-encode. - Snapshot path: PIL rotation in camera.async_camera_image() so push notifications, NAS clips, and other consumers see the right-way-up image (~15-30 ms per snapshot). - 360 PTZ pan: BoschPanNumber inverts the slider sign when rotation is on, so "right" stays "right" on screen even upside-down. State persists across HA restarts via RestoreEntity. Default OFF. Outdoor cameras (Gen1 Eyes Außenkamera, Gen2 Eyes Außenkamera II) do not get the switch — fixed mounting orientation by design. Card v2.11.1 ships alongside.
1 parent 085febd commit d7a49e1

13 files changed

Lines changed: 193 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ versions see this file or the [GitHub Releases page](https://github.com/mosandlt
77

88
---
99

10+
## v10.6.0
11+
12+
**Image rotation 180° for ceiling-mounted indoor cameras.** New per-camera switch `switch.bosch_<cam>_bild_180deg_drehen` that rotates the camera image by 180° for upside-down (ceiling) mounting. Indoor-only — outdoor cameras have a fixed mounting orientation and don't get the switch. Three layers of effect, all client-side (Bosch firmware does not expose any image-rotation API):
13+
14+
- **Lovelace card** applies a CSS `transform: rotate(180deg)` to the `<video>` and `<img>` elements. Zero CPU, zero latency, GPU-composited — the toggle is instant with no stream restart and no re-encode.
15+
- **Snapshot path** rotates the JPEG via PIL before serving it through `camera.async_camera_image()`, so push notifications, NAS clip uploads, and any other consumer that reads the camera entity also see the right-way-up image. ~15-30 ms per snapshot.
16+
- **PTZ pan inversion** — for the Gen1 360 camera, `BoschPanNumber` automatically inverts the slider sign when the rotation switch is on, so "right" on the slider stays "right" on the user's screen even when the camera is upside-down.
17+
18+
State persists across HA restarts via `RestoreEntity`. Default OFF. Available on Gen1 360 Innenkamera and Gen2 Eyes Innenkamera II.
19+
20+
**Card v2.11.1** ships alongside.
21+
1022
## v10.5.4
1123

1224
**Stream switch unblocked when prior session has expired upstream.** When a previous live session had its underlying URL invalidated (e.g. the relay-side lifetime cap was reached while the switch was still ON), HA's `Stream.stop()` could block waiting for a stuck FFmpeg reconnect-loop to exit. Both teardown paths (`_tear_down_live_stream` shared exit, fresh-toggle stale-Stream invalidation in `_try_live_connection_inner`) now wrap the call in `asyncio.wait_for(timeout=5)` and force-detach on timeout. Without this, a single hung `stream.stop()` held the per-camera setup lock for >5 minutes and every subsequent switch-ON returned `try_live_connection: already in progress for ... — skipping`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,8 +1116,8 @@ Features investigated or intentionally parked — listed here so the direction i
11161116

11171117
## Releases
11181118

1119-
Latest stable: **v10.5.4** — see the GitHub release page for full notes:
1120-
[**v10.5.4 release notes →**](https://github.com/mosandlt/Bosch-Smart-Home-Camera-Tool-HomeAssistant/releases/tag/v10.5.4)
1119+
Latest stable: **v10.6.0** — see the GitHub release page for full notes:
1120+
[**v10.6.0 release notes →**](https://github.com/mosandlt/Bosch-Smart-Home-Camera-Tool-HomeAssistant/releases/tag/v10.6.0)
11211121

11221122
| | |
11231123
|---|---|

custom_components/bosch_shc_camera/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
411411
self._audio_cache: dict[str, dict] = {}
412412
# Motion light cache — keyed by cam_id, from GET /lighting/motion (Gen2 only)
413413
self._motion_light_cache: dict[str, dict] = {}
414+
# Image rotation 180° flag — keyed by cam_id, indoor cameras only.
415+
# No API call — purely a client-side display flag for ceiling-mounted cams.
416+
# Read by camera.async_camera_image (rotates JPEG via PIL) and by the
417+
# Pan number entity (inverts sign so "right" stays "right" on screen).
418+
# State is owned by BoschImageRotation180Switch (RestoreEntity).
419+
self._image_rotation_180: dict[str, bool] = {}
414420
# Ambient lighting config cache — keyed by cam_id, from GET /lighting/ambient (Gen2 only)
415421
self._ambient_lighting_cache: dict[str, dict] = {}
416422
# Lighting switch cache — keyed by cam_id, from GET /lighting/switch (Gen2 only)

custom_components/bosch_shc_camera/camera.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,26 @@
4343
IDLE_FRAME_INTERVAL = 60 # seconds — how often HA's camera proxy calls async_camera_image
4444

4545

46+
def _rotate_jpeg_180(jpeg_bytes: bytes) -> bytes:
47+
"""Rotate a JPEG image by 180° using PIL. Sync — call via executor.
48+
49+
Used by async_camera_image when the user enabled the Bild 180° drehen
50+
switch (ceiling-mounted indoor cameras). Typical 1280×720 JPEG: ~15-30 ms
51+
with libjpeg-turbo. Returns the original bytes if rotation fails.
52+
"""
53+
try:
54+
from PIL import Image
55+
from io import BytesIO
56+
img = Image.open(BytesIO(jpeg_bytes))
57+
rotated = img.rotate(180)
58+
out = BytesIO()
59+
rotated.save(out, format="JPEG", quality=90)
60+
return out.getvalue()
61+
except Exception as err: # noqa: BLE001
62+
_LOGGER.debug("rotate_jpeg_180 failed (%s) — returning original", err)
63+
return jpeg_bytes
64+
65+
4666
async def async_setup_entry(
4767
hass: HomeAssistant,
4868
config_entry: ConfigEntry,
@@ -515,15 +535,21 @@ async def async_camera_image(
515535
"""
516536
try:
517537
result = await self._async_camera_image_impl(width, height)
518-
return result if result else self._PLACEHOLDER_JPEG
538+
jpeg = result if result else self._PLACEHOLDER_JPEG
519539
except asyncio.CancelledError:
520540
raise # let cancellation propagate cleanly
521541
except Exception as err: # noqa: BLE001
522542
_LOGGER.debug(
523543
"%s: async_camera_image failed (%s) — serving placeholder",
524544
self._attr_name, err,
525545
)
526-
return self._cached_image or self._PLACEHOLDER_JPEG
546+
jpeg = self._cached_image or self._PLACEHOLDER_JPEG
547+
# Apply 180° rotation if the user enabled it via the Bild 180° drehen
548+
# switch (ceiling-mounted indoor cameras). Skip the placeholder JPEG.
549+
rotate = bool(getattr(self.coordinator, "_image_rotation_180", {}).get(self._cam_id))
550+
if rotate and jpeg is not self._PLACEHOLDER_JPEG and jpeg:
551+
jpeg = await self.hass.async_add_executor_job(_rotate_jpeg_180, jpeg)
552+
return jpeg
527553

528554
async def _async_camera_image_impl(
529555
self, width: int | None = None, height: int | None = None

custom_components/bosch_shc_camera/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# Lovelace card version — must match CARD_VERSION in src/bosch-camera-card.js.
66
# Bumped here alongside every card release so the auto-registered resource URL
77
# changes and browsers fetch the new file (HA serves www/ with max-age=31 days).
8-
CARD_VERSION = "2.10.21"
8+
CARD_VERSION = "2.11.1"
99
CLOUD_API = "https://residential.cbs.boschsecurity.com"
1010

1111
ALL_PLATFORMS = [

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.5.4"
13+
"version": "10.6.0"
1414
}

custom_components/bosch_shc_camera/number.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,21 @@ def device_info(self) -> dict:
110110
"connections": {("mac", self._mac)} if self._mac else set(),
111111
}
112112

113+
def _rotation_180(self) -> bool:
114+
"""Return True if the camera is configured as ceiling-mounted (image
115+
rotated 180°). When True, the slider sign is inverted so that "right"
116+
on the slider stays "right" on the user's screen.
117+
"""
118+
return bool(
119+
getattr(self.coordinator, "_image_rotation_180", {}).get(self._cam_id)
120+
)
121+
113122
@property
114123
def native_value(self) -> float | None:
115-
return self.coordinator._pan_cache.get(self._cam_id)
124+
raw = self.coordinator._pan_cache.get(self._cam_id)
125+
if raw is None:
126+
return None
127+
return -raw if self._rotation_180() else raw
116128

117129
@property
118130
def available(self) -> bool:
@@ -122,7 +134,10 @@ def available(self) -> bool:
122134
)
123135

124136
async def async_set_native_value(self, value: float) -> None:
125-
await self.coordinator.async_cloud_set_pan(self._cam_id, int(value))
137+
# Invert sign when the camera is ceiling-mounted so the user-visible
138+
# direction matches the camera-physical pan direction.
139+
actual = -int(value) if self._rotation_180() else int(value)
140+
await self.coordinator.async_cloud_set_pan(self._cam_id, actual)
126141

127142

128143
# ─────────────────────────────────────────────────────────────────────────────

custom_components/bosch_shc_camera/switch.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from homeassistant.exceptions import ServiceValidationError
4141
from homeassistant.helpers.entity import EntityCategory
4242
from homeassistant.helpers.entity_platform import AddEntitiesCallback
43+
from homeassistant.helpers.restore_state import RestoreEntity
4344
from homeassistant.helpers.update_coordinator import CoordinatorEntity
4445

4546
from . import DOMAIN, get_options, CLOUD_API
@@ -48,6 +49,7 @@
4849

4950

5051
_GEN2_INDOOR_HW = {"HOME_Eyes_Indoor", "CAMERA_INDOOR_GEN2"}
52+
_INDOOR_HW = {"INDOOR", "CAMERA_360", "HOME_Eyes_Indoor", "CAMERA_INDOOR_GEN2"}
5153

5254

5355
def _is_gen2_indoor(entity) -> bool:
@@ -212,6 +214,13 @@ async def async_setup_entry(
212214
entities.append(BoschAlarmModeSwitch(coordinator, cam_id, config_entry))
213215
entities.append(BoschPreAlarmSwitch(coordinator, cam_id, config_entry))
214216
entities.append(BoschAudioAlarmSwitch(coordinator, cam_id, config_entry))
217+
# Image rotation 180° — only for indoor cameras (Gen1 360 + Gen2 Indoor II).
218+
# Outdoor cameras have a fixed mounting orientation by design and don't
219+
# need this. The switch is purely client-side display state — the card
220+
# applies CSS transform, the snapshot path applies PIL rotation, and
221+
# (for Gen1 360) the pan slider sign is inverted.
222+
if hw_version in _INDOOR_HW:
223+
entities.append(BoschImageRotation180Switch(coordinator, cam_id, config_entry))
215224
async_add_entities(entities, update_before_add=False)
216225

217226

@@ -1684,3 +1693,62 @@ async def async_turn_on(self, **kwargs):
16841693

16851694
async def async_turn_off(self, **kwargs):
16861695
await self._set(False)
1696+
1697+
1698+
# ─────────────────────────────────────────────────────────────────────────────
1699+
class BoschImageRotation180Switch(_BoschSwitchBase, RestoreEntity):
1700+
"""Switch: ON = display the camera image rotated 180° (ceiling mount).
1701+
1702+
Indoor-only — outdoor cameras have a fixed mounting orientation. Bosch's
1703+
Cloud API does not expose any image-rotation field; this switch is a
1704+
pure client-side display flag with three effects:
1705+
1706+
1. The Lovelace card applies `transform: rotate(180deg)` to its <video>
1707+
and <img> elements (zero CPU, zero latency, GPU-composited).
1708+
2. `camera.async_camera_image()` rotates the snapshot JPEG via PIL
1709+
before serving it (so push notifications, NAS clips, and other
1710+
consumers see the right-way-up image too).
1711+
3. For PTZ cameras (Gen1 360), `BoschPanNumber` inverts the sign of
1712+
the pan value so "right" on the slider stays "right" on screen
1713+
even when the camera is upside-down.
1714+
1715+
State persists across restarts via RestoreEntity. Default: OFF.
1716+
"""
1717+
1718+
def __init__(self, coordinator, cam_id: str, entry: ConfigEntry) -> None:
1719+
super().__init__(coordinator, cam_id, entry)
1720+
self._attr_name = f"Bosch {self._cam_title} Bild 180° drehen"
1721+
self._attr_unique_id = f"bosch_shc_camera_{cam_id}_image_rotation_180"
1722+
self._attr_icon = "mdi:image-auto-adjust"
1723+
self._attr_translation_key = "image_rotation_180"
1724+
self._attr_entity_category = EntityCategory.CONFIG
1725+
1726+
@property
1727+
def is_on(self) -> bool:
1728+
return bool(self.coordinator._image_rotation_180.get(self._cam_id, False))
1729+
1730+
@property
1731+
def available(self) -> bool:
1732+
# Always available — pure client-side flag, no API dependency
1733+
return self.coordinator.last_update_success
1734+
1735+
async def async_added_to_hass(self) -> None:
1736+
await super().async_added_to_hass()
1737+
last = await self.async_get_last_state()
1738+
if last is not None and last.state == "on":
1739+
self.coordinator._image_rotation_180[self._cam_id] = True
1740+
_LOGGER.debug(
1741+
"image_rotation_180: restored ON for %s from previous state",
1742+
self._cam_id[:8],
1743+
)
1744+
1745+
async def async_turn_on(self, **kwargs):
1746+
self.coordinator._image_rotation_180[self._cam_id] = True
1747+
self.async_write_ha_state()
1748+
# Notify pan number entity to refresh display value (sign flips).
1749+
self.coordinator.async_update_listeners()
1750+
1751+
async def async_turn_off(self, **kwargs):
1752+
self.coordinator._image_rotation_180[self._cam_id] = False
1753+
self.async_write_ha_state()
1754+
self.coordinator.async_update_listeners()

custom_components/bosch_shc_camera/translations/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@
220220
"intrusion_detection": {
221221
"name": "Einbrucherkennung"
222222
},
223+
"image_rotation_180": {
224+
"name": "Bild 180° drehen"
225+
},
223226
"alarm_system_arm": {
224227
"name": "Alarmanlage"
225228
},

custom_components/bosch_shc_camera/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@
220220
"intrusion_detection": {
221221
"name": "Intrusion Detection"
222222
},
223+
"image_rotation_180": {
224+
"name": "Rotate Image 180°"
225+
},
223226
"alarm_system_arm": {
224227
"name": "Alarm System"
225228
},

0 commit comments

Comments
 (0)