From 697d7e54d0d1336b5bc83b837f7e48567ea58ed5 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Tue, 13 Oct 2026 12:56:10 +0000 Subject: [PATCH 1/8] Fix cover position scale and native endpoint movements --- README.md | 29 ++++-- .../blebox_smartwindowbox/cover.py | 51 ++++++----- .../blebox_smartwindowbox/manifest.json | 14 +-- tests/test_cover.py | 89 +++++++++++++++++++ 4 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 tests/test_cover.py diff --git a/README.md b/README.md index 46c9012..716cd6c 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,17 @@ The smartWindowBox motor is exposed as a standard Home Assistant **cover entity* * `Open` * `Close` * `Stop` -* `Set position (0=open, 100=closed)` +* `Set position (0=closed, 100=open)` -Since the official BleBox API does **not** support direct position setting, this integration emulates it by: +For intermediate positions, the integration emulates position setting because the BleBox API does **not** support direct position setting: 1. Sending open or close command 2. Polling device state 3. Stopping the motor once the target position is reached -This method is precise and tuned for real-world motor timing. +Fully closed (0%) and fully open (100%) use the native close/open commands. The device completes its calibrated travel without an early stop based on a reported percentage. + +All exposed position attributes use the Home Assistant scale (0% closed, 100% open). `device_position` retains the raw BleBox scale (0% open, 100% closed). Existing automations using numeric positions must invert their targets and comparisons (`100 - old_position`) when upgrading. --- @@ -192,8 +194,8 @@ You can: Position scale: ``` -0 = Fully Open -100 = Fully Closed +0 = Fully Closed +100 = Fully Open ``` --- @@ -307,6 +309,21 @@ This value has been tuned for reliable real-world accuracy. --- +# Development checks + +With Home Assistant installed in the active Python environment, run the cover +regression tests from the repository root: + +```sh +python -m unittest discover -s tests -v +``` + +These cover the Home Assistant position scale, intermediate movements, and native +endpoint commands. They use simulated responses; physical travel still needs to +be verified on a real device. The GitHub workflow also runs Hassfest validation. + +--- + # Future Improvements (Ideas) * HACS support @@ -319,4 +336,4 @@ This value has been tuned for reliable real-world accuracy. # License -MIT License \ No newline at end of file +MIT License diff --git a/custom_components/blebox_smartwindowbox/cover.py b/custom_components/blebox_smartwindowbox/cover.py index 9a22c9b..4bc7150 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -32,6 +32,14 @@ def _motor_list(data: dict[str, Any]) -> list[dict[str, Any]]: return (data.get("window") or {}).get("motors") or [] +def _ha_position(device_position: Any) -> int | None: + """Convert BleBox's closed percentage to Home Assistant's open percentage.""" + try: + return 100 - int(device_position) + except (TypeError, ValueError): + return None + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -97,18 +105,14 @@ def name(self) -> str | None: return f"{self._name_prefix} {base}" return str(base) - # Your device scale: - # 0 = fully open, 100 = fully closed + # Home Assistant: 0 = fully closed, 100 = fully open. @property def current_cover_position(self) -> int | None: motor = self._motor() if not motor: return None pos = (motor.get("currentPos") or {}).get("position") - try: - return int(pos) - except (TypeError, ValueError): - return None + return _ha_position(pos) # Your observed mapping: # 0 closing (moving to 100) @@ -131,7 +135,7 @@ def is_closed(self) -> bool | None: pos = self.current_cover_position if pos is None: return None - return pos >= 100 + return pos <= 0 def _full_travel_time_s(self, motor: dict[str, Any], direction: str) -> float: calib = motor.get("calibrationParameters") or {} @@ -240,9 +244,10 @@ def extra_state_attributes(self) -> dict[str, Any]: return { "enabled": motor.get("enabled"), "motor_state": motor.get("state"), - "current_position": cur_i, - "desired_position": des_i, - "favorite_position": fav.get("position"), + "current_position": _ha_position(cur_i), + "device_position": cur_i, + "desired_position": _ha_position(des_i), + "favorite_position": _ha_position(fav.get("position")), "at_favorite": at_fav, "control_type": motor.get("controlType"), "icon_set": motor.get("iconSet"), @@ -252,8 +257,8 @@ def extra_state_attributes(self) -> dict[str, Any]: # movement metadata (any movement) "moving": self._tracked_moving, "move_direction": self._move_direction, - "move_target_position": self._move_target, - "move_start_position": self._move_start_pos, + "move_target_position": _ha_position(self._move_target), + "move_start_position": _ha_position(self._move_start_pos), "move_elapsed_s": move_elapsed_s, "estimated_total_s": estimated_total_s, "estimated_remaining_s": estimated_remaining_s, @@ -283,7 +288,8 @@ async def async_next_step(self) -> None: async def async_set_cover_position(self, **kwargs: Any) -> None: """ Emulate set position by moving and stopping when target reached. - Position scale: 0=open, 100=closed + Position scale: 0=closed, 100=open. + Endpoints use native commands so the device can finish its travel. """ target = kwargs.get("position") if target is None: @@ -295,13 +301,18 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: return target = max(0, min(100, target)) - # Special-case endpoints for better accuracy + # A reported endpoint may precede the physical end of travel. + # Do not stop the motor or skip the command based on cached position. + if target == 0: + await self.async_close_cover() + return if target == 100: - tol = 0 - elif target == 0: - tol = 0 - else: - tol = POSITION_TOLERANCE + await self.async_open_cover() + return + + # Keep the polling loop in the native BleBox coordinate system. + target = 100 - target + tol = POSITION_TOLERANCE motor = self._motor() if not motor: @@ -362,4 +373,4 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await asyncio.sleep(EXTRA_STOP_DELAY_SEC) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await self.coordinator.async_request_refresh() - return \ No newline at end of file + return diff --git a/custom_components/blebox_smartwindowbox/manifest.json b/custom_components/blebox_smartwindowbox/manifest.json index e9b58d8..69a38d3 100644 --- a/custom_components/blebox_smartwindowbox/manifest.json +++ b/custom_components/blebox_smartwindowbox/manifest.json @@ -1,11 +1,13 @@ { "domain": "blebox_smartwindowbox", "name": "BleBox smartWindowBox", - "version": "0.0.1", - "documentation": "https://github.com/Sjnaaf/bleBoxSmartWindowBox/blob/main/README.md", - "requirements": [], - "codeowners": ["@sjnaaf"], + "codeowners": [ + "@sjnaaf" + ], "config_flow": true, + "documentation": "https://github.com/Sjnaaf/bleBoxSmartWindowBox/blob/main/README.md", + "integration_type": "device", "iot_class": "local_polling", - "integration_type": "device" -} \ No newline at end of file + "requirements": [], + "version": "0.0.1" +} diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..d17b935 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,89 @@ +"""Regression checks for upstream issues #1 and #2. + +Run with: python -m unittest discover -s tests -v +Requires Home Assistant in the active Python environment. +""" +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from custom_components.blebox_smartwindowbox.cover import BleBoxMotorCover + + +class CoverTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.motor = { + 'id': 0, 'state': 2, 'currentPos': {'position': 40}, + 'desiredPos': {'position': 80}, 'favPos': {'position': 30}, + } + self.data = {'window': {'motors': [self.motor]}} + self.api = SimpleNamespace( + send_motor_command=AsyncMock(), + window_extended_state=AsyncMock(return_value=self.data), + ) + self.coordinator = SimpleNamespace( + data=self.data, api=self.api, config_entry=None, + async_request_refresh=AsyncMock(), + ) + self.cover = BleBoxMotorCover(self.coordinator, 'entry', 0, None) + + def test_reported_positions_and_closed_state(self): + for raw, expected, closed in [(100, 0, True), (0, 100, False), (40, 60, False)]: + with self.subTest(raw=raw): + self.motor['currentPos']['position'] = raw + self.assertEqual(self.cover.current_cover_position, expected) + self.assertEqual(self.cover.extra_state_attributes['current_position'], expected) + self.assertEqual(self.cover.is_closed, closed) + self.assertEqual(self.cover.extra_state_attributes['device_position'], raw) + + def test_unknown_position(self): + for value in (None, 'invalid'): + self.motor['currentPos']['position'] = value + self.assertIsNone(self.cover.current_cover_position) + self.assertIsNone(self.cover.is_closed) + self.assertIsNone(self.cover.extra_state_attributes['current_position']) + + def test_position_metadata_uses_home_assistant_scale(self): + self.cover._move_target = 80 + self.cover._move_start_pos = 40 + attributes = self.cover.extra_state_attributes + self.assertEqual(attributes['desired_position'], 20) + self.assertEqual(attributes['favorite_position'], 70) + self.assertEqual(attributes['move_target_position'], 20) + self.assertEqual(attributes['move_start_position'], 60) + + async def test_endpoints_do_not_stop_or_skip_motor(self): + # Cached/reported positions near or at an endpoint must still send + # the native command, leaving physical completion to the device. + for target, command in [(0, 'd'), (100, 'u')]: + for raw in (0, 1, 50, 99, 100): + with self.subTest(target=target, raw=raw): + self.api.send_motor_command.reset_mock() + self.motor['currentPos']['position'] = raw + await self.cover.async_set_cover_position(position=target) + self.api.send_motor_command.assert_awaited_once_with(0, command) + self.api.window_extended_state.assert_not_awaited() + + async def test_intermediate_positions_move_in_correct_direction_and_stop(self): + for target, initial, polled, command in [(70, 60, 30, 'u'), (20, 40, 80, 'd')]: + with self.subTest(target=target): + self.motor['currentPos']['position'] = initial + self.api.send_motor_command.reset_mock() + self.api.window_extended_state.return_value = {'window': {'motors': [ + {'id': 0, 'currentPos': {'position': polled}} + ]}} + with patch('custom_components.blebox_smartwindowbox.cover.asyncio.sleep', new=AsyncMock()): + await self.cover.async_set_cover_position(position=target) + self.assertEqual( + [call.args for call in self.api.send_motor_command.await_args_list], + [(0, command), (0, 's'), (0, 's')], + ) + + async def test_matching_intermediate_position_does_not_move(self): + await self.cover.async_set_cover_position(position=60) + self.api.send_motor_command.assert_not_awaited() + + async def test_invalid_target_does_not_move(self): + for target in (None, 'invalid'): + await self.cover.async_set_cover_position(position=target) + self.api.send_motor_command.assert_not_awaited() From c067ec06d12c24011d64bac5e59f2982a16c215e Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Tue, 13 Oct 2026 13:07:30 +0000 Subject: [PATCH 2/8] Add BleBox brand icon --- .../blebox_smartwindowbox/brand/icon.png | Bin 0 -> 13639 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 custom_components/blebox_smartwindowbox/brand/icon.png diff --git a/custom_components/blebox_smartwindowbox/brand/icon.png b/custom_components/blebox_smartwindowbox/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a425a6d08931c8edc673a21445a29f65ed7f8fa3 GIT binary patch literal 13639 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 Date: Tue, 13 Oct 2026 13:29:03 +0000 Subject: [PATCH 3/8] Publish cover movement updates promptly --- README.md | 12 +++++ .../blebox_smartwindowbox/const.py | 3 +- .../blebox_smartwindowbox/coordinator.py | 13 +++-- .../blebox_smartwindowbox/cover.py | 18 +++---- tests/test_coordinator.py | 48 +++++++++++++++++++ tests/test_cover.py | 41 +++++++++++++++- 6 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 tests/test_coordinator.py diff --git a/README.md b/README.md index 716cd6c..8d01ffa 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,18 @@ While the window is moving (open, close, favorite, or manual movement), the foll These update live during movement. +Device state is refreshed every second while a motor is moving and every five +seconds while idle. Commands trigger an immediate refresh. Intermediate-position +control also publishes each direct device reading to Home Assistant. + +### Apple Home / HomeKit Bridge + +Use Home Assistant 2026.10 or newer when exposing the cover through HomeKit +Bridge. In 2026.9.3 the bridge can retain an old target position during movement +started outside Apple Home, causing iOS to display the opposite direction even +when Home Assistant reports the correct direction. The bridge correction is in +Home Assistant; updating this custom integration alone does not fix that issue. + --- ### Rain Sensor diff --git a/custom_components/blebox_smartwindowbox/const.py b/custom_components/blebox_smartwindowbox/const.py index 5f592b7..071c42a 100644 --- a/custom_components/blebox_smartwindowbox/const.py +++ b/custom_components/blebox_smartwindowbox/const.py @@ -7,6 +7,7 @@ PLATFORMS = ["cover", "binary_sensor"] UPDATE_INTERVAL_SECONDS = 5 +MOVING_UPDATE_INTERVAL_SECONDS = 1 # Motor commands CMD_UP = "u" # moves toward position 0 (open) on your device @@ -18,4 +19,4 @@ # Emulated position control tuning POSITION_TOLERANCE = 1 # stop when within 1% POLL_INTERVAL_SEC = 0.35 # fast polling while moving to target -EXTRA_STOP_DELAY_SEC = 0.15 # optional second stop to reduce coasting \ No newline at end of file +EXTRA_STOP_DELAY_SEC = 0.15 # optional second stop to reduce coasting diff --git a/custom_components/blebox_smartwindowbox/coordinator.py b/custom_components/blebox_smartwindowbox/coordinator.py index 99cec67..cbc3258 100644 --- a/custom_components/blebox_smartwindowbox/coordinator.py +++ b/custom_components/blebox_smartwindowbox/coordinator.py @@ -7,7 +7,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import BleBoxSmartWindowBoxApi, BleBoxApiError -from .const import DOMAIN, UPDATE_INTERVAL_SECONDS +from .const import DOMAIN, MOVING_UPDATE_INTERVAL_SECONDS, UPDATE_INTERVAL_SECONDS class BleBoxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -22,6 +22,13 @@ def __init__(self, hass: HomeAssistant, api: BleBoxSmartWindowBoxApi) -> None: async def _async_update_data(self) -> dict[str, Any]: try: - return await self.api.window_extended_state() + data = await self.api.window_extended_state() except BleBoxApiError as e: - raise UpdateFailed(str(e)) from e \ No newline at end of file + raise UpdateFailed(str(e)) from e + + motors = (data.get("window") or {}).get("motors") or [] + moving = any(motor.get("state") in (0, 1) for motor in motors) + self.update_interval = timedelta( + seconds=MOVING_UPDATE_INTERVAL_SECONDS if moving else UPDATE_INTERVAL_SECONDS + ) + return data diff --git a/custom_components/blebox_smartwindowbox/cover.py b/custom_components/blebox_smartwindowbox/cover.py index 4bc7150..5c61b8c 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -267,23 +267,23 @@ def extra_state_attributes(self) -> dict[str, Any]: async def async_open_cover(self, **kwargs: Any) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_UP) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs: Any) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_DOWN) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() async def async_stop_cover(self, **kwargs: Any) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() async def async_favorite(self) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_FAV) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() async def async_next_step(self) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_NEXT) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() async def async_set_cover_position(self, **kwargs: Any) -> None: """ @@ -335,18 +335,20 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: timeout_s = max(5.0, full_time_s + 5.0) await self.coordinator.api.send_motor_command(self._channel, command) + await self.coordinator.async_refresh() start = time.monotonic() while True: if time.monotonic() - start > timeout_s: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() return await asyncio.sleep(POLL_INTERVAL_SEC) # Poll direct for freshest position data = await self.coordinator.api.window_extended_state() + self.coordinator.async_set_updated_data(data) motors = (data.get("window") or {}).get("motors") or [] m = next((mm for mm in motors if int(mm.get("id", -1)) == self._channel), None) if not m: @@ -364,7 +366,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await asyncio.sleep(EXTRA_STOP_DELAY_SEC) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() return else: # closing upward (toward 100): stop once we've reached/passed target @@ -372,5 +374,5 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await asyncio.sleep(EXTRA_STOP_DELAY_SEC) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() return diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..c9ce61d --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,48 @@ +"""Verify polling responds to actual device movement.""" +import tempfile +import unittest +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.blebox_smartwindowbox.api import BleBoxApiError +from custom_components.blebox_smartwindowbox.coordinator import BleBoxCoordinator + + +class CoordinatorTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.config = tempfile.TemporaryDirectory() + self.hass = HomeAssistant(self.config.name) + frame.async_setup(self.hass) + self.api = SimpleNamespace(window_extended_state=AsyncMock()) + self.coordinator = BleBoxCoordinator(self.hass, self.api) + + async def asyncTearDown(self): + await self.hass.async_stop() + self.config.cleanup() + + async def test_polling_accelerates_while_moving_and_returns_to_idle(self): + self.assertEqual(self.coordinator.update_interval, timedelta(seconds=5)) + for state, seconds in [(1, 1), (0, 1), (2, 5), (3, 5), (4, 5)]: + with self.subTest(state=state): + payload = {'window': {'motors': [{'id': 0, 'state': state}]}} + self.api.window_extended_state.return_value = payload + await self.coordinator.async_refresh() + self.assertEqual(self.coordinator.data, payload) + self.assertEqual(self.coordinator.update_interval, timedelta(seconds=seconds)) + + async def test_one_moving_motor_accelerates_polling(self): + self.api.window_extended_state.return_value = {'window': {'motors': [ + {'id': 0, 'state': 2}, {'id': 1, 'state': 1}, + ]}} + await self.coordinator.async_refresh() + self.assertEqual(self.coordinator.update_interval, timedelta(seconds=1)) + + async def test_transport_failure_is_update_failure(self): + self.api.window_extended_state.side_effect = BleBoxApiError('unreachable') + with self.assertRaises(UpdateFailed): + await self.coordinator._async_update_data() diff --git a/tests/test_cover.py b/tests/test_cover.py index d17b935..7707d26 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -5,7 +5,7 @@ """ import unittest from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from custom_components.blebox_smartwindowbox.cover import BleBoxMotorCover @@ -24,6 +24,8 @@ def setUp(self): self.coordinator = SimpleNamespace( data=self.data, api=self.api, config_entry=None, async_request_refresh=AsyncMock(), + async_refresh=AsyncMock(), + async_set_updated_data=Mock(), ) self.cover = BleBoxMotorCover(self.coordinator, 'entry', 0, None) @@ -87,3 +89,40 @@ async def test_invalid_target_does_not_move(self): for target in (None, 'invalid'): await self.cover.async_set_cover_position(position=target) self.api.send_motor_command.assert_not_awaited() + + async def test_commands_refresh_state_immediately(self): + for method in ( + self.cover.async_open_cover, self.cover.async_close_cover, + self.cover.async_stop_cover, self.cover.async_favorite, + self.cover.async_next_step, + ): + with self.subTest(method=method.__name__): + self.coordinator.async_refresh.reset_mock() + await method() + self.coordinator.async_refresh.assert_awaited_once() + self.coordinator.async_request_refresh.assert_not_awaited() + + async def test_intermediate_motion_publishes_each_position(self): + opening = {'window': {'motors': [ + {'id': 0, 'state': 1, 'currentPos': {'position': 35}} + ]}} + reached = {'window': {'motors': [ + {'id': 0, 'state': 1, 'currentPos': {'position': 30}} + ]}} + self.api.window_extended_state.side_effect = [opening, reached] + with patch('custom_components.blebox_smartwindowbox.cover.asyncio.sleep', new=AsyncMock()): + await self.cover.async_set_cover_position(position=70) + self.assertEqual( + [call.args[0] for call in self.coordinator.async_set_updated_data.call_args_list], + [opening, reached], + ) + self.assertEqual(self.coordinator.async_refresh.await_count, 2) + + def test_movement_state_and_position_agree(self): + for state, raw, opening, closing in [(1, 30, True, False), (0, 70, False, True), (2, 50, False, False)]: + with self.subTest(state=state): + self.motor['state'] = state + self.motor['currentPos']['position'] = raw + self.assertEqual(self.cover.is_opening, opening) + self.assertEqual(self.cover.is_closing, closing) + self.assertEqual(self.cover.current_cover_position, 100 - raw) From b0ea1023fbde794509c5b73d5ee426ce1f828499 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Tue, 13 Oct 2026 15:00:14 +0000 Subject: [PATCH 4/8] Make position control interruptible and handle movement failures --- README.md | 15 ++ .../blebox_smartwindowbox/api.py | 9 +- .../blebox_smartwindowbox/cover.py | 225 +++++++++++------- tests/test_api.py | 51 ++++ tests/test_cover.py | 152 ++++++++++++ 5 files changed, 364 insertions(+), 88 deletions(-) create mode 100644 tests/test_api.py diff --git a/README.md b/README.md index 8d01ffa..d9d7b6f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,17 @@ Fully closed (0%) and fully open (100%) use the native close/open commands. The All exposed position attributes use the Home Assistant scale (0% closed, 100% open). `device_position` retains the raw BleBox scale (0% open, 100% closed). Existing automations using numeric positions must invert their targets and comparisons (`100 - old_position`) when upgrading. +A new cover command cancels any previous intermediate-position controller before +sending the new command. Stop, favorite, next-step, and new position requests all +take precedence over the old target. Cancelling an active position-control +service or unloading the integration also cancels the controller and attempts +to stop the motor. + +If position control loses contact with the device or exceeds its travel timeout, +it attempts a stop and reports an error. A stop cannot be guaranteed while the +device is unreachable; the original error is preserved if the stop attempt also +fails. + --- ### Movement Metadata @@ -73,6 +84,10 @@ While the window is moving (open, close, favorite, or manual movement), the foll These update live during movement. +When the motor changes direction or target, progress starts from the newly +observed position and the elapsed time and direction-specific travel estimate +are reset. + Device state is refreshed every second while a motor is moving and every five seconds while idle. Commands trigger an immediate refresh. Intermediate-position control also publishes each direct device reading to Home Assistant. diff --git a/custom_components/blebox_smartwindowbox/api.py b/custom_components/blebox_smartwindowbox/api.py index 329b945..aa82e67 100644 --- a/custom_components/blebox_smartwindowbox/api.py +++ b/custom_components/blebox_smartwindowbox/api.py @@ -40,8 +40,11 @@ async def _get_json(self, path: str) -> Any: async with self._session.get(url, timeout=self._timeout) as resp: if resp.status != 200: raise BleBoxApiError(f"GET {path} failed: HTTP {resp.status}") - return await resp.json(content_type=None) - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + data = await resp.json(content_type=None) + if not isinstance(data, dict): + raise BleBoxApiError(f"GET {path} failed: expected a JSON object") + return data + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e: raise BleBoxApiError(f"GET {path} failed: {e}") from e async def device_state(self) -> DeviceInfo: @@ -61,4 +64,4 @@ async def window_extended_state(self) -> dict[str, Any]: return await self._get_json("/api/window/extended/state") async def send_motor_command(self, channel: int, command: str) -> dict[str, Any]: - return await self._get_json(f"/s/{channel}/{command}") \ No newline at end of file + return await self._get_json(f"/s/{channel}/{command}") diff --git a/custom_components/blebox_smartwindowbox/cover.py b/custom_components/blebox_smartwindowbox/cover.py index 5c61b8c..964b323 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import logging import time from typing import Any @@ -9,10 +10,12 @@ from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed +from .api import BleBoxApiError from .const import ( DOMAIN, CONF_NAME, @@ -27,6 +30,8 @@ ) from .coordinator import BleBoxCoordinator +_LOGGER = logging.getLogger(__name__) + def _motor_list(data: dict[str, Any]) -> list[dict[str, Any]]: return (data.get("window") or {}).get("motors") or [] @@ -90,6 +95,8 @@ def __init__(self, coordinator: BleBoxCoordinator, entry_id: str, channel: int, self._move_target: int | None = None self._move_direction: str | None = None # "opening" / "closing" self._move_full_time_s: float | None = None + self._command_lock = asyncio.Lock() + self._position_task: asyncio.Task[None] | None = None def _motor(self) -> dict[str, Any] | None: for m in _motor_list(self.coordinator.data): @@ -169,19 +176,19 @@ def _handle_coordinator_update(self) -> None: direction = "opening" if state == 1 else "closing" target = des_i - if not self._tracked_moving: + if ( + not self._tracked_moving + or direction != self._move_direction + or target != self._move_target + ): self._tracked_moving = True self._move_started = time.monotonic() self._move_start_pos = cur_i self._move_direction = direction self._move_target = target self._move_full_time_s = self._full_travel_time_s(motor, direction) - else: - # update target/direction if changed mid-move - self._move_direction = direction - self._move_target = target - if self._move_full_time_s is None: - self._move_full_time_s = self._full_travel_time_s(motor, direction) + elif self._move_full_time_s is None: + self._move_full_time_s = self._full_travel_time_s(motor, direction) else: if self._tracked_moving: @@ -265,44 +272,67 @@ def extra_state_attributes(self) -> dict[str, Any]: "move_progress_pct": move_progress_pct, } + async def _async_cancel_position_task(self, *, stop: bool = False) -> bool: + """Cancel the previous controller while holding the command lock.""" + task = self._position_task + self._position_task = None + if task is None or task.done(): + return False + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if stop: + await self._async_best_effort_stop() + return True + + async def _async_best_effort_stop(self) -> None: + """Try stopping once, keeping the original error if the device is offline.""" + try: + await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) + except BleBoxApiError: + _LOGGER.warning("Could not stop BleBox motor %s", self._channel, exc_info=True) + + async def _async_command(self, command: str) -> None: + """Replace any position controller before sending a new command.""" + async with self._command_lock: + replaced = await self._async_cancel_position_task() + try: + await self.coordinator.api.send_motor_command(self._channel, command) + except BleBoxApiError as err: + self.coordinator.async_set_update_error(UpdateFailed(str(err))) + if replaced: + await self._async_best_effort_stop() + raise HomeAssistantError(f"BleBox command failed: {err}") from err + await self.coordinator.async_refresh() + async def async_open_cover(self, **kwargs: Any) -> None: - await self.coordinator.api.send_motor_command(self._channel, CMD_UP) - await self.coordinator.async_refresh() + await self._async_command(CMD_UP) async def async_close_cover(self, **kwargs: Any) -> None: - await self.coordinator.api.send_motor_command(self._channel, CMD_DOWN) - await self.coordinator.async_refresh() + await self._async_command(CMD_DOWN) async def async_stop_cover(self, **kwargs: Any) -> None: - await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_refresh() + await self._async_command(CMD_STOP) async def async_favorite(self) -> None: - await self.coordinator.api.send_motor_command(self._channel, CMD_FAV) - await self.coordinator.async_refresh() + await self._async_command(CMD_FAV) async def async_next_step(self) -> None: - await self.coordinator.api.send_motor_command(self._channel, CMD_NEXT) - await self.coordinator.async_refresh() + await self._async_command(CMD_NEXT) - async def async_set_cover_position(self, **kwargs: Any) -> None: - """ - Emulate set position by moving and stopping when target reached. - Position scale: 0=closed, 100=open. - Endpoints use native commands so the device can finish its travel. - """ - target = kwargs.get("position") - if target is None: - return + async def async_will_remove_from_hass(self) -> None: + async with self._command_lock: + await self._async_cancel_position_task(stop=True) + await super().async_will_remove_from_hass() + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Use native endpoints and an interruptible controller in between.""" try: - target = int(target) + target = int(kwargs.get("position")) except (TypeError, ValueError): return target = max(0, min(100, target)) - # A reported endpoint may precede the physical end of travel. - # Do not stop the motor or skip the command based on cached position. + # Let the device finish calibrated endpoint travel without early stops. if target == 0: await self.async_close_cover() return @@ -310,69 +340,94 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await self.async_open_cover() return - # Keep the polling loop in the native BleBox coordinate system. target = 100 - target - tol = POSITION_TOLERANCE - - motor = self._motor() - if not motor: - return - - cur = (motor.get("currentPos") or {}).get("position") - try: - cur_i = int(cur) - except (TypeError, ValueError): - cur_i = None - - if cur_i is not None and abs(cur_i - target) <= POSITION_TOLERANCE: - return - - direction = "opening" if (cur_i is None or target < cur_i) else "closing" - command = CMD_UP if direction == "opening" else CMD_DOWN - - # Timeout based on calibration full travel time + buffer - full_time_s = self._full_travel_time_s(motor, direction) - timeout_s = max(5.0, full_time_s + 5.0) - - await self.coordinator.api.send_motor_command(self._channel, command) - await self.coordinator.async_refresh() - - start = time.monotonic() - while True: - if time.monotonic() - start > timeout_s: - await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_refresh() + async with self._command_lock: + replaced = await self._async_cancel_position_task() + motor = self._motor() + if not motor: return + try: + cur_i = int((motor.get("currentPos") or {}).get("position")) + except (TypeError, ValueError): + cur_i = None - await asyncio.sleep(POLL_INTERVAL_SEC) + if cur_i is not None and abs(cur_i - target) <= POSITION_TOLERANCE: + if replaced or motor.get("state") in (0, 1): + await self._async_command_stop_at_target() + return - # Poll direct for freshest position - data = await self.coordinator.api.window_extended_state() - self.coordinator.async_set_updated_data(data) - motors = (data.get("window") or {}).get("motors") or [] - m = next((mm for mm in motors if int(mm.get("id", -1)) == self._channel), None) - if not m: - continue + direction = "opening" if cur_i is None or target < cur_i else "closing" + timeout_s = max(5.0, self._full_travel_time_s(motor, direction) + 5.0) + task = asyncio.create_task( + self._async_move_to_position(target, direction, timeout_s) + ) + self._position_task = task - pos = (m.get("currentPos") or {}).get("position") - try: - pos_i = int(pos) - except (TypeError, ValueError): - continue + try: + # Caller cancellation is handled under the command lock below. + await asyncio.shield(task) + except asyncio.CancelledError: + if asyncio.current_task().cancelling(): + async with self._command_lock: + if self._position_task is task: + await self._async_cancel_position_task(stop=True) + raise + # A newer command superseded this controller successfully. + + async def _async_command_stop_at_target(self) -> None: + try: + await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) + except BleBoxApiError as err: + self.coordinator.async_set_update_error(UpdateFailed(str(err))) + raise HomeAssistantError(f"BleBox stop failed: {err}") from err + await self.coordinator.async_refresh() - if direction == "opening": - # moving downward (toward 0): stop once we've reached/passed target - if pos_i <= target + tol: - await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await asyncio.sleep(EXTRA_STOP_DELAY_SEC) - await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) - await self.coordinator.async_refresh() - return - else: - # closing upward (toward 100): stop once we've reached/passed target - if pos_i >= target - tol: + async def _async_move_to_position( + self, target: int, direction: str, timeout_s: float + ) -> None: + """Publish readings and stop at the target until replaced or cancelled.""" + try: + # Include transport and refresh time in the movement deadline. + start = time.monotonic() + command = CMD_UP if direction == "opening" else CMD_DOWN + await self.coordinator.api.send_motor_command(self._channel, command) + await self.coordinator.async_refresh() + + while True: + if time.monotonic() - start > timeout_s: + raise HomeAssistantError("BleBox position movement timed out") + await asyncio.sleep(POLL_INTERVAL_SEC) + data = await self.coordinator.api.window_extended_state() + self.coordinator.async_set_updated_data(data) + motor = next( + (m for m in _motor_list(data) if int(m.get("id", -1)) == self._channel), + None, + ) + if not motor: + continue + try: + position = int((motor.get("currentPos") or {}).get("position")) + except (TypeError, ValueError): + continue + + reached = ( + position <= target + POSITION_TOLERANCE + if direction == "opening" + else position >= target - POSITION_TOLERANCE + ) + if reached: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await asyncio.sleep(EXTRA_STOP_DELAY_SEC) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await self.coordinator.async_refresh() return + except (BleBoxApiError, HomeAssistantError) as err: + async with self._command_lock: + if self._position_task is asyncio.current_task(): + if isinstance(err, BleBoxApiError): + self.coordinator.async_set_update_error(UpdateFailed(str(err))) + await self._async_best_effort_stop() + raise HomeAssistantError(f"BleBox position control failed: {err}") from err + finally: + if self._position_task is asyncio.current_task(): + self._position_task = None diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3310553 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,51 @@ +"""Exercise real HTTP responses and transport failure handling.""" +import unittest + +import aiohttp +from aiohttp import web + +from custom_components.blebox_smartwindowbox.api import BleBoxApiError, BleBoxSmartWindowBoxApi + + +class ApiTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.response = web.json_response({'window': {'motors': []}}) + async def respond(request): + return self.response + app = web.Application() + app.router.add_get('/api/window/extended/state', respond) + self.runner = web.AppRunner(app) + await self.runner.setup() + site = web.TCPSite(self.runner, '127.0.0.1', 0) + await site.start() + self.port = site._server.sockets[0].getsockname()[1] + self.session = aiohttp.ClientSession() + self.api = BleBoxSmartWindowBoxApi(self.session, f'127.0.0.1:{self.port}') + + async def asyncTearDown(self): + await self.session.close() + await self.runner.cleanup() + + async def test_valid_json_without_content_type(self): + self.response = web.Response(text='{"window": {"motors": []}}') + self.assertEqual(await self.api.window_extended_state(), {'window': {'motors': []}}) + + async def test_invalid_json_is_api_error(self): + self.response = web.Response(text='not JSON') + with self.assertRaises(BleBoxApiError): + await self.api.window_extended_state() + + async def test_non_object_json_is_api_error(self): + self.response = web.json_response([]) + with self.assertRaisesRegex(BleBoxApiError, 'expected a JSON object'): + await self.api.window_extended_state() + + async def test_http_error_is_api_error(self): + self.response = web.Response(status=503) + with self.assertRaisesRegex(BleBoxApiError, 'HTTP 503'): + await self.api.window_extended_state() + + async def test_disconnection_is_api_error(self): + await self.runner.cleanup() + with self.assertRaises(BleBoxApiError): + await self.api.window_extended_state() diff --git a/tests/test_cover.py b/tests/test_cover.py index 7707d26..7cedb89 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -3,10 +3,15 @@ Run with: python -m unittest discover -s tests -v Requires Home Assistant in the active Python environment. """ +import asyncio import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from custom_components.blebox_smartwindowbox.api import BleBoxApiError from custom_components.blebox_smartwindowbox.cover import BleBoxMotorCover @@ -26,6 +31,7 @@ def setUp(self): async_request_refresh=AsyncMock(), async_refresh=AsyncMock(), async_set_updated_data=Mock(), + async_set_update_error=Mock(), ) self.cover = BleBoxMotorCover(self.coordinator, 'entry', 0, None) @@ -126,3 +132,149 @@ def test_movement_state_and_position_agree(self): self.assertEqual(self.cover.is_opening, opening) self.assertEqual(self.cover.is_closing, closing) self.assertEqual(self.cover.current_cover_position, 100 - raw) + + async def _start_blocked_controller(self): + entered = asyncio.Event() + async def read(): + entered.set() + await asyncio.Event().wait() + self.api.window_extended_state.side_effect = read + caller = asyncio.create_task(self.cover.async_set_cover_position(position=70)) + await asyncio.wait_for(entered.wait(), 2) + return caller + + async def test_each_new_command_cancels_old_controller(self): + for method, command in [ + (self.cover.async_open_cover, 'u'), (self.cover.async_close_cover, 'd'), + (self.cover.async_stop_cover, 's'), (self.cover.async_favorite, 'f'), + (self.cover.async_next_step, 'n'), + ]: + with self.subTest(command=command), patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0): + self.api.send_motor_command.reset_mock() + caller = await self._start_blocked_controller() + await method() + await asyncio.wait_for(caller, 2) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', command]) + self.assertIsNone(self.cover._position_task) + + async def test_new_position_replaces_old_controller(self): + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0), patch('custom_components.blebox_smartwindowbox.cover.EXTRA_STOP_DELAY_SEC', 0): + caller = await self._start_blocked_controller() + self.api.window_extended_state.side_effect = None + self.api.window_extended_state.return_value = {'window': {'motors': [ + {'id': 0, 'currentPos': {'position': 80}} + ]}} + await self.cover.async_set_cover_position(position=20) + await asyncio.wait_for(caller, 2) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 'd', 's', 's']) + + async def test_replacement_at_current_position_stops_previous_movement(self): + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0): + caller = await self._start_blocked_controller() + await self.cover.async_set_cover_position(position=60) + await asyncio.wait_for(caller, 2) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's']) + + async def test_new_command_interrupts_extra_stop_delay(self): + delayed = asyncio.Event() + async def sleep(delay): + if delay > 0: + delayed.set() + await asyncio.Event().wait() + self.api.window_extended_state.return_value = {'window': {'motors': [ + {'id': 0, 'currentPos': {'position': 30}} + ]}} + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0), patch('custom_components.blebox_smartwindowbox.cover.asyncio.sleep', sleep): + caller = asyncio.create_task(self.cover.async_set_cover_position(position=70)) + await asyncio.wait_for(delayed.wait(), 2) + await self.cover.async_close_cover() + await asyncio.wait_for(caller, 2) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's', 'd']) + + async def test_caller_cancellation_stops_and_cleans_up(self): + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0): + caller = await self._start_blocked_controller() + caller.cancel() + with self.assertRaises(asyncio.CancelledError): + await caller + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's']) + self.assertIsNone(self.cover._position_task) + + async def test_unload_stops_and_cancels_controller(self): + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0), patch.object(CoordinatorEntity, 'async_will_remove_from_hass', new=AsyncMock()) as remove: + caller = await self._start_blocked_controller() + await self.cover.async_will_remove_from_hass() + await asyncio.wait_for(caller, 2) + remove.assert_awaited_once() + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's']) + + async def test_poll_failure_stops_and_reports_original_error(self): + self.api.window_extended_state.side_effect = BleBoxApiError('poll failed') + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0): + with self.assertRaisesRegex(HomeAssistantError, 'poll failed'): + await self.cover.async_set_cover_position(position=70) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's']) + self.coordinator.async_set_update_error.assert_called_once() + self.assertIsNone(self.cover._position_task) + + async def test_failed_recovery_stop_preserves_original_error(self): + self.api.window_extended_state.side_effect = BleBoxApiError('poll failed') + self.api.send_motor_command.side_effect = [None, BleBoxApiError('stop failed')] + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0), self.assertLogs('custom_components.blebox_smartwindowbox.cover', level='WARNING'): + with self.assertRaisesRegex(HomeAssistantError, 'poll failed'): + await self.cover.async_set_cover_position(position=70) + self.assertIsNone(self.cover._position_task) + + async def test_timeout_stops_and_reports_failure(self): + task = asyncio.create_task(self.cover._async_move_to_position(30, 'opening', 0)) + self.cover._position_task = task + with self.assertRaisesRegex(HomeAssistantError, 'timed out'): + await task + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 's']) + self.assertIsNone(self.cover._position_task) + + async def test_native_command_error_is_reported(self): + self.api.send_motor_command.side_effect = BleBoxApiError('offline') + with self.assertRaisesRegex(HomeAssistantError, 'offline'): + await self.cover.async_close_cover() + self.coordinator.async_set_update_error.assert_called_once() + + async def test_failed_replacement_attempts_stop_of_previous_movement(self): + with patch('custom_components.blebox_smartwindowbox.cover.POLL_INTERVAL_SEC', 0): + caller = await self._start_blocked_controller() + self.api.send_motor_command.side_effect = [BleBoxApiError('replacement failed'), None] + with self.assertRaisesRegex(HomeAssistantError, 'replacement failed'): + await self.cover.async_close_cover() + await asyncio.wait_for(caller, 2) + self.assertEqual([c.args[1] for c in self.api.send_motor_command.await_args_list], ['u', 'd', 's']) + self.assertIsNone(self.cover._position_task) + + def test_reversal_and_retarget_reset_progress_and_travel_time(self): + self.motor['calibrationParameters'] = {'maxMoveTimeUpMs': 20000, 'maxMoveTimeDownMs': 40000} + self.motor.update(state=1, currentPos={'position': 80}, desiredPos={'position': 0}) + with patch.object(self.cover, 'async_write_ha_state'), patch('custom_components.blebox_smartwindowbox.cover.time.monotonic', return_value=100) as clock: + self.cover._handle_coordinator_update() + self.motor['currentPos']['position'] = 60 + clock.return_value = 105 + self.cover._handle_coordinator_update() + self.assertEqual(self.cover.extra_state_attributes['move_progress_pct'], 25) + self.motor.update(state=0, desiredPos={'position': 100}) + clock.return_value = 110 + self.cover._handle_coordinator_update() + attrs = self.cover.extra_state_attributes + self.assertEqual(attrs['move_direction'], 'closing') + self.assertEqual(attrs['move_start_position'], 40) + self.assertEqual(attrs['move_elapsed_s'], 0) + self.assertEqual(attrs['move_progress_pct'], 0) + self.assertEqual(attrs['estimated_remaining_s'], 16) + self.assertEqual(attrs['estimated_total_s'], 40) + self.motor.update(currentPos={'position': 70}, desiredPos={'position': 90}) + clock.return_value = 115 + self.cover._handle_coordinator_update() + self.assertEqual(self.cover.extra_state_attributes['move_start_position'], 30) + self.assertEqual(self.cover.extra_state_attributes['move_target_position'], 10) + self.assertEqual(self.cover.extra_state_attributes['move_progress_pct'], 0) + self.motor['state'] = 2 + self.cover._handle_coordinator_update() + self.assertFalse(self.cover.extra_state_attributes['moving']) + self.assertIsNone(self.cover.extra_state_attributes['move_elapsed_s']) From 69bd707455a793c0a961ea41b8f54eddb2aa0871 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Wed, 14 Oct 2026 06:52:48 +0000 Subject: [PATCH 5/8] Log HomeKit position requests and motor stop reasons --- README.md | 17 ++++++++++++++ .../blebox_smartwindowbox/cover.py | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index d9d7b6f..57573ec 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,23 @@ started outside Apple Home, causing iOS to display the opposite direction even when Home Assistant reports the correct direction. The bridge correction is in Home Assistant; updating this custom integration alone does not fix that issue. +If a movement stops early from Apple Home, enable both loggers temporarily in +Home Assistant **Developer Tools → Actions**: + +```yaml +action: logger.set_level +data: + custom_components.blebox_smartwindowbox: debug + homeassistant.components.homekit: debug +``` + +Reproduce one movement and inspect the logs for the requested Home Assistant +position, native command, and stop reason (target reached, cancellation, +timeout, or connection failure). Include the HomeKit lines immediately before +the stop when reporting the issue. Debug logging does not change motor behavior. +After collecting the logs, run the same action with both levels set to `warning` +or restart Home Assistant to restore the configured logging levels. + --- ### Rain Sensor diff --git a/custom_components/blebox_smartwindowbox/cover.py b/custom_components/blebox_smartwindowbox/cover.py index 964b323..38d3d77 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -278,6 +278,10 @@ async def _async_cancel_position_task(self, *, stop: bool = False) -> bool: self._position_task = None if task is None or task.done(): return False + _LOGGER.debug( + "Motor %s: cancelling previous position controller (stop=%s)", + self._channel, stop, + ) task.cancel() await asyncio.gather(task, return_exceptions=True) if stop: @@ -286,6 +290,7 @@ async def _async_cancel_position_task(self, *, stop: bool = False) -> bool: async def _async_best_effort_stop(self) -> None: """Try stopping once, keeping the original error if the device is offline.""" + _LOGGER.debug("Motor %s: recovery/cancellation stop", self._channel) try: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) except BleBoxApiError: @@ -295,6 +300,7 @@ async def _async_command(self, command: str) -> None: """Replace any position controller before sending a new command.""" async with self._command_lock: replaced = await self._async_cancel_position_task() + _LOGGER.debug("Motor %s: native command %s", self._channel, command) try: await self.coordinator.api.send_motor_command(self._channel, command) except BleBoxApiError as err: @@ -320,6 +326,7 @@ async def async_next_step(self) -> None: await self._async_command(CMD_NEXT) async def async_will_remove_from_hass(self) -> None: + _LOGGER.debug("Motor %s: removing entity", self._channel) async with self._command_lock: await self._async_cancel_position_task(stop=True) await super().async_will_remove_from_hass() @@ -331,6 +338,11 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: except (TypeError, ValueError): return target = max(0, min(100, target)) + _LOGGER.debug( + "Motor %s: requested HA position %s (current=%s, motor_state=%s)", + self._channel, target, self.current_cover_position, + (self._motor() or {}).get("state"), + ) # Let the device finish calibrated endpoint travel without early stops. if target == 0: @@ -368,6 +380,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await asyncio.shield(task) except asyncio.CancelledError: if asyncio.current_task().cancelling(): + _LOGGER.debug("Motor %s: position service cancelled", self._channel) async with self._command_lock: if self._position_task is task: await self._async_cancel_position_task(stop=True) @@ -375,6 +388,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: # A newer command superseded this controller successfully. async def _async_command_stop_at_target(self) -> None: + _LOGGER.debug("Motor %s: already at requested position; stopping", self._channel) try: await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) except BleBoxApiError as err: @@ -390,6 +404,10 @@ async def _async_move_to_position( # Include transport and refresh time in the movement deadline. start = time.monotonic() command = CMD_UP if direction == "opening" else CMD_DOWN + _LOGGER.debug( + "Motor %s: position controller %s to HA position %s (timeout=%ss)", + self._channel, direction, 100 - target, timeout_s, + ) await self.coordinator.api.send_motor_command(self._channel, command) await self.coordinator.async_refresh() @@ -416,12 +434,17 @@ async def _async_move_to_position( else position >= target - POSITION_TOLERANCE ) if reached: + _LOGGER.debug( + "Motor %s: reached HA position %s (target=%s); stopping", + self._channel, 100 - position, 100 - target, + ) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await asyncio.sleep(EXTRA_STOP_DELAY_SEC) await self.coordinator.api.send_motor_command(self._channel, CMD_STOP) await self.coordinator.async_refresh() return except (BleBoxApiError, HomeAssistantError) as err: + _LOGGER.debug("Motor %s: position controller failed: %s", self._channel, err) async with self._command_lock: if self._position_task is asyncio.current_task(): if isinstance(err, BleBoxApiError): From 79661cca5adf25fb908f9e871f46790570cb73e1 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Wed, 14 Oct 2026 07:36:08 +0000 Subject: [PATCH 6/8] Update installation and development documentation for this fork --- README.md | 102 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 57573ec..b01b67f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,6 @@ -## Installation (HACS) - -1. Open **HACS** in Home Assistant. -2. Go to **Integrations**. -3. Click the **⋮** (top right) → **Custom repositories**. -4. Add this repository URL: - - `https://github.com/Sjnaaf/bleBoxSmartWindowBox` - -5. Choose **Category: Integration**. -6. Click **Add**. -7. Search for **BleBox smartWindowBox** in HACS and install it. -8. Restart Home Assistant. -9. Add the integration via: **Settings → Devices & Services → Add integration**. - ---- ---- - # BleBox smartWindowBox – Home Assistant Integration -A fully featured **custom Home Assistant integration** for the **BleBox smartWindowBox** ([API level 20180604](https://technical.blebox.eu/openAPI_smartWindowBox/openAPI_smartWindowBox_20180604.html)). +A **custom Home Assistant integration** for the **BleBox smartWindowBox** ([API level 20180604](https://technical.blebox.eu/openAPI_smartWindowBox/openAPI_smartWindowBox_20180604.html)). This integration provides: @@ -31,6 +13,25 @@ This integration provides: * ✅ Progress %, remaining time & direction tracking * ✅ Proper Home Assistant entity services with UI selectors +This fork includes position-scale fixes, native endpoint movement, faster status +updates, interruptible position control, and improved error handling. The original +project is [Sjnaaf/bleBoxSmartWindowBox](https://github.com/Sjnaaf/bleBoxSmartWindowBox). + +## Installation (HACS) + +1. Open **HACS** in Home Assistant. +2. Go to **Integrations**. +3. Click the **⋮** (top right) → **Custom repositories**. +4. Add this repository URL: + + `https://github.com/pepijnnichting/bleBoxSmartWindowBox` + +5. Choose **Category: Integration**. +6. Click **Add**. +7. Search for **BleBox smartWindowBox** in HACS and install it. +8. Restart Home Assistant. +9. Add the integration via: **Settings → Devices & Services → Add integration**. + --- ## Features @@ -149,13 +150,13 @@ The integration registers proper entity services with UI selectors: | `blebox_smartwindowbox.next_step` | Perform next-step movement | | `blebox_smartwindowbox.set_position` | Move to specific position (0–100) | -These appear in **Developer Tools → Services**. +These appear in **Developer Tools → Actions**. --- # Installation -## Method 1 — Manual Installation (Recommended) +## Manual Installation 1. Download or clone this repository. 2. Copy the folder: @@ -179,6 +180,8 @@ config/ ├── __init__.py ├── api.py ├── binary_sensor.py + ├── brand/ + │ └── icon.png ├── config_flow.py ├── const.py ├── coordinator.py @@ -194,6 +197,17 @@ config/ * **Settings → System → Restart** +## Updating + +Download the latest integration files through HACS, or replace the complete +`custom_components/blebox_smartwindowbox/` folder for a manual installation. Restart +Home Assistant afterward. Existing integration entries can remain configured. + +If upgrading from the original position scale, review automations and scripts +with numeric position targets: Home Assistant now uses **0% closed, 100% open**. +Invert old values with `100 - old_position`. Native open, close, stop, and favorite +actions keep their meaning. + --- # Setup in Home Assistant @@ -249,7 +263,7 @@ Position scale: Go to: ``` -Developer Tools → Services +Developer Tools → Actions ``` Select: @@ -282,7 +296,7 @@ The integration will: * Move in correct direction * Track progress -* Stop exactly at the target +* Stop when the reported position reaches the target within the configured tolerance --- @@ -306,22 +320,28 @@ These update live while the window moves. # Tuning & Advanced Settings -Movement precision is controlled by: +The defaults are defined in `custom_components/blebox_smartwindowbox/const.py`: ```python POSITION_TOLERANCE = 1 -POLL_INTERVAL_SEC = 0.20 +POLL_INTERVAL_SEC = 0.35 +EXTRA_STOP_DELAY_SEC = 0.15 +MOVING_UPDATE_INTERVAL_SECONDS = 1 +UPDATE_INTERVAL_SECONDS = 5 ``` -Lower polling interval increases precision but increases API traffic. - -Default polling interval while moving: +Intermediate-position control polls every 0.35 seconds and uses a tolerance of +one percentage point. After reaching the target, a second stop is sent after +0.15 seconds to reduce coasting. A newer command cancels the old controller, +including a pending second stop. -```python -POLL_INTERVAL_SEC = 0.20 -``` +Regular status polling runs every second during movement and every five seconds +while idle. Fully open and fully closed requests use native commands and do not +use the intermediate-position stop loop. -This value has been tuned for reliable real-world accuracy. +These are source-code constants, not options in the integration UI. Lower polling +intervals increase API traffic. Physical accuracy depends on calibration, response +time, and motor coasting. --- @@ -341,13 +361,15 @@ This value has been tuned for reliable real-world accuracy. * BleBox smartWindowBox * API level 20180604 -* Single motor configuration (current version) +* Validated with a single-motor configuration +* Development checks passed on Home Assistant 2026.9.3 and 2026.10.1 +* Home Assistant 2026.10 or newer recommended for HomeKit Bridge --- # Known Limitations -* Position control is emulated (not native in API) +* Intermediate-position control is emulated; endpoint movement uses native commands * Accuracy depends on motor calibration * Rapid repeated commands may slightly affect precision @@ -355,22 +377,24 @@ This value has been tuned for reliable real-world accuracy. # Development checks -With Home Assistant installed in the active Python environment, run the cover +With Home Assistant installed in the active Python environment, run the regression tests from the repository root: ```sh python -m unittest discover -s tests -v ``` -These cover the Home Assistant position scale, intermediate movements, and native -endpoint commands. They use simulated responses; physical travel still needs to -be verified on a real device. The GitHub workflow also runs Hassfest validation. +These cover position conversion, native endpoints, intermediate movements, +command replacement and cancellation, network errors, timeouts, polling intervals, +and progress resets after a direction or target change. API tests use a local HTTP +server; other device responses are simulated. Physical travel still needs to be +verified on a real device. The GitHub workflow runs Hassfest validation; the Python +regression tests currently run separately. --- # Future Improvements (Ideas) -* HACS support * Multi-motor support * Dedicated progress sensor entity * Configurable precision via UI From edc3d68d8471c5a87c812a5db85328956d8d3c34 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Wed, 14 Oct 2026 07:45:21 +0000 Subject: [PATCH 7/8] Group BleBox entities under one device and enable regression CI --- .github/workflows/tests.yml | 29 +++++++ README.md | 26 +++++- .../blebox_smartwindowbox/binary_sensor.py | 3 +- .../blebox_smartwindowbox/coordinator.py | 22 ++++- .../blebox_smartwindowbox/cover.py | 4 +- .../blebox_smartwindowbox/manifest.json | 2 +- tests/test_cover.py | 2 +- tests/test_device.py | 68 +++++++++++++++ tests/test_integration.py | 84 +++++++++++++++++++ 9 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/test_device.py create mode 100644 tests/test_integration.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..28ac74c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Regression tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: Home Assistant ${{ matrix.homeassistant }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + homeassistant: ["2026.9.3", "2026.10.1"] + env: + UV_CACHE_DIR: ${{ runner.temp }}/uv-cache + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Install Home Assistant + run: python -m pip install "homeassistant==${{ matrix.homeassistant }}" + - name: Run regression tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index b01b67f..d3b6500 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This integration provides: * ✅ Favorite position support * ✅ Next step support * ✅ Rain sensor as binary sensor +* ✅ Cover and rain sensor grouped under one BleBox device * ✅ Real-time movement metadata * ✅ Progress %, remaining time & direction tracking * ✅ Proper Home Assistant entity services with UI selectors @@ -36,6 +37,17 @@ project is [Sjnaaf/bleBoxSmartWindowBox](https://github.com/Sjnaaf/bleBoxSmartWi ## Features +### Device information + +The cover and rain sensor belong to one BleBox device in **Settings → Devices & +Services**. Its identity is based on the device ID returned by the BleBox API. +The device page shows the manufacturer, model, available hardware and firmware +versions, and a link to the device's local web interface. The cover uses Home +Assistant's `window` device class. + +Existing entity unique IDs are preserved, so the update does not require recreating +automations or removing the configured integration. + ### Cover Entity The smartWindowBox motor is exposed as a standard Home Assistant **cover entity**: @@ -388,8 +400,18 @@ These cover position conversion, native endpoints, intermediate movements, command replacement and cancellation, network errors, timeouts, polling intervals, and progress resets after a direction or target change. API tests use a local HTTP server; other device responses are simulated. Physical travel still needs to be -verified on a real device. The GitHub workflow runs Hassfest validation; the Python -regression tests currently run separately. +verified on a real device. GitHub Actions runs these tests on Home Assistant +2026.9.3 and 2026.10.1 with Python 3.14 for pushes and pull requests, alongside +Hassfest validation. The suite also checks actual Home Assistant device/entity +registration and identity preservation after reloading the integration. + +## Version 0.1.0 + +This version includes the corrected Home Assistant position scale, native endpoint +commands, faster status updates, interruptible position control, improved error +handling, movement-progress resets, shared device metadata, and the `window` +device class. Upgrade instructions and the position-scale migration are described +above. --- diff --git a/custom_components/blebox_smartwindowbox/binary_sensor.py b/custom_components/blebox_smartwindowbox/binary_sensor.py index 4c7fcd4..56b1dbf 100644 --- a/custom_components/blebox_smartwindowbox/binary_sensor.py +++ b/custom_components/blebox_smartwindowbox/binary_sensor.py @@ -38,6 +38,7 @@ class BleBoxRainBinarySensor(CoordinatorEntity[BleBoxCoordinator], BinarySensorE def __init__(self, coordinator: BleBoxCoordinator, entry_id: str, sensor_id: int) -> None: super().__init__(coordinator) + self._attr_device_info = coordinator.device_info self._entry_id = entry_id self._sensor_id = sensor_id self._attr_unique_id = f"{entry_id}_rain_{sensor_id}" @@ -65,4 +66,4 @@ def extra_state_attributes(self) -> dict[str, Any]: "trend": s.get("trend"), "elapsedTimeS": s.get("elapsedTimeS"), "iconSet": s.get("iconSet"), - } \ No newline at end of file + } diff --git a/custom_components/blebox_smartwindowbox/coordinator.py b/custom_components/blebox_smartwindowbox/coordinator.py index cbc3258..829709e 100644 --- a/custom_components/blebox_smartwindowbox/coordinator.py +++ b/custom_components/blebox_smartwindowbox/coordinator.py @@ -4,10 +4,11 @@ from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import BleBoxSmartWindowBoxApi, BleBoxApiError -from .const import DOMAIN, MOVING_UPDATE_INTERVAL_SECONDS, UPDATE_INTERVAL_SECONDS +from .const import DEFAULT_NAME, DOMAIN, MOVING_UPDATE_INTERVAL_SECONDS, UPDATE_INTERVAL_SECONDS class BleBoxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -19,6 +20,25 @@ def __init__(self, hass: HomeAssistant, api: BleBoxSmartWindowBoxApi) -> None: update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), ) self.api = api + self.device_info: DeviceInfo | None = None + + async def _async_setup(self) -> None: + """Fetch stable device metadata once when setting up the integration.""" + try: + info = await self.api.device_state() + except BleBoxApiError as err: + raise UpdateFailed(str(err)) from err + if not info.device_id: + raise UpdateFailed("BleBox returned no device id") + self.device_info = DeviceInfo( + identifiers={(DOMAIN, info.device_id)}, + name=info.device_name or DEFAULT_NAME, + manufacturer="BleBox", + model=info.device_type or "smartWindowBox", + hw_version=info.hw, + sw_version=info.fw, + configuration_url=self.api.base_url, + ) async def _async_update_data(self) -> dict[str, Any]: try: diff --git a/custom_components/blebox_smartwindowbox/cover.py b/custom_components/blebox_smartwindowbox/cover.py index 38d3d77..664c38c 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -7,7 +7,7 @@ import voluptuous as vol -from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -73,9 +73,11 @@ async def async_setup_entry( class BleBoxMotorCover(CoordinatorEntity[BleBoxCoordinator], CoverEntity): _attr_has_entity_name = True + _attr_device_class = CoverDeviceClass.WINDOW def __init__(self, coordinator: BleBoxCoordinator, entry_id: str, channel: int, name_prefix: str | None) -> None: super().__init__(coordinator) + self._attr_device_info = coordinator.device_info self._entry_id = entry_id self._channel = channel self._name_prefix = name_prefix diff --git a/custom_components/blebox_smartwindowbox/manifest.json b/custom_components/blebox_smartwindowbox/manifest.json index 69a38d3..22fb7c7 100644 --- a/custom_components/blebox_smartwindowbox/manifest.json +++ b/custom_components/blebox_smartwindowbox/manifest.json @@ -9,5 +9,5 @@ "integration_type": "device", "iot_class": "local_polling", "requirements": [], - "version": "0.0.1" + "version": "0.1.0" } diff --git a/tests/test_cover.py b/tests/test_cover.py index 7cedb89..f03add3 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -27,7 +27,7 @@ def setUp(self): window_extended_state=AsyncMock(return_value=self.data), ) self.coordinator = SimpleNamespace( - data=self.data, api=self.api, config_entry=None, + data=self.data, api=self.api, config_entry=None, device_info=None, async_request_refresh=AsyncMock(), async_refresh=AsyncMock(), async_set_updated_data=Mock(), diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..42067eb --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,68 @@ +"""Check shared device metadata and setup failures.""" +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.blebox_smartwindowbox.api import BleBoxApiError, DeviceInfo +from custom_components.blebox_smartwindowbox.binary_sensor import BleBoxRainBinarySensor +from custom_components.blebox_smartwindowbox.coordinator import BleBoxCoordinator +from custom_components.blebox_smartwindowbox.cover import BleBoxMotorCover + + +class DeviceTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.config = tempfile.TemporaryDirectory() + self.hass = HomeAssistant(self.config.name) + frame.async_setup(self.hass) + self.info = DeviceInfo('Bedroom window', 'smartWindowBox', '20180604', '1.0', '2.0', 'device-123', None) + self.api = SimpleNamespace( + device_state=AsyncMock(return_value=self.info), + base_url='http://blebox.local', + ) + self.coordinator = BleBoxCoordinator(self.hass, self.api) + + async def asyncTearDown(self): + await self.hass.async_stop() + self.config.cleanup() + + async def test_entities_share_stable_device_identifier_and_metadata(self): + await self.coordinator._async_setup() + cover = BleBoxMotorCover(self.coordinator, 'existing-entry', 0, None) + sensor = BleBoxRainBinarySensor(self.coordinator, 'existing-entry', 0) + self.assertEqual(cover.device_class, 'window') + self.assertEqual(sensor.device_class, 'moisture') + self.assertEqual(cover.device_info, sensor.device_info) + self.assertEqual(cover.device_info['identifiers'], {('blebox_smartwindowbox', 'device-123')}) + self.assertEqual(cover.device_info['manufacturer'], 'BleBox') + self.assertEqual(cover.device_info['name'], 'Bedroom window') + self.assertEqual(cover.device_info['model'], 'smartWindowBox') + self.assertEqual(cover.device_info['hw_version'], '1.0') + self.assertEqual(cover.device_info['sw_version'], '2.0') + self.assertEqual(cover.device_info['configuration_url'], 'http://blebox.local') + # Preserve entity identifiers already stored by existing installations. + self.assertEqual(cover.unique_id, 'existing-entry_motor_0') + self.assertEqual(sensor.unique_id, 'existing-entry_rain_0') + + async def test_metadata_uses_defaults_when_optional_fields_are_missing(self): + self.info.device_name = '' + self.info.device_type = '' + self.info.hw = None + self.info.fw = None + await self.coordinator._async_setup() + self.assertEqual(self.coordinator.device_info['name'], 'BleBox smartWindowBox') + self.assertEqual(self.coordinator.device_info['model'], 'smartWindowBox') + + async def test_missing_device_identifier_fails_setup(self): + self.info.device_id = '' + with self.assertRaisesRegex(UpdateFailed, 'no device id'): + await self.coordinator._async_setup() + + async def test_metadata_connection_failure_is_retriable_update_failure(self): + self.api.device_state.side_effect = BleBoxApiError('offline') + with self.assertRaisesRegex(UpdateFailed, 'offline'): + await self.coordinator._async_setup() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..8e2f7dc --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,84 @@ +"""Verify actual Home Assistant device/entity registration against a local API.""" +import tempfile +import unittest +from pathlib import Path + +from aiohttp import web +from homeassistant import bootstrap, loader +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry, entity_registry + +DOMAIN = 'blebox_smartwindowbox' + + +class IntegrationTests(unittest.IsolatedAsyncioTestCase): + async def test_cover_and_rain_sensor_registered_under_one_device(self): + async def device(request): + return web.json_response({'device': { + 'deviceName': 'Test window', 'type': 'smartWindowBox', + 'apiLevel': '20180604', 'id': 'test-device', 'hv': '1.0', 'fv': '2.0', + }}) + async def state(request): + return web.json_response({'window': { + 'motors': [{'id': 0, 'name': 'Motor 1', 'state': 2, 'currentPos': {'position': 40}}], + 'sensors': [{'id': 0, 'type': 'rain', 'value': 1}], + }}) + app = web.Application() + app.router.add_get('/api/device/state', device) + app.router.add_get('/api/window/extended/state', state) + runner = web.AppRunner(app) + await runner.setup() + try: + site = web.TCPSite(runner, '127.0.0.1', 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + with tempfile.TemporaryDirectory() as config: + components = Path(config) / 'custom_components' + components.mkdir() + source = Path(__file__).resolve().parents[1] / 'custom_components' / DOMAIN + (components / DOMAIN).symlink_to(source) + hass = HomeAssistant(config) + loader.async_setup(hass) + try: + self.assertIsNotNone(await bootstrap.async_from_config_dict({ + 'homeassistant': {'name': 'Device test', 'latitude': 52.0, + 'longitude': 5.0, 'elevation': 0, + 'time_zone': 'Europe/Amsterdam', 'unit_system': 'metric'}, + }, hass)) + await hass.async_start() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={'source': 'user'}, data={'host': f'127.0.0.1:{port}'}, + ) + self.assertEqual(result['type'], 'create_entry', result) + entry = result['result'] + await hass.async_block_till_done() + self.assertEqual(entry.state.value, 'loaded') + entities = entity_registry.async_entries_for_config_entry( + entity_registry.async_get(hass), entry.entry_id, + ) + self.assertEqual(len(entities), 2) + self.assertEqual({e.unique_id for e in entities}, { + f'{entry.entry_id}_motor_0', f'{entry.entry_id}_rain_0', + }) + devices = {e.device_id for e in entities} + self.assertEqual(len(devices), 1) + self.assertNotIn(None, devices) + device_id = devices.pop() + registered_device = device_registry.async_get(hass).async_get(device_id) + self.assertEqual(registered_device.identifiers, {(DOMAIN, 'test-device')}) + self.assertEqual(registered_device.manufacturer, 'BleBox') + self.assertEqual(registered_device.model, 'smartWindowBox') + self.assertEqual(registered_device.sw_version, '2.0') + cover = next(e for e in entities if e.entity_id.startswith('cover.')) + self.assertEqual(hass.states.get(cover.entity_id).attributes['device_class'], 'window') + self.assertEqual(hass.states.get(cover.entity_id).attributes['current_position'], 60) + before = {e.unique_id: e.entity_id for e in entities} + self.assertTrue(await hass.config_entries.async_reload(entry.entry_id)) + await hass.async_block_till_done() + after = entity_registry.async_entries_for_config_entry(entity_registry.async_get(hass), entry.entry_id) + self.assertEqual({e.unique_id: e.entity_id for e in after}, before) + self.assertEqual({e.device_id for e in after}, {device_id}) + finally: + await hass.async_stop() + finally: + await runner.cleanup() From d4bb1c4ed577da65b0a0fd2c77e43f4560e917c8 Mon Sep 17 00:00:00 2001 From: Pepijn Nichting Date: Wed, 14 Oct 2026 07:46:20 +0000 Subject: [PATCH 8/8] Set regression test cache in the step context --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28ac74c..43124fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,6 @@ jobs: fail-fast: false matrix: homeassistant: ["2026.9.3", "2026.10.1"] - env: - UV_CACHE_DIR: ${{ runner.temp }}/uv-cache steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v6 @@ -26,4 +24,6 @@ jobs: - name: Install Home Assistant run: python -m pip install "homeassistant==${{ matrix.homeassistant }}" - name: Run regression tests + env: + UV_CACHE_DIR: ${{ runner.temp }}/uv-cache run: python -m unittest discover -s tests -v