diff --git a/.coveragerc b/.coveragerc index 35cdb0002f1..5198d8c34b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1112,6 +1112,7 @@ omit = homeassistant/components/tuya/__init__.py homeassistant/components/tuya/base.py homeassistant/components/tuya/binary_sensor.py + homeassistant/components/tuya/camera.py homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py homeassistant/components/tuya/fan.py diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py new file mode 100644 index 00000000000..78c38725d7e --- /dev/null +++ b/homeassistant/components/tuya/camera.py @@ -0,0 +1,130 @@ +"""Support for Tuya cameras.""" +from __future__ import annotations + +from tuya_iot import TuyaDevice, TuyaDeviceManager + +from homeassistant.components import ffmpeg +from homeassistant.components.camera import SUPPORT_STREAM, Camera as CameraEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HomeAssistantTuyaData +from .base import TuyaEntity +from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +CAMERAS: tuple[str, ...] = ( + # Smart Camera (including doorbells) + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sp", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Tuya cameras dynamically through Tuya discovery.""" + hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + + @callback + def async_discover_device(device_ids: list[str]) -> None: + """Discover and add a discovered Tuya camera.""" + entities: list[TuyaCameraEntity] = [] + for device_id in device_ids: + device = hass_data.device_manager.device_map[device_id] + if device.category in CAMERAS: + entities.append(TuyaCameraEntity(device, hass_data.device_manager)) + + async_add_entities(entities) + + async_discover_device([*hass_data.device_manager.device_map]) + + entry.async_on_unload( + async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) + ) + + +class TuyaCameraEntity(TuyaEntity, CameraEntity): + """Tuya Camera Entity.""" + + def __init__( + self, + device: TuyaDevice, + device_manager: TuyaDeviceManager, + ) -> None: + """Init Tuya Camera.""" + super().__init__(device, device_manager) + CameraEntity.__init__(self) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_STREAM + + @property + def is_recording(self) -> bool: + """Return true if the device is recording.""" + return self.device.status.get(DPCode.RECORD_SWITCH, False) + + @property + def brand(self) -> str | None: + """Return the camera brand.""" + return "Tuya" + + @property + def motion_detection_enabled(self) -> bool: + """Return the camera motion detection status.""" + return self.device.status.get(DPCode.MOTION_SWITCH, False) + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + + def _stream_source() -> str | None: + # This method can be replaced by the following snippet, once + # upstream changes have been merged. + # + # return self.device_manager.get_device_stream_allocate( + # self.device.id, stream_type="rtsp" + # ) + # + # https://github.com/tuya/tuya-iot-python-sdk/pull/28 + + response = self.device_manager.api.post( + f"/v1.0/devices/{self.device.id}/stream/actions/allocate", + {"type": "rtsp"}, + ) + if response["success"]: + return response["result"]["url"] + return None + + return await self.hass.async_add_executor_job(_stream_source) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + stream_source = await self.stream_source() + if not stream_source: + return None + return await ffmpeg.async_get_image( + self.hass, + stream_source, + width=width, + height=height, + ) + + @property + def model(self) -> str | None: + """Return the camera model.""" + return self.device.product_name + + def enable_motion_detection(self) -> None: + """Enable motion detection in the camera.""" + self._send_command([{"code": DPCode.MOTION_SWITCH, "value": True}]) + + def disable_motion_detection(self) -> None: + """Disable motion detection in camera.""" + self._send_command([{"code": DPCode.MOTION_SWITCH, "value": False}]) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index b68d2573717..7dc1e529a47 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -46,8 +46,9 @@ TUYA_SUPPORTED_PRODUCT_CATEGORIES = ( "pc", # Power Strip "pir", # PIR Detector "qn", # Heater - "sos", # SOS Button "sgbj", # Siren Alarm + "sos", # SOS Button + "sp", # Smart Camera "wk", # Thermostat "xdd", # Ceiling Light "xxj", # Diffuser @@ -58,6 +59,7 @@ SMARTLIFE_APP = "smartlife" PLATFORMS = [ "binary_sensor", + "camera", "climate", "fan", "light", @@ -106,10 +108,12 @@ class DPCode(str, Enum): LOCK = "lock" # Lock / Child lock MATERIAL = "material" # Material MODE = "mode" # Working mode / Mode + MOTION_SWITCH = "motion_switch" # Motion switch MUFFLING = "muffling" # Muffling PIR = "pir" # Motion sensor POWDER_SET = "powder_set" # Powder PUMP_RESET = "pump_reset" # Water pump reset + RECORD_SWITCH = "record_switch" # Recording switch SHAKE = "shake" # Oscillating SOS = "sos" # Emergency State SOS_STATE = "sos_state" # Emergency mode diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 8412c40dfc0..5e59f2a3e8e 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -3,6 +3,7 @@ "name": "Tuya", "documentation": "https://github.com/tuya/tuya-home-assistant", "requirements": ["tuya-iot-py-sdk==0.5.0"], + "dependencies": ["ffmpeg"], "codeowners": ["@Tuya", "@zlinoliver", "@METISU", "@frenck"], "config_flow": true, "iot_class": "cloud_push",