diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..43124fd --- /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"] + 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 + env: + UV_CACHE_DIR: ${{ runner.temp }}/uv-cache + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 46c9012..d3b6500 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,23 @@ +# BleBox smartWindowBox – Home Assistant Integration + +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: + +* ✅ Cover entity (open / close / stop) +* ✅ Emulated **set position (0–100%)** +* ✅ 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 + +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. @@ -5,7 +25,7 @@ 3. Click the **⋮** (top right) → **Custom repositories**. 4. Add this repository URL: - `https://github.com/Sjnaaf/bleBoxSmartWindowBox` + `https://github.com/pepijnnichting/bleBoxSmartWindowBox` 5. Choose **Category: Integration**. 6. Click **Add**. @@ -13,27 +33,20 @@ 8. Restart Home Assistant. 9. Add the integration via: **Settings → Devices & Services → Add integration**. ---- --- -# BleBox smartWindowBox – Home Assistant Integration +## Features -A fully featured **custom Home Assistant integration** for the **BleBox smartWindowBox** ([API level 20180604](https://technical.blebox.eu/openAPI_smartWindowBox/openAPI_smartWindowBox_20180604.html)). +### Device information -This integration provides: - -* ✅ Cover entity (open / close / stop) -* ✅ Emulated **set position (0–100%)** -* ✅ Favorite position support -* ✅ Next step support -* ✅ Rain sensor as binary sensor -* ✅ Real-time movement metadata -* ✅ Progress %, remaining time & direction tracking -* ✅ Proper Home Assistant entity services with UI selectors +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. ---- - -## Features +Existing entity unique IDs are preserved, so the update does not require recreating +automations or removing the configured integration. ### Cover Entity @@ -42,15 +55,28 @@ 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. + +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. --- @@ -71,6 +97,39 @@ 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. + +### 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. + +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 @@ -103,13 +162,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: @@ -133,6 +192,8 @@ config/ ├── __init__.py ├── api.py ├── binary_sensor.py + ├── brand/ + │ └── icon.png ├── config_flow.py ├── const.py ├── coordinator.py @@ -148,6 +209,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 @@ -192,8 +264,8 @@ You can: Position scale: ``` -0 = Fully Open -100 = Fully Closed +0 = Fully Closed +100 = Fully Open ``` --- @@ -203,7 +275,7 @@ Position scale: Go to: ``` -Developer Tools → Services +Developer Tools → Actions ``` Select: @@ -236,7 +308,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 --- @@ -260,22 +332,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. +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. -Default polling interval while moving: +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. -```python -POLL_INTERVAL_SEC = 0.20 -``` - -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. --- @@ -295,21 +373,50 @@ 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 --- +# Development checks + +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 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. 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. + +--- + # Future Improvements (Ideas) -* HACS support * Multi-motor support * Dedicated progress sensor entity * Configurable precision via UI @@ -319,4 +426,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/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/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/brand/icon.png b/custom_components/blebox_smartwindowbox/brand/icon.png new file mode 100644 index 0000000..a425a6d Binary files /dev/null and b/custom_components/blebox_smartwindowbox/brand/icon.png differ 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..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, UPDATE_INTERVAL_SECONDS +from .const import DEFAULT_NAME, DOMAIN, MOVING_UPDATE_INTERVAL_SECONDS, UPDATE_INTERVAL_SECONDS class BleBoxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -19,9 +20,35 @@ 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: - 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 9a22c9b..664c38c 100644 --- a/custom_components/blebox_smartwindowbox/cover.py +++ b/custom_components/blebox_smartwindowbox/cover.py @@ -1,18 +1,21 @@ from __future__ import annotations import asyncio +import logging import time from typing import Any 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 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,11 +30,21 @@ ) 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 [] +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: @@ -60,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 @@ -82,6 +97,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): @@ -97,18 +114,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 +144,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 {} @@ -165,19 +178,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: @@ -240,9 +253,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,114 +266,193 @@ 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, "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 + _LOGGER.debug( + "Motor %s: cancelling previous position controller (stop=%s)", + self._channel, stop, + ) + 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.""" + _LOGGER.debug("Motor %s: recovery/cancellation stop", self._channel) + 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() + _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: + 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_request_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_request_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_request_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_request_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_request_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=open, 100=closed - """ - target = kwargs.get("position") - if target is None: - return + 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() + 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)) + _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"), + ) - # Special-case endpoints for better accuracy - if target == 100: - tol = 0 - elif target == 0: - tol = 0 - else: - tol = POSITION_TOLERANCE - - motor = self._motor() - if not motor: + # Let the device finish calibrated endpoint travel without early stops. + if target == 0: + await self.async_close_cover() 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: + if target == 100: + await self.async_open_cover() 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) - - 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() + target = 100 - target + async with self._command_lock: + replaced = await self._async_cancel_position_task() + motor = self._motor() + if not motor: return - - await asyncio.sleep(POLL_INTERVAL_SEC) - - # Poll direct for freshest position - data = await self.coordinator.api.window_extended_state() - 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 - - pos = (m.get("currentPos") or {}).get("position") try: - pos_i = int(pos) + cur_i = int((motor.get("currentPos") or {}).get("position")) except (TypeError, ValueError): - continue + cur_i = None + + 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 + + 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 - if direction == "opening": - # moving downward (toward 0): stop once we've reached/passed target - if pos_i <= target + tol: + try: + # Caller cancellation is handled under the command lock below. + 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) + raise + # 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: + self.coordinator.async_set_update_error(UpdateFailed(str(err))) + raise HomeAssistantError(f"BleBox stop failed: {err}") from err + await self.coordinator.async_refresh() + + 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 + _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() + + 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: + _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_request_refresh() + await self.coordinator.async_refresh() return - else: - # closing upward (toward 100): 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_request_refresh() - return \ No newline at end of file + 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): + 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/custom_components/blebox_smartwindowbox/manifest.json b/custom_components/blebox_smartwindowbox/manifest.json index e9b58d8..22fb7c7 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.1.0" +} 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_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 new file mode 100644 index 0000000..f03add3 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,280 @@ +"""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 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 + + +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, device_info=None, + 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) + + 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() + + 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) + + 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']) 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()