diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40980f6..c244d7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,7 @@ repos: - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - --skip="./.*,*.csv,*.json" - --quiet-level=2 + exclude: ^.*\.patch$ exclude_types: [ csv, json ] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/custom_components/schellenberg_usb/cover.py b/custom_components/schellenberg_usb/cover.py index 7638ee4..c8eebde 100644 --- a/custom_components/schellenberg_usb/cover.py +++ b/custom_components/schellenberg_usb/cover.py @@ -737,6 +737,8 @@ def _handle_event(self, event: str) -> None: self._attr_is_opening = logical_opening self._attr_is_closing = not logical_opening self._move_start_time = time.monotonic() + if self._attr_current_cover_position is None: + self._attr_current_cover_position = 0 self._move_start_position = self._attr_current_cover_position self._position_source_kind = "primary status" self._position_confirmed_since_restart = True diff --git a/tests/test_api_extended.py b/tests/test_api_extended.py index 5475a76..77f3f08 100644 --- a/tests/test_api_extended.py +++ b/tests/test_api_extended.py @@ -145,7 +145,8 @@ async def _complete_transmit(_: str) -> bool: api._handle_message("t0") return True - api._wait_for_transmitter_idle = AsyncMock(side_effect=_complete_transmit) + wait_for_idle = AsyncMock(side_effect=_complete_transmit) + setattr(api, "_wait_for_transmitter_idle", wait_for_idle) with caplog.at_level("WARNING"): assert await api.teach_motor("0d", device_id="F2B8D5", source="developer_tools") @@ -154,7 +155,7 @@ async def _complete_transmit(_: str) -> bool: b"ss0D9600000\r\n", b"ss0D9400000\r\n", ] - assert api._wait_for_transmitter_idle.await_args_list == [ + assert wait_for_idle.await_args_list == [ call("finishing motor teach phase teach_60"), call("finishing motor teach phase finish_40"), ] @@ -183,12 +184,13 @@ async def test_raw_transmit_preserves_exact_protocol_slots( api._transport = mock_transport api._is_connected = True api._device_mode = "listening" - api._wait_for_transmitter_idle = AsyncMock(return_value=True) + wait_for_idle = AsyncMock(return_value=True) + setattr(api, "_wait_for_transmitter_idle", wait_for_idle) assert await api.send_raw_transmit(payload) mock_transport.write.assert_called_once_with(expected) - api._wait_for_transmitter_idle.assert_awaited_once_with("finishing raw RF transmit") + wait_for_idle.assert_awaited_once_with("finishing raw RF transmit") @pytest.mark.asyncio @@ -222,7 +224,8 @@ async def test_raw_transmit_timeout_latches_busy_state(hass: HomeAssistant) -> N api._transport = mock_transport api._is_connected = True api._device_mode = "listening" - api._wait_for_transmitter_idle = AsyncMock(return_value=False) + wait_for_idle = AsyncMock(return_value=False) + setattr(api, "_wait_for_transmitter_idle", wait_for_idle) assert not await api.send_raw_transmit("ss109010000") diff --git a/tests/test_api_messages.py b/tests/test_api_messages.py index c057a92..e01dfec 100644 --- a/tests/test_api_messages.py +++ b/tests/test_api_messages.py @@ -502,7 +502,9 @@ def test_manual_position_sync_dispatches_and_retains_last_confirmation( new_position=45, status="estimated", ) - assert api.get_last_position_update("F2B8D5")["new_position"] == 45 + latest_position_update = api.get_last_position_update("F2B8D5") + assert latest_position_update is not None + assert latest_position_update["new_position"] == 45 assert api.get_last_manual_position_sync("F2B8D5") == manual_update diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 2dde39d..c218a85 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,16 +3,16 @@ from __future__ import annotations from types import MappingProxyType -from uuid import UUID from unittest.mock import AsyncMock, MagicMock, call, patch +from uuid import UUID import pytest from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, ConfigEntries, ConfigSubentry, ConfigSubentryFlow, - SOURCE_RECONFIGURE, - SOURCE_USER, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -23,10 +23,10 @@ SchellenbergPairingSubentryFlow, ) from custom_components.schellenberg_usb.const import ( - CONF_BLIND_ID, CMD_DOWN, CMD_STOP, CMD_UP, + CONF_BLIND_ID, CONF_CLOSE_TIME, CONF_CLOSE_TIME_SECONDS, CONF_COMMAND_DEVICE_ID, @@ -573,6 +573,7 @@ async def test_developer_tools_show_restored_startup_confidence() -> None: copy_result = await flow.async_step_copy_diagnostics() placeholders = result["description_placeholders"] + assert placeholders is not None assert placeholders["current_position"] == "75%" assert placeholders["position_source"] == "restored HA state" assert placeholders["position_confidence"] == ( @@ -580,7 +581,9 @@ async def test_developer_tools_show_restored_startup_confidence() -> None: ) assert placeholders["position_confirmed_since_restart"] == "No" - diagnostics = copy_result["data_schema"]({})["diagnostics"] + data_schema = copy_result["data_schema"] + assert data_schema is not None + diagnostics = data_schema({})["diagnostics"] assert "Current estimated position: 75" in diagnostics assert "Source: restored HA state" in diagnostics assert ( @@ -681,6 +684,7 @@ async def test_developer_manual_position_form_uses_current_position_and_submits( result = await flow.async_step_set_position_manual({"position": 63}) assert form["step_id"] == "set_position_manual" + assert form["data_schema"] is not None assert form["data_schema"]({})["position"] == 37 assert form["description_placeholders"] == { "selected_blind": "Sitting room door", @@ -688,6 +692,7 @@ async def test_developer_manual_position_form_uses_current_position_and_submits( } api.manual_sync_position.assert_called_once_with("F2B8D5", 63) assert result["step_id"] == "developer_tools" + assert result["description_placeholders"] is not None assert "confirmed at 63%" in result["description_placeholders"]["result"] @@ -746,6 +751,7 @@ async def test_original_remote_discovery_saves_primary_secondary_and_provenance( assert form["step_id"] == "discover_status" assert confirmation["step_id"] == "confirm_status_discovery" placeholders = confirmation["description_placeholders"] + assert placeholders is not None assert placeholders["command_identity"] == "06C5C0/11" assert placeholders["primary_identity"] == "3720B8/08" assert placeholders["primary_commands"] == "01, 00, 02" @@ -798,10 +804,9 @@ async def test_original_remote_discovery_keeps_status_unknown_when_unrecognized( confirmation = await flow.async_step_discover_status({}) result = await flow.async_step_confirm_status_discovery({}) - assert ( - "No remote/status tracking identity" - in confirmation["description_placeholders"]["position_tracking"] - ) + placeholders = confirmation["description_placeholders"] + assert placeholders is not None + assert "No remote/status tracking identity" in placeholders["position_tracking"] assert result["type"] is FlowResultType.CREATE_ENTRY assert CONF_STATUS_DEVICE_ID not in result["data"] assert CONF_STATUS_ENUM not in result["data"] @@ -916,6 +921,7 @@ async def test_developer_raw_command_validates_and_uses_api() -> None: result = await flow.async_step_send_raw_command({"payload": "ss109010000"}) assert form["step_id"] == "send_raw_command" + assert form["data_schema"] is not None assert form["data_schema"]({})["payload"] == "ss109010000" api.send_raw_transmit.assert_awaited_once_with( "ss109010000", source="developer_tools" diff --git a/tests/test_cover.py b/tests/test_cover.py index 33acb31..afa66e8 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -12,8 +12,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er from custom_components.schellenberg_usb.api import SchellenbergUsbApi from custom_components.schellenberg_usb.const import ( @@ -184,29 +184,26 @@ async def test_setup_restores_manual_cover_from_persisted_subentry( source="user", unique_id="/dev/ttyUSB0", discovery_keys=MappingProxyType({}), - subentries_data=[ - { - "subentry_id": "manual_blind", - "subentry_type": SUBENTRY_TYPE_BLIND, - "title": "Sitting room door", - "unique_id": "F2B8D5", - "data": { - CONF_BLIND_ID: TEST_BLIND_ID, - CONF_DEVICE_ID: "F2B8D5", - CONF_DEVICE_ENUM: "23", - CONF_COMMAND_DEVICE_ID: "F2B8D5", - CONF_COMMAND_ENUM: command_enum, - CONF_STATUS_DEVICE_ID: "3720B8", - CONF_STATUS_ENUM: status_enum, - CONF_SECONDARY_STATUS_IDENTITIES: [ - {"device_id": "F2B8D5", "enum": "23"} - ], - CONF_OPEN_TIME: 25.06, - CONF_CLOSE_TIME: 23.05, - }, - } - ], + subentries_data=None, ) + subentry = MagicMock() + subentry.subentry_id = "manual_blind" + subentry.subentry_type = SUBENTRY_TYPE_BLIND + subentry.title = "Sitting room door" + subentry.unique_id = "F2B8D5" + subentry.data = { + CONF_BLIND_ID: TEST_BLIND_ID, + CONF_DEVICE_ID: "F2B8D5", + CONF_DEVICE_ENUM: "23", + CONF_COMMAND_DEVICE_ID: "F2B8D5", + CONF_COMMAND_ENUM: command_enum, + CONF_STATUS_DEVICE_ID: "3720B8", + CONF_STATUS_ENUM: status_enum, + CONF_SECONDARY_STATUS_IDENTITIES: [{"device_id": "F2B8D5", "enum": "23"}], + CONF_OPEN_TIME: 25.06, + CONF_CLOSE_TIME: 23.05, + } + entry.subentries = MappingProxyType({"manual_blind": subentry}) # type: ignore[misc] entry.runtime_data = mock_api hass.config_entries._entries[entry.entry_id] = entry add_entities = MagicMock() @@ -730,6 +727,33 @@ async def test_cover_handle_started_moving_down( assert cover._move_start_position == 100 +@pytest.mark.asyncio +async def test_cover_handle_started_moving_up_without_known_position_defaults_to_zero( + hass: HomeAssistant, + mock_api: SchellenbergUsbApi, +) -> None: + """Test movement events default to a closed-start estimate when no prior position exists.""" + cover = SchellenbergCover( + api=mock_api, + device_id="ABC123", + device_enum="01", + device_name="Test Cover", + ) + cover.hass = hass + cover._attr_current_cover_position = None + + with ( + patch.object(cover, "_start_position_tracking") as start_tracking, + patch.object(cover, "async_write_ha_state"), + ): + cover._handle_event(EVENT_STARTED_MOVING_UP) + + assert cover._attr_is_opening is True + assert cover._attr_is_closing is False + assert cover._move_start_position == 0 + start_tracking.assert_called_once_with() + + @pytest.mark.asyncio async def test_cover_handle_stopped( hass: HomeAssistant,