|
40 | 40 | from homeassistant.exceptions import ServiceValidationError |
41 | 41 | from homeassistant.helpers.entity import EntityCategory |
42 | 42 | from homeassistant.helpers.entity_platform import AddEntitiesCallback |
| 43 | +from homeassistant.helpers.restore_state import RestoreEntity |
43 | 44 | from homeassistant.helpers.update_coordinator import CoordinatorEntity |
44 | 45 |
|
45 | 46 | from . import DOMAIN, get_options, CLOUD_API |
|
48 | 49 |
|
49 | 50 |
|
50 | 51 | _GEN2_INDOOR_HW = {"HOME_Eyes_Indoor", "CAMERA_INDOOR_GEN2"} |
| 52 | +_INDOOR_HW = {"INDOOR", "CAMERA_360", "HOME_Eyes_Indoor", "CAMERA_INDOOR_GEN2"} |
51 | 53 |
|
52 | 54 |
|
53 | 55 | def _is_gen2_indoor(entity) -> bool: |
@@ -212,6 +214,13 @@ async def async_setup_entry( |
212 | 214 | entities.append(BoschAlarmModeSwitch(coordinator, cam_id, config_entry)) |
213 | 215 | entities.append(BoschPreAlarmSwitch(coordinator, cam_id, config_entry)) |
214 | 216 | 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)) |
215 | 224 | async_add_entities(entities, update_before_add=False) |
216 | 225 |
|
217 | 226 |
|
@@ -1684,3 +1693,62 @@ async def async_turn_on(self, **kwargs): |
1684 | 1693 |
|
1685 | 1694 | async def async_turn_off(self, **kwargs): |
1686 | 1695 | 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() |
0 commit comments