Просмотр исходного кода

Camera support: BCom intercom camera.

This commit adds a still image capable camera.  There is not yet
support for streaming, or cameras that provide still images via linked
URLs only.  It is not tested against the actual camera yet, so may
turn out not to work.

Issue #371, potential for use for #168, though the user there
documented the still image as being a URL (but that may be for the cloud API).
Jason Rumney 3 лет назад
Родитель
Сommit
34b22ead0c

+ 1 - 1
ACKNOWLEDGEMENTS.md

@@ -166,7 +166,7 @@ Further device support has been made with the assistance of users.  Please consi
 - [MadDoct](https://github.com/MadDoct) for contributing support for RGB nightlight sockets.
 - [larueli](https://github.com/larueli) for contributing improvements to unavailable device detection when using persistent connections.
 - [austinhodak](https://github.com/austinhodak) for contributing support for generic dimmable lights.
-- [x5500](https://github.com/x5500) for contributing support for Loonas curtains.
+- [x5500](https://github.com/x5500) for contributing support for Loonas curtains and assisting with support for BCom doorbell camera.
 - [mypixies](https://github.com/mypixies) for assisting with support for Moes dimmer switch.
 - [BeardedTinker](https://github.com/BeardedTinker) for assisting with support for SG600MD smart inverter.
 - [LeandroIssa](https://github.com/LeandroIssa) for contributing Brazilian Portuguese translations.

+ 4 - 0
DEVICES.md

@@ -290,6 +290,10 @@ of device.
 
 - Orion Grid Connect outdoor siren
 
+### Cameras
+
+- BCom Majic IPBox intercom camera
+
 ### Miscellaneous
 
 - Bresser smart 7-in-1 weather station

+ 94 - 0
custom_components/tuya_local/camera.py

@@ -0,0 +1,94 @@
+"""
+Platform for Tuya Cameras
+"""
+
+from homeassistant.components.camera import (
+    Camera as CameraEntity,
+    CameraEntityFeature,
+)
+import logging
+
+from .device import TuyaLocalDevice
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+from .helpers.mixin import TuyaLocalEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "camera",
+        TuyaLocalCamera,
+    )
+
+
+class TuyaLocalCamera(TuyaLocalEntity, CameraEntity):
+    """Representation of a Tuya Camera"""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """
+        Initialise the camera.
+        Args:
+            device (TuyaLocalDevice): the device API instance
+            config (TuyaEntityConfig): the configuration for this entity
+        """
+        dps_map = self._init_begin(device, config)
+        self._switch_dp = dps_map.pop("switch", None)
+        self._snapshot_dp = dps_map.pop("snapshot", None)
+        self._record_dp = dps_map.pop("record", None)
+        self._motion_enable_dp = dps_map.pop("motion_enable", None)
+
+        self._init_end(dps_map)
+        if self._switch_dp:
+            self._attr_supported_features |= CameraEntityFeature.ON_OFF
+
+    @property
+    def is_recording(self):
+        """Return whether the camera is recording, if we know that."""
+        if self._record_dp:
+            return self._record_dp.get_value(self._device)
+
+    @property
+    def motion_detection_enabled(self):
+        """Return whether motion detection is enabled if supported."""
+        if self._motion_enable_dp:
+            return self._motion_enable_dp.get_value(self._device)
+
+    async def async_camera_image(self, width=None, height=None):
+        if self._snapshot_dp:
+            return self._snapshot_dp.decoded_value(self._device)
+
+    @property
+    def is_on(self):
+        """Return the power state of the camera"""
+        if self._switch_dp:
+            return self._switch_dp.get_value(self.device)
+
+    async def async_turn_off(self):
+        """Turn off the camera"""
+        if not self._switch_dp:
+            raise NotImplementedError()
+        await self._switch_dp.async_set_value(self._device, False)
+
+    async def async_turn_on(self):
+        """Turn on the camera"""
+        if not self._switch_dp:
+            raise NotImplementedError()
+        await self._switch_dp.async_set_value(self._device, True)
+
+    async def async_enable_motion_detection(self):
+        """Enable motion detection on the camera"""
+        if not self._motion_enable_dp:
+            raise NotImplementedError()
+        await self._motion_enable_dp.async_set_value(self._device, True)
+
+    async def async_disable_motion_detection(self):
+        """Disable motion detection on the camera"""
+        if not self._motion_enable_dp:
+            raise NotImplementedError()
+        await self._motion_enable_dp.async_set_value(self._device, False)

+ 151 - 0
custom_components/tuya_local/devices/bcom_intercom_camera.yaml

@@ -0,0 +1,151 @@
+name: BCom doorbell
+products:
+  - id: bf71b225dc6dd70835wlbt
+    name: Bcom Majic IPBox
+primary_entity:
+  entity: camera
+  dps:
+    - id: 150
+      name: record
+      type: boolean
+      optional: true
+    - id: 136
+      name: snapshot
+      type: base64
+      optional: true
+      mapping:
+        - dps_val: ""
+          value_redirect: motion_detected
+        - dps_val: null
+          value_redirect: motion_detected
+    - id: 115
+      name: motion_detected
+      type: base64
+secondary_entities:
+  - entity: lock
+    name: Door lock
+    dps:
+      - id: 232
+        type: boolean
+        name: lock
+  - entity: light
+    name: Indicator
+    category: config
+    icon: "mdi:led-on"
+    dps:
+      - id: 101
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Flip image
+    icon: "mdi:flip-horizontal"
+    category: config
+    dps:
+      - id: 103
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Watermark
+    category: config
+    icon: "mdi:watermark"
+    dps:
+      - id: 104
+        type: boolean
+        name: switch
+  - entity: select
+    name: Motion Detection
+    icon: "mdi:motion-sensor"
+    category: config
+    dps:
+      - id: 106
+        type: string
+        name: option
+        mapping:
+          - dps_val: "0"
+            value: Low
+          - dps_val: "1"
+            value: Medium
+          - dps_val: "2"
+            value: High
+  - entity: select
+    name: Night vision
+    icon: "mdi:weather-night"
+    category: config
+    dps:
+      - id: 108
+        type: string
+        name: option
+        mapping:
+          - dps_val: "0"
+            value: Auto
+          - dps_val: "1"
+            value: "Off"
+          - dps_val: "2"
+            value: "On"
+  - entity: sensor
+    name: SD capacity
+    category: diagnostic
+    dps:
+      - id: 109
+        type: string
+        name: sensor
+  - entity: sensor
+    name: SD status
+    category: diagnostic
+    dps:
+      - id: 110
+        type: integer
+        name: sensor
+  - entity: button
+    name: SD format
+    category: config
+    icon: "mdi:micro-sd"
+    dps:
+      - id: 111
+        type: boolean
+        name: button
+  - entity: sensor
+    name: SD format state
+    category: diagnostic
+    dps:
+      - id: 117
+        type: integer
+        name: sensor
+  - entity: select
+    name: Recording mode
+    icon: "mdi:file-video"
+    category: config
+    dps:
+      - id: 151
+        type: string
+        name: option
+        mapping:
+          - dps_val: "1" 
+            value: Event
+          - dps_val: "2"
+            value: Continuous
+  - entity: button
+    name: Restart
+    category: config
+    class: restart
+    dps:
+      - id: 162
+        type: boolean
+        name: button
+  - entity: sensor
+    name: Channel
+    icon: "mdi:ab-testing"
+    category: diagnostic
+    dps:
+      - id: 231
+        type: string
+        name: sensor
+
+  
+            
+
+            
+
+            
+
+

+ 19 - 0
tests/const.py

@@ -1540,3 +1540,22 @@ BLITZWOLF_BWSH2_PAYLOAD = {
     "6": "close",
     "19": "cancel",
 }
+
+BCOM_CAMERA_PAYLOAD = {
+    "101": True,
+    "103": False,
+    "104": False,
+    "106": "1",
+    "108": "0",
+    "109": "64GB",
+    "110": 1,
+    "111": False,
+    "115": "",
+    "117": 0,
+    "136": "",
+    "150": True,
+    "151": "1",
+    "162": False,
+    "231": "",
+    "232": False,
+}

+ 2 - 0
tests/devices/base_device_tests.py

@@ -6,6 +6,7 @@ from homeassistant.helpers.entity import EntityCategory
 
 from custom_components.tuya_local.binary_sensor import TuyaLocalBinarySensor
 from custom_components.tuya_local.button import TuyaLocalButton
+from custom_components.tuya_local.camera import TuyaLocalCamera
 from custom_components.tuya_local.climate import TuyaLocalClimate
 from custom_components.tuya_local.cover import TuyaLocalCover
 from custom_components.tuya_local.fan import TuyaLocalFan
@@ -28,6 +29,7 @@ from custom_components.tuya_local.helpers.device_config import (
 DEVICE_TYPES = {
     "binary_sensor": TuyaLocalBinarySensor,
     "button": TuyaLocalButton,
+    "camera": TuyaLocalCamera,
     "climate": TuyaLocalClimate,
     "cover": TuyaLocalCover,
     "fan": TuyaLocalFan,

+ 75 - 0
tests/devices/test_bcom_intercom_camera.py

@@ -0,0 +1,75 @@
+"""Tests for the bcom intercom camera"""
+from ..const import BCOM_CAMERA_PAYLOAD
+from .base_device_tests import TuyaDeviceTestCase
+
+LIGHT_DPS = "101"
+FLIP_DPS = "103"
+WATERMARK_DPS = "104"
+MOTION_DPS = "106"
+NIGHT_DPS = "108"
+SDSIZE_DPS = "109"
+SDSTATUS_DPS = "110"
+SDFORMAT_DPS = "111"
+SNAPSHOT_DPS = "115"
+SDFMTSTATE_DPS = "117"
+DOORBELL_DPS = "136"
+RECORD_DPS = "150"
+RECMODE_DPS = "151"
+REBOOT_DPS = "162"
+CHANNEL_DPS = "231"
+LOCK_DPS = "232"
+
+
+class TestBcomIntercomCamera(TuyaDeviceTestCase):
+    __test__ = True
+
+    def setUp(self):
+        self.setUpForConfig("bcom_intercom_camera.yaml", BCOM_CAMERA_PAYLOAD)
+        self.subject = self.entities.get("camera")
+
+        self.mark_secondary(
+            [
+                "light_indicator",
+                "switch_flip_image",
+                "switch_watermark",
+                "select_motion_detection",
+                "select_night_vision",
+                "sensor_sd_capacity",
+                "sensor_sd_status",
+                "button_sd_format",
+                "sensor_sd_format_state",
+                "select_recording_mode",
+                "button_restart",
+                "sensor_channel",
+            ]
+        )
+
+    def test_is_recording(self):
+        self.dps[RECORD_DPS] = True
+        self.assertTrue(self.subject.is_recording)
+        self.dps[RECORD_DPS] = False
+        self.assertFalse(self.subject.is_recording)
+
+    def test_motion_detection_enabled(self):
+        self.assertIsNone(self.subject.motion_detection_enabled)
+
+    def test_is_on(self):
+        self.assertIsNone(self.subject.is_on)
+
+    async def test_camera_image(self):
+        self.dps[DOORBELL_DPS] = ""
+        self.dps[SNAPSHOT_DPS] = "VGVzdA=="
+        image = await self.subject.async_camera_image()
+        self.assertEqual(image, b"Test")
+
+        self.dps[DOORBELL_DPS] = "a25vY2sga25vY2s="
+        image = await self.subject.async_camera_image()
+        self.assertEqual(image, b"knock knock")
+
+    async def test_turn_off(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_turn_off()
+
+    async def test_turn_on(self):
+        with self.assertRaises(NotImplementedError):
+            await self.subject.async_turn_on()

+ 88 - 0
tests/test_camera.py

@@ -0,0 +1,88 @@
+"""Tests for the camera entity."""
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+import pytest
+from unittest.mock import AsyncMock, Mock
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.camera import (
+    async_setup_entry,
+    TuyaLocalCamera,
+)
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "bcom_intercom_camera",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert type(hass.data[DOMAIN]["dummy"]["camera"]) == TuyaLocalCamera
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_camera(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "mirabella_genio_usb",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "non_existing",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {
+        "dummy": {"device": m_device},
+    }
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
tests/test_device_config.py


Некоторые файлы не были показаны из-за большого количества измененных файлов