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

test: increase unit test coverage (cloud, cover, remote, datetime, config_flow, entity) (#5304)

* test: increase unit coverage for cloud, cover, remote, datetime, config_flow, entity

Adds unit tests for Python code (no production changes), per the
'complete the remaining unit tests' item in the README.

Coverage across these modules rises from 43% to 94%:
- cloud.py        25% -> 99%
- cover.py        34% -> 98%
- remote.py       31% -> 93%
- datetime.py     31% -> 88%
- config_flow.py  53% -> 91%
- entity.py       85% -> 94%

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: fix import ordering (ruff I001) in new tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: merge per-module tests into existing files per review

Fold the new cover/datetime/remote/config_flow tests into their existing
per-module test files and remove the parallel *_unit/_extended files, per
maintainer review. test_entity.py and test_cloud.py are kept as new files
since entity.py and cloud.py had no existing test file to merge into.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wei Kit 2 недель назад
Родитель
Сommit
be8051039c
6 измененных файлов с 2452 добавлено и 4 удалено
  1. 365 0
      tests/test_cloud.py
  2. 776 0
      tests/test_config_flow.py
  3. 467 1
      tests/test_cover.py
  4. 190 1
      tests/test_datetime.py
  5. 320 0
      tests/test_entity.py
  6. 334 2
      tests/test_remote.py

+ 365 - 0
tests/test_cloud.py

@@ -0,0 +1,365 @@
+"""Tests for the Tuya cloud interface."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from custom_components.tuya_local.cloud import (
+    HUB_CATEGORIES,
+    Cloud,
+    DeviceListener,
+    TokenListener,
+)
+from custom_components.tuya_local.const import (
+    CONF_ENDPOINT,
+    CONF_LOCAL_KEY,
+    CONF_TERMINAL_ID,
+    DOMAIN,
+    TUYA_RESPONSE_CODE,
+    TUYA_RESPONSE_MSG,
+    TUYA_RESPONSE_QR_CODE,
+    TUYA_RESPONSE_RESULT,
+    TUYA_RESPONSE_SUCCESS,
+)
+
+
+@pytest.fixture
+def mock_hass():
+    hass = MagicMock()
+    hass.data = {DOMAIN: {}}
+
+    async def run_in_executor(fn, *args):
+        return fn(*args)
+
+    hass.async_add_executor_job = AsyncMock(side_effect=run_in_executor)
+    return hass
+
+
+@pytest.fixture
+def cloud(mock_hass):
+    return Cloud(mock_hass)
+
+
+class TestCloudInit:
+    def test_init_without_cache(self, mock_hass):
+        c = Cloud(mock_hass)
+        assert c.is_authenticated is False
+
+    def test_init_with_cached_auth(self, mock_hass):
+        mock_hass.data[DOMAIN]["auth_cache"] = {
+            "user_code": "abc",
+            "terminal_id": "tid",
+            "endpoint": "ep",
+            "token_info": {},
+        }
+        c = Cloud(mock_hass)
+        assert c.is_authenticated is True
+
+
+class TestIsAuthenticated:
+    def test_false_by_default(self, cloud):
+        assert cloud.is_authenticated is False
+
+    def test_true_after_login(self, cloud, mock_hass):
+        mock_hass.data[DOMAIN]["auth_cache"] = {"user_code": "test"}
+        c = Cloud(mock_hass)
+        assert c.is_authenticated is True
+
+
+class TestLastError:
+    def test_none_by_default(self, cloud):
+        assert cloud.last_error is None
+
+    @pytest.mark.asyncio
+    async def test_set_after_failed_qr(self, cloud):
+        with patch(
+            "custom_components.tuya_local.cloud.LoginControl"
+        ) as MockLoginControl:
+            mock_lc = MockLoginControl.return_value
+            mock_lc.qr_code.return_value = {
+                TUYA_RESPONSE_SUCCESS: False,
+                TUYA_RESPONSE_CODE: 1001,
+                TUYA_RESPONSE_MSG: "Invalid code",
+            }
+            cloud._Cloud__login_control = mock_lc
+            await cloud.async_get_qr_code("test_code")
+            error = cloud.last_error
+            assert error is not None
+            assert error[TUYA_RESPONSE_CODE] == 1001
+            assert error[TUYA_RESPONSE_MSG] == "Invalid code"
+
+
+class TestGetQrCode:
+    @pytest.mark.asyncio
+    async def test_without_user_code_returns_false(self, cloud):
+        result = await cloud.async_get_qr_code()
+        assert result is not None  # Returns (False, {...}) tuple
+
+    @pytest.mark.asyncio
+    async def test_success(self, cloud):
+        mock_lc = MagicMock()
+        mock_lc.qr_code.return_value = {
+            TUYA_RESPONSE_SUCCESS: True,
+            TUYA_RESPONSE_RESULT: {
+                TUYA_RESPONSE_QR_CODE: "https://qr.example.com/code123",
+            },
+        }
+        cloud._Cloud__login_control = mock_lc
+        result = await cloud.async_get_qr_code("my_user_code")
+        assert result == "https://qr.example.com/code123"
+
+    @pytest.mark.asyncio
+    async def test_failure(self, cloud):
+        mock_lc = MagicMock()
+        mock_lc.qr_code.return_value = {
+            TUYA_RESPONSE_SUCCESS: False,
+            TUYA_RESPONSE_CODE: 500,
+            TUYA_RESPONSE_MSG: "Server error",
+        }
+        cloud._Cloud__login_control = mock_lc
+        result = await cloud.async_get_qr_code("my_user_code")
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_reuses_user_code(self, cloud):
+        mock_lc = MagicMock()
+        mock_lc.qr_code.return_value = {
+            TUYA_RESPONSE_SUCCESS: True,
+            TUYA_RESPONSE_RESULT: {
+                TUYA_RESPONSE_QR_CODE: "qr1",
+            },
+        }
+        cloud._Cloud__login_control = mock_lc
+
+        await cloud.async_get_qr_code("my_code")
+        # Second call without user_code should reuse
+        await cloud.async_get_qr_code()
+        assert mock_lc.qr_code.call_count == 2
+        assert mock_lc.qr_code.call_args_list[1][0][2] == "my_code"
+
+
+class TestLogin:
+    @pytest.mark.asyncio
+    async def test_without_qr_returns_false(self, cloud):
+        result = await cloud.async_login()
+        # Returns (False, {}) when no user_code/qr_code
+        assert result is not None
+
+    @pytest.mark.asyncio
+    async def test_success(self, cloud, mock_hass):
+        # First get QR code
+        mock_lc = MagicMock()
+        mock_lc.qr_code.return_value = {
+            TUYA_RESPONSE_SUCCESS: True,
+            TUYA_RESPONSE_RESULT: {TUYA_RESPONSE_QR_CODE: "qr_code_value"},
+        }
+        mock_lc.login_result.return_value = (
+            True,
+            {
+                CONF_TERMINAL_ID: "term_123",
+                CONF_ENDPOINT: "https://openapi.tuyaus.com",
+                "t": 1234567890,
+                "uid": "user_abc",
+                "expire_time": 7200,
+                "access_token": "at_xyz",
+                "refresh_token": "rt_xyz",
+            },
+        )
+        cloud._Cloud__login_control = mock_lc
+
+        await cloud.async_get_qr_code("user_code_123")
+        result = await cloud.async_login()
+        assert result is True
+        assert cloud.is_authenticated is True
+        assert mock_hass.data[DOMAIN]["auth_cache"] is not None
+
+    @pytest.mark.asyncio
+    async def test_failure_clears_auth(self, cloud, mock_hass):
+        mock_lc = MagicMock()
+        mock_lc.qr_code.return_value = {
+            TUYA_RESPONSE_SUCCESS: True,
+            TUYA_RESPONSE_RESULT: {TUYA_RESPONSE_QR_CODE: "qr_code_value"},
+        }
+        mock_lc.login_result.return_value = (
+            False,
+            {
+                TUYA_RESPONSE_CODE: 2000,
+                TUYA_RESPONSE_MSG: "Auth failed",
+            },
+        )
+        cloud._Cloud__login_control = mock_lc
+
+        await cloud.async_get_qr_code("user_code_123")
+        result = await cloud.async_login()
+        assert result is False
+        assert cloud.is_authenticated is False
+        assert mock_hass.data[DOMAIN]["auth_cache"] is None
+
+
+class TestLogout:
+    def test_logout_clears_auth(self, cloud, mock_hass):
+        # Manually set authentication
+        mock_hass.data[DOMAIN]["auth_cache"] = {"some": "data"}
+        cloud._Cloud__authentication = {"some": "data"}
+        assert cloud.is_authenticated is True
+
+        cloud.logout()
+        assert cloud.is_authenticated is False
+        assert mock_hass.data[DOMAIN]["auth_cache"] is None
+
+
+class TestGetDevices:
+    @pytest.mark.asyncio
+    async def test_get_devices(self, cloud, mock_hass):
+        # Set up authentication
+        cloud._Cloud__authentication = {
+            "user_code": "uc",
+            "terminal_id": "tid",
+            "endpoint": "ep",
+            "token_info": {"access_token": "at"},
+        }
+
+        mock_device = MagicMock()
+        mock_device.category = "dj"
+        mock_device.id = "dev_001"
+        mock_device.ip = "192.168.1.100"
+        mock_device.local_key = "local_key_123"
+        mock_device.name = "Test Light"
+        mock_device.node_id = ""
+        mock_device.online = True
+        mock_device.product_id = "prod_001"
+        mock_device.product_name = "Smart Light"
+        mock_device.uid = "uid_001"
+        mock_device.uuid = "uuid_001"
+        mock_device.support_local = True
+
+        with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
+            mock_manager = MockManager.return_value
+            mock_manager.device_map = {"dev_001": mock_device}
+            mock_manager.update_device_cache = MagicMock()
+
+            devices = await cloud.async_get_devices()
+            assert "dev_001/" in devices
+            assert devices["dev_001/"]["name"] == "Test Light"
+            assert devices["dev_001/"][CONF_LOCAL_KEY] == "local_key_123"
+            assert devices["dev_001/"]["is_hub"] is False
+
+    @pytest.mark.asyncio
+    async def test_get_devices_hub_category(self, cloud, mock_hass):
+        cloud._Cloud__authentication = {
+            "user_code": "uc",
+            "terminal_id": "tid",
+            "endpoint": "ep",
+            "token_info": {"access_token": "at"},
+        }
+
+        mock_device = MagicMock()
+        mock_device.category = "zigbee"
+        mock_device.id = "hub_001"
+        mock_device.ip = "192.168.1.200"
+        mock_device.local_key = "lk"
+        mock_device.name = "Hub"
+        mock_device.node_id = ""
+        mock_device.online = True
+        mock_device.product_id = "hp_001"
+        mock_device.product_name = "Zigbee Hub"
+        mock_device.uid = "uid_hub"
+        mock_device.uuid = "uuid_hub"
+        mock_device.support_local = True
+
+        with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
+            mock_manager = MockManager.return_value
+            mock_manager.device_map = {"hub_001": mock_device}
+            mock_manager.update_device_cache = MagicMock()
+
+            devices = await cloud.async_get_devices()
+            assert devices["hub_001/"]["is_hub"] is True
+
+
+class TestGetDatamodel:
+    @pytest.mark.asyncio
+    async def test_get_datamodel(self, cloud, mock_hass):
+        cloud._Cloud__authentication = {
+            "user_code": "uc",
+            "terminal_id": "tid",
+            "endpoint": "ep",
+            "token_info": {"access_token": "at"},
+        }
+
+        mock_response = {
+            "result": {
+                "dpStatusRelationDTOS": [
+                    {
+                        "dpId": 1,
+                        "dpCode": "switch",
+                        "valueType": "Boolean",
+                        "valueDesc": "{}",
+                        "enumMappingMap": {},
+                        "supportLocal": True,
+                    },
+                    {
+                        "dpId": 2,
+                        "dpCode": "cloud_only",
+                        "valueType": "Boolean",
+                        "valueDesc": "{}",
+                        "enumMappingMap": {},
+                        "supportLocal": False,
+                    },
+                ]
+            }
+        }
+
+        with patch("custom_components.tuya_local.cloud.Manager") as MockManager:
+            mock_manager = MockManager.return_value
+            mock_manager.customer_api.get.return_value = mock_response
+
+            result = await cloud.async_get_datamodel("dev_001")
+            assert len(result) == 1
+            assert result[0]["id"] == 1
+            assert result[0]["name"] == "switch"
+
+
+class TestDeviceListener:
+    def test_update_device(self):
+        hass = MagicMock()
+        manager = MagicMock()
+        device = MagicMock()
+        device.id = "dev_001"
+        manager.device_map = {"dev_001": MagicMock()}
+        listener = DeviceListener(hass, manager)
+        # Should not raise
+        listener.update_device(device, ["status"])
+
+    def test_add_device(self):
+        hass = MagicMock()
+        manager = MagicMock()
+        device = MagicMock()
+        device.id = "dev_001"
+        manager.device_map = {"dev_001": MagicMock()}
+        listener = DeviceListener(hass, manager)
+        listener.add_device(device)
+
+    def test_remove_device(self):
+        hass = MagicMock()
+        manager = MagicMock()
+        manager.device_map = {"dev_001": MagicMock()}
+        listener = DeviceListener(hass, manager)
+        listener.remove_device("dev_001")
+
+
+class TestTokenListener:
+    def test_update_token(self):
+        hass = MagicMock()
+        listener = TokenListener(hass)
+        # Should not raise
+        listener.update_token({"access_token": "new_token"})
+
+
+class TestHubCategories:
+    def test_known_hub_categories(self):
+        assert "zigbee" in HUB_CATEGORIES
+        assert "wg2" in HUB_CATEGORIES
+        assert "wnykq" in HUB_CATEGORIES
+
+    def test_non_hub_category(self):
+        assert "dj" not in HUB_CATEGORIES

+ 776 - 0
tests/test_config_flow.py

@@ -1,5 +1,7 @@
 """Tests for the config flow."""
 
+from unittest.mock import AsyncMock
+
 import pytest
 import voluptuous as vol
 from homeassistant.const import CONF_HOST, CONF_NAME
@@ -805,3 +807,777 @@ def test_migration_gets_correct_device_id():
         },
     )
     assert get_device_unique_id(entry) == "deviceid"
+
+
+# ---------------------------------------------------------------------------
+# async_step_user
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_user_shows_form(hass):
+    """Test the user step shows the setup mode form when no input."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "user"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "user"
+
+
+@pytest.mark.asyncio
+async def test_flow_user_manual_goes_to_local(hass):
+    """Test that choosing 'manual' advances to the local step."""
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "manual"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_flow_user_cloud_authenticated_goes_to_choose_device(hass, mocker):
+    """Test cloud mode when already authenticated goes to choose_device."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(
+        return_value={
+            "dev1": {
+                "name": "Light",
+                "product_name": "Smart Light",
+                "local_key": "key1",
+                "online": True,
+                "is_hub": False,
+                "exists": False,
+            }
+        }
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_device"
+
+
+@pytest.mark.asyncio
+async def test_flow_user_cloud_not_authenticated_goes_to_cloud_step(hass, mocker):
+    """Test cloud mode when not authenticated goes to the cloud (QR) step."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = False
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_flow_user_cloud_fresh_login_logs_out_and_goes_to_cloud(hass, mocker):
+    """Test cloud_fresh_login forces logout then goes to cloud step."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = False
+    mock_cloud.logout = mocker.MagicMock()
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud_fresh_login"}
+    )
+    mock_cloud.logout.assert_called_once()
+    assert result["type"] == "form"
+    assert result["step_id"] == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_flow_user_cloud_exception_goes_to_cloud_step(hass, mocker):
+    """Test that cloud exceptions cause re-auth (go to cloud step)."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(side_effect=Exception("network error"))
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "cloud"
+
+
+# ---------------------------------------------------------------------------
+# async_step_cloud
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_cloud_shows_form(hass, mocker):
+    """Test cloud step shows the user_code form."""
+    mocker.patch("custom_components.tuya_local.config_flow.Cloud")
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_flow_cloud_success_goes_to_scan(hass, mocker):
+    """Test entering a user code that succeeds goes to QR scan step."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN_123")
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"user_code": "MY_CODE"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "scan"
+
+
+@pytest.mark.asyncio
+async def test_flow_cloud_failure_shows_error(hass, mocker):
+    """Test entering a bad user code stays on cloud step with error."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.async_get_qr_code = AsyncMock(return_value=False)
+    mock_cloud.last_error = {"msg": "Invalid code", "code": 1001}
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"user_code": "BAD_CODE"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "cloud"
+    assert result["errors"] == {"base": "login_error"}
+
+
+# ---------------------------------------------------------------------------
+# async_step_scan
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_scan_shows_qr_form(hass, mocker):
+    """Test the scan step shows the QR code form."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    # Get to scan via cloud step
+    flow = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"user_code": "CODE"}
+    )
+    assert result["step_id"] == "scan"
+
+
+@pytest.mark.asyncio
+async def test_flow_scan_login_success_goes_to_choose_device(hass, mocker):
+    """Test scanning QR and successful login goes to choose_device."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
+    mock_cloud.async_login = AsyncMock(return_value=True)
+    mock_cloud.async_get_devices = AsyncMock(
+        return_value={
+            "dev1": {
+                "name": "Plug",
+                "product_name": "Smart Plug",
+                "local_key": "key",
+                "online": True,
+                "is_hub": False,
+                "exists": False,
+            }
+        }
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"user_code": "CODE"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_device"
+
+
+@pytest.mark.asyncio
+async def test_flow_scan_login_failure_stays_on_scan(hass, mocker):
+    """Test failed login stays on scan step with error."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
+    mock_cloud.async_login = AsyncMock(return_value=False)
+    mock_cloud.last_error = {"msg": "Auth failed", "code": 2000}
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"user_code": "CODE"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "scan"
+    assert result["errors"] == {"base": "login_error"}
+
+
+# ---------------------------------------------------------------------------
+# async_step_choose_device
+# ---------------------------------------------------------------------------
+
+
+def _make_cloud_devices(include_hub=False, include_offline=False):
+    devices = {
+        "dev1": {
+            "name": "Smart Light",
+            "product_name": "Light",
+            "local_key": "key1",
+            "online": True,
+            "is_hub": False,
+            "exists": False,
+            "ip": "192.168.1.10",
+        }
+    }
+    if include_hub:
+        devices["hub1"] = {
+            "name": "Zigbee Hub",
+            "product_name": "Hub",
+            "local_key": "hubkey",
+            "online": True,
+            "is_hub": True,
+            "exists": False,
+            "ip": "192.168.1.1",
+        }
+    if include_offline:
+        devices["dev2"] = {
+            "name": "Offline Device",
+            "product_name": "Sensor",
+            "local_key": "key2",
+            "online": False,
+            "is_hub": False,
+            "exists": False,
+            "ip": "192.168.1.20",
+        }
+    return devices
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_shows_form(hass, mocker):
+    """Test the choose_device step shows the device list form."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_device"
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_aborts_when_no_devices(hass, mocker):
+    """Test choose_device aborts when no new devices are available."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value={})
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    assert result["type"] == "abort"
+    assert result["reason"] == "no_devices"
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_direct_device_no_hub_goes_to_search(hass, mocker):
+    """Test selecting a directly addressable device (no hub) goes to search."""
+    devices = _make_cloud_devices()
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=devices)
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "search"
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_direct_device_with_hub_shows_error(hass, mocker):
+    """Test selecting a hub for a direct device shows an error."""
+    devices = _make_cloud_devices(include_hub=True)
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=devices)
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "hub1"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_device"
+    assert result["errors"] == {"base": "does_not_need_hub"}
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_indirect_device_with_hub_goes_to_search(hass, mocker):
+    """Test selecting an indirect device with a hub goes to search."""
+    devices = {
+        "subdev1": {
+            "name": "Sub Device",
+            "product_name": "Sensor",
+            "local_key": "subkey",  # non-empty so it appears in list
+            "online": True,
+            "is_hub": False,
+            "exists": False,
+            "ip": "",  # empty ip = indirect/sub-device
+            "node_id": "node123",
+            "uuid": "uuid123",
+            "product_id": "prod_sub",
+        },
+        "hub1": {
+            "name": "Hub",
+            "product_name": "Gateway",
+            "local_key": "hubkey",
+            "online": True,
+            "is_hub": True,
+            "exists": False,
+            "ip": "192.168.1.1",
+        },
+    }
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=devices)
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "subdev1", "hub_id": "hub1"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "search"
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_device_indirect_no_hub_shows_error(hass, mocker):
+    """Test selecting an indirect device without a hub shows an error."""
+    devices = {
+        "subdev1": {
+            "name": "Sub Device",
+            "product_name": "Sensor",
+            "local_key": "subkey",  # non-empty so it appears in list
+            "online": True,
+            "is_hub": False,
+            "exists": False,
+            "ip": "",  # empty ip = indirect/sub-device
+        }
+    }
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=devices)
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "subdev1", "hub_id": "None"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_device"
+    assert result["errors"] == {"base": "needs_hub"}
+
+
+# ---------------------------------------------------------------------------
+# async_step_search
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_search_shows_form(hass, mocker):
+    """Test the search step shows the scanning form."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "search"
+
+
+@pytest.mark.asyncio
+async def test_flow_search_found_device_goes_to_local(hass, mocker):
+    """Test that finding a device on the network advances to the local step."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.scan_for_device",
+        return_value={"ip": "192.168.1.50", "version": "3.3", "productKey": "pk123"},
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_flow_search_not_found_still_goes_to_local(hass, mocker):
+    """Test that not finding a device still advances to local step (blank IP)."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.scan_for_device",
+        return_value={"ip": None},
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_flow_search_oserror_still_goes_to_local(hass, mocker):
+    """Test that an OSError during scan still advances to local step."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.scan_for_device",
+        side_effect=OSError("network unreachable"),
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "local"
+
+
+# ---------------------------------------------------------------------------
+# async_test_connection with fixed protocol
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_async_test_connection_fixed_protocol_success(hass, mocker):
+    """Test connection with a fixed protocol version (not auto)."""
+    mock_device = mocker.patch(
+        "custom_components.tuya_local.config_flow.TuyaLocalDevice"
+    )
+    mock_instance = mocker.AsyncMock()
+    mock_instance.has_returned_state = True
+    mock_device.return_value = mock_instance
+
+    device = await config_flow.async_test_connection(
+        {
+            CONF_DEVICE_ID: "deviceid",
+            CONF_LOCAL_KEY: TESTKEY,
+            CONF_HOST: "hostname",
+            CONF_PROTOCOL_VERSION: 3.3,
+        },
+        hass,
+    )
+    assert device == mock_instance
+
+
+@pytest.mark.asyncio
+async def test_async_test_connection_fixed_protocol_no_state(hass, mocker):
+    """Test fixed protocol returns None when device has no state."""
+    mock_device = mocker.patch(
+        "custom_components.tuya_local.config_flow.TuyaLocalDevice"
+    )
+    mock_instance = mocker.AsyncMock()
+    mock_instance.has_returned_state = False
+    mock_device.return_value = mock_instance
+
+    device = await config_flow.async_test_connection(
+        {
+            CONF_DEVICE_ID: "deviceid",
+            CONF_LOCAL_KEY: TESTKEY,
+            CONF_HOST: "hostname",
+            CONF_PROTOCOL_VERSION: 3.3,
+        },
+        hass,
+    )
+    assert device is None
+
+
+@pytest.mark.asyncio
+async def test_async_test_connection_fixed_protocol_exception(hass, mocker):
+    """Test fixed protocol returns None on exception."""
+    mock_device = mocker.patch(
+        "custom_components.tuya_local.config_flow.TuyaLocalDevice"
+    )
+    mock_instance = mocker.AsyncMock()
+    mock_instance.async_refresh = AsyncMock(side_effect=Exception("timeout"))
+    mock_device.return_value = mock_instance
+
+    device = await config_flow.async_test_connection(
+        {
+            CONF_DEVICE_ID: "deviceid",
+            CONF_LOCAL_KEY: TESTKEY,
+            CONF_HOST: "hostname",
+            CONF_PROTOCOL_VERSION: 3.3,
+        },
+        hass,
+    )
+    assert device is None
+
+
+@pytest.mark.asyncio
+async def test_async_test_connection_auto_all_protocols_fail(hass, mocker):
+    """Test auto mode returns None when all protocols fail."""
+    mock_device = mocker.patch(
+        "custom_components.tuya_local.config_flow.TuyaLocalDevice"
+    )
+    mock_instance = mocker.AsyncMock()
+    mock_instance.has_returned_state = False
+    mock_instance._api = mocker.MagicMock()
+    mock_instance._api.parent = None
+    mock_device.return_value = mock_instance
+
+    device = await config_flow.async_test_connection(
+        {
+            CONF_DEVICE_ID: "deviceid",
+            CONF_LOCAL_KEY: TESTKEY,
+            CONF_HOST: "hostname",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+        hass,
+    )
+    assert device is None
+
+
+# ---------------------------------------------------------------------------
+# _device_name_placeholder
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_device_name_placeholder_with_cloud_device(hass, mocker):
+    """Test _device_name_placeholder returns formatted name when cloud device set."""
+    mock_cloud = mocker.MagicMock()
+    mock_cloud.is_authenticated = True
+    mock_cloud.async_get_devices = AsyncMock(
+        return_value={
+            "dev1": {
+                "name": "My Light",
+                "product_name": "Smart Bulb",
+                "local_key": "key",
+                "online": True,
+                "is_hub": False,
+                "exists": False,
+                "ip": "192.168.1.5",
+            }
+        }
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
+    )
+    mocker.patch(
+        "custom_components.tuya_local.config_flow.scan_for_device",
+        return_value={"ip": None},
+    )
+
+    flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"setup_mode": "cloud"}
+    )
+    await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
+    )
+    result = await hass.config_entries.flow.async_configure(
+        flow["flow_id"], user_input={}
+    )
+    # The local step description_placeholders should contain the device name
+    assert result["step_id"] == "local"
+    placeholder = result["description_placeholders"]["device_name"]
+    assert "My Light" in placeholder
+    assert "Smart Bulb" in placeholder
+
+
+@pytest.mark.asyncio
+async def test_device_name_placeholder_without_cloud_device(hass, mocker):
+    """Test _device_name_placeholder returns empty string when no cloud device."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "local"}
+    )
+    assert result["step_id"] == "local"
+    assert result["description_placeholders"]["device_name"] == ""
+
+
+# ---------------------------------------------------------------------------
+# async_step_select_type with auto-detected protocol
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_select_type_shows_auto_detected_form(hass, mocker):
+    """Test select_type shows the auto_detected variant when protocol was detected."""
+    mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
+
+    mock_type = mocker.MagicMock()
+    mock_type.config_type = "smartplugv1"
+    mock_type.name = "Smart Plug"
+    mock_type.match_quality.return_value = 85
+    mock_type.product_display_entries.return_value = [(None, None)]
+    mock_device.async_possible_types = mocker.AsyncMock(return_value=[mock_type])
+    mock_device._get_cached_state.return_value = {"1": True}
+    mock_device._product_ids = []
+
+    mocker.patch.object(
+        config_flow.ConfigFlowHandler,
+        "_auto_detected_protocol",
+        new_callable=lambda: property(lambda self: 3.3),
+        create=True,
+    )
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "select_type"}
+    )
+    # Either select_type or select_type_auto_detected depending on attribute
+    assert result["step_id"] in ("select_type", "select_type_auto_detected")
+
+
+# ---------------------------------------------------------------------------
+# choose_entities with cloud device name as default
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_flow_choose_entities_uses_cloud_name_as_default(
+    hass, bypass_setup, mocker
+):
+    """Test choose_entities uses cloud device name as the default entity name."""
+    mocker.patch.dict(config_flow.ConfigFlowHandler.data, {CONF_TYPE: "smartplugv1"})
+    # Patch __cloud_device on the handler class
+    mocker.patch.object(
+        config_flow.ConfigFlowHandler,
+        "_ConfigFlowHandler__cloud_device",
+        new={"name": "My Cloud Device", "product_name": "Plug"},
+        create=True,
+    )
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": "choose_entities"}
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "choose_entities"
+    # The schema default should be the cloud device name
+    schema = result["data_schema"]
+    # Validate it accepts the cloud device name
+    validated = schema({CONF_NAME: "My Cloud Device"})
+    assert validated[CONF_NAME] == "My Cloud Device"

