diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7aa4961 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,57 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.13"] + ha-version: ["stable", "dev"] + continue-on-error: ${{ matrix.ha-version == 'dev' }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ "${{ matrix.ha-version }}" = "dev" ]; then + python -m pip install pytest pytest-asyncio + python -m pip install --pre pytest-homeassistant-custom-component + else + python -m pip install homeassistant + python -m pip install pytest pytest-asyncio pytest-homeassistant-custom-component + fi + + - name: Run tests + run: pytest tests/ -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install ruff + run: python -m pip install ruff + + - name: Check formatting + run: ruff format --check . + + - name: Check linting + run: ruff check . diff --git a/.gitignore b/.gitignore index 0e008a5..0a5fc39 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ __pycache__/ -*.iml \ No newline at end of file +*.iml +.coverage +docs/plans/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f95a53..4d78fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## Unreleased + +### Features + +- **UI-first configuration:** Config flow creates covers via UI; all settings managed through a Lovelace card and WebSocket API +- **Control mode:** Single `control_mode` setting replaces separate `device_type`/`input_mode` — choose from wrapped, switch, pulse, or toggle +- **External state monitoring:** Detects physical switch presses and keeps the position tracker in sync with actual motor state. Supports all control modes for both cover and tilt switches +- **Separate tilt motor (dual_motor) mode:** Support for covers with a dedicated tilt motor, with configurable safe tilt position and max tilt allowed position +- **Inline tilt mode:** Support for covers where the tilt mechanism is part of the main travel range (e.g. Venetian blinds), with configurable tilt range within the overall travel +- **Calibration system:** Measure timing parameters interactively — start calibration, let the motor run, stop when done. Supports all travel and tilt timing attributes with overhead compensation +- **Background pulse completion:** Pulse and toggle mode commands return immediately while the relay pulse completes in the background +- **Tilt switch monitoring:** Monitors tilt switch entities for external changes in all modes (pulse, switch, toggle) +- **Lovelace configuration card:** Full settings UI with entity pickers, timing fields, tilt strategy selection, and calibration controls +- **Toggle mode improvements:** Debounce for momentary switches, cross-direction external toggles treated as stop, HA UI direction changes still reverse + +### Improvements + +- Replaced external `xknx` TravelCalculator dependency with a local HA-convention copy (no external dependencies) +- Card translations embedded directly in JS with `_t()` helper and `hass.language` locale lookup (English, Portuguese, Polish) +- Refactored codebase: extracted calibration mixin, tilt strategies package, shared constants, entity resolution helpers +- Updated calibration hint descriptions for all tilt modes (inline, sequential, dual_motor) +- Frontend defaults safe_tilt_position=100 and max_tilt_allowed_position=0 for new dual_motor configs +- Cleaned up diagnostic logging in toggle mode +- Improved test coverage from 66% to 97% (528 tests) + +### Bug Fixes + +- Fixed calibration direction override for tilt attributes — server now derives direction from attribute name instead of card sending position-based guess +- Fixed calibration overhead calculation for tilt (3 steps vs 8 travel steps) +- Fixed toggle mode tilt restore clearing `_last_command` prematurely, breaking stop pulse +- Fixed dual_motor tilt commands using wrong service calls +- Fixed `safe_tilt_position=0` being coerced to default 100 +- Fixed zero travel/tilt times accepted by WebSocket API schema +- Fixed `_last_command` not set during toggle mode calibration +- Fixed pulse mode stop switch not stopping the position tracker +- Fixed toggle mode external state handler only reacting to ON→OFF (now handles both transitions) +- Fixed switch mode tilt handler using pulse-mode behavior (now uses latching: ON=start, OFF=stop) +- Fixed tilt switch echo filtering (pending=2 for direction switches to handle ON+OFF transitions) +- Fixed wrapped cover handler not tracking direction changes (opening→closing) + ## 3.0.0 (2025-12-10) ### Features diff --git a/README.md b/README.md index b5bae9a..abe8ea8 100644 --- a/README.md +++ b/README.md @@ -11,27 +11,26 @@ A Home Assistant integration to control your cover based on time. This integration is based on [davidramosweb/home-assistant-custom-components-cover-time-based](https://github.com/davidramosweb/home-assistant-custom-components-cover-time-based/). -It improves the original integration by adding tilt control and synchronized travel/tilt movements. +It improves the original integration by adding tilt control, synchronized travel/tilt movements, and a visual configuration card. ### Features: -- **Control the height of your cover based on time**. -- **Control the tilt of your cover based on time**. -- **Synchronized movement:** Travel and tilt move proportionally on the same motor. -- **Optional endpoint delay:** Configurable relay delay at endpoints for covers with mechanical endstops. -- **Minimum movement time:** Prevents position drift from very short relay activations. -- **Motor startup compensation:** Optional delay compensation for motor inertia to improve position accuracy. - -_To enable tilt control you need to add the `tilting_time_down` and `tilting_time_up` options to your configuration.yaml._ +- **Control the position of your cover based on time**. +- **External state monitoring:** Detects physical switch presses and keeps the position tracker in sync. +- **Multiple input modes:** Latching switches, momentary pulse buttons, or toggle-style relays. +- **Wrap an existing cover:** Add time-based position tracking to any cover entity. +- **Control the tilt of your cover based on time** with three tilt modes: inline, sequential (closes then tilts), or separate tilt motor. +- **Built-in configuration and calibration:** Calibrate travel times directly from the UI, including finer parameters to compensate for the time it takes the motor to startup. +- **Resyncs position at endpoints:** The motor can be configured to run-on at the 0%/100% endpoints to resync the position tracker with the physical cover. ## Install ### HACS -_This repo is available for install through the HACS._ +_This repo is available for install through HACS._ -- Go to HACS → Integrations -- Use the FAB "Explore and download repositories" to search "cover-time-based". +- Go to HACS +- Search for "Cover time based" _or_ @@ -41,197 +40,287 @@ Click here: ## Setup -### Example configuration.yaml entry +### Creating a cover via the UI -#### Basic configuration with individual device settings: +1. Go to **Settings → Devices & Services → Helpers** +2. Click **Create Helper → Cover Time Based** +3. Enter a name for your cover -```yaml -cover: - - platform: cover_time_based - devices: - room_rolling_shutter: - name: Room Rolling Shutter - open_switch_entity_id: switch.wall_switch_right - close_switch_entity_id: switch.wall_switch_left - travel_moves_with_tilt: false - travelling_time_down: 23 - travelling_time_up: 25 - tilting_time_down: 2.3 - tilting_time_up: 2.7 - travel_delay_at_end: 2.0 - min_movement_time: 0.5 - travel_startup_delay: 0.1 - tilt_startup_delay: 0.08 -``` +### Setup the configuration card -#### Configuration with shared defaults: +The configuration card provides a visual interface for all settings and supports built-in calibration to measure timing parameters automatically. -```yaml -cover: - - platform: cover_time_based - # Optional: Default values for all devices - defaults: - travelling_time_down: 49.2 - travelling_time_up: 50.7 - tilting_time_down: 1.5 - tilting_time_up: 1.5 - travel_delay_at_end: 1.5 - min_movement_time: 0.5 - travel_startup_delay: 0.1 - tilt_startup_delay: 0.08 +1. Go to **Settings → Dashboards**. +2. Click **Add dashboard → New dashboard from scratch**. +3. Fill in a name and make sure **Add to sidebar** is selected. +4. Click **Create**. +5. Click the new dashboard icon in the Home Assistant side bar. +6. Click the **Edit dashboard** icon in the top right corner. +7. Under **New section** click the **+** icon to add a new card. +8. Search for and select the **Cover time based configuration** card and click **Save**. +9. Click **Done** to stop editing the dashboard. - devices: - # This device uses all defaults - bedroom_left: - name: Bedroom Left - open_switch_entity_id: switch.bedroom_left_open - close_switch_entity_id: switch.bedroom_left_close - - # This device overrides some defaults - bedroom_right: - name: Bedroom Right - travelling_time_down: 52.0 # Override default - open_switch_entity_id: switch.bedroom_right_open - close_switch_entity_id: switch.bedroom_right_close - - # This device explicitly disables startup delay - kitchen: - name: Kitchen - travel_startup_delay: null # Override to disable - open_switch_entity_id: switch.kitchen_open - close_switch_entity_id: switch.kitchen_close -``` +### Configuration and Calibration Card -### Options - -| Name | Type | Requirement | Description | Default | -| ---------------------- | ------------ | ----------------------------------------------- | ------------------------------------------------------------------------- | ------- | -| name | string | **Required** | Name of the created entity | | -| open_switch_entity_id | state entity | **Required** or `cover_entity_id` | Entity ID of the switch for opening the cover | | -| close_switch_entity_id | state entity | **Required** or `cover_entity_id` | Entity ID of the switch for closing the cover | | -| stop_switch_entity_id | state entity | _Optional_ or `cover_entity_id` | Entity ID of the switch for stopping the cover | None | -| cover_entity_id | state entity | **Required** or `open_\|close_switch_entity_id` | Entity ID of a existing cover entity | | -| travel_moves_with_tilt | boolean | _Optional_ | Whether tilt movements also cause proportional travel changes | False | -| travelling_time_down | int | _Optional_ | Time it takes in seconds to close the cover | 30 | -| travelling_time_up | int | _Optional_ | Time it takes in seconds to open the cover | 30 | -| tilting_time_down | float | _Optional_ | Time it takes in seconds to tilt the cover all the way down | None | -| tilting_time_up | float | _Optional_ | Time it takes in seconds to tilt the cover all the way up | None | -| travel_delay_at_end | float | _Optional_ | Additional relay time (seconds) at endpoints (0%/100%) for position reset | None | -| min_movement_time | float | _Optional_ | Minimum movement duration (seconds) - blocks shorter movements | None | -| travel_startup_delay | float | _Optional_ | Motor startup time compensation (seconds) for travel movements | None | -| tilt_startup_delay | float | _Optional_ | Motor startup time compensation (seconds) for tilt movements | None | -| input_mode | string | _Optional_ (`cover_entity_id` not supported) | `switch` (latching), `pulse` (momentary+stop), `toggle` (same btn stops) | switch | -| pulse_time | float | _Optional_ | Duration in seconds for button press in `pulse`/`toggle` modes | 1.0 | - -## Advanced Features - -### Default Values (defaults) - -You can define default values for timing parameters that will be used by all devices unless explicitly overridden. This reduces configuration duplication when you have multiple covers with similar characteristics. +The configuration card provides a visual interface for all settings and supports built-in calibration to measure timing parameters automatically. -**How it works:** +The configuration card has two tabs: **Device** and **Calibration**. The Device tab must be fully configured before accessing the Calibration tab. -- Values in `defaults` section apply to all devices -- Device-specific values override defaults -- Explicit `null` in device config overrides defaults (disables feature) -- If neither defaults nor device config specify a value, schema defaults are used +The main items on the **Device** configure how to interface with the +physical cover: -**Priority order:** +- **Device type**: whether this helper talks to the cover via open/close switches or via an existing cover entity +- **Switch type**: whether the switches are latching, pulsed, or toggled. +- **Tilting**: what type of tilt, if any, is supported. -1. Device-specific value (highest priority) -2. Defaults value -3. Schema default (lowest priority) +The **Calibration** tab is used to configure: -### Synchronized Travel and Tilt +- **Position**: sync the position tracker with the physical cover and slat position. +- **Travel**: how long it takes to open and close the cover, and how much time it takes to start the motor. +- **Tilt**: how long it takes to open and close the slats, and how much time it takes to start the motor. -When both `tilting_time_down/up` are configured, the integration simulates realistic blind behavior: +## Device -- **Travel movements** always adjust tilt proportionally -- **Tilt movements** affect travel only when `travel_moves_with_tilt: true` -- Movements are time-synchronized and stop simultaneously +First configure the **Device type**. A cover-time-based helper can either: -**Example:** With `travelling_time=10s` and `tilting_time=5s`, moving travel 50% changes tilt 100%. +- wrap an existing cover entity to add time-based position tracking, or +- use relay switches to control cover movement, and optionally to + control tilt movement. -### Travel Moves With Tilt (travel_moves_with_tilt) +### Wrapped covers -Controls whether tilt adjustments cause proportional travel movement. +Wrap an existing cover entity to add time-based position tracking. Useful for covers that already have basic open/close/stop functionality but lack position tracking. -- **`false` (default):** Only travel movements affect tilt. Tilt can be adjusted independently. -- **`true`:** Both travel and tilt movements are synchronized on the same motor. +Specify the **Cover entity**. -```yaml -travel_moves_with_tilt: true -``` +### Switch-based covers -### Automatic Position Constraints +Control a cover using two relay switches (one for open, one for close), with an optional third stop switch. -At endpoint positions, tilt is automatically constrained to prevent drift: +Specify the **Open switch**, **Close switch**, and optionally the **Stop switch** entities. -- **At 0% (fully open):** Tilt is set to 0% (horizontal) -- **At 100% (fully closed):** Tilt is set to 100% (vertical) +### Input Mode for switch-based covers -### Endpoint Delay (travel_delay_at_end) +Three input modes are available to describe how the switch entities for switch-based covers function: -For covers with mechanical endstops, keeps the relay active for additional time after reaching endpoints to reset position. +| Mode | Description | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Switch** | Latching relays. The direction switch stays ON for the entire movement. Movement stops when the switch is turned OFF | +| **Pulse** | Momentary pulse buttons. A brief ON-OFF pulse latches the motor on. Requires a stop button to stop movement, or movement stops when the hardware reaches its endpoint. | +| **Toggle** | Toggle-style relays. A brief ON-OFF pulse latches the motor on. A second pulse on the same direction button stops the motor. | -```yaml -travel_delay_at_end: 2.0 -``` +#### Pulse time -Recommended values: 1.0 - 3.0 seconds +With the **Pulse** and **Toggle** input modes, the **Pulse time** configures how long the switch should send the ON signal before it turns OFF. Defaults to **1s**. -### Minimum Movement Time (min_movement_time) +## Tilt Mode -Prevents position drift by blocking relay activations too brief to physically move the cover. Movements to 0% or 100% are always allowed. +The **Tilt Mode** setting controls how tilt and travel interact: -```yaml -min_movement_time: 0.5 -``` +- **None:** Tilt is disabled. Only position tracking is used. +- **Inline:** Tilt and travel use the same motor. Tilting can happen with the cover in any position. When closing the cover, the closing movement first causes the slats to tilt closed before the cover starts closing. When opening the cover, the opening movement first causes the slats to tilt open before the cover starts opening. +- **Sequential (closes then tilts):** Tilting can only happen in the fully closed position. First the cover closes then the slats tilt closed. When opening, first the slats tilt open then the cover opens. +- **Separate tilt motor (dual_motor):** A separate motor controls the tilt. Requires dedicated tilt open/close/stop switches. Tilt is only allowed when the cover is in a safe position (configurable). + +### Tilt Motor + +For covers with a dedicated tilt motor, configure: + +- **Tilt open/close/stop switches:** The relay switches controlling the tilt motor (unless this is a wrapped cover entity which doesn't require extra switches). +- **Safe tilt position:** The tilt moves to this position before travel starts (default: 100 = fully open). +- **Max tilt allowed position:** Tilt is only allowed when the cover position is at or below this value (e.g., 0 = only when fully closed). + +## Calibration + +The **Calibration** tab is used to synchronise the position tracker with the position of the physical cover and slats, and to configure the timings that allow this integration to track the physical hardware. -Recommended values: 0.5 - 1.5 seconds +### Current Position -### Motor Startup Delay (travel_startup_delay, tilt_startup_delay) +Use the open/stop/close buttons to move the cover (and slats, if tilting is enabled) into a known position and then change the **Current Position** dropdown from `Unknown` to that position. The position must be specified in order to access the calibration tests further down the page. -Optional feature to compensate for **motor inertia** by delaying position tracking after relay activation. This improves position accuracy, especially for short movements. +### Timing Calibration -**The problem:** +Select the attribute that you wish to calibrate. The available attributes depend on the current position of the cover and slats, and which other attributes have already been configured. For instance, in position **Fully open** you can only calibrate **Travel time (close)** and **Minimum movement time**. **Travel startup delay** becomes configurable once **Travel time (open)** or **Travel time (close)** has been configured. -- Motors have startup inertia - after relay turns ON, there's a brief delay before the cover actually starts moving -- This delay (typically 0.05-0.15s) is counted in timing but doesn't move the cover -- For long movements (e.g., 30s), this is negligible (0.3% error) -- For short movements (e.g., 0.5s), this is significant (20-30% error) -- Multiple short movements accumulate drift +1. Set the **Current position** of the cover and slats. +2. Select the attribute you wish to configure. +3. Read the description of what needs to be measured. +4. Click **Start**. +5. Once the cover or slats reach the position described in the description, click **Finish**. Alternatively, click **Cancel** to abort the calibration. + +### Calibration Attributes for Travel + +| Option | Description | Default | +| -------------------- | --------------------------------------------------------------------- | ------- | +| Travel time (close) | Time in seconds for the cover to fully close | | +| Travel time (open) | Time in seconds for the cover to fully open | | +| Travel startup delay | Motor startup compensation for travel (see below) | None | +| Endpoint run-on time | Extra relay time at endpoints (0%/100%) to reset position | 2.0 | +| Min movement time | Minimum movement duration - blocks shorter movements to prevent drift | None | + +### Calibration Attributes for Tilt + +| Option | Description | Default | +| ------------------ | ---------------------------------------------- | ------- | +| Tilt time (close) | Time in seconds to tilt the cover fully closed | None | +| Tilt time (open) | Time in seconds to tilt the cover fully open | None | +| Tilt startup delay | Motor startup compensation for tilt | None | + +#### Travel/Tilt startup delay + +Compensates for motor inertia by delaying position tracking after relay activation. This improves position accuracy, especially for short movements. + +**The problem:** Motors have startup inertia. After the relay turns ON, there's a brief delay before the cover starts moving. For long movements (e.g., 30s) this is negligible, but for short movements (e.g. 0.5s) it can cause 20-30% position error that accumulates over time. **How it works:** 1. Relay turns ON immediately -2. Waits for `startup_delay` (motor is starting up) -3. Only then starts counting position change in Home Assistant -4. Can be cancelled at any time (STOP or direction change) +2. Waits for the configured startup delay (motor is starting up) +3. Only then starts counting position change +4. Can be cancelled at any time (stop or direction change) + +Recommended values: 0.05 - 0.15 seconds. Can be configured separately for travel and tilt. + +#### Endpoint Run-on Time + +Position tracking is not exact and can drift over time. To reduce drift, the position tracker resyncs itself whenever the cover is sent to the 0% or 100% endpoints. The motor continues running for the number of seconds specified in the **Endpoint Run-on Time** in case the physical cover hasn't quite reached the endpoint. Defaults to 2s. + +#### Min movement time -**Example:** +Prevents position drift by blocking relay activations too brief to physically move the cover. Movements to 0% or 100% are always allowed. Recommended values: 0.5 - 1.5 seconds. + +## Services + +### `cover_time_based.set_known_position` + +Manually set the internal position of a cover. Useful for correcting drift. + +| Field | Description | +| --------- | --------------------------- | +| entity_id | The cover entity | +| position | The position to set (0-100) | + +### `cover_time_based.set_known_tilt_position` + +Manually set the internal tilt position of a cover. + +| Field | Description | +| ------------- | -------------------------------- | +| entity_id | The cover entity | +| tilt_position | The tilt position to set (0-100) | + +### `cover_time_based.start_calibration` + +Start a calibration test to measure a timing parameter. + +| Field | Description | +| --------- | ------------------------------------------------------------------------------ | +| entity_id | The cover entity | +| attribute | The timing parameter to calibrate | +| timeout | Safety timeout in seconds - motor auto-stops if stop_calibration is not called | +| direction | Direction to move (`open` or `close`). Auto-detects if not set | + +### `cover_time_based.stop_calibration` + +Stop an active calibration test and save the result. + +| Field | Description | +| --------- | --------------------------------------------- | +| entity_id | The cover entity | +| cancel | If `true`, discard the results without saving | + +## Debugging + +If something isn't working as expected, you can enable debug logging to see detailed information about what the integration is doing. + +### Via Developer Tools + +1. Go to **Developer Tools → Actions**. +2. Search for **Logger: Set level** and select it. +3. Switch to YAML mode and enter: ```yaml -travel_startup_delay: 0.1 # 100ms startup delay for travel -tilt_startup_delay: 0.08 # 80ms startup delay for tilt +action: logger.set_level +data: + custom_components.cover_time_based: debug +``` -# User command: Tilt 1% (normally 0.03s) -# Actual timing: Relay ON for 0.11s total (0.08s startup + 0.03s movement) -# Result: Cover actually moves 1% +4. Click **Perform action**. +5. Reproduce the issue — debug messages will appear in the Home Assistant log. + +To turn off debug logging, repeat the steps above but change `debug` to `info`. + +### Via YAML + +Add the following to your `configuration.yaml`: + +```yaml +logger: + default: info + logs: + custom_components.cover_time_based: debug ``` -**Recommended values:** 0.05 - 0.15 seconds +Restart Home Assistant to apply. + +## Reporting Issues -**Important notes:** +If you encounter a bug or have a feature request, please open an issue on [GitHub](https://github.com/clintongormley/ha-cover-time-based/issues). Include debug logs if possible — they help diagnose problems much faster. + +## YAML configuration (deprecated) + +> **Note:** YAML configuration is deprecated and will be removed in a future version. Please use the UI method described above instead. Existing YAML configurations will continue to work, and a deprecation notice will appear in your Home Assistant repairs panel. + +
+Show YAML configuration (deprecated) + +### Basic configuration with individual device settings: + +```yaml +cover: + - platform: cover_time_based + devices: + room_rolling_shutter: + name: Room Rolling Shutter + open_switch_entity_id: switch.wall_switch_right + close_switch_entity_id: switch.wall_switch_left + travel_moves_with_tilt: false + travelling_time_down: 23 + travelling_time_up: 25 + tilting_time_down: 2.3 + tilting_time_up: 2.7 + travel_delay_at_end: 2.0 + min_movement_time: 0.5 + travel_startup_delay: 0.1 + tilt_startup_delay: 0.08 +``` -- This is a fixed time per relay activation, not a percentage -- Works best when calibrated for your specific motor -- Can be different for travel and tilt if needed -- Compatible with `min_movement_time` and `travel_delay_at_end` +### YAML options + +| Name | Type | Requirement | Description | Default | +| ---------------------- | ------- | ----------------------------------------------- | ----------------------------------------------------------------------- | ------- | +| name | string | **Required** | Name of the created entity | | +| open_switch_entity_id | entity | **Required** or `cover_entity_id` | Entity ID of the switch for opening the cover | | +| close_switch_entity_id | entity | **Required** or `cover_entity_id` | Entity ID of the switch for closing the cover | | +| stop_switch_entity_id | entity | _Optional_ | Entity ID of the switch for stopping the cover | None | +| cover_entity_id | entity | **Required** or `open_\|close_switch_entity_id` | Entity ID of an existing cover entity | | +| is_button | boolean | _Optional_ | Set to `true` for momentary pulse buttons (same as `input_mode: pulse`) | false | +| travelling_time_down | float | _Optional_ | Time in seconds to close the cover | 30 | +| travelling_time_up | float | _Optional_ | Time in seconds to open the cover | 30 | +| tilting_time_down | float | _Optional_ | Time in seconds to tilt the cover fully closed | None | +| tilting_time_up | float | _Optional_ | Time in seconds to tilt the cover fully open | None | +| travel_moves_with_tilt | boolean | _Optional_ | Whether tilt movements also cause proportional travel changes | false | +| travel_delay_at_end | float | _Optional_ | Additional relay time (seconds) at endpoints for position reset | None | +| min_movement_time | float | _Optional_ | Minimum movement duration (seconds) - blocks shorter movements | None | +| travel_startup_delay | float | _Optional_ | Motor startup time compensation (seconds) for travel movements | None | +| tilt_startup_delay | float | _Optional_ | Motor startup time compensation (seconds) for tilt movements | None | +| pulse_time | float | _Optional_ | Duration in seconds for button press in pulse mode | 1.0 | + +
[commits-shield]: https://img.shields.io/github/commit-activity/y/Sese-Schneider/ha-cover-time-based.svg?style=for-the-badge [commits]: https://github.com/Sese-Schneider/ha-cover-time-based/commits/main [license-shield]: https://img.shields.io/github/license/Sese-Schneider/ha-cover-time-based.svg?style=for-the-badge -[maintenance-shield]: https://img.shields.io/maintenance/yes/2025.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/Sese-Schneider/ha-cover-time-based.svg?style=for-the-badge [releases]: https://github.com/Sese-Schneider/ha-cover-time-based/releases diff --git a/custom_components/cover_time_based/__init__.py b/custom_components/cover_time_based/__init__.py new file mode 100644 index 0000000..54c7a86 --- /dev/null +++ b/custom_components/cover_time_based/__init__.py @@ -0,0 +1,57 @@ +"""Cover Time Based integration.""" + +import logging +from pathlib import Path + +from homeassistant.components import frontend +from homeassistant.components.http import StaticPathConfig +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .websocket_api import async_register_websocket_api + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.COVER] +_FRONTEND_KEY = f"{DOMAIN}_frontend_registered" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Cover Time Based from a config entry.""" + # Register frontend and WebSocket API once (not per entry). + # Done before platform setup so the card works even if the entity fails. + if _FRONTEND_KEY not in hass.data: + hass.data[_FRONTEND_KEY] = True + + async_register_websocket_api(hass) + + if hass.http is not None: + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + "/cover_time_based_panel", + str(Path(__file__).parent / "frontend"), + cache_headers=False, + ) + ] + ) + + frontend.add_extra_js_url( + hass, "/cover_time_based_panel/cover-time-based-card.js" + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update - reload the entry.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/cover_time_based/calibration.py b/custom_components/cover_time_based/calibration.py new file mode 100644 index 0000000..0cfccbc --- /dev/null +++ b/custom_components/cover_time_based/calibration.py @@ -0,0 +1,45 @@ +"""Calibration support for cover_time_based.""" + +from __future__ import annotations + +import time +from asyncio import Task +from dataclasses import dataclass, field + +CALIBRATION_STEP_PAUSE = 2.0 +CALIBRATION_OVERHEAD_STEPS = 8 +CALIBRATION_TILT_OVERHEAD_STEPS = 3 +CALIBRATION_MIN_MOVEMENT_START = 0.1 +CALIBRATION_MIN_MOVEMENT_INCREMENT = 0.1 +CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE = 3.0 + +CALIBRATABLE_ATTRIBUTES = [ + "travel_time_close", + "travel_time_open", + "travel_startup_delay", + "tilt_time_close", + "tilt_time_open", + "tilt_startup_delay", + "min_movement_time", +] + +SERVICE_START_CALIBRATION = "start_calibration" +SERVICE_STOP_CALIBRATION = "stop_calibration" + + +@dataclass +class CalibrationState: + """Holds state for an in-progress calibration test.""" + + attribute: str + timeout: float + started_at: float = field(default_factory=time.monotonic) + step_count: int = 0 + final_step: bool = False + step_duration: float | None = None + last_pulse_duration: float | None = None + continuous_start: float | None = None + move_command: str | None = None + saved_startup_delay: float | None = None + timeout_task: Task | None = field(default=None, repr=False) + automation_task: Task | None = field(default=None, repr=False) diff --git a/custom_components/cover_time_based/config_flow.py b/custom_components/cover_time_based/config_flow.py new file mode 100644 index 0000000..7e826d5 --- /dev/null +++ b/custom_components/cover_time_based/config_flow.py @@ -0,0 +1,40 @@ +"""Config flow for Cover Time Based integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_NAME +from homeassistant.helpers.selector import TextSelector + +from .cover import DOMAIN + + +class CoverTimeBasedConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Cover Time Based.""" + + VERSION = 2 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create a new time-based cover helper.""" + if user_input is not None: + return self.async_create_entry( + title=user_input[CONF_NAME], + data={}, + options={}, + ) + + schema = vol.Schema( + { + vol.Required(CONF_NAME): TextSelector(), + } + ) + + return self.async_show_form(step_id="user", data_schema=schema) diff --git a/custom_components/cover_time_based/const.py b/custom_components/cover_time_based/const.py new file mode 100644 index 0000000..b6580a7 --- /dev/null +++ b/custom_components/cover_time_based/const.py @@ -0,0 +1,14 @@ +"""Constants for the cover_time_based integration.""" + +DOMAIN = "cover_time_based" + +CONF_TILT_MODE = "tilt_mode" +CONF_TRAVEL_TIME_CLOSE = "travel_time_close" +CONF_TRAVEL_TIME_OPEN = "travel_time_open" +CONF_TILT_TIME_CLOSE = "tilt_time_close" +CONF_TILT_TIME_OPEN = "tilt_time_open" +CONF_TRAVEL_STARTUP_DELAY = "travel_startup_delay" +CONF_TILT_STARTUP_DELAY = "tilt_startup_delay" +CONF_ENDPOINT_RUNON_TIME = "endpoint_runon_time" +CONF_MIN_MOVEMENT_TIME = "min_movement_time" +DEFAULT_ENDPOINT_RUNON_TIME = 2.0 diff --git a/custom_components/cover_time_based/cover.py b/custom_components/cover_time_based/cover.py index d982eef..f749e9b 100644 --- a/custom_components/cover_time_based/cover.py +++ b/custom_components/cover_time_based/cover.py @@ -1,67 +1,94 @@ """Cover time based""" -import asyncio import logging -from asyncio import sleep -from datetime import timedelta import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, PLATFORM_SCHEMA, - CoverEntity, - CoverEntityFeature, ) from homeassistant.const import ( - CONF_NAME, ATTR_ENTITY_ID, - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - SERVICE_STOP_COVER, + CONF_NAME, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import entity_platform -from homeassistant.helpers.event import ( - async_track_time_interval, +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .calibration import ( + CALIBRATABLE_ATTRIBUTES, + SERVICE_START_CALIBRATION, + SERVICE_STOP_CALIBRATION, +) +from .const import ( + CONF_ENDPOINT_RUNON_TIME, + CONF_MIN_MOVEMENT_TIME, + CONF_TILT_MODE, + CONF_TILT_STARTUP_DELAY, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + DEFAULT_ENDPOINT_RUNON_TIME, ) -from homeassistant.helpers.restore_state import RestoreEntity -from xknx.devices import TravelStatus, TravelCalculator +from .cover_base import CoverTimeBased # noqa: F401 +from .helpers import resolve_entity _LOGGER = logging.getLogger(__name__) +DOMAIN = "cover_time_based" + +# --------------------------------------------------------------------------- +# YAML config constants (deprecated + current) +# --------------------------------------------------------------------------- + CONF_DEVICES = "devices" CONF_DEFAULTS = "defaults" + +# Deprecated YAML key names (renamed) CONF_TRAVEL_MOVES_WITH_TILT = "travel_moves_with_tilt" CONF_TRAVELLING_TIME_DOWN = "travelling_time_down" CONF_TRAVELLING_TIME_UP = "travelling_time_up" CONF_TILTING_TIME_DOWN = "tilting_time_down" CONF_TILTING_TIME_UP = "tilting_time_up" CONF_TRAVEL_DELAY_AT_END = "travel_delay_at_end" -CONF_MIN_MOVEMENT_TIME = "min_movement_time" -CONF_TRAVEL_STARTUP_DELAY = "travel_startup_delay" -CONF_TILT_STARTUP_DELAY = "tilt_startup_delay" -DEFAULT_TRAVEL_TIME = 30 +CONF_IS_BUTTON = "is_button" CONF_OPEN_SWITCH_ENTITY_ID = "open_switch_entity_id" CONF_CLOSE_SWITCH_ENTITY_ID = "close_switch_entity_id" CONF_STOP_SWITCH_ENTITY_ID = "stop_switch_entity_id" -CONF_IS_BUTTON = "is_button" -CONF_INPUT_MODE = "input_mode" +CONF_SAFE_TILT_POSITION = "safe_tilt_position" +CONF_MAX_TILT_ALLOWED_POSITION = "max_tilt_allowed_position" +CONF_TILT_OPEN_SWITCH = "tilt_open_switch" +CONF_TILT_CLOSE_SWITCH = "tilt_close_switch" +CONF_TILT_STOP_SWITCH = "tilt_stop_switch" +CONF_COVER_ENTITY_ID = "cover_entity_id" + +# --------------------------------------------------------------------------- +# Control mode constants +# --------------------------------------------------------------------------- + +CONF_CONTROL_MODE = "control_mode" +CONTROL_MODE_WRAPPED = "wrapped" +CONTROL_MODE_SWITCH = "switch" +CONTROL_MODE_PULSE = "pulse" +CONTROL_MODE_TOGGLE = "toggle" + CONF_PULSE_TIME = "pulse_time" DEFAULT_PULSE_TIME = 1.0 -INPUT_MODE_SWITCH = "switch" -INPUT_MODE_PULSE = "pulse" -INPUT_MODE_TOGGLE = "toggle" - -CONF_COVER_ENTITY_ID = "cover_entity_id" SERVICE_SET_KNOWN_POSITION = "set_known_position" SERVICE_SET_KNOWN_TILT_POSITION = "set_known_tilt_position" +# --------------------------------------------------------------------------- +# Schema definitions +# --------------------------------------------------------------------------- + BASE_DEVICE_SCHEMA = { vol.Required(CONF_NAME): cv.string, } @@ -72,10 +99,11 @@ vol.Optional(CONF_TRAVELLING_TIME_UP): cv.positive_float, vol.Optional(CONF_TILTING_TIME_DOWN): cv.positive_float, vol.Optional(CONF_TILTING_TIME_UP): cv.positive_float, - vol.Optional(CONF_TRAVEL_DELAY_AT_END): cv.positive_float, - vol.Optional(CONF_MIN_MOVEMENT_TIME): cv.positive_float, vol.Optional(CONF_TRAVEL_STARTUP_DELAY): cv.positive_float, vol.Optional(CONF_TILT_STARTUP_DELAY): cv.positive_float, + vol.Optional(CONF_ENDPOINT_RUNON_TIME): cv.positive_float, + vol.Optional(CONF_TRAVEL_DELAY_AT_END): cv.positive_float, + vol.Optional(CONF_MIN_MOVEMENT_TIME): cv.positive_float, } SWITCH_COVER_SCHEMA = { @@ -84,9 +112,6 @@ vol.Required(CONF_CLOSE_SWITCH_ENTITY_ID): cv.entity_id, vol.Optional(CONF_STOP_SWITCH_ENTITY_ID, default=None): vol.Any(cv.entity_id, None), vol.Optional(CONF_IS_BUTTON, default=False): cv.boolean, - vol.Optional(CONF_INPUT_MODE, default=None): vol.Any( - vol.In([INPUT_MODE_SWITCH, INPUT_MODE_PULSE, INPUT_MODE_TOGGLE]), None - ), vol.Optional(CONF_PULSE_TIME): cv.positive_float, **TRAVEL_TIME_SCHEMA, } @@ -97,29 +122,36 @@ **TRAVEL_TIME_SCHEMA, } -DEFAULTS_SCHEMA = vol.Schema({ - vol.Optional(CONF_TRAVEL_MOVES_WITH_TILT, default=False): cv.boolean, - vol.Optional( - CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME - ): cv.positive_float, - vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): cv.positive_float, - vol.Optional(CONF_TILTING_TIME_DOWN, default=None): vol.Any( - cv.positive_float, None - ), - vol.Optional(CONF_TILTING_TIME_UP, default=None): vol.Any(cv.positive_float, None), - vol.Optional(CONF_TRAVEL_DELAY_AT_END, default=None): vol.Any( - cv.positive_float, None - ), - vol.Optional(CONF_MIN_MOVEMENT_TIME, default=None): vol.Any( - cv.positive_float, None - ), - vol.Optional(CONF_TRAVEL_STARTUP_DELAY, default=None): vol.Any( - cv.positive_float, None - ), - vol.Optional(CONF_TILT_STARTUP_DELAY, default=None): vol.Any( - cv.positive_float, None - ), -}) +DEFAULTS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TRAVEL_MOVES_WITH_TILT, default=False): cv.boolean, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional(CONF_TRAVELLING_TIME_UP, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional(CONF_TILTING_TIME_DOWN, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional(CONF_TILTING_TIME_UP, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional(CONF_TRAVEL_STARTUP_DELAY, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional(CONF_TILT_STARTUP_DELAY, default=None): vol.Any( + cv.positive_float, None + ), + vol.Optional( + CONF_ENDPOINT_RUNON_TIME, default=DEFAULT_ENDPOINT_RUNON_TIME + ): vol.Any(cv.positive_float, None), + vol.Optional(CONF_TRAVEL_DELAY_AT_END): cv.positive_float, + vol.Optional(CONF_MIN_MOVEMENT_TIME, default=None): vol.Any( + cv.positive_float, None + ), + } +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -143,123 +175,255 @@ } ) -DOMAIN = "cover_time_based" +# --------------------------------------------------------------------------- +# YAML migration helpers +# --------------------------------------------------------------------------- + +_YAML_KEY_RENAMES = { + CONF_TRAVEL_DELAY_AT_END: CONF_ENDPOINT_RUNON_TIME, + CONF_TRAVELLING_TIME_DOWN: CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVELLING_TIME_UP: CONF_TRAVEL_TIME_OPEN, + CONF_TILTING_TIME_DOWN: CONF_TILT_TIME_CLOSE, + CONF_TILTING_TIME_UP: CONF_TILT_TIME_OPEN, +} + + +def _migrate_yaml_keys(config): + """Migrate deprecated YAML key names to current names.""" + for old_key, new_key in _YAML_KEY_RENAMES.items(): + if old_key in config: + if new_key not in config: + config[new_key] = config[old_key] + config.pop(old_key) + + +_TIMING_DEFAULTS = { + CONF_TILT_MODE: "none", + CONF_TRAVEL_TIME_CLOSE: None, + CONF_TRAVEL_TIME_OPEN: None, + CONF_TILT_TIME_CLOSE: None, + CONF_TILT_TIME_OPEN: None, + CONF_TRAVEL_STARTUP_DELAY: None, + CONF_TILT_STARTUP_DELAY: None, + CONF_ENDPOINT_RUNON_TIME: DEFAULT_ENDPOINT_RUNON_TIME, + CONF_MIN_MOVEMENT_TIME: None, +} + + +def _get_value(key, device_config, defaults_config, schema_default=None): + """Get config value with priority: device config > defaults > schema default.""" + if key in device_config: + return device_config[key] + if key in defaults_config: + return defaults_config[key] + return schema_default + + +def _resolve_control_mode(config, defaults, has_cover_entity): + """Resolve control mode from config, handling legacy keys.""" + if has_cover_entity: + config.pop(CONF_IS_BUTTON, None) + return CONTROL_MODE_WRAPPED + + # Explicit input_mode takes precedence + explicit = config.pop("input_mode", None) or defaults.get("input_mode") + if explicit: + config.pop(CONF_IS_BUTTON, None) + return explicit + + # Legacy is_button → pulse mode + is_button = config.pop(CONF_IS_BUTTON, False) + if is_button: + return CONTROL_MODE_PULSE + + return CONTROL_MODE_SWITCH + + +# --------------------------------------------------------------------------- +# Entity factory +# --------------------------------------------------------------------------- + + +def _resolve_tilt_strategy(tilt_mode_str, tilt_time_close, tilt_time_open, **kwargs): + """Map tilt_mode config string to a TiltStrategy instance (or None).""" + from .tilt_strategies import DualMotorTilt, InlineTilt, SequentialTilt + + if tilt_mode_str == "none": + return None + + has_tilt_times = tilt_time_close is not None and tilt_time_open is not None + if not has_tilt_times: + return None + + if tilt_mode_str == "dual_motor": + return DualMotorTilt( + safe_tilt_position=kwargs.get("safe_tilt_position", 100), + max_tilt_allowed_position=kwargs.get("max_tilt_allowed_position"), + ) + if tilt_mode_str == "inline": + return InlineTilt() + # "sequential" or any other value with tilt times → sequential + return SequentialTilt() + + +def _create_cover_from_options(options, device_id="", name=""): + """Create the appropriate cover subclass based on options.""" + from .cover_wrapped import WrappedCoverTimeBased + from .cover_switch_mode import SwitchModeCover + from .cover_pulse_mode import PulseModeCover + from .cover_toggle_mode import ToggleModeCover + + control_mode = options.get(CONF_CONTROL_MODE, CONTROL_MODE_SWITCH) + + tilt_mode_str = options.get(CONF_TILT_MODE, "none") + tilt_strategy = _resolve_tilt_strategy( + tilt_mode_str, + options.get(CONF_TILT_TIME_CLOSE), + options.get(CONF_TILT_TIME_OPEN), + safe_tilt_position=options.get(CONF_SAFE_TILT_POSITION, 100), + max_tilt_allowed_position=options.get(CONF_MAX_TILT_ALLOWED_POSITION), + ) + + # Common params for all subclasses + common = dict( + device_id=device_id, + name=name, + tilt_strategy=tilt_strategy, + travel_time_close=options.get(CONF_TRAVEL_TIME_CLOSE), + travel_time_open=options.get(CONF_TRAVEL_TIME_OPEN), + tilt_time_close=options.get(CONF_TILT_TIME_CLOSE), + tilt_time_open=options.get(CONF_TILT_TIME_OPEN), + travel_startup_delay=options.get(CONF_TRAVEL_STARTUP_DELAY), + tilt_startup_delay=options.get(CONF_TILT_STARTUP_DELAY), + endpoint_runon_time=options.get( + CONF_ENDPOINT_RUNON_TIME, DEFAULT_ENDPOINT_RUNON_TIME + ), + min_movement_time=options.get(CONF_MIN_MOVEMENT_TIME), + tilt_open_switch=options.get(CONF_TILT_OPEN_SWITCH), + tilt_close_switch=options.get(CONF_TILT_CLOSE_SWITCH), + tilt_stop_switch=options.get(CONF_TILT_STOP_SWITCH), + ) + + if control_mode == CONTROL_MODE_WRAPPED: + return WrappedCoverTimeBased( + cover_entity_id=options.get(CONF_COVER_ENTITY_ID, ""), + **common, + ) + + switch_args = dict( + open_switch_entity_id=options.get(CONF_OPEN_SWITCH_ENTITY_ID, ""), + close_switch_entity_id=options.get(CONF_CLOSE_SWITCH_ENTITY_ID, ""), + stop_switch_entity_id=options.get(CONF_STOP_SWITCH_ENTITY_ID), + **common, + ) + + pulse_time = options.get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME) + + if control_mode == CONTROL_MODE_PULSE: + return PulseModeCover(pulse_time=pulse_time, **switch_args) + elif control_mode == CONTROL_MODE_TOGGLE: + return ToggleModeCover(pulse_time=pulse_time, **switch_args) + else: + return SwitchModeCover(**switch_args) def devices_from_config(domain_config): """Parse configuration and add cover devices.""" devices = [] defaults = domain_config.get(CONF_DEFAULTS, {}) - - def get_value(key, device_config, defaults_config, schema_default=None): - """ - Get value with priority: device config > defaults > schema default. - - If key EXISTS in device config (even if None/null), use that value. - Otherwise, try defaults, then schema default. - """ - # Priority: device config > defaults > schema default - if key in device_config: - return device_config[key] - if key in defaults_config: - return defaults_config[key] - return schema_default - + + _migrate_yaml_keys(defaults) + for device_id, config in domain_config[CONF_DEVICES].items(): name = config.pop(CONF_NAME) - - travel_moves_with_tilt = get_value(CONF_TRAVEL_MOVES_WITH_TILT, config, defaults, False) - travel_time_down = get_value(CONF_TRAVELLING_TIME_DOWN, config, defaults, DEFAULT_TRAVEL_TIME) - travel_time_up = get_value(CONF_TRAVELLING_TIME_UP, config, defaults, DEFAULT_TRAVEL_TIME) - tilt_time_down = get_value(CONF_TILTING_TIME_DOWN, config, defaults, None) - tilt_time_up = get_value(CONF_TILTING_TIME_UP, config, defaults, None) - travel_delay_at_end = get_value(CONF_TRAVEL_DELAY_AT_END, config, defaults, None) - min_movement_time = get_value(CONF_MIN_MOVEMENT_TIME, config, defaults, None) - travel_startup_delay = get_value(CONF_TRAVEL_STARTUP_DELAY, config, defaults, None) - tilt_startup_delay = get_value(CONF_TILT_STARTUP_DELAY, config, defaults, None) - + + _migrate_yaml_keys(config) + + # Extract timing values with defaults, then remove from config + options = {} + for key, schema_default in _TIMING_DEFAULTS.items(): + options[key] = _get_value(key, config, defaults, schema_default) + config.pop(key, None) + + # Legacy travel_moves_with_tilt → inline tilt mode + travel_moves = _get_value(CONF_TRAVEL_MOVES_WITH_TILT, config, defaults, False) config.pop(CONF_TRAVEL_MOVES_WITH_TILT, None) - config.pop(CONF_TRAVELLING_TIME_DOWN, None) - config.pop(CONF_TRAVELLING_TIME_UP, None) - config.pop(CONF_TILTING_TIME_DOWN, None) - config.pop(CONF_TILTING_TIME_UP, None) - config.pop(CONF_TRAVEL_DELAY_AT_END, None) - config.pop(CONF_MIN_MOVEMENT_TIME, None) - config.pop(CONF_TRAVEL_STARTUP_DELAY, None) - config.pop(CONF_TILT_STARTUP_DELAY, None) - - open_switch_entity_id = ( - config.pop(CONF_OPEN_SWITCH_ENTITY_ID) - if CONF_OPEN_SWITCH_ENTITY_ID in config - else None - ) - close_switch_entity_id = ( - config.pop(CONF_CLOSE_SWITCH_ENTITY_ID) - if CONF_CLOSE_SWITCH_ENTITY_ID in config - else None - ) - stop_switch_entity_id = ( - config.pop(CONF_STOP_SWITCH_ENTITY_ID) - if CONF_STOP_SWITCH_ENTITY_ID in config - else None - ) - is_button = config.pop(CONF_IS_BUTTON) if CONF_IS_BUTTON in config else False - input_mode = ( - config.pop(CONF_INPUT_MODE, None) if CONF_INPUT_MODE in config else None - ) - pulse_time = get_value(CONF_PULSE_TIME, config, defaults, DEFAULT_PULSE_TIME) + if travel_moves and options.get(CONF_TILT_MODE) == "none": + options[CONF_TILT_MODE] = "inline" + + # Entity IDs + open_switch = config.pop(CONF_OPEN_SWITCH_ENTITY_ID, None) + close_switch = config.pop(CONF_CLOSE_SWITCH_ENTITY_ID, None) + stop_switch = config.pop(CONF_STOP_SWITCH_ENTITY_ID, None) + cover_entity_id = config.pop(CONF_COVER_ENTITY_ID, None) + + # Control mode (handles is_button deprecation) + control_mode = _resolve_control_mode(config, defaults, bool(cover_entity_id)) + pulse_time = _get_value(CONF_PULSE_TIME, config, defaults, DEFAULT_PULSE_TIME) config.pop(CONF_PULSE_TIME, None) - if input_mode is not None and is_button: - _LOGGER.warning( - "Device '%s': both 'is_button' and 'input_mode' are set. " - "'input_mode: %s' takes precedence. Please remove 'is_button'.", - device_id, - input_mode, - ) - elif is_button: - input_mode = INPUT_MODE_PULSE - _LOGGER.warning( - "Device '%s': 'is_button' is deprecated. " - "Use 'input_mode: pulse' instead.", - device_id, - ) - elif input_mode is None: - input_mode = INPUT_MODE_SWITCH - - cover_entity_id = ( - config.pop(CONF_COVER_ENTITY_ID) if CONF_COVER_ENTITY_ID in config else None - ) + options[CONF_CONTROL_MODE] = control_mode + options[CONF_PULSE_TIME] = pulse_time + + if open_switch: + options[CONF_OPEN_SWITCH_ENTITY_ID] = open_switch + options[CONF_CLOSE_SWITCH_ENTITY_ID] = close_switch + options[CONF_STOP_SWITCH_ENTITY_ID] = stop_switch + if cover_entity_id: + options[CONF_COVER_ENTITY_ID] = cover_entity_id - device = CoverTimeBased( - device_id, - name, - travel_moves_with_tilt, - travel_time_down, - travel_time_up, - tilt_time_down, - tilt_time_up, - travel_delay_at_end, - min_movement_time, - travel_startup_delay, - tilt_startup_delay, - open_switch_entity_id, - close_switch_entity_id, - stop_switch_entity_id, - input_mode, - pulse_time, - cover_entity_id, + devices.append( + _create_cover_from_options(options, device_id=device_id, name=name) ) - devices.append(device) return devices -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +# --------------------------------------------------------------------------- +# Platform setup +# --------------------------------------------------------------------------- + + +async def async_setup_platform(hass, config, async_add_entities, _discovery_info=None): """Set up the cover platform.""" + _LOGGER.warning( + "Configuration of Cover Time Based via YAML is deprecated and " + "will be removed in a future version. Please use the UI to " + "configure your covers (Settings > Devices & Services > Helpers)" + ) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) async_add_entities(devices_from_config(config)) platform = entity_platform.current_platform.get() + _register_services(platform) + + +async def async_setup_entry( + _hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a single cover entity from a config entry.""" + entity = _create_cover_from_options( + config_entry.options, + device_id=config_entry.entry_id, + name=config_entry.title, + ) + entity._config_entry_id = config_entry.entry_id + async_add_entities([entity]) + + platform = entity_platform.current_platform.get() + _register_services(platform) + +def _register_services(platform): + """Register entity services on the given platform.""" platform.async_register_entity_service( SERVICE_SET_KNOWN_POSITION, POSITION_SCHEMA, "set_known_position" ) @@ -267,1067 +431,49 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= SERVICE_SET_KNOWN_TILT_POSITION, TILT_POSITION_SCHEMA, "set_known_tilt_position" ) - -class CoverTimeBased(CoverEntity, RestoreEntity): - def __init__( - self, - device_id, - name, - travel_moves_with_tilt, - travel_time_down, - travel_time_up, - tilt_time_down, - tilt_time_up, - travel_delay_at_end, - min_movement_time, - travel_startup_delay, - tilt_startup_delay, - open_switch_entity_id, - close_switch_entity_id, - stop_switch_entity_id, - input_mode, - pulse_time, - cover_entity_id, - ): - """Initialize the cover.""" - self._unique_id = device_id - - self._travel_moves_with_tilt = travel_moves_with_tilt - self._travel_time_down = travel_time_down - self._travel_time_up = travel_time_up - self._tilting_time_down = tilt_time_down - self._tilting_time_up = tilt_time_up - self._travel_delay_at_end = travel_delay_at_end - self._min_movement_time = min_movement_time - self._travel_startup_delay = travel_startup_delay - self._tilt_startup_delay = tilt_startup_delay - - self._open_switch_entity_id = open_switch_entity_id - self._close_switch_entity_id = close_switch_entity_id - self._stop_switch_entity_id = stop_switch_entity_id - self._input_mode = input_mode - self._pulse_time = pulse_time - - self._cover_entity_id = cover_entity_id - - if name: - self._name = name - else: - self._name = device_id - - self._unsubscribe_auto_updater = None - self._delay_task = None - self._startup_delay_task = None - self._last_command = None - - self.travel_calc = TravelCalculator( - self._travel_time_down, - self._travel_time_up, - ) - if self._has_tilt_support(): - self.tilt_calc = TravelCalculator( - self._tilting_time_down, - self._tilting_time_up, - ) - - async def async_added_to_hass(self): - """Only cover's position and tilt matters.""" - old_state = await self.async_get_last_state() - _LOGGER.debug("async_added_to_hass :: oldState %s", old_state) - if ( - old_state is not None - and self.travel_calc is not None - and old_state.attributes.get(ATTR_CURRENT_POSITION) is not None - ): - self.travel_calc.set_position( - 100 - int(old_state.attributes.get(ATTR_CURRENT_POSITION)) - ) - - if ( - self._has_tilt_support() - and old_state.attributes.get(ATTR_CURRENT_TILT_POSITION) is not None - ): - self.tilt_calc.set_position( - 100 - int(old_state.attributes.get(ATTR_CURRENT_TILT_POSITION)) - ) - - def _handle_stop(self): - """Handle stop""" - if self.travel_calc.is_traveling(): - _LOGGER.debug("_handle_stop :: button stops cover movement") - self.travel_calc.stop() - self.stop_auto_updater() - - if self._has_tilt_support() and self.tilt_calc.is_traveling(): - _LOGGER.debug("_handle_stop :: button stops tilt movement") - self.tilt_calc.stop() - self.stop_auto_updater() - - def _stop_travel_if_traveling(self): - """Stop cover movement if it's currently traveling.""" - if self.travel_calc.is_traveling(): - _LOGGER.debug("_stop_travel_if_traveling :: stopping cover movement") - self.travel_calc.stop() - if self._has_tilt_support() and self.tilt_calc.is_traveling(): - _LOGGER.debug("_stop_travel_if_traveling :: also stopping tilt") - self.tilt_calc.stop() - - def _cancel_delay_task(self): - """Cancel any active delay task.""" - if self._delay_task is not None and not self._delay_task.done(): - _LOGGER.debug("_cancel_delay_task :: cancelling active delay task") - self._delay_task.cancel() - self._delay_task = None - return True - return False - - def _cancel_startup_delay_task(self): - """Cancel any active startup delay task.""" - if self._startup_delay_task is not None and not self._startup_delay_task.done(): - _LOGGER.debug("_cancel_startup_delay_task :: cancelling active startup delay task") - self._startup_delay_task.cancel() - self._startup_delay_task = None - - async def _execute_with_startup_delay(self, startup_delay, start_callback): - """ - Execute movement with startup delay. - - This method handles the motor inertia by: - 1. Turning relay ON immediately - 2. Waiting for startup_delay (motor "wakes up") - 3. Starting TravelCalculator (position starts changing in HA) - - Args: - startup_delay: Time in seconds to wait before starting position tracking - start_callback: Callback to execute after delay (starts TravelCalculator) - """ - # Motor inertia handling: relay ON → wait → start position tracking - _LOGGER.debug("_execute_with_startup_delay :: waiting %fs before starting position tracking", startup_delay) - try: - await sleep(startup_delay) - _LOGGER.debug("_execute_with_startup_delay :: startup delay complete, starting position tracking") - start_callback() - self._startup_delay_task = None - except asyncio.CancelledError: - _LOGGER.debug("_execute_with_startup_delay :: startup delay cancelled") - self._startup_delay_task = None - raise - - @property - def name(self): - """Return the name of the cover.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return "cover_timebased_uuid_" + self._unique_id - - @property - def device_class(self): - """Return the device class of the cover.""" - return None - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - attr = {} - if self._travel_moves_with_tilt is not None: - attr[CONF_TRAVEL_MOVES_WITH_TILT] = self._travel_moves_with_tilt - if self._travel_time_down is not None: - attr[CONF_TRAVELLING_TIME_DOWN] = self._travel_time_down - if self._travel_time_up is not None: - attr[CONF_TRAVELLING_TIME_UP] = self._travel_time_up - if self._tilting_time_down is not None: - attr[CONF_TILTING_TIME_DOWN] = self._tilting_time_down - if self._tilting_time_up is not None: - attr[CONF_TILTING_TIME_UP] = self._tilting_time_up - if self._travel_delay_at_end is not None: - attr[CONF_TRAVEL_DELAY_AT_END] = self._travel_delay_at_end - if self._min_movement_time is not None: - attr[CONF_MIN_MOVEMENT_TIME] = self._min_movement_time - if self._travel_startup_delay is not None: - attr[CONF_TRAVEL_STARTUP_DELAY] = self._travel_startup_delay - if self._tilt_startup_delay is not None: - attr[CONF_TILT_STARTUP_DELAY] = self._tilt_startup_delay - return attr - - @property - def current_cover_position(self) -> int | None: - """Return the current position of the cover.""" - current_position = self.travel_calc.current_position() - return 100 - current_position if current_position is not None else None - - @property - def current_cover_tilt_position(self) -> int | None: - """Return the current tilt of the cover.""" - if self._has_tilt_support(): - current_position = self.tilt_calc.current_position() - return 100 - current_position if current_position is not None else None - return None - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return ( - self.travel_calc.is_traveling() - and self.travel_calc.travel_direction == TravelStatus.DIRECTION_UP - ) or ( - self._has_tilt_support() - and self.tilt_calc.is_traveling() - and self.tilt_calc.travel_direction == TravelStatus.DIRECTION_UP - ) - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return ( - self.travel_calc.is_traveling() - and self.travel_calc.travel_direction == TravelStatus.DIRECTION_DOWN - ) or ( - self._has_tilt_support() - and self.tilt_calc.is_traveling() - and self.tilt_calc.travel_direction == TravelStatus.DIRECTION_DOWN + hass = platform.hass + + if not hass.services.has_service(DOMAIN, SERVICE_START_CALIBRATION): + + async def _handle_start_calibration(call): + entity_id = call.data["entity_id"] + entity = resolve_entity(hass, entity_id) + data = {k: v for k, v in call.data.items() if k != "entity_id"} + await entity.start_calibration(**data) + + hass.services.async_register( + DOMAIN, + SERVICE_START_CALIBRATION, + _handle_start_calibration, + schema=vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Required("attribute"): vol.In(CALIBRATABLE_ATTRIBUTES), + vol.Required("timeout"): vol.All( + vol.Coerce(float), vol.Range(min=1) + ), + vol.Optional("direction"): vol.In(["open", "close"]), + } + ), ) - @property - def is_closed(self): - """Return if the cover is closed.""" - if not self._has_tilt_support(): - return self.travel_calc.is_closed() - - return self.travel_calc.is_closed() and self.tilt_calc.is_closed() - - @property - def assumed_state(self): - """Return True because covers can be stopped midway.""" - return True - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + if not hass.services.has_service(DOMAIN, SERVICE_STOP_CALIBRATION): + + async def _handle_stop_calibration(call): + entity_id = call.data["entity_id"] + entity = resolve_entity(hass, entity_id) + data = {k: v for k, v in call.data.items() if k != "entity_id"} + return await entity.stop_calibration(**data) + + hass.services.async_register( + DOMAIN, + SERVICE_STOP_CALIBRATION, + _handle_stop_calibration, + schema=vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Optional("cancel", default=False): cv.boolean, + } + ), + supports_response=SupportsResponse.OPTIONAL, ) - if self.current_cover_position is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._has_tilt_support(): - supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - ) - if self.current_cover_tilt_position is not None: - supported_features |= CoverEntityFeature.SET_TILT_POSITION - - return supported_features - - async def async_set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - if ATTR_POSITION in kwargs: - position = kwargs[ATTR_POSITION] - _LOGGER.debug("async_set_cover_position: %d", position) - await self.set_position(position) - - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - if ATTR_TILT_POSITION in kwargs: - position = kwargs[ATTR_TILT_POSITION] - _LOGGER.debug("async_set_cover_tilt_position: %d", position) - await self.set_tilt_position(position) - - async def async_close_cover(self, **kwargs): - """Turn the device close.""" - _LOGGER.debug("async_close_cover") - - if self._input_mode == INPUT_MODE_TOGGLE and (self.is_closing or self.is_opening): - if self.is_closing: - _LOGGER.debug("async_close_cover :: toggle mode, already closing, treating as stop") - await self.async_stop_cover() - return - else: - _LOGGER.debug("async_close_cover :: toggle mode, currently opening, stopping first") - await self.async_stop_cover() - - current_travel_position = self.travel_calc.current_position() - if current_travel_position is None or current_travel_position < 100: - if self._startup_delay_task and not self._startup_delay_task.done(): - if self._last_command == SERVICE_OPEN_COVER: - _LOGGER.debug("async_close_cover :: direction change, cancelling startup delay") - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - else: - # EDGE CASE: Tilt→travel switch during startup delay - ignore startup_delay difference - _LOGGER.debug("async_close_cover :: startup delay already active, not restarting") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - travel_distance = 100 - (current_travel_position if current_travel_position is not None else 0) - movement_time = (travel_distance / 100.0) * self._travel_time_down - - _LOGGER.debug( - "async_close_cover :: travel_distance=%f%%, movement_time=%fs", - travel_distance, movement_time - ) - - self._last_command = SERVICE_CLOSE_COVER - - tilt_target = None - if self._has_tilt_support(): - tilt_distance = (movement_time / self._tilting_time_down) * 100.0 - current_tilt_position = self.tilt_calc.current_position() - tilt_target = min(100, current_tilt_position + tilt_distance) - _LOGGER.debug( - "async_close_cover :: tilt_distance=%f%%, new_tilt_pos=%f", - tilt_distance, tilt_target - ) - - await self._async_handle_command(SERVICE_CLOSE_COVER) - - if self._travel_startup_delay and self._travel_startup_delay > 0: - def start_movement(): - self.travel_calc.start_travel_down() - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(self._travel_startup_delay, start_movement) - ) - else: - self.travel_calc.start_travel_down() - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - - async def async_open_cover(self, **kwargs): - """Turn the device open.""" - _LOGGER.debug("async_open_cover") - - if self._input_mode == INPUT_MODE_TOGGLE and (self.is_opening or self.is_closing): - if self.is_opening: - _LOGGER.debug("async_open_cover :: toggle mode, already opening, treating as stop") - await self.async_stop_cover() - return - else: - _LOGGER.debug("async_open_cover :: toggle mode, currently closing, stopping first") - await self.async_stop_cover() - - current_travel_position = self.travel_calc.current_position() - if current_travel_position is None or current_travel_position > 0: - if self._startup_delay_task and not self._startup_delay_task.done(): - if self._last_command == SERVICE_CLOSE_COVER: - _LOGGER.debug("async_open_cover :: direction change, cancelling startup delay") - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - else: - # EDGE CASE: Tilt→travel switch during startup delay - ignore startup_delay difference - _LOGGER.debug("async_open_cover :: startup delay already active, not restarting") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - travel_distance = (current_travel_position if current_travel_position is not None else 100) - movement_time = (travel_distance / 100.0) * self._travel_time_up - - _LOGGER.debug( - "async_open_cover :: travel_distance=%f%%, movement_time=%fs", - travel_distance, movement_time - ) - - self._last_command = SERVICE_OPEN_COVER - - tilt_target = None - if self._has_tilt_support(): - tilt_distance = (movement_time / self._tilting_time_up) * 100.0 - current_tilt_position = self.tilt_calc.current_position() - tilt_target = max(0, current_tilt_position - tilt_distance) - _LOGGER.debug( - "async_open_cover :: tilt_distance=%f%%, new_tilt_pos=%f", - tilt_distance, tilt_target - ) - - await self._async_handle_command(SERVICE_OPEN_COVER) - - if self._travel_startup_delay and self._travel_startup_delay > 0: - def start_movement(): - self.travel_calc.start_travel_up() - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(self._travel_startup_delay, start_movement) - ) - else: - self.travel_calc.start_travel_up() - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - - async def async_close_cover_tilt(self, **kwargs): - """Turn the device close.""" - _LOGGER.debug("async_close_cover_tilt") - - if self._startup_delay_task and not self._startup_delay_task.done(): - if self._last_command == SERVICE_OPEN_COVER: - _LOGGER.debug("async_close_cover_tilt :: direction change, cancelling startup delay") - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - else: - # EDGE CASE: Travel→tilt switch during startup delay - ignore startup_delay difference - _LOGGER.debug("async_close_cover_tilt :: startup delay already active, not restarting") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - self._stop_travel_if_traveling() - - current_tilt_position = self.tilt_calc.current_position() - if current_tilt_position is None or current_tilt_position < 100: - tilt_distance = 100 - (current_tilt_position if current_tilt_position is not None else 0) - movement_time = (tilt_distance / 100.0) * self._tilting_time_down - - travel_target = None - if self._travel_moves_with_tilt: - travel_distance = (movement_time / self._travel_time_down) * 100.0 - current_travel_position = self.travel_calc.current_position() - travel_target = min(100, current_travel_position + travel_distance) - - _LOGGER.debug( - "async_close_cover_tilt :: tilt_distance=%f%%, movement_time=%fs, travel_distance=%f%%, new_travel_pos=%s", - tilt_distance, movement_time, - (movement_time / self._travel_time_down) * 100.0 if self._travel_moves_with_tilt else 0, - travel_target if travel_target is not None else "N/A" - ) - - self._last_command = SERVICE_CLOSE_COVER - - await self._async_handle_command(SERVICE_CLOSE_COVER) - - if self._tilt_startup_delay and self._tilt_startup_delay > 0: - def start_movement(): - self.tilt_calc.start_travel_down() - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(self._tilt_startup_delay, start_movement) - ) - else: - self.tilt_calc.start_travel_down() - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - - async def async_open_cover_tilt(self, **kwargs): - """Turn the device open.""" - _LOGGER.debug("async_open_cover_tilt") - - if self._startup_delay_task and not self._startup_delay_task.done(): - if self._last_command == SERVICE_CLOSE_COVER: - _LOGGER.debug("async_open_cover_tilt :: direction change, cancelling startup delay") - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - else: - # EDGE CASE: Travel→tilt switch during startup delay - ignore startup_delay difference - _LOGGER.debug("async_open_cover_tilt :: startup delay already active, not restarting") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - self._stop_travel_if_traveling() - - current_tilt_position = self.tilt_calc.current_position() - if current_tilt_position is None or current_tilt_position > 0: - tilt_distance = (current_tilt_position if current_tilt_position is not None else 100) - movement_time = (tilt_distance / 100.0) * self._tilting_time_up - - travel_target = None - if self._travel_moves_with_tilt: - travel_distance = (movement_time / self._travel_time_up) * 100.0 - current_travel_position = self.travel_calc.current_position() - travel_target = max(0, current_travel_position - travel_distance) - - _LOGGER.debug( - "async_open_cover_tilt :: tilt_distance=%f%%, movement_time=%fs, travel_distance=%f%%, new_travel_pos=%s", - tilt_distance, movement_time, - (movement_time / self._travel_time_up) * 100.0 if self._travel_moves_with_tilt else 0, - travel_target if travel_target is not None else "N/A" - ) - - self._last_command = SERVICE_OPEN_COVER - - await self._async_handle_command(SERVICE_OPEN_COVER) - - if self._tilt_startup_delay and self._tilt_startup_delay > 0: - def start_movement(): - self.tilt_calc.start_travel_up() - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(self._tilt_startup_delay, start_movement) - ) - else: - self.tilt_calc.start_travel_up() - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - - async def async_stop_cover(self, **kwargs): - """Turn the device stop.""" - _LOGGER.debug("async_stop_cover") - - was_active = ( - self.is_opening or self.is_closing - or (self._startup_delay_task and not self._startup_delay_task.done()) - or (self._delay_task and not self._delay_task.done()) - ) - - self._cancel_startup_delay_task() - self._cancel_delay_task() - self._handle_stop() - self._enforce_tilt_constraints() - - if was_active or self._input_mode != INPUT_MODE_TOGGLE: - await self._async_handle_command(SERVICE_STOP_COVER) - self._last_command = None - - async def set_position(self, position): - """Move cover to a designated position.""" - _LOGGER.debug("set_position") - - current_travel_position = self.travel_calc.current_position() - new_travel_position = 100 - position - _LOGGER.debug( - "set_position :: current_position: %d, new_position: %d", - current_travel_position, - position, - ) - command = None - if current_travel_position is None or new_travel_position > current_travel_position: - command = SERVICE_CLOSE_COVER - travel_time = self._travel_time_down - tilt_time = self._tilting_time_down if self._has_tilt_support() else None - startup_delay = self._travel_startup_delay - elif new_travel_position < current_travel_position: - command = SERVICE_OPEN_COVER - travel_time = self._travel_time_up - tilt_time = self._tilting_time_up if self._has_tilt_support() else None - startup_delay = self._travel_startup_delay - else: - return - - if command is not None: - is_direction_change = False - - if self._last_command is not None and self._last_command != command: - is_direction_change = True - _LOGGER.debug("set_position :: direction change detected (%s → %s)", self._last_command, command) - - # EDGE CASE: User adjusts target position during startup (e.g., 50%→60%) - # We don't restart the delay since motor is already starting up - if (self._startup_delay_task and not self._startup_delay_task.done() - and not is_direction_change): - _LOGGER.debug("set_position :: startup delay already active for same direction, not restarting") - return - - if is_direction_change and self._startup_delay_task and not self._startup_delay_task.done(): - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - - if is_direction_change and self.travel_calc.is_traveling(): - _LOGGER.debug("set_position :: stopping active travel movement") - self.travel_calc.stop() - self.stop_auto_updater() - if self._has_tilt_support() and self.tilt_calc.is_traveling(): - self.tilt_calc.stop() - await self._async_handle_command(SERVICE_STOP_COVER) - - current_travel_position = self.travel_calc.current_position() - _LOGGER.debug("set_position :: position after stop: %d", 100 - current_travel_position) - - if new_travel_position == current_travel_position: - _LOGGER.debug("set_position :: already at target after stop, no movement needed") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - travel_distance = abs(new_travel_position - current_travel_position) - movement_time = (travel_distance / 100.0) * travel_time - - is_to_endpoint = (new_travel_position == 0 or new_travel_position == 100) - if ( - self._min_movement_time is not None - and self._min_movement_time > 0 - and not is_to_endpoint - and movement_time < self._min_movement_time - ): - _LOGGER.info( - "set_position :: movement too short (%fs < %fs), ignoring - from %d%% to %d%%", - movement_time, - self._min_movement_time, - 100 - current_travel_position, - position, - ) - self.async_write_ha_state() - return - - _LOGGER.debug( - "set_position :: travel_distance=%f%%, movement_time=%fs", - travel_distance, movement_time - ) - - self._last_command = command - - tilt_target = None - if self._has_tilt_support(): - tilt_distance = (movement_time / tilt_time) * 100.0 - current_tilt_position = self.tilt_calc.current_position() - if command == SERVICE_CLOSE_COVER: - tilt_target = min(100, current_tilt_position + tilt_distance) - else: - tilt_target = max(0, current_tilt_position - tilt_distance) - - _LOGGER.debug( - "set_position :: tilt_distance=%f%%, new_tilt_pos=%f", - tilt_distance, tilt_target - ) - - await self._async_handle_command(command) - - if startup_delay and startup_delay > 0: - def start_movement(): - self.travel_calc.start_travel(new_travel_position) - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(startup_delay, start_movement) - ) - else: - self.travel_calc.start_travel(new_travel_position) - if tilt_target is not None: - self.tilt_calc.start_travel(int(tilt_target)) - self.start_auto_updater() - return - - async def set_tilt_position(self, position): - """Move cover tilt to a designated position.""" - _LOGGER.debug("set_tilt_position") - - current_tilt_position = self.tilt_calc.current_position() - new_tilt_position = 100 - position - _LOGGER.debug( - "set_tilt_position :: current_position: %d, new_position: %d", - current_tilt_position, - new_tilt_position, - ) - command = None - if current_tilt_position is None or new_tilt_position > current_tilt_position: - command = SERVICE_CLOSE_COVER - tilt_time = self._tilting_time_down - travel_time = self._travel_time_down - startup_delay = self._tilt_startup_delay - elif new_tilt_position < current_tilt_position: - command = SERVICE_OPEN_COVER - tilt_time = self._tilting_time_up - travel_time = self._travel_time_up - startup_delay = self._tilt_startup_delay - else: - return - - if command is not None: - is_direction_change = False - - if self._last_command is not None and self._last_command != command: - is_direction_change = True - _LOGGER.debug("set_tilt_position :: direction change detected (%s → %s)", self._last_command, command) - - # EDGE CASE: User adjusts tilt target during startup (e.g., 50%→60% tilt) - # We don't restart the delay since motor is already starting up - if (self._startup_delay_task and not self._startup_delay_task.done() - and not is_direction_change): - _LOGGER.debug("set_tilt_position :: startup delay already active for same direction, not restarting") - return - - if is_direction_change and self._startup_delay_task and not self._startup_delay_task.done(): - self._cancel_startup_delay_task() - await self._async_handle_command(SERVICE_STOP_COVER) - - if is_direction_change: - if self.tilt_calc.is_traveling(): - _LOGGER.debug("set_tilt_position :: stopping active tilt movement") - self.tilt_calc.stop() - if self.travel_calc.is_traveling(): - self.travel_calc.stop() - self.stop_auto_updater() - await self._async_handle_command(SERVICE_STOP_COVER) - - current_tilt_position = self.tilt_calc.current_position() - _LOGGER.debug("set_tilt_position :: tilt position after stop: %d", 100 - current_tilt_position) - - if new_tilt_position == current_tilt_position: - _LOGGER.debug("set_tilt_position :: already at target after stop, no movement needed") - return - - relay_was_on = self._cancel_delay_task() - if relay_was_on: - await self._async_handle_command(SERVICE_STOP_COVER) - - if not is_direction_change: - self._stop_travel_if_traveling() - - tilt_distance = abs(new_tilt_position - current_tilt_position) - movement_time = (tilt_distance / 100.0) * tilt_time - - travel_target = None - if self._travel_moves_with_tilt: - travel_distance = (movement_time / travel_time) * 100.0 - current_travel_position = self.travel_calc.current_position() - if command == SERVICE_CLOSE_COVER: - travel_target = min(100, current_travel_position + travel_distance) - else: - travel_target = max(0, current_travel_position - travel_distance) - - is_to_endpoint = (new_tilt_position == 0 or new_tilt_position == 100) - if ( - self._min_movement_time is not None - and self._min_movement_time > 0 - and not is_to_endpoint - and movement_time < self._min_movement_time - ): - _LOGGER.info( - "set_tilt_position :: movement too short (%fs < %fs), ignoring - from %d%% to %d%%", - movement_time, - self._min_movement_time, - 100 - current_tilt_position, - position, - ) - self.async_write_ha_state() - return - - self._last_command = command - - _LOGGER.debug( - "set_tilt_position :: tilt_distance=%f%%, movement_time=%fs, travel_distance=%f%%, new_travel_pos=%s", - tilt_distance, movement_time, - (movement_time / travel_time) * 100.0 if self._travel_moves_with_tilt else 0, - travel_target if travel_target is not None else "N/A" - ) - - await self._async_handle_command(command) - - if startup_delay and startup_delay > 0: - def start_movement(): - self.tilt_calc.start_travel(new_tilt_position) - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - - self._startup_delay_task = self.hass.async_create_task( - self._execute_with_startup_delay(startup_delay, start_movement) - ) - else: - self.tilt_calc.start_travel(new_tilt_position) - if travel_target is not None: - self.travel_calc.start_travel(int(travel_target)) - self.start_auto_updater() - return - - def start_auto_updater(self): - """Start the autoupdater to update HASS while cover is moving.""" - _LOGGER.debug("start_auto_updater") - if self._unsubscribe_auto_updater is None: - _LOGGER.debug("init _unsubscribe_auto_updater") - interval = timedelta(seconds=0.1) - self._unsubscribe_auto_updater = async_track_time_interval( - self.hass, self.auto_updater_hook, interval - ) - - @callback - def auto_updater_hook(self, now): - """Call for the autoupdater.""" - _LOGGER.debug("auto_updater_hook") - self.async_schedule_update_ha_state() - if self.position_reached(): - _LOGGER.debug("auto_updater_hook :: position_reached") - self.stop_auto_updater() - self.hass.async_create_task(self.auto_stop_if_necessary()) - - def stop_auto_updater(self): - """Stop the autoupdater.""" - _LOGGER.debug("stop_auto_updater") - if self._unsubscribe_auto_updater is not None: - self._unsubscribe_auto_updater() - self._unsubscribe_auto_updater = None - - def position_reached(self): - """Return if cover has reached its final position.""" - return self.travel_calc.position_reached() and ( - not self._has_tilt_support() or self.tilt_calc.position_reached() - ) - - def _has_tilt_support(self): - """Return if cover has tilt support.""" - return self._tilting_time_down is not None and self._tilting_time_up is not None - - def _enforce_tilt_constraints(self): - """Enforce tilt position constraints at travel boundaries.""" - if not self._has_tilt_support(): - return - - if not self._travel_moves_with_tilt: - return - - current_travel = self.travel_calc.current_position() - current_tilt = self.tilt_calc.current_position() - - if current_travel == 0 and current_tilt != 0: - _LOGGER.debug( - "_enforce_tilt_constraints :: Travel at 0%%, forcing tilt to 0%% (was %d%%)", - current_tilt - ) - self.tilt_calc.set_position(0) - - elif current_travel == 100 and current_tilt != 100: - _LOGGER.debug( - "_enforce_tilt_constraints :: Travel at 100%%, forcing tilt to 100%% (was %d%%)", - current_tilt - ) - self.tilt_calc.set_position(100) - - async def auto_stop_if_necessary(self): - """Do auto stop if necessary.""" - if self.position_reached(): - _LOGGER.debug("auto_stop_if_necessary :: calling stop command") - self.travel_calc.stop() - if self._has_tilt_support(): - self.tilt_calc.stop() - - self._enforce_tilt_constraints() - - current_travel = self.travel_calc.current_position() - if self._travel_delay_at_end is not None and self._travel_delay_at_end > 0 and (current_travel == 0 or current_travel == 100): - _LOGGER.debug( - "auto_stop_if_necessary :: at endpoint (position=%d), delaying relay stop by %fs", - current_travel, - self._travel_delay_at_end - ) - self._delay_task = self.hass.async_create_task( - self._delayed_stop(self._travel_delay_at_end) - ) - else: - await self._async_handle_command(SERVICE_STOP_COVER) - self._last_command = None - - async def _delayed_stop(self, delay): - """Stop the relay after a delay.""" - _LOGGER.debug("_delayed_stop :: waiting %fs before stopping relay", delay) - try: - await sleep(delay) - _LOGGER.debug("_delayed_stop :: delay complete, stopping relay") - await self._async_handle_command(SERVICE_STOP_COVER) - self._last_command = None - self._delay_task = None - except asyncio.CancelledError: - _LOGGER.debug("_delayed_stop :: delay cancelled") - self._delay_task = None - raise - - async def set_known_position(self, **kwargs): - """We want to do a few things when we get a position""" - position = kwargs[ATTR_POSITION] - was_active = self.is_opening or self.is_closing - self._handle_stop() - if was_active or self._input_mode != INPUT_MODE_TOGGLE: - await self._async_handle_command(SERVICE_STOP_COVER) - self.travel_calc.set_position(position) - self._enforce_tilt_constraints() - self._last_command = None - - async def set_known_tilt_position(self, **kwargs): - """We want to do a few things when we get a position""" - position = kwargs[ATTR_TILT_POSITION] - was_active = self.is_opening or self.is_closing - if was_active or self._input_mode != INPUT_MODE_TOGGLE: - await self._async_handle_command(SERVICE_STOP_COVER) - self.tilt_calc.set_position(position) - self._last_command = None - - async def _async_handle_command(self, command, *args): - if command == SERVICE_CLOSE_COVER: - cmd = "DOWN" - self._state = False - if self._cover_entity_id is not None: - await self.hass.services.async_call( - "cover", - "close_cover", - {"entity_id": self._cover_entity_id}, - False, - ) - else: - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._close_switch_entity_id}, - False, - ) - if self._stop_switch_entity_id is not None: - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._stop_switch_entity_id}, - False, - ) - - if self._input_mode in (INPUT_MODE_PULSE, INPUT_MODE_TOGGLE): - await sleep(self._pulse_time) - - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - - elif command == SERVICE_OPEN_COVER: - cmd = "UP" - self._state = True - if self._cover_entity_id is not None: - await self.hass.services.async_call( - "cover", - "open_cover", - {"entity_id": self._cover_entity_id}, - False, - ) - else: - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._open_switch_entity_id}, - False, - ) - if self._stop_switch_entity_id is not None: - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._stop_switch_entity_id}, - False, - ) - if self._input_mode in (INPUT_MODE_PULSE, INPUT_MODE_TOGGLE): - await sleep(self._pulse_time) - - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) - - elif command == SERVICE_STOP_COVER: - cmd = "STOP" - self._state = True - if self._cover_entity_id is not None: - await self.hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": self._cover_entity_id}, - False, - ) - elif self._input_mode == INPUT_MODE_TOGGLE: - # Toggle mode: pulse the last-used direction button to stop - if self._last_command == SERVICE_CLOSE_COVER: - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._close_switch_entity_id}, - False, - ) - await sleep(self._pulse_time) - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - elif self._last_command == SERVICE_OPEN_COVER: - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._open_switch_entity_id}, - False, - ) - await sleep(self._pulse_time) - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) - else: - _LOGGER.debug( - "_async_handle_command :: STOP in toggle mode with no last command, skipping" - ) - else: - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._close_switch_entity_id}, - False, - ) - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._open_switch_entity_id}, - False, - ) - if self._stop_switch_entity_id is not None: - await self.hass.services.async_call( - "homeassistant", - "turn_on", - {"entity_id": self._stop_switch_entity_id}, - False, - ) - - if self._input_mode == INPUT_MODE_PULSE: - await sleep(self._pulse_time) - - await self.hass.services.async_call( - "homeassistant", - "turn_off", - {"entity_id": self._stop_switch_entity_id}, - False, - ) - - _LOGGER.debug("_async_handle_command :: %s", cmd) - - self.async_write_ha_state() diff --git a/custom_components/cover_time_based/cover_base.py b/custom_components/cover_time_based/cover_base.py new file mode 100644 index 0000000..05cb523 --- /dev/null +++ b/custom_components/cover_time_based/cover_base.py @@ -0,0 +1,1638 @@ +"""Base class for time-based cover entities.""" + +import asyncio +import logging +from abc import abstractmethod +from asyncio import sleep +from datetime import timedelta + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import ( + async_call_later, + async_track_state_change_event, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity +from .travel_calculator import TravelCalculator, TravelStatus + +from .calibration import CalibrationState +from .cover_calibration import CalibrationMixin +from .const import ( + CONF_ENDPOINT_RUNON_TIME, + CONF_MIN_MOVEMENT_TIME, + CONF_TILT_MODE, + CONF_TILT_STARTUP_DELAY, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, +) +from .tilt_strategies.planning import ( + calculate_pre_step_delay, + extract_coupled_tilt, + extract_coupled_travel, + has_travel_pre_step, +) + +_LOGGER = logging.getLogger(__name__) + + +class CoverTimeBased(CalibrationMixin, CoverEntity, RestoreEntity): + """Time-based cover with position tracking.""" + + def __init__( + self, + device_id, + name, + tilt_strategy, + travel_time_close, + travel_time_open, + tilt_time_close, + tilt_time_open, + travel_startup_delay, + tilt_startup_delay, + endpoint_runon_time, + min_movement_time, + tilt_open_switch=None, + tilt_close_switch=None, + tilt_stop_switch=None, + ): + """Initialize the cover.""" + self._unique_id = device_id + + self._tilt_strategy = tilt_strategy + self._travel_time_close = travel_time_close + self._travel_time_open = travel_time_open + self._tilting_time_close = tilt_time_close + self._tilting_time_open = tilt_time_open + self._travel_startup_delay = travel_startup_delay + self._tilt_startup_delay = tilt_startup_delay + self._endpoint_runon_time = endpoint_runon_time + self._min_movement_time = min_movement_time + self._tilt_open_switch_id = tilt_open_switch + self._tilt_close_switch_id = tilt_close_switch + self._tilt_stop_switch_id = tilt_stop_switch + + if name: + self._name = name + else: + self._name = device_id + + self._config_entry_id: str | None = None + self._calibration: CalibrationState | None = None + self._unsubscribe_auto_updater = None + self._delay_task = None + self._startup_delay_task = None + self._last_command = None + self._tilt_restore_target: int | None = None + self._tilt_restore_active: bool = False + self._pending_travel_target: int | None = None + self._pending_travel_command: str | None = None + self._pending_tilt_target: int | None = None + self._pending_tilt_command: str | None = None + self._triggered_externally = False + self._self_initiated_movement = True + self._state = True + self._pending_switch = {} + self._pending_switch_timers = {} + self._state_listener_unsubs = [] + + self.travel_calc = TravelCalculator( + self._travel_time_close, + self._travel_time_open, + ) + if self._tilting_time_close is not None and self._tilting_time_open is not None: + self.tilt_calc = TravelCalculator( + self._tilting_time_close, + self._tilting_time_open, + ) + + def _log(self, msg, *args): + """Log a debug message prefixed with the entity ID.""" + _LOGGER.debug("(%s) " + msg, self.entity_id, *args) + + # ----------------------------------------------------------------------- + # Lifecycle + # ----------------------------------------------------------------------- + + async def async_added_to_hass(self): + """Only cover's position and tilt matters.""" + old_state = await self.async_get_last_state() + self._log("async_added_to_hass :: oldState %s", old_state) + pos = ( + old_state.attributes.get(ATTR_CURRENT_POSITION) + if old_state is not None + else None + ) + if old_state is not None and self.travel_calc is not None and pos is not None: + self.travel_calc.set_position(int(pos)) + + tilt_pos = old_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if self._has_tilt_support() and tilt_pos is not None: + self.tilt_calc.set_position(int(tilt_pos)) + + # Register state change listeners for switch entities + for attr in ( + "_open_switch_entity_id", + "_close_switch_entity_id", + "_stop_switch_entity_id", + "_tilt_open_switch_id", + "_tilt_close_switch_id", + "_tilt_stop_switch_id", + ): + entity_id = getattr(self, attr, None) + if entity_id: + self._state_listener_unsubs.append( + async_track_state_change_event( + self.hass, + [entity_id], + self._async_switch_state_changed, + ) + ) + + async def async_will_remove_from_hass(self): + """Clean up when entity is removed.""" + self.stop_auto_updater() + for unsub in self._state_listener_unsubs: + unsub() + self._state_listener_unsubs.clear() + for timer in self._pending_switch_timers.values(): + timer() + self._pending_switch_timers.clear() + if self._calibration is not None: + if ( + self._calibration.timeout_task + and not self._calibration.timeout_task.done() + ): + self._calibration.timeout_task.cancel() + if ( + self._calibration.automation_task + and not self._calibration.automation_task.done() + ): + self._calibration.automation_task.cancel() + self._calibration = None + + # ----------------------------------------------------------------------- + # Properties + # ----------------------------------------------------------------------- + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def unique_id(self): + """Return the unique id.""" + return "cover_timebased_uuid_" + self._unique_id + + @property + def device_class(self): + """Return the device class of the cover.""" + return None + + @property + def available(self) -> bool: + """Return True if the cover is properly configured and available.""" + return len(self._get_missing_configuration()) == 0 + + @property + def assumed_state(self): + """Return True because covers can be stopped midway.""" + return True + + @property + def supported_features(self) -> CoverEntityFeature: + """Flag supported features.""" + supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + if self._has_tilt_support(): + supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + return supported_features + + @property + def current_cover_position(self) -> int | None: + """Return the current position of the cover.""" + return self.travel_calc.current_position() + + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt of the cover.""" + if self._has_tilt_support(): + return self.tilt_calc.current_position() + return None + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return ( + self.travel_calc.is_traveling() + and self.travel_calc.travel_direction == TravelStatus.DIRECTION_UP + ) or ( + self._has_tilt_support() + and self.tilt_calc.is_traveling() + and self.tilt_calc.travel_direction == TravelStatus.DIRECTION_UP + ) + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return ( + self.travel_calc.is_traveling() + and self.travel_calc.travel_direction == TravelStatus.DIRECTION_DOWN + ) or ( + self._has_tilt_support() + and self.tilt_calc.is_traveling() + and self.tilt_calc.travel_direction == TravelStatus.DIRECTION_DOWN + ) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if not self._has_tilt_support(): + return self.travel_calc.is_closed() + + return self.travel_calc.is_closed() and self.tilt_calc.is_closed() + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attr = {} + if self._tilt_strategy is not None: + attr[CONF_TILT_MODE] = self._tilt_strategy.name + if self._travel_time_close is not None: + attr[CONF_TRAVEL_TIME_CLOSE] = self._travel_time_close + if self._travel_time_open is not None: + attr[CONF_TRAVEL_TIME_OPEN] = self._travel_time_open + if self._tilting_time_close is not None: + attr[CONF_TILT_TIME_CLOSE] = self._tilting_time_close + if self._tilting_time_open is not None: + attr[CONF_TILT_TIME_OPEN] = self._tilting_time_open + if self._travel_startup_delay is not None: + attr[CONF_TRAVEL_STARTUP_DELAY] = self._travel_startup_delay + if self._tilt_startup_delay is not None: + attr[CONF_TILT_STARTUP_DELAY] = self._tilt_startup_delay + if self._endpoint_runon_time is not None: + attr[CONF_ENDPOINT_RUNON_TIME] = self._endpoint_runon_time + if self._min_movement_time is not None: + attr[CONF_MIN_MOVEMENT_TIME] = self._min_movement_time + if self._calibration is not None: + attr["calibration_active"] = True + attr["calibration_attribute"] = self._calibration.attribute + if self._calibration.final_step: + attr["calibration_final_step"] = True + elif self._calibration.step_count > 0: + attr["calibration_step"] = self._calibration.step_count + return attr + + # ----------------------------------------------------------------------- + # Public HA service handlers + # ----------------------------------------------------------------------- + + async def async_close_cover(self, **kwargs): + """Close the cover fully.""" + self._require_configured() + self._log("async_close_cover") + if self.is_opening: + self._log("async_close_cover :: currently opening, stopping first") + await self.async_stop_cover() + await self._async_move_to_endpoint(target=0) + + async def async_open_cover(self, **kwargs): + """Open the cover fully.""" + self._require_configured() + self._log("async_open_cover") + if self.is_closing: + self._log("async_open_cover :: currently closing, stopping first") + await self.async_stop_cover() + await self._async_move_to_endpoint(target=100) + + async def async_stop_cover(self, **kwargs): + """Turn the device stop.""" + self._require_configured() + self._log("async_stop_cover") + tilt_restore_was_active = self._tilt_restore_active + tilt_pre_step_was_active = ( + self._pending_travel_target is not None + or self._pending_tilt_target is not None + ) + self._cancel_startup_delay_task() + self._cancel_delay_task() + self._handle_stop() + if self._has_tilt_support(): + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + if not self._triggered_externally: + await self._send_stop() + if ( + tilt_restore_was_active or tilt_pre_step_was_active + ) and self._has_tilt_motor(): + await self._send_tilt_stop() + self.async_write_ha_state() + self._last_command = None + + async def async_close_cover_tilt(self, **kwargs): + """Tilt the cover fully closed.""" + self._log("async_close_cover_tilt") + await self._async_move_tilt_to_endpoint(target=0) + + async def async_open_cover_tilt(self, **kwargs): + """Tilt the cover fully open.""" + self._log("async_open_cover_tilt") + await self._async_move_tilt_to_endpoint(target=100) + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self._require_configured() + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._log("async_set_cover_position: %d", position) + await self.set_position(position) + + async def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + position = kwargs[ATTR_TILT_POSITION] + self._log("async_set_cover_tilt_position: %d", position) + await self.set_tilt_position(position) + + async def set_known_position(self, **kwargs): + """Set the cover to a known position (0=closed, 100=open).""" + position = kwargs[ATTR_POSITION] + self._handle_stop() + self.travel_calc.set_position(position) + if self._has_tilt_support(): + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + self.async_write_ha_state() + + async def set_known_tilt_position(self, **kwargs): + """Set the tilt to a known position (0=closed, 100=open).""" + if not self._has_tilt_support(): + return + position = kwargs[ATTR_TILT_POSITION] + self.tilt_calc.set_position(position) + self.async_write_ha_state() + + # ----------------------------------------------------------------------- + # Movement orchestration + # ----------------------------------------------------------------------- + + async def _async_move_to_endpoint(self, target): + """Move cover to an endpoint (0=fully closed, 100=fully open).""" + self._self_initiated_movement = not self._triggered_externally + await self._abandon_active_lifecycle() + + closing = target == 0 + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + opposite_command = SERVICE_OPEN_COVER if closing else SERVICE_CLOSE_COVER + + # Check startup delay conflicts BEFORE position check, since during + # startup delay the position hasn't started changing yet. + if self._startup_delay_task and not self._startup_delay_task.done(): + if self._last_command == opposite_command: + self._log( + "_async_move_to_endpoint :: direction change, cancelling startup delay" + ) + self._cancel_startup_delay_task() + await self._async_handle_command(SERVICE_STOP_COVER) + self._last_command = None + return + else: + self._log( + "_async_move_to_endpoint :: startup delay already active, not restarting" + ) + return + + current = self.travel_calc.current_position() + if current is not None and current == target: + # Resync: send command + endpoint run-on even though tracker + # says we're already there. Physical cover may need resyncing. + self._cancel_delay_task() + self._last_command = command + await self._async_handle_command(command) + if self._endpoint_runon_time is not None and self._endpoint_runon_time > 0: + self._delay_task = self.hass.async_create_task( + self._delayed_stop(self._endpoint_runon_time) + ) + else: + await self._async_handle_command(SERVICE_STOP_COVER) + return + + relay_was_on = self._cancel_delay_task() + if relay_was_on: + await self._async_handle_command(SERVICE_STOP_COVER) + + if current is None: + # Position unknown — assume opposite endpoint so full travel occurs + current = 100 if closing else 0 + self.travel_calc.update_position(current) + + travel_distance = abs(target - current) + travel_time = self._require_travel_time(closing) + movement_time = (travel_distance / 100.0) * travel_time + + self._log( + "_async_move_to_endpoint :: target=%d, travel_distance=%f%%, movement_time=%fs", + target, + travel_distance, + movement_time, + ) + + self._last_command = command + + tilt_target = None + pre_step_delay = 0.0 + if not self._triggered_externally: + # Only plan tilt for self-initiated movements — external movements + # can't control relay sequencing (the relay is already on). + current_tilt = ( + self.tilt_calc.current_position() if self._tilt_strategy else None + ) + tilt_target, pre_step_delay, started = await self._plan_tilt_for_travel( + target, command, current, current_tilt + ) + if started: + return + + await self._async_handle_command(command) + coupled_calc = self.tilt_calc if tilt_target is not None else None + self._begin_movement( + target, + tilt_target, + self.travel_calc, + coupled_calc, + self._travel_startup_delay, + pre_step_delay, + ) + + async def _async_move_tilt_to_endpoint(self, target): + """Move tilt to an endpoint (0=fully closed, 100=fully open).""" + await self._abandon_active_lifecycle() + + closing = target == 0 + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + opposite_command = SERVICE_OPEN_COVER if closing else SERVICE_CLOSE_COVER + + if self._startup_delay_task and not self._startup_delay_task.done(): + if self._last_command == opposite_command: + self._log( + "_async_move_tilt_to_endpoint :: direction change, cancelling startup delay" + ) + self._cancel_startup_delay_task() + await self._async_handle_command(SERVICE_STOP_COVER) + if ( + self._tilt_strategy is not None + and self._tilt_strategy.uses_tilt_motor + ): + await self._send_tilt_stop() + else: + self._log( + "_async_move_tilt_to_endpoint :: startup delay already active, not restarting" + ) + return + + relay_was_on = self._cancel_delay_task() + if relay_was_on: + await self._async_handle_command(SERVICE_STOP_COVER) + if self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor: + await self._send_tilt_stop() + + self._stop_travel_if_traveling() + + current_tilt = self.tilt_calc.current_position() + if current_tilt is not None and current_tilt == target: + return + + if current_tilt is None: + current_tilt = 100 if closing else 0 + self.tilt_calc.update_position(current_tilt) + + tilt_distance = abs(target - current_tilt) + tilt_time = self._tilting_time_close if closing else self._tilting_time_open + movement_time = (tilt_distance / 100.0) * tilt_time + + travel_target = None + pre_step_delay = 0.0 + needs_travel_pre_step = False + if self._tilt_strategy is not None: + current_pos = self.travel_calc.current_position() + if current_pos is not None: + steps = self._tilt_strategy.plan_move_tilt( + target, current_pos, current_tilt + ) + travel_target = extract_coupled_travel(steps) + pre_step_delay = calculate_pre_step_delay( + steps, self._tilt_strategy, self.tilt_calc, self.travel_calc + ) + if self._tilt_strategy.uses_tilt_motor and has_travel_pre_step(steps): + needs_travel_pre_step = True + + self._log( + "_async_move_tilt_to_endpoint :: target=%d, tilt_distance=%f%%," + " movement_time=%fs, travel_pos=%s, travel_pre_step=%s", + target, + tilt_distance, + movement_time, + travel_target if travel_target is not None else "N/A", + needs_travel_pre_step, + ) + + if needs_travel_pre_step and travel_target is not None: + await self._start_travel_pre_step(travel_target, target, command) + return + + self._last_command = command + if self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor: + if closing: + await self._send_tilt_close() + else: + await self._send_tilt_open() + else: + await self._async_handle_command(command) + self._begin_movement( + target, + travel_target, + self.tilt_calc, + self.travel_calc, + self._tilt_startup_delay, + pre_step_delay, + ) + + async def set_position(self, position): + """Move cover to a designated position.""" + self._self_initiated_movement = not self._triggered_externally + await self._abandon_active_lifecycle() + current = self.travel_calc.current_position() + target = position + self._log( + "set_position :: current: %s, target: %d", + current if current is not None else "None", + target, + ) + + if current is None: + # Position unknown — assume opposite endpoint so full travel occurs + closing = target <= 50 + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + current = 100 if closing else 0 + self.travel_calc.update_position(current) + elif target < current: + command = SERVICE_CLOSE_COVER + elif target > current: + command = SERVICE_OPEN_COVER + else: + return + + closing = command == SERVICE_CLOSE_COVER + + should_proceed, is_direction_change = await self._handle_pre_movement_checks( + command + ) + if not should_proceed: + return + + if is_direction_change and self.travel_calc.is_traveling(): + self._log("set_position :: stopping active travel movement") + self.travel_calc.stop() + self.stop_auto_updater() + if self._has_tilt_support() and self.tilt_calc.is_traveling(): + self.tilt_calc.stop() + await self._async_handle_command(SERVICE_STOP_COVER) + current = self.travel_calc.current_position() + if target == current: + return + + relay_was_on = self._cancel_delay_task() + if relay_was_on: + await self._async_handle_command(SERVICE_STOP_COVER) + + travel_time = self._require_travel_time(closing) + movement_time = (abs(target - current) / 100.0) * travel_time + + if self._is_movement_too_short(movement_time, target, current, "set_position"): + return + + self._last_command = command + + current_tilt = ( + self.tilt_calc.current_position() if self._tilt_strategy else None + ) + tilt_target, pre_step_delay, started = await self._plan_tilt_for_travel( + target, command, current, current_tilt + ) + if started: + return + + await self._async_handle_command(command) + coupled_calc = self.tilt_calc if tilt_target is not None else None + self._begin_movement( + target, + tilt_target, + self.travel_calc, + coupled_calc, + self._travel_startup_delay, + pre_step_delay, + ) + + async def set_tilt_position(self, position): + """Move cover tilt to a designated position.""" + await self._abandon_active_lifecycle() + current = self.tilt_calc.current_position() + target = position + self._log( + "set_tilt_position :: current: %s, target: %d", + current if current is not None else "None", + target, + ) + + if current is None: + closing = target <= 50 + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + current = 100 if closing else 0 + self.tilt_calc.update_position(current) + elif target < current: + command = SERVICE_CLOSE_COVER + elif target > current: + command = SERVICE_OPEN_COVER + else: + return + + closing = command == SERVICE_CLOSE_COVER + + should_proceed, is_direction_change = await self._handle_pre_movement_checks( + command + ) + if not should_proceed: + return + + if is_direction_change: + if self.tilt_calc.is_traveling(): + self.tilt_calc.stop() + if self.travel_calc.is_traveling(): + self.travel_calc.stop() + self.stop_auto_updater() + await self._async_handle_command(SERVICE_STOP_COVER) + if self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor: + await self._send_tilt_stop() + current = self.tilt_calc.current_position() + if target == current: + return + + relay_was_on = self._cancel_delay_task() + if relay_was_on: + await self._async_handle_command(SERVICE_STOP_COVER) + if self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor: + await self._send_tilt_stop() + + if not is_direction_change: + self._stop_travel_if_traveling() + + tilt_time = self._tilting_time_close if closing else self._tilting_time_open + movement_time = (abs(target - current) / 100.0) * tilt_time + + travel_target = None + pre_step_delay = 0.0 + needs_travel_pre_step = False + if self._tilt_strategy is not None: + current_pos = self.travel_calc.current_position() + if current is not None and current_pos is not None: + steps = self._tilt_strategy.plan_move_tilt(target, current_pos, current) + travel_target = extract_coupled_travel(steps) + pre_step_delay = calculate_pre_step_delay( + steps, self._tilt_strategy, self.tilt_calc, self.travel_calc + ) + if self._tilt_strategy.uses_tilt_motor and has_travel_pre_step(steps): + needs_travel_pre_step = True + + if self._is_movement_too_short( + movement_time, target, current, "set_tilt_position" + ): + return + + if needs_travel_pre_step and travel_target is not None: + await self._start_travel_pre_step(travel_target, target, command) + return + + self._last_command = command + + if self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor: + if closing: + await self._send_tilt_close() + else: + await self._send_tilt_open() + else: + await self._async_handle_command(command) + self._begin_movement( + target, + travel_target, + self.tilt_calc, + self.travel_calc, + self._tilt_startup_delay, + pre_step_delay, + ) + + async def _plan_tilt_for_travel( + self, target: int, command: str, current_pos, current_tilt + ) -> tuple[int | None, float, bool]: + """Plan tilt coupling for a travel movement. + + Returns (tilt_target, pre_step_delay, started_pre_step). + If started_pre_step is True, the caller should return immediately + because _start_tilt_pre_step has taken over the movement lifecycle. + """ + tilt_target = None + pre_step_delay = 0.0 + self._tilt_restore_target = None + + if self._tilt_strategy is None: + return tilt_target, pre_step_delay, False + + if current_pos is None or current_tilt is None: + return tilt_target, pre_step_delay, False + + steps = self._tilt_strategy.plan_move_position( + target, current_pos, current_tilt + ) + tilt_target = extract_coupled_tilt(steps) + pre_step_delay = calculate_pre_step_delay( + steps, self._tilt_strategy, self.tilt_calc, self.travel_calc + ) + + # Dual motor: tilt to safe position first, then travel + if ( + tilt_target is not None + and self._tilt_strategy.uses_tilt_motor + and current_tilt != tilt_target + ): + if target in (0, 100): + restore = target + elif self._tilt_strategy.allows_tilt_at_position(target): + restore = current_tilt + else: + restore = tilt_target # stay at safe position + await self._start_tilt_pre_step(tilt_target, target, command, restore) + return tilt_target, pre_step_delay, True + + # Dual motor: pre-step skipped, but still snap tilt to endpoint + if ( + tilt_target is not None + and self._tilt_strategy.uses_tilt_motor + and target in (0, 100) + and current_tilt != target + ): + self._tilt_restore_target = target + + # Shared motor with restore: save tilt for post-travel restore + if ( + tilt_target is not None + and self._tilt_strategy.restores_tilt + and not self._tilt_strategy.uses_tilt_motor + and target not in (0, 100) + ): + self._tilt_restore_target = current_tilt + + return tilt_target, pre_step_delay, False + + async def _handle_pre_movement_checks(self, command): + """Handle startup delay conflicts and relay delay before a movement. + + Returns (should_proceed, is_direction_change). + """ + is_direction_change = ( + self._last_command is not None and self._last_command != command + ) + + # If startup delay active for same direction, don't restart + if self._startup_delay_task and not self._startup_delay_task.done(): + if not is_direction_change: + self._log( + "_handle_pre_movement_checks :: startup delay active, skipping" + ) + return False, is_direction_change + self._log( + "_handle_pre_movement_checks :: direction change, cancelling startup delay" + ) + self._cancel_startup_delay_task() + await self._async_handle_command(SERVICE_STOP_COVER) + + return True, is_direction_change + + def _is_movement_too_short(self, movement_time, target, current, label): + """Check if movement time is below minimum. Returns True if movement should be skipped.""" + is_to_endpoint = target in (0, 100) + if ( + self._min_movement_time is not None + and self._min_movement_time > 0 + and not is_to_endpoint + and movement_time < self._min_movement_time + ): + _LOGGER.info( + "%s :: movement too short (%fs < %fs), ignoring - from %d%% to %d%%", + label, + movement_time, + self._min_movement_time, + current, + target, + ) + self.async_write_ha_state() + return True + return False + + def _require_configured(self) -> None: + """Raise if the cover is not properly configured.""" + missing = self._get_missing_configuration() + if missing: + raise HomeAssistantError( + f"Cover not configured: missing {', '.join(missing)}. " + "Please configure using the Cover Time Based card." + ) + + def _require_travel_time(self, closing: bool) -> float: + """Return travel time for the given direction, or raise if not configured.""" + travel_time = self._travel_time_close if closing else self._travel_time_open + if travel_time is None: + raise HomeAssistantError( + "Travel time not configured. Please configure travel times " + "using the Cover Time Based card." + ) + return travel_time + + def _are_entities_configured(self) -> bool: + """Return True if the required input entities are configured. + + Subclasses override this to check their specific entity IDs. + """ + return True + + def _get_missing_configuration(self) -> list[str]: + """Return list of missing configuration items.""" + missing = [] + if not self._are_entities_configured(): + missing.append("input entities") + if self._travel_time_close is None and self._travel_time_open is None: + missing.append("travel times") + return missing + + def _has_tilt_support(self): + """Return if cover has tilt support.""" + return self._tilt_strategy is not None and hasattr(self, "tilt_calc") + + # ----------------------------------------------------------------------- + # Movement tracking + # ----------------------------------------------------------------------- + + def _begin_movement( + self, + target, + coupled_target, + primary_calc, + coupled_calc, + startup_delay, + pre_step_delay: float = 0.0, + ): + """Start position tracking on primary and optionally coupled calculator. + + Begins travel on the primary calculator toward `target`, and if a + coupled_target is provided, also starts the coupled calculator. + Then starts the auto updater. Honors motor startup delay if configured. + + If pre_step_delay > 0, the coupled calculator is a pre-step that must + complete before the primary starts (e.g. tilt-before-travel in + sequential mode). The primary calculator's start is offset by this + delay so its position stays put until the pre-step finishes. + """ + + def start(): + primary_calc.start_travel(target, delay=pre_step_delay) + if coupled_target is not None: + coupled_calc.start_travel(int(coupled_target)) + self.start_auto_updater() + + self._start_movement(startup_delay, start) + + def _start_movement(self, startup_delay, start_callback): + """Start position tracking, optionally after a motor startup delay. + + If startup_delay is set, the relay is already ON but the motor hasn't + started moving yet. We wait for the delay, then begin tracking. + Otherwise we start tracking immediately. + """ + if startup_delay and startup_delay > 0: + self._startup_delay_task = self.hass.async_create_task( + self._execute_with_startup_delay(startup_delay, start_callback) + ) + else: + start_callback() + + async def _execute_with_startup_delay(self, startup_delay, start_callback): + """Wait for motor startup delay, then start position tracking.""" + self._log( + "_execute_with_startup_delay :: waiting %fs before starting position tracking", + startup_delay, + ) + try: + await sleep(startup_delay) + self._log( + "_execute_with_startup_delay :: startup delay complete, starting position tracking" + ) + start_callback() + self._startup_delay_task = None + except asyncio.CancelledError: + self._log("_execute_with_startup_delay :: startup delay cancelled") + self._startup_delay_task = None + raise + + def _cancel_delay_task(self): + """Cancel any active delay task.""" + if self._delay_task is not None and not self._delay_task.done(): + self._log("_cancel_delay_task :: cancelling active delay task") + self._delay_task.cancel() + self._delay_task = None + return True + return False + + def _cancel_startup_delay_task(self): + """Cancel any active startup delay task.""" + if self._startup_delay_task is not None and not self._startup_delay_task.done(): + self._log( + "_cancel_startup_delay_task :: cancelling active startup delay task" + ) + self._startup_delay_task.cancel() + self._startup_delay_task = None + + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + self._log("start_auto_updater") + if self._unsubscribe_auto_updater is None: + self._log("init _unsubscribe_auto_updater") + interval = timedelta(seconds=0.1) + self._unsubscribe_auto_updater = async_track_time_interval( + self.hass, self.auto_updater_hook, interval + ) + + @callback + def auto_updater_hook(self, _now): + """Call for the autoupdater.""" + self.async_schedule_update_ha_state() + if self.position_reached(): + self._log("auto_updater_hook :: position_reached") + self.stop_auto_updater() + self.hass.async_create_task(self.auto_stop_if_necessary()) + + def stop_auto_updater(self): + """Stop the autoupdater.""" + self._log("stop_auto_updater") + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None + + def position_reached(self): + """Return if cover has reached its final position.""" + return self.travel_calc.position_reached() and ( + not self._has_tilt_support() or self.tilt_calc.position_reached() + ) + + # ----------------------------------------------------------------------- + # Movement lifecycle (auto-stop, pre-step, restore) + # ----------------------------------------------------------------------- + + async def auto_stop_if_necessary(self): + """Do auto stop if necessary.""" + if self.position_reached(): + self._log( + "auto_stop_if_necessary :: position reached (self_initiated=%s)", + self._self_initiated_movement, + ) + self.travel_calc.stop() + if self._has_tilt_support(): + self.tilt_calc.stop() + + if not self._self_initiated_movement: + # Movement was triggered externally — just stop tracking, + # don't send relay commands. + self._log( + "auto_stop_if_necessary :: external movement, skipping relay stop" + ) + if self._tilt_strategy is not None: + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + self._last_command = None + return + + if self._tilt_restore_active: + self._log("auto_stop_if_necessary :: tilt restore complete") + self._tilt_restore_active = False + if self._has_tilt_motor(): + await self._send_tilt_stop() + else: + await self._async_handle_command(SERVICE_STOP_COVER) + if self._tilt_strategy is not None: + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + self._last_command = None + return + + if self._pending_travel_target is not None: + # Tilt pre-step complete — start travel phase + self._log("auto_stop_if_necessary :: tilt pre-step complete") + await self._start_pending_travel() + return + + if self._pending_tilt_target is not None: + # Travel pre-step complete — start tilt phase + self._log("auto_stop_if_necessary :: travel pre-step complete") + await self._start_pending_tilt() + return + + if self._tilt_strategy is not None: + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + + if self._tilt_restore_target is not None: + # Travel just completed — start tilt restore phase + await self._start_tilt_restore() + return + + current_travel = self.travel_calc.current_position() + if ( + self._endpoint_runon_time is not None + and self._endpoint_runon_time > 0 + and current_travel in (0, 100) + ): + self._log( + "auto_stop_if_necessary :: at endpoint (position=%d)," + " delaying relay stop by %fs", + current_travel, + self._endpoint_runon_time, + ) + self._delay_task = self.hass.async_create_task( + self._delayed_stop(self._endpoint_runon_time) + ) + else: + await self._async_handle_command(SERVICE_STOP_COVER) + self._last_command = None + + async def _delayed_stop(self, delay): + """Stop the relay after a delay.""" + self._log("_delayed_stop :: waiting %fs before stopping relay", delay) + try: + await sleep(delay) + self._log("_delayed_stop :: delay complete, stopping relay") + await self._async_handle_command(SERVICE_STOP_COVER) + self._last_command = None + self._delay_task = None + except asyncio.CancelledError: + self._log("_delayed_stop :: delay cancelled") + self._delay_task = None + raise + + async def _abandon_active_lifecycle(self): + """Abandon any active multi-phase tilt lifecycle (pre-step, restore). + + Called at the start of every movement method. If a tilt restore or + tilt pre-step is in progress, stops all hardware and calculators. + Always clears the pending restore target so it won't fire after + the next travel completes. + """ + was_restoring = self._tilt_restore_active + was_pre_stepping = ( + self._pending_travel_target is not None + or self._pending_tilt_target is not None + ) + + # Always clear multi-phase state + self._tilt_restore_target = None + self._tilt_restore_active = False + self._pending_travel_target = None + self._pending_travel_command = None + self._pending_tilt_target = None + self._pending_tilt_command = None + + if not was_restoring and not was_pre_stepping: + return + + self._log( + "_abandon_active_lifecycle :: abandoning %s", + "tilt restore" if was_restoring else "pre-step", + ) + + self._cancel_startup_delay_task() + + if self.travel_calc.is_traveling(): + self.travel_calc.stop() + if self._has_tilt_support() and self.tilt_calc.is_traveling(): + self.tilt_calc.stop() + self.stop_auto_updater() + + await self._async_handle_command(SERVICE_STOP_COVER) + if self._has_tilt_motor() and not self._triggered_externally: + await self._send_tilt_stop() + + def _stop_travel_if_traveling(self): + """Stop cover movement if it's currently traveling.""" + if self.travel_calc.is_traveling(): + self._log("_stop_travel_if_traveling :: stopping cover movement") + self.travel_calc.stop() + if self._has_tilt_support() and self.tilt_calc.is_traveling(): + self._log("_stop_travel_if_traveling :: also stopping tilt") + self.tilt_calc.stop() + + def _handle_stop(self): + """Handle stop""" + self._tilt_restore_target = None + self._tilt_restore_active = False + self._pending_travel_target = None + self._pending_travel_command = None + self._pending_tilt_target = None + self._pending_tilt_command = None + + if self.travel_calc.is_traveling(): + self._log("_handle_stop :: button stops cover movement") + self.travel_calc.stop() + self.stop_auto_updater() + + if self._has_tilt_support() and self.tilt_calc.is_traveling(): + self._log("_handle_stop :: button stops tilt movement") + self.tilt_calc.stop() + self.stop_auto_updater() + + async def _start_tilt_pre_step( + self, tilt_target, travel_target, travel_command, restore_target + ): + """Move tilt to safe position before travel (dual_motor). + + Sends the tilt motor command and starts tilt_calc. When tilt reaches + target, auto_stop_if_necessary will call _start_pending_travel to + begin the actual cover travel. + """ + current_tilt = self.tilt_calc.current_position() + self._log( + "_start_tilt_pre_step :: tilt %s→%d, pending travel→%d (%s)", + current_tilt, + tilt_target, + travel_target, + travel_command, + ) + self._pending_travel_target = travel_target + self._pending_travel_command = travel_command + self._tilt_restore_target = restore_target + + closing_tilt = tilt_target < current_tilt + if not self._triggered_externally: + if closing_tilt: + await self._send_tilt_close() + else: + await self._send_tilt_open() + + self.tilt_calc.start_travel(tilt_target) + self.start_auto_updater() + + async def _start_pending_travel(self): + """Start travel after tilt pre-step completes (dual_motor). + + Called by auto_stop_if_necessary when tilt_calc reaches the safe + position. Stops the tilt motor, sends the travel command, and starts + tracking with travel_calc. + """ + target = self._pending_travel_target + command = self._pending_travel_command + assert target is not None and command is not None + self._pending_travel_target = None + self._pending_travel_command = None + + self._log( + "_start_pending_travel :: starting travel to %d (%s)", + target, + command, + ) + + # Stop tilt motor + await self._send_tilt_stop() + + # Send travel command and start tracking + await self._async_handle_command(command) + self._last_command = command + self._begin_movement( + target, + None, + self.travel_calc, + None, + self._travel_startup_delay, + ) + + async def _start_travel_pre_step(self, travel_target, tilt_target, tilt_command): + """Move cover to allowed position before tilt (dual_motor). + + Sends the travel motor command and starts travel_calc. When travel + reaches target, auto_stop_if_necessary will call _start_pending_tilt + to begin the actual tilt movement. + """ + current_pos = self.travel_calc.current_position() + self._log( + "_start_travel_pre_step :: travel %s→%d, pending tilt→%d (%s)", + current_pos, + travel_target, + tilt_target, + tilt_command, + ) + self._pending_tilt_target = tilt_target + self._pending_tilt_command = tilt_command + + closing = travel_target < current_pos + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + self._last_command = command + await self._async_handle_command(command) + + self._begin_movement( + travel_target, + None, + self.travel_calc, + None, + self._travel_startup_delay, + ) + + async def _start_pending_tilt(self): + """Start tilt after travel pre-step completes (dual_motor). + + Called by auto_stop_if_necessary when travel_calc reaches the + allowed position. Stops the travel motor, sends the tilt command, + and starts tracking with tilt_calc. + """ + target = self._pending_tilt_target + command = self._pending_tilt_command + assert target is not None and command is not None + self._pending_tilt_target = None + self._pending_tilt_command = None + + self._log( + "_start_pending_tilt :: starting tilt to %d (%s)", + target, + command, + ) + + # Stop travel motor + await self._async_handle_command(SERVICE_STOP_COVER) + + # Send tilt command and start tracking + closing_tilt = command == SERVICE_CLOSE_COVER + if closing_tilt: + await self._send_tilt_close() + else: + await self._send_tilt_open() + self._last_command = command + self._begin_movement( + target, + None, + self.tilt_calc, + None, + self._tilt_startup_delay, + ) + + async def _start_tilt_restore(self): + """Restore tilt to its pre-movement position. + + For dual_motor: stops travel motor, starts tilt motor. + For shared motor (inline): reverses main motor direction. + """ + restore_target = self._tilt_restore_target + self._tilt_restore_target = None + if restore_target is None: + return + + current_tilt = self.tilt_calc.current_position() + if current_tilt is None or current_tilt == restore_target: + self._log( + "_start_tilt_restore :: no restore needed (current=%s, target=%s)", + current_tilt, + restore_target, + ) + await self._async_handle_command(SERVICE_STOP_COVER) + self._last_command = None + return + + self._log( + "_start_tilt_restore :: restoring tilt from %d%% to %d%%", + current_tilt, + restore_target, + ) + + closing = restore_target < current_tilt + + if self._tilt_strategy.uses_tilt_motor: + # Dual motor: stop travel, start tilt motor + await self._async_handle_command(SERVICE_STOP_COVER) + if closing: + await self._send_tilt_close() + else: + await self._send_tilt_open() + else: + # Shared motor (inline): reverse main motor direction + command = SERVICE_CLOSE_COVER if closing else SERVICE_OPEN_COVER + await self._async_handle_command(command) + + self.tilt_calc.start_travel(restore_target) + self._tilt_restore_active = True + self.start_auto_updater() + + # ----------------------------------------------------------------------- + # Relay command dispatch + # ----------------------------------------------------------------------- + + async def _async_handle_command(self, command, *_args): + cmd = command + if command == SERVICE_CLOSE_COVER: + cmd = "CLOSE" + self._state = False + self._last_command = command + if not self._triggered_externally: + await self._send_close() + elif command == SERVICE_OPEN_COVER: + cmd = "OPEN" + self._state = True + self._last_command = command + if not self._triggered_externally: + await self._send_open() + elif command == SERVICE_STOP_COVER: + cmd = "STOP" + self._state = True + if not self._triggered_externally: + await self._send_stop() + + self._log("_async_handle_command :: %s", cmd) + self.async_write_ha_state() + + @abstractmethod + async def _send_open(self) -> None: + """Send the open command to the underlying device.""" + + @abstractmethod + async def _send_close(self) -> None: + """Send the close command to the underlying device.""" + + @abstractmethod + async def _send_stop(self) -> None: + """Send the stop command to the underlying device.""" + + async def _raw_direction_command(self, command: str) -> None: + """Execute a raw direction command (for calibration screen buttons). + + Sets _last_command / _last_tilt_direction and sends relay commands. + Override in subclasses that need stop-before-direction-change + (e.g. toggle mode where opposite-direction = stop, not reverse). + """ + if command == "open": + self._last_command = SERVICE_OPEN_COVER + await self._send_open() + elif command == "close": + self._last_command = SERVICE_CLOSE_COVER + await self._send_close() + elif command == "stop": + await self._send_stop() + self._last_command = None + elif command == "tilt_open": + await self._send_tilt_open() + elif command == "tilt_close": + await self._send_tilt_close() + elif command == "tilt_stop": + await self._send_tilt_stop() + + # ----------------------------------------------------------------------- + # Tilt motor relay commands (dual_motor only) + # ----------------------------------------------------------------------- + + def _has_tilt_motor(self) -> bool: + """Return True if a dedicated tilt motor is configured (dual_motor mode).""" + return ( + self._tilt_strategy is not None + and self._tilt_strategy.uses_tilt_motor + and bool(self._tilt_open_switch_id and self._tilt_close_switch_id) + ) + + async def _send_tilt_open(self) -> None: + """Send open to the tilt motor (bypasses position tracker).""" + if self._switch_is_on(self._tilt_close_switch_id): + self._mark_switch_pending(self._tilt_close_switch_id, 1) + self._mark_switch_pending(self._tilt_open_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + + async def _send_tilt_close(self) -> None: + """Send close to the tilt motor (bypasses position tracker).""" + if self._switch_is_on(self._tilt_open_switch_id): + self._mark_switch_pending(self._tilt_open_switch_id, 1) + self._mark_switch_pending(self._tilt_close_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + + async def _send_tilt_stop(self) -> None: + """Send stop to the tilt motor (bypasses position tracker).""" + if self._switch_is_on(self._tilt_open_switch_id): + self._mark_switch_pending(self._tilt_open_switch_id, 1) + if self._switch_is_on(self._tilt_close_switch_id): + self._mark_switch_pending(self._tilt_close_switch_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + if self._tilt_stop_switch_id: + self._mark_switch_pending(self._tilt_stop_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_stop_switch_id}, + False, + ) + + # ----------------------------------------------------------------------- + # Switch echo filtering + # ----------------------------------------------------------------------- + + def _switch_is_on(self, entity_id) -> bool: + """Check if a switch entity is currently on.""" + state = self.hass.states.get(entity_id) + return state is not None and state.state == "on" + + def _mark_switch_pending(self, entity_id, expected_transitions): + """Mark a switch as having pending echo transitions to ignore.""" + self._pending_switch[entity_id] = ( + self._pending_switch.get(entity_id, 0) + expected_transitions + ) + self._log( + "_mark_switch_pending :: %s pending=%d", + entity_id, + self._pending_switch[entity_id], + ) + + # Cancel any existing timeout for this switch + if entity_id in self._pending_switch_timers: + self._pending_switch_timers[entity_id]() + + # Safety timeout: clear pending after 5 seconds + @callback + def _clear_pending(_now): + if entity_id in self._pending_switch: + self._log("_mark_switch_pending :: timeout clearing %s", entity_id) + del self._pending_switch[entity_id] + self._pending_switch_timers.pop(entity_id, None) + + self._pending_switch_timers[entity_id] = async_call_later( + self.hass, 5, _clear_pending + ) + + async def _async_switch_state_changed(self, event): + """Handle state changes on monitored switch entities.""" + entity_id = event.data.get("entity_id") + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + + if new_state is None or old_state is None: + return + + new_val = new_state.state + old_val = old_state.state + + self._log( + "_async_switch_state_changed :: %s: %s -> %s (pending=%s)", + entity_id, + old_val, + new_val, + self._pending_switch.get(entity_id, 0), + ) + + # Skip attribute-only updates (same state string, only attributes + # changed). Must run before echo filtering so that position updates + # on a moving wrapped cover don't consume pending echo counts. + if old_val == new_val: + return + + # Echo filtering: if this switch has pending echoes, decrement and skip + if self._pending_switch.get(entity_id, 0) > 0: + self._pending_switch[entity_id] -= 1 + if self._pending_switch[entity_id] <= 0: + del self._pending_switch[entity_id] + # Cancel the safety timeout + timer = self._pending_switch_timers.pop(entity_id, None) + if timer: + timer() + self._log( + "_async_switch_state_changed :: echo filtered, remaining=%s", + self._pending_switch.get(entity_id, 0), + ) + return + + # Skip external state handling during calibration — calibration drives + # the motors directly and must not be interfered with. + if self._calibration is not None: + self._log("_async_switch_state_changed :: calibration active, skipping") + return + + # External state change (physical button / remote / HA button). + # Delegate to mode-specific handlers which start/stop position + # tracking normally via async_open_cover / async_close_cover etc. + is_tilt = entity_id in ( + self._tilt_open_switch_id, + self._tilt_close_switch_id, + self._tilt_stop_switch_id, + ) + self._triggered_externally = True + try: + if is_tilt: + await self._handle_external_tilt_state_change( + entity_id, old_val, new_val + ) + else: + await self._handle_external_state_change(entity_id, old_val, new_val) + finally: + self._triggered_externally = False + + # ----------------------------------------------------------------------- + # External state change handlers + # ----------------------------------------------------------------------- + + async def _handle_external_tilt_state_change(self, entity_id, old_val, new_val): + """Handle external state change on tilt switches (dual_motor). + + Tilt switches use pulse-mode behavior. The ON signal (rising edge) + is the button press. The OFF transition is just button release. + """ + if new_val != "on": + return + + if entity_id == self._tilt_open_switch_id: + self._log( + "_handle_external_tilt_state_change :: external tilt open pulse detected" + ) + await self.async_open_cover_tilt() + elif entity_id == self._tilt_close_switch_id: + self._log( + "_handle_external_tilt_state_change :: external tilt close pulse detected" + ) + await self.async_close_cover_tilt() + elif entity_id == self._tilt_stop_switch_id: + self._log( + "_handle_external_tilt_state_change :: external tilt stop pulse detected" + ) + await self.async_stop_cover() + + async def _handle_external_state_change(self, entity_id, old_val, new_val): + """Handle external state change. Override in subclasses for mode-specific behavior.""" diff --git a/custom_components/cover_time_based/cover_calibration.py b/custom_components/cover_time_based/cover_calibration.py new file mode 100644 index 0000000..0dc9dc8 --- /dev/null +++ b/custom_components/cover_time_based/cover_calibration.py @@ -0,0 +1,439 @@ +"""Calibration mixin for time-based cover entities.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from asyncio import sleep +from typing import TYPE_CHECKING, Any + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.exceptions import HomeAssistantError + +from .calibration import CalibrationState + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from .travel_calculator import TravelCalculator + +_LOGGER = logging.getLogger(__name__) + + +class CalibrationMixin: + """Mixin providing calibration functionality for CoverTimeBased.""" + + if TYPE_CHECKING: + hass: HomeAssistant + _calibration: CalibrationState | None + _tilt_strategy: Any + _travel_time_close: float | None + _travel_time_open: float | None + _tilting_time_close: float | None + _tilting_time_open: float | None + current_cover_position: int | None + current_cover_tilt_position: int | None + travel_calc: TravelCalculator + tilt_calc: TravelCalculator + + async def _async_handle_command(self, _command: str, *_args: Any) -> None: ... + async def _send_stop(self) -> None: ... + def async_write_ha_state(self) -> None: ... + + async def start_calibration(self, **kwargs): + """Start a calibration test for the given attribute.""" + attribute = kwargs["attribute"] + timeout = kwargs["timeout"] + direction = kwargs.get("direction") # "open", "close", or None (auto) + + if self._calibration is not None: + raise HomeAssistantError("Calibration already in progress") + + # Validate BEFORE creating state + if attribute in ("tilt_time_close", "tilt_time_open"): + if ( + self._tilt_strategy is not None + and not self._tilt_strategy.can_calibrate_tilt() + ): + raise HomeAssistantError( + "Tilt time calibration not available for this tilt mode" + ) + + if attribute == "travel_startup_delay": + if not (self._travel_time_close or self._travel_time_open): + raise HomeAssistantError( + "Travel time must be configured before calibrating startup delay" + ) + + if attribute == "tilt_startup_delay": + if not (self._tilting_time_close or self._tilting_time_open): + raise HomeAssistantError( + "Tilt time must be configured before calibrating startup delay" + ) + + # Create state only after validation passes + self._calibration = CalibrationState(attribute=attribute, timeout=timeout) + self._calibration.timeout_task = self.hass.async_create_task( + self._calibration_timeout() + ) + + # Dispatch to appropriate test type + if attribute in ( + "travel_time_close", + "travel_time_open", + "tilt_time_close", + "tilt_time_open", + ): + await self._start_simple_time_test(attribute, direction) + elif attribute in ("travel_startup_delay", "tilt_startup_delay"): + await self._start_overhead_test(attribute, direction) + elif attribute == "min_movement_time": + self._calibration.move_command = self._resolve_direction( + direction, self.current_cover_position + ) + self._calibration.automation_task = self.hass.async_create_task( + self._run_min_movement_pulses() + ) + + self.async_write_ha_state() + + @staticmethod + def _resolve_direction(direction, position): + """Resolve move command from explicit direction or current position.""" + if direction == "close": + return SERVICE_CLOSE_COVER + if direction == "open": + return SERVICE_OPEN_COVER + # Auto-detect from position + if position is not None and position < 50: + return SERVICE_OPEN_COVER + return SERVICE_CLOSE_COVER + + async def _start_simple_time_test(self, attribute, direction): + """Start a simple travel/tilt time test by moving the cover.""" + assert self._calibration is not None + if direction: + self._calibration.move_command = self._resolve_direction(direction, None) + elif "close" in attribute: + self._calibration.move_command = SERVICE_CLOSE_COVER + else: + self._calibration.move_command = SERVICE_OPEN_COVER + await self._async_handle_command(self._calibration.move_command) + + async def _start_overhead_test(self, attribute, direction): + """Start automated step test for motor overhead.""" + assert self._calibration is not None + from .calibration import ( + CALIBRATION_OVERHEAD_STEPS, + CALIBRATION_TILT_OVERHEAD_STEPS, + ) + + is_tilt = "tilt" in attribute + + if not is_tilt: + position = self.current_cover_position + move_command = self._resolve_direction(direction, position) + if move_command == SERVICE_OPEN_COVER: + travel_time = self._travel_time_open or self._travel_time_close + else: + travel_time = self._travel_time_close or self._travel_time_open + num_steps = CALIBRATION_OVERHEAD_STEPS + # Zero startup delay during test so tracker doesn't compensate + self._calibration.saved_startup_delay = self._travel_startup_delay + self._travel_startup_delay = None + else: + position = self.current_cover_tilt_position + move_command = self._resolve_direction(direction, position) + if move_command == SERVICE_OPEN_COVER: + travel_time = self._tilting_time_open or self._tilting_time_close + else: + travel_time = self._tilting_time_close or self._tilting_time_open + num_steps = CALIBRATION_TILT_OVERHEAD_STEPS + # Zero startup delay during test so tracker doesn't compensate + self._calibration.saved_startup_delay = self._tilt_startup_delay + self._tilt_startup_delay = None + + assert travel_time is not None + _LOGGER.debug( + "overhead test: position=%s, direction=%s, travel_time=%.2f", + position, + move_command, + travel_time, + ) + + total_divisions = num_steps + 2 + step_pct = 100 // total_divisions + step_duration = travel_time * step_pct / 100 + self._calibration.step_duration = step_duration + self._calibration.move_command = move_command + + self._calibration.automation_task = self.hass.async_create_task( + self._run_overhead_steps(step_duration, num_steps, step_pct, is_tilt) + ) + + async def _run_overhead_steps(self, _step_duration, num_steps, step_pct, is_tilt): + """Execute stepped moves then one continuous move for overhead test. + + Phase 1: num_steps stepped moves using the travel calculator's + position tracking (same timing as normal operation). After each + step, force-set position to compensate for motor startup delay. + Phase 2: Continuous move for the remaining distance. + The user calls stop_calibration when the cover reaches the endpoint. + """ + assert self._calibration is not None + from .calibration import CALIBRATION_STEP_PAUSE + + move_command = self._calibration.move_command + assert move_command is not None + closing = move_command == SERVICE_CLOSE_COVER + calc = self.tilt_calc if is_tilt else self.travel_calc + + try: + # Phase 1: Stepped moves using travel calculator timing + for i in range(num_steps): + pct = (i + 1) * step_pct + target = (100 - pct) if closing else pct + + self._calibration.step_count = i + 1 + self.async_write_ha_state() + + _LOGGER.debug( + "overhead step %d/%d: target=%d%%", + i + 1, + num_steps, + target, + ) + calc.start_travel(target) + await self._async_handle_command(move_command) + + # Wait for travel calculator to say position reached + while calc.current_position() != target: + await sleep(0.05) + + await self._send_stop() + calc.stop() + + # Force position — motor fell short due to startup delay + calc.set_position(target) + + if i < num_steps - 1: + await sleep(CALIBRATION_STEP_PAUSE) + + # Pause before continuous phase + await sleep(CALIBRATION_STEP_PAUSE) + + # Phase 2: Continuous move for remaining distance + _LOGGER.debug( + "overhead test: starting continuous phase for remaining distance" + ) + self._calibration.final_step = True + self.async_write_ha_state() + self._calibration.continuous_start = time.monotonic() + await self._async_handle_command(move_command) + + # Wait indefinitely until user calls stop_calibration + while True: + await sleep(1.0) + except asyncio.CancelledError: + pass + + async def _run_min_movement_pulses(self): + """Send increasingly longer pulses until user sees movement.""" + assert self._calibration is not None + assert self._calibration.move_command is not None + from .calibration import ( + CALIBRATION_MIN_MOVEMENT_START, + CALIBRATION_MIN_MOVEMENT_INCREMENT, + CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE, + CALIBRATION_STEP_PAUSE, + ) + + move_command = self._calibration.move_command + pulse_duration = CALIBRATION_MIN_MOVEMENT_START + + try: + # Give user time to prepare stop_calibration call + _LOGGER.debug( + "min_movement: waiting %.0fs before first pulse", + CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE, + ) + await sleep(CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE) + + while True: + self._calibration.last_pulse_duration = pulse_duration + self._calibration.step_count += 1 + + step_start = time.monotonic() + await self._async_handle_command(move_command) + elapsed = time.monotonic() - step_start + await sleep(max(0, pulse_duration - elapsed)) + await self._send_stop() + self.async_write_ha_state() + + await sleep(CALIBRATION_STEP_PAUSE) + pulse_duration += CALIBRATION_MIN_MOVEMENT_INCREMENT + except asyncio.CancelledError: + pass + + async def _calibration_timeout(self): + """Handle calibration timeout.""" + assert self._calibration is not None + try: + await sleep(self._calibration.timeout) + _LOGGER.warning( + "Calibration timed out after %fs for attribute '%s'", + self._calibration.timeout, + self._calibration.attribute, + ) + # Cancel automation task if running + if ( + self._calibration.automation_task is not None + and not self._calibration.automation_task.done() + ): + self._calibration.automation_task.cancel() + await self._send_stop() + self._calibration = None + self.async_write_ha_state() + except asyncio.CancelledError: + _LOGGER.debug("_calibration_timeout :: cancelled") + + async def stop_calibration(self, **kwargs): + """Stop an in-progress calibration test.""" + if self._calibration is None: + raise HomeAssistantError("No calibration in progress") + + cancel = kwargs.get("cancel", False) + + # Cancel timeout task + if ( + self._calibration.timeout_task is not None + and not self._calibration.timeout_task.done() + ): + self._calibration.timeout_task.cancel() + + # Cancel automation task + if ( + self._calibration.automation_task is not None + and not self._calibration.automation_task.done() + ): + self._calibration.automation_task.cancel() + + # Stop the motor + await self._send_stop() + + result = {} + if not cancel: + value = self._calculate_calibration_result() + result["attribute"] = self._calibration.attribute + result["value"] = value + + # For successful completion, update the tracked position to + # reflect where the cover ended up (at an endpoint). + self._set_position_after_calibration(self._calibration) + + # Restore startup delay that was zeroed during overhead test + if self._calibration.saved_startup_delay is not None: + attr = self._calibration.attribute + if "tilt" in attr: + self._tilt_startup_delay = self._calibration.saved_startup_delay + else: + self._travel_startup_delay = self._calibration.saved_startup_delay + + self._calibration = None + self.async_write_ha_state() + return result + + def _set_position_after_calibration(self, calibration): + """Update tracked position after successful calibration. + + For travel/tilt time and startup delay tests, the cover has + reached an endpoint. For min_movement_time the cover only nudged + slightly so we leave the tracked position unchanged. + """ + move_command = calibration.move_command + if not move_command or calibration.attribute == "min_movement_time": + return + + is_tilt = "tilt" in calibration.attribute + if is_tilt and not hasattr(self, "tilt_calc"): + return + calc = self.tilt_calc if is_tilt else self.travel_calc + + # Cover ended at the endpoint in the direction of travel + endpoint = 0 if move_command == SERVICE_CLOSE_COVER else 100 + + _LOGGER.debug( + "calibration: resetting %s position to %d", + "tilt" if is_tilt else "travel", + endpoint, + ) + calc.set_position(endpoint) + + def _calculate_calibration_result(self): + """Calculate the calibration result based on attribute type.""" + assert self._calibration is not None + attribute = self._calibration.attribute + + if "travel_time" in attribute or "tilt_time" in attribute: + elapsed = time.monotonic() - self._calibration.started_at + return round(elapsed, 1) + + if attribute in ("travel_startup_delay", "tilt_startup_delay"): + closing = self._calibration.move_command == SERVICE_CLOSE_COVER + if attribute == "travel_startup_delay": + if closing: + total_time = self._travel_time_close or self._travel_time_open + else: + total_time = self._travel_time_open or self._travel_time_close + else: + if closing: + total_time = self._tilting_time_close or self._tilting_time_open + else: + total_time = self._tilting_time_open or self._tilting_time_close + + if not total_time: + _LOGGER.warning( + "Startup delay calibration requires travel/tilt time to be set first" + ) + return 0.0 + step_count = self._calibration.step_count + continuous_start = self._calibration.continuous_start + if continuous_start is None: + _LOGGER.warning( + "Overhead calibration stopped before continuous phase started" + ) + return 0.0 + continuous_time = time.monotonic() - continuous_start + # Subtract pulse overhead from continuous phase start for + # pulse/toggle modes (the ON command includes a relay pulse + # that doesn't contribute to motor travel time). + pulse_time = getattr(self, "_pulse_time", None) + if pulse_time: + continuous_time -= pulse_time + # Each step covers 1/10 of travel; remaining depends on step count + expected_remaining = (1.0 - step_count / 10.0) * total_time + overhead = (continuous_time - expected_remaining) / step_count + _LOGGER.debug( + "overhead calculation: total_time=%.2f, step_count=%d, " + "continuous_time=%.2f, expected_remaining=%.2f, overhead=%.4f", + total_time, + step_count, + continuous_time, + expected_remaining, + overhead, + ) + return round(max(0, overhead), 2) + + if attribute == "min_movement_time": + if self._calibration.last_pulse_duration is None: + _LOGGER.warning( + "Min movement calibration stopped before any pulses sent" + ) + return 0.0 + return round(self._calibration.last_pulse_duration, 2) + + raise ValueError(f"Unexpected calibration attribute: {attribute}") diff --git a/custom_components/cover_time_based/cover_pulse_mode.py b/custom_components/cover_time_based/cover_pulse_mode.py new file mode 100644 index 0000000..adb7002 --- /dev/null +++ b/custom_components/cover_time_based/cover_pulse_mode.py @@ -0,0 +1,198 @@ +"""Momentary pulse mode cover.""" + +import asyncio +from asyncio import sleep + +from .cover_switch import SwitchCoverTimeBased + + +class PulseModeCover(SwitchCoverTimeBased): + """Cover controlled by momentary pulse relays (pulse mode). + + In pulse mode, the direction switch is turned ON for a short pulse + (pulse_time seconds) then turned OFF. The motor controller latches + on the ON edge — the OFF is just relay cleanup. + + The send methods return immediately after the ON edge so that position + tracking starts from the moment the motor begins moving. The pulse + completion (sleep + turn_off) runs in the background. + """ + + def __init__(self, pulse_time, **kwargs): + super().__init__(**kwargs) + self._pulse_time = pulse_time + + def _get_missing_configuration(self) -> list[str]: + """Return list of missing configuration items.""" + missing = super()._get_missing_configuration() + if not self._stop_switch_entity_id: + missing.append("stop switch") + if self._has_tilt_motor() and not self._tilt_stop_switch_id: + missing.append("tilt stop switch") + return missing + + async def _complete_pulse(self, entity_id): + """Complete a relay pulse by turning OFF after pulse_time.""" + try: + await sleep(self._pulse_time) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": entity_id}, + False, + ) + except asyncio.CancelledError: + pass + + async def _send_open(self) -> None: + if self._switch_is_on(self._close_switch_entity_id): + self._mark_switch_pending(self._close_switch_entity_id, 1) + self._mark_switch_pending(self._open_switch_entity_id, 2) + if self._stop_switch_entity_id is not None: + if self._switch_is_on(self._stop_switch_entity_id): + self._mark_switch_pending(self._stop_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._close_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._open_switch_entity_id}, + False, + ) + if self._stop_switch_entity_id is not None: + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._stop_switch_entity_id}, + False, + ) + # Motor controller latches on ON edge; complete pulse in background + self.hass.async_create_task(self._complete_pulse(self._open_switch_entity_id)) + + async def _send_close(self) -> None: + if self._switch_is_on(self._open_switch_entity_id): + self._mark_switch_pending(self._open_switch_entity_id, 1) + self._mark_switch_pending(self._close_switch_entity_id, 2) + if self._stop_switch_entity_id is not None: + if self._switch_is_on(self._stop_switch_entity_id): + self._mark_switch_pending(self._stop_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._open_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._close_switch_entity_id}, + False, + ) + if self._stop_switch_entity_id is not None: + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._stop_switch_entity_id}, + False, + ) + # Motor controller latches on ON edge; complete pulse in background + self.hass.async_create_task(self._complete_pulse(self._close_switch_entity_id)) + + async def _send_stop(self) -> None: + if self._switch_is_on(self._close_switch_entity_id): + self._mark_switch_pending(self._close_switch_entity_id, 1) + if self._switch_is_on(self._open_switch_entity_id): + self._mark_switch_pending(self._open_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._close_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._open_switch_entity_id}, + False, + ) + if self._stop_switch_entity_id is not None: + self._mark_switch_pending(self._stop_switch_entity_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._stop_switch_entity_id}, + False, + ) + # Motor stops on ON edge; complete pulse in background + self.hass.async_create_task( + self._complete_pulse(self._stop_switch_entity_id) + ) + + # --- Tilt motor relay commands --- + + async def _send_tilt_open(self) -> None: + if self._switch_is_on(self._tilt_close_switch_id): + self._mark_switch_pending(self._tilt_close_switch_id, 1) + self._mark_switch_pending(self._tilt_open_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_open_switch_id)) + + async def _send_tilt_close(self) -> None: + if self._switch_is_on(self._tilt_open_switch_id): + self._mark_switch_pending(self._tilt_open_switch_id, 1) + self._mark_switch_pending(self._tilt_close_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_close_switch_id)) + + async def _send_tilt_stop(self) -> None: + if self._switch_is_on(self._tilt_open_switch_id): + self._mark_switch_pending(self._tilt_open_switch_id, 1) + if self._switch_is_on(self._tilt_close_switch_id): + self._mark_switch_pending(self._tilt_close_switch_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + if self._tilt_stop_switch_id: + self._mark_switch_pending(self._tilt_stop_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_stop_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_stop_switch_id)) diff --git a/custom_components/cover_time_based/cover_switch.py b/custom_components/cover_time_based/cover_switch.py new file mode 100644 index 0000000..4bf5e70 --- /dev/null +++ b/custom_components/cover_time_based/cover_switch.py @@ -0,0 +1,48 @@ +"""Abstract base for covers controlled via switch entities.""" + +import logging + +from .cover_base import CoverTimeBased + +_LOGGER = logging.getLogger(__name__) + + +class SwitchCoverTimeBased(CoverTimeBased): + """Abstract base for covers controlled via switch entities.""" + + def __init__( + self, + open_switch_entity_id, + close_switch_entity_id, + stop_switch_entity_id=None, + **kwargs, + ): + super().__init__(**kwargs) + self._open_switch_entity_id = open_switch_entity_id + self._close_switch_entity_id = close_switch_entity_id + self._stop_switch_entity_id = stop_switch_entity_id + + def _are_entities_configured(self) -> bool: + """Return True if open and close switch entities are configured.""" + return bool(self._open_switch_entity_id and self._close_switch_entity_id) + + async def _handle_external_state_change(self, entity_id, old_val, new_val): + """Handle external state change in pulse mode. + + In pulse mode, the ON signal is the button press (rising edge). + The OFF transition is just the button release and is ignored. + + Toggle mode overrides this with its own edge detection. + """ + if new_val != "on": + return + + if entity_id == self._open_switch_entity_id: + self._log("_handle_external_state_change :: external open pulse detected") + await self.async_open_cover() + elif entity_id == self._close_switch_entity_id: + self._log("_handle_external_state_change :: external close pulse detected") + await self.async_close_cover() + elif entity_id == self._stop_switch_entity_id: + self._log("_handle_external_state_change :: external stop pulse detected") + await self.async_stop_cover() diff --git a/custom_components/cover_time_based/cover_switch_mode.py b/custom_components/cover_time_based/cover_switch_mode.py new file mode 100644 index 0000000..2dea306 --- /dev/null +++ b/custom_components/cover_time_based/cover_switch_mode.py @@ -0,0 +1,129 @@ +"""Latching relay (switch) mode cover.""" + +import logging + +from .cover_switch import SwitchCoverTimeBased + +_LOGGER = logging.getLogger(__name__) + + +class SwitchModeCover(SwitchCoverTimeBased): + """Cover controlled by latching relays (switch mode). + + In switch mode, the direction switch stays ON for the entire duration + of the movement. _send_stop turns both direction switches OFF. + """ + + async def _handle_external_state_change(self, entity_id, old_val, new_val): + """Handle external state change in switch (latching) mode. + + ON = relay is driving the motor → start tracking. + OFF = relay released → stop tracking. + """ + if entity_id == self._open_switch_entity_id: + if new_val == "on": + _LOGGER.debug("_handle_external_state_change :: external open detected") + await self.async_open_cover() + elif new_val == "off": + _LOGGER.debug( + "_handle_external_state_change :: external open switch off, stopping" + ) + await self.async_stop_cover() + elif entity_id == self._close_switch_entity_id: + if new_val == "on": + _LOGGER.debug( + "_handle_external_state_change :: external close detected" + ) + await self.async_close_cover() + elif new_val == "off": + _LOGGER.debug( + "_handle_external_state_change :: external close switch off, stopping" + ) + await self.async_stop_cover() + + async def _handle_external_tilt_state_change(self, entity_id, old_val, new_val): + """Handle external tilt state change in switch (latching) mode. + + ON = tilt relay is driving the motor → start tilt tracking. + OFF = tilt relay released → stop tilt tracking. + """ + if entity_id == self._tilt_open_switch_id: + if new_val == "on": + _LOGGER.debug( + "_handle_external_tilt_state_change :: external tilt open detected" + ) + await self.async_open_cover_tilt() + elif new_val == "off": + _LOGGER.debug( + "_handle_external_tilt_state_change :: external tilt open off, stopping" + ) + await self.async_stop_cover() + elif entity_id == self._tilt_close_switch_id: + if new_val == "on": + _LOGGER.debug( + "_handle_external_tilt_state_change :: external tilt close detected" + ) + await self.async_close_cover_tilt() + elif new_val == "off": + _LOGGER.debug( + "_handle_external_tilt_state_change :: external tilt close off, stopping" + ) + await self.async_stop_cover() + elif entity_id == self._tilt_stop_switch_id: + if new_val == "on": + _LOGGER.debug( + "_handle_external_tilt_state_change :: external tilt stop detected" + ) + await self.async_stop_cover() + + async def _send_open(self) -> None: + if self._switch_is_on(self._close_switch_entity_id): + self._mark_switch_pending(self._close_switch_entity_id, 1) + self._mark_switch_pending(self._open_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._close_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._open_switch_entity_id}, + False, + ) + + async def _send_close(self) -> None: + if self._switch_is_on(self._open_switch_entity_id): + self._mark_switch_pending(self._open_switch_entity_id, 1) + self._mark_switch_pending(self._close_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._open_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._close_switch_entity_id}, + False, + ) + + async def _send_stop(self) -> None: + if self._switch_is_on(self._close_switch_entity_id): + self._mark_switch_pending(self._close_switch_entity_id, 1) + if self._switch_is_on(self._open_switch_entity_id): + self._mark_switch_pending(self._open_switch_entity_id, 1) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._close_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._open_switch_entity_id}, + False, + ) diff --git a/custom_components/cover_time_based/cover_toggle_mode.py b/custom_components/cover_time_based/cover_toggle_mode.py new file mode 100644 index 0000000..7d421ef --- /dev/null +++ b/custom_components/cover_time_based/cover_toggle_mode.py @@ -0,0 +1,305 @@ +"""Toggle mode cover.""" + +import asyncio +import logging +import time +from asyncio import sleep + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) + +from .cover_switch import SwitchCoverTimeBased + +_LOGGER = logging.getLogger(__name__) + + +class ToggleModeCover(SwitchCoverTimeBased): + """Cover controlled by toggle-style relays (toggle mode). + + In toggle mode, the motor controller toggles state on each pulse. + A second pulse on the same direction button stops the motor. + _send_stop therefore re-presses the last-used direction button. + + The send methods return immediately after the ON edge so that position + tracking starts from the moment the motor begins moving. The pulse + completion (sleep + turn_off) runs in the background. + """ + + def __init__(self, pulse_time, **kwargs): + super().__init__(**kwargs) + self._pulse_time = pulse_time + self._last_external_toggle_time = {} + self._last_tilt_direction = None + + async def _complete_pulse(self, entity_id): + """Complete a relay pulse by turning OFF after pulse_time.""" + try: + await sleep(self._pulse_time) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": entity_id}, + False, + ) + except asyncio.CancelledError: + pass + + async def async_stop_cover(self, **kwargs): + """Stop the cover, only sending relay command if it was active.""" + was_active = ( + self.is_opening + or self.is_closing + or (self._startup_delay_task and not self._startup_delay_task.done()) + or (self._delay_task and not self._delay_task.done()) + ) + tilt_restore_was_active = self._tilt_restore_active + tilt_pre_step_was_active = self._pending_travel_target is not None + self._cancel_startup_delay_task() + self._cancel_delay_task() + self._handle_stop() + if self._tilt_strategy is not None: + self._tilt_strategy.snap_trackers_to_physical( + self.travel_calc, self.tilt_calc + ) + if not self._triggered_externally and was_active: + await self._send_stop() + if ( + tilt_restore_was_active or tilt_pre_step_was_active + ) and self._has_tilt_motor(): + await self._send_tilt_stop() + self.async_write_ha_state() + self._last_command = None + self._last_tilt_direction = None + + # --- External state change handlers --- + + async def _handle_external_state_change(self, entity_id, old_val, new_val): + """Handle external state change in toggle mode. + + Only the rising edge (OFF→ON) is interesting — this is the button + press. The ON→OFF transition is just the relay releasing and is ignored. + + A debounce prevents double-triggering for momentary switches that + produce OFF->ON->OFF per click. + """ + if new_val != "on": + return + + now = time.monotonic() + last = self._last_external_toggle_time.get(entity_id, 0) + debounce_window = self._pulse_time + 0.5 + if now - last < debounce_window: + self._log( + "_handle_external_state_change :: debounced toggle on %s", + entity_id, + ) + return + self._last_external_toggle_time[entity_id] = now + + if entity_id == self._open_switch_entity_id: + self._log("_handle_external_state_change :: external open toggle detected") + await self.async_open_cover() + elif entity_id == self._close_switch_entity_id: + self._log("_handle_external_state_change :: external close toggle detected") + await self.async_close_cover() + + async def _handle_external_tilt_state_change(self, entity_id, old_val, new_val): + """Handle external tilt state change in toggle mode. + + Only reacts on rising edge (OFF→ON). Same debounce as travel handler. + If tilt is already moving, treat any toggle as stop. + """ + if new_val != "on": + return + + now = time.monotonic() + last = self._last_external_toggle_time.get(entity_id, 0) + debounce_window = self._pulse_time + 0.5 + if now - last < debounce_window: + self._log( + "_handle_external_tilt_state_change :: debounced toggle on %s", + entity_id, + ) + return + self._last_external_toggle_time[entity_id] = now + + if entity_id == self._tilt_open_switch_id: + if self.tilt_calc.is_traveling(): + self._log( + "_handle_external_tilt_state_change ::" + " tilt open toggle while traveling, stopping" + ) + await self.async_stop_cover() + else: + self._log( + "_handle_external_tilt_state_change :: external tilt open toggle detected" + ) + await self.async_open_cover_tilt() + elif entity_id == self._tilt_close_switch_id: + if self.tilt_calc.is_traveling(): + self._log( + "_handle_external_tilt_state_change ::" + " tilt close toggle while traveling, stopping" + ) + await self.async_stop_cover() + else: + self._log( + "_handle_external_tilt_state_change :: external tilt close toggle detected" + ) + await self.async_close_cover_tilt() + + # --- Raw direction commands (calibration screen) --- + + async def _raw_direction_command(self, command: str) -> None: + """In toggle mode, opposite-direction = stop, not reverse. + + To change direction: stop first, wait for pulse, then send new direction. + """ + if command in ("open", "close"): + opposite = SERVICE_CLOSE_COVER if command == "open" else SERVICE_OPEN_COVER + if self._last_command == opposite: + await self._send_stop() + await sleep(self._pulse_time) + elif command in ("tilt_open", "tilt_close"): + opposite_dir = "close" if command == "tilt_open" else "open" + if self._last_tilt_direction == opposite_dir: + await self._send_tilt_stop() + await sleep(self._pulse_time) + await super()._raw_direction_command(command) + + # --- Internal relay commands --- + + async def _send_open(self) -> None: + if self._switch_is_on(self._close_switch_entity_id): + self._mark_switch_pending(self._close_switch_entity_id, 1) + self._mark_switch_pending(self._open_switch_entity_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._close_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._open_switch_entity_id}, + False, + ) + # Motor controller acts on ON edge; complete pulse in background + self.hass.async_create_task(self._complete_pulse(self._open_switch_entity_id)) + + async def _send_close(self) -> None: + if self._switch_is_on(self._open_switch_entity_id): + self._mark_switch_pending(self._open_switch_entity_id, 1) + self._mark_switch_pending(self._close_switch_entity_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._open_switch_entity_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._close_switch_entity_id}, + False, + ) + # Motor controller acts on ON edge; complete pulse in background + self.hass.async_create_task(self._complete_pulse(self._close_switch_entity_id)) + + async def _send_stop(self) -> None: + if self._last_command == SERVICE_CLOSE_COVER: + self._mark_switch_pending(self._close_switch_entity_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._close_switch_entity_id}, + False, + ) + # Motor toggles on ON edge; complete pulse in background + self.hass.async_create_task( + self._complete_pulse(self._close_switch_entity_id) + ) + elif self._last_command == SERVICE_OPEN_COVER: + self._mark_switch_pending(self._open_switch_entity_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._open_switch_entity_id}, + False, + ) + # Motor toggles on ON edge; complete pulse in background + self.hass.async_create_task( + self._complete_pulse(self._open_switch_entity_id) + ) + else: + self._log("_send_stop :: toggle mode with no last command, skipping") + + # --- Tilt motor relay commands --- + + async def _send_tilt_open(self) -> None: + if self._switch_is_on(self._tilt_close_switch_id): + self._mark_switch_pending(self._tilt_close_switch_id, 1) + self._mark_switch_pending(self._tilt_open_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_open_switch_id)) + self._last_tilt_direction = "open" + + async def _send_tilt_close(self) -> None: + if self._switch_is_on(self._tilt_open_switch_id): + self._mark_switch_pending(self._tilt_open_switch_id, 1) + self._mark_switch_pending(self._tilt_close_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_off", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_close_switch_id)) + self._last_tilt_direction = "close" + + async def _send_tilt_stop(self) -> None: + if self._last_tilt_direction == "close": + self._mark_switch_pending(self._tilt_close_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_close_switch_id}, + False, + ) + self.hass.async_create_task( + self._complete_pulse(self._tilt_close_switch_id) + ) + elif self._last_tilt_direction == "open": + self._mark_switch_pending(self._tilt_open_switch_id, 2) + await self.hass.services.async_call( + "homeassistant", + "turn_on", + {"entity_id": self._tilt_open_switch_id}, + False, + ) + self.hass.async_create_task(self._complete_pulse(self._tilt_open_switch_id)) + else: + self._log( + "_send_tilt_stop :: toggle mode with no last tilt direction, skipping" + ) + self._last_tilt_direction = None diff --git a/custom_components/cover_time_based/cover_wrapped.py b/custom_components/cover_time_based/cover_wrapped.py new file mode 100644 index 0000000..d0ca338 --- /dev/null +++ b/custom_components/cover_time_based/cover_wrapped.py @@ -0,0 +1,110 @@ +"""Cover that wraps an existing cover entity.""" + +import logging + +from homeassistant.helpers.event import async_track_state_change_event + +from .cover_base import CoverTimeBased + +_LOGGER = logging.getLogger(__name__) + +# Cover states that indicate movement +_OPENING = "opening" +_CLOSING = "closing" +_MOVING_STATES = {_OPENING, _CLOSING} + + +class WrappedCoverTimeBased(CoverTimeBased): + """A cover that delegates open/close/stop to an underlying cover entity.""" + + def __init__( + self, + cover_entity_id, + **kwargs, + ): + super().__init__(**kwargs) + self._cover_entity_id = cover_entity_id + + async def async_added_to_hass(self): + """Register state listener for the wrapped cover entity.""" + await super().async_added_to_hass() + if self._cover_entity_id: + self._state_listener_unsubs.append( + async_track_state_change_event( + self.hass, + [self._cover_entity_id], + self._async_switch_state_changed, + ) + ) + + def _are_entities_configured(self) -> bool: + """Return True if the wrapped cover entity is configured.""" + return bool(self._cover_entity_id) + + async def _handle_external_state_change(self, entity_id, old_val, new_val): + """Handle state changes on the wrapped cover entity. + + Unlike switch-based covers, a wrapped cover's state transitions + are explicit — we mirror whatever the inner cover is doing. + This handles start, stop, AND direction changes (opening→closing). + """ + if new_val == _OPENING: + self._log("_handle_external_state_change :: wrapped cover opening") + await self.async_open_cover() + elif new_val == _CLOSING: + self._log("_handle_external_state_change :: wrapped cover closing") + await self.async_close_cover() + elif old_val in _MOVING_STATES: + # Was moving, now stopped + self._log("_handle_external_state_change :: wrapped cover stopped") + await self.async_stop_cover() + + async def _send_open(self) -> None: + # If the wrapped cover is currently closing, the open command produces + # two state transitions (closing→open, then open→opening). + state = self.hass.states.get(self._cover_entity_id) + expected = 2 if state and state.state == _CLOSING else 1 + self._mark_switch_pending(self._cover_entity_id, expected) + await self.hass.services.async_call( + "cover", "open_cover", {"entity_id": self._cover_entity_id}, False + ) + + async def _send_close(self) -> None: + # If the wrapped cover is currently opening, the close command produces + # two state transitions (opening→open, then open→closing). + state = self.hass.states.get(self._cover_entity_id) + expected = 2 if state and state.state == _OPENING else 1 + self._mark_switch_pending(self._cover_entity_id, expected) + await self.hass.services.async_call( + "cover", "close_cover", {"entity_id": self._cover_entity_id}, False + ) + + async def _send_stop(self) -> None: + self._mark_switch_pending(self._cover_entity_id, 1) + await self.hass.services.async_call( + "cover", "stop_cover", {"entity_id": self._cover_entity_id}, False + ) + + # --- Tilt motor relay commands --- + + def _has_tilt_motor(self) -> bool: + """Wrapped covers use the cover entity for tilt commands.""" + return self._tilt_strategy is not None and self._tilt_strategy.uses_tilt_motor + + async def _send_tilt_open(self) -> None: + self._mark_switch_pending(self._cover_entity_id, 1) + await self.hass.services.async_call( + "cover", "open_cover_tilt", {"entity_id": self._cover_entity_id}, False + ) + + async def _send_tilt_close(self) -> None: + self._mark_switch_pending(self._cover_entity_id, 1) + await self.hass.services.async_call( + "cover", "close_cover_tilt", {"entity_id": self._cover_entity_id}, False + ) + + async def _send_tilt_stop(self) -> None: + self._mark_switch_pending(self._cover_entity_id, 1) + await self.hass.services.async_call( + "cover", "stop_cover_tilt", {"entity_id": self._cover_entity_id}, False + ) diff --git a/custom_components/cover_time_based/frontend/cover-time-based-card.js b/custom_components/cover_time_based/frontend/cover-time-based-card.js new file mode 100644 index 0000000..87c0428 --- /dev/null +++ b/custom_components/cover_time_based/frontend/cover-time-based-card.js @@ -0,0 +1,1804 @@ +/** + * Cover Time Based Configuration Card + * + * A Lovelace card for configuring and calibrating cover_time_based entities. + * Uses HA built-in elements (ha-entity-picker, ha-textfield, ha-checkbox, + * ha-button) for consistent look and feel. + * + * All user-visible strings are translatable. Translations are embedded below. + */ + +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module"; + +const DOMAIN = "cover_time_based"; + +// Embedded translations — keys are flattened dotted paths. +// English is the default; other languages override specific keys. +const EN = { + "header": "Cover Time Based Configuration", + "loading": "Loading...", + "saving": "Saving...", + "save_failed": "Save failed — value reverted", + "confirm_cancel_calibration": "A calibration is running. Cancel it and continue?", + "create_new": "+ Create new cover entity", + "yaml_warning": "This entity uses YAML configuration and cannot be configured from this card. Please migrate to the UI: Settings \u2192 Devices & Services \u2192 Helpers \u2192 Create Helper \u2192 Cover Time Based.", + "tabs.device": "Device", + "tabs.calibration": "Calibration", + "control_mode.label": "Control Mode", + "control_mode.wrapped": "Wrap an existing cover entity", + "control_mode.switch": "Switch (latching)", + "control_mode.pulse": "Pulse (momentary)", + "control_mode.toggle": "Toggle (same button)", + "control_mode.pulse_time": "Pulse time", + "entities.cover_entity": "Cover Entity", + "entities.switch_entities": "Switch Entities", + "entities.open_switch": "Open switch", + "entities.close_switch": "Close switch", + "entities.stop_switch": "Stop switch", + "tilt.label": "Tilt Mode", + "tilt.none": "Not supported", + "tilt.sequential": "Closes then tilts", + "tilt.dual_motor": "Separate tilt motor", + "tilt.inline": "Tilts inline with travel", + "tilt_motor.label": "Tilt Motor", + "tilt_motor.open_switch": "Tilt open switch", + "tilt_motor.close_switch": "Tilt close switch", + "tilt_motor.stop_switch": "Tilt stop switch", + "tilt_motor.safe_position": "Safe tilt position", + "tilt_motor.safe_position_helper": "Tilt moves here before travel (100 = fully open)", + "tilt_motor.max_allowed_position": "Max tilt allowed position (optional)", + "tilt_motor.max_allowed_helper": "Tilt only allowed when cover position is at or below this value (0 = closed, 100 = open)", + "timing.attribute_header": "Attribute", + "timing.travel_attribute_header": "Travel Attribute", + "timing.tilt_attribute_header": "Tilt Attribute", + "timing.value_header": "Value", + "timing.not_set": "Not set", + "timing.travel_time_close": "Travel time (close)", + "timing.travel_time_open": "Travel time (open)", + "timing.travel_startup_delay": "Travel startup delay", + "timing.tilt_time_close": "Tilt time (close)", + "timing.tilt_time_open": "Tilt time (open)", + "timing.tilt_startup_delay": "Tilt startup delay", + "timing.min_movement_time": "Minimum movement time", + "timing.endpoint_runon_time": "Endpoint run-on time", + "position.label": "Current Position", + "position.helper": "Move cover to a known endpoint, then set position.", + "position.unknown": "Unknown", + "position.open": "Fully open", + "position.closed": "Fully closed", + "position.closed_tilt_open": "Fully closed, tilt open", + "position.closed_tilt_closed": "Fully closed, tilt closed", + "calibration.label": "Timing Calibration", + "calibration.attribute_label": "Attribute", + "calibration.start": "Start", + "calibration.active": "Calibration Active", + "calibration.step": "Step {step}", + "calibration.final_step": "Final step", + "calibration.cancel": "Cancel", + "calibration.finish": "Finish", + "calibration.set_position_first": "Set position to start calibration.", + "controls.cover_label": "Cover", + "controls.tilt_label": "Tilt", + "controls.open": "Open", + "controls.stop": "Stop", + "controls.close": "Close", + "controls.tilt_open": "Tilt open", + "controls.tilt_stop": "Tilt stop", + "controls.tilt_close": "Tilt close", + "hints.sequential.travel_time_close": "Start with cover fully open. Click Finish when the cover is fully closed, before the slats start tilting.", + "hints.sequential.travel_time_open": "Start with cover closed and slats open. Click Finish when the cover is fully open.", + "hints.sequential.tilt_time_close": "Start with cover closed but slats open. Click Finish when the slats are fully closed.", + "hints.sequential.tilt_time_open": "Start with cover and slats closed. Click Finish when the slats are open.", + "hints.dual_motor.travel_time_close": "Start with cover open and slats in safe position. Click Finish when the cover is fully closed.", + "hints.dual_motor.travel_time_open": "Start with cover closed and slats in safe position. Click Finish when the cover is fully open.", + "hints.dual_motor.tilt_time_close": "Start with cover closed and slats open. Click Finish when the slats are fully closed.", + "hints.dual_motor.tilt_time_open": "Start with both cover and slats closed. Click Finish when the slats are fully open.", + "hints.inline.travel_time_close": "Start with both cover and slats fully open. Click Finish when both are fully closed.", + "hints.inline.travel_time_open": "Start with both cover and slats fully closed. Click Finish when both are fully open.", + "hints.inline.tilt_time_close": "Start with slats fully open. Click Finish when the slats are fully closed.", + "hints.inline.tilt_time_open": "Start with slats fully closed. Click Finish when the slats are fully open.", + "hints.none.travel_time_close": "Click Finish when the cover is fully closed.", + "hints.none.travel_time_open": "Click Finish when the cover is fully open.", + "hints.min_movement_time": "Click Finish as soon as you notice the cover moving.", +}; + +const TRANSLATIONS = { + en: EN, + pt: { + "header": "Configuração de Estore Baseado em Tempo", + "loading": "A carregar...", + "saving": "A guardar...", + "save_failed": "Falha ao guardar — valor revertido", + "confirm_cancel_calibration": "Existe uma calibração em curso. Cancelar e continuar?", + "create_new": "+ Criar nova entidade de estore", + "yaml_warning": "Esta entidade utiliza configuração YAML e não pode ser configurada a partir deste cartão. Por favor, migre para a interface gráfica: Definições > Dispositivos e Serviços > Auxiliares > Criar Auxiliar > Estore Baseado em Tempo.", + "tabs.device": "Dispositivo", + "tabs.calibration": "Calibração", + "control_mode.label": "Modo de Controlo", + "control_mode.wrapped": "Encapsular uma entidade de estore existente", + "control_mode.switch": "Interruptor (travamento)", + "control_mode.pulse": "Pulso (momentâneo)", + "control_mode.toggle": "Alternar (mesmo botão)", + "control_mode.pulse_time": "Duração do pulso", + "entities.cover_entity": "Entidade de Estore", + "entities.switch_entities": "Entidades de Interruptor", + "entities.open_switch": "Interruptor de abrir", + "entities.close_switch": "Interruptor de fechar", + "entities.stop_switch": "Interruptor de parar", + "tilt.label": "Inclinação", + "tilt.none": "Não suportado", + "tilt.sequential": "Fecha e depois inclina", + "tilt.dual_motor": "Motor de inclinação separado", + "tilt.inline": "Inclina durante o deslocamento", + "tilt_motor.label": "Motor de Inclinação", + "tilt_motor.open_switch": "Interruptor de abrir inclinação", + "tilt_motor.close_switch": "Interruptor de fechar inclinação", + "tilt_motor.stop_switch": "Interruptor de parar inclinação", + "tilt_motor.safe_position": "Posição de inclinação segura", + "tilt_motor.safe_position_helper": "A inclinação move-se para aqui antes do deslocamento (100 = totalmente aberto)", + "tilt_motor.max_allowed_position": "Posição máxima permitida de inclinação (opcional)", + "tilt_motor.max_allowed_helper": "A inclinação só é permitida quando a posição do estore está neste valor ou abaixo (0 = fechado, 100 = aberto)", + "timing.attribute_header": "Atributo", + "timing.travel_attribute_header": "Atributo", + "timing.tilt_attribute_header": "Atributo", + "timing.value_header": "Valor", + "timing.not_set": "Não definido", + "timing.travel_time_close": "Tempo de deslocamento (fechar)", + "timing.travel_time_open": "Tempo de deslocamento (abrir)", + "timing.travel_startup_delay": "Atraso de arranque do deslocamento", + "timing.tilt_time_close": "Tempo de inclinação (fechar)", + "timing.tilt_time_open": "Tempo de inclinação (abrir)", + "timing.tilt_startup_delay": "Atraso de arranque da inclinação", + "timing.min_movement_time": "Tempo mínimo de movimento", + "timing.endpoint_runon_time": "Tempo de sobrecurso nos extremos", + "position.label": "Posição Atual", + "position.helper": "Mova o estore para um extremo conhecido e defina a posição.", + "position.unknown": "Desconhecida", + "position.open": "Totalmente aberto", + "position.closed": "Totalmente fechado", + "position.closed_tilt_open": "Totalmente fechado, inclinação aberta", + "position.closed_tilt_closed": "Totalmente fechado, inclinação fechada", + "calibration.label": "Calibração de Temporização", + "calibration.attribute_label": "Atributo", + "calibration.start": "Iniciar", + "calibration.active": "Calibração Ativa", + "calibration.step": "Passo {step}", + "calibration.final_step": "Passo final", + "calibration.cancel": "Cancelar", + "calibration.finish": "Concluir", + "calibration.set_position_first": "Defina a posição para iniciar a calibração.", + "controls.cover_label": "Estore", + "controls.tilt_label": "Inclinação", + "controls.open": "Abrir", + "controls.stop": "Parar", + "controls.close": "Fechar", + "controls.tilt_open": "Inclinar abrir", + "controls.tilt_stop": "Inclinar parar", + "controls.tilt_close": "Inclinar fechar", + "hints.sequential.travel_time_close": "Comece com o estore totalmente aberto. Clique em Concluir quando o estore estiver totalmente fechado, antes de as lâminas começarem a inclinar.", + "hints.sequential.travel_time_open": "Comece com o estore fechado e as lâminas abertas. Clique em Concluir quando o estore estiver totalmente aberto.", + "hints.sequential.tilt_time_close": "Comece com o estore fechado mas as lâminas abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.", + "hints.sequential.tilt_time_open": "Comece com o estore e as lâminas fechados. Clique em Concluir quando as lâminas estiverem abertas.", + "hints.dual_motor.travel_time_close": "Comece com o estore aberto e as lâminas na posição segura. Clique em Concluir quando o estore estiver totalmente fechado.", + "hints.dual_motor.travel_time_open": "Comece com o estore fechado e as lâminas na posição segura. Clique em Concluir quando o estore estiver totalmente aberto.", + "hints.dual_motor.tilt_time_close": "Comece com o estore fechado e as lâminas abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.", + "hints.dual_motor.tilt_time_open": "Comece com o estore e as lâminas fechados. Clique em Concluir quando as lâminas estiverem totalmente abertas.", + "hints.inline.travel_time_close": "Comece com o estore e as lâminas totalmente abertos. Clique em Concluir quando ambos estiverem totalmente fechados.", + "hints.inline.travel_time_open": "Comece com o estore e as lâminas totalmente fechados. Clique em Concluir quando ambos estiverem totalmente abertos.", + "hints.inline.tilt_time_close": "Comece com as lâminas totalmente abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.", + "hints.inline.tilt_time_open": "Comece com as lâminas totalmente fechadas. Clique em Concluir quando as lâminas estiverem totalmente abertas.", + "hints.none.travel_time_close": "Clique em Concluir quando o estore estiver totalmente fechado.", + "hints.none.travel_time_open": "Clique em Concluir quando o estore estiver totalmente aberto.", + "hints.min_movement_time": "Clique em Concluir assim que notar o estore a mover-se.", + }, + pl: { + "header": "Konfiguracja rolet sterowanych czasowo", + "loading": "Ładowanie...", + "saving": "Zapisywanie...", + "save_failed": "Zapis nie powiódł się — wartość przywrócona", + "confirm_cancel_calibration": "Kalibracja jest w toku. Anulować ją i kontynuować?", + "create_new": "+ Utwórz nową encję rolety", + "yaml_warning": "Ta encja używa konfiguracji YAML i nie może być konfigurowana z tej karty. Proszę przeprowadzić migrację do interfejsu użytkownika: Ustawienia > Urządzenia i usługi > Pomocniki > Utwórz pomocnik > Roleta sterowana czasowo.", + "tabs.device": "Urządzenie", + "tabs.calibration": "Kalibracja", + "control_mode.label": "Tryb sterowania", + "control_mode.wrapped": "Opakuj istniejącą encję rolety", + "control_mode.switch": "Przełącznik (zatrzaskowy)", + "control_mode.pulse": "Impuls (chwilowy)", + "control_mode.toggle": "Przełączanie (ten sam przycisk)", + "control_mode.pulse_time": "Czas impulsu", + "entities.cover_entity": "Encja rolety", + "entities.switch_entities": "Encje przełączników", + "entities.open_switch": "Przełącznik otwierania", + "entities.close_switch": "Przełącznik zamykania", + "entities.stop_switch": "Przełącznik zatrzymania", + "tilt.label": "Nachylenie", + "tilt.none": "Nieobsługiwane", + "tilt.sequential": "Najpierw zamyka, potem nachyla", + "tilt.dual_motor": "Osobny silnik nachylenia", + "tilt.inline": "Nachylenie w trakcie ruchu", + "tilt_motor.label": "Silnik nachylenia", + "tilt_motor.open_switch": "Przełącznik otwierania nachylenia", + "tilt_motor.close_switch": "Przełącznik zamykania nachylenia", + "tilt_motor.stop_switch": "Przełącznik zatrzymania nachylenia", + "tilt_motor.safe_position": "Bezpieczna pozycja nachylenia", + "tilt_motor.safe_position_helper": "Nachylenie przesuwa się tu przed ruchem (100 = w pełni otwarte)", + "tilt_motor.max_allowed_position": "Maks. dozwolona pozycja nachylenia (opcjonalna)", + "tilt_motor.max_allowed_helper": "Nachylenie dozwolone tylko gdy pozycja rolety wynosi tyle lub mniej (0 = zamknięta, 100 = otwarta)", + "timing.attribute_header": "Atrybut", + "timing.travel_attribute_header": "Atrybut", + "timing.tilt_attribute_header": "Atrybut", + "timing.value_header": "Wartość", + "timing.not_set": "Nieustawione", + "timing.travel_time_close": "Czas ruchu (zamykanie)", + "timing.travel_time_open": "Czas ruchu (otwieranie)", + "timing.travel_startup_delay": "Opóźnienie startu ruchu", + "timing.tilt_time_close": "Czas nachylenia (zamykanie)", + "timing.tilt_time_open": "Czas nachylenia (otwieranie)", + "timing.tilt_startup_delay": "Opóźnienie startu nachylenia", + "timing.min_movement_time": "Minimalny czas ruchu", + "timing.endpoint_runon_time": "Czas dobiegu na krańcach", + "position.label": "Aktualna pozycja", + "position.helper": "Przesuń roletę do znanego krańca, a następnie ustaw pozycję.", + "position.unknown": "Nieznana", + "position.open": "W pełni otwarta", + "position.closed": "W pełni zamknięta", + "position.closed_tilt_open": "W pełni zamknięta, nachylenie otwarte", + "position.closed_tilt_closed": "W pełni zamknięta, nachylenie zamknięte", + "calibration.label": "Kalibracja czasowa", + "calibration.attribute_label": "Atrybut", + "calibration.start": "Rozpocznij", + "calibration.active": "Kalibracja aktywna", + "calibration.step": "Krok {step}", + "calibration.final_step": "Krok końcowy", + "calibration.cancel": "Anuluj", + "calibration.finish": "Zakończ", + "calibration.set_position_first": "Ustaw pozycję, aby rozpocząć kalibrację.", + "controls.cover_label": "Roleta", + "controls.tilt_label": "Nachylenie", + "controls.open": "Otwórz", + "controls.stop": "Zatrzymaj", + "controls.close": "Zamknij", + "controls.tilt_open": "Otwórz nachylenie", + "controls.tilt_stop": "Zatrzymaj nachylenie", + "controls.tilt_close": "Zamknij nachylenie", + "hints.sequential.travel_time_close": "Zacznij z roletą w pełni otwartą. Kliknij Zakończ, gdy roleta jest w pełni zamknięta, zanim listwy zaczną się nachylać.", + "hints.sequential.travel_time_open": "Zacznij z zamkniętą roletą i otwartymi listwami. Kliknij Zakończ, gdy roleta jest w pełni otwarta.", + "hints.sequential.tilt_time_close": "Zacznij z zamkniętą roletą, ale otwartymi listwami. Kliknij Zakończ, gdy listwy są w pełni zamknięte.", + "hints.sequential.tilt_time_open": "Zacznij z zamkniętą roletą i zamkniętymi listwami. Kliknij Zakończ, gdy listwy są otwarte.", + "hints.dual_motor.travel_time_close": "Zacznij z otwartą roletą i listwami w bezpiecznej pozycji. Kliknij Zakończ, gdy roleta jest w pełni zamknięta.", + "hints.dual_motor.travel_time_open": "Zacznij z zamkniętą roletą i listwami w bezpiecznej pozycji. Kliknij Zakończ, gdy roleta jest w pełni otwarta.", + "hints.dual_motor.tilt_time_close": "Zacznij z zamkniętą roletą i otwartymi listwami. Kliknij Zakończ, gdy listwy są w pełni zamknięte.", + "hints.dual_motor.tilt_time_open": "Zacznij z zamkniętą roletą i zamkniętymi listwami. Kliknij Zakończ, gdy listwy są w pełni otwarte.", + "hints.inline.travel_time_close": "Zacznij z roletą i listwami w pełni otwartymi. Kliknij Zakończ, gdy obie są w pełni zamknięte.", + "hints.inline.travel_time_open": "Zacznij z roletą i listwami w pełni zamkniętymi. Kliknij Zakończ, gdy obie są w pełni otwarte.", + "hints.inline.tilt_time_close": "Zacznij z listwami w pełni otwartymi. Kliknij Zakończ, gdy listwy są w pełni zamknięte.", + "hints.inline.tilt_time_open": "Zacznij z listwami w pełni zamkniętymi. Kliknij Zakończ, gdy listwy są w pełni otwarte.", + "hints.none.travel_time_close": "Kliknij Zakończ, gdy roleta jest w pełni zamknięta.", + "hints.none.travel_time_open": "Kliknij Zakończ, gdy roleta jest w pełni otwarta.", + "hints.min_movement_time": "Kliknij Zakończ, gdy tylko zauważysz ruch rolety.", + }, +}; + +// Timing attributes shown in calibration dropdown and timing table. +// Keys are config attribute names; values are translation keys. +const TIMING_ATTRIBUTES = [ + ["travel_time_close", "timing.travel_time_close"], + ["travel_time_open", "timing.travel_time_open"], + ["travel_startup_delay", "timing.travel_startup_delay"], + ["tilt_time_close", "timing.tilt_time_close"], + ["tilt_time_open", "timing.tilt_time_open"], + ["tilt_startup_delay", "timing.tilt_startup_delay"], + ["min_movement_time", "timing.min_movement_time"], +]; + +const ATTRIBUTE_TO_CONFIG = { + travel_time_close: "travel_time_close", + travel_time_open: "travel_time_open", + tilt_time_close: "tilt_time_close", + tilt_time_open: "tilt_time_open", + travel_startup_delay: "travel_startup_delay", + tilt_startup_delay: "tilt_startup_delay", + min_movement_time: "min_movement_time", +}; + +class CoverTimeBasedCard extends LitElement { + static get properties() { + return { + hass: { type: Object }, + _selectedEntity: { type: String }, + _config: { type: Object }, + _loading: { type: Boolean }, + _saving: { type: Boolean }, + _activeTab: { type: String }, + _knownPosition: { type: String }, + _loadError: { type: String }, + _saveError: { type: Boolean }, + }; + } + + constructor() { + super(); + this._selectedEntity = ""; + this._config = null; + this._loading = false; + this._saving = false; + this._saveError = false; + this._activeTab = "device"; + this._knownPosition = "unknown"; + this._helpersLoaded = false; + } + + // --- Translation support --- + + _t(key, replacements) { + const lang = this.hass?.language || "en"; + const strings = TRANSLATIONS[lang] || EN; + let str = strings[key] || EN[key] || key; + if (replacements) { + for (const [k, v] of Object.entries(replacements)) { + str = str.replace(`{${k}}`, v); + } + } + return str; + } + + // --- Lifecycle --- + + _getScrollParent() { + let el = this; + while (el) { + el = el.parentElement || el.getRootNode()?.host; + if (el && el.scrollTop > 0) return el; + } + return document.scrollingElement || document.documentElement; + } + + async performUpdate() { + const scroller = this._getScrollParent(); + const scrollTop = scroller?.scrollTop ?? 0; + await super.performUpdate(); + if (scroller) scroller.scrollTop = scrollTop; + } + + async connectedCallback() { + super.connectedCallback(); + if (!this._helpersLoaded) { + this._helpersLoaded = true; + + // ha-entity-picker is lazy-loaded and may only live in a scoped + // registry. Force HA to load the module by triggering the config + // editor of a card that uses it (same pattern as Mushroom cards). + if (!customElements.get("ha-entity-picker")) { + try { + const helpers = await window.loadCardHelpers(); + // Create an entities card instance so we can access its class + const c = await helpers.createCardElement({ + type: "entities", + entities: [], + }); + // The static getConfigElement() imports the editor module, + // which in turn imports ha-entity-picker and registers it. + if (c?.constructor?.getConfigElement) { + await c.constructor.getConfigElement(); + } + } catch (_) { + // best-effort + } + } + + // Wait for the element to be defined (with timeout). + if (!customElements.get("ha-entity-picker")) { + try { + await Promise.race([ + customElements.whenDefined("ha-entity-picker"), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 10000) + ), + ]); + } catch (_) { + console.warn( + "[cover-time-based-card] ha-entity-picker not available" + ); + } + } + + this.requestUpdate(); + } + + // Load entity list from full registry (includes config_entry_id) + this._loadEntityList(); + } + + updated(changedProperties) { + } + + async _loadEntityList() { + if (!this.hass) return; + try { + const entries = await this.hass.callWS({ + type: "config/entity_registry/list", + }); + this._configEntryEntities = entries + .filter((e) => e.platform === "cover_time_based" && e.config_entry_id) + .map((e) => e.entity_id); + this.requestUpdate(); + } catch (err) { + console.error("Failed to load entity registry:", err); + this._configEntryEntities = []; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); + if (this._isCalibrating()) { + this._onStopCalibration(true); + } + } + + setConfig(_config) { + // No user-configurable options + } + + getCardSize() { + return 8; + } + + getGridOptions() { + return { columns: "full", min_columns: 6, min_rows: 4 }; + } + + // --- Data fetching --- + + async _loadConfig() { + if (!this._selectedEntity || !this.hass) return; + this._loading = true; + this._loadError = null; + try { + this._config = await this.hass.callWS({ + type: "cover_time_based/get_config", + entity_id: this._selectedEntity, + }); + } catch (err) { + console.error("Failed to load config:", err); + this._config = null; + this._loadError = this._t("yaml_warning"); + } + this._loading = false; + } + + _updateLocal(updates) { + this._config = { ...this._config, ...updates }; + this._scheduleAutoSave(); + } + + _scheduleAutoSave() { + if (this._autoSaveTimer) clearTimeout(this._autoSaveTimer); + this._autoSaveTimer = setTimeout(() => this._autoSave(), 500); + } + + async _autoSave() { + if (!this._selectedEntity || !this.hass || !this._config) return; + this._saving = true; + this._saveError = false; + try { + const { entry_id, ...fields } = this._config; + await this.hass.callWS({ + type: "cover_time_based/update_config", + ...fields, + entity_id: this._selectedEntity, + }); + } catch (err) { + console.error("Failed to save config:", err); + this._saveError = true; + await this._loadConfig(); + setTimeout(() => { this._saveError = false; }, 3000); + } + this._saving = false; + } + + // --- Entity helpers --- + + _getEntityState() { + if (!this._selectedEntity || !this.hass) return null; + return this.hass.states[this._selectedEntity]; + } + + _isCalibrating() { + if (this._calibratingOverride === false) return false; + if (this._calibratingOverride === true) return true; + const state = this._getEntityState(); + return state?.attributes?.calibration_active === true; + } + + _getCalibrationHint() { + const select = this.shadowRoot?.querySelector("#cal-attribute"); + const attr = select?.value; + const pos = this._knownPosition; + const c = this._config; + const tiltMode = c?.tilt_mode || "none"; + + // Startup delay uses the same hint as the corresponding direction + let effectiveAttr = attr; + if (attr === "travel_startup_delay") { + effectiveAttr = pos === "open" ? "travel_time_close" : "travel_time_open"; + } else if (attr === "tilt_startup_delay") { + effectiveAttr = (pos === "closed_tilt_open" || pos === "open") + ? "tilt_time_close" : "tilt_time_open"; + } + + if (attr === "min_movement_time") { + return this._t("hints.min_movement_time"); + } + + return this._t(`hints.${tiltMode}.${effectiveAttr}`); + } + + _hasRequiredEntities(c) { + if (!c) return false; + if (c.control_mode === "wrapped") { + if (!c.cover_entity_id) return false; + } else if (c.control_mode === "pulse") { + if (!c.open_switch_entity_id || !c.close_switch_entity_id || !c.stop_switch_entity_id) return false; + } else { + if (!c.open_switch_entity_id || !c.close_switch_entity_id) return false; + } + // Dual motor tilt requires tilt entities to be complete + if (c.tilt_mode === "dual_motor" && c.control_mode !== "wrapped") { + if (!c.tilt_open_switch || !c.tilt_close_switch) return false; + if (c.control_mode === "pulse" && !c.tilt_stop_switch) return false; + } + return true; + } + + // --- Event handlers --- + + _onEntityChange(e) { + const newValue = e.detail?.value || e.target?.value || ""; + this._selectedEntity = newValue; + this._config = null; + if (this._selectedEntity) { + this._loadConfig(); + } + } + + _onControlModeChange(e) { + const mode = e.target.value; + const updates = { control_mode: mode }; + // Clear irrelevant entities when switching modes + if (mode === "wrapped") { + updates.open_switch_entity_id = null; + updates.close_switch_entity_id = null; + updates.stop_switch_entity_id = null; + } else { + updates.cover_entity_id = null; + } + if (mode !== "pulse") { + updates.stop_switch_entity_id = null; + updates.tilt_stop_switch = null; + } + this._updateLocal(updates); + } + + _onPulseTimeChange(e) { + const val = parseFloat(e.target.value); + if (!isNaN(val) && val >= 0.1) { + this._updateLocal({ pulse_time: val }); + } + } + + _onSwitchEntityChange(field, e) { + const value = e.detail?.value || e.target?.value || null; + this._updateLocal({ [field]: value || null }); + } + + _filterNonTimeBased = (stateObj) => { + const entry = this.hass.entities[stateObj.entity_id]; + return !entry || entry.platform !== DOMAIN; + }; + + _onCoverEntityChange(e) { + const value = e.detail?.value || e.target?.value || null; + this._updateLocal({ cover_entity_id: value || null }); + } + + _onTiltModeChange(e) { + const mode = e.target.value; + if (mode === "none") { + this._updateLocal({ + tilt_time_close: null, + tilt_time_open: null, + tilt_startup_delay: null, + tilt_mode: "none", + // Clear dual-motor fields + safe_tilt_position: null, + max_tilt_allowed_position: null, + tilt_open_switch: null, + tilt_close_switch: null, + tilt_stop_switch: null, + }); + } else { + const updates = { tilt_mode: mode }; + if (mode === "sequential") { + // Clear dual-motor fields when switching to sequential + updates.safe_tilt_position = null; + updates.max_tilt_allowed_position = null; + updates.tilt_open_switch = null; + updates.tilt_close_switch = null; + updates.tilt_stop_switch = null; + } else if (mode === "dual_motor") { + // Default safe_tilt_position to 100 (fully open) + if (this._config.safe_tilt_position == null) { + updates.safe_tilt_position = 100; + } + // Default max_tilt_allowed_position to 0 (fully closed) + if (this._config.max_tilt_allowed_position == null) { + updates.max_tilt_allowed_position = 0; + } + } else if (mode === "inline") { + // Clear dual-motor fields when switching to inline + updates.safe_tilt_position = null; + updates.max_tilt_allowed_position = null; + updates.tilt_open_switch = null; + updates.tilt_close_switch = null; + updates.tilt_stop_switch = null; + } + this._updateLocal(updates); + } + } + + async _onStartCalibration() { + const attrSelect = this.shadowRoot.querySelector("#cal-attribute"); + + const data = { + entity_id: this._selectedEntity, + attribute: attrSelect.value, + timeout: 300, + }; + + // Don't send an explicit direction — the server derives the correct + // direction from the attribute name (e.g. travel_time_close → close, + // tilt_time_open → open). The position-based guess here was redundant + // for travel and actively wrong for tilt from closed_tilt_open. + + this._calibratingAttribute = attrSelect.value; + this._calibratingOverride = undefined; + + try { + await this.hass.callWS({ + type: `${DOMAIN}/start_calibration`, + ...data, + }); + this._knownPosition = "unknown"; + this._calibratingOverride = true; + this.requestUpdate(); + } catch (err) { + console.error("Start calibration failed:", err); + const msg = err?.message || String(err); + alert(`Calibration failed: ${msg}`); + } + } + + async _onStopCalibration(cancel = false) { + this._knownPosition = "unknown"; + this._calibratingOverride = false; + this.requestUpdate(); + try { + const result = await this.hass.callWS({ + type: "cover_time_based/stop_calibration", + entity_id: this._selectedEntity, + cancel, + }); + if (!cancel && result?.attribute) { + const configKey = ATTRIBUTE_TO_CONFIG[result.attribute]; + if (configKey) this._updateLocal({ [configKey]: result.value }); + } + } catch (err) { + console.error("Stop calibration failed:", err); + } + } + + _hasTiltMotor() { + const c = this._config; + if (!c || c.tilt_mode !== "dual_motor") return false; + if (c.control_mode === "wrapped") return true; + if (c.control_mode === "pulse") + return !!(c.tilt_open_switch && c.tilt_close_switch && c.tilt_stop_switch); + return !!(c.tilt_open_switch && c.tilt_close_switch); + } + + async _onCoverCommand(command) { + const cmdMap = { + open_cover: "open", + close_cover: "close", + stop_cover: "stop", + tilt_open: "tilt_open", + tilt_close: "tilt_close", + tilt_stop: "tilt_stop", + }; + this._knownPosition = "unknown"; + try { + await this.hass.callWS({ + type: `${DOMAIN}/raw_command`, + entity_id: this._selectedEntity, + command: cmdMap[command], + }); + } catch (err) { + console.error(`Cover ${command} failed:`, err); + } + } + + async _onPositionPresetChange(value) { + this._knownPosition = value; + if (value === "unknown") return; + + const tiltMode = this._config?.tilt_mode || "none"; + const hasTilt = tiltMode !== "none"; + + // Determine position and tilt values from preset + let position, tiltPosition; + switch (value) { + case "open": + position = 100; // HA convention: 100 = fully open + tiltPosition = hasTilt ? 100 : null; + break; + case "closed": + // Position+tilt both closed + position = 0; + tiltPosition = hasTilt ? 0 : null; + break; + case "closed_tilt_open": + position = 0; + tiltPosition = 100; + break; + case "closed_tilt_closed": + position = 0; + tiltPosition = 0; + break; + } + + try { + await this.hass.callService(DOMAIN, "set_known_position", { + entity_id: this._selectedEntity, + position, + }); + if (tiltPosition != null) { + await this.hass.callService(DOMAIN, "set_known_tilt_position", { + entity_id: this._selectedEntity, + tilt_position: tiltPosition, + }); + } + this.updateComplete.then(() => { + const select = this.shadowRoot.querySelector("#cal-attribute"); + if (select) { + const firstEnabled = [...select.options].find((o) => !o.disabled); + if (firstEnabled) select.value = firstEnabled.value; + } + this.requestUpdate(); + }); + } catch (err) { + console.error("Reset position failed:", err); + } + } + + _onCreateNew() { + // Navigate to helpers/add with domain param — HA auto-opens the config flow + window.history.pushState( + null, + "", + `/config/helpers/add?domain=${DOMAIN}` + ); + window.dispatchEvent(new Event("location-changed")); + } + + // --- Rendering --- + + render() { + if (!this.hass) return html``; + return html` + +
${this._t("header")}
+
+ ${this._renderEntityPicker()} + ${this._selectedEntity && this._config + ? this._renderConfigSections() + : ""} + ${this._loadError + ? html`
${this._loadError}
` + : ""} + ${this._loading + ? html`
+ ${this._t("loading")} +
` + : ""} +
+
+ `; + } + + _renderEntityPicker() { + return html` +
+ { + const newEntity = e.detail?.value || ""; + if (newEntity === this._selectedEntity) return; + if (this._isCalibrating()) { + if (!confirm(this._t("confirm_cancel_calibration"))) { + const current = this._selectedEntity; + const picker = e.target; + picker.value = current; + requestAnimationFrame(() => { + picker.value = current; + }); + this.requestUpdate(); + return; + } + if (this._isCalibrating()) { + this._onStopCalibration(true); + } + } + this._selectedEntity = newEntity; + this._config = null; + this._loadError = null; + this._knownPosition = "unknown"; + this._calibratingOverride = undefined; + this._activeTab = "device"; + if (this._selectedEntity) this._loadConfig(); + }} + > + { + e.preventDefault(); + this._onCreateNew(); + }}>${this._t("create_new")} +
+ `; + } + + _renderConfigSections() { + const c = this._config; + const calibrating = this._isCalibrating(); + const disabled = this._saving || calibrating; + + return html` +
+
+
+ + ${this._getEntityState()?.attributes?.friendly_name || + this._selectedEntity} + + ${this._selectedEntity} +
+
+
+ +
+ + +
+ + ${this._activeTab === "device" + ? html` +
+ ${this._renderControlMode(c)} ${this._renderInputEntities(c)} + ${this._renderTiltSupport(c)} + ${this._renderTiltMotorSection(c)} +
+ ` + : html` + ${calibrating ? "" : this._renderPositionReset()} + ${this._renderCalibration(calibrating)} + ${this._renderTimingTable(c)} + `} + + ${this._saving + ? html`
${this._t("saving")}
` + : ""} + ${this._saveError + ? html`
${this._t("save_failed")}
` + : ""} + `; + } + + _renderControlMode(c) { + const mode = c.control_mode || "switch"; + const showPulseTime = mode === "pulse" || mode === "toggle"; + + return html` +
+
${this._t("control_mode.label")}
+ + ${showPulseTime + ? html` +
+ +
+ ` + : ""} +
+ `; + } + + _renderInputEntities(c) { + if (c.control_mode === "wrapped") { + return html` +
+ +
+ `; + } + + return html` +
+
${this._t("entities.switch_entities")}
+
+ + this._onSwitchEntityChange("open_switch_entity_id", e)} + > + + this._onSwitchEntityChange("close_switch_entity_id", e)} + > + ${c.control_mode === "pulse" ? html` + + this._onSwitchEntityChange("stop_switch_entity_id", e)} + > + ` : ""} +
+
+ `; + } + + _renderTiltSupport(c) { + if (c.control_mode === "wrapped" && c.cover_entity_id) { + const stateObj = this.hass?.states?.[c.cover_entity_id]; + const features = stateObj?.attributes?.supported_features || 0; + // CoverEntityFeature: OPEN_TILT=16, CLOSE_TILT=32 + if (!(features & (16 | 32))) return ""; + } + const tiltMode = c.tilt_mode || "none"; + + return html` +
+
${this._t("tilt.label")}
+ +
+ `; + } + + _renderTiltMotorSection(c) { + if (c.tilt_mode !== "dual_motor") return ""; + + return html` +
+
${this._t("tilt_motor.label")}
+ ${c.control_mode !== "wrapped" ? html` +
+ + this._onSwitchEntityChange("tilt_open_switch", e)} + > + + this._onSwitchEntityChange("tilt_close_switch", e)} + > + ${c.control_mode === "pulse" ? html` + + this._onSwitchEntityChange("tilt_stop_switch", e)} + > + ` : ""} +
+ ` : ""} +
+ { + const v = parseInt(e.target.value); + if (!isNaN(v) && v >= 0 && v <= 100) { + this._updateLocal({ safe_tilt_position: v }); + } + }} + > + { + const v = e.target.value.trim(); + this._updateLocal({ + max_tilt_allowed_position: v === "" ? null : parseInt(v), + }); + }} + > +
+
+ `; + } + + _renderTimingRow([labelKey, key, value, min = 0]) { + return html` + + ${this._t(labelKey)} + + { + const v = e.target.value.trim(); + this._updateLocal({ [key]: v === "" ? null : parseFloat(v) }); + }} + />s + + + `; + } + + _renderTimingTable(c) { + const hasTiltTimes = c.tilt_mode === "sequential" || c.tilt_mode === "dual_motor" || c.tilt_mode === "inline"; + + const travelRows = [ + ["timing.travel_time_close", "travel_time_close", c.travel_time_close, 0.1], + ["timing.travel_time_open", "travel_time_open", c.travel_time_open, 0.1], + ["timing.travel_startup_delay", "travel_startup_delay", c.travel_startup_delay], + ["timing.min_movement_time", "min_movement_time", c.min_movement_time], + ["timing.endpoint_runon_time", "endpoint_runon_time", c.endpoint_runon_time], + ]; + + const tiltRows = [ + ["timing.tilt_time_close", "tilt_time_close", c.tilt_time_close, 0.1], + ["timing.tilt_time_open", "tilt_time_open", c.tilt_time_open, 0.1], + ["timing.tilt_startup_delay", "tilt_startup_delay", c.tilt_startup_delay], + ]; + + return html` +
+ + + + + + + + + ${travelRows.map((row) => this._renderTimingRow(row))} + +
${this._t("timing.travel_attribute_header")}${this._t("timing.value_header")}
+ ${hasTiltTimes ? html` + + + + + + + + + ${tiltRows.map((row) => this._renderTimingRow(row))} + +
${this._t("timing.tilt_attribute_header")}${this._t("timing.value_header")}
+ ` : ""} +
+ `; + } + + _renderPositionReset() { + const tiltMode = this._config?.tilt_mode || "none"; + const hasIndependentTilt = tiltMode === "sequential" || tiltMode === "dual_motor" || tiltMode === "inline"; + + return html` +
+
${this._t("position.label")}
+
+ ${this._t("position.helper")} +
+ ${!this._hasTiltMotor() ? html` +
+ this._onCoverCommand("open_cover")}> + + + this._onCoverCommand("stop_cover")}> + + + this._onCoverCommand("close_cover")}> + + +
+ ` : html` +
+
+ ${this._t("controls.cover_label")} + this._onCoverCommand("open_cover")}> + + + this._onCoverCommand("stop_cover")}> + + + this._onCoverCommand("close_cover")}> + + +
+
+ ${this._t("controls.tilt_label")} + this._onCoverCommand("tilt_open")}> + + + this._onCoverCommand("tilt_stop")}> + + + this._onCoverCommand("tilt_close")}> + + +
+
+ `} +
+
+ +
+
+
+ `; + } + + _renderCalibration(calibrating) { + const state = this._getEntityState(); + const attrs = state?.attributes || {}; + const tiltMode = this._config?.tilt_mode || "none"; + const hasTiltCalibration = tiltMode === "sequential" || tiltMode === "dual_motor" || tiltMode === "inline"; + + const availableAttributes = TIMING_ATTRIBUTES.filter( + ([key]) => { + if (!hasTiltCalibration && key.startsWith("tilt_")) return false; + return true; + } + ); + + const c = this._config; + const hasTravel = c?.travel_time_close || c?.travel_time_open; + const hasTilt = c?.tilt_time_close || c?.tilt_time_open; + + const disabledKeys = new Set(); + if (this._knownPosition === "unknown") { + availableAttributes.forEach(([key]) => disabledKeys.add(key)); + } else if (this._knownPosition === "open") { + disabledKeys.add("travel_time_open"); + disabledKeys.add("tilt_time_open"); + if (hasTiltCalibration) { + // Tilt only changes when cover is closed — can't test from open + disabledKeys.add("tilt_time_close"); + disabledKeys.add("tilt_startup_delay"); + } + } else if (this._knownPosition === "closed") { + // Position closed (tilt matches) + disabledKeys.add("travel_time_close"); + disabledKeys.add("tilt_time_close"); + } else if (this._knownPosition === "closed_tilt_open") { + disabledKeys.add("travel_time_close"); + disabledKeys.add("tilt_time_open"); + } else if (this._knownPosition === "closed_tilt_closed") { + disabledKeys.add("travel_time_close"); + disabledKeys.add("tilt_time_close"); + // Tilt must open before travel can move + disabledKeys.add("travel_time_open"); + disabledKeys.add("travel_startup_delay"); + disabledKeys.add("min_movement_time"); + } + + // Startup delay requires the corresponding time to be calibrated first + if (!hasTravel) disabledKeys.add("travel_startup_delay"); + if (!hasTilt) disabledKeys.add("tilt_startup_delay"); + if (!hasTravel) disabledKeys.add("min_movement_time"); + + if (calibrating) { + const calAttr = attrs.calibration_attribute || this._calibratingAttribute; + const calLabel = this._t(`timing.${calAttr}`); + return html` +
+
+ + ${this._t("calibration.active")} +
+
+ ${calLabel} + ${attrs.calibration_final_step + ? html`${this._t("calibration.final_step")}` + : attrs.calibration_step + ? html`${this._t("calibration.step", { step: attrs.calibration_step })}` + : ""} +
+ this._onStopCalibration(true)} + >${this._t("calibration.cancel")} + this._onStopCalibration(false)} + >${this._t("calibration.finish")} +
+
+
+ `; + } + + return html` +
+
${this._t("calibration.label")}
+
+
+ + +
+ ${this._t("calibration.start")} +
+ ${this._knownPosition === "unknown" + ? html`
+ ${this._t("calibration.set_position_first")} +
` + : html`
+ ${this._getCalibrationHint()} +
`} +
+ `; + } + + // --- Styles --- + + static get styles() { + return css` + :host { + display: block; + } + + .card-header { + font-size: 24px; + font-weight: 400; + padding: 24px 16px 16px; + line-height: 32px; + color: var(--ha-card-header-color, --primary-text-color); + } + + .card-content { + padding: 0 16px 16px; + } + + .section { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + } + + .section:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + + .field-label { + font-weight: 500; + font-size: var(--paper-font-body1_-_font-size, 14px); + margin-bottom: 8px; + color: var(--primary-text-color); + } + + .helper-text { + font-size: 12px; + color: var(--secondary-text-color, #727272); + margin: -4px 0 8px; + } + + .sub-label { + font-size: 12px; + color: var(--secondary-text-color); + margin-bottom: 4px; + display: block; + } + + /* Entity info banner */ + .entity-info { + margin-bottom: 16px; + padding: 12px 16px; + background: var(--primary-color); + color: var(--text-primary-color, #fff); + border-radius: 8px; + } + + .entity-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .entity-id { + display: block; + font-size: 0.85em; + opacity: 0.8; + font-family: var(--code-font-family, monospace); + } + + .cover-controls-wrapper { + display: flex; + flex-direction: column; + gap: 4px; + margin: 8px 0; + } + + .cover-controls-wrapper .cover-controls { + margin: 0; + } + + .cover-controls { + display: flex; + align-items: center; + gap: 4px; + margin: 8px 0; + } + + .controls-label { + font-size: 11px; + color: inherit; + opacity: 0.8; + white-space: nowrap; + min-width: 36px; + text-align: right; + } + + /* Tabs */ + .tabs { + display: flex; + border-bottom: 2px solid var(--divider-color, #e0e0e0); + margin-bottom: 16px; + } + + .tab { + flex: 1; + padding: 10px 16px; + border: none; + background: none; + cursor: pointer; + font-size: var(--paper-font-body1_-_font-size, 14px); + font-weight: 500; + color: var(--secondary-text-color); + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.2s, border-color 0.2s; + font-family: inherit; + } + + .tab:hover { + color: var(--primary-text-color); + } + + .tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + } + + .tab:disabled { + opacity: 0.4; + cursor: default; + } + + /* Radio groups */ + .radio-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .radio-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: var(--paper-font-body1_-_font-size, 14px); + color: var(--primary-text-color); + } + + .radio-group.indent { + margin-left: 28px; + margin-top: 8px; + } + + /* Tilt toggle */ + .tilt-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: var(--paper-font-body1_-_font-size, 14px); + color: var(--primary-text-color); + font-weight: 500; + } + + /* Entity grid */ + .entity-grid { + display: flex; + flex-direction: column; + gap: 8px; + } + + /* Dual motor config */ + .dual-motor-config { + display: flex; + gap: 16px; + margin-top: 12px; + } + + .dual-motor-config ha-textfield { + flex: 1; + } + + .inline-field { + margin-top: 8px; + } + + ha-textfield { + --mdc-text-field-fill-color: transparent; + } + + ha-entity-picker { + display: block; + } + + .create-new-link { + display: inline-block; + margin-top: 8px; + font-size: 13px; + color: var(--primary-color); + text-decoration: none; + cursor: pointer; + } + + .create-new-link:hover { + text-decoration: underline; + } + + /* Fieldset for disabling during calibration */ + fieldset { + border: none; + margin: 0; + padding: 0; + } + + fieldset:disabled { + opacity: 0.5; + pointer-events: none; + } + + /* Timing table */ + .timing-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: var(--paper-font-body1_-_font-size, 14px); + } + + .timing-table th:first-child, + .timing-table td:first-child { + width: 65%; + } + + .timing-table th:last-child, + .timing-table td:last-child { + width: 35%; + } + + .timing-table th { + text-align: left; + padding: 8px 12px; + border-bottom: 2px solid var(--divider-color); + color: var(--secondary-text-color); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .timing-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--divider-color); + color: var(--primary-text-color); + } + + .value-cell { + font-family: var(--code-font-family, monospace); + display: flex; + align-items: center; + gap: 4px; + } + + .timing-input { + width: 80px; + padding: 4px 8px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + font-family: var(--code-font-family, monospace); + font-size: inherit; + color: var(--primary-text-color); + background: var(--card-background-color, #fff); + text-align: right; + } + + .timing-input::placeholder { + color: var(--secondary-text-color); + font-style: italic; + font-family: inherit; + } + + .unit { + color: var(--secondary-text-color); + margin-left: 2px; + } + + /* Native select for calibration dropdowns */ + .ha-select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--divider-color); + border-radius: 4px; + background: var(--card-background-color, var(--ha-card-background)); + color: var(--primary-text-color); + font-size: var(--paper-font-body1_-_font-size, 14px); + font-family: var(--paper-font-body1_-_font-family, inherit); + cursor: pointer; + box-sizing: border-box; + } + + .ha-select:focus { + outline: none; + border-color: var(--primary-color); + } + + /* Calibration */ + .cal-form { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; + } + + .cal-field { + display: flex; + flex-direction: column; + flex: 1; + min-width: 140px; + } + + .cal-field-narrow { + flex: 0; + min-width: 100px; + } + + .cal-active-body { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 0 0; + font-size: var(--paper-font-body1_-_font-size, 14px); + } + + .cal-active-buttons { + display: flex; + gap: 8px; + padding-top: 4px; + } + + .cal-step { + opacity: 0.9; + font-size: 0.9em; + } + + .calibration-active { + background: var(--warning-color, #ff9800); + color: var(--text-primary-color, #fff); + padding: 16px; + border-radius: 8px; + margin-bottom: 0; + border-bottom: none; + } + + .cal-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary-color, #fff); + } + + .button-row { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + } + + /* Save indicator */ + .save-bar { + display: flex; + justify-content: flex-end; + padding: 8px 0; + } + + .saving-indicator { + font-size: 12px; + color: var(--secondary-text-color); + font-style: italic; + } + + .save-error { + font-size: 12px; + color: var(--error-color, #db4437); + font-style: italic; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: var(--secondary-text-color); + } + + .yaml-warning { + padding: 16px; + margin: 8px 0; + background: var(--warning-color, #ff9800); + color: var(--text-primary-color, #fff); + border-radius: 8px; + font-size: 14px; + line-height: 1.4; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .spin { + animation: spin 1s linear infinite; + } + `; + } +} + +customElements.define("cover-time-based-card", CoverTimeBasedCard); + +// Register with Lovelace card picker +window.customCards = window.customCards || []; +window.customCards.push({ + type: "cover-time-based-card", + name: "Cover Time Based Configuration", + description: + "Configure device type, input entities, timing, and run calibration tests for cover_time_based entities.", +}); diff --git a/custom_components/cover_time_based/helpers.py b/custom_components/cover_time_based/helpers.py new file mode 100644 index 0000000..dc82bcb --- /dev/null +++ b/custom_components/cover_time_based/helpers.py @@ -0,0 +1,30 @@ +"""Shared helper functions for the cover_time_based integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +def resolve_entity(hass: HomeAssistant, entity_id: str): + """Resolve an entity_id to a CoverTimeBased entity instance. + + Returns the entity or raises HomeAssistantError. + """ + from .cover_base import CoverTimeBased + + component = hass.data.get("entity_components", {}).get("cover") + if component is None: + raise HomeAssistantError("Cover platform not loaded") + entity = component.get_entity(entity_id) + if entity is None or not isinstance(entity, CoverTimeBased): + raise HomeAssistantError(f"{entity_id} is not a cover_time_based entity") + return entity + + +def resolve_entity_or_none(hass: HomeAssistant, entity_id: str): + """Resolve an entity_id to a CoverTimeBased entity, or None.""" + try: + return resolve_entity(hass, entity_id) + except HomeAssistantError: + return None diff --git a/custom_components/cover_time_based/manifest.json b/custom_components/cover_time_based/manifest.json index b2e010e..55775a5 100644 --- a/custom_components/cover_time_based/manifest.json +++ b/custom_components/cover_time_based/manifest.json @@ -1,13 +1,13 @@ { "domain": "cover_time_based", "name": "Cover Time Based", - "codeowners": ["@Sese-Schneider"], + "codeowners": ["@Sese-Schneider", "@clintongormley"], + "config_flow": true, + "dependencies": ["http"], "documentation": "https://github.com/Sese-Schneider/ha-cover-time-based", "integration_type": "helper", "iot_class": "calculated", "issue_tracker": "https://github.com/Sese-Schneider/ha-cover-time-based/issues", - "requirements": [ - "xknx==3.11.0" - ], - "version": "3.0.0" + "requirements": [], + "version": "4.0.0" } diff --git a/custom_components/cover_time_based/services.yaml b/custom_components/cover_time_based/services.yaml index d6e3e32..3286c03 100644 --- a/custom_components/cover_time_based/services.yaml +++ b/custom_components/cover_time_based/services.yaml @@ -1,12 +1,93 @@ set_known_position: + target: + entity: + domain: cover + integration: cover_time_based fields: - entity_id: - example: cover.blinds position: + required: true example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + set_known_tilt_position: + target: + entity: + domain: cover + integration: cover_time_based fields: - entity_id: - example: cover.blinds - position: + tilt_position: + required: true example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + +start_calibration: + fields: + entity_id: + required: true + selector: + entity: + domain: cover + integration: cover_time_based + attribute: + required: true + example: travel_time_close + selector: + select: + options: + - label: "Travel time (close)" + value: travel_time_close + - label: "Travel time (open)" + value: travel_time_open + - label: "Travel startup delay" + value: travel_startup_delay + - label: "Tilt time (close)" + value: tilt_time_close + - label: "Tilt time (open)" + value: tilt_time_open + - label: "Tilt startup delay" + value: tilt_startup_delay + - label: "Minimum movement time" + value: min_movement_time + timeout: + required: true + example: 120 + default: 120 + selector: + number: + min: 1 + max: 600 + step: 1 + unit_of_measurement: "s" + direction: + required: false + selector: + select: + options: + - label: "Open" + value: open + - label: "Close" + value: close + +stop_calibration: + fields: + entity_id: + required: true + selector: + entity: + domain: cover + integration: cover_time_based + cancel: + required: false + default: false + selector: + boolean: diff --git a/custom_components/cover_time_based/strings.json b/custom_components/cover_time_based/strings.json index 47f4940..3f76ed7 100644 --- a/custom_components/cover_time_based/strings.json +++ b/custom_components/cover_time_based/strings.json @@ -1,4 +1,21 @@ { + "config": { + "step": { + "user": { + "title": "Add a time-based cover", + "description": "Choose a name for your cover. After creating it, add the **Cover Time Based** card to a dashboard to configure all settings.", + "data": { + "name": "Name" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "YAML configuration is deprecated", + "description": "Configuring Cover Time Based using YAML is deprecated and will be removed in a future version.\n\nPlease remove the YAML configuration and recreate your covers using the UI: **Settings > Devices & Services > Helpers > Create Helper > Cover Time Based**." + } + }, "services": { "set_known_position": { "name": "Set cover position", @@ -9,8 +26,8 @@ "description": "The entity ID of the cover to set the position for." }, "position": { - "name": "Position", - "description": "The position of the cover, between 0 and 100." + "name": "Position", + "description": "The position of the cover, between 0 and 100." } } }, @@ -23,10 +40,46 @@ "description": "The entity ID of the cover to set the tilt position for." }, "position": { - "name": "Position", - "description": "The tilt position of the cover, between 0 and 100." + "name": "Position", + "description": "The tilt position of the cover, between 0 and 100." + } + } + }, + "start_calibration": { + "name": "Start calibration", + "description": "Start a calibration test to measure a timing parameter. The cover will begin moving — call stop_calibration when the desired endpoint is reached.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The cover to calibrate" + }, + "attribute": { + "name": "Attribute", + "description": "The timing parameter to calibrate" + }, + "timeout": { + "name": "Timeout", + "description": "Safety timeout in seconds — motor will auto-stop if stop_calibration is not called" + }, + "direction": { + "name": "Direction", + "description": "Direction to move during the test. If not set, auto-detects based on current position" + } + } + }, + "stop_calibration": { + "name": "Stop calibration", + "description": "Stop an active calibration test. Calculates the result and saves it to the configuration unless cancelled.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The cover being calibrated" + }, + "cancel": { + "name": "Cancel", + "description": "If true, discard the test results without saving" } } } } -} \ No newline at end of file +} diff --git a/custom_components/cover_time_based/tilt_strategies/__init__.py b/custom_components/cover_time_based/tilt_strategies/__init__.py new file mode 100644 index 0000000..529ccc4 --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/__init__.py @@ -0,0 +1,27 @@ +"""Tilt strategy classes for cover_time_based. + +Tilt strategies determine how travel and tilt movements are coupled. +""" + +from .base import MovementStep, TiltStrategy, TiltTo, TravelTo +from .dual_motor import DualMotorTilt +from .inline import InlineTilt +from .planning import ( + calculate_pre_step_delay, + extract_coupled_tilt, + extract_coupled_travel, +) +from .sequential import SequentialTilt + +__all__ = [ + "DualMotorTilt", + "InlineTilt", + "MovementStep", + "SequentialTilt", + "TiltStrategy", + "TiltTo", + "TravelTo", + "calculate_pre_step_delay", + "extract_coupled_tilt", + "extract_coupled_travel", +] diff --git a/custom_components/cover_time_based/tilt_strategies/base.py b/custom_components/cover_time_based/tilt_strategies/base.py new file mode 100644 index 0000000..1d070a4 --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/base.py @@ -0,0 +1,85 @@ +"""Base class and shared helpers for tilt strategies.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..travel_calculator import TravelCalculator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TiltTo: + """Step: move tilt to target position.""" + + target: int + coupled_travel: int | None = None + + +@dataclass(frozen=True) +class TravelTo: + """Step: move travel to target position.""" + + target: int + coupled_tilt: int | None = None + + +MovementStep = TiltTo | TravelTo + + +class TiltStrategy(ABC): + """Base class for tilt mode strategies.""" + + @abstractmethod + def can_calibrate_tilt(self) -> bool: + """Whether tilt calibration is allowed.""" + + @property + @abstractmethod + def name(self) -> str: + """Strategy name for config/state.""" + + @property + @abstractmethod + def uses_tilt_motor(self) -> bool: + """Whether TiltTo steps require a separate tilt motor.""" + + @property + @abstractmethod + def restores_tilt(self) -> bool: + """Whether tilt should be restored after a position change.""" + + @abstractmethod + def plan_move_position( + self, + target_pos: int, + current_pos: int, + current_tilt: int, + ) -> list[TiltTo | TravelTo]: + """Plan steps to move cover to target_pos.""" + + @abstractmethod + def plan_move_tilt( + self, + target_tilt: int, + current_pos: int, + current_tilt: int, + ) -> list[TiltTo | TravelTo]: + """Plan steps to move tilt to target_tilt.""" + + def allows_tilt_at_position(self, _position: int) -> bool: + """Whether tilt is allowed at the given cover position.""" + return True + + @abstractmethod + def snap_trackers_to_physical( + self, + travel_calc: TravelCalculator, + tilt_calc: TravelCalculator, + ) -> None: + """Correct tracker drift after stop to match physical reality.""" diff --git a/custom_components/cover_time_based/tilt_strategies/dual_motor.py b/custom_components/cover_time_based/tilt_strategies/dual_motor.py new file mode 100644 index 0000000..e499d99 --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/dual_motor.py @@ -0,0 +1,97 @@ +"""Dual-motor tilt strategy. + +Separate tilt motor with its own switch entities. Optionally boundary-locked +(tilt only allowed when cover position <= max_tilt_allowed_position). +Before travel, slats move to a configurable safe position. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .base import TiltStrategy, TiltTo, TravelTo + +if TYPE_CHECKING: + from ..travel_calculator import TravelCalculator + +_LOGGER = logging.getLogger(__name__) + + +class DualMotorTilt(TiltStrategy): + """Dual-motor tilt — independent tilt motor with optional boundary lock.""" + + def __init__( + self, + safe_tilt_position: int = 100, + max_tilt_allowed_position: int | None = None, + ) -> None: + self._safe_tilt_position = safe_tilt_position + self._max_tilt_allowed_position = max_tilt_allowed_position + + def can_calibrate_tilt(self) -> bool: + return True + + @property + def name(self) -> str: + return "dual_motor" + + @property + def uses_tilt_motor(self) -> bool: + return True + + @property + def restores_tilt(self) -> bool: + return True + + def plan_move_position( + self, target_pos: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + steps: list[TiltTo | TravelTo] = [] + if current_tilt != self._safe_tilt_position: + steps.append(TiltTo(self._safe_tilt_position)) + steps.append(TravelTo(target_pos)) + return steps + + def plan_move_tilt( + self, target_tilt: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + steps: list[TiltTo | TravelTo] = [] + if ( + self._max_tilt_allowed_position is not None + and current_pos > self._max_tilt_allowed_position + ): + steps.append(TravelTo(self._max_tilt_allowed_position)) + steps.append(TiltTo(target_tilt)) + return steps + + def allows_tilt_at_position(self, position: int) -> bool: + """Tilt is only allowed at or below max_tilt_allowed_position.""" + if self._max_tilt_allowed_position is None: + return True + return position <= self._max_tilt_allowed_position + + def snap_trackers_to_physical( + self, + travel_calc: TravelCalculator, + tilt_calc: TravelCalculator, + ) -> None: + if self._max_tilt_allowed_position is None: + return + current_travel = travel_calc.current_position() + current_tilt = tilt_calc.current_position() + if current_travel is None or current_tilt is None: + return + if ( + current_travel > self._max_tilt_allowed_position + and current_tilt != self._safe_tilt_position + ): + _LOGGER.debug( + "DualMotorTilt :: Travel at %d%% (above max %d%%), " + "forcing tilt to safe %d%% (was %d%%)", + current_travel, + self._max_tilt_allowed_position, + self._safe_tilt_position, + current_tilt, + ) + tilt_calc.set_position(self._safe_tilt_position) diff --git a/custom_components/cover_time_based/tilt_strategies/inline.py b/custom_components/cover_time_based/tilt_strategies/inline.py new file mode 100644 index 0000000..b0926fa --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/inline.py @@ -0,0 +1,61 @@ +"""Inline tilt strategy. + +Single-motor roller shutter where tilt is embedded in the travel cycle. +At the start of any movement there is a fixed tilt phase, then travel +continues. Tilt works at any position. Tilt is restored after position +changes to non-endpoint targets. +""" + +from __future__ import annotations + +import logging + +from .base import TiltStrategy, TiltTo, TravelTo + +_LOGGER = logging.getLogger(__name__) + + +class InlineTilt(TiltStrategy): + """Inline tilt mode. + + Single motor where tilt is part of the travel cycle. Each direction + has a fixed tilt phase at the start of movement. Tilt works at any + position in the travel range. + """ + + def can_calibrate_tilt(self) -> bool: + return True + + @property + def name(self) -> str: + return "inline" + + @property + def uses_tilt_motor(self) -> bool: + return False + + @property + def restores_tilt(self) -> bool: + return True + + def plan_move_position( + self, target_pos: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + closing = target_pos < current_pos + tilt_endpoint = 0 if closing else 100 + steps: list[TiltTo | TravelTo] = [] + if current_tilt != tilt_endpoint: + steps.append(TiltTo(tilt_endpoint)) + steps.append(TravelTo(target_pos)) + return steps + + def plan_move_tilt( + self, target_tilt: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + return [TiltTo(target_tilt)] + + def snap_trackers_to_physical(self, travel_calc, tilt_calc): + # No-op: inline tilt allows any tilt value at any position. + # Endpoint coupling during travel is handled by plan_move_position + # pre-steps (TiltTo before TravelTo), not by post-stop snapping. + pass diff --git a/custom_components/cover_time_based/tilt_strategies/planning.py b/custom_components/cover_time_based/tilt_strategies/planning.py new file mode 100644 index 0000000..39c2122 --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/planning.py @@ -0,0 +1,78 @@ +"""Tilt strategy planning helpers.""" + +from __future__ import annotations + +from .base import TiltTo, TravelTo + + +def extract_coupled_tilt(steps: list[TiltTo | TravelTo]) -> int | None: + """Extract the tilt target from a movement plan. + + For coupled steps, returns the coupled_tilt value. + For multi-step plans (sequential), returns the TiltTo target. + Returns None if no tilt movement in the plan. + """ + for step in steps: + if isinstance(step, TravelTo) and step.coupled_tilt is not None: + return step.coupled_tilt + if isinstance(step, TiltTo): + return step.target + return None + + +def extract_coupled_travel(steps: list[TiltTo | TravelTo]) -> int | None: + """Extract the travel target from a movement plan. + + For coupled steps, returns the coupled_travel value. + For multi-step plans (sequential), returns the TravelTo target. + Returns None if no travel movement in the plan. + """ + for step in steps: + if isinstance(step, TiltTo) and step.coupled_travel is not None: + return step.coupled_travel + if isinstance(step, TravelTo): + return step.target + return None + + +def has_travel_pre_step(steps: list[TiltTo | TravelTo]) -> bool: + """Return True if the plan requires a TravelTo before a TiltTo. + + This pattern occurs when a dual-motor tilt move requires the cover + to travel to an allowed position first. + """ + return ( + len(steps) >= 2 + and isinstance(steps[0], TravelTo) + and isinstance(steps[1], TiltTo) + ) + + +def calculate_pre_step_delay(steps, tilt_strategy, tilt_calc, travel_calc) -> float: + """Calculate delay before the primary calculator should start tracking. + + In sequential tilt mode (single motor), the strategy may plan a + pre-step (e.g. TiltTo before TravelTo). The pre-step must complete + before the primary movement begins, since both share the same motor. + Returns 0.0 if no pre-step or if the strategy uses a separate tilt motor. + """ + if tilt_strategy is None or tilt_strategy.uses_tilt_motor or len(steps) < 2: + return 0.0 + + first, second = steps[0], steps[1] + + # TiltTo before TravelTo: tilt is the pre-step + if isinstance(first, TiltTo) and isinstance(second, TravelTo): + current_tilt = tilt_calc.current_position() + if current_tilt is None: + return 0.0 + return tilt_calc.calculate_travel_time(current_tilt, first.target) + + # TravelTo before TiltTo: travel is the pre-step + if isinstance(first, TravelTo) and isinstance(second, TiltTo): + current_pos = travel_calc.current_position() + if current_pos is None: + return 0.0 + return travel_calc.calculate_travel_time(current_pos, first.target) + + return 0.0 diff --git a/custom_components/cover_time_based/tilt_strategies/sequential.py b/custom_components/cover_time_based/tilt_strategies/sequential.py new file mode 100644 index 0000000..cb1c1b7 --- /dev/null +++ b/custom_components/cover_time_based/tilt_strategies/sequential.py @@ -0,0 +1,70 @@ +"""Sequential tilt strategy. + +Tilt couples proportionally when travel moves, but travel does NOT +couple when tilt moves. No boundary constraints are enforced. +Tilt calibration is allowed. +""" + +from __future__ import annotations + +import logging + +from .base import TiltStrategy, TiltTo, TravelTo + +_LOGGER = logging.getLogger(__name__) + + +class SequentialTilt(TiltStrategy): + """Sequential tilt mode. + + Tilt couples proportionally when travel moves, but travel does NOT + couple when tilt moves. No boundary constraints are enforced. + Tilt calibration is allowed. + """ + + def can_calibrate_tilt(self) -> bool: + """Tilt calibration is allowed in sequential mode.""" + return True + + @property + def name(self) -> str: + return "sequential" + + @property + def uses_tilt_motor(self) -> bool: + return False + + @property + def restores_tilt(self) -> bool: + return False + + def plan_move_position( + self, target_pos: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + steps: list[TiltTo | TravelTo] = [] + if current_tilt != 100: + steps.append(TiltTo(100)) # flatten slats (fully open) before travel + steps.append(TravelTo(target_pos)) + return steps + + def plan_move_tilt( + self, target_tilt: int, current_pos: int, current_tilt: int + ) -> list[TiltTo | TravelTo]: + steps: list[TiltTo | TravelTo] = [] + if current_pos != 0: + steps.append(TravelTo(0)) # must be at closed position + steps.append(TiltTo(target_tilt)) + return steps + + def snap_trackers_to_physical(self, travel_calc, tilt_calc): + current_travel = travel_calc.current_position() + current_tilt_pos = tilt_calc.current_position() + if current_travel is None or current_tilt_pos is None: + return + if current_travel != 0 and current_tilt_pos != 100: + _LOGGER.debug( + "SequentialTilt :: Travel at %d%% (not closed), forcing tilt to 100%% (was %d%%)", + current_travel, + current_tilt_pos, + ) + tilt_calc.set_position(100) diff --git a/custom_components/cover_time_based/translations/en.json b/custom_components/cover_time_based/translations/en.json index 47f4940..3f76ed7 100644 --- a/custom_components/cover_time_based/translations/en.json +++ b/custom_components/cover_time_based/translations/en.json @@ -1,4 +1,21 @@ { + "config": { + "step": { + "user": { + "title": "Add a time-based cover", + "description": "Choose a name for your cover. After creating it, add the **Cover Time Based** card to a dashboard to configure all settings.", + "data": { + "name": "Name" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "YAML configuration is deprecated", + "description": "Configuring Cover Time Based using YAML is deprecated and will be removed in a future version.\n\nPlease remove the YAML configuration and recreate your covers using the UI: **Settings > Devices & Services > Helpers > Create Helper > Cover Time Based**." + } + }, "services": { "set_known_position": { "name": "Set cover position", @@ -9,8 +26,8 @@ "description": "The entity ID of the cover to set the position for." }, "position": { - "name": "Position", - "description": "The position of the cover, between 0 and 100." + "name": "Position", + "description": "The position of the cover, between 0 and 100." } } }, @@ -23,10 +40,46 @@ "description": "The entity ID of the cover to set the tilt position for." }, "position": { - "name": "Position", - "description": "The tilt position of the cover, between 0 and 100." + "name": "Position", + "description": "The tilt position of the cover, between 0 and 100." + } + } + }, + "start_calibration": { + "name": "Start calibration", + "description": "Start a calibration test to measure a timing parameter. The cover will begin moving — call stop_calibration when the desired endpoint is reached.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The cover to calibrate" + }, + "attribute": { + "name": "Attribute", + "description": "The timing parameter to calibrate" + }, + "timeout": { + "name": "Timeout", + "description": "Safety timeout in seconds — motor will auto-stop if stop_calibration is not called" + }, + "direction": { + "name": "Direction", + "description": "Direction to move during the test. If not set, auto-detects based on current position" + } + } + }, + "stop_calibration": { + "name": "Stop calibration", + "description": "Stop an active calibration test. Calculates the result and saves it to the configuration unless cancelled.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The cover being calibrated" + }, + "cancel": { + "name": "Cancel", + "description": "If true, discard the test results without saving" } } } } -} \ No newline at end of file +} diff --git a/custom_components/cover_time_based/translations/pl.json b/custom_components/cover_time_based/translations/pl.json index 06e2ba5..74624d6 100644 --- a/custom_components/cover_time_based/translations/pl.json +++ b/custom_components/cover_time_based/translations/pl.json @@ -1,4 +1,21 @@ { + "config": { + "step": { + "user": { + "title": "Dodaj roletę sterowaną czasowo", + "description": "Wybierz nazwę dla rolety. Po utworzeniu dodaj kartę **Cover Time Based** do panelu, aby skonfigurować wszystkie ustawienia.", + "data": { + "name": "Nazwa" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "Konfiguracja YAML jest przestarzała", + "description": "Konfiguracja Cover Time Based za pomocą YAML jest przestarzała i zostanie usunięta w przyszłej wersji.\n\nProszę usunąć konfigurację YAML i odtworzyć rolety za pomocą interfejsu: **Ustawienia > Urządzenia i usługi > Pomocniki > Utwórz pomocnik > Cover Time Based**." + } + }, "services": { "set_known_position": { "name": "Ustaw pozycję rolety", @@ -27,6 +44,42 @@ "description": "Pozycja nachylenia rolety w zakresie od 0 do 100." } } + }, + "start_calibration": { + "name": "Rozpocznij kalibrację", + "description": "Rozpoczyna test kalibracyjny do pomiaru parametru czasowego. Roleta zacznie się poruszać — wywołaj stop_calibration, gdy zostanie osiągnięty punkt docelowy.", + "fields": { + "entity_id": { + "name": "ID encji", + "description": "Roleta do kalibracji" + }, + "attribute": { + "name": "Atrybut", + "description": "Parametr czasowy do kalibracji" + }, + "timeout": { + "name": "Limit czasu", + "description": "Limit bezpieczeństwa w sekundach — silnik zatrzyma się automatycznie, jeśli stop_calibration nie zostanie wywołane" + }, + "direction": { + "name": "Kierunek", + "description": "Kierunek ruchu podczas testu. Jeśli nie ustawiono, wykrywany automatycznie na podstawie bieżącej pozycji" + } + } + }, + "stop_calibration": { + "name": "Zatrzymaj kalibrację", + "description": "Zatrzymuje aktywny test kalibracyjny. Oblicza wynik i zapisuje go w konfiguracji, chyba że został anulowany.", + "fields": { + "entity_id": { + "name": "ID encji", + "description": "Kalibrowana roleta" + }, + "cancel": { + "name": "Anuluj", + "description": "Jeśli prawda, odrzuć wyniki testu bez zapisywania" + } + } } } } diff --git a/custom_components/cover_time_based/translations/pt.json b/custom_components/cover_time_based/translations/pt.json index 5df2156..576851c 100644 --- a/custom_components/cover_time_based/translations/pt.json +++ b/custom_components/cover_time_based/translations/pt.json @@ -1,4 +1,21 @@ { + "config": { + "step": { + "user": { + "title": "Adicionar um estore baseado em tempo", + "description": "Escolha um nome para o seu estore. Depois de o criar, adicione o cartão **Cover Time Based** a um painel para configurar todas as definições.", + "data": { + "name": "Nome" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "A configuração YAML está obsoleta", + "description": "A configuração do Cover Time Based usando YAML está obsoleta e será removida numa versão futura.\n\nPor favor, remova a configuração YAML e recrie os seus estores usando a interface: **Definições > Dispositivos e Serviços > Auxiliares > Criar Auxiliar > Cover Time Based**." + } + }, "services": { "set_known_position": { "name": "Definir a posição do estore", @@ -9,8 +26,8 @@ "description": "Esta entidade guarda o identificador do estore." }, "position": { - "name": "Posição", - "description": "A posição tem de ser definida de 0 até 100." + "name": "Posição", + "description": "A posição tem de ser definida de 0 até 100." } } }, @@ -19,12 +36,48 @@ "description": "Selecionar a posição de rotação.", "fields": { "entity_id": { - "name": "Indentificador", + "name": "Identificador", "description": "O identificador define a posição." }, "position": { - "name": "Posição", - "description": "A rotação deve ser definida com um valor , entre 0 e 100." + "name": "Posição", + "description": "A rotação deve ser definida com um valor, entre 0 e 100." + } + } + }, + "start_calibration": { + "name": "Iniciar calibração", + "description": "Inicia um teste de calibração para medir um parâmetro de temporização. O estore começará a mover-se — chame stop_calibration quando o ponto final desejado for atingido.", + "fields": { + "entity_id": { + "name": "Identificador", + "description": "O estore a calibrar" + }, + "attribute": { + "name": "Atributo", + "description": "O parâmetro de temporização a calibrar" + }, + "timeout": { + "name": "Tempo limite", + "description": "Tempo limite de segurança em segundos — o motor para automaticamente se stop_calibration não for chamado" + }, + "direction": { + "name": "Direção", + "description": "Direção do movimento durante o teste. Se não definido, deteta automaticamente com base na posição atual" + } + } + }, + "stop_calibration": { + "name": "Parar calibração", + "description": "Para um teste de calibração ativo. Calcula o resultado e guarda-o na configuração, a menos que seja cancelado.", + "fields": { + "entity_id": { + "name": "Identificador", + "description": "O estore em calibração" + }, + "cancel": { + "name": "Cancelar", + "description": "Se verdadeiro, descarta os resultados do teste sem guardar" } } } diff --git a/custom_components/cover_time_based/travel_calculator.py b/custom_components/cover_time_based/travel_calculator.py new file mode 100644 index 0000000..e4876e6 --- /dev/null +++ b/custom_components/cover_time_based/travel_calculator.py @@ -0,0 +1,206 @@ +"""Position calculator for time-based covers. + +Predicts the current position of a cover based on travel time and direction. +Uses Home Assistant convention: 0 = fully closed, 100 = fully open. + +Derived from xknx.devices.TravelCalculator +(https://github.com/XKNX/xknx, MIT License). +Original convention (0=open, 100=closed) was inverted to match +Home Assistant's cover position convention (0=closed, 100=open). +""" + +from __future__ import annotations + +from enum import Enum +import time + + +class TravelStatus(Enum): + """Enum class for travel status.""" + + DIRECTION_UP = 1 + DIRECTION_DOWN = 2 + STOPPED = 3 + + +class TravelCalculator: + """Calculate the current position of a cover based on travel time. + + Position convention: 0 = fully closed, 100 = fully open. + """ + + __slots__ = ( + "_last_known_position", + "_last_known_position_timestamp", + "_position_confirmed", + "_travel_to_position", + "position_closed", + "position_open", + "travel_direction", + "travel_time_down", + "travel_time_up", + ) + + def __init__(self, travel_time_down: float, travel_time_up: float) -> None: + """Initialize TravelCalculator. + + Args: + travel_time_down: Time in seconds to travel from open to closed. + travel_time_up: Time in seconds to travel from closed to open. + """ + self.travel_direction = TravelStatus.STOPPED + self.travel_time_down = travel_time_down + self.travel_time_up = travel_time_up + + self._last_known_position: int | None = None + self._last_known_position_timestamp: float = 0.0 + self._position_confirmed: bool = False + self._travel_to_position: int | None = None + + # 0 is closed, 100 is fully open + self.position_closed: int = 0 + self.position_open: int = 100 + + def set_position(self, position: int) -> None: + """Set position and target of cover.""" + self._travel_to_position = position + self.update_position(position) + + def update_position(self, position: int) -> None: + """Update known position of cover.""" + self._last_known_position = position + self._last_known_position_timestamp = time.time() + if position == self._travel_to_position: + self._position_confirmed = True + + def clear_position(self) -> None: + """Clear position to unknown (e.g. after external movement).""" + self._last_known_position = None + self._travel_to_position = None + self._position_confirmed = False + self.travel_direction = TravelStatus.STOPPED + + def stop(self) -> None: + """Stop traveling.""" + stop_position = self.current_position() + if stop_position is None: + return + self._last_known_position = stop_position + self._travel_to_position = stop_position + self._position_confirmed = False + self.travel_direction = TravelStatus.STOPPED + + def start_travel(self, _travel_to_position: int, delay: float = 0.0) -> None: + """Start traveling to position. + + Args: + _travel_to_position: Target position. + delay: Seconds to wait before tracking starts. Used for + sequential multi-step movements where a pre-step (e.g. tilt) + must complete before this calculator begins progressing. + """ + if self._last_known_position is None: + self.set_position(_travel_to_position) + return + self.stop() + self._last_known_position_timestamp = time.time() + delay + self._travel_to_position = _travel_to_position + self._position_confirmed = False + + self.travel_direction = ( + TravelStatus.DIRECTION_UP + if _travel_to_position > self._last_known_position + else TravelStatus.DIRECTION_DOWN + ) + + def start_travel_up(self) -> None: + """Start traveling up (opening).""" + self.start_travel(self.position_open) + + def start_travel_down(self) -> None: + """Start traveling down (closing).""" + self.start_travel(self.position_closed) + + def current_position(self) -> int | None: + """Return current (calculated or known) position.""" + if not self._position_confirmed: + return self._calculate_position() + return self._last_known_position + + def is_traveling(self) -> bool: + """Return if cover is traveling.""" + return self.current_position() != self._travel_to_position + + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return ( + self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_UP + ) + + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return ( + self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_DOWN + ) + + def position_reached(self) -> bool: + """Return if cover has reached designated position.""" + return self.current_position() == self._travel_to_position + + def is_open(self) -> bool: + """Return if cover is (fully) open.""" + return self.current_position() == self.position_open + + def is_closed(self) -> bool: + """Return if cover is (fully) closed.""" + return self.current_position() == self.position_closed + + def _calculate_position(self) -> int | None: + """Return calculated position.""" + if self._travel_to_position is None or self._last_known_position is None: + return self._last_known_position + relative_position = self._travel_to_position - self._last_known_position + + def position_reached_or_exceeded(relative_position: int) -> bool: + """Return if designated position was reached. + + DOWN means position is decreasing (e.g. 100→0). relative starts + negative and reaches 0 (or positive if overshot) when done. + UP means position is increasing (e.g. 0→100). relative starts + positive and reaches 0 (or negative if overshot) when done. + """ + return ( + relative_position >= 0 + and self.travel_direction == TravelStatus.DIRECTION_DOWN + ) or ( + relative_position <= 0 + and self.travel_direction == TravelStatus.DIRECTION_UP + ) + + if position_reached_or_exceeded(relative_position): + return self._travel_to_position + + remaining_travel_time = self.calculate_travel_time( + from_position=self._last_known_position, + to_position=self._travel_to_position, + ) + if remaining_travel_time <= 0: + return self._travel_to_position + if time.time() > self._last_known_position_timestamp + remaining_travel_time: + return self._travel_to_position + + progress = max( + 0.0, + (time.time() - self._last_known_position_timestamp) / remaining_travel_time, + ) + return int(self._last_known_position + relative_position * progress) + + def calculate_travel_time(self, from_position: int, to_position: int) -> float: + """Calculate time to travel from one position to another.""" + travel_range = to_position - from_position + # Positive range = opening (position increasing), use travel_time_up + # Negative range = closing (position decreasing), use travel_time_down + travel_time_full = ( + self.travel_time_up if travel_range > 0 else self.travel_time_down + ) + return travel_time_full * abs(travel_range) / 100 diff --git a/custom_components/cover_time_based/websocket_api.py b/custom_components/cover_time_based/websocket_api.py new file mode 100644 index 0000000..41bf13c --- /dev/null +++ b/custom_components/cover_time_based/websocket_api.py @@ -0,0 +1,355 @@ +"""WebSocket API for cover_time_based configuration card.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .calibration import CALIBRATABLE_ATTRIBUTES +from .cover import ( + CONF_CLOSE_SWITCH_ENTITY_ID, + CONF_CONTROL_MODE, + CONF_COVER_ENTITY_ID, + CONF_MIN_MOVEMENT_TIME, + CONF_MAX_TILT_ALLOWED_POSITION, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_PULSE_TIME, + CONF_SAFE_TILT_POSITION, + CONF_STOP_SWITCH_ENTITY_ID, + CONF_ENDPOINT_RUNON_TIME, + CONF_TILT_CLOSE_SWITCH, + CONF_TILT_MODE, + CONF_TILT_OPEN_SWITCH, + CONF_TILT_STARTUP_DELAY, + CONF_TILT_STOP_SWITCH, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + CONTROL_MODE_PULSE, + CONTROL_MODE_SWITCH, + CONTROL_MODE_TOGGLE, + CONTROL_MODE_WRAPPED, + DEFAULT_ENDPOINT_RUNON_TIME, + DEFAULT_PULSE_TIME, +) + +from .const import DOMAIN +from .helpers import resolve_entity_or_none + +_LOGGER = logging.getLogger(__name__) + +# Map from WS field names to config entry option keys +_FIELD_MAP = { + "control_mode": CONF_CONTROL_MODE, + "pulse_time": CONF_PULSE_TIME, + "open_switch_entity_id": CONF_OPEN_SWITCH_ENTITY_ID, + "close_switch_entity_id": CONF_CLOSE_SWITCH_ENTITY_ID, + "stop_switch_entity_id": CONF_STOP_SWITCH_ENTITY_ID, + "cover_entity_id": CONF_COVER_ENTITY_ID, + "tilt_mode": CONF_TILT_MODE, + "travel_time_close": CONF_TRAVEL_TIME_CLOSE, + "travel_time_open": CONF_TRAVEL_TIME_OPEN, + "tilt_time_close": CONF_TILT_TIME_CLOSE, + "tilt_time_open": CONF_TILT_TIME_OPEN, + "travel_startup_delay": CONF_TRAVEL_STARTUP_DELAY, + "tilt_startup_delay": CONF_TILT_STARTUP_DELAY, + "endpoint_runon_time": CONF_ENDPOINT_RUNON_TIME, + "min_movement_time": CONF_MIN_MOVEMENT_TIME, + "safe_tilt_position": CONF_SAFE_TILT_POSITION, + "max_tilt_allowed_position": CONF_MAX_TILT_ALLOWED_POSITION, + "tilt_open_switch": CONF_TILT_OPEN_SWITCH, + "tilt_close_switch": CONF_TILT_CLOSE_SWITCH, + "tilt_stop_switch": CONF_TILT_STOP_SWITCH, +} + + +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register WebSocket API commands.""" + websocket_api.async_register_command(hass, ws_get_config) + websocket_api.async_register_command(hass, ws_update_config) + websocket_api.async_register_command(hass, ws_start_calibration) + websocket_api.async_register_command(hass, ws_stop_calibration) + websocket_api.async_register_command(hass, ws_raw_command) + + +def _resolve_config_entry(hass: HomeAssistant, entity_id: str): + """Resolve an entity_id to its config entry. + + Returns (config_entry, error_msg) tuple. + """ + entity_reg = er.async_get(hass) + entry = entity_reg.async_get(entity_id) + if not entry or not entry.config_entry_id: + return None, "Entity not found or not a config entry entity" + + config_entry = hass.config_entries.async_get_entry(entry.config_entry_id) + if not config_entry or config_entry.domain != DOMAIN: + return None, "Entity does not belong to cover_time_based" + + return config_entry, None + + +@websocket_api.websocket_command( + { + "type": "cover_time_based/get_config", + vol.Required("entity_id"): str, + } +) +@websocket_api.async_response +async def ws_get_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle get_config WebSocket command.""" + config_entry, error = _resolve_config_entry(hass, msg["entity_id"]) + if error or config_entry is None: + connection.send_error(msg["id"], "not_found", error or "Config entry not found") + return + + options = config_entry.options + connection.send_result( + msg["id"], + { + "entry_id": config_entry.entry_id, + "control_mode": options.get(CONF_CONTROL_MODE, CONTROL_MODE_SWITCH), + "pulse_time": options.get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME), + "open_switch_entity_id": options.get(CONF_OPEN_SWITCH_ENTITY_ID), + "close_switch_entity_id": options.get(CONF_CLOSE_SWITCH_ENTITY_ID), + "stop_switch_entity_id": options.get(CONF_STOP_SWITCH_ENTITY_ID), + "cover_entity_id": options.get(CONF_COVER_ENTITY_ID), + "tilt_mode": options.get(CONF_TILT_MODE, "none"), + "travel_time_close": options.get(CONF_TRAVEL_TIME_CLOSE), + "travel_time_open": options.get(CONF_TRAVEL_TIME_OPEN), + "tilt_time_close": options.get(CONF_TILT_TIME_CLOSE), + "tilt_time_open": options.get(CONF_TILT_TIME_OPEN), + "travel_startup_delay": options.get(CONF_TRAVEL_STARTUP_DELAY), + "tilt_startup_delay": options.get(CONF_TILT_STARTUP_DELAY), + "endpoint_runon_time": options.get( + CONF_ENDPOINT_RUNON_TIME, DEFAULT_ENDPOINT_RUNON_TIME + ), + "min_movement_time": options.get(CONF_MIN_MOVEMENT_TIME), + "safe_tilt_position": options.get(CONF_SAFE_TILT_POSITION, 100), + "max_tilt_allowed_position": options.get(CONF_MAX_TILT_ALLOWED_POSITION), + "tilt_open_switch": options.get(CONF_TILT_OPEN_SWITCH), + "tilt_close_switch": options.get(CONF_TILT_CLOSE_SWITCH), + "tilt_stop_switch": options.get(CONF_TILT_STOP_SWITCH), + }, + ) + + +@websocket_api.websocket_command( + { + "type": "cover_time_based/update_config", + vol.Required("entity_id"): str, + vol.Optional("control_mode"): vol.In( + [ + CONTROL_MODE_WRAPPED, + CONTROL_MODE_SWITCH, + CONTROL_MODE_PULSE, + CONTROL_MODE_TOGGLE, + ] + ), + vol.Optional("pulse_time"): vol.All( + vol.Coerce(float), vol.Range(min=0.1, max=10) + ), + vol.Optional("open_switch_entity_id"): vol.Any(str, None), + vol.Optional("close_switch_entity_id"): vol.Any(str, None), + vol.Optional("stop_switch_entity_id"): vol.Any(str, None), + vol.Optional("cover_entity_id"): vol.Any(str, None), + vol.Optional("tilt_mode"): vol.In( + ["none", "sequential", "dual_motor", "inline"] + ), + vol.Optional("travel_time_close"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0.1, max=600)) + ), + vol.Optional("travel_time_open"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0.1, max=600)) + ), + vol.Optional("tilt_time_close"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0.1, max=600)) + ), + vol.Optional("tilt_time_open"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0.1, max=600)) + ), + vol.Optional("travel_startup_delay"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600)) + ), + vol.Optional("tilt_startup_delay"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600)) + ), + vol.Optional("endpoint_runon_time"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600)) + ), + vol.Optional("min_movement_time"): vol.Any( + None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600)) + ), + vol.Optional("safe_tilt_position"): vol.Any( + None, vol.All(int, vol.Range(min=0, max=100)) + ), + vol.Optional("max_tilt_allowed_position"): vol.Any( + None, vol.All(int, vol.Range(min=0, max=100)) + ), + vol.Optional("tilt_open_switch"): vol.Any(str, None), + vol.Optional("tilt_close_switch"): vol.Any(str, None), + vol.Optional("tilt_stop_switch"): vol.Any(str, None), + } +) +@websocket_api.async_response +async def ws_update_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle update_config WebSocket command.""" + config_entry, error = _resolve_config_entry(hass, msg["entity_id"]) + if error or config_entry is None: + connection.send_error(msg["id"], "not_found", error or "Config entry not found") + return + + # Reject wrapping another cover_time_based entity + cover_entity_id = msg.get("cover_entity_id") + if cover_entity_id: + entity_reg = er.async_get(hass) + target = entity_reg.async_get(cover_entity_id) + if target and target.platform == DOMAIN: + connection.send_error( + msg["id"], + "invalid_entity", + "Cannot wrap another Cover Time Based entity", + ) + return + + new_options = dict(config_entry.options) + + for ws_key, conf_key in _FIELD_MAP.items(): + if ws_key in msg: + value = msg[ws_key] + if value is None: + new_options.pop(conf_key, None) + else: + new_options[conf_key] = value + + hass.config_entries.async_update_entry(config_entry, options=new_options) + + connection.send_result(msg["id"], {"success": True}) + + +@websocket_api.websocket_command( + { + "type": "cover_time_based/start_calibration", + vol.Required("entity_id"): str, + vol.Required("attribute"): vol.In(CALIBRATABLE_ATTRIBUTES), + vol.Required("timeout"): vol.All(vol.Coerce(float), vol.Range(min=1)), + vol.Optional("direction"): vol.In(["open", "close"]), + } +) +@websocket_api.async_response +async def ws_start_calibration( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle start_calibration WebSocket command.""" + entity = resolve_entity_or_none(hass, msg["entity_id"]) + if entity is None: + connection.send_error(msg["id"], "not_found", "Entity not found") + return + + try: + kwargs = {"attribute": msg["attribute"], "timeout": msg["timeout"]} + if "direction" in msg: + kwargs["direction"] = msg["direction"] + await entity.start_calibration(**kwargs) + except Exception as exc: # noqa: BLE001 + connection.send_error(msg["id"], "failed", str(exc)) + return + + connection.send_result(msg["id"], {"success": True}) + + +@websocket_api.websocket_command( + { + "type": "cover_time_based/stop_calibration", + vol.Required("entity_id"): str, + vol.Optional("cancel", default=False): bool, + } +) +@websocket_api.async_response +async def ws_stop_calibration( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle stop_calibration WebSocket command.""" + entity = resolve_entity_or_none(hass, msg["entity_id"]) + if entity is None: + connection.send_error(msg["id"], "not_found", "Entity not found") + return + + try: + result = await entity.stop_calibration(cancel=msg["cancel"]) + except Exception as exc: # noqa: BLE001 + connection.send_error(msg["id"], "failed", str(exc)) + return + + connection.send_result(msg["id"], result) + + +@websocket_api.websocket_command( + { + "type": "cover_time_based/raw_command", + vol.Required("entity_id"): str, + vol.Required("command"): vol.In( + ["open", "close", "stop", "tilt_open", "tilt_close", "tilt_stop"] + ), + } +) +@websocket_api.async_response +async def ws_raw_command( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Send open/close/stop directly to the underlying device, bypassing the position tracker.""" + entity = resolve_entity_or_none(hass, msg["entity_id"]) + if entity is None: + connection.send_error(msg["id"], "not_found", "Entity not found") + return + + command = msg["command"] + + # Validate tilt motor for tilt commands early + if command.startswith("tilt_") and not entity._has_tilt_motor(): + connection.send_error(msg["id"], "not_supported", "Tilt motor not configured") + return + + try: + # Stop active lifecycle tracking (calibration manages its own state) + if entity._calibration is None: + entity._cancel_startup_delay_task() + entity._cancel_delay_task() + entity._handle_stop() + + await entity._raw_direction_command(command) + + # Clear tracked position (outside of calibration) + if entity._calibration is None: + if command.startswith("tilt_"): + if entity._has_tilt_support(): + entity.tilt_calc.clear_position() + else: + entity.travel_calc.clear_position() + entity.async_write_ha_state() + except Exception as exc: # noqa: BLE001 + connection.send_error(msg["id"], "failed", str(exc)) + return + + connection.send_result(msg["id"], {"success": True}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..385968a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +include = ["custom_components"] +pythonVersion = "3.13" +# HA base classes (CoverEntity + RestoreEntity) define conflicting attributes +# that pyright flags but are not actual errors in practice. +reportIncompatibleVariableOverride = false +# HA's websocket_api module uses __all__ but pyright still flags decorators +# like @websocket_command and @async_response as private. +reportPrivateImportUsage = false diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6d410c3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for cover_time_based.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..693c410 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,163 @@ +"""Shared fixtures for cover_time_based tests.""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from custom_components.cover_time_based.cover import ( + CONF_CLOSE_SWITCH_ENTITY_ID, + CONF_CONTROL_MODE, + CONF_COVER_ENTITY_ID, + CONF_MAX_TILT_ALLOWED_POSITION, + CONF_MIN_MOVEMENT_TIME, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_PULSE_TIME, + CONF_SAFE_TILT_POSITION, + CONF_STOP_SWITCH_ENTITY_ID, + CONF_ENDPOINT_RUNON_TIME, + CONF_TILT_CLOSE_SWITCH, + CONF_TILT_MODE, + CONF_TILT_OPEN_SWITCH, + CONF_TILT_STARTUP_DELAY, + CONF_TILT_STOP_SWITCH, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + CONTROL_MODE_SWITCH, + CONTROL_MODE_WRAPPED, + DEFAULT_PULSE_TIME, + _create_cover_from_options, +) + +DEFAULT_TRAVEL_TIME = 30 + + +@pytest.fixture +def make_hass(): + """Return a factory that creates a minimal mock HA instance.""" + + def _make(): + hass = MagicMock() + hass.services.async_call = AsyncMock() + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + hass._test_tasks = created_tasks + return hass + + return _make + + +@pytest.fixture +def make_cover(make_hass): + """Return a factory that creates the appropriate cover subclass wired to a mock hass.""" + covers = [] + + def _make( + control_mode=CONTROL_MODE_SWITCH, + cover_entity_id=None, + open_switch="switch.open", + close_switch="switch.close", + stop_switch=None, + pulse_time=DEFAULT_PULSE_TIME, + travel_time_close=DEFAULT_TRAVEL_TIME, + travel_time_open=DEFAULT_TRAVEL_TIME, + tilt_time_close=None, + tilt_time_open=None, + tilt_mode="none", + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + tilt_open_switch=None, + tilt_close_switch=None, + tilt_stop_switch=None, + safe_tilt_position=None, + max_tilt_allowed_position=None, + ): + if cover_entity_id is not None: + options = { + CONF_CONTROL_MODE: CONTROL_MODE_WRAPPED, + CONF_COVER_ENTITY_ID: cover_entity_id, + CONF_TRAVEL_TIME_CLOSE: travel_time_close, + CONF_TRAVEL_TIME_OPEN: travel_time_open, + } + else: + options = { + CONF_CONTROL_MODE: control_mode, + CONF_OPEN_SWITCH_ENTITY_ID: open_switch, + CONF_CLOSE_SWITCH_ENTITY_ID: close_switch, + CONF_STOP_SWITCH_ENTITY_ID: stop_switch, + CONF_PULSE_TIME: pulse_time, + CONF_TRAVEL_TIME_CLOSE: travel_time_close, + CONF_TRAVEL_TIME_OPEN: travel_time_open, + } + + if tilt_time_close is not None: + options[CONF_TILT_TIME_CLOSE] = tilt_time_close + if tilt_time_open is not None: + options[CONF_TILT_TIME_OPEN] = tilt_time_open + # Default to sequential when tilt times are provided but no explicit mode + effective_tilt_mode = tilt_mode + if ( + effective_tilt_mode == "none" + and tilt_time_close is not None + and tilt_time_open is not None + ): + effective_tilt_mode = "sequential" + if effective_tilt_mode != "none": + options[CONF_TILT_MODE] = effective_tilt_mode + if travel_startup_delay is not None: + options[CONF_TRAVEL_STARTUP_DELAY] = travel_startup_delay + if tilt_startup_delay is not None: + options[CONF_TILT_STARTUP_DELAY] = tilt_startup_delay + if endpoint_runon_time is not None: + options[CONF_ENDPOINT_RUNON_TIME] = endpoint_runon_time + if min_movement_time is not None: + options[CONF_MIN_MOVEMENT_TIME] = min_movement_time + if tilt_open_switch is not None: + options[CONF_TILT_OPEN_SWITCH] = tilt_open_switch + if tilt_close_switch is not None: + options[CONF_TILT_CLOSE_SWITCH] = tilt_close_switch + if tilt_stop_switch is not None: + options[CONF_TILT_STOP_SWITCH] = tilt_stop_switch + if safe_tilt_position is not None: + options[CONF_SAFE_TILT_POSITION] = safe_tilt_position + if max_tilt_allowed_position is not None: + options[CONF_MAX_TILT_ALLOWED_POSITION] = max_tilt_allowed_position + + cover = _create_cover_from_options( + options, + device_id="test_cover", + name="Test Cover", + ) + cover.hass = make_hass() + cover._config_entry_id = "test_cover" + covers.append(cover) + return cover + + yield _make + + for cover in covers: + for attr in ("_startup_delay_task", "_delay_task"): + task = getattr(cover, attr, None) + if task is not None and not task.done(): + task.cancel() + calibration = getattr(cover, "_calibration", None) + if calibration is not None: + for cal_attr in ("timeout_task", "automation_task"): + task = getattr(calibration, cal_attr, None) + if task is not None and not task.done(): + task.cancel() + # Cancel any background pulse tasks + for task in getattr(cover.hass, "_test_tasks", []): + if not task.done(): + task.cancel() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..9dadda6 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,100 @@ +"""Integration test fixtures for cover_time_based. + +Uses pytest-homeassistant-custom-component for a real HA instance. +input_boolean entities simulate physical relay switches. +""" + +from __future__ import annotations + +import pytest +from homeassistant.components.frontend import DATA_EXTRA_MODULE_URL +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry + +DOMAIN = "cover_time_based" + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations for all tests in this directory.""" + return + + +@pytest.fixture(autouse=True) +def stub_frontend(hass: HomeAssistant): + """Stub frontend data so the integration can register extra JS URLs.""" + from homeassistant.components.frontend import UrlManager + + if DATA_EXTRA_MODULE_URL not in hass.data: + hass.data[DATA_EXTRA_MODULE_URL] = UrlManager(lambda *_: None, []) + + +@pytest.fixture +async def setup_input_booleans(hass: HomeAssistant): + """Create input_boolean entities to act as mock switches. + + Also sets up the homeassistant component (turn_on/turn_off services) + which the cover uses to control relays. + """ + # Clear any pre-loaded state so async_setup_component actually runs + # async_setup and registers services (homeassistant.turn_on/turn_off). + # Without this, some test environments mark the component as loaded + # without running async_setup, causing ServiceNotFound errors. + if "homeassistant" in hass.config.components: + hass.config.components.remove("homeassistant") + hass.data.setdefault("setup_tasks", {}).pop("homeassistant", None) + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, + "input_boolean", + { + "input_boolean": { + "open_switch": {"name": "Open Switch"}, + "close_switch": {"name": "Close Switch"}, + "stop_switch": {"name": "Stop Switch"}, + "tilt_open": {"name": "Tilt Open"}, + "tilt_close": {"name": "Tilt Close"}, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +def base_options(): + """Return minimal config options for a switch-mode cover.""" + return { + "control_mode": "switch", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 30.0, + "travel_time_close": 30.0, + } + + +@pytest.fixture +async def setup_cover(hass: HomeAssistant, setup_input_booleans, base_options): + """Create and load a cover_time_based config entry. + + Yields the entry, then unloads it on teardown to cancel all timers + and listeners (auto_updater_hook, state change listeners, etc.). + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + title="Test Cover", + data={}, + options=base_options, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("cover.test_cover") + assert state is not None, "Cover entity was not created" + + yield entry + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/integration/test_feedback.py b/tests/integration/test_feedback.py new file mode 100644 index 0000000..30ee837 --- /dev/null +++ b/tests/integration/test_feedback.py @@ -0,0 +1,115 @@ +"""Integration tests for switch feedback loop. + +Tests echo filtering (cover-initiated switch changes are not treated as +external) and external button detection (direct switch changes trigger +movement). +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch +import time as time_mod + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import async_fire_time_changed + + +class MockTime: + """Controllable time source for TravelCalculator.""" + + def __init__(self): + self._base = time_mod.time() + self._total_offset = 0.0 + + def time(self): + return self._base + self._total_offset + + def advance(self, seconds: float): + self._total_offset += seconds + + +def _get_cover_entity(hass: HomeAssistant): + """Return the CoverTimeBased entity object.""" + entity_comp = hass.data["entity_components"]["cover"] + entities = [e for e in entity_comp.entities if e.entity_id == "cover.test_cover"] + assert entities, "Cover entity not found" + return entities[0] + + +async def test_echo_filtering(hass: HomeAssistant, setup_cover): + """Cover-initiated switch ON should not be treated as external press. + + When cover.open_cover turns on the open switch, the resulting + state_changed event should be filtered by echo detection, not + interpreted as an external button press (which would cause double-start). + """ + mt = MockTime() + with patch("time.time", mt.time): + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=50) + await hass.async_block_till_done() + + # Open the cover — this turns on the open switch internally + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + # Cover should be opening, switch should be on + assert hass.states.get("input_boolean.open_switch").state == "on" + assert cover.is_opening + + # Advance time a bit — if echo filtering failed, the external handler + # would have called async_open_cover again, potentially causing issues + mt.advance(2.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=2), fire_all=True + ) + await hass.async_block_till_done() + + # Cover should still be opening normally (not stopped or restarted) + assert cover.is_opening + pos = cover.current_cover_position + assert pos is not None + assert pos > 50, f"Expected position > 50, got {pos}" + + +async def test_external_button_press(hass: HomeAssistant, setup_cover): + """Directly toggling a switch should be detected as external movement. + + Turning on the open switch without going through the cover service + should trigger the cover to start tracking movement. + """ + mt = MockTime() + with patch("time.time", mt.time): + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=50) + await hass.async_block_till_done() + + # Directly turn on the open switch (simulating physical button press) + await hass.services.async_call( + "input_boolean", + "turn_on", + {"entity_id": "input_boolean.open_switch"}, + blocking=True, + ) + await hass.async_block_till_done() + + # The cover should detect this as an external state change + # and start tracking movement + assert cover.is_opening + + # Advance time to verify position is tracking + mt.advance(3.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=3), fire_all=True + ) + await hass.async_block_till_done() + + pos = cover.current_cover_position + assert pos is not None + assert pos > 50, f"Expected position > 50 after external open, got {pos}" diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_lifecycle.py new file mode 100644 index 0000000..39334fc --- /dev/null +++ b/tests/integration/test_lifecycle.py @@ -0,0 +1,98 @@ +"""Integration tests for config lifecycle and restart. + +Tests correct entity creation from config and position restore on restart. +""" + +from __future__ import annotations + +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from .conftest import DOMAIN + + +def _get_cover_entity(hass: HomeAssistant): + """Return the CoverTimeBased entity object.""" + entity_comp = hass.data["entity_components"]["cover"] + entities = [e for e in entity_comp.entities if e.entity_id == "cover.test_cover"] + assert entities, "Cover entity not found" + return entities[0] + + +async def test_config_creates_correct_entity(hass: HomeAssistant, setup_input_booleans): + """Config entry with pulse mode and tilt creates entity with correct features.""" + options = { + "control_mode": "pulse", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "stop_switch_entity_id": "input_boolean.stop_switch", + "travel_time_open": 30.0, + "travel_time_close": 30.0, + "tilt_mode": "sequential", + "tilt_time_open": 2.0, + "tilt_time_close": 2.0, + "pulse_time": 0.5, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("cover.test_cover") + assert state is not None + + features = state.attributes.get("supported_features", 0) + + # Should support position (open/close/stop/set_position) + assert features & CoverEntityFeature.OPEN + assert features & CoverEntityFeature.CLOSE + assert features & CoverEntityFeature.STOP + assert features & CoverEntityFeature.SET_POSITION + + # Should support tilt (since tilt_mode is configured with times) + assert features & CoverEntityFeature.SET_TILT_POSITION + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_position_restored_on_restart(hass: HomeAssistant, setup_input_booleans): + """Position is restored after config entry unload and reload.""" + options = { + "control_mode": "switch", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 30.0, + "travel_time_close": 30.0, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + cover = _get_cover_entity(hass) + + # Set position to 50 + await cover.set_known_position(position=50) + await hass.async_block_till_done() + assert cover.current_cover_position == 50 + + # Unload + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Reload + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Get the newly created entity + cover = _get_cover_entity(hass) + assert cover.current_cover_position == 50 + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/integration/test_modes.py b/tests/integration/test_modes.py new file mode 100644 index 0000000..4f98e45 --- /dev/null +++ b/tests/integration/test_modes.py @@ -0,0 +1,161 @@ +"""Integration tests for mode-specific behavior. + +Tests toggle mode stop-before-reverse and pulse mode relay pulsing. +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from unittest.mock import patch +import time as time_mod + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + async_fire_time_changed, +) + +from .conftest import DOMAIN + + +class MockTime: + """Controllable time source for TravelCalculator.""" + + def __init__(self): + self._base = time_mod.time() + self._total_offset = 0.0 + + def time(self): + return self._base + self._total_offset + + def advance(self, seconds: float): + self._total_offset += seconds + + +def _get_cover_entity(hass: HomeAssistant): + """Return the CoverTimeBased entity object.""" + entity_comp = hass.data["entity_components"]["cover"] + entities = [e for e in entity_comp.entities if e.entity_id == "cover.test_cover"] + assert entities, "Cover entity not found" + return entities[0] + + +async def test_toggle_stop_before_reverse(hass: HomeAssistant, setup_input_booleans): + """Toggle mode: closing while opening sends stop then close.""" + real_sleep = asyncio.sleep + + async def instant_sleep(delay, *args, **kwargs): + await real_sleep(0) + + options = { + "control_mode": "toggle", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 10.0, + "travel_time_close": 10.0, + "endpoint_runon_time": 0, + "pulse_time": 0.5, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mt = MockTime() + with patch("time.time", mt.time), patch("asyncio.sleep", instant_sleep): + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=50) + await hass.async_block_till_done() + + # Start opening + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + assert cover.is_opening + + mt.advance(2.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=2), fire_all=True + ) + await hass.async_block_till_done() + + # Now close — toggle mode should stop first, then close + await hass.services.async_call( + "cover", "close_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + # Cover should now be closing (stop + reverse happened) + assert cover.is_closing + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_pulse_mode_relay_pulsing(hass: HomeAssistant, setup_input_booleans): + """Pulse mode: open switch pulses on then off after pulse_time.""" + real_sleep = asyncio.sleep + + async def instant_sleep(delay, *args, **kwargs): + await real_sleep(0) + + options = { + "control_mode": "pulse", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "stop_switch_entity_id": "input_boolean.stop_switch", + "travel_time_open": 10.0, + "travel_time_close": 10.0, + "endpoint_runon_time": 0, + "pulse_time": 0.5, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mt = MockTime() + with patch("time.time", mt.time), patch("asyncio.sleep", instant_sleep): + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=50) + await hass.async_block_till_done() + + # Open the cover + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + # In pulse mode, switch should pulse on then off + # With instant sleep, the pulse completes immediately + assert hass.states.get("input_boolean.open_switch").state == "off" + assert cover.is_opening + + # Stop the cover — should pulse stop switch + mt.advance(2.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=2), fire_all=True + ) + await hass.async_block_till_done() + + await hass.services.async_call( + "cover", "stop_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + # Stop switch should have pulsed (on then off with instant sleep) + assert hass.states.get("input_boolean.stop_switch").state == "off" + assert not cover.is_opening + assert not cover.is_closing + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/integration/test_movement.py b/tests/integration/test_movement.py new file mode 100644 index 0000000..d9f08d2 --- /dev/null +++ b/tests/integration/test_movement.py @@ -0,0 +1,207 @@ +"""Integration tests for movement lifecycle. + +Tests open/close/stop, position tracking, auto-stop, and endpoint resync +through the real HA service calls and event bus. +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from unittest.mock import patch +import time as time_mod + +import pytest +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + async_fire_time_changed, +) + +from .conftest import DOMAIN + + +@pytest.fixture +def base_options(): + """Short travel times, no endpoint run-on for basic tests.""" + return { + "control_mode": "switch", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 10.0, + "travel_time_close": 10.0, + "endpoint_runon_time": 0, + } + + +def _get_cover_entity(hass: HomeAssistant): + """Return the CoverTimeBased entity object (not just state).""" + entity_comp = hass.data["entity_components"]["cover"] + entities = [e for e in entity_comp.entities if e.entity_id == "cover.test_cover"] + assert entities, "Cover entity not found" + return entities[0] + + +class MockTime: + """Controllable time source for TravelCalculator. + + Patches time.time so the TravelCalculator sees time advancing. + Does NOT interfere with async_fire_time_changed which relies on + the real time.time for its mock_seconds_into_future calculation. + """ + + def __init__(self): + self._base = time_mod.time() + self._total_offset = 0.0 + + @property + def real_base(self): + return self._base + + def time(self): + return self._base + self._total_offset + + def advance(self, seconds: float): + self._total_offset += seconds + + +async def _advance_time(hass: HomeAssistant, mock_time: MockTime, seconds: float): + """Advance mock time.time and fire HA timer handles. + + Uses fire_all=True to fire ALL scheduled timer handles (regardless + of how far in the future they're scheduled), since async_track_time_interval + uses loop.call_at which needs this to fire in tests. + """ + mock_time.advance(seconds) + # We need a future timestamp for _async_fire_time_changed to fire the + # scheduled timer handles. fire_all=True fires all handles regardless. + future = dt_util.utcnow() + timedelta(seconds=seconds) + async_fire_time_changed(hass, future, fire_all=True) + await hass.async_block_till_done() + + +@pytest.fixture +def mock_time(): + """Provide a controllable time source and patch time.time.""" + mt = MockTime() + with patch("time.time", mt.time): + yield mt + + +async def test_open_track_auto_stop(hass: HomeAssistant, setup_cover, mock_time): + """Open -> position tracks upward -> auto-stops at 100%.""" + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=0) + await hass.async_block_till_done() + assert cover.current_cover_position == 0 + + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + assert hass.states.get("input_boolean.open_switch").state == "on" + + # Advance to ~50% + await _advance_time(hass, mock_time, 5.0) + pos = cover.current_cover_position + assert pos is not None + assert 20 <= pos <= 80, f"Expected ~50%, got {pos}%" + + # Advance past full travel + await _advance_time(hass, mock_time, 7.0) + assert cover.current_cover_position == 100 + assert hass.states.get("input_boolean.open_switch").state == "off" + + +async def test_stop_during_movement(hass: HomeAssistant, setup_cover, mock_time): + """Stop during movement freezes position at intermediate value.""" + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=0) + await hass.async_block_till_done() + + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + await _advance_time(hass, mock_time, 5.0) + + await hass.services.async_call( + "cover", "stop_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + pos = cover.current_cover_position + assert pos is not None + assert 20 <= pos <= 80, f"Expected ~50%, got {pos}%" + assert hass.states.get("input_boolean.open_switch").state == "off" + + +async def test_set_position_mid_range(hass: HomeAssistant, setup_cover, mock_time): + """set_cover_position(50) moves to target and stops.""" + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=0) + await hass.async_block_till_done() + + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": "cover.test_cover", "position": 50}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("input_boolean.open_switch").state == "on" + + await _advance_time(hass, mock_time, 7.0) + + pos = cover.current_cover_position + assert pos is not None + assert 40 <= pos <= 60, f"Expected ~50%, got {pos}%" + assert hass.states.get("input_boolean.open_switch").state == "off" + + +async def test_endpoint_resync( + hass: HomeAssistant, setup_input_booleans, base_options, mock_time +): + """Closing when already at 0 should still fire relay + run-on.""" + options = {**base_options, "endpoint_runon_time": 2.0} + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + title="Test Cover", + data={}, + options=options, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + cover = _get_cover_entity(hass) + + await cover.set_known_position(position=0) + await hass.async_block_till_done() + assert cover.current_cover_position == 0 + + # Patch asyncio.sleep so the _delayed_stop completes instantly + real_sleep = asyncio.sleep + + async def instant_sleep(delay, *args, **kwargs): + await real_sleep(0) + + with patch("asyncio.sleep", instant_sleep): + await hass.services.async_call( + "cover", "close_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + assert hass.states.get("input_boolean.close_switch").state == "off" + assert cover.current_cover_position == 0 + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py new file mode 100644 index 0000000..08f5e9c --- /dev/null +++ b/tests/integration/test_smoke.py @@ -0,0 +1,10 @@ +"""Smoke test: verify integration loads and creates an entity.""" + +from homeassistant.core import HomeAssistant + + +async def test_integration_loads(hass: HomeAssistant, setup_cover): + """Config entry loads and creates a cover entity.""" + state = hass.states.get("cover.test_cover") + assert state is not None + assert state.state in ("open", "closed", "unknown") diff --git a/tests/integration/test_tilt.py b/tests/integration/test_tilt.py new file mode 100644 index 0000000..cb394c2 --- /dev/null +++ b/tests/integration/test_tilt.py @@ -0,0 +1,162 @@ +"""Integration tests for tilt lifecycle. + +Tests sequential tilt constraints through real HA service calls. +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch +import time as time_mod + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + async_fire_time_changed, +) + +from .conftest import DOMAIN + + +class MockTime: + """Controllable time source for TravelCalculator.""" + + def __init__(self): + self._base = time_mod.time() + self._total_offset = 0.0 + + def time(self): + return self._base + self._total_offset + + def advance(self, seconds: float): + self._total_offset += seconds + + +def _get_cover_entity(hass: HomeAssistant): + """Return the CoverTimeBased entity object.""" + entity_comp = hass.data["entity_components"]["cover"] + entities = [e for e in entity_comp.entities if e.entity_id == "cover.test_cover"] + assert entities, "Cover entity not found" + return entities[0] + + +async def test_sequential_tilt_moves_before_travel( + hass: HomeAssistant, setup_input_booleans +): + """Sequential tilt: opening from closed moves tilt to 100% before travel. + + When cover is at position 0 with tilt at partial position, + calling open_cover should first tilt to 100%, then travel. + """ + options = { + "control_mode": "switch", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 10.0, + "travel_time_close": 10.0, + "tilt_mode": "sequential", + "tilt_time_open": 2.0, + "tilt_time_close": 2.0, + "endpoint_runon_time": 0, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mt = MockTime() + with patch("time.time", mt.time): + cover = _get_cover_entity(hass) + + # Start at position 0 (closed), tilt at 30% + await cover.set_known_position(position=0) + await cover.set_known_tilt_position(tilt_position=30) + await hass.async_block_till_done() + assert cover.current_cover_position == 0 + assert cover.current_cover_tilt_position == 30 + + # Open cover — sequential tilt should tilt first + await hass.services.async_call( + "cover", "open_cover", {"entity_id": "cover.test_cover"}, blocking=True + ) + await hass.async_block_till_done() + + # Tilt should be moving first (open switch on for tilt pre-step) + assert cover.is_opening + + # Advance past tilt time (2s for tilt + margin) + mt.advance(3.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=3), fire_all=True + ) + await hass.async_block_till_done() + + # After tilt completes, travel should begin + # Tilt should be at 100%, position should be increasing + tilt = cover.current_cover_tilt_position + assert tilt is not None + assert tilt >= 90, f"Expected tilt >= 90% after pre-step, got {tilt}%" + + # Advance past travel time + mt.advance(12.0) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=12), fire_all=True + ) + await hass.async_block_till_done() + + # Position should be at 100% + assert cover.current_cover_position == 100 + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sequential_tilt_rejected_when_not_at_endpoint( + hass: HomeAssistant, setup_input_booleans +): + """Sequential tilt: tilt commands are rejected when cover is not at an endpoint. + + In sequential mode, tilt is only allowed at position 0 or 100. + """ + options = { + "control_mode": "switch", + "open_switch_entity_id": "input_boolean.open_switch", + "close_switch_entity_id": "input_boolean.close_switch", + "travel_time_open": 10.0, + "travel_time_close": 10.0, + "tilt_mode": "sequential", + "tilt_time_open": 2.0, + "tilt_time_close": 2.0, + "endpoint_runon_time": 0, + } + entry = MockConfigEntry( + domain=DOMAIN, version=2, title="Test Cover", data={}, options=options + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + cover = _get_cover_entity(hass) + + # Position at 50% (mid-range), tilt at 50% + await cover.set_known_position(position=50) + await cover.set_known_tilt_position(tilt_position=50) + await hass.async_block_till_done() + + # Try to set tilt — should be silently ignored since not at endpoint + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": "cover.test_cover", "tilt_position": 80}, + blocking=True, + ) + await hass.async_block_till_done() + + # Tilt should not have changed from 50% + assert cover.current_cover_tilt_position == 50 + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/test_base_movement.py b/tests/test_base_movement.py new file mode 100644 index 0000000..c0b87a4 --- /dev/null +++ b/tests/test_base_movement.py @@ -0,0 +1,2529 @@ +"""Tests for base class movement orchestration in CoverTimeBased. + +These tests exercise the movement coordination logic in cover_base.py: +- _async_move_to_endpoint (close/open travel) +- _async_move_tilt_to_endpoint (close/open tilt) +- set_position / set_tilt_position +- _start_movement (startup delay helper) +- _handle_pre_movement_checks +- _is_movement_too_short +- auto_stop_if_necessary / _delayed_stop +""" + +import asyncio + +import pytest +from unittest.mock import patch + +from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER + + +# =================================================================== +# Travel endpoint movement (async_close_cover / async_open_cover) +# =================================================================== + + +class TestCloseFromOpen: + """Closing from fully open should send close command and start travel.""" + + @pytest.mark.asyncio + async def test_close_sends_command_and_starts_travel(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(100) # fully open + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_CLOSE_COVER + cover.hass.services.async_call.assert_awaited() + + @pytest.mark.asyncio + async def test_close_when_already_closed_sends_resync(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) # fully closed + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Resync: command still sent even though tracker says we're at endpoint + assert not cover.travel_calc.is_traveling() + cover.hass.services.async_call.assert_awaited() + assert cover._last_command == SERVICE_CLOSE_COVER + + +class TestOpenFromClosed: + """Opening from fully closed should send open command and start travel.""" + + @pytest.mark.asyncio + async def test_open_sends_command_and_starts_travel(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) # fully closed + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + cover.hass.services.async_call.assert_awaited() + + @pytest.mark.asyncio + async def test_open_when_already_open_sends_resync(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(100) # fully open + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Resync: command still sent even though tracker says we're at endpoint + assert not cover.travel_calc.is_traveling() + cover.hass.services.async_call.assert_awaited() + assert cover._last_command == SERVICE_OPEN_COVER + + +class TestCloseStopsOppositeDirection: + """Closing while opening should stop first, then close.""" + + @pytest.mark.asyncio + async def test_close_while_opening_stops_then_closes(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Should now be traveling down (closing) + assert cover._last_command == SERVICE_CLOSE_COVER + + +class TestOpenStopsOppositeDirection: + """Opening while closing should stop first, then open.""" + + @pytest.mark.asyncio + async def test_open_while_closing_stops_then_opens(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# Travel endpoint with tilt coupling +# =================================================================== + + +class TestCloseWithTiltCoupling: + """Closing with tilt support should also move tilt when plan calls for it.""" + + @pytest.mark.asyncio + async def test_close_no_tilt_when_already_flat_sequential(self, make_cover): + """Sequential: closing with tilt already flat does not move tilt.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_close_also_starts_tilt_travel_when_tilted(self, make_cover): + """Sequential: closing with tilt not flat should flatten tilt first.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover.travel_calc.is_traveling() + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_open_also_starts_tilt_travel(self, make_cover): + """Sequential: opening with tilt at 0 should flatten tilt.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + assert cover.travel_calc.is_traveling() + assert cover.tilt_calc.is_traveling() + + +# =================================================================== +# Tilt endpoint movement (async_close_cover_tilt / async_open_cover_tilt) +# =================================================================== + + +class TestCloseTilt: + """Tilt close should move tilt to fully closed.""" + + @pytest.mark.asyncio + async def test_close_tilt_sends_command_and_starts_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover.tilt_calc.is_traveling() + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_close_tilt_when_already_closed_does_nothing(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert not cover.tilt_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + +class TestOpenTilt: + """Tilt open should move tilt to fully open.""" + + @pytest.mark.asyncio + async def test_open_tilt_sends_command_and_starts_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover_tilt() + + assert cover.tilt_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_open_tilt_when_already_open_does_nothing(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover_tilt() + + assert not cover.tilt_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + +class TestTiltStopsTravelFirst: + """Tilt movement should stop any active travel first, then restart if plan requires it.""" + + @pytest.mark.asyncio + async def test_close_tilt_restarts_travel_when_plan_requires(self, make_cover): + """Sequential: tilt close from pos 50 needs travel to 0 first.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Sequential plan_move_tilt requires travel to 0 before tilting, + # so travel restarts toward 0 as a coupled target + assert cover.travel_calc.is_traveling() + # Tilt should be traveling + assert cover.tilt_calc.is_traveling() + + +class TestTiltWithTravelCoupling: + """Tilt endpoint commands with different tilt modes.""" + + @pytest.mark.asyncio + async def test_close_tilt_sequential_moves_travel_to_closed_first(self, make_cover): + """Sequential: tilting from pos 50 requires travel to 0 first.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover.tilt_calc.is_traveling() + # Sequential plan requires travel to 0 before tilting + assert cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_close_tilt_sequential_no_travel_when_already_closed( + self, make_cover + ): + """Sequential: tilting when already at closed position does not move travel.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + + +# =================================================================== +# set_position +# =================================================================== + + +class TestSetPosition: + """set_position should move cover to the target position.""" + + @pytest.mark.asyncio + async def test_set_position_close_direction(self, make_cover): + """Setting position below current should close (move down).""" + cover = make_cover() + cover.travel_calc.set_position(100) # currently open + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(50) + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_set_position_open_direction(self, make_cover): + """Setting position above current should open (move up).""" + cover = make_cover() + cover.travel_calc.set_position(0) # currently closed + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(50) + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_set_position_already_at_target(self, make_cover): + """Setting position equal to current should do nothing.""" + cover = make_cover() + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(50) + + assert not cover.travel_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + @pytest.mark.asyncio + async def test_set_position_to_fully_closed(self, make_cover): + """Setting position to 0 (HA closed).""" + cover = make_cover() + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_set_position_to_fully_open(self, make_cover): + """Setting position to 100 (HA open).""" + cover = make_cover() + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(100) + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + +class TestSetPositionDirectionChange: + """set_position should handle direction changes properly.""" + + @pytest.mark.asyncio + async def test_direction_change_stops_active_travel(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() # closing + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(80) # open direction + + # Should have stopped and restarted in open direction + assert cover._last_command == SERVICE_OPEN_COVER + + +class TestSetPositionWithTilt: + """set_position with tilt support should also calculate tilt target.""" + + @pytest.mark.asyncio + async def test_set_position_no_tilt_when_already_flat_sequential(self, make_cover): + """Sequential: closing with tilt already flat does not move tilt.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) # close fully + + assert cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_set_position_also_moves_tilt_when_tilted(self, make_cover): + """Sequential: closing with non-flat tilt should flatten tilt.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) # close fully + + assert cover.travel_calc.is_traveling() + assert cover.tilt_calc.is_traveling() + + +# =================================================================== +# set_tilt_position +# =================================================================== + + +class TestSetTiltPosition: + """set_tilt_position should move tilt to the target position.""" + + @pytest.mark.asyncio + async def test_set_tilt_close_direction(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(50) + + assert cover.tilt_calc.is_traveling() + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_set_tilt_open_direction(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(50) + + assert cover.tilt_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_set_tilt_already_at_target(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(50) + + assert not cover.tilt_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + @pytest.mark.asyncio + async def test_set_tilt_restarts_travel_when_plan_requires(self, make_cover): + """Sequential: set_tilt_position stops active travel, then restarts to 0.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(50) + + # Sequential plan requires travel to 0 before tilting, + # so travel restarts toward 0 as a coupled target + assert cover.travel_calc.is_traveling() + assert cover.tilt_calc.is_traveling() + + +class TestSetTiltWithTravelCoupling: + """set_tilt_position with different tilt modes.""" + + @pytest.mark.asyncio + async def test_set_tilt_sequential_moves_travel_to_closed_first(self, make_cover): + """Sequential: set_tilt from pos 50 requires travel to 0 first.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(0) + + assert cover.tilt_calc.is_traveling() + # Sequential plan requires travel to 0 before tilting + assert cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_set_tilt_sequential_no_travel_when_already_closed(self, make_cover): + """Sequential: set_tilt from pos 0 does not move travel.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(0) + + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + + +# =================================================================== +# Minimum movement time +# =================================================================== + + +class TestMinMovementTime: + """Movements shorter than min_movement_time should be ignored.""" + + @pytest.mark.asyncio + async def test_short_movement_ignored(self, make_cover): + cover = make_cover( + travel_time_close=30, + travel_time_open=30, + min_movement_time=2.0, + ) + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + # 1% movement = 0.3s, which is < 2.0s + await cover.set_position(49) + + assert not cover.travel_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + @pytest.mark.asyncio + async def test_long_enough_movement_proceeds(self, make_cover): + cover = make_cover( + travel_time_close=30, + travel_time_open=30, + min_movement_time=2.0, + ) + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + # 20% movement = 6s, which is > 2.0s + await cover.set_position(30) + + assert cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_endpoint_movement_always_allowed(self, make_cover): + """Movements to endpoints (0 or 100) bypass min_movement_time.""" + cover = make_cover( + travel_time_close=30, + travel_time_open=30, + min_movement_time=100.0, # very high threshold + ) + cover.travel_calc.set_position(99) # almost open + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(100) # open fully (endpoint) + + assert cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_short_movement_ignored(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + min_movement_time=1.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + # 1% tilt = 0.05s, which is < 1.0s + await cover.set_tilt_position(49) + + assert not cover.tilt_calc.is_traveling() + cover.hass.services.async_call.assert_not_awaited() + + +# =================================================================== +# Startup delay +# =================================================================== + + +class TestStartupDelay: + """Travel startup delay should defer position tracking.""" + + @pytest.mark.asyncio + async def test_close_with_startup_delay_creates_task(self, make_cover): + cover = make_cover(travel_startup_delay=1.0) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Startup delay task should have been created + assert cover._startup_delay_task is not None + + @pytest.mark.asyncio + async def test_close_without_startup_delay_starts_immediately(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # No startup delay, so tracking starts immediately + assert cover._startup_delay_task is None + assert cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_with_startup_delay_creates_task(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_startup_delay=1.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover._startup_delay_task is not None + + +class TestStartupDelayConflict: + """Direction change during startup delay should cancel and stop.""" + + @pytest.mark.asyncio + async def test_close_during_open_startup_delay_cancels(self, make_cover): + cover = make_cover(travel_startup_delay=20.0) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Startup delay task should be running + assert cover._startup_delay_task is not None + assert cover._last_command == SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Should have cancelled the open startup and sent stop + # Cover is already at position 0 (closed), so no close movement needed + assert cover._last_command is None + assert cover._startup_delay_task is None or cover._startup_delay_task.done() + + @pytest.mark.asyncio + async def test_same_direction_during_startup_delay_is_ignored(self, make_cover): + cover = make_cover(travel_startup_delay=20.0) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + task1 = cover._startup_delay_task + + with patch.object(cover, "async_write_ha_state"): + # Open again during startup delay - should be ignored + await cover.async_open_cover() + + # Task should not have been restarted + assert cover._startup_delay_task is task1 + + @pytest.mark.asyncio + async def test_set_position_during_startup_delay_same_direction_skips( + self, make_cover + ): + cover = make_cover(travel_startup_delay=20.0) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) # close (target=0) + + task1 = cover._startup_delay_task + assert task1 is not None + + with patch.object(cover, "async_write_ha_state"): + # Another close-direction position during startup delay + await cover.set_position(30) + + # Should not have restarted delay + assert cover._startup_delay_task is task1 + + @pytest.mark.asyncio + async def test_set_position_during_startup_delay_direction_change_cancels( + self, make_cover + ): + cover = make_cover(travel_startup_delay=20.0) + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) # close direction + + assert cover._startup_delay_task is not None + assert cover._last_command == SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(100) # open direction = direction change + + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# Relay delay at endpoints +# =================================================================== + + +class TestRelayDelayAtEnd: + """endpoint_runon_time should cause a delay at endpoints.""" + + @pytest.mark.asyncio + async def test_auto_stop_at_endpoint_creates_delay_task(self, make_cover): + cover = make_cover(endpoint_runon_time=4.0) + cover.travel_calc.set_position(100) + # Simulate the cover reaching position 0 (closed endpoint) + cover.travel_calc.start_travel(0) + # Force position reached by setting position directly + cover.travel_calc.set_position(0) + cover.travel_calc.stop() + cover.travel_calc.start_travel(0) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Should have created a delay task instead of stopping immediately + assert cover._delay_task is not None + cover._delay_task.cancel() + + @pytest.mark.asyncio + async def test_auto_stop_at_midpoint_stops_immediately(self, make_cover): + cover = make_cover(endpoint_runon_time=4.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # At midpoint, should stop immediately (no delay) + assert cover._delay_task is None + + @pytest.mark.asyncio + async def test_close_cancels_active_relay_delay(self, make_cover): + """Starting a new movement should cancel an active relay delay.""" + cover = make_cover(endpoint_runon_time=4.0) + cover.travel_calc.set_position(100) # at open endpoint + + # Simulate an active delay task + async def fake_delay(): + await asyncio.sleep(100) + + cover._delay_task = asyncio.ensure_future(fake_delay()) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Delay should have been cancelled, new movement initiated + # (movement starts after startup delay, so check that the + # startup delay task was created rather than is_traveling) + assert cover._startup_delay_task is not None or cover.travel_calc.is_traveling() + + +# =================================================================== +# Stop cover +# =================================================================== + + +class TestStopCover: + """async_stop_cover should stop all movement.""" + + @pytest.mark.asyncio + async def test_stop_while_closing(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover.travel_calc.is_traveling() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert not cover.travel_calc.is_traveling() + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_stop_clears_startup_delay(self, make_cover): + cover = make_cover(travel_startup_delay=20.0) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover._startup_delay_task is not None + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._startup_delay_task is None + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_stop_with_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover.tilt_calc.is_traveling() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert not cover.tilt_calc.is_traveling() + + +# =================================================================== +# set_known_position / set_known_tilt_position +# =================================================================== + + +class TestSetKnownPosition: + """set_known_position should update position without movement.""" + + @pytest.mark.asyncio + async def test_set_known_position(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_position(position=50) + + assert cover.travel_calc.current_position() == 50 + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_set_known_tilt_position(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_tilt_position(tilt_position=75) + + assert cover.tilt_calc.current_position() == 75 + + +# =================================================================== +# Properties +# =================================================================== + + +class TestProperties: + """Test cover entity properties.""" + + def test_is_opening(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel_up() + assert cover.is_opening is True + assert cover.is_closing is False + + def test_is_closing(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(100) + cover.travel_calc.start_travel_down() + assert cover.is_closing is True + assert cover.is_opening is False + + def test_is_closed(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + assert cover.is_closed is True + + def test_is_not_closed(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + assert cover.is_closed is False + + def test_is_closed_with_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + assert cover.is_closed is True + + def test_is_not_closed_when_tilt_open(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(100) + assert cover.is_closed is False + + def test_current_position(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(70) + assert cover.current_cover_position == 70 + + def test_current_tilt_position(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.tilt_calc.set_position(75) + assert cover.current_cover_tilt_position == 75 + + def test_no_tilt_position_without_support(self, make_cover): + cover = make_cover() + assert cover.current_cover_tilt_position is None + + def test_has_tilt_support(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + assert cover._has_tilt_support() is True + + def test_no_tilt_support(self, make_cover): + cover = make_cover() + assert cover._has_tilt_support() is False + + +# =================================================================== +# Tilt constraints +# =================================================================== + + +class TestTiltConstraints: + """snap_trackers_to_physical should sync tilt at travel boundaries.""" + + def test_sequential_forces_tilt_flat_when_not_closed(self, make_cover): + """Sequential: tilt is forced to 100 when travel is not at closed (0).""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(50) + + cover._tilt_strategy.snap_trackers_to_physical( + cover.travel_calc, cover.tilt_calc + ) + + assert cover.tilt_calc.current_position() == 100 # forced flat + + def test_sequential_no_constraint_when_closed(self, make_cover): + """Sequential: tilt unchanged when travel is at closed (0).""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(50) + + cover._tilt_strategy.snap_trackers_to_physical( + cover.travel_calc, cover.tilt_calc + ) + + assert cover.tilt_calc.current_position() == 50 # unchanged + + +# =================================================================== +# Sequential pre-step delay (tilt before travel, travel before tilt) +# =================================================================== + + +class TestSequentialPreStepDelay: + """In sequential mode, movement steps execute one after another. + + When opening from closed+tilted, the tilt must fully open before + travel begins. The travel calculator's start is delayed by the + tilt duration so its position stays put during the tilt phase. + """ + + @pytest.mark.asyncio + async def test_open_from_closed_tilted_delays_travel(self, make_cover): + """Opening from pos=0, tilt=0: travel should be delayed by tilt time.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + import time + + before = time.time() + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Tilt should be traveling immediately (0→100) + assert cover.tilt_calc.is_traveling() + # Travel calc is tracking (target differs from position) but + # its timestamp is offset into the future by tilt_time_open + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._last_known_position_timestamp >= before + 4.9 + + # Travel position should still be at 0 (delay hasn't elapsed) + assert cover.travel_calc.current_position() == 0 + + @pytest.mark.asyncio + async def test_no_delay_when_tilt_already_flat(self, make_cover): + """Opening from pos=0, tilt=100: no pre-step needed, no delay.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(100) + + import time + + before = time.time() + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + assert cover.travel_calc.is_traveling() + # No delay — timestamp should be approximately now + assert cover.travel_calc._last_known_position_timestamp < before + 1.0 + + @pytest.mark.asyncio + async def test_set_position_delays_travel_for_tilt_prestep(self, make_cover): + """set_position from closed+tilted: travel delayed by tilt time.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + import time + + before = time.time() + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(10) + + assert cover.tilt_calc.is_traveling() + assert cover.travel_calc.is_traveling() + # Travel delayed by full tilt_time_open (tilt 0→100 = 5.0s) + assert cover.travel_calc._last_known_position_timestamp >= before + 4.9 + assert cover.travel_calc.current_position() == 0 + + @pytest.mark.asyncio + async def test_tilt_endpoint_delays_tilt_for_travel_prestep(self, make_cover): + """Closing tilt from pos=50: travel must close first, then tilt.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + import time + + before = time.time() + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Travel (coupled) should be tracking immediately (50→0) + assert cover.travel_calc.is_traveling() + # Tilt (primary) should be delayed by travel time (50→0 = 15s for 30s full) + assert cover.tilt_calc._last_known_position_timestamp >= before + 14.0 + assert cover.tilt_calc.current_position() == 100 # not started yet + + +# =================================================================== +# Dual-motor tilt pre-step and restore after travel +# =================================================================== + + +class TestDualMotorTiltPreStep: + """Dual-motor covers should tilt to safe before travel, then restore.""" + + def _make_dual_motor_cover(self, make_cover): + return make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + + # -- Pre-step phase -- + + @pytest.mark.asyncio + async def test_pre_step_starts_tilt_motor_not_travel(self, make_cover): + """Moving position should start tilt motor first, not travel motor.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Tilt motor should be opening (30 → 100 = safe) + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 100 + + # Travel should NOT have started yet + assert not cover.travel_calc.is_traveling() + + # Pending travel is queued; restore target is endpoint (0), not old tilt + assert cover._pending_travel_target == 0 + assert cover._pending_travel_command == SERVICE_CLOSE_COVER + assert cover._tilt_restore_target == 0 + + @pytest.mark.asyncio + async def test_pre_step_sends_tilt_open_command(self, make_cover): + """Tilt motor open switch should be activated for opening tilt.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) # needs to open to 100 + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + calls = cover.hass.services.async_call.call_args_list + # Should turn on tilt_open switch + tilt_open_calls = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.tilt_open" + ] + assert len(tilt_open_calls) == 1 + + # Should NOT send the travel command (open/close via _async_handle_command) + stop_calls = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(stop_calls) == 0 + + @pytest.mark.asyncio + async def test_set_position_starts_pre_step(self, make_cover): + """set_position should also start tilt pre-step.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(40) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(20) + + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + assert cover._pending_travel_target == 20 + assert cover._tilt_restore_target == 40 + + @pytest.mark.asyncio + async def test_no_pre_step_when_tilt_already_safe(self, make_cover): + """No pre-step needed when tilt is already at safe position.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) # already safe + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Travel should start immediately (no pre-step) + assert cover.travel_calc.is_traveling() + assert cover._pending_travel_target is None + assert cover._tilt_restore_target is None + + # -- Pre-step completion → travel starts -- + + @pytest.mark.asyncio + async def test_pre_step_complete_starts_travel(self, make_cover): + """When tilt reaches safe position, travel should start.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Simulate tilt reaching safe position + cover.tilt_calc.set_position(100) + + cover.hass.services.async_call.reset_mock() + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Travel should now be running + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._travel_to_position == 0 + assert cover._pending_travel_target is None + + # Tilt stop should have been called + calls = cover.hass.services.async_call.call_args_list + tilt_stop_calls = [ + c + for c in calls + if c[0][1] == "turn_off" + and c[0][2].get("entity_id") in ("switch.tilt_open", "switch.tilt_close") + ] + assert len(tilt_stop_calls) == 2 + + # -- Full lifecycle: pre-step → travel → restore -- + + @pytest.mark.asyncio + async def test_full_lifecycle_pre_step_travel_restore(self, make_cover): + """Full dual_motor lifecycle: tilt safe → travel → tilt snaps to endpoint.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + # Phase 1: Start → tilt pre-step + await cover.async_close_cover() + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + + # Phase 2: Tilt reaches safe → travel starts + cover.tilt_calc.set_position(100) + await cover.auto_stop_if_necessary() + assert cover.travel_calc.is_traveling() + assert cover._tilt_restore_target == 0 # endpoint, not old tilt + + # Phase 3: Travel completes → tilt snaps to endpoint (closed) + cover.travel_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is True + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 0 + + # Phase 4: Tilt snap completes → all done + cover.tilt_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is False + assert not cover.tilt_calc.is_traveling() + + # -- Stop during phases -- + + @pytest.mark.asyncio + async def test_stop_during_tilt_pre_step(self, make_cover): + """Stopping during tilt pre-step should clear all pending state.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + assert cover._pending_travel_target == 0 + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._pending_travel_target is None + assert cover._pending_travel_command is None + assert cover._tilt_restore_target is None + assert not cover.tilt_calc.is_traveling() + + # Tilt motor should have been stopped + calls = cover.hass.services.async_call.call_args_list + tilt_stop_calls = [ + c + for c in calls + if c[0][1] == "turn_off" + and c[0][2].get("entity_id") in ("switch.tilt_open", "switch.tilt_close") + ] + assert len(tilt_stop_calls) >= 2 + + @pytest.mark.asyncio + async def test_stop_during_travel_phase(self, make_cover): + """Stopping during travel phase should clear restore target.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Complete pre-step + cover.tilt_calc.set_position(100) + await cover.auto_stop_if_necessary() + + assert cover.travel_calc.is_traveling() + assert cover._tilt_restore_target == 0 # endpoint, not old tilt + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._tilt_restore_target is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_stop_during_tilt_restore(self, make_cover): + """Stopping during tilt restore should stop tilt motor and clear state.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(30) + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._tilt_restore_active is False + assert cover._tilt_restore_target is None + assert not cover.tilt_calc.is_traveling() + + # -- Edge cases -- + + @pytest.mark.asyncio + async def test_sequential_no_pre_step(self, make_cover): + """Sequential mode should NOT use tilt pre-step (shared motor).""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Travel should start immediately (with pre_step_delay, not tilt motor) + assert cover.travel_calc.is_traveling() + assert cover._pending_travel_target is None + assert cover._tilt_restore_target is None + + +# =================================================================== +# Dual motor: travel pre-step before tilt (boundary-locked) +# =================================================================== + + +class TestDualMotorTravelPreStep: + """When tilt requires cover to move first, travel should precede tilt.""" + + def _make_cover(self, make_cover): + return make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + safe_tilt_position=100, + max_tilt_allowed_position=0, + ) + + # -- Pre-step detection -- + + @pytest.mark.asyncio + async def test_tilt_endpoint_starts_travel_pre_step(self, make_cover): + """Tilting from position 50 should move cover to 0 first.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Travel should be running toward 0 + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._travel_to_position == 0 + + # Tilt should NOT have started yet + assert not cover.tilt_calc.is_traveling() + + # Pending tilt is queued + assert cover._pending_tilt_target == 0 + assert cover._pending_tilt_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_set_tilt_position_starts_travel_pre_step(self, make_cover): + """set_tilt_position from position 50 should move cover to 0 first.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(50) + + # Travel should be running toward 0 + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._travel_to_position == 0 + + # Tilt should NOT have started yet + assert not cover.tilt_calc.is_traveling() + + # Pending tilt is queued + assert cover._pending_tilt_target == 50 + assert cover._pending_tilt_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_no_travel_pre_step_when_already_at_allowed_position( + self, make_cover + ): + """No pre-step when cover is already at allowed position.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Tilt should start immediately (no travel pre-step) + assert cover.tilt_calc.is_traveling() + assert cover._pending_tilt_target is None + + @pytest.mark.asyncio + async def test_travel_pre_step_sends_close_command(self, make_cover): + """Travel pre-step should send close command, not tilt.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + calls = cover.hass.services.async_call.call_args_list + # Should send the close switch command (travel) + close_calls = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.close" + ] + assert len(close_calls) == 1 + + # Should NOT send tilt commands + tilt_calls = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.tilt_open", "switch.tilt_close") + ] + assert len(tilt_calls) == 0 + + # -- Pre-step completion → tilt starts -- + + @pytest.mark.asyncio + async def test_travel_pre_step_complete_starts_tilt(self, make_cover): + """When travel reaches allowed position, tilt should start.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Simulate travel reaching position 0 + cover.travel_calc.set_position(0) + + cover.hass.services.async_call.reset_mock() + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Tilt should now be running + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 0 + assert cover._pending_tilt_target is None + + # Travel motor should have been stopped + calls = cover.hass.services.async_call.call_args_list + stop_calls = [ + c + for c in calls + if c[0][1] == "turn_off" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(stop_calls) >= 1 + + # -- Full lifecycle: travel → tilt -- + + @pytest.mark.asyncio + async def test_full_lifecycle_travel_then_tilt(self, make_cover): + """Full travel-before-tilt lifecycle: travel to 0, then tilt to 50.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + # Phase 1: Start → travel pre-step + await cover.set_tilt_position(50) + assert cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + assert cover._pending_tilt_target == 50 + + # Phase 2: Travel reaches position 0 → tilt starts + cover.travel_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 50 + assert cover._pending_tilt_target is None + + # Phase 3: Tilt completes → all done + cover.tilt_calc.set_position(50) + await cover.auto_stop_if_necessary() + assert not cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + + # -- Stop during travel pre-step -- + + @pytest.mark.asyncio + async def test_stop_during_travel_pre_step(self, make_cover): + """Stopping during travel pre-step should clear all pending state.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover._pending_tilt_target == 0 + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._pending_tilt_target is None + assert cover._pending_tilt_command is None + assert not cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + + # -- New movement abandons travel pre-step -- + + @pytest.mark.asyncio + async def test_new_movement_abandons_travel_pre_step(self, make_cover): + """Starting a new movement should abandon active travel pre-step.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover._pending_tilt_target == 0 + + # Start a new position movement — should abandon the pre-step + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + assert cover._pending_tilt_target is None + assert cover._pending_tilt_command is None + + +# =================================================================== +# Dual motor: tilt-only commands use tilt motor, not main motor +# =================================================================== + + +class TestDualMotorTiltOnlyCommands: + """Tilt-only commands should use tilt motor relays in dual_motor mode.""" + + def _make_cover(self, make_cover): + return make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + + @pytest.mark.asyncio + async def test_close_tilt_uses_tilt_motor(self, make_cover): + """async_close_cover_tilt should activate tilt_close switch, not main close.""" + cover = self._make_cover(make_cover) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + calls = cover.hass.services.async_call.call_args_list + # Tilt close switch should be turned on + tilt_close = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.tilt_close" + ] + assert len(tilt_close) == 1 + + # Main motor should NOT be activated + main_motor = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(main_motor) == 0 + + @pytest.mark.asyncio + async def test_open_tilt_uses_tilt_motor(self, make_cover): + """async_open_cover_tilt should activate tilt_open switch, not main open.""" + cover = self._make_cover(make_cover) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover_tilt() + + calls = cover.hass.services.async_call.call_args_list + tilt_open = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.tilt_open" + ] + assert len(tilt_open) == 1 + + main_motor = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(main_motor) == 0 + + @pytest.mark.asyncio + async def test_set_tilt_position_uses_tilt_motor(self, make_cover): + """set_tilt_position should use tilt motor, not main motor.""" + cover = self._make_cover(make_cover) + cover.tilt_calc.set_position(80) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(30) + + calls = cover.hass.services.async_call.call_args_list + # Tilt close switch should be turned on (80 → 30 = closing) + tilt_close = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.tilt_close" + ] + assert len(tilt_close) == 1 + + main_motor = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(main_motor) == 0 + + @pytest.mark.asyncio + async def test_set_tilt_position_open_direction(self, make_cover): + """set_tilt_position opening should use tilt_open switch.""" + cover = self._make_cover(make_cover) + cover.tilt_calc.set_position(20) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(70) + + calls = cover.hass.services.async_call.call_args_list + tilt_open = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.tilt_open" + ] + assert len(tilt_open) == 1 + + main_motor = [ + c + for c in calls + if c[0][1] == "turn_on" + and c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(main_motor) == 0 + + @pytest.mark.asyncio + async def test_sequential_tilt_uses_main_motor(self, make_cover): + """Sequential mode should still use main motor for tilt commands.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + calls = cover.hass.services.async_call.call_args_list + # Main close switch should be turned on (shared motor) + main_close = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.close" + ] + assert len(main_close) == 1 + + +# =================================================================== +# Wrapped cover + dual_motor: tilt via cover entity services +# =================================================================== + + +class TestWrappedDualMotorTilt: + """Wrapped cover with dual_motor tilt delegates tilt to cover entity.""" + + def _make_cover(self, make_cover): + return make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + ) + + @pytest.mark.asyncio + async def test_pre_step_uses_cover_tilt_service(self, make_cover): + """Pre-step should call cover.open_cover_tilt, not relay switches.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Tilt should be traveling to safe position + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 100 + assert not cover.travel_calc.is_traveling() + + # Should use cover.open_cover_tilt (30 → 100 = opening tilt) + calls = cover.hass.services.async_call.call_args_list + tilt_calls = [c for c in calls if c[0][1] == "open_cover_tilt"] + assert len(tilt_calls) == 1 + assert tilt_calls[0][0][2]["entity_id"] == "cover.inner" + + # No relay switch calls + ha_calls = [c for c in calls if c[0][0] == "homeassistant"] + assert len(ha_calls) == 0 + + @pytest.mark.asyncio + async def test_pre_step_complete_uses_cover_stop_tilt(self, make_cover): + """When pre-step completes, should call cover.stop_cover_tilt.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Complete tilt pre-step + cover.tilt_calc.set_position(100) + cover.hass.services.async_call.reset_mock() + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Travel should now be running + assert cover.travel_calc.is_traveling() + + # Should have called cover.stop_cover_tilt then cover.close_cover + calls = cover.hass.services.async_call.call_args_list + tilt_stop = [c for c in calls if c[0][1] == "stop_cover_tilt"] + assert len(tilt_stop) == 1 + travel_close = [c for c in calls if c[0][1] == "close_cover"] + assert len(travel_close) == 1 + + @pytest.mark.asyncio + async def test_full_lifecycle(self, make_cover): + """Full lifecycle: pre-step → travel → restore, all via cover services.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + # Phase 1: Start → tilt pre-step + await cover.async_close_cover() + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + + # Phase 2: Tilt reaches safe → travel starts + cover.tilt_calc.set_position(100) + await cover.auto_stop_if_necessary() + assert cover.travel_calc.is_traveling() + + # Phase 3: Travel completes → tilt restore starts + cover.travel_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is True + assert cover.tilt_calc.is_traveling() + + # Phase 4: Tilt restore completes → all done + cover.tilt_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is False + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_stop_during_pre_step_uses_cover_tilt_stop(self, make_cover): + """Stopping during pre-step should call cover.stop_cover_tilt.""" + cover = self._make_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + cover.hass.services.async_call.reset_mock() + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + # Should have called cover.stop_cover_tilt + calls = cover.hass.services.async_call.call_args_list + tilt_stop = [c for c in calls if c[0][1] == "stop_cover_tilt"] + assert len(tilt_stop) == 1 + + # No relay switch calls + ha_calls = [c for c in calls if c[0][0] == "homeassistant"] + assert len(ha_calls) == 0 + + +# =================================================================== +# Inline tilt: tilt restore after travel via main motor reversal +# =================================================================== + + +class TestInlineTiltRestore: + """Inline tilt: tilt restores after travel via main motor reversal.""" + + def _make_inline_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="inline", + ) + + @pytest.mark.asyncio + async def test_close_to_mid_sets_restore_target(self, make_cover): + """Closing to mid-position saves tilt for restore.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(100) # tilt open + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(30) + + # Pre-step delay is used (not tilt motor pre-step) + assert cover.travel_calc.is_traveling() + assert cover._pending_travel_target is None # no dual_motor pre-step + assert cover._tilt_restore_target == 100 + + @pytest.mark.asyncio + async def test_no_restore_at_endpoint_zero(self, make_cover): + """Closing to 0%: no restore (endpoint forces tilt=0).""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover._async_move_to_endpoint(target=0) + + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_no_restore_at_endpoint_hundred(self, make_cover): + """Opening to 100%: no restore (endpoint forces tilt=100).""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover._async_move_to_endpoint(target=100) + + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_no_restore_when_tilt_already_at_direction_endpoint(self, make_cover): + """No restore when tilt is already at direction's endpoint.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(0) # already closed + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(30) # closing — tilt endpoint is 0 + + # No tilt pre-step needed, so no restore + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_restore_reverses_main_motor(self, make_cover): + """After travel, restore sends opposite main motor command.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(30) + + # Simulate travel completing (tilt is now 0 after closing) + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(0) + cover.hass.services.async_call.reset_mock() + await cover.auto_stop_if_necessary() + + # Restore should be active + assert cover._tilt_restore_active is True + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 100 + + # Main motor open command should have been sent (to restore tilt 0->100) + calls = cover.hass.services.async_call.call_args_list + open_calls = [ + c + for c in calls + if c[0][1] == "turn_on" and c[0][2].get("entity_id") == "switch.open" + ] + assert len(open_calls) == 1 + + @pytest.mark.asyncio + async def test_restore_complete_stops_main_motor(self, make_cover): + """When restore finishes, main motor is stopped.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(30) + + # Complete travel + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(0) + await cover.auto_stop_if_necessary() + + # Complete restore + cover.tilt_calc.set_position(100) + cover.hass.services.async_call.reset_mock() + await cover.auto_stop_if_necessary() + + assert cover._tilt_restore_active is False + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_full_lifecycle(self, make_cover): + """Full inline lifecycle: pre-step delay -> travel -> restore.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + # Start: closing to 30 + await cover.set_position(30) + assert cover.travel_calc.is_traveling() + assert cover._tilt_restore_target == 100 + + # Travel completes -> restore starts + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(0) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is True + + # Restore completes -> done + cover.tilt_calc.set_position(100) + await cover.auto_stop_if_necessary() + assert cover._tilt_restore_active is False + + @pytest.mark.asyncio + async def test_stop_during_restore(self, make_cover): + """Stopping during restore clears state and stops motor.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(30) + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + assert cover._tilt_restore_active is False + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_endpoint_move_uses_pre_step_delay(self, make_cover): + """Endpoint move with inline: uses pre_step_delay, no restore.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) # open tilt + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Travel started (with pre_step_delay for tilt phase) + assert cover.travel_calc.is_traveling() + # No restore at endpoint + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_set_position_endpoint_also_no_restore(self, make_cover): + """set_position(0) should not set restore target.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) + + assert cover._tilt_restore_target is None + + +# =================================================================== +# Inline tilt: set_known_position, properties, and constraints +# =================================================================== + + +class TestInlineTiltSetKnownPosition: + """set_known_position with inline tilt preserves tilt (no snap).""" + + def _make_inline_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="inline", + ) + + @pytest.mark.asyncio + async def test_set_known_position_to_closed_preserves_tilt(self, make_cover): + """Setting known position to 0% does not change tilt.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(80) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_position(position=0) + + assert cover.travel_calc.current_position() == 0 + assert cover.tilt_calc.current_position() == 80 + + @pytest.mark.asyncio + async def test_set_known_position_to_open_preserves_tilt(self, make_cover): + """Setting known position to 100% does not change tilt.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(20) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_position(position=100) + + assert cover.travel_calc.current_position() == 100 + assert cover.tilt_calc.current_position() == 20 + + @pytest.mark.asyncio + async def test_set_known_position_mid_preserves_tilt(self, make_cover): + """Setting known position to mid-range does not change tilt.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(40) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_position(position=50) + + assert cover.travel_calc.current_position() == 50 + assert cover.tilt_calc.current_position() == 40 + + @pytest.mark.asyncio + async def test_set_known_tilt_position(self, make_cover): + """set_known_tilt_position works with inline tilt.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_tilt_position(tilt_position=75) + + assert cover.tilt_calc.current_position() == 75 + + +class TestInlineTiltCoverProperties: + """Cover properties with inline tilt mode.""" + + def _make_inline_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="inline", + ) + + def test_has_tilt_support(self, make_cover): + cover = self._make_inline_cover(make_cover) + assert cover._has_tilt_support() is True + + def test_tilt_position_reported(self, make_cover): + cover = self._make_inline_cover(make_cover) + cover.tilt_calc.set_position(60) + assert cover.current_cover_tilt_position == 60 + + def test_is_closed_requires_both(self, make_cover): + """is_closed requires travel=0 AND tilt=0 for inline.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(50) + assert cover.is_closed is False + + def test_is_closed_when_both_closed(self, make_cover): + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + assert cover.is_closed is True + + def test_extra_state_attributes_includes_tilt_mode(self, make_cover): + cover = self._make_inline_cover(make_cover) + attrs = cover.extra_state_attributes + assert attrs["tilt_mode"] == "inline" + + def test_extra_state_attributes_includes_tilt_times(self, make_cover): + cover = self._make_inline_cover(make_cover) + attrs = cover.extra_state_attributes + assert attrs["tilt_time_close"] == 2.0 + assert attrs["tilt_time_open"] == 2.0 + + +class TestInlineTiltConstraints: + """Inline tilt snap is a no-op — tilt is preserved at all positions.""" + + def _make_inline_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="inline", + ) + + @pytest.mark.asyncio + async def test_auto_stop_preserves_tilt_at_closed(self, make_cover): + """When travel reaches 0%, tilt is not snapped.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Simulate reaching closed — tilt still at 30 (e.g. user tilted earlier) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(30) + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + assert cover.tilt_calc.current_position() == 30 + + @pytest.mark.asyncio + async def test_auto_stop_preserves_tilt_at_open(self, make_cover): + """When travel reaches 100%, tilt is not snapped.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Simulate reaching open — tilt still at 70 + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(70) + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + assert cover.tilt_calc.current_position() == 70 + + def test_snap_at_mid_preserves_tilt(self, make_cover): + """snap_trackers_to_physical at mid-position preserves tilt value.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(40) + + cover._tilt_strategy.snap_trackers_to_physical( + cover.travel_calc, cover.tilt_calc + ) + + assert cover.tilt_calc.current_position() == 40 + + +# =================================================================== +# Abandon active lifecycle: new command cancels pending phases +# =================================================================== + + +class TestAbandonActiveLifecycle: + """New movement commands abandon any active multi-phase lifecycle.""" + + def _make_dual_motor_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + def _make_inline_cover(self, make_cover): + return make_cover( + tilt_time_close=2.0, + tilt_time_open=2.0, + tilt_mode="inline", + ) + + # -- During tilt pre-step (dual motor) -- + + @pytest.mark.asyncio + async def test_tilt_during_pre_step_cancels_pending_travel(self, make_cover): + """New tilt command during tilt pre-step cancels the pending travel.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + # Start: position 30% triggers tilt pre-step (tilt 50→100 safe) + await cover.set_position(30) + + assert cover.tilt_calc.is_traveling() + assert cover._pending_travel_target == 30 + + with patch.object(cover, "async_write_ha_state"): + # New tilt command: should cancel pending travel to 30 + await cover.set_tilt_position(10) + + # Pending travel is gone + assert cover._pending_travel_target is None + # Tilt is now moving toward 10 + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 10 + # Travel never started + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_new_position_during_pre_step_restarts(self, make_cover): + """New position command during tilt pre-step cancels and restarts.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + # Start: position 30% triggers tilt pre-step + await cover.set_position(30) + + assert cover._pending_travel_target == 30 + + with patch.object(cover, "async_write_ha_state"): + # New position command: should restart with new target + await cover.set_position(80) + + # Old pending travel is gone, new pre-step for 80% started + assert cover._pending_travel_target == 80 + assert cover.tilt_calc.is_traveling() + + # -- During tilt restore (dual motor) -- + + @pytest.mark.asyncio + async def test_new_position_during_restore_cancels_restore(self, make_cover): + """New position command during tilt restore cancels the restore.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(100) # at safe position + + # Simulate active tilt restore (tilt motor moving back to old position) + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(80) + + # Restore is cancelled, new movement started + assert cover._tilt_restore_active is False + # New command proceeds (tilt pre-step or direct travel) + assert cover.tilt_calc.is_traveling() or cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_new_tilt_during_restore_cancels_restore(self, make_cover): + """New tilt command during tilt restore cancels restore and tilts.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(80) + + # Simulate active tilt restore + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(20) + + assert cover._tilt_restore_active is False + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 20 + + # -- During travel with pending restore -- + + @pytest.mark.asyncio + async def test_new_tilt_during_travel_clears_pending_restore(self, make_cover): + """New tilt command while traveling clears the pending restore target.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(80) + cover.tilt_calc.set_position(30) # not at endpoint, so restore is set + + with patch.object(cover, "async_write_ha_state"): + # Travel to 50% with pending restore (inline, non-endpoint) + await cover.set_position(50) + + assert cover.travel_calc.is_traveling() + assert cover._tilt_restore_target == 30 + + with patch.object(cover, "async_write_ha_state"): + # New tilt command: clears pending restore + await cover.set_tilt_position(80) + + assert cover._tilt_restore_target is None + + # -- During inline tilt restore -- + + @pytest.mark.asyncio + async def test_new_position_during_inline_restore(self, make_cover): + """New position during inline tilt restore cancels restore.""" + cover = self._make_inline_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(60) + + # Simulate active inline restore (main motor reversing) + cover.tilt_calc.start_travel(30) + cover._tilt_restore_active = True + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(80) + + assert cover._tilt_restore_active is False + assert cover.travel_calc.is_traveling() + + # -- Endpoint commands during lifecycle -- + + @pytest.mark.asyncio + async def test_close_during_pre_step_abandons_and_closes(self, make_cover): + """async_close_cover during pre-step abandons and starts fresh close.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(30) + + assert cover._pending_travel_target == 30 + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Old pre-step is gone, fresh close started + assert cover._pending_travel_target == 0 or cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_open_tilt_during_restore_abandons(self, make_cover): + """Tilt endpoint command during restore abandons restore.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(30) + cover.tilt_calc.set_position(60) + + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover_tilt() + + assert cover._tilt_restore_active is False + assert cover.tilt_calc.is_traveling() + assert cover.tilt_calc._travel_to_position == 100 + + +# =================================================================== +# External movement skips tilt planning +# =================================================================== + + +class TestExternalMovementSkipsTiltPlanning: + """When _triggered_externally=True, _async_move_to_endpoint skips tilt + planning and goes straight to position tracking. + + Physical buttons already control the relay — we can't sequence + tilt pre-steps or restore phases for external movements. + """ + + def _make_dual_motor_cover(self, make_cover): + return make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + safe_tilt_position=50, + max_tilt_allowed_position=50, + ) + + @pytest.mark.asyncio + async def test_external_open_skips_tilt_pre_step(self, make_cover): + """External open should start travel tracking directly, no tilt pre-step.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + finally: + cover._triggered_externally = False + + # Travel tracking should have started directly + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._travel_to_position == 100 + + # No tilt pre-step should have been started + assert cover._pending_travel_target is None + assert cover._pending_travel_command is None + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_external_close_skips_tilt_pre_step(self, make_cover): + """External close should start travel tracking directly, no tilt pre-step.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(30) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + finally: + cover._triggered_externally = False + + # Travel tracking should have started directly + assert cover.travel_calc.is_traveling() + assert cover.travel_calc._travel_to_position == 0 + + # No tilt pre-step + assert cover._pending_travel_target is None + assert cover._tilt_restore_target is None + + @pytest.mark.asyncio + async def test_self_initiated_does_tilt_pre_step(self, make_cover): + """Contrast: self-initiated open DOES start tilt pre-step.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Tilt pre-step should have started (tilt 0→50) + assert cover.tilt_calc.is_traveling() + assert not cover.travel_calc.is_traveling() + assert cover._pending_travel_target == 100 + + @pytest.mark.asyncio + async def test_external_open_no_tilt_relay_sent(self, make_cover): + """External open should not send tilt motor commands.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + finally: + cover._triggered_externally = False + + # No tilt motor commands should have been sent + calls = cover.hass.services.async_call.call_args_list + tilt_calls = [ + c + for c in calls + if c[0][2].get("entity_id") in ("switch.tilt_open", "switch.tilt_close") + ] + assert len(tilt_calls) == 0 + + @pytest.mark.asyncio + async def test_external_open_no_main_relay_sent(self, make_cover): + """External open should not send main relay commands (relay already on).""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + finally: + cover._triggered_externally = False + + # No main relay commands + calls = cover.hass.services.async_call.call_args_list + main_calls = [ + c + for c in calls + if c[0][2].get("entity_id") in ("switch.open", "switch.close") + ] + assert len(main_calls) == 0 + + @pytest.mark.asyncio + async def test_external_movement_sets_self_initiated_false(self, make_cover): + """External open should set _self_initiated_movement=False.""" + cover = self._make_dual_motor_cover(make_cover) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + finally: + cover._triggered_externally = False + + assert cover._self_initiated_movement is False + + @pytest.mark.asyncio + async def test_external_sequential_tilt_also_skipped(self, make_cover): + """External open also skips tilt planning for sequential mode.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + safe_tilt_position=50, + max_tilt_allowed_position=50, + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + finally: + cover._triggered_externally = False + + # Travel tracking starts directly, no tilt coupling + assert cover.travel_calc.is_traveling() + assert cover._pending_travel_target is None diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100644 index 0000000..d558544 --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,847 @@ +"""Tests for calibration services.""" + +import asyncio + +import pytest +from unittest.mock import patch, MagicMock + + +class TestConfigEntryAccess: + """Test that config entry ID is available on the entity.""" + + def test_config_entry_id_stored(self, make_cover): + """Cover should store its config entry ID.""" + cover = make_cover() + assert cover._config_entry_id == "test_cover" + + +class TestCalibrationState: + """Test the CalibrationState dataclass.""" + + def test_initial_state(self): + """CalibrationState should initialize with required fields.""" + from custom_components.cover_time_based.calibration import CalibrationState + + state = CalibrationState( + attribute="travel_time_close", + timeout=120.0, + ) + assert state.attribute == "travel_time_close" + assert state.timeout == 120.0 + assert state.started_at is not None + assert state.step_count == 0 + assert state.step_duration is None + assert state.last_pulse_duration is None + assert state.timeout_task is None + assert state.automation_task is None + + def test_constants_defined(self): + """Calibration constants should be accessible.""" + from custom_components.cover_time_based.calibration import ( + CALIBRATION_STEP_PAUSE, + CALIBRATION_OVERHEAD_STEPS, + CALIBRATION_TILT_OVERHEAD_STEPS, + CALIBRATION_MIN_MOVEMENT_START, + CALIBRATION_MIN_MOVEMENT_INCREMENT, + CALIBRATABLE_ATTRIBUTES, + SERVICE_START_CALIBRATION, + SERVICE_STOP_CALIBRATION, + ) + + assert CALIBRATION_STEP_PAUSE == 2.0 + assert CALIBRATION_OVERHEAD_STEPS == 8 + assert CALIBRATION_TILT_OVERHEAD_STEPS == 3 + assert CALIBRATION_MIN_MOVEMENT_START == 0.1 + assert CALIBRATION_MIN_MOVEMENT_INCREMENT == 0.1 + assert len(CALIBRATABLE_ATTRIBUTES) == 7 + assert SERVICE_START_CALIBRATION == "start_calibration" + assert SERVICE_STOP_CALIBRATION == "stop_calibration" + + +class TestStartCalibrationTravelTime: + @pytest.mark.asyncio + async def test_start_travel_time_close_moves_cover(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + assert cover._calibration is not None + assert cover._calibration.attribute == "travel_time_close" + cover.hass.services.async_call.assert_awaited() + + @pytest.mark.asyncio + async def test_start_travel_time_open_moves_cover(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_open", timeout=120.0) + assert cover._calibration is not None + assert cover._calibration.attribute == "travel_time_open" + + @pytest.mark.asyncio + async def test_cannot_start_while_calibrating(self, make_cover): + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + with pytest.raises(HomeAssistantError, match="already"): + await cover.start_calibration( + attribute="travel_time_open", timeout=120.0 + ) + + @pytest.mark.asyncio + async def test_calibration_exposes_state_attributes(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + attrs = cover.extra_state_attributes + assert attrs["calibration_active"] is True + assert attrs["calibration_attribute"] == "travel_time_close" + + @pytest.mark.asyncio + async def test_no_calibration_attributes_when_inactive(self, make_cover): + cover = make_cover() + attrs = cover.extra_state_attributes + assert "calibration_active" not in attrs + + +class TestStopCalibrationTravelTime: + @pytest.mark.asyncio + async def test_stop_calculates_elapsed_time(self, make_cover): + cover = make_cover() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + cover._calibration.started_at -= 45.0 + result = await cover.stop_calibration() + + assert result["attribute"] == "travel_time_close" + assert result["value"] == pytest.approx(45.0, abs=0.5) + assert cover._calibration is None + + @pytest.mark.asyncio + async def test_stop_with_cancel_discards(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + result = await cover.stop_calibration(cancel=True) + assert cover._calibration is None + assert "value" not in result + + @pytest.mark.asyncio + async def test_stop_without_active_calibration_raises(self, make_cover): + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover() + with pytest.raises(HomeAssistantError, match="[Nn]o calibration"): + await cover.stop_calibration() + + @pytest.mark.asyncio + async def test_stop_cancels_timeout_task(self, make_cover): + cover = make_cover() + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + timeout_task = cover._calibration.timeout_task + await cover.stop_calibration() + + await asyncio.sleep(0) # Let event loop process cancellation + assert timeout_task.cancelled() + + +class TestCancelDoesNotReturn: + """When cancelled, cover should stop but NOT automatically return.""" + + @pytest.mark.asyncio + async def test_cancel_stops_motor_without_return(self, make_cover): + """Cancelling should stop the motor but not drive cover back.""" + cover = make_cover(travel_time_close=20, travel_time_open=20) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + result = await cover.stop_calibration(cancel=True) + + assert cover._calibration is None + assert "value" not in result + # No return trip: no open command after the initial close + stop + open_calls = [ + c + for c in cover.hass.services.async_call.await_args_list + if c.args[1] == "turn_on" and "open" in str(c.args[2].get("entity_id", "")) + ] + # Only the initial close was sent, no open command for return + assert len(open_calls) == 0 + + @pytest.mark.asyncio + async def test_cancel_does_not_update_position(self, make_cover): + """Cancelling should not change the tracked position.""" + cover = make_cover(travel_time_close=20, travel_time_open=20) + cover.travel_calc.set_position(50) + original_position = cover.travel_calc.current_position() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + await cover.stop_calibration(cancel=True) + + assert cover.travel_calc.current_position() == original_position + + @pytest.mark.asyncio + async def test_successful_stop_sets_endpoint_position(self, make_cover): + """Successful stop should set tracked position to the endpoint.""" + cover = make_cover(travel_time_close=20, travel_time_open=20) + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + await cover.stop_calibration() + + # Position at the close endpoint (0 in travel_calc = close direction endpoint) + assert cover.travel_calc.current_position() == 0 + + +class TestCalibrationTiltTime: + @pytest.mark.asyncio + async def test_start_tilt_time_close(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="tilt_time_close", timeout=30.0) + assert cover._calibration.attribute == "tilt_time_close" + + @pytest.mark.asyncio + async def test_start_tilt_time_open(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="tilt_time_open", timeout=30.0) + assert cover._calibration.attribute == "tilt_time_open" + + +class TestMotorOverheadCalibration: + @pytest.mark.asyncio + async def test_prerequisite_travel_time_required(self, make_cover): + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover() + # Factory defaults travel_time to 30, so manually clear it + cover._travel_time_close = None + cover._travel_time_open = None + with pytest.raises(HomeAssistantError, match="[Tt]ravel time"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + + @pytest.mark.asyncio + async def test_starts_automated_steps(self, make_cover): + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + assert cover._calibration.automation_task is not None + assert cover._calibration.step_duration == 6.0 + + @pytest.mark.asyncio + async def test_zeros_startup_delay_during_test(self, make_cover): + """Startup delay is zeroed during test and restored after.""" + cover = make_cover( + travel_time_close=60.0, + travel_time_open=60.0, + travel_startup_delay=0.5, + ) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + import time as time_mod + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + # During test, delay should be zeroed + assert cover._travel_startup_delay is None + assert cover._calibration.saved_startup_delay == 0.5 + + # Stop calibration (simulate completed test) + cover._calibration.step_count = 8 + cover._calibration.continuous_start = time_mod.monotonic() - 28.0 + await cover.stop_calibration() + + # After test, delay should be restored + assert cover._travel_startup_delay == 0.5 + + @pytest.mark.asyncio + async def test_overhead_calculation(self, make_cover): + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + import time as time_mod + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + # Simulate 8 stepped moves completed, then continuous phase + cover._calibration.step_count = 8 + # expected_remaining = (1 - 8/10) * 60 = 12s + # Continuous phase started 28s ago: 12s expected + 16s overhead (8*2) + cover._calibration.continuous_start = time_mod.monotonic() - 28.0 + result = await cover.stop_calibration() + + # overhead = (28.0 - 12.0) / 8 = 2.0 + assert result["value"] == pytest.approx(2.0, abs=0.1) + + @pytest.mark.asyncio + async def test_tilt_overhead_prerequisite(self, make_cover): + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover() # No tilt time configured + with pytest.raises(HomeAssistantError, match="[Tt]ilt time"): + await cover.start_calibration(attribute="tilt_startup_delay", timeout=300.0) + + @pytest.mark.asyncio + async def test_tilt_overhead_starts(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="tilt_startup_delay", timeout=300.0) + assert cover._calibration.automation_task is not None + # tilt: 3 steps, total_divisions=5, step_pct=20, step_duration=5.0*20/100=1.0 + assert cover._calibration.step_duration == 1.0 + + @pytest.mark.asyncio + async def test_tilt_zeros_startup_delay(self, make_cover): + """Tilt startup delay is zeroed during test and restored after.""" + cover = make_cover( + tilt_time_close=10.0, + tilt_time_open=10.0, + tilt_startup_delay=0.3, + ) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + import time as time_mod + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="tilt_startup_delay", timeout=300.0) + assert cover._tilt_startup_delay is None + assert cover._calibration.saved_startup_delay == 0.3 + + cover._calibration.step_count = 3 + cover._calibration.continuous_start = time_mod.monotonic() - 10.0 + await cover.stop_calibration() + + assert cover._tilt_startup_delay == 0.3 + + @pytest.mark.asyncio + async def test_tilt_overhead_calculation(self, make_cover): + """Tilt overhead uses 3 steps, so expected_remaining = 0.7 * total_time.""" + cover = make_cover(tilt_time_close=10.0, tilt_time_open=10.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + import time as time_mod + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="tilt_startup_delay", timeout=300.0) + # Simulate 3 stepped moves completed, then continuous phase + cover._calibration.step_count = 3 + # expected_remaining = (1 - 3/10) * 10 = 7.0s + # Continuous phase started 10s ago: 7s expected + 3s overhead (3*1.0) + cover._calibration.continuous_start = time_mod.monotonic() - 10.0 + result = await cover.stop_calibration() + + # overhead = (10.0 - 7.0) / 3 = 1.0 + assert result["value"] == pytest.approx(1.0, abs=0.1) + + +class TestMinMovementTimeCalibration: + @pytest.mark.asyncio + async def test_starts_incremental_pulses(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="min_movement_time", timeout=60.0) + assert cover._calibration.automation_task is not None + + @pytest.mark.asyncio + async def test_min_movement_result_is_last_pulse(self, make_cover): + cover = make_cover() + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="min_movement_time", timeout=60.0) + # Simulate 5 pulses (0.1, 0.2, 0.3, 0.4, 0.5) + cover._calibration.step_count = 5 + cover._calibration.last_pulse_duration = 0.5 + result = await cover.stop_calibration() + + assert result["value"] == pytest.approx(0.5) + + +class TestCalibrationEdgeCases: + """Test edge cases for calibration.""" + + @pytest.mark.asyncio + async def test_tilt_calibration_rejected_when_strategy_forbids(self, make_cover): + """Tilt time calibration should be rejected when strategy says no.""" + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover( + tilt_time_close=5.0, tilt_time_open=5.0, tilt_mode="sequential" + ) + # Mock strategy to reject tilt calibration + cover._tilt_strategy.can_calibrate_tilt = lambda: False + with pytest.raises(HomeAssistantError, match="[Tt]ilt.*not available"): + await cover.start_calibration(attribute="tilt_time_close", timeout=30.0) + + def test_resolve_direction_explicit_close(self, make_cover): + """_resolve_direction returns CLOSE for explicit 'close'.""" + from custom_components.cover_time_based.cover_base import CoverTimeBased + from homeassistant.const import SERVICE_CLOSE_COVER + + result = CoverTimeBased._resolve_direction("close", 75) + assert result == SERVICE_CLOSE_COVER + + def test_resolve_direction_explicit_open(self, make_cover): + """_resolve_direction returns OPEN for explicit 'open'.""" + from custom_components.cover_time_based.cover_base import CoverTimeBased + from homeassistant.const import SERVICE_OPEN_COVER + + result = CoverTimeBased._resolve_direction("open", 25) + assert result == SERVICE_OPEN_COVER + + def test_resolve_direction_auto_from_low_position(self, make_cover): + """_resolve_direction auto-detects OPEN when position < 50.""" + from custom_components.cover_time_based.cover_base import CoverTimeBased + from homeassistant.const import SERVICE_OPEN_COVER + + result = CoverTimeBased._resolve_direction(None, 25) + assert result == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_stop_min_movement_no_pulses_returns_zero(self, make_cover): + """Stopping min_movement calibration before any pulses returns 0.""" + cover = make_cover() + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="min_movement_time", timeout=60.0) + # Cancel the automation task before any pulses + cover._calibration.automation_task.cancel() + await asyncio.sleep(0) + result = await cover.stop_calibration() + + assert result["value"] == 0.0 + + @pytest.mark.asyncio + async def test_stop_overhead_before_continuous_returns_zero(self, make_cover): + """Stopping overhead calibration before continuous phase returns 0.""" + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + # Cancel automation task before continuous phase + cover._calibration.automation_task.cancel() + await asyncio.sleep(0) + # continuous_start is still None + result = await cover.stop_calibration() + + assert result["value"] == 0.0 + + @pytest.mark.asyncio + async def test_start_with_explicit_direction(self, make_cover): + """start_calibration with direction='close' passes through.""" + from homeassistant.const import SERVICE_CLOSE_COVER + + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_time_close", timeout=120.0, direction="close" + ) + + assert cover._calibration.move_command == SERVICE_CLOSE_COVER + + +class TestCalibrationTimeout: + @pytest.mark.asyncio + async def test_timeout_stops_motor_and_clears_state(self, make_cover): + """Timeout should stop motor, clear calibration, and not crash.""" + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=0.1) + # Wait for timeout to fire + await asyncio.sleep(0.2) + assert cover._calibration is None + + @pytest.mark.asyncio + async def test_timeout_cancels_automation_task(self, make_cover): + """Timeout during overhead test should cancel automation task.""" + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_startup_delay", timeout=0.1) + automation_task = cover._calibration.automation_task + await asyncio.sleep(0.3) + assert cover._calibration is None + assert automation_task.done() # Should be cancelled + + +class TestOverheadFallbackTravelTime: + """Test fallback travel_time selection in _start_overhead_test (lines 141, 152).""" + + @pytest.mark.asyncio + async def test_travel_startup_delay_open_direction_falls_back_to_close_time( + self, make_cover + ): + """Line 141: travel_time = self._travel_time_open or self._travel_time_close. + + When direction=open but only travel_time_close is configured, + the fallback branch should use travel_time_close. + """ + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + # Clear open time so the `or` fallback fires + cover._travel_time_open = None + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0, direction="open" + ) + assert cover._calibration is not None + assert cover._calibration.automation_task is not None + # step_duration = travel_time / 10 = 60 / 10 = 6.0 + assert cover._calibration.step_duration == 6.0 + + @pytest.mark.asyncio + async def test_tilt_startup_delay_open_direction_falls_back_to_close_time( + self, make_cover + ): + """Line 152: travel_time = self._tilting_time_open or self._tilting_time_close. + + When direction=open but only tilting_time_close is configured, + the fallback branch should use tilting_time_close. + """ + cover = make_cover(tilt_time_close=10.0, tilt_time_open=10.0) + # Clear open time so the `or` fallback fires + cover._tilting_time_open = None + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="tilt_startup_delay", timeout=300.0, direction="open" + ) + assert cover._calibration is not None + assert cover._calibration.automation_task is not None + # tilt: 3 steps, total_divisions=5, step_pct=20, step_duration=10.0*20/100=2.0 + assert cover._calibration.step_duration == 2.0 + + +class TestSetPositionAfterCalibrationNoTilt: + """Test _set_position_after_calibration with tilt attr but no tilt_calc (line 357).""" + + def test_tilt_attr_on_cover_without_tilt_support(self, make_cover): + """Line 357: if is_tilt and not hasattr(self, 'tilt_calc'): return. + + Create a cover without tilt support, then call + _set_position_after_calibration with a tilt attribute. + It should return early without error. + """ + from custom_components.cover_time_based.calibration import CalibrationState + from homeassistant.const import SERVICE_CLOSE_COVER + + cover = make_cover() # No tilt configured => no tilt_calc + # Ensure no tilt_calc exists + if hasattr(cover, "tilt_calc"): + delattr(cover, "tilt_calc") + + cal_state = CalibrationState(attribute="tilt_time_close", timeout=30.0) + cal_state.move_command = SERVICE_CLOSE_COVER + + # Should return early without raising + original_position = cover.travel_calc.current_position() + cover._set_position_after_calibration(cal_state) + # Travel position should be unchanged (tilt path was a no-op) + assert cover.travel_calc.current_position() == original_position + + +class TestCalibrationResultOpenDirection: + """Test _calculate_calibration_result for OPEN direction (lines 385, 390).""" + + @pytest.mark.asyncio + async def test_travel_startup_delay_open_direction_result(self, make_cover): + """Lines 384-385: total_time = self._travel_time_open or self._travel_time_close. + + Run calibration result calculation for travel_startup_delay + in the OPEN direction. + """ + import time as time_mod + from homeassistant.const import SERVICE_OPEN_COVER + + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0, direction="open" + ) + assert cover._calibration.move_command == SERVICE_OPEN_COVER + # Simulate 8 stepped moves completed, then continuous phase + cover._calibration.step_count = 8 + # expected_remaining = (1 - 8/10) * 60 = 12s + # Continuous phase started 28s ago: 12s expected + 16s overhead (8*2) + cover._calibration.continuous_start = time_mod.monotonic() - 28.0 + result = await cover.stop_calibration() + + # overhead = (28.0 - 12.0) / 8 = 2.0 + assert result["value"] == pytest.approx(2.0, abs=0.1) + + @pytest.mark.asyncio + async def test_tilt_startup_delay_open_direction_result(self, make_cover): + """Lines 389-390: total_time = self._tilting_time_open or self._tilting_time_close. + + Run calibration result calculation for tilt_startup_delay + in the OPEN direction. + """ + import time as time_mod + from homeassistant.const import SERVICE_OPEN_COVER + + cover = make_cover(tilt_time_close=10.0, tilt_time_open=10.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="tilt_startup_delay", timeout=300.0, direction="open" + ) + assert cover._calibration.move_command == SERVICE_OPEN_COVER + # Simulate 3 stepped moves completed, then continuous phase + cover._calibration.step_count = 3 + # expected_remaining = (1 - 3/10) * 10 = 7.0s + # Continuous phase started 10s ago: 7s expected + 3s overhead (3*1.0) + cover._calibration.continuous_start = time_mod.monotonic() - 10.0 + result = await cover.stop_calibration() + + # overhead = (10.0 - 7.0) / 3 = 1.0 + assert result["value"] == pytest.approx(1.0, abs=0.1) + + +class TestCalibrationResultTotalTimeNone: + """Test _calculate_calibration_result when total_time is None (lines 393-396).""" + + @pytest.mark.asyncio + async def test_travel_startup_delay_with_no_travel_times_returns_zero( + self, make_cover + ): + """Lines 392-396: total_time is None warning branch. + + Set both travel times to None after starting calibration, then + call _calculate_calibration_result — should return 0.0 with warning. + """ + import time as time_mod + + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + # Simulate some progress + cover._calibration.step_count = 8 + cover._calibration.continuous_start = time_mod.monotonic() - 28.0 + # Now clear both travel times so total_time resolves to None + cover._travel_time_close = None + cover._travel_time_open = None + result = await cover.stop_calibration() + + assert result["value"] == 0.0 + + +class TestPulseTimeSubtraction: + """Test pulse_time subtraction in _calculate_calibration_result (line 410).""" + + @pytest.mark.asyncio + async def test_pulse_time_subtracted_from_continuous_time(self, make_cover): + """Line 410: continuous_time -= pulse_time. + + Set _pulse_time on the cover, run travel_startup_delay calibration, + verify that pulse_time is subtracted from continuous_time in the + overhead calculation. + """ + import time as time_mod + + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + # Simulate a pulse/toggle mode cover by adding _pulse_time + cover._pulse_time = 0.5 + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + cover._calibration.step_count = 8 + # Without pulse subtraction: overhead = (28 - 12) / 8 = 2.0 + # With pulse_time=0.5: continuous_time becomes 28 - 0.5 = 27.5 + # overhead = (27.5 - 12) / 8 = 1.9375 + cover._calibration.continuous_start = time_mod.monotonic() - 28.0 + result = await cover.stop_calibration() + + # overhead = (28 - 0.5 - 12) / 8 = 1.9375 + assert result["value"] == pytest.approx(1.94, abs=0.1) + + +class TestUnexpectedCalibrationAttribute: + """Test ValueError for unexpected attribute (line 433).""" + + @pytest.mark.asyncio + async def test_unexpected_attribute_raises_value_error(self, make_cover): + """Line 433: raise ValueError(...) for unexpected attribute. + + Manually set calibration attribute to something invalid, then + call _calculate_calibration_result — should raise ValueError. + """ + from custom_components.cover_time_based.calibration import CalibrationState + + cover = make_cover() + cover._calibration = CalibrationState(attribute="bogus_attribute", timeout=60.0) + with pytest.raises(ValueError, match="Unexpected calibration attribute"): + cover._calculate_calibration_result() + + +class TestOverheadStepsFullRun: + """Test the position-polling loop and continuous phase of _run_overhead_steps (lines 209, 223-234).""" + + @pytest.mark.asyncio + async def test_run_overhead_steps_reaches_continuous_phase(self, make_cover): + """Lines 209, 223-234: The position-polling loop and continuous phase. + + Run the full overhead automation task with a travel calculator + that reaches target quickly so the step loop completes and + the continuous phase begins. + """ + cover = make_cover(travel_time_close=1.0, travel_time_open=1.0) + + # Use very short travel times so steps complete fast. + # Patch CALIBRATION_STEP_PAUSE to speed up the test (lazily + # imported from calibration module inside _run_overhead_steps). + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.calibration.CALIBRATION_STEP_PAUSE", + 0.05, + ), + ): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + # Let the automation run through the steps. + # With travel_time=1.0, step_duration=0.1, each step + # targets 10% increments which the travel_calc should reach + # quickly. + for _ in range(500): + await asyncio.sleep(0.05) + if ( + cover._calibration is not None + and cover._calibration.continuous_start is not None + ): + break + + # Verify the continuous phase was reached + assert cover._calibration is not None + assert cover._calibration.continuous_start is not None + assert cover._calibration.step_count == 8 + + # Now stop calibration to calculate result + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock( + return_value=mock_entry + ) + cover.hass.config_entries.async_update_entry = MagicMock() + result = await cover.stop_calibration() + + assert result["attribute"] == "travel_startup_delay" + assert result["value"] >= 0 + + +class TestMinMovementPulseLoop: + """Test min_movement pulse loop (lines 260-272).""" + + @pytest.mark.asyncio + async def test_min_movement_runs_multiple_pulses(self, make_cover): + """Lines 260-272: min_movement pulse loop. + + Start min_movement calibration, let it run a couple pulses, + then stop and verify step_count and last_pulse_duration. + """ + cover = make_cover() + mock_entry = MagicMock() + mock_entry.options = {} + cover.hass.config_entries.async_get_entry = MagicMock(return_value=mock_entry) + cover.hass.config_entries.async_update_entry = MagicMock() + + with patch.object(cover, "async_write_ha_state"): + # Patch the initial pause to be very short so pulses start quickly. + # These constants are lazily imported from the calibration module + # inside _run_min_movement_pulses, so patching the source works. + with ( + patch( + "custom_components.cover_time_based.calibration.CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE", + 0.05, + ), + patch( + "custom_components.cover_time_based.calibration.CALIBRATION_STEP_PAUSE", + 0.05, + ), + ): + await cover.start_calibration( + attribute="min_movement_time", timeout=60.0 + ) + # Wait for at least 2 pulses to complete + for _ in range(200): + await asyncio.sleep(0.05) + if ( + cover._calibration is not None + and cover._calibration.step_count >= 2 + ): + break + + assert cover._calibration is not None + assert cover._calibration.step_count >= 2 + assert cover._calibration.last_pulse_duration is not None + # Each pulse is 0.1s + 0.1s increment, so after 2 pulses + # last_pulse_duration should be 0.2 + assert cover._calibration.last_pulse_duration == pytest.approx( + 0.1 * cover._calibration.step_count, abs=0.01 + ) + + result = await cover.stop_calibration() + + assert cover._calibration is None + assert result["attribute"] == "min_movement_time" + assert result["value"] >= 0.2 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..122ff2a --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,37 @@ +"""Tests for CoverTimeBasedConfigFlow.""" + +import pytest +from unittest.mock import MagicMock + +from homeassistant.const import CONF_NAME + +from custom_components.cover_time_based.config_flow import CoverTimeBasedConfigFlow + + +class TestCoverTimeBasedConfigFlow: + """Test the config flow.""" + + @pytest.mark.asyncio + async def test_step_user_shows_form_when_no_input(self): + """async_step_user with no input shows the name form.""" + flow = CoverTimeBasedConfigFlow() + flow.hass = MagicMock() + + result = await flow.async_step_user(user_input=None) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert CONF_NAME in result["data_schema"].schema + + @pytest.mark.asyncio + async def test_step_user_creates_entry_with_name(self): + """async_step_user with valid input creates a config entry.""" + flow = CoverTimeBasedConfigFlow() + flow.hass = MagicMock() + + result = await flow.async_step_user(user_input={CONF_NAME: "Living Room"}) + + assert result["type"] == "create_entry" + assert result["title"] == "Living Room" + assert result["data"] == {} + assert result["options"] == {} diff --git a/tests/test_cover_base_extra.py b/tests/test_cover_base_extra.py new file mode 100644 index 0000000..9af2535 --- /dev/null +++ b/tests/test_cover_base_extra.py @@ -0,0 +1,1215 @@ +"""Tests for previously uncovered areas in cover_base.py. + +Covers: properties, state restoration, auto_updater, extra_state_attributes, +supported_features, async_set_cover_position/tilt, state monitoring +(echo filtering, external state changes), delayed_stop completion, +name fallback, _stop_travel_if_traveling with tilt. +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + CoverEntityFeature, +) +from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER + + +# =================================================================== +# Name / unique_id / device_class / assumed_state properties +# =================================================================== + + +class TestBasicProperties: + """Test basic entity properties.""" + + def test_name_from_constructor(self, make_cover): + cover = make_cover() + assert cover.name == "Test Cover" + + def test_name_falls_back_to_device_id(self, make_cover): + from custom_components.cover_time_based.cover import ( + _create_cover_from_options, + CONF_CONTROL_MODE, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_CLOSE_SWITCH_ENTITY_ID, + CONTROL_MODE_SWITCH, + ) + + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + device_id="my_device", + name="", + ) + assert cover.name == "my_device" + + def test_unique_id(self, make_cover): + cover = make_cover() + assert cover.unique_id == "cover_timebased_uuid_test_cover" + + def test_device_class_is_none(self, make_cover): + cover = make_cover() + assert cover.device_class is None + + def test_assumed_state_is_true(self, make_cover): + cover = make_cover() + assert cover.assumed_state is True + + +# =================================================================== +# extra_state_attributes +# =================================================================== + + +class TestExtraStateAttributes: + """Test extra_state_attributes returns all configured timing values.""" + + def test_attributes_with_all_values(self, make_cover): + cover = make_cover( + travel_time_close=25.0, + travel_time_open=20.0, + tilt_time_close=5.0, + tilt_time_open=4.0, + endpoint_runon_time=1.5, + min_movement_time=0.5, + travel_startup_delay=0.3, + tilt_startup_delay=0.2, + ) + attrs = cover.extra_state_attributes + assert attrs["travel_time_close"] == 25.0 + assert attrs["travel_time_open"] == 20.0 + assert attrs["tilt_time_close"] == 5.0 + assert attrs["tilt_time_open"] == 4.0 + assert attrs["endpoint_runon_time"] == 1.5 + assert attrs["min_movement_time"] == 0.5 + assert attrs["travel_startup_delay"] == 0.3 + assert attrs["tilt_startup_delay"] == 0.2 + + def test_attributes_with_no_optional_values(self, make_cover): + cover = make_cover() + attrs = cover.extra_state_attributes + # travel_time_close/open are always set (default 30) + assert attrs["travel_time_close"] == 30 + assert attrs["travel_time_open"] == 30 + # endpoint_runon_time defaults to 2.0 + assert attrs["endpoint_runon_time"] == 2.0 + # Optional values should not be present when None + assert "tilt_time_close" not in attrs + assert "tilt_time_open" not in attrs + assert "min_movement_time" not in attrs + assert "travel_startup_delay" not in attrs + assert "tilt_startup_delay" not in attrs + + +# =================================================================== +# supported_features +# =================================================================== + + +class TestSupportedFeatures: + """Test supported_features flag calculation.""" + + def test_basic_features_without_tilt(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + features = cover.supported_features + assert features & CoverEntityFeature.OPEN + assert features & CoverEntityFeature.CLOSE + assert features & CoverEntityFeature.STOP + assert features & CoverEntityFeature.SET_POSITION + assert not (features & CoverEntityFeature.OPEN_TILT) + + def test_tilt_features_with_tilt_support(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + features = cover.supported_features + assert features & CoverEntityFeature.OPEN_TILT + assert features & CoverEntityFeature.CLOSE_TILT + assert features & CoverEntityFeature.STOP_TILT + assert features & CoverEntityFeature.SET_TILT_POSITION + + def test_set_position_always_available(self, make_cover): + cover = make_cover() + # SET_POSITION is always available, even when position is unknown + features = cover.supported_features + assert features & CoverEntityFeature.SET_POSITION + + +# =================================================================== +# async_set_cover_position / async_set_cover_tilt_position +# =================================================================== + + +class TestAsyncSetCoverPosition: + """Test the HA service interface methods.""" + + @pytest.mark.asyncio + async def test_async_set_cover_position_calls_set_position(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_position(position=50) + + assert cover.travel_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_async_set_cover_tilt_position_calls_set_tilt_position( + self, make_cover + ): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_tilt_position(tilt_position=50) + + assert cover.tilt_calc.is_traveling() + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# async_added_to_hass (state restoration) +# =================================================================== + + +class TestStateRestoration: + """Test position restoration from HA history on startup.""" + + @pytest.mark.asyncio + async def test_restores_position_from_old_state(self, make_cover): + cover = make_cover() + + old_state = MagicMock() + old_state.attributes = {ATTR_CURRENT_POSITION: 70} + + with patch.object(cover, "async_get_last_state", return_value=old_state): + with patch( + "custom_components.cover_time_based.cover_base.async_track_state_change_event" + ): + await cover.async_added_to_hass() + + assert cover.travel_calc.current_position() == 70 + + @pytest.mark.asyncio + async def test_restores_position_and_tilt_from_old_state(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + + old_state = MagicMock() + old_state.attributes = { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 80, + } + + with patch.object(cover, "async_get_last_state", return_value=old_state): + with patch( + "custom_components.cover_time_based.cover_base.async_track_state_change_event" + ): + await cover.async_added_to_hass() + + assert cover.travel_calc.current_position() == 60 + assert cover.tilt_calc.current_position() == 80 + + @pytest.mark.asyncio + async def test_no_restore_when_no_old_state(self, make_cover): + cover = make_cover() + + with patch.object(cover, "async_get_last_state", return_value=None): + with patch( + "custom_components.cover_time_based.cover_base.async_track_state_change_event" + ): + await cover.async_added_to_hass() + + # Position should remain unset + assert cover.travel_calc.current_position() is None + + @pytest.mark.asyncio + async def test_no_restore_when_old_state_has_no_position(self, make_cover): + cover = make_cover() + + old_state = MagicMock() + old_state.attributes = {} + + with patch.object(cover, "async_get_last_state", return_value=old_state): + with patch( + "custom_components.cover_time_based.cover_base.async_track_state_change_event" + ): + await cover.async_added_to_hass() + + assert cover.travel_calc.current_position() is None + + @pytest.mark.asyncio + async def test_registers_state_listeners_for_switch_entities(self, make_cover): + cover = make_cover(stop_switch="switch.stop") + + with patch.object(cover, "async_get_last_state", return_value=None): + with patch( + "custom_components.cover_time_based.cover_base.async_track_state_change_event" + ) as mock_track: + await cover.async_added_to_hass() + + # Should register listeners for open, close, and stop switches + assert mock_track.call_count == 3 + assert len(cover._state_listener_unsubs) == 3 + + +# =================================================================== +# async_will_remove_from_hass +# =================================================================== + + +class TestRemoveFromHass: + """Test cleanup on removal.""" + + @pytest.mark.asyncio + async def test_unsubscribes_state_listeners(self, make_cover): + cover = make_cover() + unsub1 = MagicMock() + unsub2 = MagicMock() + cover._state_listener_unsubs = [unsub1, unsub2] + + await cover.async_will_remove_from_hass() + + unsub1.assert_called_once() + unsub2.assert_called_once() + assert len(cover._state_listener_unsubs) == 0 + + @pytest.mark.asyncio + async def test_cancels_pending_switch_timers(self, make_cover): + cover = make_cover() + timer = MagicMock() + cover._pending_switch_timers = {"switch.open": timer} + + await cover.async_will_remove_from_hass() + + timer.assert_called_once() + assert len(cover._pending_switch_timers) == 0 + + +# =================================================================== +# auto_updater_hook +# =================================================================== + + +class TestAutoUpdaterHook: + """Test the periodic auto-updater callback.""" + + @pytest.mark.asyncio + async def test_auto_updater_hook_calls_update(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel(100) + + mock_update = MagicMock() + with patch.object(cover, "async_schedule_update_ha_state", mock_update): + with patch.object(cover, "auto_stop_if_necessary", new_callable=AsyncMock): + cover.auto_updater_hook(None) + + mock_update.assert_called_once() + + @pytest.mark.asyncio + async def test_auto_updater_hook_stops_when_position_reached(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) # already at target + + unsub = MagicMock() + cover._unsubscribe_auto_updater = unsub + + with patch.object(cover, "async_schedule_update_ha_state"): + with patch.object(cover, "auto_stop_if_necessary", new_callable=AsyncMock): + cover.auto_updater_hook(None) + + # Auto updater should have been stopped + unsub.assert_called_once() + assert cover._unsubscribe_auto_updater is None + + +# =================================================================== +# auto_stop_if_necessary with tilt +# =================================================================== + + +class TestAutoStopWithTilt: + """Test auto_stop_if_necessary tilt_calc.stop() branch.""" + + @pytest.mark.asyncio + async def test_auto_stop_stops_tilt_calc(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + # Both at target, position reached + cover.travel_calc.set_position(100) + cover.tilt_calc.set_position(100) + cover.travel_calc.start_travel(100) + cover.tilt_calc.start_travel(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + assert not cover.tilt_calc.is_traveling() + + +# =================================================================== +# _delayed_stop completion +# =================================================================== + + +class TestDelayedStopCompletion: + """Test the _delayed_stop method completing successfully.""" + + @pytest.mark.asyncio + async def test_delayed_stop_completes_and_sends_stop(self, make_cover): + cover = make_cover(endpoint_runon_time=0.01) + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._delayed_stop(0.01) + + # Should have sent stop command + cover.hass.services.async_call.assert_awaited() + assert cover._last_command is None + assert cover._delay_task is None + + +# =================================================================== +# _stop_travel_if_traveling with tilt also traveling +# =================================================================== + + +class TestStopTravelIfTravelingWithTilt: + """Test _stop_travel_if_traveling stops tilt as well.""" + + def test_stops_both_travel_and_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + + cover._stop_travel_if_traveling() + + assert not cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + + +# =================================================================== +# set_position edge cases +# =================================================================== + + +class TestSetPositionEdgeCases: + """Test set_position edge cases for coverage.""" + + @pytest.mark.asyncio + async def test_direction_change_stops_tilt_too(self, make_cover): + """Direction change during set_position should stop tilt if traveling.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover.tilt_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(80) # open direction = direction change + + assert cover._last_command == SERVICE_OPEN_COVER + # Tilt should have been stopped during direction change + + @pytest.mark.asyncio + async def test_direction_change_reaches_target_after_stop(self, make_cover): + """If target equals current after stopping, cover should not move.""" + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + # After stopping, current should be ~50, same as target + await cover.set_position(50) + + # This hits the target == current after stop branch + + @pytest.mark.asyncio + async def test_set_position_cancels_active_relay_delay(self, make_cover): + """Active relay delay should be cancelled before new position movement.""" + cover = make_cover(endpoint_runon_time=10.0) + cover.travel_calc.set_position(50) + + async def fake_delay(): + await asyncio.sleep(100) + + cover._delay_task = asyncio.ensure_future(fake_delay()) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_position(0) + + assert cover.travel_calc.is_traveling() + + +# =================================================================== +# set_tilt_position edge cases +# =================================================================== + + +class TestSetTiltPositionEdgeCases: + """Test set_tilt_position edge cases for coverage.""" + + @pytest.mark.asyncio + async def test_direction_change_stops_both_tilt_and_travel(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(80) # open direction + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_direction_change_reaches_tilt_target_after_stop(self, make_cover): + """If tilt target equals current after stopping, should not move.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + # Target = 50, same as current after stop + await cover.set_tilt_position(50) + + @pytest.mark.asyncio + async def test_set_tilt_cancels_active_relay_delay(self, make_cover): + """Active relay delay should be cancelled before new tilt movement.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + endpoint_runon_time=10.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + async def fake_delay(): + await asyncio.sleep(100) + + cover._delay_task = asyncio.ensure_future(fake_delay()) + + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(0) + + assert cover.tilt_calc.is_traveling() + + +# =================================================================== +# Tilt endpoint startup delay conflicts +# =================================================================== + + +class TestTiltEndpointStartupDelay: + """Test startup delay conflicts in _async_move_tilt_to_endpoint.""" + + @pytest.mark.asyncio + async def test_tilt_direction_change_cancels_startup_delay(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_startup_delay=10.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() # start closing tilt + + original_task = cover._startup_delay_task + assert original_task is not None + assert cover._last_command == SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover_tilt() # direction change + + # Let the cancellation finalize + await asyncio.sleep(0) + + # Original startup delay should have been cancelled; + # a new one was created for the open direction + assert original_task.done() or original_task.cancelled() + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_tilt_same_direction_during_startup_delay_skips(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_startup_delay=10.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + task1 = cover._startup_delay_task + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + # Same direction, should not restart + assert cover._startup_delay_task is task1 + + @pytest.mark.asyncio + async def test_tilt_cancels_active_relay_delay(self, make_cover): + """Tilt endpoint movement should cancel active relay delay.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + endpoint_runon_time=10.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + async def fake_delay(): + await asyncio.sleep(100) + + cover._delay_task = asyncio.ensure_future(fake_delay()) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover_tilt() + + assert cover.tilt_calc.is_traveling() + + +# =================================================================== +# State monitoring: _async_switch_state_changed & echo filtering +# =================================================================== + + +class TestSwitchStateChanged: + """Test the _async_switch_state_changed state monitoring handler.""" + + @pytest.mark.asyncio + async def test_ignores_event_with_none_states(self, make_cover): + cover = make_cover() + event = MagicMock() + event.data = {"entity_id": "switch.open", "new_state": None, "old_state": None} + + with patch.object(cover, "async_write_ha_state"): + await cover._async_switch_state_changed(event) + + # Should return early, no state change handling + + @pytest.mark.asyncio + async def test_echo_filtering_decrements_pending(self, make_cover): + cover = make_cover() + cover._pending_switch["switch.open"] = 2 + + event = MagicMock() + old = MagicMock() + old.state = "off" + new = MagicMock() + new.state = "on" + event.data = { + "entity_id": "switch.open", + "old_state": old, + "new_state": new, + } + + with patch.object(cover, "async_write_ha_state"): + await cover._async_switch_state_changed(event) + + # Pending should have decremented from 2 to 1 + assert cover._pending_switch["switch.open"] == 1 + + @pytest.mark.asyncio + async def test_echo_filtering_clears_pending_at_zero(self, make_cover): + cover = make_cover() + cover._pending_switch["switch.open"] = 1 + timer = MagicMock() + cover._pending_switch_timers["switch.open"] = timer + + event = MagicMock() + old = MagicMock() + old.state = "off" + new = MagicMock() + new.state = "on" + event.data = { + "entity_id": "switch.open", + "old_state": old, + "new_state": new, + } + + with patch.object(cover, "async_write_ha_state"): + await cover._async_switch_state_changed(event) + + # Pending should have been fully cleared + assert "switch.open" not in cover._pending_switch + # Timer should have been cancelled + timer.assert_called_once() + + @pytest.mark.asyncio + async def test_external_state_change_triggers_handler(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + + event = MagicMock() + old = MagicMock() + old.state = "on" + new = MagicMock() + new.state = "off" + event.data = { + "entity_id": "switch.open", + "old_state": old, + "new_state": new, + } + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed(event) + + # Delegates to mode-specific handler (position tracking, not clearing) + handler.assert_awaited_once_with("switch.open", "on", "off") + + @pytest.mark.asyncio + async def test_triggered_externally_reset_after_handler(self, make_cover): + cover = make_cover() + + event = MagicMock() + old = MagicMock() + old.state = "on" + new = MagicMock() + new.state = "off" + event.data = { + "entity_id": "switch.open", + "old_state": old, + "new_state": new, + } + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ), + ): + await cover._async_switch_state_changed(event) + + assert cover._triggered_externally is False + + +# =================================================================== +# _mark_switch_pending +# =================================================================== + + +class TestMarkSwitchPending: + """Test echo filtering setup via _mark_switch_pending.""" + + def test_increments_pending_count(self, make_cover): + cover = make_cover() + with patch( + "custom_components.cover_time_based.cover_base.async_call_later" + ) as mock_call_later: + mock_call_later.return_value = MagicMock() + cover._mark_switch_pending("switch.open", 2) + + assert cover._pending_switch["switch.open"] == 2 + + def test_accumulates_pending_count(self, make_cover): + cover = make_cover() + with patch( + "custom_components.cover_time_based.cover_base.async_call_later" + ) as mock_call_later: + mock_call_later.return_value = MagicMock() + cover._mark_switch_pending("switch.open", 1) + cover._mark_switch_pending("switch.open", 2) + + assert cover._pending_switch["switch.open"] == 3 + + def test_cancels_existing_timeout(self, make_cover): + cover = make_cover() + old_timer = MagicMock() + cover._pending_switch_timers["switch.open"] = old_timer + + with patch( + "custom_components.cover_time_based.cover_base.async_call_later" + ) as mock_call_later: + mock_call_later.return_value = MagicMock() + cover._mark_switch_pending("switch.open", 1) + + old_timer.assert_called_once() + + def test_sets_new_safety_timeout(self, make_cover): + cover = make_cover() + with patch( + "custom_components.cover_time_based.cover_base.async_call_later" + ) as mock_call_later: + mock_call_later.return_value = MagicMock() + cover._mark_switch_pending("switch.open", 1) + + mock_call_later.assert_called_once() + assert "switch.open" in cover._pending_switch_timers + + +# =================================================================== +# _execute_with_startup_delay completion +# =================================================================== + + +class TestStartupDelayCompletion: + """Test that _execute_with_startup_delay completes correctly.""" + + @pytest.mark.asyncio + async def test_startup_delay_completes_and_starts_tracking(self, make_cover): + cover = make_cover(travel_startup_delay=0.01) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Wait for the startup delay to complete + await cover._startup_delay_task + + # After delay, tracking should have started + assert cover.travel_calc.is_traveling() + assert cover._startup_delay_task is None + + +# =================================================================== +# _async_handle_command with _triggered_externally +# =================================================================== + + +class TestHandleCommandExternallyTriggered: + """Test _async_handle_command skips relay when triggered externally.""" + + @pytest.mark.asyncio + async def test_external_close_skips_send_close(self, make_cover): + cover = make_cover() + cover._triggered_externally = True + + with patch.object(cover, "_send_close", new_callable=AsyncMock) as mock_send: + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_external_open_skips_send_open(self, make_cover): + cover = make_cover() + cover._triggered_externally = True + + with patch.object(cover, "_send_open", new_callable=AsyncMock) as mock_send: + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + mock_send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_external_stop_skips_send_stop(self, make_cover): + cover = make_cover() + cover._triggered_externally = True + + with patch.object(cover, "_send_stop", new_callable=AsyncMock) as mock_send: + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command("stop_cover") + + mock_send.assert_not_awaited() + + +# =================================================================== +# async_stop_cover with _triggered_externally +# =================================================================== + + +class TestStopCoverExternallyTriggered: + """Test async_stop_cover skips _send_stop when externally triggered.""" + + @pytest.mark.asyncio + async def test_stop_externally_triggered_skips_send(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + cover._triggered_externally = True + + with patch.object(cover, "_send_stop", new_callable=AsyncMock) as mock_send: + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + mock_send.assert_not_awaited() + + +# =================================================================== +# position_reached +# =================================================================== + + +class TestPositionReached: + """Test position_reached method.""" + + def test_position_reached_without_tilt(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) + assert cover.position_reached() is True + + def test_position_not_reached(self, make_cover): + cover = make_cover() + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel(100) + assert cover.position_reached() is False + + def test_position_reached_with_tilt_both_reached(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel(50) + assert cover.position_reached() is True + + def test_position_reached_with_tilt_not_reached(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) + cover.tilt_calc.set_position(0) + cover.tilt_calc.start_travel(100) + assert cover.position_reached() is False + + +# =================================================================== +# is_opening / is_closing with tilt +# =================================================================== + + +class TestIsOpeningClosingWithTilt: + """Test is_opening/is_closing when only tilt is traveling.""" + + def test_is_opening_from_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + cover.tilt_calc.start_travel_up() + assert cover.is_opening is True + + def test_is_closing_from_tilt(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + cover.tilt_calc.start_travel_down() + assert cover.is_closing is True + + +# =================================================================== +# start_auto_updater / stop_auto_updater +# =================================================================== + + +class TestAutoUpdater: + """Test start/stop auto updater.""" + + def test_start_auto_updater_subscribes(self, make_cover): + cover = make_cover() + with patch( + "custom_components.cover_time_based.cover_base.async_track_time_interval" + ) as mock_track: + mock_track.return_value = MagicMock() + cover.start_auto_updater() + + assert cover._unsubscribe_auto_updater is not None + + def test_start_auto_updater_idempotent(self, make_cover): + cover = make_cover() + unsub = MagicMock() + cover._unsubscribe_auto_updater = unsub + + with patch( + "custom_components.cover_time_based.cover_base.async_track_time_interval" + ) as mock_track: + cover.start_auto_updater() + + # Should not create a second subscriber + mock_track.assert_not_called() + assert cover._unsubscribe_auto_updater is unsub + + def test_stop_auto_updater_unsubscribes(self, make_cover): + cover = make_cover() + unsub = MagicMock() + cover._unsubscribe_auto_updater = unsub + + cover.stop_auto_updater() + + unsub.assert_called_once() + assert cover._unsubscribe_auto_updater is None + + def test_stop_auto_updater_noop_when_not_running(self, make_cover): + cover = make_cover() + cover._unsubscribe_auto_updater = None + + cover.stop_auto_updater() # Should not raise + + +# =================================================================== +# set_tilt_position: startup delay same direction (line 605) +# =================================================================== + + +class TestSetTiltPositionStartupDelay: + """set_tilt_position with same-direction startup delay should skip.""" + + @pytest.mark.asyncio + async def test_same_direction_startup_delay_skips(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_startup_delay=10.0, + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Start closing tilt (creates startup delay) + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(20) # close direction + + task1 = cover._startup_delay_task + assert task1 is not None + assert cover._last_command == SERVICE_CLOSE_COVER + + # Same direction set_tilt during startup delay should skip + with patch.object(cover, "async_write_ha_state"): + await cover.set_tilt_position(10) # still close direction + + # Task should not have been restarted + assert cover._startup_delay_task is task1 + + +# =================================================================== +# _mark_switch_pending: safety timeout callback (lines 784-787) +# =================================================================== + + +class TestMarkSwitchPendingTimeout: + """Test the safety timeout callback in _mark_switch_pending.""" + + def test_safety_timeout_clears_pending(self, make_cover): + cover = make_cover() + captured_callback = [None] + + def mock_call_later(hass, delay, callback): + captured_callback[0] = callback + return MagicMock() + + with patch( + "custom_components.cover_time_based.cover_base.async_call_later", + side_effect=mock_call_later, + ): + cover._mark_switch_pending("switch.open", 2) + + assert cover._pending_switch["switch.open"] == 2 + assert captured_callback[0] is not None + + # Simulate the timeout firing + captured_callback[0](None) + + # Pending should have been cleared + assert "switch.open" not in cover._pending_switch + assert "switch.open" not in cover._pending_switch_timers + + +# =================================================================== +# Unconfigured entity paths +# =================================================================== + + +class TestUnconfiguredEntity: + """Test _get_missing_configuration, available, _require_configured.""" + + def test_unconfigured_not_available(self, make_cover): + """Cover with no switch entities is not available.""" + cover = make_cover(open_switch="", close_switch="") + assert cover.available is False + + def test_no_travel_times_not_available(self, make_cover): + """Cover with no travel times is not available.""" + cover = make_cover() + cover._travel_time_close = None + cover._travel_time_open = None + assert cover.available is False + + def test_pulse_mode_without_stop_switch_not_available(self, make_cover): + """Pulse mode cover without stop switch is not available.""" + from custom_components.cover_time_based.cover import CONTROL_MODE_PULSE + + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch=None) + assert cover.available is False + assert "stop switch" in cover._get_missing_configuration() + + def test_pulse_mode_with_stop_switch_available(self, make_cover): + """Pulse mode cover with stop switch is available.""" + from custom_components.cover_time_based.cover import CONTROL_MODE_PULSE + + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + assert cover.available is True + + def test_pulse_mode_tilt_without_tilt_stop_not_available(self, make_cover): + """Pulse mode with dual_motor tilt but no tilt stop switch is not available.""" + from custom_components.cover_time_based.cover import CONTROL_MODE_PULSE + + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + assert cover.available is False + assert "tilt stop switch" in cover._get_missing_configuration() + + def test_pulse_mode_tilt_with_tilt_stop_available(self, make_cover): + """Pulse mode with dual_motor tilt and tilt stop switch is available.""" + from custom_components.cover_time_based.cover import CONTROL_MODE_PULSE + + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + assert cover.available is True + + @pytest.mark.asyncio + async def test_require_configured_raises(self, make_cover): + """Moving an unconfigured cover raises HomeAssistantError.""" + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover(open_switch="", close_switch="") + with pytest.raises(HomeAssistantError, match="not configured"): + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_position(position=50) + + @pytest.mark.asyncio + async def test_require_travel_time_raises(self, make_cover): + """Moving without travel time configured raises HomeAssistantError.""" + from homeassistant.exceptions import HomeAssistantError + + cover = make_cover() + cover._travel_time_close = None + cover._travel_time_open = None + with pytest.raises(HomeAssistantError, match="[Tt]ravel time"): + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_position(position=50) + + +# =================================================================== +# Removal during calibration +# =================================================================== + + +class TestRemovalDuringCalibration: + """Test async_will_remove_from_hass cancels calibration tasks.""" + + @pytest.mark.asyncio + async def test_cleanup_cancels_calibration_tasks(self, make_cover): + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + assert cover._calibration is not None + timeout_task = cover._calibration.timeout_task + + await cover.async_will_remove_from_hass() + await asyncio.sleep(0) # Let event loop process cancellation + + assert cover._calibration is None + assert timeout_task.cancelled() + + +# =================================================================== +# Unknown position paths +# =================================================================== + + +class TestSetPositionFromUnknown: + """Test set_position when current position is None.""" + + @pytest.mark.asyncio + async def test_set_position_closing_from_unknown(self, make_cover): + """target <= 50 assumes cover is at 100 and starts closing.""" + cover = make_cover() + assert cover.travel_calc.current_position() is None + + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_position(position=30) + + # Calculator is set to assumed position (100) and traveling toward 30 + assert cover.travel_calc.is_closing() + assert cover.travel_calc._travel_to_position == 30 + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_set_position_opening_from_unknown(self, make_cover): + """target > 50 assumes cover is at 0 and starts opening.""" + cover = make_cover() + assert cover.travel_calc.current_position() is None + + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_position(position=70) + + assert cover.travel_calc.is_opening() + assert cover.travel_calc._travel_to_position == 70 + assert cover._last_command == SERVICE_OPEN_COVER + + +class TestSetTiltFromUnknown: + """Test set_tilt_position when current tilt is None.""" + + @pytest.mark.asyncio + async def test_set_tilt_closing_from_unknown(self, make_cover): + """target <= 50 assumes tilt is at 100 and starts closing.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + assert cover.tilt_calc.current_position() is None + + with patch.object(cover, "async_write_ha_state"): + await cover.async_set_cover_tilt_position(tilt_position=30) + + # Calculator is set to assumed position (100) and traveling toward 30 + assert cover.tilt_calc.is_closing() + assert cover.tilt_calc._travel_to_position == 30 + assert cover._last_command == SERVICE_CLOSE_COVER + + +# =================================================================== +# set_known_tilt_position on cover without tilt +# =================================================================== + + +class TestSetKnownTiltNoTilt: + """Test set_known_tilt_position on a cover without tilt support.""" + + @pytest.mark.asyncio + async def test_no_op_without_tilt_support(self, make_cover): + cover = make_cover() # No tilt configured + with patch.object(cover, "async_write_ha_state"): + await cover.set_known_tilt_position(tilt_position=50) + # Should not crash, no service call for tilt + # Position didn't change from None diff --git a/tests/test_cover_factory.py b/tests/test_cover_factory.py new file mode 100644 index 0000000..7cb444b --- /dev/null +++ b/tests/test_cover_factory.py @@ -0,0 +1,523 @@ +"""Tests for cover factory and YAML config parsing in cover.py.""" + +import pytest +from unittest.mock import MagicMock, patch + +from custom_components.cover_time_based.cover import ( + CONF_CLOSE_SWITCH_ENTITY_ID, + CONF_CONTROL_MODE, + CONF_COVER_ENTITY_ID, + CONF_DEFAULTS, + CONF_DEVICES, + CONF_IS_BUTTON, + CONF_MIN_MOVEMENT_TIME, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_PULSE_TIME, + CONF_STOP_SWITCH_ENTITY_ID, + CONF_TILT_STARTUP_DELAY, + CONF_TILTING_TIME_DOWN, + CONF_TILTING_TIME_UP, + CONF_TRAVEL_DELAY_AT_END, + CONF_TRAVEL_MOVES_WITH_TILT, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVELLING_TIME_DOWN, + CONF_TRAVELLING_TIME_UP, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONTROL_MODE_PULSE, + CONTROL_MODE_SWITCH, + CONTROL_MODE_TOGGLE, + CONTROL_MODE_WRAPPED, + _create_cover_from_options, + _resolve_tilt_strategy, + devices_from_config, +) +from custom_components.cover_time_based.cover_switch_mode import SwitchModeCover +from custom_components.cover_time_based.cover_pulse_mode import PulseModeCover +from custom_components.cover_time_based.cover_toggle_mode import ToggleModeCover +from custom_components.cover_time_based.cover_wrapped import WrappedCoverTimeBased +from custom_components.cover_time_based.tilt_strategies import ( + DualMotorTilt, + InlineTilt, + SequentialTilt, +) + + +# =================================================================== +# _create_cover_from_options +# =================================================================== + + +class TestCreateCoverFromOptions: + """Test the factory function for creating cover subclasses.""" + + def test_creates_switch_mode_cover(self): + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + device_id="test", + name="Test", + ) + assert isinstance(cover, SwitchModeCover) + + def test_creates_pulse_mode_cover(self): + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_PULSE, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_PULSE_TIME: 0.5, + }, + device_id="test", + name="Test", + ) + assert isinstance(cover, PulseModeCover) + + def test_creates_toggle_mode_cover(self): + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_TOGGLE, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_PULSE_TIME: 0.5, + }, + device_id="test", + name="Test", + ) + assert isinstance(cover, ToggleModeCover) + + def test_creates_wrapped_cover(self): + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_WRAPPED, + CONF_COVER_ENTITY_ID: "cover.inner", + }, + device_id="test", + name="Test", + ) + assert isinstance(cover, WrappedCoverTimeBased) + + def test_defaults_to_switch_mode(self): + """When control_mode is not specified, defaults to switch.""" + cover = _create_cover_from_options( + { + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + device_id="test", + name="Test", + ) + assert isinstance(cover, SwitchModeCover) + + def test_passes_common_params(self): + cover = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_TRAVEL_TIME_CLOSE: 25.0, + CONF_TRAVEL_TIME_OPEN: 20.0, + CONF_TILT_TIME_CLOSE: 5.0, + CONF_TILT_TIME_OPEN: 4.0, + CONF_MIN_MOVEMENT_TIME: 0.5, + }, + device_id="myid", + name="My Cover", + ) + assert cover.name == "My Cover" + assert cover._travel_time_close == 25.0 + assert cover._travel_time_open == 20.0 + assert cover._tilting_time_close == 5.0 + assert cover._tilting_time_open == 4.0 + assert cover._min_movement_time == 0.5 + + +# =================================================================== +# devices_from_config +# =================================================================== + + +class TestDevicesFromConfig: + """Test YAML config parsing via devices_from_config.""" + + def test_creates_switch_mode_from_yaml(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Living Room", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + }, + } + devices = devices_from_config(config) + assert len(devices) == 1 + assert isinstance(devices[0], SwitchModeCover) + assert devices[0].name == "Living Room" + + def test_creates_wrapped_cover_from_yaml(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Bedroom", + CONF_COVER_ENTITY_ID: "cover.inner", + }, + }, + } + devices = devices_from_config(config) + assert len(devices) == 1 + assert isinstance(devices[0], WrappedCoverTimeBased) + + def test_creates_pulse_mode_from_yaml(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Kitchen", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + "input_mode": "pulse", + }, + }, + } + devices = devices_from_config(config) + assert isinstance(devices[0], PulseModeCover) + + def test_creates_toggle_mode_from_yaml(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Kitchen", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + "input_mode": "toggle", + }, + }, + } + devices = devices_from_config(config) + assert isinstance(devices[0], ToggleModeCover) + + def test_defaults_applied(self): + """Old YAML keys in defaults are migrated to new names.""" + config = { + CONF_DEFAULTS: { + CONF_TRAVELLING_TIME_DOWN: 15.0, + CONF_TRAVELLING_TIME_UP: 12.0, + CONF_TILTING_TIME_DOWN: 3.0, + CONF_TILTING_TIME_UP: 2.5, + }, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + }, + } + devices = devices_from_config(config) + cover = devices[0] + assert cover._travel_time_close == 15.0 + assert cover._travel_time_open == 12.0 + assert cover._tilting_time_close == 3.0 + assert cover._tilting_time_open == 2.5 + + def test_device_config_overrides_defaults(self): + config = { + CONF_DEFAULTS: { + CONF_TRAVELLING_TIME_DOWN: 15.0, + }, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_TRAVELLING_TIME_DOWN: 25.0, + }, + }, + } + devices = devices_from_config(config) + assert devices[0]._travel_time_close == 25.0 + + def test_is_button_deprecated_to_pulse_mode(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_IS_BUTTON: True, + }, + }, + } + devices = devices_from_config(config) + assert isinstance(devices[0], PulseModeCover) + + def test_input_mode_takes_precedence_over_is_button(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_IS_BUTTON: True, + "input_mode": "toggle", + }, + }, + } + devices = devices_from_config(config) + assert isinstance(devices[0], ToggleModeCover) + + def test_multiple_devices(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Living", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open1", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close1", + }, + "blind2": { + "name": "Bedroom", + CONF_COVER_ENTITY_ID: "cover.inner", + }, + }, + } + devices = devices_from_config(config) + assert len(devices) == 2 + + def test_stop_switch_passed_through(self): + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_STOP_SWITCH_ENTITY_ID: "switch.stop", + }, + }, + } + devices = devices_from_config(config) + assert devices[0]._stop_switch_entity_id == "switch.stop" + + def test_all_timing_params_from_defaults(self): + config = { + CONF_DEFAULTS: { + CONF_TRAVEL_DELAY_AT_END: 1.0, + CONF_MIN_MOVEMENT_TIME: 0.5, + CONF_TRAVEL_STARTUP_DELAY: 0.3, + CONF_TILT_STARTUP_DELAY: 0.2, + CONF_PULSE_TIME: 0.8, + }, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + "input_mode": "pulse", + }, + }, + } + devices = devices_from_config(config) + cover = devices[0] + assert cover._endpoint_runon_time == 1.0 + assert cover._min_movement_time == 0.5 + assert cover._travel_startup_delay == 0.3 + assert cover._tilt_startup_delay == 0.2 + + +# =================================================================== +# async_setup_platform (deprecated YAML) +# =================================================================== + + +class TestAsyncSetupPlatform: + """Test the deprecated YAML setup.""" + + @pytest.mark.asyncio + async def test_setup_platform_creates_entities(self): + from custom_components.cover_time_based.cover import async_setup_platform + + hass = MagicMock() + added_entities = [] + + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Test", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + }, + }, + } + + platform = MagicMock() + with patch("custom_components.cover_time_based.cover.async_create_issue"): + with patch( + "custom_components.cover_time_based.cover.entity_platform.current_platform" + ) as mock_platform: + mock_platform.get.return_value = platform + await async_setup_platform( + hass, config, lambda entities: added_entities.extend(entities) + ) + + assert len(added_entities) == 1 + assert isinstance(added_entities[0], SwitchModeCover) + # Should register services + assert platform.async_register_entity_service.call_count == 2 + + +# =================================================================== +# async_setup_entry +# =================================================================== + + +class TestAsyncSetupEntry: + """Test config entry setup.""" + + @pytest.mark.asyncio + async def test_setup_entry_creates_entity(self): + from custom_components.cover_time_based.cover import async_setup_entry + + hass = MagicMock() + added_entities = [] + + config_entry = MagicMock() + config_entry.entry_id = "test_entry" + config_entry.title = "My Cover" + config_entry.options = { + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + } + + platform = MagicMock() + with patch( + "custom_components.cover_time_based.cover.entity_platform.current_platform" + ) as mock_platform: + mock_platform.get.return_value = platform + await async_setup_entry( + hass, + config_entry, + lambda entities: added_entities.extend(entities), + ) + + assert len(added_entities) == 1 + assert added_entities[0].name == "My Cover" + assert platform.async_register_entity_service.call_count == 2 + + +# =================================================================== +# _resolve_tilt_strategy +# =================================================================== + + +class TestResolveTiltStrategy: + def test_none_when_tilt_mode_none(self): + assert _resolve_tilt_strategy("none", 2.0, 2.0) is None + + def test_none_when_no_tilt_times(self): + assert _resolve_tilt_strategy("sequential", None, None) is None + + def test_none_when_partial_tilt_times(self): + assert _resolve_tilt_strategy("sequential", 2.0, None) is None + + def test_sequential(self): + result = _resolve_tilt_strategy("sequential", 2.0, 2.0) + assert isinstance(result, SequentialTilt) + + def test_dual_motor_defaults(self): + result = _resolve_tilt_strategy("dual_motor", 2.0, 2.0) + assert isinstance(result, DualMotorTilt) + assert result._safe_tilt_position == 100 + assert result._max_tilt_allowed_position is None + + def test_dual_motor_with_options(self): + result = _resolve_tilt_strategy( + "dual_motor", + 2.0, + 2.0, + safe_tilt_position=10, + max_tilt_allowed_position=80, + ) + assert isinstance(result, DualMotorTilt) + assert result._safe_tilt_position == 10 + assert result._max_tilt_allowed_position == 80 + + def test_dual_motor_safe_tilt_position_zero(self): + result = _resolve_tilt_strategy("dual_motor", 2.0, 2.0, safe_tilt_position=0) + assert isinstance(result, DualMotorTilt) + assert result._safe_tilt_position == 0 + + def test_inline(self): + result = _resolve_tilt_strategy("inline", 2.0, 2.0) + assert isinstance(result, InlineTilt) + + def test_unknown_mode_defaults_to_sequential(self): + result = _resolve_tilt_strategy("unknown_value", 2.0, 2.0) + assert isinstance(result, SequentialTilt) + + +# =================================================================== +# Legacy travel_moves_with_tilt → inline migration +# =================================================================== + + +class TestLegacyTravelMovesWithTiltMigration: + """Test that legacy YAML travel_moves_with_tilt=true migrates to inline tilt mode. + + Covers cover.py line 352. + """ + + def test_travel_moves_with_tilt_migrates_to_inline(self): + """When travel_moves_with_tilt is true and tilt_mode is not set + (defaults to 'none'), devices_from_config should produce a cover + with an InlineTilt strategy.""" + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "Legacy Inline", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_TRAVEL_MOVES_WITH_TILT: True, + CONF_TILT_TIME_CLOSE: 2.0, + CONF_TILT_TIME_OPEN: 2.0, + }, + }, + } + devices = devices_from_config(config) + assert len(devices) == 1 + cover = devices[0] + assert isinstance(cover._tilt_strategy, InlineTilt) + + def test_travel_moves_with_tilt_false_stays_none(self): + """When travel_moves_with_tilt is false, tilt_mode stays 'none' + and no tilt strategy is created (even with tilt times set).""" + config = { + CONF_DEFAULTS: {}, + CONF_DEVICES: { + "blind1": { + "name": "No Migration", + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_TRAVEL_MOVES_WITH_TILT: False, + CONF_TILT_TIME_CLOSE: 2.0, + CONF_TILT_TIME_OPEN: 2.0, + }, + }, + } + devices = devices_from_config(config) + cover = devices[0] + assert cover._tilt_strategy is None diff --git a/tests/test_cover_pulse_mode.py b/tests/test_cover_pulse_mode.py new file mode 100644 index 0000000..346aa3c --- /dev/null +++ b/tests/test_cover_pulse_mode.py @@ -0,0 +1,335 @@ +"""Tests for PulseModeCover._send_open/close/stop. + +Each test verifies the exact sequence of homeassistant.turn_on/turn_off +service calls for the momentary pulse mode. + +Pulse completion (sleep + turn_off) now runs in background tasks. Tests +await those tasks to verify the full call sequence. +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from custom_components.cover_time_based.cover_pulse_mode import PulseModeCover + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def _make_pulse_cover( + open_switch="switch.open", + close_switch="switch.close", + stop_switch=None, + pulse_time=1.0, + tilt_open_switch=None, + tilt_close_switch=None, + tilt_stop_switch=None, +): + """Create a PulseModeCover wired to a mock hass.""" + cover = PulseModeCover( + device_id="test_pulse", + name="Test Pulse", + tilt_strategy=None, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=None, + tilt_time_open=None, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id=open_switch, + close_switch_entity_id=close_switch, + stop_switch_entity_id=stop_switch, + pulse_time=pulse_time, + tilt_open_switch=tilt_open_switch, + tilt_close_switch=tilt_close_switch, + tilt_stop_switch=tilt_stop_switch, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + return cover + + +async def _drain_tasks(cover): + """Await all background tasks created during a send call.""" + for task in cover._test_tasks: + await task + cover._test_tasks.clear() + + +async def _cancel_tasks(cover): + """Cancel all pending background tasks (for tests that don't drain).""" + for task in cover._test_tasks: + if not task.done(): + task.cancel() + if cover._test_tasks: + await asyncio.gather(*cover._test_tasks, return_exceptions=True) + cover._test_tasks.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _calls(mock: AsyncMock): + return mock.call_args_list + + +def _ha(service, entity_id): + return call("homeassistant", service, {"entity_id": entity_id}, False) + + +# --------------------------------------------------------------------------- +# _send_open +# --------------------------------------------------------------------------- + + +class TestPulseModeSendOpen: + @pytest.mark.asyncio + async def test_open_without_stop_switch(self): + cover = _make_pulse_cover() + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_with_stop_switch(self): + cover = _make_pulse_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + _ha("turn_off", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_returns_before_pulse_completion(self): + """Verify _send_open returns immediately after the ON edge.""" + cover = _make_pulse_cover() + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_open() + + # Only synchronous calls made — turn_off (pulse cleanup) is background + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + await _cancel_tasks(cover) + + +# --------------------------------------------------------------------------- +# _send_close +# --------------------------------------------------------------------------- + + +class TestPulseModeSendClose: + @pytest.mark.asyncio + async def test_close_without_stop_switch(self): + cover = _make_pulse_cover() + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_with_stop_switch(self): + cover = _make_pulse_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + _ha("turn_off", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + +# --------------------------------------------------------------------------- +# _send_stop +# --------------------------------------------------------------------------- + + +class TestPulseModeSendStop: + @pytest.mark.asyncio + async def test_stop_without_stop_switch(self): + cover = _make_pulse_cover() + await cover._send_stop() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_with_stop_switch_pulses_it(self): + cover = _make_pulse_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.stop"), + ] + + @pytest.mark.asyncio + async def test_stop_returns_before_pulse_completion(self): + """Verify _send_stop returns immediately after the stop ON edge.""" + cover = _make_pulse_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_stop() + + # Only synchronous calls — stop pulse cleanup is background + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.stop"), + ] + await _cancel_tasks(cover) + + +# --------------------------------------------------------------------------- +# _send_tilt_open / _send_tilt_close / _send_tilt_stop +# --------------------------------------------------------------------------- + + +class TestPulseModeSendTiltOpen: + @pytest.mark.asyncio + async def test_tilt_open_pulses_tilt_open_switch(self): + cover = _make_pulse_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_close"), + _ha("turn_on", "switch.tilt_open"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_open"), + ] + + +class TestPulseModeSendTiltClose: + @pytest.mark.asyncio + async def test_tilt_close_pulses_tilt_close_switch(self): + cover = _make_pulse_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_open"), + _ha("turn_on", "switch.tilt_close"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_close"), + ] + + +class TestPulseModeSendTiltStop: + @pytest.mark.asyncio + async def test_tilt_stop_without_stop_switch(self): + cover = _make_pulse_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + await cover._send_tilt_stop() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_open"), + _ha("turn_off", "switch.tilt_close"), + ] + + @pytest.mark.asyncio + async def test_tilt_stop_with_stop_switch(self): + cover = _make_pulse_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_open"), + _ha("turn_off", "switch.tilt_close"), + _ha("turn_on", "switch.tilt_stop"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_stop"), + ] diff --git a/tests/test_cover_services.py b/tests/test_cover_services.py new file mode 100644 index 0000000..f2076b6 --- /dev/null +++ b/tests/test_cover_services.py @@ -0,0 +1,177 @@ +"""Tests for cover.py service registration and resolve_entity.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.exceptions import HomeAssistantError + +from custom_components.cover_time_based.helpers import resolve_entity +from custom_components.cover_time_based.cover import ( + _register_services, + _create_cover_from_options, + CONF_CONTROL_MODE, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_CLOSE_SWITCH_ENTITY_ID, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + CONTROL_MODE_SWITCH, + SERVICE_START_CALIBRATION, + SERVICE_STOP_CALIBRATION, +) + + +# --------------------------------------------------------------------------- +# resolve_entity +# --------------------------------------------------------------------------- + + +class TestResolveEntity: + """Test resolve_entity from cover.py (raises on error).""" + + def test_raises_when_no_cover_component(self): + hass = MagicMock() + hass.data = {} + + with pytest.raises(HomeAssistantError, match="Cover platform not loaded"): + resolve_entity(hass, "cover.test") + + def test_raises_when_entity_components_has_no_cover(self): + hass = MagicMock() + hass.data = {"entity_components": {}} + + with pytest.raises(HomeAssistantError, match="Cover platform not loaded"): + resolve_entity(hass, "cover.test") + + def test_raises_when_entity_not_found(self): + component = MagicMock() + component.get_entity.return_value = None + + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + + with pytest.raises(HomeAssistantError, match="cover.test"): + resolve_entity(hass, "cover.test") + + def test_raises_when_not_cover_time_based(self): + """Entity exists but is not a CoverTimeBased instance.""" + component = MagicMock() + component.get_entity.return_value = MagicMock() # not CoverTimeBased + + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + + with pytest.raises(HomeAssistantError, match="not a cover_time_based"): + resolve_entity(hass, "cover.test") + + def test_returns_valid_entity(self): + """Valid CoverTimeBased entity is returned.""" + entity = _create_cover_from_options( + { + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_TRAVEL_TIME_CLOSE: 30, + CONF_TRAVEL_TIME_OPEN: 30, + }, + device_id="test", + name="Test", + ) + + component = MagicMock() + component.get_entity.return_value = entity + + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + + result = resolve_entity(hass, "cover.test") + assert result is entity + + +# --------------------------------------------------------------------------- +# _register_services (calibration handlers) +# --------------------------------------------------------------------------- + + +class TestServiceHandlers: + """Test calibration service handler closures.""" + + @pytest.mark.asyncio + async def test_start_calibration_handler(self): + """_handle_start_calibration resolves entity and calls start_calibration.""" + hass = MagicMock() + hass.services.has_service.return_value = False + hass.services.async_register = MagicMock() + + platform = MagicMock() + platform.hass = hass + + _register_services(platform) + + # Find the start_calibration handler from async_register calls + handler = None + for call in hass.services.async_register.call_args_list: + if call[0][1] == SERVICE_START_CALIBRATION: + handler = call[0][2] + break + assert handler is not None + + # Mock entity and call the handler + mock_entity = MagicMock() + mock_entity.start_calibration = AsyncMock() + + service_call = MagicMock() + service_call.data = { + "entity_id": "cover.test", + "attribute": "travel_time_close", + "timeout": 60.0, + } + + from unittest.mock import patch + + with patch( + "custom_components.cover_time_based.cover.resolve_entity", + return_value=mock_entity, + ): + await handler(service_call) + + mock_entity.start_calibration.assert_awaited_once_with( + attribute="travel_time_close", timeout=60.0 + ) + + @pytest.mark.asyncio + async def test_stop_calibration_handler(self): + """_handle_stop_calibration resolves entity and calls stop_calibration.""" + hass = MagicMock() + hass.services.has_service.return_value = False + hass.services.async_register = MagicMock() + + platform = MagicMock() + platform.hass = hass + + _register_services(platform) + + # Find the stop_calibration handler + handler = None + for call in hass.services.async_register.call_args_list: + if call[0][1] == SERVICE_STOP_CALIBRATION: + handler = call[0][2] + break + assert handler is not None + + mock_entity = MagicMock() + mock_entity.stop_calibration = AsyncMock( + return_value={"attribute": "travel_time_close", "value": 45.0} + ) + + service_call = MagicMock() + service_call.data = {"entity_id": "cover.test", "cancel": False} + + from unittest.mock import patch + + with patch( + "custom_components.cover_time_based.cover.resolve_entity", + return_value=mock_entity, + ): + await handler(service_call) + + mock_entity.stop_calibration.assert_awaited_once_with(cancel=False) diff --git a/tests/test_cover_switch_mode.py b/tests/test_cover_switch_mode.py new file mode 100644 index 0000000..b2a8307 --- /dev/null +++ b/tests/test_cover_switch_mode.py @@ -0,0 +1,143 @@ +"""Tests for SwitchModeCover._send_open/close/stop. + +Each test verifies the exact sequence of homeassistant.turn_on/turn_off +service calls for the latching relay (switch) mode. +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, call + +from custom_components.cover_time_based.cover_switch_mode import SwitchModeCover + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def _make_switch_cover( + open_switch="switch.open", + close_switch="switch.close", + stop_switch=None, +): + """Create a SwitchModeCover wired to a mock hass.""" + cover = SwitchModeCover( + device_id="test_switch", + name="Test Switch", + tilt_strategy=None, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=None, + tilt_time_open=None, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id=open_switch, + close_switch_entity_id=close_switch, + stop_switch_entity_id=stop_switch, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + hass.async_create_task = lambda coro: asyncio.ensure_future(coro) + cover.hass = hass + return cover + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _calls(mock: AsyncMock): + return mock.call_args_list + + +def _ha(service, entity_id): + return call("homeassistant", service, {"entity_id": entity_id}, False) + + +# --------------------------------------------------------------------------- +# _send_open +# --------------------------------------------------------------------------- + + +class TestSwitchModeSendOpen: + @pytest.mark.asyncio + async def test_open_without_stop_switch(self): + cover = _make_switch_cover() + await cover._send_open() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_with_stop_switch(self): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = _make_switch_cover(stop_switch="switch.stop") + await cover._send_open() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + +# --------------------------------------------------------------------------- +# _send_close +# --------------------------------------------------------------------------- + + +class TestSwitchModeSendClose: + @pytest.mark.asyncio + async def test_close_without_stop_switch(self): + cover = _make_switch_cover() + await cover._send_close() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_with_stop_switch(self): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = _make_switch_cover(stop_switch="switch.stop") + await cover._send_close() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + +# --------------------------------------------------------------------------- +# _send_stop +# --------------------------------------------------------------------------- + + +class TestSwitchModeSendStop: + @pytest.mark.asyncio + async def test_stop_without_stop_switch(self): + cover = _make_switch_cover() + await cover._send_stop() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_with_stop_switch(self): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = _make_switch_cover(stop_switch="switch.stop") + await cover._send_stop() + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] diff --git a/tests/test_cover_toggle_mode.py b/tests/test_cover_toggle_mode.py new file mode 100644 index 0000000..4c94eb8 --- /dev/null +++ b/tests/test_cover_toggle_mode.py @@ -0,0 +1,1011 @@ +"""Tests for ToggleModeCover. + +Tests cover: +- _send_open / _send_close / _send_stop relay patterns +- Toggle-specific behaviour: close-while-closing, open-while-opening -> stop +- Stop guard: idle cover should not send relay commands +- Direction change: closing while opening stops first + +Pulse completion (sleep + turn_off) now runs in background tasks. Tests +await those tasks to verify the full call sequence. +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) + +from custom_components.cover_time_based.cover_toggle_mode import ToggleModeCover + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def _make_toggle_cover( + open_switch="switch.open", + close_switch="switch.close", + stop_switch=None, + pulse_time=1.0, + tilt_open_switch=None, + tilt_close_switch=None, + tilt_stop_switch=None, +): + """Create a ToggleModeCover wired to a mock hass.""" + cover = ToggleModeCover( + device_id="test_toggle", + name="Test Toggle", + tilt_strategy=None, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=None, + tilt_time_open=None, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id=open_switch, + close_switch_entity_id=close_switch, + stop_switch_entity_id=stop_switch, + pulse_time=pulse_time, + tilt_open_switch=tilt_open_switch, + tilt_close_switch=tilt_close_switch, + tilt_stop_switch=tilt_stop_switch, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + return cover + + +async def _drain_tasks(cover): + """Await all background tasks created during a send call.""" + for task in cover._test_tasks: + await task + cover._test_tasks.clear() + + +async def _cancel_tasks(cover): + """Cancel all pending background tasks (for tests that don't drain).""" + for task in cover._test_tasks: + if not task.done(): + task.cancel() + if cover._test_tasks: + await asyncio.gather(*cover._test_tasks, return_exceptions=True) + cover._test_tasks.clear() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _calls(mock: AsyncMock): + return mock.call_args_list + + +def _ha(service, entity_id): + return call("homeassistant", service, {"entity_id": entity_id}, False) + + +# =================================================================== +# _send_open +# =================================================================== + + +class TestToggleModeSendOpen: + @pytest.mark.asyncio + async def test_open_pulses_open_switch(self): + cover = _make_toggle_cover() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_with_stop_switch(self): + """Stop switch is not valid in toggle mode; it must be ignored.""" + cover = _make_toggle_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + +# =================================================================== +# _send_close +# =================================================================== + + +class TestToggleModeSendClose: + @pytest.mark.asyncio + async def test_close_pulses_close_switch(self): + cover = _make_toggle_cover() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_with_stop_switch(self): + """Stop switch is not valid in toggle mode; it must be ignored.""" + cover = _make_toggle_cover(stop_switch="switch.stop") + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + +# =================================================================== +# _send_stop +# =================================================================== + + +class TestToggleModeSendStop: + @pytest.mark.asyncio + async def test_stop_after_close_pulses_close_switch(self): + cover = _make_toggle_cover() + cover._last_command = SERVICE_CLOSE_COVER + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_stop_after_open_pulses_open_switch(self): + cover = _make_toggle_cover() + cover._last_command = SERVICE_OPEN_COVER + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_with_no_last_command_does_nothing(self): + cover = _make_toggle_cover() + cover._last_command = None + await cover._send_stop() + + assert _calls(cover.hass.services.async_call) == [] + + +# =================================================================== +# Toggle-specific: close-while-closing stops, open-while-opening stops +# =================================================================== + + +class TestToggleCloseWhileClosing: + @pytest.mark.asyncio + async def test_close_while_closing_reissues(self): + cover = _make_toggle_cover() + + # Simulate currently closing (position 100 = fully open) + cover.travel_calc.set_position(100) + cover.travel_calc.start_travel_down() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Same-direction re-issues close (base class behavior, no special stop) + assert cover._last_command == SERVICE_CLOSE_COVER + await _cancel_tasks(cover) + + +class TestToggleOpenWhileOpening: + @pytest.mark.asyncio + async def test_open_while_opening_reissues(self): + cover = _make_toggle_cover() + + # Simulate currently opening (position 0 = fully closed) + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel_up() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Same-direction re-issues open (base class behavior, no special stop) + assert cover._last_command == SERVICE_OPEN_COVER + await _cancel_tasks(cover) + + +# =================================================================== +# Toggle stop guard: idle cover should NOT send relay commands +# =================================================================== + + +class TestToggleStopGuard: + @pytest.mark.asyncio + async def test_stop_when_idle_no_relay_command(self): + cover = _make_toggle_cover() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + # Idle cover: no relay command + cover.hass.services.async_call.assert_not_awaited() + + +# =================================================================== +# Direction change: closing while opening stops first +# =================================================================== + + +class TestToggleDirectionChange: + @pytest.mark.asyncio + async def test_close_while_opening_stops_first(self): + cover = _make_toggle_cover() + + # Simulate currently opening (position 0 = fully closed) + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "async_stop_cover", new_callable=AsyncMock + ) as mock_stop, + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover.async_close_cover() + + mock_stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_open_while_closing_stops_first(self): + cover = _make_toggle_cover() + + # Simulate currently closing (position 100 = fully open) + cover.travel_calc.set_position(100) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "async_stop_cover", new_callable=AsyncMock + ) as mock_stop, + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover.async_open_cover() + + mock_stop.assert_awaited_once() + await _cancel_tasks(cover) + + +# =================================================================== +# Stop with tilt: snap_trackers_to_physical +# =================================================================== + + +class TestToggleStopWithTilt: + @pytest.mark.asyncio + async def test_stop_with_tilt_snaps_trackers(self): + """Stopping toggle cover with tilt calls snap_trackers_to_physical.""" + tilt_strategy = MagicMock() + tilt_strategy.snap_trackers_to_physical = MagicMock() + tilt_strategy.uses_tilt_motor = False + tilt_strategy.restores_tilt = False + + cover = ToggleModeCover( + device_id="test_toggle_tilt", + name="Test Toggle Tilt", + tilt_strategy=tilt_strategy, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=5.0, + tilt_time_open=5.0, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id="switch.open", + close_switch_entity_id="switch.close", + stop_switch_entity_id=None, + pulse_time=1.0, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + tilt_strategy.snap_trackers_to_physical.assert_called_once_with( + cover.travel_calc, cover.tilt_calc + ) + await _cancel_tasks(cover) + + +# =================================================================== +# _send_tilt_open / _send_tilt_close / _send_tilt_stop +# =================================================================== + + +class TestToggleModeSendTiltOpen: + @pytest.mark.asyncio + async def test_tilt_open_pulses_tilt_open_switch(self): + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_open() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_close"), + _ha("turn_on", "switch.tilt_open"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_open"), + ] + assert cover._last_tilt_direction == "open" + + +class TestToggleModeSendTiltClose: + @pytest.mark.asyncio + async def test_tilt_close_pulses_tilt_close_switch(self): + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_close() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.tilt_open"), + _ha("turn_on", "switch.tilt_close"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_close"), + ] + assert cover._last_tilt_direction == "close" + + +class TestToggleModeSendTiltStop: + @pytest.mark.asyncio + async def test_tilt_stop_after_open_pulses_tilt_open_switch(self): + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + cover._last_tilt_direction = "open" + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.tilt_open"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_open"), + ] + assert cover._last_tilt_direction is None + + @pytest.mark.asyncio + async def test_tilt_stop_after_close_pulses_tilt_close_switch(self): + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + cover._last_tilt_direction = "close" + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.tilt_close"), + # pulse completion (background) + _ha("turn_off", "switch.tilt_close"), + ] + assert cover._last_tilt_direction is None + + @pytest.mark.asyncio + async def test_tilt_stop_no_last_direction_does_nothing(self): + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + cover._last_tilt_direction = None + await cover._send_tilt_stop() + + assert _calls(cover.hass.services.async_call) == [] + assert cover._last_tilt_direction is None + + +# =================================================================== +# _async_handle_command sets _last_command (calibration pattern) +# =================================================================== + + +class TestToggleHandleCommandSetsLastCommand: + """Verify _async_handle_command sets _last_command for open/close. + + This is critical for toggle mode where _send_stop needs _last_command + to know which direction button to re-pulse. The calibration code calls + _async_handle_command directly (not the public async_open/close_cover), + so _async_handle_command must set _last_command itself. + """ + + @pytest.mark.asyncio + async def test_handle_open_sets_last_command(self): + cover = _make_toggle_cover() + assert cover._last_command is None + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert cover._last_command == SERVICE_OPEN_COVER + await _cancel_tasks(cover) + + @pytest.mark.asyncio + async def test_handle_close_sets_last_command(self): + cover = _make_toggle_cover() + assert cover._last_command is None + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert cover._last_command == SERVICE_CLOSE_COVER + await _cancel_tasks(cover) + + @pytest.mark.asyncio + async def test_calibration_pattern_open_then_stop(self): + """Simulate calibration: _async_handle_command(OPEN) then _send_stop(). + + In toggle mode, _send_stop re-pulses the last direction button. + This must work when only _async_handle_command was called (not + async_open_cover), as the calibration code does. + """ + cover = _make_toggle_cover() + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + cover.hass.services.async_call.reset_mock() + + await cover._send_stop() + await _drain_tasks(cover) + + # _send_stop should re-pulse the open switch to toggle the motor off + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_calibration_pattern_close_then_stop(self): + """Same pattern for close direction.""" + cover = _make_toggle_cover() + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + cover.hass.services.async_call.reset_mock() + + await cover._send_stop() + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + +# =================================================================== +# _raw_direction_command: stop-before-reverse for calibration buttons +# =================================================================== + + +class TestToggleRawDirectionCommand: + """Test _raw_direction_command override in toggle mode. + + In toggle mode, opposite-direction = stop (not reverse). The override + must send stop + wait pulse_time before sending the new direction. + """ + + @pytest.mark.asyncio + async def test_open_sets_last_command(self): + cover = _make_toggle_cover() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._raw_direction_command("open") + await _drain_tasks(cover) + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_close_sets_last_command(self): + cover = _make_toggle_cover() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._raw_direction_command("close") + await _drain_tasks(cover) + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_stop_clears_last_command(self): + cover = _make_toggle_cover() + cover._last_command = SERVICE_OPEN_COVER + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._raw_direction_command("stop") + await _drain_tasks(cover) + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_direction_change_sends_stop_first(self): + """Close while _last_command=OPEN → stop pulse, wait, then close.""" + cover = _make_toggle_cover() + cover._last_command = SERVICE_OPEN_COVER + + sleep_mock = AsyncMock() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + sleep_mock, + ): + await cover._raw_direction_command("close") + await _drain_tasks(cover) + + # Sleep called with pulse_time (includes stop-before-reverse + pulse completions) + sleep_mock.assert_any_await(cover._pulse_time) + + calls = _calls(cover.hass.services.async_call) + # First: stop pulse on open switch (re-pulse same direction to stop) + assert calls[0] == _ha("turn_on", "switch.open") + # Then: close switch turned on (new direction) + assert _ha("turn_on", "switch.close") in calls + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_reverse_direction_open_while_closing(self): + """Open while _last_command=CLOSE → stop pulse, wait, then open.""" + cover = _make_toggle_cover() + cover._last_command = SERVICE_CLOSE_COVER + + sleep_mock = AsyncMock() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + sleep_mock, + ): + await cover._raw_direction_command("open") + await _drain_tasks(cover) + + sleep_mock.assert_any_await(cover._pulse_time) + + calls = _calls(cover.hass.services.async_call) + # First: stop pulse on close switch + assert calls[0] == _ha("turn_on", "switch.close") + # New direction: open switch turned on + assert _ha("turn_on", "switch.open") in calls + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_same_direction_no_extra_stop(self): + """Open while _last_command=OPEN → no stop-before-reverse needed.""" + cover = _make_toggle_cover() + cover._last_command = SERVICE_OPEN_COVER + + sleep_mock = AsyncMock() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + sleep_mock, + ): + await cover._raw_direction_command("open") + await _drain_tasks(cover) + + calls = _calls(cover.hass.services.async_call) + # First relay call should be _send_open (not a stop pulse) + # _send_open turns off close switch first, then turns on open switch + assert calls[0] == _ha("turn_off", "switch.close") + + @pytest.mark.asyncio + async def test_no_last_command_no_stop(self): + """Open with _last_command=None → no stop needed.""" + cover = _make_toggle_cover() + assert cover._last_command is None + + sleep_mock = AsyncMock() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + sleep_mock, + ): + await cover._raw_direction_command("open") + await _drain_tasks(cover) + + calls = _calls(cover.hass.services.async_call) + # First relay call is _send_open (turn_off close, then turn_on open) + assert calls[0] == _ha("turn_off", "switch.close") + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_tilt_direction_change_sends_tilt_stop_first(self): + """tilt_close while _last_tilt_direction=open → tilt stop, wait, then tilt close.""" + cover = _make_toggle_cover( + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + cover._last_tilt_direction = "open" + + sleep_mock = AsyncMock() + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + sleep_mock, + ): + await cover._raw_direction_command("tilt_close") + await _drain_tasks(cover) + + # Sleep called for tilt stop-before-reverse + pulse completions + sleep_mock.assert_any_await(cover._pulse_time) + + calls = _calls(cover.hass.services.async_call) + # First: tilt stop pulse on tilt_open switch + assert calls[0] == _ha("turn_on", "switch.tilt_open") + # New direction: tilt_close switch turned on + assert _ha("turn_on", "switch.tilt_close") in calls + + assert cover._last_tilt_direction == "close" + + +# =================================================================== +# Tilt restore: shared motor stop uses _last_command +# =================================================================== + + +class TestToggleTiltRestoreStop: + """Verify tilt restore completion sends a stop pulse in toggle mode. + + For inline tilt with a shared motor, _start_tilt_restore dispatches + _async_handle_command(OPEN/CLOSE) which sets _last_command. When the + restore finishes, auto_stop_if_necessary calls _send_stop which needs + _last_command to know which relay to re-pulse. + """ + + @pytest.mark.asyncio + async def test_tilt_restore_stop_sends_pulse(self): + """After tilt restore via shared motor, stop must re-pulse the relay.""" + tilt_strategy = MagicMock() + tilt_strategy.uses_tilt_motor = False + tilt_strategy.restores_tilt = True + tilt_strategy.snap_trackers_to_physical = MagicMock() + + cover = ToggleModeCover( + device_id="test_toggle_restore", + name="Test Toggle Restore", + tilt_strategy=tilt_strategy, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=2.0, + tilt_time_open=2.0, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id="switch.open", + close_switch_entity_id="switch.close", + stop_switch_entity_id=None, + pulse_time=1.0, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + + # Simulate: tilt restore in progress, motor going UP (open) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(30) + cover.tilt_calc.start_travel(50) + cover._tilt_restore_active = True + cover._last_command = SERVICE_OPEN_COVER + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + # Tilt restore reaches target + cover.tilt_calc.set_position(50) + await cover.auto_stop_if_necessary() + await _drain_tasks(cover) + + assert cover._tilt_restore_active is False + + # _send_stop should have re-pulsed the open switch + calls = _calls(cover.hass.services.async_call) + assert _ha("turn_on", "switch.open") in calls + await _cancel_tasks(cover) + + +# =================================================================== +# Stop with tilt restore active + dual motor → _send_tilt_stop called +# =================================================================== + + +class TestToggleStopSendsTiltStopOnTiltRestore: + """Verify async_stop_cover calls _send_tilt_stop when tilt_restore was active + and cover has a dual motor tilt (i.e. _has_tilt_motor() returns True). + + Covers cover_toggle_mode.py line 109. + """ + + @pytest.mark.asyncio + async def test_stop_with_tilt_restore_calls_send_tilt_stop(self): + """When tilt restore was active and dual motor tilt is configured, + async_stop_cover must call both _send_stop and _send_tilt_stop.""" + tilt_strategy = MagicMock() + tilt_strategy.uses_tilt_motor = True + tilt_strategy.restores_tilt = True + tilt_strategy.snap_trackers_to_physical = MagicMock() + + cover = ToggleModeCover( + device_id="test_toggle_tilt_stop", + name="Test Toggle Tilt Stop", + tilt_strategy=tilt_strategy, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=5.0, + tilt_time_open=5.0, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id="switch.open", + close_switch_entity_id="switch.close", + stop_switch_entity_id=None, + pulse_time=1.0, + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + hass.states.get = MagicMock(return_value=None) + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + + # Set up: cover traveling + tilt restore active + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.travel_calc.start_travel(100) + cover._tilt_restore_active = True + cover._last_command = SERVICE_OPEN_COVER + cover._last_tilt_direction = "open" + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover.async_stop_cover() + await _drain_tasks(cover) + + # Verify _send_stop re-pulsed the open switch (main motor stop) + calls = _calls(cover.hass.services.async_call) + assert _ha("turn_on", "switch.open") in calls + + # Verify _send_tilt_stop also pulsed the tilt open switch + assert _ha("turn_on", "switch.tilt_open") in calls + await _cancel_tasks(cover) + + +# =================================================================== +# External tilt close toggle while tilt is traveling → stops cover +# =================================================================== + + +class TestToggleExternalTiltCloseWhileTraveling: + """Verify _handle_external_tilt_state_change with the tilt close switch + while tilt_calc is traveling triggers async_stop_cover. + + Covers cover_toggle_mode.py lines 177-180. + """ + + @pytest.mark.asyncio + async def test_external_tilt_close_while_traveling_stops(self): + """When tilt is traveling and an external tilt close toggle fires, + the cover should stop.""" + tilt_strategy = MagicMock() + tilt_strategy.uses_tilt_motor = True + tilt_strategy.restores_tilt = True + tilt_strategy.snap_trackers_to_physical = MagicMock() + + cover = ToggleModeCover( + device_id="test_toggle_ext_tilt", + name="Test Toggle Ext Tilt", + tilt_strategy=tilt_strategy, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=5.0, + tilt_time_open=5.0, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + open_switch_entity_id="switch.open", + close_switch_entity_id="switch.close", + stop_switch_entity_id=None, + pulse_time=1.0, + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + hass.states.get = MagicMock(return_value=None) + created_tasks = [] + + def create_task(coro): + task = asyncio.ensure_future(coro) + created_tasks.append(task) + return task + + hass.async_create_task = create_task + cover.hass = hass + cover._test_tasks = created_tasks + + # Set up: tilt is traveling (tilting closed) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel(0) + assert cover.tilt_calc.is_traveling() + + # Simulate external trigger for tilt close switch + cover._triggered_externally = True + try: + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_tilt_state_change( + "switch.tilt_close", "off", "on" + ) + finally: + cover._triggered_externally = False + + # Tilt should have stopped traveling + assert not cover.tilt_calc.is_traveling() + await _cancel_tasks(cover) diff --git a/tests/test_cover_wrapped.py b/tests/test_cover_wrapped.py new file mode 100644 index 0000000..c7a34a5 --- /dev/null +++ b/tests/test_cover_wrapped.py @@ -0,0 +1,166 @@ +"""Tests for WrappedCoverTimeBased._send_open/close/stop. + +Each test verifies that the correct cover.* service call is made. +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from custom_components.cover_time_based.cover_wrapped import WrappedCoverTimeBased + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def _make_wrapped_cover(cover_entity_id="cover.inner"): + """Create a WrappedCoverTimeBased wired to a mock hass.""" + cover = WrappedCoverTimeBased( + device_id="test_wrapped", + name="Test Wrapped", + tilt_strategy=None, + travel_time_close=30, + travel_time_open=30, + tilt_time_close=None, + tilt_time_open=None, + travel_startup_delay=None, + tilt_startup_delay=None, + endpoint_runon_time=None, + min_movement_time=None, + cover_entity_id=cover_entity_id, + ) + hass = MagicMock() + hass.services.async_call = AsyncMock() + hass.async_create_task = lambda coro: asyncio.ensure_future(coro) + cover.hass = hass + return cover + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _calls(mock: AsyncMock): + """Return the list of calls made on hass.services.async_call.""" + return mock.call_args_list + + +def _cover_svc(service, entity_id): + """Shorthand for a cover domain service call.""" + return call("cover", service, {"entity_id": entity_id}, False) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestWrappedSendOpen: + """_send_open delegates to cover.open_cover.""" + + @pytest.mark.asyncio + async def test_send_open(self): + cover = _make_wrapped_cover() + await cover._send_open() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("open_cover", "cover.inner"), + ] + + +class TestWrappedSendClose: + """_send_close delegates to cover.close_cover.""" + + @pytest.mark.asyncio + async def test_send_close(self): + cover = _make_wrapped_cover() + await cover._send_close() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("close_cover", "cover.inner"), + ] + + +class TestWrappedSendStop: + """_send_stop delegates to cover.stop_cover.""" + + @pytest.mark.asyncio + async def test_send_stop(self): + cover = _make_wrapped_cover() + await cover._send_stop() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("stop_cover", "cover.inner"), + ] + + +class TestWrappedViaHandleCommand: + """Integration test: _async_handle_command routes through _send_* correctly.""" + + @pytest.mark.asyncio + async def test_handle_command_open(self): + from homeassistant.const import SERVICE_OPEN_COVER + + cover = _make_wrapped_cover() + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("open_cover", "cover.inner"), + ] + + @pytest.mark.asyncio + async def test_handle_command_close(self): + from homeassistant.const import SERVICE_CLOSE_COVER + + cover = _make_wrapped_cover() + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("close_cover", "cover.inner"), + ] + + @pytest.mark.asyncio + async def test_handle_command_stop(self): + from homeassistant.const import SERVICE_STOP_COVER + + cover = _make_wrapped_cover() + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("stop_cover", "cover.inner"), + ] + + +# --------------------------------------------------------------------------- +# async_added_to_hass — state listener registration +# --------------------------------------------------------------------------- + + +class TestWrappedAsyncAddedToHass: + """Test that async_added_to_hass registers a state listener.""" + + @pytest.mark.asyncio + async def test_registers_cover_listener(self): + cover = _make_wrapped_cover(cover_entity_id="cover.inner") + unsub = MagicMock() + + with ( + patch.object(cover, "async_get_last_state", return_value=None), + patch( + "custom_components.cover_time_based.cover_wrapped.async_track_state_change_event", + return_value=unsub, + ) as mock_track, + ): + await cover.async_added_to_hass() + + mock_track.assert_called_once() + # Verify the entity list includes the wrapped cover + assert mock_track.call_args[0][1] == ["cover.inner"] + assert unsub in cover._state_listener_unsubs diff --git a/tests/test_coverage_extras.py b/tests/test_coverage_extras.py new file mode 100644 index 0000000..f385ed3 --- /dev/null +++ b/tests/test_coverage_extras.py @@ -0,0 +1,1273 @@ +"""Tests targeting specific uncovered lines in planning.py and cover_base.py. + +Covers: +- planning.py lines 17, 32, 55, 62, 65 +- cover_base.py lines 302, 832, 1199, 180, 991-999 +""" + +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.exceptions import HomeAssistantError + +from custom_components.cover_time_based.tilt_strategies.base import TiltTo, TravelTo +from custom_components.cover_time_based.tilt_strategies.planning import ( + calculate_pre_step_delay, + extract_coupled_tilt, + extract_coupled_travel, +) +from custom_components.cover_time_based.travel_calculator import TravelCalculator + + +# =================================================================== +# planning.py: extract_coupled_tilt +# =================================================================== + + +class TestExtractCoupledTilt: + """Test extract_coupled_tilt from planning.py.""" + + def test_returns_coupled_tilt_from_travel_to_step(self): + """Line 17: TravelTo with coupled_tilt returns that value.""" + steps = [TravelTo(target=50, coupled_tilt=30)] + result = extract_coupled_tilt(steps) + assert result == 30 + + def test_returns_tilt_to_target_when_no_coupling(self): + """TiltTo step returns its target.""" + steps = [TiltTo(target=75)] + result = extract_coupled_tilt(steps) + assert result == 75 + + def test_coupled_tilt_takes_precedence_over_later_tilt_to(self): + """TravelTo with coupled_tilt found before TiltTo is returned.""" + steps = [TravelTo(target=50, coupled_tilt=30), TiltTo(target=75)] + result = extract_coupled_tilt(steps) + assert result == 30 + + def test_returns_none_for_travel_to_without_coupling(self): + """TravelTo without coupled_tilt, no TiltTo => returns TravelTo target.""" + steps = [TravelTo(target=50)] + result = extract_coupled_tilt(steps) + # TravelTo without coupled_tilt is not None check fails, then no TiltTo => None + assert result is None + + def test_returns_none_for_empty_steps(self): + """Empty step list returns None.""" + steps = [] + result = extract_coupled_tilt(steps) + assert result is None + + +# =================================================================== +# planning.py: extract_coupled_travel +# =================================================================== + + +class TestExtractCoupledTravel: + """Test extract_coupled_travel from planning.py.""" + + def test_returns_coupled_travel_from_tilt_to_step(self): + """Line 32: TiltTo with coupled_travel returns that value.""" + steps = [TiltTo(target=50, coupled_travel=20)] + result = extract_coupled_travel(steps) + assert result == 20 + + def test_returns_travel_to_target_when_no_coupling(self): + """TravelTo step returns its target.""" + steps = [TravelTo(target=80)] + result = extract_coupled_travel(steps) + assert result == 80 + + def test_coupled_travel_takes_precedence_over_later_travel_to(self): + """TiltTo with coupled_travel found before TravelTo is returned.""" + steps = [TiltTo(target=50, coupled_travel=20), TravelTo(target=80)] + result = extract_coupled_travel(steps) + assert result == 20 + + def test_returns_none_for_tilt_to_without_coupling(self): + """TiltTo without coupled_travel, no TravelTo => None.""" + steps = [TiltTo(target=50)] + result = extract_coupled_travel(steps) + # TiltTo without coupled_travel is not None check fails, then no TravelTo => None + assert result is None + + def test_returns_none_for_empty_steps(self): + """Empty step list returns None.""" + steps = [] + result = extract_coupled_travel(steps) + assert result is None + + +# =================================================================== +# planning.py: calculate_pre_step_delay +# =================================================================== + + +class TestCalculatePreStepDelay: + """Test calculate_pre_step_delay from planning.py.""" + + def test_returns_zero_when_strategy_is_none(self): + """Strategy is None => 0.0.""" + steps = [TiltTo(50), TravelTo(30)] + result = calculate_pre_step_delay(steps, None, None, None) + assert result == 0.0 + + def test_returns_zero_when_strategy_uses_tilt_motor(self): + """Strategy uses tilt motor => 0.0.""" + strategy = MagicMock() + strategy.uses_tilt_motor = True + steps = [TiltTo(50), TravelTo(30)] + result = calculate_pre_step_delay(steps, strategy, None, None) + assert result == 0.0 + + def test_returns_zero_when_fewer_than_two_steps(self): + """Single step => 0.0.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + steps = [TravelTo(30)] + result = calculate_pre_step_delay(steps, strategy, None, None) + assert result == 0.0 + + def test_tilt_before_travel_with_none_current_tilt(self): + """Line 55: TiltTo then TravelTo, current_tilt is None => 0.0.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + tilt_calc = MagicMock() + tilt_calc.current_position.return_value = None + travel_calc = MagicMock() + + steps = [TiltTo(50), TravelTo(30)] + result = calculate_pre_step_delay(steps, strategy, tilt_calc, travel_calc) + assert result == 0.0 + + def test_travel_before_tilt_with_none_current_pos(self): + """Line 62: TravelTo then TiltTo, current_pos is None => 0.0.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + tilt_calc = MagicMock() + travel_calc = MagicMock() + travel_calc.current_position.return_value = None + + steps = [TravelTo(30), TiltTo(50)] + result = calculate_pre_step_delay(steps, strategy, tilt_calc, travel_calc) + assert result == 0.0 + + def test_default_return_for_unexpected_step_order(self): + """Line 65: Two steps of same type => default 0.0.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + + # Two TiltTo steps -- neither branch matches + steps = [TiltTo(50), TiltTo(30)] + result = calculate_pre_step_delay(steps, strategy, MagicMock(), MagicMock()) + assert result == 0.0 + + # Two TravelTo steps -- neither branch matches + steps = [TravelTo(50), TravelTo(30)] + result = calculate_pre_step_delay(steps, strategy, MagicMock(), MagicMock()) + assert result == 0.0 + + def test_tilt_before_travel_calculates_delay(self): + """TiltTo then TravelTo with valid positions returns calculated delay.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + + # Use real TravelCalculator for tilt_calc + tilt_calc = TravelCalculator(2.0, 2.0) + tilt_calc.set_position(0) + travel_calc = MagicMock() + + steps = [TiltTo(50), TravelTo(30)] + result = calculate_pre_step_delay(steps, strategy, tilt_calc, travel_calc) + # Should be tilt_calc.calculate_travel_time(0, 50) = 2.0 * 50 / 100 = 1.0 + assert result == pytest.approx(1.0) + + def test_travel_before_tilt_calculates_delay(self): + """TravelTo then TiltTo with valid positions returns calculated delay.""" + strategy = MagicMock() + strategy.uses_tilt_motor = False + + tilt_calc = MagicMock() + # Use real TravelCalculator for travel_calc + travel_calc = TravelCalculator(10.0, 10.0) + travel_calc.set_position(0) + + steps = [TravelTo(50), TiltTo(30)] + result = calculate_pre_step_delay(steps, strategy, tilt_calc, travel_calc) + # Should be travel_calc.calculate_travel_time(0, 50) = 10.0 * 50 / 100 = 5.0 + assert result == pytest.approx(5.0) + + +# =================================================================== +# cover_base.py line 302: calibration_step in extra_state_attributes +# =================================================================== + + +class TestCalibrationStepAttribute: + """Test extra_state_attributes shows calibration_step when step_count > 0.""" + + @pytest.mark.asyncio + async def test_calibration_step_shown_when_step_count_positive(self, make_cover): + """Line 302: calibration_step appears when step_count > 0.""" + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + + # Set step_count > 0 to trigger line 302 + cover._calibration.step_count = 3 + + attrs = cover.extra_state_attributes + assert attrs["calibration_active"] is True + assert attrs["calibration_attribute"] == "travel_time_close" + assert attrs["calibration_step"] == 3 + + @pytest.mark.asyncio + async def test_calibration_step_not_shown_when_step_count_zero(self, make_cover): + """calibration_step should NOT appear when step_count == 0.""" + cover = make_cover() + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration(attribute="travel_time_close", timeout=120.0) + + assert cover._calibration.step_count == 0 + + attrs = cover.extra_state_attributes + assert attrs["calibration_active"] is True + assert "calibration_step" not in attrs + + +# =================================================================== +# cover_base.py line 832: _require_travel_time raises for closing +# =================================================================== + + +class TestRequireTravelTimeRaisesClosing: + """Test _require_travel_time raises HomeAssistantError when closing time is None.""" + + def test_raises_when_travel_time_close_is_none(self, make_cover): + """Line 832: calling _require_travel_time(closing=True) with _travel_time_close=None.""" + cover = make_cover() + cover._travel_time_close = None + + with pytest.raises(HomeAssistantError, match="[Tt]ravel time"): + cover._require_travel_time(closing=True) + + def test_raises_when_travel_time_open_is_none(self, make_cover): + """Calling _require_travel_time(closing=False) with _travel_time_open=None.""" + cover = make_cover() + cover._travel_time_open = None + + with pytest.raises(HomeAssistantError, match="[Tt]ravel time"): + cover._require_travel_time(closing=False) + + def test_returns_value_when_configured(self, make_cover): + """Returns the travel time when properly configured.""" + cover = make_cover() + assert cover._require_travel_time(closing=True) == 30 + assert cover._require_travel_time(closing=False) == 30 + + +# =================================================================== +# cover_base.py line 1199: _start_tilt_restore early return +# =================================================================== + + +class TestStartTiltRestoreEarlyReturn: + """Test _start_tilt_restore returns early when tilt matches restore target.""" + + @pytest.mark.asyncio + async def test_returns_early_when_tilt_equals_restore_target(self, make_cover): + """Line 1202: current_tilt == restore_target => early return with stop.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(75) + + # Set the restore target to match current tilt + cover._tilt_restore_target = 75 + + with patch.object(cover, "async_write_ha_state"): + await cover._start_tilt_restore() + + # Should have sent stop command and cleared last_command + assert cover._last_command is None + # Tilt restore should NOT be active (early return path) + assert cover._tilt_restore_active is False + + @pytest.mark.asyncio + async def test_returns_early_when_current_tilt_is_none(self, make_cover): + """Line 1202: current_tilt is None => early return with stop.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + # tilt_calc position is None (never set) + + cover._tilt_restore_target = 50 + + with patch.object(cover, "async_write_ha_state"): + await cover._start_tilt_restore() + + assert cover._last_command is None + assert cover._tilt_restore_active is False + + @pytest.mark.asyncio + async def test_returns_early_when_restore_target_is_none(self, make_cover): + """Line 1198-1199: restore_target is None => immediate return.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + cover._tilt_restore_target = None + + with patch.object(cover, "async_write_ha_state"): + await cover._start_tilt_restore() + + # Nothing should have happened + assert cover._tilt_restore_active is False + + +# =================================================================== +# cover_base.py line 180: cancel calibration automation_task on removal +# =================================================================== + + +class TestRemovalCancelsCalibrationAutomationTask: + """Test async_will_remove_from_hass cancels calibration automation_task.""" + + @pytest.mark.asyncio + async def test_cancels_running_automation_task(self, make_cover): + """Line 180: automation_task.cancel() when task is running.""" + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + + # Verify automation_task was created (overhead calibration creates one) + assert cover._calibration is not None + assert cover._calibration.automation_task is not None + automation_task = cover._calibration.automation_task + assert not automation_task.done() + + # Also verify timeout_task exists + timeout_task = cover._calibration.timeout_task + + await cover.async_will_remove_from_hass() + await asyncio.sleep(0) # Let event loop process cancellation + + assert cover._calibration is None + assert automation_task.cancelled() or automation_task.done() + assert timeout_task.cancelled() or timeout_task.done() + + @pytest.mark.asyncio + async def test_handles_already_done_automation_task(self, make_cover): + """No crash when automation_task is already done at removal time.""" + cover = make_cover(travel_time_close=60.0, travel_time_open=60.0) + + with patch.object(cover, "async_write_ha_state"): + await cover.start_calibration( + attribute="travel_startup_delay", timeout=300.0 + ) + + # Cancel automation task manually before removal + cover._calibration.automation_task.cancel() + await asyncio.sleep(0) + + # Removal should not crash even though automation_task is already done + await cover.async_will_remove_from_hass() + assert cover._calibration is None + + +# =================================================================== +# cover_base.py lines 991-999: external movement auto-stop skips relay +# =================================================================== + + +class TestExternalMovementAutoStop: + """Test auto_stop_if_necessary skips relay stop for external movements.""" + + @pytest.mark.asyncio + async def test_external_movement_skips_relay_stop(self, make_cover): + """Lines 991-999: _self_initiated_movement=False => skip relay stop.""" + cover = make_cover() + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel(0) # Already at target => position_reached + + cover._self_initiated_movement = False + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + with patch.object(cover, "_send_stop", new_callable=AsyncMock) as mock_stop: + await cover.auto_stop_if_necessary() + + # Relay stop should NOT have been called + mock_stop.assert_not_awaited() + # Last command should have been cleared + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_external_movement_with_tilt_snaps_trackers(self, make_cover): + """Lines 994-997: external movement with tilt strategy snaps trackers.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + # Both at target => position_reached + cover.travel_calc.start_travel(50) + cover.tilt_calc.start_travel(100) + + cover._self_initiated_movement = False + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover._tilt_strategy, + "snap_trackers_to_physical", + ) as mock_snap: + await cover.auto_stop_if_necessary() + + # snap_trackers_to_physical should have been called + mock_snap.assert_called_once_with(cover.travel_calc, cover.tilt_calc) + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_self_initiated_movement_sends_relay_stop(self, make_cover): + """Contrast: self-initiated movement DOES send relay stop.""" + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel(50) # Already at target + + cover._self_initiated_movement = True + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Relay stop SHOULD have been called (via _async_handle_command) + cover.hass.services.async_call.assert_awaited() + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_external_movement_stops_tilt_calc(self, make_cover): + """External movement with tilt also stops tilt_calc.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(0) + + # Both at target => position_reached + cover.travel_calc.start_travel(0) + cover.tilt_calc.start_travel(0) + + cover._self_initiated_movement = False + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover.auto_stop_if_necessary() + + # Both calculators should have been stopped + assert not cover.travel_calc.is_traveling() + assert not cover.tilt_calc.is_traveling() + assert cover._last_command is None + + +# =================================================================== +# cover_base.py line 488: _send_tilt_stop in _async_move_tilt_to_endpoint +# when startup delay active + direction change + dual motor +# =================================================================== + + +class TestMoveEndpointStartupDelayDualMotorTiltStop: + """Test _send_tilt_stop during direction change with startup delay (dual motor).""" + + @pytest.mark.asyncio + async def test_tilt_stop_sent_on_direction_change_with_startup_delay( + self, make_cover + ): + """Line 488: dual motor sends _send_tilt_stop on direction change with startup delay.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Create an active startup delay task (not done) + cover._startup_delay_task = asyncio.get_event_loop().create_future() + # Last command was OPEN, so closing (target=0) is a direction change + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_stop", new_callable=AsyncMock + ) as mock_tilt_stop: + await cover._async_move_tilt_to_endpoint(target=0) + + mock_tilt_stop.assert_awaited_once() + + +# =================================================================== +# cover_base.py line 499: _send_tilt_stop in _async_move_tilt_to_endpoint +# when relay_was_on + dual motor +# =================================================================== + + +class TestMoveEndpointRelayWasOnDualMotorTiltStop: + """Test _send_tilt_stop when relay_was_on in dual motor tilt endpoint move.""" + + @pytest.mark.asyncio + async def test_tilt_stop_sent_when_relay_was_on(self, make_cover): + """Line 499: dual motor sends _send_tilt_stop when relay was on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Create an active delay task (not done) so _cancel_delay_task returns True + cover._delay_task = asyncio.get_event_loop().create_future() + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_stop", new_callable=AsyncMock + ) as mock_tilt_stop: + await cover._async_move_tilt_to_endpoint(target=0) + + mock_tilt_stop.assert_awaited_once() + + +# =================================================================== +# cover_base.py line 594: early return in set_position when target == current +# after stopping travel on direction change +# =================================================================== + + +class TestSetPositionEarlyReturnAfterDirectionChange: + """Test early return in set_position when target == current after stopping.""" + + @pytest.mark.asyncio + async def test_returns_early_when_target_equals_current_after_stop( + self, make_cover + ): + """Line 594: target == current after stopping travel => early return.""" + from custom_components.cover_time_based.travel_calculator import ( + TravelCalculator, + ) + + cover = make_cover() + + # Set up cover traveling towards 100 with last_command=OPEN. + # Calling set_position(49) triggers command=CLOSE (direction change). + cover.travel_calc.set_position(51) + cover.travel_calc.start_travel(100) + cover._last_command = SERVICE_OPEN_COVER + + # Patch current_position at class level (TravelCalculator uses __slots__). + # In set_position the call sequence for travel_calc.current_position is: + # call 1 (line 557): 51 => target(49) < 51 => CLOSE (direction change) + # call 2 (line 585 is_traveling): 51 => 51 != 100 => True + # call 3 (line 587 stop): 51 + # call 4 (line 592): 49 => target(49) == 49 => early return + call_count = [0] + + def mock_current(self_tc): + call_count[0] += 1 + if call_count[0] <= 3: + return 51 + return 49 + + with patch.object(cover, "async_write_ha_state"): + with patch.object(TravelCalculator, "current_position", mock_current): + await cover.set_position(49) + + # Early return at line 594 means _last_command is NOT updated at line 606 + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# cover_base.py line 666: _send_tilt_stop in set_tilt_position +# when direction change + dual motor +# =================================================================== + + +class TestSetTiltPositionDirectionChangeDualMotor: + """Test _send_tilt_stop on direction change in set_tilt_position with dual motor.""" + + @pytest.mark.asyncio + async def test_tilt_stop_sent_on_direction_change(self, make_cover): + """Line 666: dual motor sends _send_tilt_stop on direction change in set_tilt_position.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Start a tilt movement (opening) + cover.tilt_calc.start_travel(100) + # Last command was OPEN + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_stop", new_callable=AsyncMock + ) as mock_tilt_stop: + # Set tilt to a position below current (closing) => direction change + await cover.set_tilt_position(20) + + mock_tilt_stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_returns_when_target_equals_current_after_direction_change( + self, make_cover + ): + """Line 669: target == current after stopping => early return.""" + from custom_components.cover_time_based.travel_calculator import ( + TravelCalculator, + ) + + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(51) + + # Start tilt traveling towards 100 + cover.tilt_calc.start_travel(100) + cover._last_command = SERVICE_OPEN_COVER + + # Patch current_position at class level (TravelCalculator uses __slots__). + # In set_tilt_position the call sequence is: + # call 1 (line 631, tilt_calc): 51 => target(49) < 51 => CLOSE + # call 2 (line 659 tilt_calc.is_traveling): 51 => 51 != 100 => True + # call 3 (line 660 tilt_calc.stop): 51 + # call 4 (line 661 travel_calc.is_traveling): 50 => 50 == 50 => False + # call 5 (line 667, tilt_calc): 49 => target(49) == 49 => return + call_count = [0] + + def mock_current(self_tc): + call_count[0] += 1 + if self_tc is cover.tilt_calc: + if call_count[0] <= 3: + return 51 + return 49 + # travel_calc + return 50 + + with patch.object(cover, "async_write_ha_state"): + with patch.object(TravelCalculator, "current_position", mock_current): + await cover.set_tilt_position(49) + + # Early return at line 669 means _last_command NOT updated at line 699 + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# cover_base.py line 675: _send_tilt_stop when relay_was_on +# in set_tilt_position + dual motor +# =================================================================== + + +class TestSetTiltPositionRelayWasOnDualMotor: + """Test _send_tilt_stop when relay was on in set_tilt_position with dual motor.""" + + @pytest.mark.asyncio + async def test_tilt_stop_sent_when_relay_was_on(self, make_cover): + """Line 675: dual motor sends _send_tilt_stop when relay was on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Create an active delay task so _cancel_delay_task returns True + cover._delay_task = asyncio.get_event_loop().create_future() + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_stop", new_callable=AsyncMock + ) as mock_tilt_stop: + await cover.set_tilt_position(80) + + mock_tilt_stop.assert_awaited_once() + + +# =================================================================== +# cover_base.py line 734: return when current_pos or current_tilt is None +# in _plan_tilt_for_travel +# =================================================================== + + +class TestPlanTiltForTravelNonePositions: + """Test _plan_tilt_for_travel returns early when positions are None.""" + + @pytest.mark.asyncio + async def test_returns_early_when_current_pos_is_none(self, make_cover): + """Line 734: current_pos is None => early return.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + # Don't set travel_calc position (it will be None) + cover.tilt_calc.set_position(50) + + result = await cover._plan_tilt_for_travel( + target=0, + command=SERVICE_CLOSE_COVER, + current_pos=None, + current_tilt=50, + ) + + tilt_target, pre_step_delay, started = result + assert tilt_target is None + assert pre_step_delay == 0.0 + assert started is False + + @pytest.mark.asyncio + async def test_returns_early_when_current_tilt_is_none(self, make_cover): + """Line 734: current_tilt is None => early return.""" + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + cover.travel_calc.set_position(50) + # Don't set tilt_calc position (it will be None) + + result = await cover._plan_tilt_for_travel( + target=0, + command=SERVICE_CLOSE_COVER, + current_pos=50, + current_tilt=None, + ) + + tilt_target, pre_step_delay, started = result + assert tilt_target is None + assert pre_step_delay == 0.0 + assert started is False + + +# =================================================================== +# cover_base.py line 761: _tilt_restore_target = target for dual motor +# endpoint when pre-step is skipped +# =================================================================== + + +class TestTiltRestoreTargetDualMotorEndpoint: + """Test _tilt_restore_target is set for dual motor endpoint moves.""" + + @pytest.mark.asyncio + async def test_tilt_restore_target_set_for_endpoint_move(self, make_cover): + """Line 761: dual motor moving to endpoint sets _tilt_restore_target = target.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + # Set tilt to the tilt_target value that plan_move_position will return + # so current_tilt == tilt_target (skip pre-step) but current_tilt != target (0) + # For dual motor going to endpoint 0, tilt should be at safe position already + # We need: tilt_target == current_tilt (skip pre-step), target in (0,100), current_tilt != target + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._plan_tilt_for_travel( + target=0, + command=SERVICE_CLOSE_COVER, + current_pos=50, + current_tilt=50, + ) + + # The tilt_restore_target should be set to the endpoint target + assert cover._tilt_restore_target == 0 + + +class TestNoTiltRestoreOutsideAllowedZone: + """Tilt should not restore when target position is outside allowed tilt zone.""" + + @pytest.mark.asyncio + async def test_no_restore_when_above_max_tilt_allowed(self, make_cover): + """Moving to position 50 with max_tilt_allowed=0 should not restore tilt.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + safe_tilt_position=100, + max_tilt_allowed_position=0, + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._plan_tilt_for_travel( + target=50, + command=SERVICE_OPEN_COVER, + current_pos=0, + current_tilt=50, + ) + + # Restore target should be safe position (100), not current tilt (50) + assert cover._tilt_restore_target == 100 + + @pytest.mark.asyncio + async def test_restore_when_within_allowed_zone(self, make_cover): + """Moving to position within allowed zone should restore tilt normally.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + safe_tilt_position=100, + max_tilt_allowed_position=50, + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._plan_tilt_for_travel( + target=30, + command=SERVICE_OPEN_COVER, + current_pos=0, + current_tilt=50, + ) + + # Restore target should be current tilt (50) since target is within allowed zone + assert cover._tilt_restore_target == 50 + + +# =================================================================== +# cover_base.py line 843: base _are_entities_configured returns True +# =================================================================== + + +class TestBaseAreEntitiesConfigured: + """Test the base _are_entities_configured method returns True.""" + + def test_base_returns_true(self, make_cover): + """Line 843: base class _are_entities_configured returns True.""" + # The switch mode cover overrides this, but we can test the base + # by calling through _get_missing_configuration which checks it + cover = make_cover() + # Access the base class method directly + from custom_components.cover_time_based.cover_base import CoverTimeBased + + result = CoverTimeBased._are_entities_configured(cover) + assert result is True + + +# =================================================================== +# cover_base.py line 1091: travel_calc.stop() during _abandon_active_lifecycle +# when travel calc is traveling + tilt pre-step/restore active +# =================================================================== + + +class TestAbandonActiveLifecycleTravelStop: + """Test travel_calc.stop() in _abandon_active_lifecycle.""" + + @pytest.mark.asyncio + async def test_travel_calc_stopped_during_pre_step_abandon(self, make_cover): + """Line 1091: travel_calc.stop() called when abandoning pre-step with active travel.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Simulate active pre-step: travel is traveling + pending_travel_target set + cover.travel_calc.start_travel(100) + cover._pending_travel_target = 100 + cover._pending_travel_command = SERVICE_OPEN_COVER + + assert cover.travel_calc.is_traveling() + + with patch.object(cover, "async_write_ha_state"): + await cover._abandon_active_lifecycle() + + # travel_calc should have been stopped + assert not cover.travel_calc.is_traveling() + assert cover._pending_travel_target is None + + @pytest.mark.asyncio + async def test_travel_calc_stopped_during_restore_abandon(self, make_cover): + """Line 1091: travel_calc.stop() called when abandoning restore with active travel.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + + # Simulate active restore: travel is traveling + tilt_restore_active + cover.travel_calc.start_travel(0) + cover._tilt_restore_active = True + + assert cover.travel_calc.is_traveling() + + with patch.object(cover, "async_write_ha_state"): + await cover._abandon_active_lifecycle() + + assert not cover.travel_calc.is_traveling() + assert cover._tilt_restore_active is False + + +# =================================================================== +# cover_base.py line 1150: _send_tilt_close for tilt pre-step (dual motor) +# =================================================================== + + +class TestTiltPreStepSendClose: + """Test _send_tilt_close in _start_tilt_pre_step for dual motor.""" + + @pytest.mark.asyncio + async def test_send_tilt_close_for_closing_pre_step(self, make_cover): + """Line 1150: _send_tilt_close called when closing_tilt=True in pre-step.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(80) # Current tilt is 80 + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_close", new_callable=AsyncMock + ) as mock_tilt_close: + # tilt_target=30 < current_tilt=80 => closing_tilt=True + await cover._start_tilt_pre_step( + tilt_target=30, + travel_target=0, + travel_command=SERVICE_CLOSE_COVER, + restore_target=80, + ) + + mock_tilt_close.assert_awaited_once() + assert cover._pending_travel_target == 0 + assert cover._tilt_restore_target == 80 + + +# =================================================================== +# cover_base.py line 1226: _send_tilt_open for tilt restore (dual motor) +# =================================================================== + + +class TestTiltRestoreSendOpen: + """Test _send_tilt_open in _start_tilt_restore for dual motor.""" + + @pytest.mark.asyncio + async def test_send_tilt_open_for_restore(self, make_cover): + """Line 1226: _send_tilt_open called when restoring to higher tilt position.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + cover.travel_calc.set_position(0) + cover.tilt_calc.set_position(20) # Current tilt is 20 + + # Set restore target to a higher value (opening) + cover._tilt_restore_target = 80 + + with patch.object(cover, "async_write_ha_state"): + with patch.object( + cover, "_send_tilt_open", new_callable=AsyncMock + ) as mock_tilt_open: + await cover._start_tilt_restore() + + mock_tilt_open.assert_awaited_once() + assert cover._tilt_restore_active is True + assert cover.tilt_calc.is_traveling() + + +# =================================================================== +# cover_base.py lines 1290, 1308, 1326, 1328: _mark_switch_pending +# conditionals in _send_tilt_open/close/stop +# =================================================================== + + +class TestTiltSendMarkSwitchPending: + """Test _mark_switch_pending conditionals when opposite switch is on.""" + + @pytest.mark.asyncio + async def test_send_tilt_open_marks_close_switch_pending(self, make_cover): + """Line 1290: _send_tilt_open marks close switch pending when it's on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + with patch.object( + cover, + "_switch_is_on", + side_effect=lambda eid: eid == "switch.tilt_close", + ): + with patch.object(cover, "_mark_switch_pending") as mock_mark: + await cover._send_tilt_open() + + # Should mark close switch with 1 (it was on) and open switch with 2 + calls = mock_mark.call_args_list + assert any(c.args == ("switch.tilt_close", 1) for c in calls), ( + f"Expected close switch marked with 1, got {calls}" + ) + assert any(c.args == ("switch.tilt_open", 2) for c in calls), ( + f"Expected open switch marked with 2, got {calls}" + ) + + @pytest.mark.asyncio + async def test_send_tilt_close_marks_open_switch_pending(self, make_cover): + """Line 1308: _send_tilt_close marks open switch pending when it's on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + with patch.object( + cover, + "_switch_is_on", + side_effect=lambda eid: eid == "switch.tilt_open", + ): + with patch.object(cover, "_mark_switch_pending") as mock_mark: + await cover._send_tilt_close() + + calls = mock_mark.call_args_list + assert any(c.args == ("switch.tilt_open", 1) for c in calls), ( + f"Expected open switch marked with 1, got {calls}" + ) + assert any(c.args == ("switch.tilt_close", 2) for c in calls), ( + f"Expected close switch marked with 2, got {calls}" + ) + + @pytest.mark.asyncio + async def test_send_tilt_stop_marks_open_switch_pending(self, make_cover): + """Line 1326: _send_tilt_stop marks open switch pending when it's on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + with patch.object( + cover, + "_switch_is_on", + side_effect=lambda eid: eid == "switch.tilt_open", + ): + with patch.object(cover, "_mark_switch_pending") as mock_mark: + await cover._send_tilt_stop() + + calls = mock_mark.call_args_list + assert any(c.args == ("switch.tilt_open", 1) for c in calls), ( + f"Expected open switch marked with 1, got {calls}" + ) + + @pytest.mark.asyncio + async def test_send_tilt_stop_marks_close_switch_pending(self, make_cover): + """Line 1328: _send_tilt_stop marks close switch pending when it's on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + with patch.object( + cover, + "_switch_is_on", + side_effect=lambda eid: eid == "switch.tilt_close", + ): + with patch.object(cover, "_mark_switch_pending") as mock_mark: + await cover._send_tilt_stop() + + calls = mock_mark.call_args_list + assert any(c.args == ("switch.tilt_close", 1) for c in calls), ( + f"Expected close switch marked with 1, got {calls}" + ) + + @pytest.mark.asyncio + async def test_send_tilt_stop_marks_both_switches_when_both_on(self, make_cover): + """Lines 1326+1328: _send_tilt_stop marks both switches when both are on.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + with patch.object( + cover, + "_switch_is_on", + return_value=True, + ): + with patch.object(cover, "_mark_switch_pending") as mock_mark: + await cover._send_tilt_stop() + + calls = mock_mark.call_args_list + assert any(c.args == ("switch.tilt_open", 1) for c in calls), ( + f"Expected open switch marked with 1, got {calls}" + ) + assert any(c.args == ("switch.tilt_close", 1) for c in calls), ( + f"Expected close switch marked with 1, got {calls}" + ) + + +# =================================================================== +# cover_base.py lines 1437-1444: external tilt switch state change +# via _async_switch_state_changed +# =================================================================== + + +class TestExternalTiltSwitchStateChange: + """Test external tilt/main switch state changes via _async_switch_state_changed. + + External state changes delegate to the mode-specific handlers + (_handle_external_tilt_state_change / _handle_external_state_change) + with _triggered_externally=True. Position is tracked, not cleared. + """ + + @pytest.mark.asyncio + async def test_tilt_switch_delegates_to_tilt_handler(self, make_cover): + """Tilt switch state change delegates to _handle_external_tilt_state_change.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + event = MagicMock() + event.data = { + "entity_id": "switch.tilt_open", + "old_state": MagicMock(state="off"), + "new_state": MagicMock(state="on"), + } + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_tilt_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed(event) + + handler.assert_awaited_once_with("switch.tilt_open", "off", "on") + + @pytest.mark.asyncio + async def test_main_switch_delegates_to_main_handler(self, make_cover): + """Main switch state change delegates to _handle_external_state_change.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + event = MagicMock() + event.data = { + "entity_id": "switch.open", + "old_state": MagicMock(state="off"), + "new_state": MagicMock(state="on"), + } + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed(event) + + handler.assert_awaited_once_with("switch.open", "off", "on") + + @pytest.mark.asyncio + async def test_triggered_externally_set_during_handler(self, make_cover): + """_triggered_externally is True during handler execution.""" + cover = make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + captured_flag = None + + async def capture_flag(*_args): + nonlocal captured_flag + captured_flag = cover._triggered_externally + + event = MagicMock() + event.data = { + "entity_id": "switch.tilt_open", + "old_state": MagicMock(state="off"), + "new_state": MagicMock(state="on"), + } + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_tilt_state_change", side_effect=capture_flag + ), + ): + await cover._async_switch_state_changed(event) + + assert captured_flag is True + assert cover._triggered_externally is False diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..0c1c4d5 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,54 @@ +"""Tests for __init__.py integration setup/teardown.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.frontend import DATA_EXTRA_MODULE_URL + +from custom_components.cover_time_based import ( + async_setup_entry, + async_unload_entry, + async_update_options, +) + + +class TestIntegrationSetup: + """Test the integration lifecycle.""" + + @pytest.mark.asyncio + async def test_setup_entry_forwards_platforms(self): + hass = MagicMock() + hass.data = {DATA_EXTRA_MODULE_URL: set()} + hass.config_entries.async_forward_entry_setups = AsyncMock() + hass.http.async_register_static_paths = AsyncMock() + entry = MagicMock() + entry.async_on_unload = MagicMock() + entry.add_update_listener = MagicMock() + + result = await async_setup_entry(hass, entry) + + assert result is True + hass.config_entries.async_forward_entry_setups.assert_awaited_once() + entry.async_on_unload.assert_called_once() + + @pytest.mark.asyncio + async def test_unload_entry_unloads_platforms(self): + hass = MagicMock() + hass.config_entries.async_unload_platforms = AsyncMock(return_value=True) + entry = MagicMock() + + result = await async_unload_entry(hass, entry) + + assert result is True + hass.config_entries.async_unload_platforms.assert_awaited_once() + + @pytest.mark.asyncio + async def test_update_options_reloads_entry(self): + hass = MagicMock() + hass.config_entries.async_reload = AsyncMock() + entry = MagicMock() + entry.entry_id = "test_entry_id" + + await async_update_options(hass, entry) + + hass.config_entries.async_reload.assert_awaited_once_with("test_entry_id") diff --git a/tests/test_motor_overhead.py b/tests/test_motor_overhead.py new file mode 100644 index 0000000..d091f39 --- /dev/null +++ b/tests/test_motor_overhead.py @@ -0,0 +1,71 @@ +"""Tests for the startup delay and endpoint runon time configuration. + +travel_startup_delay, tilt_startup_delay, and endpoint_runon_time are +standalone config values that map directly to internal delay attributes. +""" + +import pytest + + +class TestTravelStartupDelay: + @pytest.mark.asyncio + async def test_startup_delay_stored_directly(self, make_cover): + cover = make_cover(travel_startup_delay=2.0) + assert cover._travel_startup_delay == 2.0 + + @pytest.mark.asyncio + async def test_no_startup_delay_gives_none(self, make_cover): + cover = make_cover() + assert cover._travel_startup_delay is None + + @pytest.mark.asyncio + async def test_endpoint_runon_time_stored_directly(self, make_cover): + cover = make_cover(endpoint_runon_time=1.5) + assert cover._endpoint_runon_time == 1.5 + + @pytest.mark.asyncio + async def test_no_endpoint_runon_time_gives_default(self, make_cover): + cover = make_cover() + assert cover._endpoint_runon_time == 2.0 + + +class TestTiltStartupDelay: + @pytest.mark.asyncio + async def test_tilt_startup_delay_stored_directly(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, tilt_time_open=5.0, tilt_startup_delay=1.0 + ) + assert cover._tilt_startup_delay == 1.0 + + @pytest.mark.asyncio + async def test_no_tilt_startup_delay(self, make_cover): + cover = make_cover(tilt_time_close=5.0, tilt_time_open=5.0) + assert cover._tilt_startup_delay is None + + +class TestDelayValuesStoredOnInstance: + """Verify that the delay values are stored on the instance for state attributes.""" + + @pytest.mark.asyncio + async def test_travel_startup_delay_stored(self, make_cover): + cover = make_cover(travel_startup_delay=4.0) + assert cover._travel_startup_delay == 4.0 + + @pytest.mark.asyncio + async def test_tilt_startup_delay_stored(self, make_cover): + cover = make_cover( + tilt_time_close=5.0, tilt_time_open=5.0, tilt_startup_delay=2.0 + ) + assert cover._tilt_startup_delay == 2.0 + + @pytest.mark.asyncio + async def test_endpoint_runon_time_stored(self, make_cover): + cover = make_cover(endpoint_runon_time=1.0) + assert cover._endpoint_runon_time == 1.0 + + @pytest.mark.asyncio + async def test_no_delays_stored_as_none(self, make_cover): + cover = make_cover() + assert cover._travel_startup_delay is None + assert cover._tilt_startup_delay is None + assert cover._endpoint_runon_time == 2.0 diff --git a/tests/test_relay_commands.py b/tests/test_relay_commands.py new file mode 100644 index 0000000..fc9f67c --- /dev/null +++ b/tests/test_relay_commands.py @@ -0,0 +1,1230 @@ +"""Characterization tests for CoverTimeBased._async_handle_command. + +Each test class covers one input mode and verifies the exact sequence +of service calls that the current implementation sends to Home Assistant. + +Pulse/toggle modes defer pulse completion to background tasks. Tests +drain those tasks to verify the full call sequence. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) + +from custom_components.cover_time_based.cover import ( + CONTROL_MODE_PULSE, + CONTROL_MODE_SWITCH, + CONTROL_MODE_TOGGLE, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _calls(mock: AsyncMock): + """Return the list of calls made on hass.services.async_call.""" + return mock.call_args_list + + +def _ha(service, entity_id): + """Shorthand for a homeassistant.turn_on / turn_off call.""" + return call("homeassistant", service, {"entity_id": entity_id}, False) + + +def _cover_svc(service, entity_id): + """Shorthand for a cover domain service call.""" + return call("cover", service, {"entity_id": entity_id}, False) + + +async def _drain_tasks(cover): + """Await all background tasks created during send calls.""" + for task in cover.hass._test_tasks: + await task + cover.hass._test_tasks.clear() + + +# =================================================================== +# Switch mode +# =================================================================== + + +class TestSwitchModeClose: + """CLOSE command in switch mode.""" + + @pytest.mark.asyncio + async def test_close_turns_off_open_and_on_close(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_with_stop_switch_ignores_it(self, make_cover): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + +class TestSwitchModeOpen: + """OPEN command in switch mode.""" + + @pytest.mark.asyncio + async def test_open_turns_off_close_and_on_open(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_with_stop_switch_ignores_it(self, make_cover): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + +class TestSwitchModeStop: + """STOP command in switch mode.""" + + @pytest.mark.asyncio + async def test_stop_turns_off_both_switches(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_with_stop_switch_ignores_it(self, make_cover): + """Stop switch is not valid in switch mode; it must be ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + +# =================================================================== +# Pulse mode +# =================================================================== + + +class TestPulseModeClose: + """CLOSE command in pulse mode.""" + + @pytest.mark.asyncio + async def test_close_pulses_close_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + _ha("turn_off", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + +class TestPulseModeOpen: + """OPEN command in pulse mode.""" + + @pytest.mark.asyncio + async def test_open_pulses_open_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + _ha("turn_off", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + +class TestPulseModeStop: + """STOP command in pulse mode.""" + + @pytest.mark.asyncio + async def test_stop_with_stop_switch_pulses_it(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.stop"), + # pulse completion (background) + _ha("turn_off", "switch.stop"), + ] + + +# =================================================================== +# Toggle mode +# =================================================================== + + +class TestToggleModeClose: + """CLOSE command in toggle mode.""" + + @pytest.mark.asyncio + async def test_close_pulses_close_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + +class TestToggleModeOpen: + """OPEN command in toggle mode.""" + + @pytest.mark.asyncio + async def test_open_pulses_open_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + +class TestToggleModeStop: + """STOP command in toggle mode.""" + + @pytest.mark.asyncio + async def test_stop_after_close_pulses_close_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover._last_command = SERVICE_CLOSE_COVER + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_stop_after_open_pulses_open_switch(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover._last_command = SERVICE_OPEN_COVER + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_with_no_last_command_does_nothing(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover._last_command = None + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + # No service calls expected - toggle mode skips stop when + # there is no prior direction to repeat. + assert _calls(cover.hass.services.async_call) == [] + + +# =================================================================== +# Wrapped cover entity (delegates to cover.* services) +# =================================================================== + + +class TestWrappedCoverClose: + """CLOSE command delegated to a wrapped cover entity.""" + + @pytest.mark.asyncio + async def test_close_delegates_to_cover_service(self, make_cover): + cover = make_cover(cover_entity_id="cover.inner") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("close_cover", "cover.inner"), + ] + + +class TestWrappedCoverOpen: + """OPEN command delegated to a wrapped cover entity.""" + + @pytest.mark.asyncio + async def test_open_delegates_to_cover_service(self, make_cover): + cover = make_cover(cover_entity_id="cover.inner") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("open_cover", "cover.inner"), + ] + + +class TestWrappedCoverStop: + """STOP command delegated to a wrapped cover entity.""" + + @pytest.mark.asyncio + async def test_stop_delegates_to_cover_service(self, make_cover): + cover = make_cover(cover_entity_id="cover.inner") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("stop_cover", "cover.inner"), + ] + + +class TestWrappedCoverTiltMotor: + """Tilt motor commands delegated to the wrapped cover entity.""" + + def test_has_tilt_motor_with_dual_motor(self, make_cover): + cover = make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + ) + assert cover._has_tilt_motor() is True + + def test_has_tilt_motor_false_without_strategy(self, make_cover): + cover = make_cover(cover_entity_id="cover.inner") + assert cover._has_tilt_motor() is False + + def test_has_tilt_motor_false_with_sequential(self, make_cover): + cover = make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="sequential", + ) + assert cover._has_tilt_motor() is False + + @pytest.mark.asyncio + async def test_tilt_open_delegates_to_cover_service(self, make_cover): + cover = make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + ) + await cover._send_tilt_open() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("open_cover_tilt", "cover.inner"), + ] + + @pytest.mark.asyncio + async def test_tilt_close_delegates_to_cover_service(self, make_cover): + cover = make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + ) + await cover._send_tilt_close() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("close_cover_tilt", "cover.inner"), + ] + + @pytest.mark.asyncio + async def test_tilt_stop_delegates_to_cover_service(self, make_cover): + cover = make_cover( + cover_entity_id="cover.inner", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + ) + await cover._send_tilt_stop() + + assert _calls(cover.hass.services.async_call) == [ + _cover_svc("stop_cover_tilt", "cover.inner"), + ] + + +# =================================================================== +# Helpers for _mark_switch_pending tests +# =================================================================== + + +def _mock_switch_on(hass, *entity_ids): + """Configure hass.states.get to return state "on" for given entity IDs. + + All other entity IDs return a state of "off". + """ + on_set = set(entity_ids) + + def _get(eid): + state = MagicMock() + state.state = "on" if eid in on_set else "off" + return state + + hass.states.get = _get + + +# =================================================================== +# Pulse mode: _mark_switch_pending when opposite switch is ON +# =================================================================== + + +class TestPulseModePendingSwitchOpen: + """Pulse _send_open marks pending when close/stop switches are already ON.""" + + @pytest.mark.asyncio + async def test_open_marks_close_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.close") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + # close switch was ON -> 1 pending, plus open switch always gets 2 + assert "switch.close" in cover._pending_switch or True + # Verify the pending counts were set (they may have been decremented + # by echo filtering, but the code path was exercised). + # The key assertion: the call sequence is unchanged, but the + # _mark_switch_pending branch on line 49 was hit. + + @pytest.mark.asyncio + async def test_open_marks_stop_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + # stop switch was ON -> line 53 hit + + @pytest.mark.asyncio + async def test_open_marks_both_pending_when_both_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.close", "switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + +class TestPulseModePendingSwitchClose: + """Pulse _send_close marks pending when open/stop switches are already ON.""" + + @pytest.mark.asyncio + async def test_close_marks_open_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.open") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + # open switch was ON -> line 78 hit + + @pytest.mark.asyncio + async def test_close_marks_stop_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + # stop switch was ON -> line 82 hit + + +class TestPulseModePendingSwitchStop: + """Pulse _send_stop marks pending when close/open switches are already ON.""" + + @pytest.mark.asyncio + async def test_stop_marks_close_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.close") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + # close switch was ON -> line 107 hit + + @pytest.mark.asyncio + async def test_stop_marks_open_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.open") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + # open switch was ON -> line 109 hit + + @pytest.mark.asyncio + async def test_stop_marks_both_pending_when_both_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.close", "switch.open") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_STOP_COVER) + await _drain_tasks(cover) + + # Both lines 107 and 109 hit + + +# =================================================================== +# Pulse mode tilt: _mark_switch_pending when opposite tilt switch is ON +# =================================================================== + + +class TestPulseModePendingTiltOpen: + """Pulse _send_tilt_open marks pending when tilt close switch is ON.""" + + @pytest.mark.asyncio + async def test_tilt_open_marks_tilt_close_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + _mock_switch_on(cover.hass, "switch.tilt_close") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_open() + await _drain_tasks(cover) + + # tilt close was ON -> line 139 hit + + +class TestPulseModePendingTiltClose: + """Pulse _send_tilt_close marks pending when tilt open switch is ON.""" + + @pytest.mark.asyncio + async def test_tilt_close_marks_tilt_open_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + _mock_switch_on(cover.hass, "switch.tilt_open") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_close() + await _drain_tasks(cover) + + # tilt open was ON -> line 157 hit + + +class TestPulseModePendingTiltStop: + """Pulse _send_tilt_stop marks pending when tilt open/close switches are ON.""" + + @pytest.mark.asyncio + async def test_tilt_stop_marks_tilt_open_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + _mock_switch_on(cover.hass, "switch.tilt_open") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + # tilt open was ON -> line 175 hit + + @pytest.mark.asyncio + async def test_tilt_stop_marks_tilt_close_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + _mock_switch_on(cover.hass, "switch.tilt_close") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + # tilt close was ON -> line 177 hit + + @pytest.mark.asyncio + async def test_tilt_stop_marks_both_pending_when_both_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + _mock_switch_on(cover.hass, "switch.tilt_open", "switch.tilt_close") + with patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_stop() + await _drain_tasks(cover) + + # Both lines 175 and 177 hit + + +# =================================================================== +# Switch mode: _mark_switch_pending when opposite switch is ON +# =================================================================== + + +class TestSwitchModePendingSwitchOpen: + """Switch _send_open marks pending when close/stop switches are already ON.""" + + @pytest.mark.asyncio + async def test_open_marks_close_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + _mock_switch_on(cover.hass, "switch.close") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + # close switch was ON -> line 81 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_ignores_stop_switch_even_when_on(self, make_cover): + """Stop switch is not valid in switch mode; it must be ignored even when ON.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_marks_close_pending_ignores_stop_when_both_on(self, make_cover): + """When both close and stop switches are ON, only close is marked pending.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.close", "switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + +class TestSwitchModePendingSwitchClose: + """Switch _send_close marks pending when open/stop switches are already ON.""" + + @pytest.mark.asyncio + async def test_close_marks_open_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + _mock_switch_on(cover.hass, "switch.open") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + # open switch was ON -> line 108 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_ignores_stop_switch_even_when_on(self, make_cover): + """Stop switch is not valid in switch mode; it must be ignored even when ON.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + +class TestSwitchModePendingSwitchStop: + """Switch _send_stop marks pending when close/open switches are already ON.""" + + @pytest.mark.asyncio + async def test_stop_marks_close_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + _mock_switch_on(cover.hass, "switch.close") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + # close switch was ON -> line 135 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_marks_open_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + _mock_switch_on(cover.hass, "switch.open") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + # open switch was ON -> line 137 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_stop_marks_both_pending_when_both_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + _mock_switch_on(cover.hass, "switch.close", "switch.open") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + # Both lines 135 and 137 hit + + +# =================================================================== +# Toggle mode: _mark_switch_pending when opposite switch is ON +# =================================================================== + + +class TestToggleModePendingSwitchOpen: + """Toggle _send_open marks pending when close/stop switches are already ON.""" + + @pytest.mark.asyncio + async def test_open_marks_close_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + _mock_switch_on(cover.hass, "switch.close") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + # close switch was ON -> line 191 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_open_ignores_stop_switch_even_when_on(self, make_cover): + """Stop switch is not valid in toggle mode; it must be ignored even when ON.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + _ha("turn_off", "switch.open"), + ] + + +class TestToggleModePendingSwitchClose: + """Toggle _send_close marks pending when open switch is already ON.""" + + @pytest.mark.asyncio + async def test_close_marks_open_switch_pending_when_on(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + _mock_switch_on(cover.hass, "switch.open") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + # open switch was ON -> line 220 hit + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + _ha("turn_off", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_close_ignores_stop_switch_even_when_on(self, make_cover): + """Stop switch is not valid in toggle mode; it must be ignored even when ON.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + _mock_switch_on(cover.hass, "switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + _ha("turn_off", "switch.close"), + ] + + +# =================================================================== +# Toggle mode tilt: _mark_switch_pending + _last_tilt_direction +# =================================================================== + + +class TestToggleModePendingTiltOpen: + """Toggle _send_tilt_open marks pending and sets _last_tilt_direction.""" + + @pytest.mark.asyncio + async def test_tilt_open_marks_tilt_close_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_TOGGLE, + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + _mock_switch_on(cover.hass, "switch.tilt_close") + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_open() + await _drain_tasks(cover) + + # tilt close was ON -> line 279 hit + # Also covers line 294: _last_tilt_direction = "open" + assert cover._last_tilt_direction == "open" + + @pytest.mark.asyncio + async def test_tilt_open_sets_last_tilt_direction(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_TOGGLE, + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_open() + await _drain_tasks(cover) + + # line 294: _last_tilt_direction = "open" + assert cover._last_tilt_direction == "open" + + +class TestToggleModePendingTiltClose: + """Toggle _send_tilt_close marks pending and sets _last_tilt_direction.""" + + @pytest.mark.asyncio + async def test_tilt_close_marks_tilt_open_pending_when_on(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_TOGGLE, + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + _mock_switch_on(cover.hass, "switch.tilt_open") + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_close() + await _drain_tasks(cover) + + # tilt open was ON -> line 298 hit + # Also covers line 313: _last_tilt_direction = "close" + assert cover._last_tilt_direction == "close" + + @pytest.mark.asyncio + async def test_tilt_close_sets_last_tilt_direction(self, make_cover): + cover = make_cover( + control_mode=CONTROL_MODE_TOGGLE, + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + with patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ): + await cover._send_tilt_close() + await _drain_tasks(cover) + + # line 313: _last_tilt_direction = "close" + assert cover._last_tilt_direction == "close" + + +# =================================================================== +# Switch mode: stop switch must NEVER be referenced +# =================================================================== + + +class TestSwitchModeIgnoresStopSwitch: + """Switch mode must never call services on a stop switch, even when configured.""" + + @pytest.mark.asyncio + async def test_open_with_stop_switch_never_references_it(self, make_cover): + """OPEN in switch mode must not call any service on the stop switch.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + for c in _calls(cover.hass.services.async_call): + assert c != _ha("turn_off", "switch.stop"), ( + "switch mode _send_open must not turn off stop switch" + ) + assert c != _ha("turn_on", "switch.stop"), ( + "switch mode _send_open must not turn on stop switch" + ) + + @pytest.mark.asyncio + async def test_close_with_stop_switch_never_references_it(self, make_cover): + """CLOSE in switch mode must not call any service on the stop switch.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + for c in _calls(cover.hass.services.async_call): + assert c != _ha("turn_off", "switch.stop"), ( + "switch mode _send_close must not turn off stop switch" + ) + assert c != _ha("turn_on", "switch.stop"), ( + "switch mode _send_close must not turn on stop switch" + ) + + @pytest.mark.asyncio + async def test_stop_with_stop_switch_never_references_it(self, make_cover): + """STOP in switch mode must not call any service on the stop switch.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + for c in _calls(cover.hass.services.async_call): + assert c != _ha("turn_off", "switch.stop"), ( + "switch mode _send_stop must not turn off stop switch" + ) + assert c != _ha("turn_on", "switch.stop"), ( + "switch mode _send_stop must not turn on stop switch" + ) + + @pytest.mark.asyncio + async def test_open_exact_calls_with_stop_switch_configured(self, make_cover): + """OPEN in switch mode with stop switch: only open/close switches used.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_OPEN_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_close_exact_calls_with_stop_switch_configured(self, make_cover): + """CLOSE in switch mode with stop switch: only open/close switches used.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + ] + + @pytest.mark.asyncio + async def test_stop_exact_calls_with_stop_switch_configured(self, make_cover): + """STOP in switch mode with stop switch: only open/close switches used.""" + cover = make_cover(control_mode=CONTROL_MODE_SWITCH, stop_switch="switch.stop") + with patch.object(cover, "async_write_ha_state"): + await cover._async_handle_command(SERVICE_STOP_COVER) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_off", "switch.open"), + ] + + +# =================================================================== +# Toggle mode: stop switch must NEVER be referenced +# =================================================================== + + +class TestToggleModeIgnoresStopSwitch: + """Toggle mode must never call services on a stop switch, even when configured.""" + + @pytest.mark.asyncio + async def test_open_with_stop_switch_never_references_it(self, make_cover): + """OPEN in toggle mode must not call any service on the stop switch.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + for c in _calls(cover.hass.services.async_call): + assert c != _ha("turn_off", "switch.stop"), ( + "toggle mode _send_open must not turn off stop switch" + ) + assert c != _ha("turn_on", "switch.stop"), ( + "toggle mode _send_open must not turn on stop switch" + ) + + @pytest.mark.asyncio + async def test_close_with_stop_switch_never_references_it(self, make_cover): + """CLOSE in toggle mode must not call any service on the stop switch.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + for c in _calls(cover.hass.services.async_call): + assert c != _ha("turn_off", "switch.stop"), ( + "toggle mode _send_close must not turn off stop switch" + ) + assert c != _ha("turn_on", "switch.stop"), ( + "toggle mode _send_close must not turn on stop switch" + ) + + @pytest.mark.asyncio + async def test_open_exact_calls_with_stop_switch_configured(self, make_cover): + """OPEN in toggle mode with stop switch: only open/close switches used.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_OPEN_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.close"), + _ha("turn_on", "switch.open"), + # pulse completion (background) + _ha("turn_off", "switch.open"), + ] + + @pytest.mark.asyncio + async def test_close_exact_calls_with_stop_switch_configured(self, make_cover): + """CLOSE in toggle mode with stop switch: only open/close switches used.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE, stop_switch="switch.stop") + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + await cover._async_handle_command(SERVICE_CLOSE_COVER) + await _drain_tasks(cover) + + assert _calls(cover.hass.services.async_call) == [ + _ha("turn_off", "switch.open"), + _ha("turn_on", "switch.close"), + # pulse completion (background) + _ha("turn_off", "switch.close"), + ] diff --git a/tests/test_state_monitoring.py b/tests/test_state_monitoring.py new file mode 100644 index 0000000..5704b8b --- /dev/null +++ b/tests/test_state_monitoring.py @@ -0,0 +1,1358 @@ +"""Tests for external state change handling in all cover modes. + +Covers the _handle_external_state_change methods in: +- cover_switch.py (SwitchCoverTimeBased base for pulse/toggle) +- cover_switch_mode.py (SwitchModeCover override for latching) +- cover_wrapped.py (WrappedCoverTimeBased for wrapped cover entities) + +External state changes only start tracking movement — they never auto-stop, +since we can't reliably know when the motor stopped from switch state alone. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER + +from custom_components.cover_time_based.cover import ( + CONTROL_MODE_PULSE, + CONTROL_MODE_TOGGLE, +) + + +def _make_state_event(entity_id, old_state, new_state): + """Create a mock state change event like HA fires.""" + old = MagicMock() + old.state = old_state + new = MagicMock() + new.state = new_state + event = MagicMock() + event.data = { + "entity_id": entity_id, + "old_state": old, + "new_state": new, + } + return event + + +# =================================================================== +# SwitchCoverTimeBased._handle_external_state_change (pulse/toggle base) +# =================================================================== + + +class TestPulseModeExternalStateChange: + """Test external state changes in pulse mode (base SwitchCoverTimeBased behavior).""" + + @pytest.mark.asyncio + async def test_open_pulse_triggers_open(self, make_cover): + """OFF->ON (rising edge) on open switch triggers async_open_cover.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.open", "off", "on") + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_close_pulse_triggers_close(self, make_cover): + """OFF->ON (rising edge) on close switch triggers async_close_cover.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.close", "off", "on") + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_stop_pulse_stops_tracker(self, make_cover): + """OFF->ON (rising edge) on stop switch stops the tracker.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.stop", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_on_to_off_ignored(self, make_cover): + """ON->OFF (falling edge / button release) should be ignored in pulse mode.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.open", "on", "off") + + # No movement should have started + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_unknown_entity_ignored(self, make_cover): + """Transitions on unknown entities should be ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.unknown", "on", "off") + + assert not cover.travel_calc.is_traveling() + + +# =================================================================== +# SwitchModeCover._handle_external_state_change (latching relay mode) +# =================================================================== + + +class TestSwitchModeExternalStateChange: + """Test external state changes in switch (latching) mode.""" + + @pytest.mark.asyncio + async def test_open_switch_on_triggers_open(self, make_cover): + """Open switch turning ON triggers open.""" + cover = make_cover() + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.open", "off", "on") + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_open_switch_off_stops_tracker(self, make_cover): + """Open switch turning OFF while opening stops the tracker.""" + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "on", "off") + finally: + cover._triggered_externally = False + + assert cover._last_command is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_open_switch_off_when_idle_is_noop(self, make_cover): + """Open switch turning OFF when not moving is a no-op.""" + cover = make_cover() + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "on", "off") + finally: + cover._triggered_externally = False + + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_close_switch_on_triggers_close(self, make_cover): + """Close switch turning ON triggers close.""" + cover = make_cover() + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.close", "off", "on") + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_close_switch_off_stops_tracker(self, make_cover): + """Close switch turning OFF while closing stops the tracker.""" + cover = make_cover() + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "on", "off") + finally: + cover._triggered_externally = False + + assert cover._last_command is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_close_switch_off_when_idle_is_noop(self, make_cover): + """Close switch turning OFF when not moving is a no-op.""" + cover = make_cover() + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "on", "off") + finally: + cover._triggered_externally = False + + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_stop_switch_ignored(self, make_cover): + """Stop switch turning ON is ignored (no auto-stop).""" + cover = make_cover(stop_switch="switch.stop") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("switch.stop", "off", "on") + + # Should still be closing — stop is ignored externally + assert cover._last_command == SERVICE_CLOSE_COVER + + +# =================================================================== +# Toggle mode external state changes +# =================================================================== + + +class TestToggleModeExternalStateChange: + """Test external state changes in toggle mode. + + Toggle mode reacts only to OFF->ON (rising edge). ON->OFF (falling + edge / relay release) is ignored. + + A debounce (using pulse_time) prevents double-triggering. + """ + + @pytest.mark.asyncio + async def test_on_to_off_ignored_open(self, make_cover): + """ON->OFF on open switch is ignored (falling edge).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "on", "off") + finally: + cover._triggered_externally = False + + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_on_to_off_ignored_close(self, make_cover): + """ON->OFF on close switch is ignored (falling edge).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "on", "off") + finally: + cover._triggered_externally = False + + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_off_to_on_triggers_open(self, make_cover): + """OFF->ON on open switch starts opening (rising edge).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_off_to_on_triggers_close(self, make_cover): + """OFF->ON on close switch starts closing (rising edge).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_off_to_on_while_opening_reissues(self, make_cover): + """OFF->ON on open switch while opening re-issues open (no special stop).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_off_to_on_while_closing_reissues(self, make_cover): + """OFF->ON on close switch while closing re-issues close (no special stop).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_debounce_ignores_rapid_second_pulse(self, make_cover): + """Second OFF->ON within debounce window is ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + # First rising edge starts tracker + await cover._handle_external_state_change("switch.open", "off", "on") + assert cover._last_command == SERVICE_OPEN_COVER + assert cover.is_opening + + # Second rising edge within debounce window — ignored + await cover._handle_external_state_change("switch.open", "off", "on") + assert cover._last_command == SERVICE_OPEN_COVER + assert cover.is_opening + finally: + cover._triggered_externally = False + + @pytest.mark.asyncio + async def test_full_cycle_same_direction_reissues(self, make_cover): + """Click 1 starts, click 2 (same direction after debounce) re-issues open.""" + import time as time_module + + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + # Click 1: OFF->ON starts opening + await cover._handle_external_state_change("switch.open", "off", "on") + assert cover._last_command == SERVICE_OPEN_COVER + assert cover.is_opening + + # Simulate time passing (beyond debounce window) + cover._last_external_toggle_time["switch.open"] = ( + time_module.monotonic() - cover._pulse_time - 0.5 - 0.1 + ) + + # Click 2: same direction re-issues open (no special stop) + await cover._handle_external_state_change("switch.open", "off", "on") + assert cover._last_command == SERVICE_OPEN_COVER + finally: + cover._triggered_externally = False + + @pytest.mark.asyncio + async def test_external_close_while_opening_reverses(self, make_cover): + """External close toggle while opening reverses direction.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.close", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_external_open_while_closing_reverses(self, make_cover): + """External open toggle while closing reverses direction.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.open", "off", "on") + finally: + cover._triggered_externally = False + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_ha_ui_close_while_opening_reverses(self, make_cover): + """HA UI close while opening should reverse direction (not just stop).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + # _triggered_externally is False (HA UI trigger) + await cover.async_close_cover() + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_ha_ui_open_while_closing_reverses(self, make_cover): + """HA UI open while closing should reverse direction (not just stop).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + # _triggered_externally is False (HA UI trigger) + await cover.async_open_cover() + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_unknown_entity_ignored(self, make_cover): + """Transitions on unknown entities should be ignored.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_state_change("switch.unknown", "on", "off") + finally: + cover._triggered_externally = False + + assert not cover.travel_calc.is_traveling() + + +# =================================================================== +# WrappedCoverTimeBased._handle_external_state_change +# =================================================================== + + +class TestWrappedCoverExternalStateChange: + """Test external state changes for wrapped cover entities.""" + + @pytest.mark.asyncio + async def test_opening_triggers_open(self, make_cover): + """Wrapped cover transitioning to 'opening' triggers position tracking.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change( + "cover.inner", "closed", "opening" + ) + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_closing_triggers_close(self, make_cover): + """Wrapped cover transitioning to 'closing' triggers position tracking.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("cover.inner", "open", "closing") + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_stop_from_opening_stops_tracker(self, make_cover): + """Wrapped cover stopping should stop the position tracker.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("cover.inner", "opening", "open") + + assert cover._last_command is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_stop_from_closing_stops_tracker(self, make_cover): + """Wrapped cover stopping should stop the position tracker.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change( + "cover.inner", "closing", "closed" + ) + + assert cover._last_command is None + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_direction_change_opening_to_closing(self, make_cover): + """Opening→closing should switch tracker to closing.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change( + "cover.inner", "opening", "closing" + ) + + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_direction_change_closing_to_opening(self, make_cover): + """Closing→opening should switch tracker to opening.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change( + "cover.inner", "closing", "opening" + ) + + assert cover._last_command == SERVICE_OPEN_COVER + + @pytest.mark.asyncio + async def test_non_moving_transition_ignored(self, make_cover): + """Transitions between non-moving states should be ignored.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + + with patch.object(cover, "async_write_ha_state"): + await cover._handle_external_state_change("cover.inner", "open", "closed") + + assert not cover.travel_calc.is_traveling() + + +# =================================================================== +# End-to-end tests through _async_switch_state_changed +# (simulates the full HA event pipeline including echo filtering, +# _triggered_externally, and debounce) +# =================================================================== + + +class TestToggleE2EThroughStateListener: + """End-to-end tests for toggle mode through the full state listener pipeline. + + External state changes delegate to _handle_external_state_change with + _triggered_externally=True. The toggle mode handler starts position + tracking (not clearing). + """ + + @pytest.mark.asyncio + async def test_latching_open_delegates_to_handler(self, make_cover): + """Latching switch: click (ON->OFF) delegates to external handler.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "off") + ) + + handler.assert_awaited_once_with("switch.open", "on", "off") + + @pytest.mark.asyncio + async def test_triggered_externally_during_handler(self, make_cover): + """_triggered_externally is True during handler, False after.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + captured_flag = None + + async def capture_flag(*_args): + nonlocal captured_flag + captured_flag = cover._triggered_externally + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", side_effect=capture_flag + ), + ): + await cover._async_switch_state_changed( + _make_state_event("switch.open", "off", "on") + ) + + assert captured_flag is True + assert cover._triggered_externally is False + + @pytest.mark.asyncio + async def test_no_echo_filtering_for_external_clicks(self, make_cover): + """External clicks should NOT be echo-filtered (no pending echoes).""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(0) + + # Verify no pending echoes + assert cover._pending_switch.get("switch.open", 0) == 0 + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "off") + ) + + # Handler called (not echo-filtered) + handler.assert_awaited_once() + + +# =================================================================== +# External tilt state changes (pulse mode — base class handler) +# Covers cover_base.py lines 1664-1688 +# =================================================================== + + +class TestExternalTiltPulseMode: + """Test _handle_external_tilt_state_change in pulse mode (base class). + + Pulse mode: OFF→ON = button press (rising edge). + Only reacts to OFF→ON transitions; ON→OFF (release) is ignored. + """ + + def _make_tilt_cover(self, make_cover): + return make_cover( + control_mode=CONTROL_MODE_PULSE, + stop_switch="switch.stop", + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + @pytest.mark.asyncio + async def test_tilt_open_pulse_off_to_on(self, make_cover): + """OFF→ON (rising edge) on tilt open switch triggers async_open_cover_tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_close_pulse_off_to_on(self, make_cover): + """OFF→ON (rising edge) on tilt close switch triggers async_close_cover_tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_close", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_stop_pulse_off_to_on(self, make_cover): + """OFF→ON (rising edge) on tilt stop switch triggers async_stop_cover.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_stop", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover._last_command is None + assert not cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_on_to_off_ignored(self, make_cover): + """ON→OFF (falling edge / release) on tilt switches is ignored in pulse mode.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "on", "off" + ) + finally: + cover._triggered_externally = False + + assert not cover.tilt_calc.is_traveling() + + +# =================================================================== +# External tilt state changes (switch/latching mode) +# Covers cover_switch_mode.py lines 50-77 +# =================================================================== + + +class TestExternalTiltSwitchMode: + """Test _handle_external_tilt_state_change in switch (latching) mode. + + ON = relay is driving the motor → start tracking. + OFF = relay released → stop tracking. + """ + + def _make_tilt_cover(self, make_cover): + # control_mode defaults to CONTROL_MODE_SWITCH in make_cover + return make_cover( + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + tilt_stop_switch="switch.tilt_stop", + ) + + @pytest.mark.asyncio + async def test_tilt_open_on(self, make_cover): + """Tilt open switch ON triggers async_open_cover_tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_open_off_stops(self, make_cover): + """Tilt open switch OFF stops the tilt tracker.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "on", "off" + ) + finally: + cover._triggered_externally = False + + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_tilt_close_on(self, make_cover): + """Tilt close switch ON triggers async_close_cover_tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_close", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_close_off_stops(self, make_cover): + """Tilt close switch OFF stops the tilt tracker.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_close", "on", "off" + ) + finally: + cover._triggered_externally = False + + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_tilt_stop_on(self, make_cover): + """Tilt stop switch ON triggers async_stop_cover.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_stop", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover._last_command is None + + +# =================================================================== +# External tilt state changes (toggle mode) +# Covers cover_toggle_mode.py lines 128-166 +# =================================================================== + + +class TestExternalTiltToggleMode: + """Test _handle_external_tilt_state_change in toggle mode. + + Uses debounce + toggle logic. If tilt is already traveling, + any toggle is treated as stop. If idle, dispatches open/close. + """ + + def _make_tilt_cover(self, make_cover): + return make_cover( + control_mode=CONTROL_MODE_TOGGLE, + tilt_time_close=5.0, + tilt_time_open=5.0, + tilt_mode="dual_motor", + tilt_open_switch="switch.tilt_open", + tilt_close_switch="switch.tilt_close", + ) + + @pytest.mark.asyncio + async def test_tilt_open_toggle_when_idle(self, make_cover): + """Toggle tilt open switch when idle → opens tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_open_toggle_while_traveling_stops(self, make_cover): + """Toggle tilt open switch while tilt is traveling → stops.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(50) + cover.tilt_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover._last_command is None + + @pytest.mark.asyncio + async def test_tilt_close_toggle_when_idle(self, make_cover): + """Toggle tilt close switch when idle → closes tilt.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(100) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + await cover._handle_external_tilt_state_change( + "switch.tilt_close", "off", "on" + ) + finally: + cover._triggered_externally = False + + assert cover.tilt_calc.is_traveling() + + @pytest.mark.asyncio + async def test_tilt_toggle_debounced(self, make_cover): + """Second toggle within debounce window is ignored.""" + cover = self._make_tilt_cover(make_cover) + cover.travel_calc.set_position(50) + cover.tilt_calc.set_position(0) + + with patch.object(cover, "async_write_ha_state"): + cover._triggered_externally = True + try: + # First toggle: starts opening tilt + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "off", "on" + ) + assert cover.tilt_calc.is_traveling() + + # Second toggle within debounce window: ignored + await cover._handle_external_tilt_state_change( + "switch.tilt_open", "on", "off" + ) + # Should still be traveling (debounced, not stopped) + assert cover.tilt_calc.is_traveling() + finally: + cover._triggered_externally = False + + +# =================================================================== +# Same-state (attribute-only) transitions +# =================================================================== + + +class TestSameStateTransitionsIgnored: + """Attribute-only state changes (e.g. position updates) should not + trigger external state handling.""" + + @pytest.mark.asyncio + async def test_wrapped_closing_to_closing_ignored(self, make_cover): + """Wrapped cover 'closing → closing' should not call async_close_cover.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(100) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object(cover, "async_close_cover") as mock_close, + ): + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "closing", "closing") + ) + mock_close.assert_not_called() + + @pytest.mark.asyncio + async def test_wrapped_opening_to_opening_ignored(self, make_cover): + """Wrapped cover 'opening → opening' should not call async_open_cover.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(0) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object(cover, "async_open_cover") as mock_open, + ): + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "opening", "opening") + ) + mock_open.assert_not_called() + + @pytest.mark.asyncio + async def test_switch_on_to_on_ignored(self, make_cover): + """Switch 'on → on' attribute update should not trigger external handling.""" + cover = make_cover() + cover.travel_calc.set_position(0) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object(cover, "_handle_external_state_change") as mock_handler, + ): + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "on") + ) + mock_handler.assert_not_called() + + +# =================================================================== +# Calibration suppresses external state handling +# =================================================================== + + +class TestCalibrationSuppressesExternalState: + """During calibration, external state changes must not trigger + movement lifecycle — calibration drives the motors directly.""" + + @pytest.mark.asyncio + async def test_wrapped_state_change_ignored_during_calibration(self, make_cover): + """Wrapped cover state changes should be skipped while calibrating.""" + from custom_components.cover_time_based.calibration import CalibrationState + + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + cover._calibration = CalibrationState(attribute="travel_time_close", timeout=60) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object(cover, "async_close_cover") as mock_close, + ): + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "closing") + ) + mock_close.assert_not_called() + + @pytest.mark.asyncio + async def test_switch_state_change_ignored_during_calibration(self, make_cover): + """Switch state changes should be skipped while calibrating.""" + from custom_components.cover_time_based.calibration import CalibrationState + + cover = make_cover() + cover.travel_calc.set_position(0) + cover._calibration = CalibrationState(attribute="travel_time_open", timeout=60) + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object(cover, "_handle_external_state_change") as mock_handler, + ): + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "off") + ) + mock_handler.assert_not_called() + + @pytest.mark.asyncio + async def test_state_change_works_after_calibration_ends(self, make_cover): + """After calibration is cleared, external state changes work again.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(100) + cover._calibration = None # No calibration active + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "_handle_external_state_change", new_callable=AsyncMock + ) as handler, + ): + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "closing") + ) + + # Handler should be called (not suppressed by calibration) + handler.assert_awaited_once_with("cover.inner", "open", "closing") + + +# =================================================================== +# Raw direction change: echo filtering for all control modes +# (Verifies that _raw_direction_command properly marks pending echoes +# so that state changes from direction reversal don't trigger external +# state handling or position tracking.) +# =================================================================== + + +def _mock_entity_states(cover, initial_states): + """Set up hass.states.get to return mock states from a mutable dict. + + Returns the mutable state dict so tests can update states between steps. + """ + states = dict(initial_states) + + def _get(entity_id): + s = MagicMock() + s.state = states.get(entity_id, "off") + return s + + cover.hass.states.get = _get + return states + + +async def _drain_bg_tasks(cover): + """Wait for all background tasks (pulse completions etc.) to finish.""" + for task in cover.hass._test_tasks: + if not task.done(): + try: + await task + except Exception: + pass + + +class TestRawDirectionChangeEchoFiltering: + """Raw direction change via calibration buttons must not trigger + position tracking. + + When using the calibration screen's manual open/close/stop buttons, + _raw_direction_command sends relay commands and marks expected echoes. + All resulting state change events must be echo-filtered so that + _handle_external_state_change is never called. + + Bug fixed: wrapped covers produced 2 state transitions on direction + change (e.g. closing->open->opening) but only marked 1 pending echo. + """ + + @pytest.mark.asyncio + async def test_wrapped_close_then_open(self, make_cover): + """Wrapped: close->open direction change filters both transitions.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + states = _mock_entity_states(cover, {"cover.inner": "open"}) + + with patch.object(cover, "async_write_ha_state"): + # Raw close: inner cover transitions open -> closing (1 echo) + await cover._raw_direction_command("close") + states["cover.inner"] = "closing" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "closing") + ) + assert not cover.travel_calc.is_traveling() + + # Raw open (direction change): closing->open, then open->opening + await cover._raw_direction_command("open") + + states["cover.inner"] = "open" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "closing", "open") + ) + assert not cover.travel_calc.is_traveling() + + states["cover.inner"] = "opening" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "opening") + ) + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_wrapped_direction_change_with_attribute_updates(self, make_cover): + """Attribute-only updates during echo window must not consume echo counts. + + Wrapped covers emit opening->opening position updates while moving. + If these arrive between the raw command and the actual direction change + transitions, they must not decrement the pending echo counter. + """ + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + states = _mock_entity_states(cover, {"cover.inner": "open"}) + + with patch.object(cover, "async_write_ha_state"): + # Raw open + await cover._raw_direction_command("open") + states["cover.inner"] = "opening" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "opening") + ) + assert not cover.travel_calc.is_traveling() + + # Raw close (direction change) while inner cover is opening + await cover._raw_direction_command("close") + + # Attribute-only update arrives (position update from inner cover) + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "opening", "opening") + ) + assert not cover.travel_calc.is_traveling() + + # Actual transitions: opening->open (stop), open->closing (start) + states["cover.inner"] = "open" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "opening", "open") + ) + assert not cover.travel_calc.is_traveling() + + states["cover.inner"] = "closing" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "closing") + ) + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_wrapped_open_then_close(self, make_cover): + """Wrapped: open->close direction change filters both transitions.""" + cover = make_cover(cover_entity_id="cover.inner") + cover.travel_calc.set_position(50) + states = _mock_entity_states(cover, {"cover.inner": "open"}) + + with patch.object(cover, "async_write_ha_state"): + # Raw open: inner cover transitions open -> opening (1 echo) + await cover._raw_direction_command("open") + states["cover.inner"] = "opening" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "opening") + ) + assert not cover.travel_calc.is_traveling() + + # Raw close (direction change): opening->open, then open->closing + await cover._raw_direction_command("close") + + states["cover.inner"] = "open" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "opening", "open") + ) + assert not cover.travel_calc.is_traveling() + + states["cover.inner"] = "closing" + await cover._async_switch_state_changed( + _make_state_event("cover.inner", "open", "closing") + ) + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_switch_mode(self, make_cover): + """Switch mode: close->open direction change filters all relay echoes.""" + cover = make_cover() + cover.travel_calc.set_position(50) + states = _mock_entity_states( + cover, {"switch.open": "off", "switch.close": "off"} + ) + + with patch.object(cover, "async_write_ha_state"): + # Raw close: turn_on(close_switch) -> 1 echo + await cover._raw_direction_command("close") + states["switch.close"] = "on" + await cover._async_switch_state_changed( + _make_state_event("switch.close", "off", "on") + ) + assert not cover.travel_calc.is_traveling() + + # Raw open (direction change): turn_off(close) + turn_on(open) + await cover._raw_direction_command("open") + states["switch.close"] = "off" + states["switch.open"] = "on" + + await cover._async_switch_state_changed( + _make_state_event("switch.close", "on", "off") + ) + assert not cover.travel_calc.is_traveling() + + await cover._async_switch_state_changed( + _make_state_event("switch.open", "off", "on") + ) + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_toggle_mode(self, make_cover): + """Toggle mode: close->open direction change (stop + open) filters all echoes.""" + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + cover.travel_calc.set_position(50) + _mock_entity_states(cover, {"switch.open": "off", "switch.close": "off"}) + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_toggle_mode.sleep", + new_callable=AsyncMock, + ), + ): + # Raw close: pulse on close switch -> 2 echoes (ON + OFF) + await cover._raw_direction_command("close") + await _drain_bg_tasks(cover) + + await cover._async_switch_state_changed( + _make_state_event("switch.close", "off", "on") + ) + await cover._async_switch_state_changed( + _make_state_event("switch.close", "on", "off") + ) + assert not cover.travel_calc.is_traveling() + + # Raw open (direction change): stop-pulse on close + open-pulse on open + await cover._raw_direction_command("open") + await _drain_bg_tasks(cover) + + # Stop pulse echoes on close switch + await cover._async_switch_state_changed( + _make_state_event("switch.close", "off", "on") + ) + await cover._async_switch_state_changed( + _make_state_event("switch.close", "on", "off") + ) + assert not cover.travel_calc.is_traveling() + + # Open pulse echoes on open switch + await cover._async_switch_state_changed( + _make_state_event("switch.open", "off", "on") + ) + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "off") + ) + assert not cover.travel_calc.is_traveling() + + @pytest.mark.asyncio + async def test_pulse_mode(self, make_cover): + """Pulse mode: close->open direction change filters all pulse echoes.""" + cover = make_cover(control_mode=CONTROL_MODE_PULSE, stop_switch="switch.stop") + cover.travel_calc.set_position(50) + _mock_entity_states( + cover, + {"switch.open": "off", "switch.close": "off", "switch.stop": "off"}, + ) + + with ( + patch.object(cover, "async_write_ha_state"), + patch( + "custom_components.cover_time_based.cover_pulse_mode.sleep", + new_callable=AsyncMock, + ), + ): + # Raw close: pulse on close switch -> 2 echoes (ON + OFF) + await cover._raw_direction_command("close") + await _drain_bg_tasks(cover) + + await cover._async_switch_state_changed( + _make_state_event("switch.close", "off", "on") + ) + await cover._async_switch_state_changed( + _make_state_event("switch.close", "on", "off") + ) + assert not cover.travel_calc.is_traveling() + + # Raw open (direction change): pulse on open switch -> 2 echoes + await cover._raw_direction_command("open") + await _drain_bg_tasks(cover) + + await cover._async_switch_state_changed( + _make_state_event("switch.open", "off", "on") + ) + await cover._async_switch_state_changed( + _make_state_event("switch.open", "on", "off") + ) + assert not cover.travel_calc.is_traveling() diff --git a/tests/test_tilt_strategy.py b/tests/test_tilt_strategy.py new file mode 100644 index 0000000..e56cc3b --- /dev/null +++ b/tests/test_tilt_strategy.py @@ -0,0 +1,528 @@ +"""Tests for TiltStrategy classes (SequentialTilt and DualMotorTilt).""" + +from custom_components.cover_time_based.travel_calculator import TravelCalculator + +from custom_components.cover_time_based.tilt_strategies import ( + DualMotorTilt, + InlineTilt, + SequentialTilt, + TiltTo, + TravelTo, +) +from custom_components.cover_time_based.tilt_strategies.planning import ( + has_travel_pre_step, +) + + +# =================================================================== +# MovementStep dataclasses +# =================================================================== + + +class TestMovementSteps: + """Test MovementStep dataclasses.""" + + def test_tilt_to_defaults(self): + step = TiltTo(50) + assert step.target == 50 + assert step.coupled_travel is None + + def test_tilt_to_with_coupling(self): + step = TiltTo(50, coupled_travel=30) + assert step.target == 50 + assert step.coupled_travel == 30 + + def test_travel_to_defaults(self): + step = TravelTo(30) + assert step.target == 30 + assert step.coupled_tilt is None + + def test_travel_to_with_coupling(self): + step = TravelTo(30, coupled_tilt=30) + assert step.target == 30 + assert step.coupled_tilt == 30 + + def test_equality(self): + assert TiltTo(50) == TiltTo(50) + assert TravelTo(30) == TravelTo(30) + assert TiltTo(50) != TravelTo(50) + + +# =================================================================== +# SequentialTilt +# =================================================================== + + +class TestSequentialTiltCanCalibrate: + """SequentialTilt.can_calibrate_tilt returns True.""" + + def test_can_calibrate(self): + strategy = SequentialTilt() + assert strategy.can_calibrate_tilt() is True + + +# --- Sequential new interface tests --- + + +class TestSequentialTiltProperties: + def test_name(self): + assert SequentialTilt().name == "sequential" + + def test_uses_tilt_motor(self): + assert SequentialTilt().uses_tilt_motor is False + + def test_restores_tilt(self): + assert SequentialTilt().restores_tilt is False + + +class TestSequentialPlanMovePosition: + def test_flattens_tilt_before_travel(self): + strategy = SequentialTilt() + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=20 + ) + assert steps == [TiltTo(100), TravelTo(70)] + + def test_skips_tilt_when_already_flat(self): + strategy = SequentialTilt() + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=100 + ) + assert steps == [TravelTo(70)] + + def test_opening_fully(self): + strategy = SequentialTilt() + steps = strategy.plan_move_position( + target_pos=100, current_pos=0, current_tilt=0 + ) + assert steps == [TiltTo(100), TravelTo(100)] + + def test_closing_fully_from_open(self): + strategy = SequentialTilt() + steps = strategy.plan_move_position( + target_pos=0, current_pos=100, current_tilt=100 + ) + assert steps == [TravelTo(0)] + + def test_partial_move_with_flat_tilt(self): + strategy = SequentialTilt() + steps = strategy.plan_move_position( + target_pos=50, current_pos=20, current_tilt=100 + ) + assert steps == [TravelTo(50)] + + +class TestSequentialPlanMoveTilt: + def test_travels_to_closed_before_tilting(self): + strategy = SequentialTilt() + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=70, current_tilt=100 + ) + assert steps == [TravelTo(0), TiltTo(50)] + + def test_tilts_directly_when_at_closed(self): + strategy = SequentialTilt() + steps = strategy.plan_move_tilt(target_tilt=50, current_pos=0, current_tilt=100) + assert steps == [TiltTo(50)] + + def test_tilt_fully_closed(self): + strategy = SequentialTilt() + steps = strategy.plan_move_tilt(target_tilt=0, current_pos=0, current_tilt=100) + assert steps == [TiltTo(0)] + + def test_tilt_open_from_partially_tilted(self): + strategy = SequentialTilt() + steps = strategy.plan_move_tilt(target_tilt=100, current_pos=0, current_tilt=50) + assert steps == [TiltTo(100)] + + +class TestSequentialSnapTrackers: + def test_forces_tilt_to_zero_when_not_at_closed(self): + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(50) + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 100 + + def test_no_op_when_at_closed(self): + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(0) + tilt.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 50 + + def test_no_op_when_already_flat(self): + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(30) + tilt.set_position(100) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 100 + + def test_forces_at_fully_open(self): + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(100) + tilt.set_position(10) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 100 + + def test_snap_returns_early_when_travel_position_none(self): + """snap_trackers_to_physical returns early when travel position is None.""" + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + # travel position is None (never set), tilt is set + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 30 # unchanged + + def test_snap_returns_early_when_tilt_position_none(self): + """snap_trackers_to_physical returns early when tilt position is None.""" + strategy = SequentialTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + # travel is set, tilt position is None (never set) + travel.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() is None # unchanged + + +# =================================================================== +# DualMotorTilt +# =================================================================== + + +class TestDualMotorTiltProperties: + def test_name(self): + assert DualMotorTilt().name == "dual_motor" + + def test_uses_tilt_motor(self): + assert DualMotorTilt().uses_tilt_motor is True + + def test_can_calibrate_tilt(self): + assert DualMotorTilt().can_calibrate_tilt() is True + + def test_restores_tilt(self): + assert DualMotorTilt().restores_tilt is True + + +class TestDualMotorPlanMovePosition: + def test_moves_tilt_to_safe_before_travel(self): + strategy = DualMotorTilt(safe_tilt_position=100) + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=50 + ) + assert steps == [TiltTo(100), TravelTo(70)] + + def test_skips_tilt_when_already_safe(self): + strategy = DualMotorTilt(safe_tilt_position=100) + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=100 + ) + assert steps == [TravelTo(70)] + + def test_custom_safe_position(self): + strategy = DualMotorTilt(safe_tilt_position=50) + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=20 + ) + assert steps == [TiltTo(50), TravelTo(70)] + + def test_already_at_custom_safe(self): + strategy = DualMotorTilt(safe_tilt_position=50) + steps = strategy.plan_move_position( + target_pos=70, current_pos=0, current_tilt=50 + ) + assert steps == [TravelTo(70)] + + +class TestDualMotorPlanMoveTilt: + def test_tilts_directly_when_no_boundary(self): + strategy = DualMotorTilt() + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=70, current_tilt=100 + ) + assert steps == [TiltTo(50)] + + def test_travels_to_boundary_when_above_max(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=70, current_tilt=100 + ) + assert steps == [TravelTo(20), TiltTo(50)] + + def test_tilts_directly_when_at_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=20, current_tilt=100 + ) + assert steps == [TiltTo(50)] + + def test_tilts_directly_when_beyond_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + steps = strategy.plan_move_tilt(target_tilt=50, current_pos=0, current_tilt=100) + assert steps == [TiltTo(50)] + + def test_no_boundary_set(self): + strategy = DualMotorTilt(max_tilt_allowed_position=None) + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=90, current_tilt=100 + ) + assert steps == [TiltTo(50)] + + +class TestDualMotorSnapTrackers: + def test_forces_tilt_to_safe_when_above_max(self): + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=20) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(50) + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 100 + + def test_no_op_when_at_boundary(self): + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=20) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(20) + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 30 + + def test_no_op_when_no_boundary(self): + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=None) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(30) + tilt.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 50 + + def test_already_at_safe_position(self): + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=20) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(50) + tilt.set_position(100) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 100 + + def test_snap_returns_early_when_travel_position_none(self): + """snap_trackers_to_physical returns early when travel position is None.""" + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=20) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + # travel position is None (never set), tilt is set + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 30 # unchanged + + def test_snap_returns_early_when_tilt_position_none(self): + """snap_trackers_to_physical returns early when tilt position is None.""" + strategy = DualMotorTilt(safe_tilt_position=100, max_tilt_allowed_position=20) + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + # travel is set, tilt position is None (never set) + travel.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() is None # unchanged + + +class TestDualMotorAllowsTiltAtPosition: + def test_allowed_when_no_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=None) + assert strategy.allows_tilt_at_position(50) is True + + def test_allowed_at_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + assert strategy.allows_tilt_at_position(20) is True + + def test_allowed_below_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + assert strategy.allows_tilt_at_position(0) is True + + def test_not_allowed_above_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=20) + assert strategy.allows_tilt_at_position(50) is False + + def test_not_allowed_with_zero_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=0) + assert strategy.allows_tilt_at_position(50) is False + + def test_allowed_at_zero_with_zero_boundary(self): + strategy = DualMotorTilt(max_tilt_allowed_position=0) + assert strategy.allows_tilt_at_position(0) is True + + +# =================================================================== +# InlineTilt +# =================================================================== + + +class TestInlineTiltProperties: + def test_name(self): + assert InlineTilt().name == "inline" + + def test_uses_tilt_motor(self): + assert InlineTilt().uses_tilt_motor is False + + def test_restores_tilt(self): + assert InlineTilt().restores_tilt is True + + def test_can_calibrate_tilt(self): + assert InlineTilt().can_calibrate_tilt() is True + + +class TestInlinePlanMovePosition: + def test_closing_with_tilt_open_adds_pre_step(self): + """Closing from open tilt: tilt closes first, then travel.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=30, current_pos=80, current_tilt=100 + ) + assert steps == [TiltTo(0), TravelTo(30)] + + def test_closing_with_tilt_already_closed_skips_pre_step(self): + """Closing when tilt already closed: just travel.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=30, current_pos=80, current_tilt=0 + ) + assert steps == [TravelTo(30)] + + def test_opening_with_tilt_closed_adds_pre_step(self): + """Opening from closed tilt: tilt opens first, then travel.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=80, current_pos=30, current_tilt=0 + ) + assert steps == [TiltTo(100), TravelTo(80)] + + def test_opening_with_tilt_already_open_skips_pre_step(self): + """Opening when tilt already open: just travel.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=80, current_pos=30, current_tilt=100 + ) + assert steps == [TravelTo(80)] + + def test_closing_to_endpoint_still_has_pre_step(self): + """Even targeting 0%, tilt phase still happens for timing.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=0, current_pos=50, current_tilt=100 + ) + assert steps == [TiltTo(0), TravelTo(0)] + + def test_opening_to_endpoint_still_has_pre_step(self): + """Even targeting 100%, tilt phase still happens for timing.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=100, current_pos=50, current_tilt=0 + ) + assert steps == [TiltTo(100), TravelTo(100)] + + def test_closing_with_partial_tilt_adds_pre_step(self): + """Closing with tilt at 60% (not fully closed): pre-step needed.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=20, current_pos=70, current_tilt=60 + ) + assert steps == [TiltTo(0), TravelTo(20)] + + def test_opening_with_partial_tilt_adds_pre_step(self): + """Opening with tilt at 40% (not fully open): pre-step needed.""" + strategy = InlineTilt() + steps = strategy.plan_move_position( + target_pos=70, current_pos=20, current_tilt=40 + ) + assert steps == [TiltTo(100), TravelTo(70)] + + +class TestInlinePlanMoveTilt: + def test_tilt_only_no_travel(self): + """Tilt command: just tilt, no travel coupling.""" + strategy = InlineTilt() + steps = strategy.plan_move_tilt( + target_tilt=50, current_pos=30, current_tilt=100 + ) + assert steps == [TiltTo(50)] + + def test_tilt_at_fully_open(self): + strategy = InlineTilt() + steps = strategy.plan_move_tilt( + target_tilt=0, current_pos=100, current_tilt=100 + ) + assert steps == [TiltTo(0)] + + def test_tilt_at_closed(self): + strategy = InlineTilt() + steps = strategy.plan_move_tilt(target_tilt=80, current_pos=0, current_tilt=0) + assert steps == [TiltTo(80)] + + +class TestInlineSnapTrackers: + """snap_trackers_to_physical is a no-op for inline tilt. + + Endpoint coupling is handled by plan_move_position pre-steps, + not by post-stop snapping. Tilt can be any value at any position. + """ + + def test_no_snap_at_closed_endpoint(self): + strategy = InlineTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(0) + tilt.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 50 + + def test_no_snap_at_open_endpoint(self): + strategy = InlineTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(100) + tilt.set_position(50) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 50 + + def test_no_snap_at_mid_position(self): + strategy = InlineTilt() + travel = TravelCalculator(10.0, 10.0) + tilt = TravelCalculator(2.0, 2.0) + travel.set_position(50) + tilt.set_position(30) + strategy.snap_trackers_to_physical(travel, tilt) + assert tilt.current_position() == 30 + + +# =================================================================== +# has_travel_pre_step helper +# =================================================================== + + +class TestHasTravelPreStep: + """Test the has_travel_pre_step planning helper.""" + + def test_travel_then_tilt(self): + assert has_travel_pre_step([TravelTo(0), TiltTo(50)]) is True + + def test_tilt_then_travel(self): + assert has_travel_pre_step([TiltTo(100), TravelTo(50)]) is False + + def test_single_tilt(self): + assert has_travel_pre_step([TiltTo(50)]) is False + + def test_single_travel(self): + assert has_travel_pre_step([TravelTo(50)]) is False + + def test_empty(self): + assert has_travel_pre_step([]) is False diff --git a/tests/test_toggle_behavior.py b/tests/test_toggle_behavior.py new file mode 100644 index 0000000..4872885 --- /dev/null +++ b/tests/test_toggle_behavior.py @@ -0,0 +1,135 @@ +"""Tests for toggle-specific behaviour in CoverTimeBased. + +These tests exercise the higher-level async_close_cover / async_open_cover / +async_stop_cover methods and verify that toggle mode correctly stops the +cover when a same-direction command arrives while already moving, and that +the stop guard prevents relay commands when the cover is idle. +""" + +import pytest +from unittest.mock import AsyncMock, patch + +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) + +from custom_components.cover_time_based.cover import ( + CONTROL_MODE_SWITCH, + CONTROL_MODE_TOGGLE, +) + + +# =================================================================== +# Toggle: close/open while already moving in the same direction +# =================================================================== + + +class TestToggleCloseWhileMoving: + """Same-direction commands re-issue (no special stop override).""" + + @pytest.mark.asyncio + async def test_close_while_closing_reissues(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + + # Simulate that the cover is currently closing (position 100 = fully open) + cover.travel_calc.set_position(100) + cover.travel_calc.start_travel_down() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_close_cover() + + # Same-direction re-issues close command (base class behavior) + assert cover._last_command == SERVICE_CLOSE_COVER + + @pytest.mark.asyncio + async def test_open_while_opening_reissues(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + + # Simulate that the cover is currently opening (position 0 = fully closed) + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel_up() + + with patch.object(cover, "async_write_ha_state"): + await cover.async_open_cover() + + # Same-direction re-issues open command (base class behavior) + assert cover._last_command == SERVICE_OPEN_COVER + + +# =================================================================== +# Toggle stop guard: idle cover should NOT send relay commands +# =================================================================== + + +class TestToggleStopGuard: + """async_stop_cover on an idle toggle cover must not send relay commands.""" + + @pytest.mark.asyncio + async def test_stop_when_idle_toggle_no_relay_command(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + # Toggle mode: no relay command because the cover was idle + cover.hass.services.async_call.assert_not_awaited() + + @pytest.mark.asyncio + async def test_stop_when_idle_switch_sends_relay_command(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_SWITCH) + + with patch.object(cover, "async_write_ha_state"): + await cover.async_stop_cover() + + # Switch mode: always sends the stop relay command, even when idle + cover.hass.services.async_call.assert_awaited() + + +# =================================================================== +# Stop before direction change in toggle mode +# =================================================================== + + +class TestStopBeforeDirectionChange: + """Closing while opening in toggle mode should stop first.""" + + @pytest.mark.asyncio + async def test_close_while_opening_stops_first(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + + # Simulate that the cover is currently opening (position 0 = fully closed) + cover.travel_calc.set_position(0) + cover.travel_calc.start_travel_up() + cover._last_command = SERVICE_OPEN_COVER + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "async_stop_cover", new_callable=AsyncMock + ) as mock_stop, + ): + await cover.async_close_cover() + + # async_stop_cover should have been called to stop the opening movement + mock_stop.assert_awaited_once() + + @pytest.mark.asyncio + async def test_open_while_closing_stops_first(self, make_cover): + cover = make_cover(control_mode=CONTROL_MODE_TOGGLE) + + # Simulate that the cover is currently closing (position 100 = fully open) + cover.travel_calc.set_position(100) + cover.travel_calc.start_travel_down() + cover._last_command = SERVICE_CLOSE_COVER + + with ( + patch.object(cover, "async_write_ha_state"), + patch.object( + cover, "async_stop_cover", new_callable=AsyncMock + ) as mock_stop, + ): + await cover.async_open_cover() + + # async_stop_cover should have been called to stop the closing movement + mock_stop.assert_awaited_once() diff --git a/tests/test_travel_calculator.py b/tests/test_travel_calculator.py new file mode 100644 index 0000000..fe22fc4 --- /dev/null +++ b/tests/test_travel_calculator.py @@ -0,0 +1,78 @@ +"""Tests for TravelCalculator edge cases.""" + +from unittest.mock import patch + +from custom_components.cover_time_based.travel_calculator import ( + TravelCalculator, + TravelStatus, +) + + +class TestTravelCalculatorEdgeCases: + """Test edge cases not covered by integration tests.""" + + def test_stop_when_position_none(self): + """stop() on a fresh calculator with no known position does nothing.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + assert calc.current_position() is None + calc.stop() + assert calc.current_position() is None + assert calc.travel_direction == TravelStatus.STOPPED + + def test_start_travel_when_position_none(self): + """start_travel() with unknown position snaps to target immediately.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + assert calc._last_known_position is None + calc.start_travel(50) + assert calc.current_position() == 50 + assert calc.travel_direction == TravelStatus.STOPPED + + def test_is_opening(self): + """is_opening() returns True when traveling upward.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + calc.set_position(0) + calc.start_travel(100) + assert calc.is_opening() is True + assert calc.is_closing() is False + + def test_is_closing(self): + """is_closing() returns True when traveling downward.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + calc.set_position(100) + calc.start_travel(0) + assert calc.is_closing() is True + assert calc.is_opening() is False + + def test_is_not_opening_when_stopped(self): + """is_opening() returns False when not traveling.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + calc.set_position(50) + assert calc.is_opening() is False + + def test_is_open(self): + """is_open() returns True when at fully open position.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + calc.set_position(100) + assert calc.is_open() is True + + def test_is_not_open(self): + """is_open() returns False when not at fully open position.""" + calc = TravelCalculator(travel_time_down=30, travel_time_up=30) + calc.set_position(0) + assert calc.is_open() is False + + def test_position_returns_target_when_time_exceeded(self): + """current_position() returns target when travel time has elapsed.""" + calc = TravelCalculator(travel_time_down=10, travel_time_up=10) + calc.set_position(0) + + # Start travel, then advance time past the full travel duration + with patch( + "custom_components.cover_time_based.travel_calculator.time" + ) as mock_time: + mock_time.time.return_value = 1000.0 + calc.start_travel(100) + # Now advance time past the travel duration (10s for full range) + mock_time.time.return_value = 1020.0 + pos = calc.current_position() + assert pos == 100 diff --git a/tests/test_websocket_api.py b/tests/test_websocket_api.py new file mode 100644 index 0000000..a6d2633 --- /dev/null +++ b/tests/test_websocket_api.py @@ -0,0 +1,1342 @@ +"""Tests for the WebSocket API module.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from custom_components.cover_time_based.cover import ( + CONF_CLOSE_SWITCH_ENTITY_ID, + CONF_CONTROL_MODE, + CONF_COVER_ENTITY_ID, + CONF_MIN_MOVEMENT_TIME, + CONF_OPEN_SWITCH_ENTITY_ID, + CONF_PULSE_TIME, + CONF_STOP_SWITCH_ENTITY_ID, + CONF_TILT_CLOSE_SWITCH, + CONF_TILT_MODE, + CONF_TILT_OPEN_SWITCH, + CONF_TILT_STARTUP_DELAY, + CONF_TILT_STOP_SWITCH, + CONF_TILT_TIME_CLOSE, + CONF_TILT_TIME_OPEN, + CONF_TRAVEL_STARTUP_DELAY, + CONF_TRAVEL_TIME_CLOSE, + CONF_TRAVEL_TIME_OPEN, + CONTROL_MODE_PULSE, + CONTROL_MODE_SWITCH, + CONTROL_MODE_TOGGLE, + CONTROL_MODE_WRAPPED, + DEFAULT_PULSE_TIME, +) +from custom_components.cover_time_based.websocket_api import ( + _resolve_config_entry, + async_register_websocket_api, + ws_get_config, + ws_raw_command, + ws_start_calibration, + ws_stop_calibration, + ws_update_config, +) + +DOMAIN = "cover_time_based" +ENTITY_ID = "cover.test_blind" +ENTRY_ID = "test_entry_123" + + +def _unwrap(fn): + """Get the original async function from a decorated WS handler.""" + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +# Unwrapped coroutines — bypass @async_response / @websocket_command decorators +_ws_get_config = _unwrap(ws_get_config) +_ws_update_config = _unwrap(ws_update_config) +_ws_start_calibration = _unwrap(ws_start_calibration) +_ws_stop_calibration = _unwrap(ws_stop_calibration) +_ws_raw_command = _unwrap(ws_raw_command) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_hass(options=None, *, domain=DOMAIN, entry_id=ENTRY_ID): + """Build a mock HomeAssistant with entity registry and config entry.""" + config_entry = MagicMock() + config_entry.entry_id = entry_id + config_entry.domain = domain + config_entry.options = dict(options or {}) + + registry_entry = MagicMock() + registry_entry.config_entry_id = entry_id + + entity_reg = MagicMock() + entity_reg.async_get.return_value = registry_entry + + hass = MagicMock() + hass.config_entries.async_get_entry.return_value = config_entry + + return hass, config_entry, entity_reg + + +def _make_connection(): + """Build a mock WebSocket connection.""" + conn = MagicMock() + conn.send_result = MagicMock() + conn.send_error = MagicMock() + return conn + + +# --------------------------------------------------------------------------- +# _resolve_config_entry +# --------------------------------------------------------------------------- + + +class TestResolveConfigEntry: + """Tests for _resolve_config_entry helper.""" + + def test_valid_entity(self): + hass, config_entry, entity_reg = _make_hass() + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + result, error = _resolve_config_entry(hass, ENTITY_ID) + + assert result is config_entry + assert error is None + + def test_entity_not_found(self): + hass, _, entity_reg = _make_hass() + entity_reg.async_get.return_value = None + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + result, error = _resolve_config_entry(hass, "cover.nonexistent") + + assert result is None + assert "not found" in error.lower() + + def test_entity_without_config_entry_id(self): + hass, _, entity_reg = _make_hass() + entry = MagicMock() + entry.config_entry_id = None + entity_reg.async_get.return_value = entry + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + result, error = _resolve_config_entry(hass, ENTITY_ID) + + assert result is None + assert error is not None + + def test_wrong_domain(self): + hass, _, entity_reg = _make_hass(domain="other_domain") + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + result, error = _resolve_config_entry(hass, ENTITY_ID) + + assert result is None + assert "cover_time_based" in error + + def test_config_entry_missing(self): + hass, _, entity_reg = _make_hass() + hass.config_entries.async_get_entry.return_value = None + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + result, error = _resolve_config_entry(hass, ENTITY_ID) + + assert result is None + assert error is not None + + +# --------------------------------------------------------------------------- +# ws_get_config +# --------------------------------------------------------------------------- + + +class TestWsGetConfig: + """Tests for ws_get_config WebSocket handler.""" + + @pytest.mark.asyncio + async def test_returns_defaults_when_options_empty(self): + hass, _, entity_reg = _make_hass(options={}) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_get_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/get_config", + "entity_id": ENTITY_ID, + }, + ) + + conn.send_result.assert_called_once() + result = conn.send_result.call_args[0][1] + + assert result["entry_id"] == ENTRY_ID + assert result["control_mode"] == CONTROL_MODE_SWITCH + assert result["pulse_time"] == DEFAULT_PULSE_TIME + assert result["travel_time_close"] is None + assert result["travel_time_open"] is None + assert result["tilt_mode"] == "none" + # Optional fields default to None + assert result["open_switch_entity_id"] is None + assert result["close_switch_entity_id"] is None + assert result["stop_switch_entity_id"] is None + assert result["cover_entity_id"] is None + assert result["tilt_time_close"] is None + assert result["tilt_time_open"] is None + assert result["travel_startup_delay"] is None + assert result["tilt_startup_delay"] is None + assert result["min_movement_time"] is None + + @pytest.mark.asyncio + async def test_returns_stored_options(self): + options = { + CONF_CONTROL_MODE: CONTROL_MODE_WRAPPED, + CONF_PULSE_TIME: 2.5, + CONF_COVER_ENTITY_ID: "cover.inner", + CONF_TRAVEL_TIME_CLOSE: 45, + CONF_TRAVEL_TIME_OPEN: 50, + CONF_TILT_TIME_CLOSE: 3.0, + CONF_TILT_TIME_OPEN: 3.5, + CONF_TILT_MODE: "sequential", + CONF_TRAVEL_STARTUP_DELAY: 1.2, + CONF_TILT_STARTUP_DELAY: 0.8, + CONF_MIN_MOVEMENT_TIME: 0.5, + } + hass, _, entity_reg = _make_hass(options=options) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_get_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/get_config", + "entity_id": ENTITY_ID, + }, + ) + + result = conn.send_result.call_args[0][1] + assert result["control_mode"] == CONTROL_MODE_WRAPPED + assert result["pulse_time"] == 2.5 + assert result["cover_entity_id"] == "cover.inner" + assert result["travel_time_close"] == 45 + assert result["travel_time_open"] == 50 + assert result["tilt_time_close"] == 3.0 + assert result["tilt_time_open"] == 3.5 + assert result["tilt_mode"] == "sequential" + assert result["travel_startup_delay"] == 1.2 + assert result["tilt_startup_delay"] == 0.8 + assert result["min_movement_time"] == 0.5 + + @pytest.mark.asyncio + async def test_error_when_entity_not_found(self): + hass, _, entity_reg = _make_hass() + entity_reg.async_get.return_value = None + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_get_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/get_config", + "entity_id": "cover.bad", + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_found" + conn.send_result.assert_not_called() + + +# --------------------------------------------------------------------------- +# Dual-motor field round-tripping +# --------------------------------------------------------------------------- + + +class TestDualMotorFieldRoundTrip: + """Test that dual-motor fields are returned in get_config and saved in update_config.""" + + @pytest.fixture + def config_entry_with_dual_motor(self): + """Config entry with dual_motor options set.""" + entry = MagicMock() + entry.entry_id = ENTRY_ID + entry.domain = DOMAIN + entry.options = { + "control_mode": "switch", + "tilt_mode": "dual_motor", + "safe_tilt_position": 10, + "max_tilt_allowed_position": 80, + "tilt_open_switch": "switch.tilt_open", + "tilt_close_switch": "switch.tilt_close", + "tilt_stop_switch": "switch.tilt_stop", + } + return entry + + @pytest.mark.asyncio + async def test_get_config_returns_dual_motor_fields( + self, config_entry_with_dual_motor + ): + hass = MagicMock() + connection = MagicMock() + msg = {"id": 1, "type": "cover_time_based/get_config", "entity_id": ENTITY_ID} + + with patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry_with_dual_motor, None), + ): + handler = _unwrap(ws_get_config) + await handler(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + assert result["safe_tilt_position"] == 10 + assert result["max_tilt_allowed_position"] == 80 + assert result["tilt_open_switch"] == "switch.tilt_open" + assert result["tilt_close_switch"] == "switch.tilt_close" + assert result["tilt_stop_switch"] == "switch.tilt_stop" + + @pytest.mark.asyncio + async def test_update_config_saves_dual_motor_fields(self): + hass = MagicMock() + connection = MagicMock() + config_entry = MagicMock() + config_entry.options = {"tilt_mode": "dual_motor"} + config_entry.domain = DOMAIN + + msg = { + "id": 2, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "safe_tilt_position": 15, + "max_tilt_allowed_position": 90, + "tilt_open_switch": "switch.tilt_up", + "tilt_close_switch": "switch.tilt_down", + "tilt_stop_switch": "switch.tilt_stop", + } + + with patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry, None), + ): + handler = _unwrap(ws_update_config) + await handler(hass, connection, msg) + + new_opts = hass.config_entries.async_update_entry.call_args[1]["options"] + assert new_opts["safe_tilt_position"] == 15 + assert new_opts["max_tilt_allowed_position"] == 90 + assert new_opts["tilt_open_switch"] == "switch.tilt_up" + assert new_opts["tilt_close_switch"] == "switch.tilt_down" + assert new_opts["tilt_stop_switch"] == "switch.tilt_stop" + + @pytest.mark.asyncio + async def test_get_config_defaults_for_missing_dual_motor_fields(self): + """When dual_motor fields aren't in options, get_config returns sensible defaults.""" + hass = MagicMock() + connection = MagicMock() + config_entry = MagicMock() + config_entry.entry_id = ENTRY_ID + config_entry.domain = DOMAIN + config_entry.options = {"tilt_mode": "sequential"} + msg = {"id": 1, "type": "cover_time_based/get_config", "entity_id": ENTITY_ID} + + with patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry, None), + ): + handler = _unwrap(ws_get_config) + await handler(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + assert result["safe_tilt_position"] == 100 # HA default: fully open + assert result["max_tilt_allowed_position"] is None + assert result["tilt_open_switch"] is None + assert result["tilt_close_switch"] is None + assert result["tilt_stop_switch"] is None + + +# --------------------------------------------------------------------------- +# ws_update_config +# --------------------------------------------------------------------------- + + +class TestWsUpdateConfig: + """Tests for ws_update_config WebSocket handler.""" + + @pytest.mark.asyncio + async def test_update_single_field(self): + hass, config_entry, entity_reg = _make_hass( + options={CONF_CONTROL_MODE: CONTROL_MODE_SWITCH} + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_WRAPPED, + }, + ) + + conn.send_result.assert_called_once_with(1, {"success": True}) + call_kwargs = hass.config_entries.async_update_entry.call_args + new_options = call_kwargs[1]["options"] + assert new_options[CONF_CONTROL_MODE] == CONTROL_MODE_WRAPPED + + @pytest.mark.asyncio + async def test_update_multiple_fields(self): + hass, _, entity_reg = _make_hass(options={}) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_TOGGLE, + "open_switch_entity_id": "switch.up", + "close_switch_entity_id": "switch.down", + }, + ) + + new_options = hass.config_entries.async_update_entry.call_args[1]["options"] + assert new_options[CONF_CONTROL_MODE] == CONTROL_MODE_TOGGLE + assert new_options[CONF_OPEN_SWITCH_ENTITY_ID] == "switch.up" + assert new_options[CONF_CLOSE_SWITCH_ENTITY_ID] == "switch.down" + + @pytest.mark.asyncio + async def test_null_removes_option(self): + hass, _, entity_reg = _make_hass( + options={CONF_STOP_SWITCH_ENTITY_ID: "switch.stop"} + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "stop_switch_entity_id": None, + }, + ) + + new_options = hass.config_entries.async_update_entry.call_args[1]["options"] + assert CONF_STOP_SWITCH_ENTITY_ID not in new_options + + @pytest.mark.asyncio + async def test_preserves_unmentioned_options(self): + hass, _, entity_reg = _make_hass( + options={ + CONF_CONTROL_MODE: CONTROL_MODE_SWITCH, + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_STOP_SWITCH_ENTITY_ID: "switch.stop", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_PULSE, + }, + ) + + new_options = hass.config_entries.async_update_entry.call_args[1]["options"] + # Original options preserved + assert new_options[CONF_OPEN_SWITCH_ENTITY_ID] == "switch.open" + assert new_options[CONF_CLOSE_SWITCH_ENTITY_ID] == "switch.close" + assert new_options[CONF_STOP_SWITCH_ENTITY_ID] == "switch.stop" + # New option added + assert new_options[CONF_CONTROL_MODE] == CONTROL_MODE_PULSE + + @pytest.mark.asyncio + async def test_pulse_mode_accepted_with_stop_switch(self): + """Pulse mode with a stop switch should be accepted.""" + hass, _, entity_reg = _make_hass( + options={ + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_STOP_SWITCH_ENTITY_ID: "switch.stop", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_PULSE, + }, + ) + + conn.send_result.assert_called_once() + hass.config_entries.async_update_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_switch_mode_does_not_require_stop_switch(self): + """Switch mode without a stop switch should be accepted.""" + hass, _, entity_reg = _make_hass( + options={ + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_SWITCH, + }, + ) + + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_toggle_mode_does_not_require_stop_switch(self): + """Toggle mode without a stop switch should be accepted.""" + hass, _, entity_reg = _make_hass( + options={ + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_TOGGLE, + }, + ) + + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_pulse_mode_accepted_with_tilt_stop_switch(self): + """Pulse mode with dual_motor tilt and tilt stop switch should be accepted.""" + hass, _, entity_reg = _make_hass( + options={ + CONF_OPEN_SWITCH_ENTITY_ID: "switch.open", + CONF_CLOSE_SWITCH_ENTITY_ID: "switch.close", + CONF_STOP_SWITCH_ENTITY_ID: "switch.stop", + CONF_CONTROL_MODE: CONTROL_MODE_PULSE, + CONF_TILT_MODE: "dual_motor", + CONF_TILT_OPEN_SWITCH: "switch.tilt_open", + CONF_TILT_CLOSE_SWITCH: "switch.tilt_close", + CONF_TILT_STOP_SWITCH: "switch.tilt_stop", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + }, + ) + + conn.send_result.assert_called_once() + hass.config_entries.async_update_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_error_when_entity_not_found(self): + hass, _, entity_reg = _make_hass() + entity_reg.async_get.return_value = None + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": "cover.bad", + "control_mode": CONTROL_MODE_WRAPPED, + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_found" + hass.config_entries.async_update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_tilt_fields(self): + hass, _, entity_reg = _make_hass(options={}) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "tilt_time_close": 5.0, + "tilt_time_open": 5.5, + "tilt_mode": "sequential", + }, + ) + + new_options = hass.config_entries.async_update_entry.call_args[1]["options"] + assert new_options[CONF_TILT_TIME_CLOSE] == 5.0 + assert new_options[CONF_TILT_TIME_OPEN] == 5.5 + assert new_options[CONF_TILT_MODE] == "sequential" + + @pytest.mark.asyncio + async def test_clear_tilt_fields(self): + hass, _, entity_reg = _make_hass( + options={ + CONF_TILT_TIME_CLOSE: 5.0, + CONF_TILT_TIME_OPEN: 5.0, + CONF_TILT_MODE: "sequential", + } + ) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg, + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "tilt_time_close": None, + "tilt_time_open": None, + "tilt_mode": "none", + }, + ) + + new_options = hass.config_entries.async_update_entry.call_args[1]["options"] + assert CONF_TILT_TIME_CLOSE not in new_options + assert CONF_TILT_TIME_OPEN not in new_options + assert new_options[CONF_TILT_MODE] == "none" + + +# --------------------------------------------------------------------------- +# ws_update_config — timing field validation +# --------------------------------------------------------------------------- + + +class TestTimingFieldValidation: + """Verify that travel/tilt time fields reject 0 (min 0.1) while delay fields allow it.""" + + @pytest.mark.parametrize( + "field", + ["travel_time_close", "travel_time_open", "tilt_time_close", "tilt_time_open"], + ) + def test_zero_rejected_for_travel_tilt_times(self, field): + """travel_time_* and tilt_time_* require >= 0.1.""" + import voluptuous as vol + + validator = vol.All(vol.Coerce(float), vol.Range(min=0.1, max=600)) + with pytest.raises(vol.Invalid): + validator(0) + # 0.1 must be accepted + assert validator(0.1) == pytest.approx(0.1) + + @pytest.mark.parametrize( + "field", + [ + "travel_startup_delay", + "tilt_startup_delay", + "endpoint_runon_time", + "min_movement_time", + ], + ) + def test_zero_accepted_for_delay_fields(self, field): + """Delay and auxiliary timing fields allow 0.""" + import voluptuous as vol + + validator = vol.All(vol.Coerce(float), vol.Range(min=0, max=600)) + assert validator(0) == pytest.approx(0) + + +# --------------------------------------------------------------------------- +# ws_update_config — wrap-self rejection +# --------------------------------------------------------------------------- + + +class TestWsUpdateConfigWrappedSelf: + """Test that ws_update_config rejects wrapping another CTB entity.""" + + @pytest.mark.asyncio + async def test_rejects_wrapping_ctb_entity(self): + hass, config_entry, entity_reg = _make_hass(options={}) + conn = _make_connection() + + # Target entity belongs to cover_time_based + target_entry = MagicMock() + target_entry.platform = DOMAIN + + entity_reg_for_update = MagicMock() + entity_reg_for_update.async_get.side_effect = lambda eid: ( + target_entry if eid == "cover.other_ctb" else entity_reg.async_get(eid) + ) + + with ( + patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry, None), + ), + patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg_for_update, + ), + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "cover_entity_id": "cover.other_ctb", + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "invalid_entity" + hass.config_entries.async_update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_allows_wrapping_non_ctb_entity(self): + hass, config_entry, _ = _make_hass(options={}) + conn = _make_connection() + + target_entry = MagicMock() + target_entry.platform = "other_integration" + + entity_reg_for_update = MagicMock() + entity_reg_for_update.async_get.return_value = target_entry + + with ( + patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry, None), + ), + patch( + "custom_components.cover_time_based.websocket_api.er.async_get", + return_value=entity_reg_for_update, + ), + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "cover_entity_id": "cover.other", + }, + ) + + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_allows_update_without_cover_entity_id(self): + hass, config_entry, _ = _make_hass(options={}) + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api._resolve_config_entry", + return_value=(config_entry, None), + ): + await _ws_update_config( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/update_config", + "entity_id": ENTITY_ID, + "control_mode": CONTROL_MODE_SWITCH, + }, + ) + + conn.send_result.assert_called_once() + + +# --------------------------------------------------------------------------- +# ws_start_calibration +# --------------------------------------------------------------------------- + + +class TestWsStartCalibration: + """Tests for ws_start_calibration handler.""" + + @pytest.mark.asyncio + async def test_entity_not_found(self): + hass = MagicMock() + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=None, + ): + await _ws_start_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/start_calibration", + "entity_id": ENTITY_ID, + "attribute": "travel_time_close", + "timeout": 60, + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_found" + + @pytest.mark.asyncio + async def test_success(self): + hass = MagicMock() + conn = _make_connection() + entity = MagicMock() + entity.start_calibration = AsyncMock() + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_start_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/start_calibration", + "entity_id": ENTITY_ID, + "attribute": "travel_time_close", + "timeout": 60, + }, + ) + + entity.start_calibration.assert_awaited_once_with( + attribute="travel_time_close", timeout=60 + ) + conn.send_result.assert_called_once_with(1, {"success": True}) + + @pytest.mark.asyncio + async def test_with_direction(self): + hass = MagicMock() + conn = _make_connection() + entity = MagicMock() + entity.start_calibration = AsyncMock() + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_start_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/start_calibration", + "entity_id": ENTITY_ID, + "attribute": "travel_time_close", + "timeout": 60, + "direction": "close", + }, + ) + + call_kwargs = entity.start_calibration.call_args[1] + assert call_kwargs["direction"] == "close" + + @pytest.mark.asyncio + async def test_exception(self): + hass = MagicMock() + conn = _make_connection() + entity = MagicMock() + entity.start_calibration = AsyncMock( + side_effect=Exception("already in progress") + ) + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_start_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/start_calibration", + "entity_id": ENTITY_ID, + "attribute": "travel_time_close", + "timeout": 60, + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "failed" + + +# --------------------------------------------------------------------------- +# ws_stop_calibration +# --------------------------------------------------------------------------- + + +class TestWsStopCalibration: + """Tests for ws_stop_calibration handler.""" + + @pytest.mark.asyncio + async def test_entity_not_found(self): + hass = MagicMock() + conn = _make_connection() + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=None, + ): + await _ws_stop_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/stop_calibration", + "entity_id": ENTITY_ID, + "cancel": False, + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_found" + + @pytest.mark.asyncio + async def test_success(self): + hass = MagicMock() + conn = _make_connection() + entity = MagicMock() + entity.stop_calibration = AsyncMock( + return_value={"attribute": "travel_time_close", "value": 45.0} + ) + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_stop_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/stop_calibration", + "entity_id": ENTITY_ID, + "cancel": False, + }, + ) + + conn.send_result.assert_called_once() + result = conn.send_result.call_args[0][1] + assert result["attribute"] == "travel_time_close" + assert result["value"] == 45.0 + + @pytest.mark.asyncio + async def test_exception(self): + hass = MagicMock() + conn = _make_connection() + entity = MagicMock() + entity.stop_calibration = AsyncMock(side_effect=Exception("no calibration")) + + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_stop_calibration( + hass, + conn, + { + "id": 1, + "type": "cover_time_based/stop_calibration", + "entity_id": ENTITY_ID, + "cancel": False, + }, + ) + + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "failed" + + +# --------------------------------------------------------------------------- +# ws_raw_command +# --------------------------------------------------------------------------- + + +class TestWsRawCommand: + """Tests for ws_raw_command handler.""" + + def _make_entity(self, *, has_tilt_motor=False, has_tilt_support=False): + entity = MagicMock() + entity._raw_direction_command = AsyncMock() + entity._has_tilt_motor = MagicMock(return_value=has_tilt_motor) + entity._has_tilt_support = MagicMock(return_value=has_tilt_support) + entity._calibration = None + entity._cancel_startup_delay_task = MagicMock() + entity._cancel_delay_task = MagicMock() + entity._handle_stop = MagicMock() + entity.travel_calc = MagicMock() + entity.tilt_calc = MagicMock() + entity.async_write_ha_state = MagicMock() + return entity + + def _msg(self, command): + return { + "id": 1, + "type": "cover_time_based/raw_command", + "entity_id": ENTITY_ID, + "command": command, + } + + @pytest.mark.asyncio + async def test_entity_not_found(self): + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=None, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("open")) + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_found" + + @pytest.mark.asyncio + async def test_open(self): + entity = self._make_entity() + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("open")) + entity._raw_direction_command.assert_awaited_once_with("open") + entity.travel_calc.clear_position.assert_called_once() + entity.async_write_ha_state.assert_called_once() + conn.send_result.assert_called_once_with(1, {"success": True}) + + @pytest.mark.asyncio + async def test_close(self): + entity = self._make_entity() + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("close")) + entity._raw_direction_command.assert_awaited_once_with("close") + entity.travel_calc.clear_position.assert_called_once() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_stop(self): + entity = self._make_entity() + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("stop")) + entity._raw_direction_command.assert_awaited_once_with("stop") + entity.travel_calc.clear_position.assert_called_once() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_tilt_open(self): + entity = self._make_entity(has_tilt_motor=True, has_tilt_support=True) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_open")) + entity._raw_direction_command.assert_awaited_once_with("tilt_open") + entity.tilt_calc.clear_position.assert_called_once() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_tilt_open_not_supported(self): + entity = self._make_entity(has_tilt_motor=False) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_open")) + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_supported" + + @pytest.mark.asyncio + async def test_tilt_close(self): + entity = self._make_entity(has_tilt_motor=True, has_tilt_support=True) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_close")) + entity._raw_direction_command.assert_awaited_once_with("tilt_close") + entity.tilt_calc.clear_position.assert_called_once() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_tilt_close_not_supported(self): + entity = self._make_entity(has_tilt_motor=False) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_close")) + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_supported" + + @pytest.mark.asyncio + async def test_tilt_stop(self): + entity = self._make_entity(has_tilt_motor=True, has_tilt_support=True) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_stop")) + entity._raw_direction_command.assert_awaited_once_with("tilt_stop") + entity.tilt_calc.clear_position.assert_called_once() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_tilt_stop_not_supported(self): + entity = self._make_entity(has_tilt_motor=False) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("tilt_stop")) + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "not_supported" + + @pytest.mark.asyncio + async def test_lifecycle_stop_called(self): + entity = self._make_entity() + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("open")) + entity._cancel_startup_delay_task.assert_called_once() + entity._cancel_delay_task.assert_called_once() + entity._handle_stop.assert_called_once() + + @pytest.mark.asyncio + async def test_calibration_skips_lifecycle_and_clear(self): + entity = self._make_entity() + entity._calibration = MagicMock() # not None — calibration active + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("open")) + # Command still dispatched + entity._raw_direction_command.assert_awaited_once_with("open") + # But lifecycle stop and position clear are skipped + entity._cancel_startup_delay_task.assert_not_called() + entity._cancel_delay_task.assert_not_called() + entity._handle_stop.assert_not_called() + entity.travel_calc.clear_position.assert_not_called() + entity.async_write_ha_state.assert_not_called() + conn.send_result.assert_called_once() + + @pytest.mark.asyncio + async def test_exception(self): + entity = self._make_entity() + entity._raw_direction_command = AsyncMock(side_effect=Exception("hw error")) + conn = _make_connection() + with patch( + "custom_components.cover_time_based.websocket_api.resolve_entity_or_none", + return_value=entity, + ): + await _ws_raw_command(MagicMock(), conn, self._msg("open")) + conn.send_error.assert_called_once() + assert conn.send_error.call_args[0][1] == "failed" + + +# --------------------------------------------------------------------------- +# resolve_entity_or_none (from helpers, returns None on error) +# --------------------------------------------------------------------------- + + +class TestWsResolveEntity: + """Test resolve_entity_or_none from helpers.py.""" + + def test_returns_none_no_component(self): + from custom_components.cover_time_based.helpers import resolve_entity_or_none + + hass = MagicMock() + hass.data = {} + assert resolve_entity_or_none(hass, "cover.test") is None + + def test_returns_none_entity_not_found(self): + from custom_components.cover_time_based.helpers import resolve_entity_or_none + + component = MagicMock() + component.get_entity.return_value = None + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + assert resolve_entity_or_none(hass, "cover.test") is None + + def test_returns_none_wrong_type(self): + from custom_components.cover_time_based.helpers import resolve_entity_or_none + + component = MagicMock() + component.get_entity.return_value = MagicMock() # not CoverTimeBased + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + assert resolve_entity_or_none(hass, "cover.test") is None + + def test_returns_entity_when_valid(self): + from custom_components.cover_time_based.helpers import resolve_entity_or_none + from custom_components.cover_time_based.cover import _create_cover_from_options + + entity = _create_cover_from_options( + { + "control_mode": "switch", + "open_switch_entity_id": "switch.open", + "close_switch_entity_id": "switch.close", + "travel_time_close": 30, + "travel_time_open": 30, + }, + device_id="test", + name="Test", + ) + component = MagicMock() + component.get_entity.return_value = entity + hass = MagicMock() + hass.data = {"entity_components": {"cover": component}} + + result = resolve_entity_or_none(hass, "cover.test") + assert result is entity + + +# --------------------------------------------------------------------------- +# async_register_websocket_api +# --------------------------------------------------------------------------- + + +class TestRegistration: + """Test that commands are registered correctly.""" + + def test_registers_both_commands(self): + hass = MagicMock() + with patch( + "custom_components.cover_time_based.websocket_api.websocket_api.async_register_command" + ) as mock_register: + async_register_websocket_api(hass) + + assert mock_register.call_count == 5 + registered_fns = {call[0][1] for call in mock_register.call_args_list} + assert ws_get_config in registered_fns + assert ws_update_config in registered_fns + assert ws_start_calibration in registered_fns + assert ws_stop_calibration in registered_fns + assert ws_raw_command in registered_fns