+ 467 - 1
tests/test_cover.py

@@ -1,8 +1,9 @@
 """Tests for the cover entity."""
 
-from unittest.mock import AsyncMock, Mock
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
 
 import pytest
+from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
 from pytest_homeassistant_custom_component.common import MockConfigEntry
 
 from custom_components.tuya_local.const import (
@@ -91,3 +92,468 @@ async def test_init_entry_fails_if_config_is_missing(hass):
     except ValueError:
         pass
     m_add_entities.assert_not_called()
+
+
+def _make_cover(
+    position=True,
+    currentpos=False,
+    tiltpos=False,
+    control=True,
+    action=True,
+    open_dp=False,
+    control_values=None,
+):
+    """Create a TuyaLocalCover with mocked internals."""
+    if control_values is None:
+        control_values = ["open", "close", "stop"]
+
+    device = MagicMock()
+    config = MagicMock()
+    config.device_class = None
+    config.name = "Test Cover"
+    config.translation_key = None
+    config.translation_only_key = None
+    config.translation_placeholders = None
+    config.entity_category = None
+
+    dps = {}
+    if position:
+        dp = MagicMock()
+        dp.name = "position"
+        dps["position"] = dp
+    if currentpos:
+        dp = MagicMock()
+        dp.name = "current_position"
+        dps["current_position"] = dp
+    if tiltpos:
+        dp = MagicMock()
+        dp.name = "tilt_position"
+        dps["tilt_position"] = dp
+    if control:
+        dp = MagicMock()
+        dp.name = "control"
+        dp.values.return_value = control_values
+        dps["control"] = dp
+    if action:
+        dp = MagicMock()
+        dp.name = "action"
+        dps["action"] = dp
+    if open_dp:
+        dp = MagicMock()
+        dp.name = "open"
+        dps["open"] = dp
+
+    config.dps.return_value = list(dps.values())
+
+    # Patch __init__ to avoid MRO issues with HA entity base classes
+    cover = object.__new__(TuyaLocalCover)
+    cover._device = device
+    cover._config = config
+    cover._attr_dps = []
+    cover._attr_translation_key = None
+    cover._attr_translation_placeholders = None
+
+    cover._position_dp = dps.get("position")
+    cover._currentpos_dp = dps.get("current_position")
+    cover._tiltpos_dp = dps.get("tilt_position")
+    cover._control_dp = dps.get("control")
+    cover._action_dp = dps.get("action")
+    cover._open_dp = dps.get("open")
+
+    # Build support flags
+    cover._support_flags = CoverEntityFeature(0)
+    if cover._position_dp:
+        cover._support_flags |= CoverEntityFeature.SET_POSITION
+    if cover._control_dp:
+        vals = cover._control_dp.values(device)
+        if "stop" in vals:
+            cover._support_flags |= CoverEntityFeature.STOP
+        if "open" in vals:
+            cover._support_flags |= CoverEntityFeature.OPEN
+        if "close" in vals:
+            cover._support_flags |= CoverEntityFeature.CLOSE
+    if cover._tiltpos_dp:
+        cover._support_flags |= CoverEntityFeature.SET_TILT_POSITION
+
+    return cover
+
+
+class TestSupportedFeatures:
+    def test_all_features(self):
+        cover = _make_cover(position=True, control=True, tiltpos=True)
+        flags = cover.supported_features
+        assert flags & CoverEntityFeature.SET_POSITION
+        assert flags & CoverEntityFeature.STOP
+        assert flags & CoverEntityFeature.OPEN
+        assert flags & CoverEntityFeature.CLOSE
+        assert flags & CoverEntityFeature.SET_TILT_POSITION
+
+    def test_position_only(self):
+        cover = _make_cover(position=True, control=False, tiltpos=False, action=False)
+        flags = cover.supported_features
+        assert flags & CoverEntityFeature.SET_POSITION
+        assert not (flags & CoverEntityFeature.STOP)
+
+    def test_no_stop(self):
+        cover = _make_cover(control=True, control_values=["open", "close"])
+        flags = cover.supported_features
+        assert not (flags & CoverEntityFeature.STOP)
+        assert flags & CoverEntityFeature.OPEN
+        assert flags & CoverEntityFeature.CLOSE
+
+
+class TestDeviceClass:
+    def test_valid_class(self):
+        cover = _make_cover()
+        cover._config.device_class = "garage"
+        assert cover.device_class == CoverDeviceClass.GARAGE
+
+    def test_none_class(self):
+        cover = _make_cover()
+        cover._config.device_class = None
+        assert cover.device_class is None
+
+    def test_invalid_class_logs_warning(self):
+        cover = _make_cover()
+        cover._config.device_class = "invalid_class"
+        with patch("custom_components.tuya_local.cover._LOGGER") as mock_logger:
+            result = cover.device_class
+            assert result is None
+            mock_logger.warning.assert_called_once()
+
+
+class TestStateToPercent:
+    def test_opened(self):
+        cover = _make_cover()
+        assert cover._state_to_percent("opened") == 100
+
+    def test_closed(self):
+        cover = _make_cover()
+        assert cover._state_to_percent("closed") == 0
+
+    def test_other(self):
+        cover = _make_cover()
+        assert cover._state_to_percent("opening") == 50
+
+
+class TestCurrentCoverPosition:
+    def test_from_currentpos_dp(self):
+        cover = _make_cover(currentpos=True)
+        cover._currentpos_dp.get_value.return_value = 75
+        assert cover.current_cover_position == 75
+
+    def test_currentpos_none_falls_through(self):
+        cover = _make_cover(currentpos=True, action=True)
+        cover._currentpos_dp.get_value.return_value = None
+        cover._action_dp.get_value.return_value = "opened"
+        assert cover.current_cover_position == 100
+
+    def test_from_open_dp_true(self):
+        cover = _make_cover(
+            currentpos=False, open_dp=True, action=False, control=False, position=False
+        )
+        cover._open_dp.get_value.return_value = True
+        assert cover.current_cover_position == 100
+
+    def test_from_open_dp_false(self):
+        cover = _make_cover(
+            currentpos=False, open_dp=True, action=False, control=False, position=False
+        )
+        cover._open_dp.get_value.return_value = False
+        assert cover.current_cover_position == 0
+
+    def test_from_action_dp(self):
+        cover = _make_cover(
+            currentpos=False, open_dp=False, action=True, control=False, position=False
+        )
+        cover._action_dp.get_value.return_value = "closed"
+        assert cover.current_cover_position == 0
+
+    def test_from_position_dp(self):
+        cover = _make_cover(
+            currentpos=False, open_dp=False, action=False, control=False, position=True
+        )
+        cover._position_dp.get_value.return_value = 42
+        assert cover.current_cover_position == 42
+
+    def test_none_when_no_dps(self):
+        cover = _make_cover(
+            currentpos=False,
+            open_dp=False,
+            action=False,
+            control=False,
+            position=False,
+        )
+        assert cover.current_cover_position is None
+
+
+class TestCurrentCoverTiltPosition:
+    def test_with_range(self):
+        cover = _make_cover(tiltpos=True)
+        cover._tiltpos_dp.range.return_value = (0, 255)
+        cover._tiltpos_dp.get_value.return_value = 128
+        pos = cover.current_cover_tilt_position
+        assert pos is not None
+        assert 0 <= pos <= 100
+
+    def test_without_range(self):
+        cover = _make_cover(tiltpos=True)
+        cover._tiltpos_dp.range.return_value = None
+        cover._tiltpos_dp.get_value.return_value = 50
+        assert cover.current_cover_tilt_position == 50
+
+    def test_none_without_dp(self):
+        cover = _make_cover(tiltpos=False)
+        assert cover.current_cover_tilt_position is None
+
+
+class TestCurrentState:
+    def test_action_opening(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "opening"
+        assert cover._current_state == "opening"
+
+    def test_action_closing(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "closing"
+        assert cover._current_state == "closing"
+
+    def test_action_opened(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "opened"
+        assert cover._current_state == "opened"
+
+    def test_action_closed(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "closed"
+        assert cover._current_state == "closed"
+
+    def test_action_unknown_falls_to_position(self):
+        cover = _make_cover(currentpos=True, action=True)
+        cover._action_dp.get_value.return_value = "idle"
+        cover._currentpos_dp.get_value.return_value = 0
+        assert cover._current_state == "closed"
+
+    def test_position_low_is_closed(self):
+        cover = _make_cover(action=False, currentpos=True)
+        cover._currentpos_dp.get_value.return_value = 3
+        assert cover._current_state == "closed"
+
+    def test_position_high_is_opened(self):
+        cover = _make_cover(action=False, currentpos=True)
+        cover._currentpos_dp.get_value.return_value = 98
+        assert cover._current_state == "opened"
+
+    def test_mid_position_with_setpos_match_is_opened(self):
+        cover = _make_cover(action=False, currentpos=True, position=True)
+        cover._currentpos_dp.get_value.return_value = 50
+        cover._position_dp.get_value.return_value = 50
+        assert cover._current_state == "opened"
+
+    def test_mid_position_with_open_cmd_is_opening(self):
+        cover = _make_cover(action=False, currentpos=True, position=True, control=True)
+        cover._currentpos_dp.get_value.return_value = 50
+        cover._position_dp.get_value.return_value = 80
+        cover._control_dp.get_value.return_value = "open"
+        assert cover._current_state == "opening"
+
+    def test_mid_position_with_close_cmd_is_closing(self):
+        cover = _make_cover(action=False, currentpos=True, position=True, control=True)
+        cover._currentpos_dp.get_value.return_value = 50
+        cover._position_dp.get_value.return_value = 20
+        cover._control_dp.get_value.return_value = "close"
+        assert cover._current_state == "closing"
+
+    def test_none_when_no_position(self):
+        cover = _make_cover(
+            action=False,
+            currentpos=False,
+            position=False,
+            control=False,
+            open_dp=False,
+        )
+        assert cover._current_state is None
+
+
+class TestIsOpeningClosingClosed:
+    def test_is_opening_true(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "opening"
+        assert cover.is_opening is True
+
+    def test_is_opening_false(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "closed"
+        assert cover.is_opening is False
+
+    def test_is_opening_none(self):
+        cover = _make_cover(
+            action=False,
+            currentpos=False,
+            position=False,
+            control=False,
+            open_dp=False,
+        )
+        assert cover.is_opening is None
+
+    def test_is_closing_true(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "closing"
+        assert cover.is_closing is True
+
+    def test_is_closing_false(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "opened"
+        assert cover.is_closing is False
+
+    def test_is_closing_none(self):
+        cover = _make_cover(
+            action=False,
+            currentpos=False,
+            position=False,
+            control=False,
+            open_dp=False,
+        )
+        assert cover.is_closing is None
+
+    def test_is_closed_true(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "closed"
+        assert cover.is_closed is True
+
+    def test_is_closed_false(self):
+        cover = _make_cover()
+        cover._action_dp.get_value.return_value = "opened"
+        assert cover.is_closed is False
+
+    def test_is_closed_none(self):
+        cover = _make_cover(
+            action=False,
+            currentpos=False,
+            position=False,
+            control=False,
+            open_dp=False,
+        )
+        assert cover.is_closed is None
+
+
+class TestAsyncOpenCover:
+    @pytest.mark.asyncio
+    async def test_open_with_control(self):
+        cover = _make_cover()
+        cover._control_dp.async_set_value = AsyncMock()
+        await cover.async_open_cover()
+        cover._control_dp.async_set_value.assert_awaited_once_with(
+            cover._device, "open"
+        )
+
+    @pytest.mark.asyncio
+    async def test_open_with_position(self):
+        cover = _make_cover(control=False)
+        cover._position_dp.async_set_value = AsyncMock()
+        await cover.async_open_cover()
+        cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 100)
+
+    @pytest.mark.asyncio
+    async def test_open_raises_not_implemented(self):
+        cover = _make_cover(control=False, position=False, action=False)
+        with pytest.raises(NotImplementedError):
+            await cover.async_open_cover()
+
+
+class TestAsyncCloseCover:
+    @pytest.mark.asyncio
+    async def test_close_with_control(self):
+        cover = _make_cover()
+        cover._control_dp.async_set_value = AsyncMock()
+        await cover.async_close_cover()
+        cover._control_dp.async_set_value.assert_awaited_once_with(
+            cover._device, "close"
+        )
+
+    @pytest.mark.asyncio
+    async def test_close_with_position(self):
+        cover = _make_cover(control=False)
+        cover._position_dp.async_set_value = AsyncMock()
+        await cover.async_close_cover()
+        cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 0)
+
+    @pytest.mark.asyncio
+    async def test_close_raises_not_implemented(self):
+        cover = _make_cover(control=False, position=False, action=False)
+        with pytest.raises(NotImplementedError):
+            await cover.async_close_cover()
+
+
+class TestAsyncSetCoverPosition:
+    @pytest.mark.asyncio
+    async def test_set_position(self):
+        cover = _make_cover()
+        cover._position_dp.async_set_value = AsyncMock()
+        await cover.async_set_cover_position(position=50)
+        cover._position_dp.async_set_value.assert_awaited_once_with(cover._device, 50)
+
+    @pytest.mark.asyncio
+    async def test_set_position_none_raises(self):
+        cover = _make_cover()
+        with pytest.raises(AttributeError):
+            await cover.async_set_cover_position(position=None)
+
+    @pytest.mark.asyncio
+    async def test_set_position_no_dp_raises(self):
+        cover = _make_cover(position=False, action=False)
+        with pytest.raises(NotImplementedError):
+            await cover.async_set_cover_position(position=50)
+
+
+class TestAsyncSetCoverTiltPosition:
+    @pytest.mark.asyncio
+    async def test_tilt_with_fixed_values(self):
+        cover = _make_cover(tiltpos=True)
+        cover._tiltpos_dp.values.return_value = [0, 50, 100]
+        cover._tiltpos_dp.range.return_value = None
+        cover._tiltpos_dp.async_set_value = AsyncMock()
+        await cover.async_set_cover_tilt_position(tilt_position=60)
+        # Should snap to 50 (closest)
+        cover._tiltpos_dp.async_set_value.assert_awaited_once_with(cover._device, 50)
+
+    @pytest.mark.asyncio
+    async def test_tilt_with_range(self):
+        cover = _make_cover(tiltpos=True)
+        cover._tiltpos_dp.values.return_value = []
+        cover._tiltpos_dp.range.return_value = (0, 255)
+        cover._tiltpos_dp.async_set_value = AsyncMock()
+        await cover.async_set_cover_tilt_position(tilt_position=50)
+        cover._tiltpos_dp.async_set_value.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_tilt_raw(self):
+        cover = _make_cover(tiltpos=True)
+        cover._tiltpos_dp.values.return_value = []
+        cover._tiltpos_dp.range.return_value = None
+        cover._tiltpos_dp.async_set_value = AsyncMock()
+        await cover.async_set_cover_tilt_position(tilt_position=75)
+        cover._tiltpos_dp.async_set_value.assert_awaited_once_with(cover._device, 75)
+
+    @pytest.mark.asyncio
+    async def test_tilt_no_dp_raises(self):
+        cover = _make_cover(tiltpos=False)
+        with pytest.raises(NotImplementedError):
+            await cover.async_set_cover_tilt_position(tilt_position=50)
+
+
+class TestAsyncStopCover:
+    @pytest.mark.asyncio
+    async def test_stop_with_control(self):
+        cover = _make_cover()
+        cover._control_dp.async_set_value = AsyncMock()
+        await cover.async_stop_cover()
+        cover._control_dp.async_set_value.assert_awaited_once_with(
+            cover._device, "stop"
+        )
+
+    @pytest.mark.asyncio
+    async def test_stop_raises_not_implemented(self):
+        cover = _make_cover(control=False, position=False, action=False)
+        with pytest.raises(NotImplementedError):
+            await cover.async_stop_cover()

+ 190 - 1
tests/test_datetime.py

@@ -1,6 +1,7 @@
 """Tests for the datetime entity."""
 
-from unittest.mock import AsyncMock, Mock
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, Mock
 
 import pytest
 from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -87,3 +88,191 @@ async def test_init_entry_fails_if_config_is_missing(hass):
     except ValueError:
         pass
     m_add_entities.assert_not_called()
+
+
+def _make_dp(name, value=None):
+    dp = MagicMock()
+    dp.name = name
+    dp.hidden = False
+    dp.optional = True
+    dp.get_value.return_value = value
+    dp.get_values_to_set.side_effect = lambda dev, val, s: {name: val}
+    return dp
+
+
+def _make_datetime(
+    year=None,
+    month=None,
+    day=None,
+    hour=None,
+    minute=None,
+    second=None,
+):
+    """Create a TuyaLocalDateTime with mocked internals."""
+    device = MagicMock()
+    config = MagicMock()
+    config.name = "Test DateTime"
+    config.translation_key = None
+    config.translation_only_key = None
+    config.translation_placeholders = None
+    config.entity_category = None
+    config.config_id = "test_datetime"
+
+    dps = {}
+    dp_list = []
+    if year is not None:
+        dp = _make_dp("year", year)
+        dps["year"] = dp
+        dp_list.append(dp)
+    if month is not None:
+        dp = _make_dp("month", month)
+        dps["month"] = dp
+        dp_list.append(dp)
+    if day is not None:
+        dp = _make_dp("day", day)
+        dps["day"] = dp
+        dp_list.append(dp)
+    if hour is not None:
+        dp = _make_dp("hour", hour)
+        dps["hour"] = dp
+        dp_list.append(dp)
+    if minute is not None:
+        dp = _make_dp("minute", minute)
+        dps["minute"] = dp
+        dp_list.append(dp)
+    if second is not None:
+        dp = _make_dp("second", second)
+        dps["second"] = dp
+        dp_list.append(dp)
+
+    config.dps.return_value = dp_list
+
+    entity = object.__new__(TuyaLocalDateTime)
+    entity._device = device
+    entity._config = config
+    entity._attr_dps = []
+    entity._attr_translation_key = None
+    entity._attr_translation_placeholders = None
+    entity._year_dps = dps.get("year")
+    entity._month_dps = dps.get("month")
+    entity._day_dps = dps.get("day")
+    entity._hour_dps = dps.get("hour")
+    entity._minute_dps = dps.get("minute")
+    entity._second_dps = dps.get("second")
+
+    return entity
+
+
+class TestNativeValue:
+    def test_all_none_returns_none(self):
+        # Create with dps present but returning None values
+        entity = _make_datetime(hour=0, minute=0, second=0)
+        entity._hour_dps.get_value.return_value = None
+        entity._minute_dps.get_value.return_value = None
+        entity._second_dps.get_value.return_value = None
+        assert entity.native_value is None
+
+    def test_hour_minute_second(self):
+        entity = _make_datetime(hour=10, minute=30, second=45)
+        result = entity.native_value
+        assert result is not None
+        assert result.hour == 10
+        assert result.minute == 30
+        assert result.second == 45
+
+    def test_hour_only(self):
+        entity = _make_datetime(hour=14)
+        result = entity.native_value
+        assert result is not None
+        assert result.hour == 14
+        assert result.minute == 0
+        assert result.second == 0
+
+    def test_minute_only(self):
+        entity = _make_datetime(minute=45)
+        result = entity.native_value
+        assert result is not None
+        assert result.minute == 45
+
+    def test_defaults_when_partial(self):
+        entity = _make_datetime(hour=5, minute=0, second=0)
+        result = entity.native_value
+        assert result.year == 1970
+        assert result.month == 1
+
+
+class TestAsyncSetValue:
+    @pytest.mark.asyncio
+    async def test_set_hour_minute_second(self):
+        entity = _make_datetime(hour=0, minute=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 1, 14, 30, 45, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        entity._device.async_set_properties.assert_awaited_once()
+        settings = entity._device.async_set_properties.call_args[0][0]
+        assert settings["hour"] == 14
+        assert settings["minute"] == 30
+        assert settings["second"] == 45
+
+    @pytest.mark.asyncio
+    async def test_set_without_second_dp(self):
+        entity = _make_datetime(hour=0, minute=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 1, 10, 20, 30, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        assert settings["hour"] == 10
+        assert settings["minute"] == 20
+        assert "second" not in settings
+
+    @pytest.mark.asyncio
+    async def test_set_without_hour_dp(self):
+        entity = _make_datetime(minute=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 1, 2, 30, 15, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        # hour=2 should be folded into minutes: 2*60 + 30 = 150
+        assert settings["minute"] == 150
+        assert settings["second"] == 15
+
+    @pytest.mark.asyncio
+    async def test_set_without_minute_dp(self):
+        entity = _make_datetime(hour=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 1, 1, 5, 30, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        assert settings["hour"] == 1
+        # minute=5 folded into seconds: 5*60 + 30 = 330
+        assert settings["second"] == 330
+
+    @pytest.mark.asyncio
+    async def test_set_without_day_dp(self):
+        entity = _make_datetime(hour=0, minute=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 2, 3, 0, 0, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        # day=2 (1 extra day) should fold into hours: 1*24 + 3 = 27
+        assert settings["hour"] == 27
+
+    @pytest.mark.asyncio
+    async def test_set_with_day_dp(self):
+        entity = _make_datetime(day=0, hour=0, minute=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 1, 15, 8, 30, 0, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        assert settings["hour"] == 8
+        assert settings["minute"] == 30
+
+    @pytest.mark.asyncio
+    async def test_set_with_month_dp(self):
+        entity = _make_datetime(month=0, day=0, hour=0, minute=0, second=0)
+        entity._device.async_set_properties = AsyncMock()
+        value = datetime(1970, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
+        await entity.async_set_value(value)
+        settings = entity._device.async_set_properties.call_args[0][0]
+        assert settings["month"] == 3
+        assert settings["hour"] == 12

+ 320 - 0
tests/test_entity.py

@@ -0,0 +1,320 @@
+"""Tests for the TuyaLocalEntity base class and unit_from_ascii helper."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.const import (
+    CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+    UnitOfArea,
+    UnitOfTemperature,
+)
+from homeassistant.helpers.entity import EntityCategory
+
+from custom_components.tuya_local.entity import (
+    BLACKLISTED_ATTRIBUTES,
+    TuyaLocalEntity,
+    unit_from_ascii,
+)
+
+
+class DummySuper:
+    """Simulates a HA entity base with a name and icon property."""
+
+    @property
+    def name(self):
+        return "translated_name"
+
+    @property
+    def icon(self):
+        return "mdi:default-icon"
+
+
+class DummyEntity(TuyaLocalEntity, DummySuper):
+    """Concrete subclass so we can instantiate TuyaLocalEntity."""
+
+    def _default_to_device_class_name(self):
+        return False
+
+
+@pytest.fixture
+def mock_device():
+    device = MagicMock()
+    device.has_returned_state = True
+    device.unique_id = "test_device_123"
+    device.name = "Test Device"
+    device.device_info = {"identifiers": {("tuya_local", "test_device_123")}}
+    device.register_entity = MagicMock()
+    device.async_unregister_entity = AsyncMock()
+    device.async_refresh = AsyncMock()
+    return device
+
+
+@pytest.fixture
+def mock_config():
+    config = MagicMock()
+    config.name = "Test Entity"
+    config.translation_key = None
+    config.translation_only_key = None
+    config.translation_placeholders = None
+    config.entity_category = None
+    config.deprecated = False
+    config.deprecation_message = ""
+    config.config_id = "test_config"
+    config.device_class = None
+
+    dp1 = MagicMock()
+    dp1.name = "state"
+    dp1.hidden = False
+    dp1.optional = False
+    dp1.get_value.return_value = "on"
+
+    dp2 = MagicMock()
+    dp2.name = "temperature"
+    dp2.hidden = False
+    dp2.optional = False
+    dp2.get_value.return_value = 25
+
+    dp3 = MagicMock()
+    dp3.name = "available"
+    dp3.hidden = False
+    dp3.optional = False
+    dp3.get_value.return_value = True
+
+    config.dps.return_value = [dp1, dp2, dp3]
+    config.icon.return_value = None
+    config.available.return_value = True
+    config.unique_id.return_value = "tuya_local_test_device_123_test"
+    config.enabled_by_default.return_value = True
+
+    return config
+
+
+@pytest.fixture
+def entity(mock_device, mock_config):
+    e = DummyEntity()
+    dps = e._init_begin(mock_device, mock_config)
+    e._init_end(dps)
+    return e
+
+
+class TestInitBeginEnd:
+    def test_init_begin_sets_device_and_config(self, mock_device, mock_config):
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        assert e._device is mock_device
+        assert e._config is mock_config
+        assert isinstance(dps, dict)
+
+    def test_init_begin_returns_dps_dict(self, mock_device, mock_config):
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        assert "state" in dps
+        assert "temperature" in dps
+        assert "available" in dps
+
+    def test_init_begin_with_translation_key(self, mock_device, mock_config):
+        mock_config.translation_key = "my_key"
+        e = DummyEntity()
+        e._init_begin(mock_device, mock_config)
+        assert e._attr_translation_key == "my_key"
+
+    def test_init_begin_with_translation_only_key(self, mock_device, mock_config):
+        mock_config.translation_key = None
+        mock_config.translation_only_key = "only_key"
+        e = DummyEntity()
+        e._init_begin(mock_device, mock_config)
+        assert e._attr_translation_key == "only_key"
+
+    def test_init_begin_with_placeholders(self, mock_device, mock_config):
+        mock_config.translation_placeholders = {"x": "1"}
+        e = DummyEntity()
+        e._init_begin(mock_device, mock_config)
+        assert e._attr_translation_placeholders == {"x": "1"}
+
+    def test_init_end_excludes_blacklisted(self, mock_device, mock_config):
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        e._init_end(dps)
+        attr_names = [d.name for d in e._attr_dps]
+        for bl in BLACKLISTED_ATTRIBUTES:
+            assert bl not in attr_names
+
+    def test_init_end_excludes_hidden(self, mock_device, mock_config):
+        dp = MagicMock()
+        dp.name = "visible"
+        dp.hidden = True
+        dp.optional = False
+        mock_config.dps.return_value = [dp]
+
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        e._init_end(dps)
+        assert len(e._attr_dps) == 0
+
+    def test_init_end_includes_non_blacklisted_non_hidden(
+        self, mock_device, mock_config
+    ):
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        e._init_end(dps)
+        attr_names = [d.name for d in e._attr_dps]
+        assert "temperature" in attr_names
+
+
+class TestProperties:
+    def test_should_poll(self, entity):
+        assert entity.should_poll is False
+
+    def test_available_when_device_returned_state(self, entity):
+        assert entity.available is True
+
+    def test_available_false_when_no_state(self, entity, mock_device):
+        mock_device.has_returned_state = False
+        assert entity.available is False
+
+    def test_available_false_when_config_unavailable(self, entity, mock_config):
+        mock_config.available.return_value = False
+        assert entity.available is False
+
+    def test_has_entity_name(self, entity):
+        assert entity.has_entity_name is True
+
+    def test_name_returns_config_name(self, entity):
+        assert entity.name == "Test Entity"
+
+    def test_name_falls_back_to_super(self, entity, mock_config):
+        mock_config.name = None
+        mock_config.translation_key = "some_key"
+        # When use_device_name is False (has translation_key), name calls super
+        assert entity.name is not None
+
+    def test_name_uses_device_name_when_no_own_name(self, entity, mock_config):
+        mock_config.name = None
+        mock_config.translation_key = None
+        mock_config.device_class = None
+        assert entity.use_device_name is True
+
+    def test_use_device_name_false_with_name(self, entity):
+        assert entity.use_device_name is False
+
+    def test_use_device_name_false_with_translation_key(self, entity, mock_config):
+        mock_config.name = None
+        mock_config.translation_key = "some_key"
+        assert entity.use_device_name is False
+
+    def test_unique_id(self, entity):
+        assert entity.unique_id == "tuya_local_test_device_123_test"
+
+    def test_device_info(self, entity, mock_device):
+        assert entity.device_info is mock_device.device_info
+
+    def test_entity_category_none(self, entity):
+        assert entity.entity_category is None
+
+    def test_entity_category_config(self, entity, mock_config):
+        mock_config.entity_category = "config"
+        assert entity.entity_category == EntityCategory.CONFIG
+
+    def test_entity_category_diagnostic(self, entity, mock_config):
+        mock_config.entity_category = "diagnostic"
+        assert entity.entity_category == EntityCategory.DIAGNOSTIC
+
+    def test_icon_from_config(self, entity, mock_config):
+        mock_config.icon.return_value = "mdi:custom-icon"
+        assert entity.icon == "mdi:custom-icon"
+
+    def test_icon_falls_back_to_super(self, entity, mock_config):
+        mock_config.icon.return_value = None
+        assert entity.icon == "mdi:default-icon"
+
+    def test_extra_state_attributes(self, entity):
+        attrs = entity.extra_state_attributes
+        assert "temperature" in attrs
+        assert attrs["temperature"] == 25
+
+    def test_extra_state_attributes_skips_none_optional(self, mock_device, mock_config):
+        dp = MagicMock()
+        dp.name = "opt_attr"
+        dp.hidden = False
+        dp.optional = True
+        dp.get_value.return_value = None
+        mock_config.dps.return_value = [dp]
+
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        e._init_end(dps)
+        attrs = e.extra_state_attributes
+        assert "opt_attr" not in attrs
+
+    def test_extra_state_attributes_includes_none_required(
+        self, mock_device, mock_config
+    ):
+        dp = MagicMock()
+        dp.name = "req_attr"
+        dp.hidden = False
+        dp.optional = False
+        dp.get_value.return_value = None
+        mock_config.dps.return_value = [dp]
+
+        e = DummyEntity()
+        dps = e._init_begin(mock_device, mock_config)
+        e._init_end(dps)
+        attrs = e.extra_state_attributes
+        assert "req_attr" in attrs
+        assert attrs["req_attr"] is None
+
+    def test_entity_registry_enabled_default(self, entity, mock_config):
+        assert entity.entity_registry_enabled_default is True
+        mock_config.enabled_by_default.return_value = False
+        assert entity.entity_registry_enabled_default is False
+
+
+class TestAsyncMethods:
+    @pytest.mark.asyncio
+    async def test_async_update(self, entity, mock_device):
+        await entity.async_update()
+        mock_device.async_refresh.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_async_added_to_hass(self, entity, mock_device):
+        await entity.async_added_to_hass()
+        mock_device.register_entity.assert_called_once_with(entity)
+
+    @pytest.mark.asyncio
+    async def test_async_added_to_hass_logs_deprecation(
+        self, entity, mock_device, mock_config
+    ):
+        mock_config.deprecated = True
+        mock_config.deprecation_message = "This entity is deprecated"
+        with patch("custom_components.tuya_local.entity._LOGGER") as mock_logger:
+            await entity.async_added_to_hass()
+            mock_logger.warning.assert_called_with("This entity is deprecated")
+
+    @pytest.mark.asyncio
+    async def test_async_will_remove_from_hass(self, entity, mock_device):
+        await entity.async_will_remove_from_hass()
+        mock_device.async_unregister_entity.assert_awaited_once_with(entity)
+
+    def test_on_receive_does_nothing(self, entity):
+        # Default implementation is a no-op
+        entity.on_receive({}, False)
+
+
+class TestUnitFromAscii:
+    def test_celsius(self):
+        assert unit_from_ascii("C") == UnitOfTemperature.CELSIUS.value
+
+    def test_fahrenheit(self):
+        assert unit_from_ascii("F") == UnitOfTemperature.FAHRENHEIT.value
+
+    def test_micrograms(self):
+        assert unit_from_ascii("ugm3") == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
+
+    def test_square_meters(self):
+        assert unit_from_ascii("m2") == UnitOfArea.SQUARE_METERS
+
+    def test_passthrough_unknown(self):
+        assert unit_from_ascii("km/h") == "km/h"
+
+    def test_passthrough_empty(self):
+        assert unit_from_ascii("") == ""

+ 334 - 2
tests/test_remote.py

@@ -1,6 +1,9 @@
 """Tests for the remote entity."""
 
-from unittest.mock import AsyncMock, Mock
+import asyncio
+import json
+from collections import defaultdict
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
 
 import pytest
 from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -11,7 +14,12 @@ from custom_components.tuya_local.const import (
     CONF_TYPE,
     DOMAIN,
 )
-from custom_components.tuya_local.remote import TuyaLocalRemote, async_setup_entry
+from custom_components.tuya_local.remote import (
+    CMD_SEND,
+    CMD_SEND_RF,
+    TuyaLocalRemote,
+    async_setup_entry,
+)
 
 
 @pytest.mark.asyncio
@@ -94,3 +102,327 @@ async def test_init_entry_fails_if_config_is_missing(hass):
     except ValueError:
         pass
     m_add_entities.assert_not_called()
+
+
+def _make_remote(has_receive=True, has_control=False, has_delay=False, has_type=False):
+    """Create a TuyaLocalRemote with mocked internals."""
+    device = MagicMock()
+    device._hass = MagicMock()
+    device.unique_id = "test_remote_123"
+    device.async_set_properties = AsyncMock()
+    device.anticipate_property_value = MagicMock()
+
+    remote = object.__new__(TuyaLocalRemote)
+    remote._device = device
+    remote._config = MagicMock()
+    remote._config.name = "Test Remote"
+    remote._config.translation_key = None
+    remote._config.translation_only_key = None
+    remote._config.translation_placeholders = None
+    remote._config.entity_category = None
+    remote._config.config_id = "test_remote"
+    remote._attr_dps = []
+    remote._attr_translation_key = None
+    remote._attr_translation_placeholders = None
+    remote._attr_is_on = True
+    remote._attr_supported_features = 0
+
+    remote._send_dp = MagicMock()
+    remote._send_dp.id = 201
+    remote._send_dp.get_values_to_set.side_effect = lambda dev, val, d: {201: val}
+    remote._send_dp.async_set_value = AsyncMock()
+
+    if has_receive:
+        remote._receive_dp = MagicMock()
+        remote._receive_dp.id = 202
+    else:
+        remote._receive_dp = None
+
+    if has_control:
+        remote._control_dp = MagicMock()
+        remote._control_dp.get_values_to_set.side_effect = lambda dev, val, d: {
+            "control": val
+        }
+        remote._control_dp.async_set_value = AsyncMock()
+    else:
+        remote._control_dp = None
+
+    if has_delay:
+        remote._delay_dp = MagicMock()
+        remote._delay_dp.get_values_to_set.side_effect = lambda dev, val, d: {
+            "delay": val
+        }
+    else:
+        remote._delay_dp = None
+
+    if has_type:
+        remote._type_dp = MagicMock()
+        remote._type_dp.get_values_to_set.side_effect = lambda dev, val, d: {
+            "type": val
+        }
+    else:
+        remote._type_dp = None
+
+    remote._code_storage = MagicMock()
+    remote._code_storage.async_load = AsyncMock(return_value={})
+    remote._code_storage.async_save = AsyncMock()
+    remote._code_storage.async_delay_save = MagicMock()
+    remote._flag_storage = MagicMock()
+    remote._flag_storage.async_load = AsyncMock(return_value={})
+    remote._flag_storage.async_delay_save = MagicMock()
+    remote._storage_loaded = False
+    remote._codes = {}
+    remote._flags = defaultdict(int)
+    remote._lock = asyncio.Lock()
+
+    return remote
+
+
+class TestExtractCodes:
+    def test_b64_prefix(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        result = remote._extract_codes(["b64:AAAA"])
+        assert result == [["AAAA"]]
+
+    def test_rf_prefix(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        result = remote._extract_codes(["rf:BBBB"])
+        assert result == [["rf:BBBB"]]
+
+    def test_storage_lookup(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        remote._codes = {"tv": {"power": "CODE123"}}
+        result = remote._extract_codes(["power"], subdevice="tv")
+        assert result == [["CODE123"]]
+
+    def test_storage_lookup_list(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        remote._codes = {"tv": {"power": ["CODE_ON", "CODE_OFF"]}}
+        result = remote._extract_codes(["power"], subdevice="tv")
+        assert result == [["CODE_ON", "CODE_OFF"]]
+
+    def test_missing_subdevice_raises(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        with pytest.raises(ValueError, match="device must be specified"):
+            remote._extract_codes(["power"])
+
+    def test_missing_command_raises(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        remote._codes = {"tv": {}}
+        with pytest.raises(ValueError, match="not found"):
+            remote._extract_codes(["volume_up"], subdevice="tv")
+
+    def test_multiple_commands(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        result = remote._extract_codes(["b64:AAA", "b64:BBB"])
+        assert len(result) == 2
+
+
+class TestEncodeSendCode:
+    def test_ir_default(self):
+        remote = _make_remote()
+        dps = remote._encode_send_code("TESTCODE", 300)
+        assert 201 in dps
+        payload = json.loads(dps[201])
+        assert payload["control"] == CMD_SEND
+        assert "TESTCODE" in payload["key1"]
+        assert payload["delay"] == 300
+
+    def test_rf_mode(self):
+        remote = _make_remote()
+        dps = remote._encode_send_code("RFCODE", 0, is_rf=True)
+        assert 201 in dps
+        payload = json.loads(dps[201])
+        assert payload["control"] == CMD_SEND_RF
+        assert payload["key1"]["code"] == "RFCODE"
+
+    def test_with_control_dp(self):
+        remote = _make_remote(has_control=True)
+        dps = remote._encode_send_code("CODE", 100)
+        assert "control" in dps
+        assert dps["control"] == CMD_SEND
+        assert 201 in dps
+        assert dps[201] == "CODE"
+
+    def test_with_control_and_delay(self):
+        remote = _make_remote(has_control=True, has_delay=True)
+        dps = remote._encode_send_code("CODE", 500)
+        assert "delay" in dps
+        assert dps["delay"] == 500
+
+    def test_with_control_and_type(self):
+        remote = _make_remote(has_control=True, has_type=True)
+        dps = remote._encode_send_code("CODE", 100)
+        assert "type" in dps
+        assert dps["type"] == 0
+
+
+class TestAsyncSendCommand:
+    @pytest.mark.asyncio
+    async def test_send_b64_command(self):
+        remote = _make_remote()
+        await remote.async_send_command(["b64:TESTCODE"], num_repeats=1)
+        remote._device.async_set_properties.assert_awaited_once()
+        assert remote._storage_loaded is True
+
+    @pytest.mark.asyncio
+    async def test_send_rf_command(self):
+        remote = _make_remote()
+        await remote.async_send_command(["rf:RFCODE"], num_repeats=1)
+        call_args = remote._device.async_set_properties.call_args[0][0]
+        payload = json.loads(call_args[201])
+        assert payload["control"] == CMD_SEND_RF
+
+    @pytest.mark.asyncio
+    async def test_send_stored_command(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {"power": "STORED_CODE"}}
+        await remote.async_send_command(["power"], device="tv", num_repeats=1)
+        remote._device.async_set_properties.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_send_toggle_command(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {"power": ["ON_CODE", "OFF_CODE"]}}
+        await remote.async_send_command(["power"], device="tv", num_repeats=1)
+        # First call uses flag=0 (ON_CODE)
+        remote._device.async_set_properties.assert_awaited_once()
+        # Flag should have been toggled
+        assert remote._flags["tv"] == 1
+        remote._flag_storage.async_delay_save.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_send_invalid_command_raises(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {}}
+        with pytest.raises(ValueError):
+            await remote.async_send_command(["missing"], device="tv", num_repeats=1)
+
+    @pytest.mark.asyncio
+    async def test_loads_storage_on_first_send(self):
+        remote = _make_remote()
+        assert remote._storage_loaded is False
+        await remote.async_send_command(["b64:CODE"], num_repeats=1)
+        assert remote._storage_loaded is True
+        remote._code_storage.async_load.assert_awaited_once()
+
+
+class TestAsyncDeleteCommand:
+    @pytest.mark.asyncio
+    async def test_delete_command(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {"power": "CODE", "volume": "CODE2"}}
+        remote._storage_loaded = True
+        await remote.async_delete_command(command=["power"], device="tv")
+        assert "power" not in remote._codes["tv"]
+        assert "volume" in remote._codes["tv"]
+
+    @pytest.mark.asyncio
+    async def test_delete_last_command_cleans_up(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {"power": "CODE"}}
+        remote._flags["tv"] = 1
+        remote._storage_loaded = True
+        await remote.async_delete_command(command=["power"], device="tv")
+        assert "tv" not in remote._codes
+        remote._flag_storage.async_delay_save.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_delete_missing_device_raises(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        with pytest.raises(ValueError, match="Device not found"):
+            await remote.async_delete_command(command=["power"], device="unknown")
+
+    @pytest.mark.asyncio
+    async def test_delete_missing_command_raises(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {}}
+        remote._storage_loaded = True
+        with pytest.raises(ValueError, match="Command not found"):
+            await remote.async_delete_command(command=["missing"], device="tv")
+
+    @pytest.mark.asyncio
+    async def test_delete_partial_missing_logs_error(self):
+        remote = _make_remote()
+        remote._codes = {"tv": {"power": "CODE"}}
+        remote._storage_loaded = True
+        # "power" exists, "missing" does not — partial failure, no raise
+        await remote.async_delete_command(command=["power", "missing"], device="tv")
+        assert "power" not in remote._codes.get("tv", {})
+
+
+class TestAsyncLearnCommand:
+    @pytest.mark.asyncio
+    async def test_learn_ir_command(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        remote._receive_dp.get_value.side_effect = [None, "LEARNED_CODE"]
+
+        with patch("custom_components.tuya_local.remote.persistent_notification"):
+            with patch(
+                "custom_components.tuya_local.remote.asyncio.sleep",
+                new_callable=AsyncMock,
+            ):
+                await remote.async_learn_command(
+                    command=["power"], device="tv", alternative=False
+                )
+
+        assert remote._codes["tv"]["power"] == "LEARNED_CODE"
+        remote._code_storage.async_save.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_learn_timeout_raises(self):
+        remote = _make_remote()
+        remote._storage_loaded = True
+        remote._receive_dp.get_value.return_value = None
+
+        with patch("custom_components.tuya_local.remote.persistent_notification"):
+            with patch(
+                "custom_components.tuya_local.remote.asyncio.sleep",
+                new_callable=AsyncMock,
+            ):
+                with patch(
+                    "custom_components.tuya_local.remote.dt_util.utcnow"
+                ) as mock_now:
+                    from datetime import datetime, timedelta
+
+                    start = datetime(2026, 1, 1)
+                    # First call returns start, then jumps past timeout
+                    mock_now.side_effect = [
+                        start,
+                        start,
+                        start + timedelta(seconds=31),
+                    ]
+                    with pytest.raises(TimeoutError):
+                        await remote.async_learn_command(
+                            command=["power"], device="tv", alternative=False
+                        )
+
+
+class TestAsyncLoadStorage:
+    @pytest.mark.asyncio
+    async def test_load_storage(self):
+        remote = _make_remote()
+        remote._code_storage.async_load.return_value = {"tv": {"power": "CODE"}}
+        remote._flag_storage.async_load.return_value = {"tv": 1}
+        await remote._async_load_storage()
+        assert remote._storage_loaded is True
+        assert remote._codes == {"tv": {"power": "CODE"}}
+        assert remote._flags["tv"] == 1
+
+    @pytest.mark.asyncio
+    async def test_load_empty_storage(self):
+        remote = _make_remote()
+        remote._code_storage.async_load.return_value = None
+        remote._flag_storage.async_load.return_value = None
+        await remote._async_load_storage()
+        assert remote._storage_loaded is True
+        assert remote._codes == {}