diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a7654b2
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,48 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.12", "3.13"]
+ 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: |
+ pip install homeassistant
+ pip install pytest pytest-asyncio pytest-homeassistant-custom-component
+
+ - 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: pip install ruff
+
+ - name: Check formatting
+ run: ruff format --check .
+
+ - name: Check linting
+ run: ruff check .
diff --git a/.gitignore b/.gitignore
index 0e008a5..00ba51d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,4 @@
__pycache__/
-*.iml
\ No newline at end of file
+*.iml.coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f95a53..1aa2a88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,30 @@
+## Unreleased
+
+### Features
+
+- **External state monitoring:** Detects physical switch presses and keeps the position tracker in sync with actual motor state. Supports all input modes (switch, pulse, toggle) 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.
+- **Tilt switch monitoring:** Monitors tilt switch entities for external changes in all modes (pulse, switch, toggle).
+- **Translatable frontend card:** All card strings externalized to `strings.json` with `_t()` helper. Translations for English, Portuguese, and Polish included.
+- **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)
+- 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% (478 tests)
+
+### Bug Fixes
+
+- 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..18f0457 100644
--- a/README.md
+++ b/README.md
@@ -11,18 +11,21 @@ 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.
+- **Control the tilt of your cover based on time** with three tilt modes: inline, sequential (closes then tilts), or separate tilt motor.
+- **Synchronized movement:** Travel and tilt move proportionally on the same motor (inline mode).
+- **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.
+- **External state monitoring:** Detects physical switch presses and keeps the position tracker in sync.
+- **Built-in calibration:** Measure timing parameters directly from the UI.
+- **Translatable UI:** All card strings are translatable. English, Portuguese, and Polish included.
- **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._
+- **Motor startup compensation:** Optional delay compensation for motor inertia to improve position accuracy for travel and tilt.
## Install
@@ -41,7 +44,22 @@ Click here:
## Setup
-### Example configuration.yaml entry
+### Creating a cover via the UI
+
+1. Go to **Settings → Devices & Services → Helpers**
+2. Click **Create Helper → Cover Time Based**
+3. Enter a name for your cover
+4. Add the **Cover Time Based** card to a Lovelace dashboard
+5. Use the card to configure and calibrate all settings: device type, input entities, timing, tilt, and more
+
+The configuration card provides a visual interface for all settings and supports built-in calibration to measure timing parameters automatically.
+
+### 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:
@@ -64,174 +82,193 @@ cover:
tilt_startup_delay: 0.08
```
-#### Configuration with shared defaults:
-```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
+#### YAML options
- 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
-```
+| 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 |
-### 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.
+
-**How it works:**
+## Device types
-- 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
+### Switch-based covers
-**Priority order:**
+Control a cover using two relay switches (one for open, one for close), with an optional stop switch.
-1. Device-specific value (highest priority)
-2. Defaults value
-3. Schema default (lowest priority)
+Three input modes are available:
-### Synchronized Travel and Tilt
+| Mode | Description |
+| ---------- | ---------------------------------------------------------------------------------------------- |
+| **Switch** | Latching relays. The direction switch stays ON for the entire movement. Default mode. |
+| **Pulse** | Momentary pulse buttons. The switch is pulsed ON briefly, then turned OFF. The motor controller latches internally. |
+| **Toggle** | Toggle-style relays. A second pulse on the same direction button stops the motor. |
-When both `tilting_time_down/up` are configured, the integration simulates realistic blind behavior:
+### Wrapped covers
-- **Travel movements** always adjust tilt proportionally
-- **Tilt movements** affect travel only when `travel_moves_with_tilt: true`
-- Movements are time-synchronized and stop simultaneously
+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.
-**Example:** With `travelling_time=10s` and `tilting_time=5s`, moving travel 50% changes tilt 100%.
+## Configuration options
-### Travel Moves With Tilt (travel_moves_with_tilt)
+All settings are available through the configuration card. Here is the full reference:
-Controls whether tilt adjustments cause proportional travel movement.
+### Timing
-- **`false` (default):** Only travel movements affect tilt. Tilt can be adjusted independently.
-- **`true`:** Both travel and tilt movements are synchronized on the same motor.
+| 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 | |
+| 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 |
+| Travel startup delay | Motor startup compensation for travel (see below) | None |
-```yaml
-travel_moves_with_tilt: true
-```
+### Tilt
-### Automatic Position Constraints
+| Option | Description | Default |
+| -------------------------- | ----------------------------------------------------------------------------------- | ------- |
+| Tilt mode | `none`, `inline`, `sequential`, or `dual_motor` (see below) | none |
+| 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 |
+| Safe tilt position | Tilt position required before travel moves (dual_motor only) | 100 |
+| Max tilt allowed position | Cover must be at or below this position to tilt (dual_motor only) | None |
-At endpoint positions, tilt is automatically constrained to prevent drift:
+### Pulse/Toggle mode
-- **At 0% (fully open):** Tilt is set to 0% (horizontal)
-- **At 100% (fully closed):** Tilt is set to 100% (vertical)
+| Option | Description | Default |
+| ---------- | ---------------------------------------------------- | ------- |
+| Pulse time | Duration in seconds for the momentary button pulse | 1.0 |
-### Endpoint Delay (travel_delay_at_end)
+## Advanced features
-For covers with mechanical endstops, keeps the relay active for additional time after reaching endpoints to reset position.
+### Tilt modes
-```yaml
-travel_delay_at_end: 2.0
-```
+The **tilt mode** setting controls how tilt and travel interact:
-Recommended values: 1.0 - 3.0 seconds
+- **None:** Tilt is disabled. Only position tracking is used.
+- **Inline:** Tilt and travel are synchronized on the same motor. Both tilt and travel movements affect each other proportionally.
+- **Sequential (closes then tilts):** Tilt is independent of travel. The cover fully closes before the slats start tilting. Travel commands adjust tilt proportionally, but tilt commands do not move travel.
+- **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).
-### Minimum Movement Time (min_movement_time)
+### Synchronized travel and tilt (inline mode)
-Prevents position drift by blocking relay activations too brief to physically move the cover. Movements to 0% or 100% are always allowed.
+In inline mode, travel and tilt are synchronized. Travel movements always adjust tilt proportionally, and tilt movements also cause proportional travel changes.
-```yaml
-min_movement_time: 0.5
-```
+**Example:** With `travel_time=10s` and `tilt_time=5s`, moving travel 50% changes tilt 100%.
+
+### Separate tilt motor (dual_motor mode)
+
+For covers with a dedicated tilt motor, configure:
-Recommended values: 0.5 - 1.5 seconds
+- **Tilt open/close/stop switches:** The relay switches controlling the tilt motor.
+- **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).
-### Motor Startup Delay (travel_startup_delay, tilt_startup_delay)
+### External state monitoring
-Optional feature to compensate for **motor inertia** by delaying position tracking after relay activation. This improves position accuracy, especially for short movements.
+The integration monitors the underlying switch entities for external changes (e.g., physical button presses, other automations). When an external state change is detected, the position tracker is updated to stay in sync with the actual motor.
-**The problem:**
+Each input mode handles external changes differently:
-- 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
+- **Switch (latching):** ON = motor running, OFF = motor stopped.
+- **Pulse (momentary):** A complete ON→OFF pulse represents one press.
+- **Toggle:** Both ON→OFF and OFF→ON transitions are treated as toggle presses, with debounce to handle momentary switches.
+
+### Automatic position constraints
+
+At endpoint positions, tilt is automatically constrained to prevent drift:
+
+- **At 0% (fully closed):** Tilt is set to 0% (fully closed)
+- **At 100% (fully open):** Tilt is set to 100% (fully open)
+
+### Endpoint run-on time
+
+For covers with mechanical endstops, keeps the relay active for additional time after reaching endpoints to reset position. Recommended values: 1.0 - 3.0 seconds.
+
+### Minimum movement time
+
+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.
+
+### Motor 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)
-**Example:**
+Recommended values: 0.05 - 0.15 seconds. Can be configured separately for travel and tilt.
-```yaml
-travel_startup_delay: 0.1 # 100ms startup delay for travel
-tilt_startup_delay: 0.08 # 80ms startup delay for tilt
+### Calibration
-# 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%
-```
+The configuration card includes built-in calibration to measure timing parameters automatically. During calibration, the cover moves in a specified direction and you stop it when it reaches the desired endpoint. The measured time is saved directly to the configuration.
+
+Calibratable parameters: `travel_time_close`, `travel_time_open`, `tilt_time_close`, `tilt_time_open`, `travel_startup_delay`, `tilt_startup_delay`, `min_movement_time`.
+
+## 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 |
-**Recommended values:** 0.05 - 0.15 seconds
+### `cover_time_based.stop_calibration`
-**Important notes:**
+Stop an active calibration test and save the result.
-- 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`
+| Field | Description |
+| --------- | -------------------------------------------------- |
+| entity_id | The cover entity |
+| cancel | If `true`, discard the results without saving |
[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..2928af5
--- /dev/null
+++ b/custom_components/cover_time_based/__init__.py
@@ -0,0 +1,58 @@
+"""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 .websocket_api import async_register_websocket_api
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "cover_time_based"
+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..ba57d6d
--- /dev/null
+++ b/custom_components/cover_time_based/calibration.py
@@ -0,0 +1,43 @@
+"""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
+ step_duration: float | None = None
+ last_pulse_duration: float | None = None
+ continuous_start: float | None = None
+ move_command: str | 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..52e05cf
--- /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 = 1
+
+ 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/cover.py b/custom_components/cover_time_based/cover.py
index d982eef..ee01b18 100644
--- a/custom_components/cover_time_based/cover.py
+++ b/custom_components/cover_time_based/cover.py
@@ -1,54 +1,61 @@
"""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 homeassistant.helpers.restore_state import RestoreEntity
-from xknx.devices import TravelStatus, TravelCalculator
+from .cover_base import CoverTimeBased # noqa: F401
_LOGGER = logging.getLogger(__name__)
CONF_DEVICES = "devices"
CONF_DEFAULTS = "defaults"
+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"
+
+# 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_ENDPOINT_RUNON_TIME = "endpoint_runon_time"
+CONF_MIN_MOVEMENT_TIME = "min_movement_time"
+DEFAULT_ENDPOINT_RUNON_TIME = 2.0
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_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_IS_BUTTON = "is_button"
CONF_INPUT_MODE = "input_mode"
CONF_PULSE_TIME = "pulse_time"
@@ -58,6 +65,9 @@
INPUT_MODE_TOGGLE = "toggle"
CONF_COVER_ENTITY_ID = "cover_entity_id"
+CONF_DEVICE_TYPE = "device_type"
+DEVICE_TYPE_SWITCH = "switch"
+DEVICE_TYPE_COVER = "cover"
SERVICE_SET_KNOWN_POSITION = "set_known_position"
SERVICE_SET_KNOWN_TILT_POSITION = "set_known_tilt_position"
@@ -66,16 +76,19 @@
vol.Required(CONF_NAME): cv.string,
}
+CONF_TRAVEL_DELAY_AT_END = "travel_delay_at_end"
+
TRAVEL_TIME_SCHEMA = {
vol.Optional(CONF_TRAVEL_MOVES_WITH_TILT): cv.boolean,
vol.Optional(CONF_TRAVELLING_TIME_DOWN): cv.positive_float,
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 +97,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 +107,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(
{
@@ -146,1188 +163,298 @@
DOMAIN = "cover_time_based"
-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
-
- 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)
-
- 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)
- config.pop(CONF_PULSE_TIME, None)
+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"
+ )
+ platform.async_register_entity_service(
+ SERVICE_SET_KNOWN_TILT_POSITION, TILT_POSITION_SCHEMA, "set_known_tilt_position"
+ )
- 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
+ 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"]),
+ }
+ ),
)
- 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,
+ 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,
)
- devices.append(device)
- return devices
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the cover platform."""
- async_add_entities(devices_from_config(config))
+def _resolve_entity(hass, entity_id):
+ """Resolve an entity_id to a CoverTimeBased instance."""
+ from homeassistant.exceptions import HomeAssistantError
- platform = entity_platform.current_platform.get()
+ 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
- platform.async_register_entity_service(
- SERVICE_SET_KNOWN_POSITION, POSITION_SCHEMA, "set_known_position"
- )
- platform.async_register_entity_service(
- SERVICE_SET_KNOWN_TILT_POSITION, TILT_POSITION_SCHEMA, "set_known_tilt_position"
- )
+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
-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."""
+ if tilt_mode_str == "none":
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
+ has_tilt_times = tilt_time_close is not None and tilt_time_open is not None
+ if not has_tilt_times:
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
+ if tilt_mode_str == "dual_motor":
+ return DualMotorTilt(
+ safe_tilt_position=kwargs.get("safe_tilt_position") or 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
+
+ device_type = options.get(CONF_DEVICE_TYPE, DEVICE_TYPE_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),
+ )
- @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
- )
+ # 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),
+ )
- @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 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())
+ if device_type == DEVICE_TYPE_COVER:
+ return WrappedCoverTimeBased(
+ cover_entity_id=options.get(CONF_COVER_ENTITY_ID, ""),
+ **common,
)
- 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,
+ 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,
+ )
+
+ input_mode = options.get(CONF_INPUT_MODE, INPUT_MODE_SWITCH)
+ pulse_time = options.get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME)
+
+ if input_mode == INPUT_MODE_PULSE:
+ return PulseModeCover(pulse_time=pulse_time, **switch_args)
+ elif input_mode == INPUT_MODE_TOGGLE:
+ return ToggleModeCover(pulse_time=pulse_time, **switch_args)
+ else:
+ return SwitchModeCover(**switch_args)
+
+
+_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_input_mode(device_id, config, defaults):
+ """Resolve input mode from config, handling legacy is_button key."""
+ # Explicit input_mode takes precedence
+ explicit = config.pop(CONF_INPUT_MODE, None) or defaults.get(CONF_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 INPUT_MODE_PULSE
+
+ return INPUT_MODE_SWITCH
+
+
+_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)
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add cover devices."""
+ devices = []
+ defaults = domain_config.get(CONF_DEFAULTS, {})
+
+ _migrate_yaml_keys(defaults)
+
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ name = config.pop(CONF_NAME)
+
+ _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)
+
+ # 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)
+
+ # Input mode (handles is_button deprecation)
+ input_mode = _resolve_input_mode(device_id, config, defaults)
+ pulse_time = _get_value(CONF_PULSE_TIME, config, defaults, DEFAULT_PULSE_TIME)
+ config.pop(CONF_PULSE_TIME, None)
+
+ options[CONF_DEVICE_TYPE] = (
+ DEVICE_TYPE_COVER if cover_entity_id else DEVICE_TYPE_SWITCH
)
- 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()
+ options[CONF_INPUT_MODE] = input_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
+
+ devices.append(
+ _create_cover_from_options(options, device_id=device_id, name=name)
)
+ return devices
+
+
+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])
- 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()
+ platform = entity_platform.current_platform.get()
+ _register_services(platform)
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..6dda884
--- /dev/null
+++ b/custom_components/cover_time_based/cover_base.py
@@ -0,0 +1,1788 @@
+"""Base class for time-based cover entities."""
+
+import asyncio
+import logging
+import time
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+# Configuration constants used by extra_state_attributes.
+# These are also defined in cover.py for YAML/UI config schemas,
+# but we define them here to avoid circular imports.
+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"
+
+
+class CoverTimeBased(CoverEntity, RestoreEntity):
+ 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._triggered_externally = False
+ 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,
+ )
+
+ 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)
+ 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."""
+ 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
+
+ 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
+
+ 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()
+
+ 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
+
+ # 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
+
+ if not was_restoring and not was_pre_stepping:
+ return
+
+ _LOGGER.debug(
+ "_abandon_active_lifecycle :: abandoning %s",
+ "tilt restore" if was_restoring else "tilt 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():
+ _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
+
+ 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."""
+ _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
+
+ 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
+
+ @property
+ def available(self) -> bool:
+ """Return True if the cover is properly configured and available."""
+ return len(self._get_missing_configuration()) == 0
+
+ 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
+
+ @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._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.step_count > 0:
+ attr["calibration_step"] = self._calibration.step_count
+ return attr
+
+ @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 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
+
+ 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]
+ _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):
+ """Close the cover fully."""
+ self._require_configured()
+ _LOGGER.debug("async_close_cover")
+ if self.is_opening:
+ _LOGGER.debug("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()
+ _LOGGER.debug("async_open_cover")
+ if self.is_closing:
+ _LOGGER.debug("async_open_cover :: currently closing, stopping first")
+ await self.async_stop_cover()
+ await self._async_move_to_endpoint(target=100)
+
+ async def _async_move_to_endpoint(self, target):
+ """Move cover 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
+
+ # 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:
+ _LOGGER.debug(
+ "_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:
+ _LOGGER.debug(
+ "_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:
+ return
+
+ relay_was_on = self._cancel_delay_task()
+ if relay_was_on:
+ await self._async_handle_command(SERVICE_STOP_COVER)
+
+ # Distance assumes full travel when position is unknown
+ default_pos = 100 if closing else 0
+ travel_distance = abs(
+ target - (current if current is not None else default_pos)
+ )
+ travel_time = self._require_travel_time(closing)
+ movement_time = (travel_distance / 100.0) * travel_time
+
+ _LOGGER.debug(
+ "_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
+ self._tilt_restore_target = None
+ if self._tilt_strategy is not None:
+ current_pos = self.travel_calc.current_position()
+ current_tilt = self.tilt_calc.current_position()
+ if current_pos is not None and current_tilt is not None:
+ steps = self._tilt_strategy.plan_move_position(
+ target, current_pos, current_tilt
+ )
+ tilt_target = self._extract_coupled_tilt(steps)
+ pre_step_delay = self._calculate_pre_step_delay(steps)
+
+ # 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
+ ):
+ # At endpoints, snap tilt to endpoint after travel
+ restore = target if target in (0, 100) else current_tilt
+ await self._start_tilt_pre_step(
+ tilt_target, target, command, restore
+ )
+ return
+
+ # Dual motor: pre-step skipped (tilt already at safe),
+ # but still need to snap tilt to endpoint after travel
+ 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
+
+ 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_close_cover_tilt(self, **kwargs):
+ """Tilt the cover fully closed."""
+ _LOGGER.debug("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."""
+ _LOGGER.debug("async_open_cover_tilt")
+ await self._async_move_tilt_to_endpoint(target=100)
+
+ 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:
+ _LOGGER.debug(
+ "_async_move_tilt_to_endpoint :: direction change, cancelling startup delay"
+ )
+ self._cancel_startup_delay_task()
+ await self._async_handle_command(SERVICE_STOP_COVER)
+ else:
+ _LOGGER.debug(
+ "_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)
+
+ self._stop_travel_if_traveling()
+
+ current_tilt = self.tilt_calc.current_position()
+ if current_tilt is not None and current_tilt == target:
+ return
+
+ default_pos = 100 if closing else 0
+ tilt_distance = abs(
+ target - (current_tilt if current_tilt is not None else default_pos)
+ )
+ 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
+ if self._tilt_strategy is not None:
+ current_pos = self.travel_calc.current_position()
+ current_tilt = self.tilt_calc.current_position()
+ if current_pos is not None and current_tilt is not None:
+ steps = self._tilt_strategy.plan_move_tilt(
+ target, current_pos, current_tilt
+ )
+ travel_target = self._extract_coupled_travel(steps)
+ pre_step_delay = self._calculate_pre_step_delay(steps)
+
+ _LOGGER.debug(
+ "_async_move_tilt_to_endpoint :: target=%d, tilt_distance=%f%%, movement_time=%fs, travel_pos=%s",
+ target,
+ tilt_distance,
+ movement_time,
+ travel_target if travel_target is not None else "N/A",
+ )
+
+ self._last_command = command
+ 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 async_stop_cover(self, **kwargs):
+ """Turn the device stop."""
+ self._require_configured()
+ _LOGGER.debug("async_stop_cover")
+ 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._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 _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:
+ _LOGGER.debug(
+ "_handle_pre_movement_checks :: startup delay active, skipping"
+ )
+ return False, is_direction_change
+ _LOGGER.debug(
+ "_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
+
+ async def set_position(self, position):
+ """Move cover to a designated position."""
+ await self._abandon_active_lifecycle()
+ current = self.travel_calc.current_position()
+ target = position
+ _LOGGER.debug(
+ "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
+ 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():
+ _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 = 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
+
+ tilt_target = None
+ pre_step_delay = 0.0
+ self._tilt_restore_target = None
+ if self._tilt_strategy is not None:
+ current_tilt = self.tilt_calc.current_position()
+ if current is not None and current_tilt is not None:
+ steps = self._tilt_strategy.plan_move_position(
+ target, current, current_tilt
+ )
+ tilt_target = self._extract_coupled_tilt(steps)
+ pre_step_delay = self._calculate_pre_step_delay(steps)
+
+ # 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
+ ):
+ # At endpoints, snap tilt to endpoint after travel
+ restore = target if target in (0, 100) else current_tilt
+ await self._start_tilt_pre_step(
+ tilt_target, target, command, restore
+ )
+ return
+
+ # Dual motor: pre-step skipped (tilt already at safe),
+ # but still need to snap tilt to endpoint after travel
+ 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
+
+ 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
+ _LOGGER.debug(
+ "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
+ 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)
+ 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 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
+ 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 = self._extract_coupled_travel(steps)
+ pre_step_delay = self._calculate_pre_step_delay(steps)
+
+ if self._is_movement_too_short(
+ movement_time, target, current, "set_tilt_position"
+ ):
+ return
+
+ self._last_command = command
+
+ 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,
+ )
+
+ 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."""
+ 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._tilt_strategy is not None and hasattr(self, "tilt_calc")
+
+ 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()
+
+ if self._tilt_restore_active:
+ _LOGGER.debug("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
+ _LOGGER.debug("auto_stop_if_necessary :: tilt pre-step complete")
+ await self._start_pending_travel()
+ 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 == 0 or current_travel == 100)
+ ):
+ _LOGGER.debug(
+ "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 _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()
+ _LOGGER.debug(
+ "_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
+ self._pending_travel_target = None
+ self._pending_travel_command = None
+
+ _LOGGER.debug(
+ "_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_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:
+ _LOGGER.debug(
+ "_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
+
+ _LOGGER.debug(
+ "_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._last_command = None
+ self.start_auto_updater()
+
+ 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):
+ """Set the cover to a known position (0=closed, 100=open)."""
+ position = kwargs[ATTR_POSITION]
+ self._handle_stop()
+ await self._async_handle_command(SERVICE_STOP_COVER)
+ 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._last_command = None
+
+ 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]
+ await self._async_handle_command(SERVICE_STOP_COVER)
+ self.tilt_calc.set_position(position)
+ self._last_command = 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,
+ )
+
+ if attribute == "travel_startup_delay":
+ 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
+ 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
+
+ _LOGGER.debug(
+ "overhead test: position=%s, direction=%s, travel_time=%.2f",
+ position,
+ move_command,
+ travel_time,
+ )
+
+ step_duration = travel_time / 10
+ 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)
+ )
+
+ async def _run_overhead_steps(self, step_duration, num_steps):
+ """Execute stepped moves then one continuous move for overhead test.
+
+ Phase 1: num_steps stepped moves of step_duration each (with pauses).
+ 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
+
+ try:
+ # Phase 1: Stepped moves
+ for i in range(num_steps):
+ _LOGGER.debug(
+ "overhead step %d/%d: moving for %.2fs",
+ i + 1,
+ num_steps,
+ step_duration,
+ )
+ await self._async_handle_command(move_command)
+ await sleep(step_duration)
+ await self._send_stop()
+ self._calibration.step_count += 1
+ self.async_write_ha_state()
+ 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.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
+ from .calibration import (
+ CALIBRATION_MIN_MOVEMENT_START,
+ CALIBRATION_MIN_MOVEMENT_INCREMENT,
+ CALIBRATION_MIN_MOVEMENT_INITIAL_PAUSE,
+ CALIBRATION_STEP_PAUSE,
+ )
+
+ 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
+
+ await self._async_handle_command(self._calibration.move_command)
+ await sleep(pulse_duration)
+ 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)
+
+ 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"):
+ if attribute == "travel_startup_delay":
+ total_time = self._travel_time_close or self._travel_time_open
+ else:
+ total_time = self._tilting_time_close or self._tilting_time_open
+
+ 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
+ # 8 steps cover 8/10 of travel; remaining is 2/10 = 0.2 * total_time
+ expected_remaining = 0.2 * 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}")
+
+ @staticmethod
+ def _extract_coupled_tilt(steps):
+ """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.
+ """
+ from .tilt_strategies import TiltTo, TravelTo
+
+ 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
+
+ @staticmethod
+ def _extract_coupled_travel(steps):
+ """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.
+ """
+ from .tilt_strategies import TiltTo, TravelTo
+
+ 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 _calculate_pre_step_delay(self, steps) -> 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.
+ """
+ from .tilt_strategies import TiltTo, TravelTo
+
+ if (
+ self._tilt_strategy is None
+ or self._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 = self.tilt_calc.current_position()
+ if current_tilt is None:
+ return 0.0
+ return self.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 = self.travel_calc.current_position()
+ if current_pos is None:
+ return 0.0
+ return self.travel_calc.calculate_travel_time(current_pos, first.target)
+
+ return 0.0
+
+ 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
+ )
+ _LOGGER.debug(
+ "_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:
+ _LOGGER.debug("_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
+
+ _LOGGER.debug(
+ "_async_switch_state_changed :: %s: %s -> %s (pending=%s)",
+ entity_id,
+ old_val,
+ new_val,
+ self._pending_switch.get(entity_id, 0),
+ )
+
+ # 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()
+ _LOGGER.debug(
+ "_async_switch_state_changed :: echo filtered, remaining=%s",
+ self._pending_switch.get(entity_id, 0),
+ )
+ return
+
+ # Tilt switches: pulse-mode (ON→OFF = command complete)
+ if entity_id in (
+ self._tilt_open_switch_id,
+ self._tilt_close_switch_id,
+ self._tilt_stop_switch_id,
+ ):
+ self._triggered_externally = True
+ try:
+ await self._handle_external_tilt_state_change(
+ entity_id, old_val, new_val
+ )
+ finally:
+ self._triggered_externally = False
+ return
+
+ # External state change detected — handle per mode
+ self._triggered_externally = True
+ try:
+ await self._handle_external_state_change(entity_id, old_val, new_val)
+ finally:
+ self._triggered_externally = False
+
+ 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: a complete ON→OFF pulse
+ represents a command. We react on the OFF transition (pulse complete).
+ """
+ if old_val != "on" or new_val != "off":
+ return
+
+ if entity_id == self._tilt_open_switch_id:
+ _LOGGER.debug(
+ "_handle_external_tilt_state_change :: external tilt open pulse detected"
+ )
+ await self.async_open_cover_tilt()
+ elif entity_id == self._tilt_close_switch_id:
+ _LOGGER.debug(
+ "_handle_external_tilt_state_change :: external tilt close pulse detected"
+ )
+ await self.async_close_cover_tilt()
+ elif entity_id == self._tilt_stop_switch_id:
+ _LOGGER.debug(
+ "_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."""
+
+ async def _async_handle_command(self, command, *args):
+ cmd = command
+ if command == SERVICE_CLOSE_COVER:
+ cmd = "DOWN"
+ self._state = False
+ if not self._triggered_externally:
+ await self._send_close()
+ elif command == SERVICE_OPEN_COVER:
+ cmd = "UP"
+ self._state = True
+ 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()
+
+ _LOGGER.debug("_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."""
+
+ # --- Tilt motor raw commands (dual_motor only) ---
+
+ def _has_tilt_motor(self) -> bool:
+ """Return True if tilt motor switches are configured."""
+ return 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)."""
+ 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)."""
+ 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)."""
+ self._mark_switch_pending(self._tilt_open_switch_id, 1)
+ 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,
+ )
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..de411a0
--- /dev/null
+++ b/custom_components/cover_time_based/cover_pulse_mode.py
@@ -0,0 +1,113 @@
+"""Momentary pulse mode cover."""
+
+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
+ internally.
+ """
+
+ def __init__(self, pulse_time, **kwargs):
+ super().__init__(**kwargs)
+ self._pulse_time = pulse_time
+
+ async def _send_open(self) -> None:
+ 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:
+ 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,
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant",
+ "turn_off",
+ {"entity_id": self._open_switch_entity_id},
+ False,
+ )
+
+ async def _send_close(self) -> None:
+ 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:
+ 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,
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant",
+ "turn_off",
+ {"entity_id": self._close_switch_entity_id},
+ False,
+ )
+
+ async def _send_stop(self) -> None:
+ 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_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,
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant",
+ "turn_off",
+ {"entity_id": self._stop_switch_entity_id},
+ False,
+ )
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..af222c4
--- /dev/null
+++ b/custom_components/cover_time_based/cover_switch.py
@@ -0,0 +1,55 @@
+"""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, a physical press produces ON->OFF.
+ We react on the OFF transition (pulse complete).
+
+ Toggle mode overrides this to react to both ON->OFF and OFF->ON,
+ with debounce to handle momentary switches.
+ """
+ if old_val != "on" or new_val != "off":
+ return
+
+ if entity_id == self._open_switch_entity_id:
+ _LOGGER.debug(
+ "_handle_external_state_change :: external open pulse detected"
+ )
+ await self.async_open_cover()
+ elif entity_id == self._close_switch_entity_id:
+ _LOGGER.debug(
+ "_handle_external_state_change :: external close pulse detected"
+ )
+ await self.async_close_cover()
+ elif entity_id == self._stop_switch_entity_id:
+ _LOGGER.debug(
+ "_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..e09fa32
--- /dev/null
+++ b/custom_components/cover_time_based/cover_switch_mode.py
@@ -0,0 +1,152 @@
+"""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:
+ self._mark_switch_pending(self._close_switch_entity_id, 1)
+ self._mark_switch_pending(self._open_switch_entity_id, 1)
+ if self._stop_switch_entity_id is not None:
+ 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,
+ )
+
+ async def _send_close(self) -> None:
+ self._mark_switch_pending(self._open_switch_entity_id, 1)
+ self._mark_switch_pending(self._close_switch_entity_id, 1)
+ if self._stop_switch_entity_id is not None:
+ 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,
+ )
+
+ async def _send_stop(self) -> None:
+ self._mark_switch_pending(self._close_switch_entity_id, 1)
+ self._mark_switch_pending(self._open_switch_entity_id, 1)
+ if self._stop_switch_entity_id is not None:
+ 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_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,
+ )
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..bd7badc
--- /dev/null
+++ b/custom_components/cover_time_based/cover_toggle_mode.py
@@ -0,0 +1,257 @@
+"""Toggle mode cover."""
+
+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.
+ """
+
+ def __init__(self, pulse_time, **kwargs):
+ super().__init__(**kwargs)
+ self._pulse_time = pulse_time
+ self._last_external_toggle_time = {}
+
+ async def _send_open(self) -> None:
+ 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:
+ 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,
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant",
+ "turn_off",
+ {"entity_id": self._open_switch_entity_id},
+ False,
+ )
+
+ async def _send_close(self) -> None:
+ 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:
+ 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,
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant",
+ "turn_off",
+ {"entity_id": self._close_switch_entity_id},
+ False,
+ )
+
+ 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,
+ )
+ 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:
+ 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,
+ )
+ 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("_send_stop :: toggle mode with no last command, skipping")
+
+ async def _handle_external_tilt_state_change(self, entity_id, old_val, new_val):
+ """Handle external tilt state change in toggle mode.
+
+ Same debounce and toggle logic as the main cover handler.
+ If tilt is already moving, treat any toggle as stop.
+ """
+ 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:
+ _LOGGER.debug(
+ "_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():
+ _LOGGER.debug(
+ "_handle_external_tilt_state_change :: tilt open toggle while traveling, stopping"
+ )
+ await self.async_stop_cover()
+ else:
+ _LOGGER.debug(
+ "_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():
+ _LOGGER.debug(
+ "_handle_external_tilt_state_change :: tilt close toggle while traveling, stopping"
+ )
+ await self.async_stop_cover()
+ else:
+ _LOGGER.debug(
+ "_handle_external_tilt_state_change :: external tilt close toggle detected"
+ )
+ await self.async_close_cover_tilt()
+
+ async def _handle_external_state_change(self, entity_id, old_val, new_val):
+ """Handle external state change in toggle mode.
+
+ In toggle mode, each switch transition toggles the motor.
+ We react to both OFF->ON and ON->OFF (unlike pulse mode which
+ only reacts to ON->OFF), since the user's switch may be latching
+ (alternates ON/OFF on each click).
+
+ A debounce prevents double-triggering for momentary switches that
+ produce OFF->ON->OFF per click. The debounce window is pulse_time + 0.5s
+ to account for switches that stay ON for approximately pulse_time before
+ auto-resetting.
+ """
+ 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:
+ _LOGGER.debug(
+ "_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:
+ _LOGGER.debug(
+ "_handle_external_state_change :: external open toggle detected"
+ )
+ await self.async_open_cover()
+ elif entity_id == self._close_switch_entity_id:
+ _LOGGER.debug(
+ "_handle_external_state_change :: external close toggle detected"
+ )
+ await self.async_close_cover()
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover; if already closing, treat as stop.
+
+ For external triggers: any movement (opening OR closing) -> stop.
+ The physical motor already stopped when the user pressed the button.
+ For HA UI: same direction -> stop, opposite direction -> reverse (base class).
+ """
+ if self.is_closing:
+ await self.async_stop_cover()
+ return
+ if self._triggered_externally and self.is_opening:
+ _LOGGER.debug(
+ "async_close_cover :: external close while opening, treating as stop"
+ )
+ await self.async_stop_cover()
+ return
+ await super().async_close_cover(**kwargs)
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover; if already opening, treat as stop.
+
+ For external triggers: any movement (opening OR closing) -> stop.
+ The physical motor already stopped when the user pressed the button.
+ For HA UI: same direction -> stop, opposite direction -> reverse (base class).
+ """
+ if self.is_opening:
+ await self.async_stop_cover()
+ return
+ if self._triggered_externally and self.is_closing:
+ _LOGGER.debug(
+ "async_open_cover :: external open while closing, treating as stop"
+ )
+ await self.async_stop_cover()
+ return
+ await super().async_open_cover(**kwargs)
+
+ 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())
+ )
+ 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()
+ self.async_write_ha_state()
+ self._last_command = 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..9d5ffd2
--- /dev/null
+++ b/custom_components/cover_time_based/cover_wrapped.py
@@ -0,0 +1,78 @@
+"""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:
+ _LOGGER.debug("_handle_external_state_change :: wrapped cover opening")
+ await self.async_open_cover()
+ elif new_val == _CLOSING:
+ _LOGGER.debug("_handle_external_state_change :: wrapped cover closing")
+ await self.async_close_cover()
+ elif old_val in _MOVING_STATES:
+ # Was moving, now stopped
+ _LOGGER.debug("_handle_external_state_change :: wrapped cover stopped")
+ await self.async_stop_cover()
+
+ async def _send_open(self) -> None:
+ self._mark_switch_pending(self._cover_entity_id, 1)
+ await self.hass.services.async_call(
+ "cover", "open_cover", {"entity_id": self._cover_entity_id}, False
+ )
+
+ async def _send_close(self) -> None:
+ self._mark_switch_pending(self._cover_entity_id, 1)
+ 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
+ )
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..073d9da
--- /dev/null
+++ b/custom_components/cover_time_based/frontend/cover-time-based-card.js
@@ -0,0 +1,1599 @@
+/**
+ * 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 via strings.json / translations/.
+ */
+
+import {
+ LitElement,
+ html,
+ css,
+} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
+
+const DOMAIN = "cover_time_based";
+
+// English fallback strings — keys match the flattened paths in strings.json "card" section.
+// When translations load successfully, they override these via _t().
+const EN = {
+ "header": "Cover Time Based Configuration",
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "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",
+ "device_type.label": "Device Type",
+ "device_type.switch": "Control via switches",
+ "device_type.cover": "Wrap an existing cover entity",
+ "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 (optional)",
+ "input_mode.label": "Input Mode",
+ "input_mode.switch": "Switch (latching)",
+ "input_mode.pulse": "Pulse (momentary)",
+ "input_mode.toggle": "Toggle (same button)",
+ "input_mode.pulse_time": "Pulse time",
+ "endpoint_runon.label": "Endpoint Run-on Time",
+ "endpoint_runon.helper": "Additional travel time at endpoints (0%/100%) for position reset",
+ "tilt.label": "Tilting",
+ "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 (optional)",
+ "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.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",
+ "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.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.",
+};
+
+// 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 },
+ };
+ }
+
+ constructor() {
+ super();
+ this._selectedEntity = "";
+ this._config = null;
+ this._loading = false;
+ this._saving = false;
+ this._activeTab = "device";
+ this._knownPosition = "unknown";
+ this._helpersLoaded = false;
+ this._translations = null;
+ this._translationsLoaded = false;
+ }
+
+ // --- Translation support ---
+
+ _t(key, replacements) {
+ let str;
+ if (this._translations) {
+ const fullKey = `component.${DOMAIN}.card.${key}`;
+ str = this._translations[fullKey];
+ }
+ if (!str) str = EN[key] || key;
+ if (replacements) {
+ for (const [k, v] of Object.entries(replacements)) {
+ str = str.replace(`{${k}}`, v);
+ }
+ }
+ return str;
+ }
+
+ async _loadTranslations() {
+ if (this._translationsLoaded || !this.hass) return;
+ this._translationsLoaded = true;
+ try {
+ const result = await this.hass.callWS({
+ type: "frontend/get_translations",
+ language: this.hass.language,
+ category: "card",
+ integration: DOMAIN,
+ });
+ if (result?.resources) {
+ this._translations = result.resources;
+ this.requestUpdate();
+ }
+ } catch (_) {
+ // Fallback to EN defaults — card remains fully functional
+ }
+ }
+
+ // --- 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();
+ this._loadTranslations();
+ }
+
+ updated(changedProperties) {
+ if (changedProperties.has("hass") && this.hass && !this._translationsLoaded) {
+ this._loadTranslations();
+ }
+ }
+
+ 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;
+ try {
+ const { entry_id, ...fields } = this._config;
+ await this.hass.callWS({
+ type: "cover_time_based/update_config",
+ entity_id: this._selectedEntity,
+ ...fields,
+ });
+ } catch (err) {
+ console.error("Failed to save config:", err);
+ }
+ 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.device_type === "cover") return !!c.cover_entity_id;
+ return !!c.open_switch_entity_id && !!c.close_switch_entity_id;
+ }
+
+ // --- Event handlers ---
+
+ _onEntityChange(e) {
+ const newValue = e.detail?.value || e.target?.value || "";
+ this._selectedEntity = newValue;
+ this._config = null;
+ if (this._selectedEntity) {
+ this._loadConfig();
+ }
+ }
+
+ _onDeviceTypeChange(e) {
+ this._updateLocal({ device_type: e.target.value });
+ }
+
+ _onInputModeChange(e) {
+ this._updateLocal({ input_mode: e.target.value });
+ }
+
+ _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 savedPosition = this._knownPosition;
+
+ const data = {
+ entity_id: this._selectedEntity,
+ attribute: attrSelect.value,
+ timeout: 300,
+ };
+
+ if (savedPosition === "open") {
+ data.direction = "close";
+ } else if (savedPosition === "closed" || savedPosition === "closed_tilt_open" || savedPosition === "closed_tilt_closed") {
+ data.direction = "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);
+ }
+ }
+
+ 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",
+ };
+ 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._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 === "timing" && !(this._config?.tilt_open_switch && this._config?.tilt_close_switch) ? html`
+
+ this._onCoverCommand("open_cover")}>
+
+
+ this._onCoverCommand("stop_cover")}>
+
+
+ this._onCoverCommand("close_cover")}>
+
+
+
+ ` : ""}
+
+ ${this._activeTab === "timing" && this._config?.tilt_open_switch && this._config?.tilt_close_switch ? 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")}>
+
+
+
+
+ ` : ""}
+
+
+
+
+
+
+
+ ${this._activeTab === "device"
+ ? html`
+
+ `
+ : html`
+ ${calibrating ? "" : this._renderPositionReset()}
+ ${this._renderCalibration(calibrating)}
+ ${this._renderTimingTable(c)}
+ `}
+
+ ${this._saving
+ ? html`${this._t("saving")}
`
+ : ""}
+ `;
+ }
+
+ _renderDeviceType(c) {
+ return html`
+
+
${this._t("device_type.label")}
+
+
+ `;
+ }
+
+ _renderInputEntities(c) {
+ if (c.device_type === "cover") {
+ return html`
+
+
+
+ `;
+ }
+
+ return html`
+
+
${this._t("entities.switch_entities")}
+
+
+ this._onSwitchEntityChange("open_switch_entity_id", e)}
+ >
+
+ this._onSwitchEntityChange("close_switch_entity_id", e)}
+ >
+
+ this._onSwitchEntityChange("stop_switch_entity_id", e)}
+ >
+
+
+ `;
+ }
+
+ _renderInputMode(c) {
+ const showPulseTime =
+ c.input_mode === "pulse" || c.input_mode === "toggle";
+
+ return html`
+
+
${this._t("input_mode.label")}
+
+ ${showPulseTime
+ ? html`
+
+
+
+ `
+ : ""}
+
+ `;
+ }
+
+ _renderEndpointRunon(c) {
+ return html`
+
+
${this._t("endpoint_runon.label")}
+
+ ${this._t("endpoint_runon.helper")}
+
+
{
+ const v = e.target.value.trim();
+ this._updateLocal({ endpoint_runon_time: v === "" ? null : parseFloat(v) });
+ }}
+ >
+
+ `;
+ }
+
+ _renderTiltSupport(c) {
+ 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")}
+
+
+ this._onSwitchEntityChange("tilt_open_switch", e)}
+ >
+
+ this._onSwitchEntityChange("tilt_close_switch", e)}
+ >
+
+ 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),
+ });
+ }}
+ >
+
+
+ `;
+ }
+
+ _renderTimingTable(c) {
+ const hasTiltTimes = c.tilt_mode === "sequential" || c.tilt_mode === "dual_motor" || c.tilt_mode === "inline";
+
+ const rows = [
+ ["timing.travel_time_close", "travel_time_close", c.travel_time_close],
+ ["timing.travel_time_open", "travel_time_open", c.travel_time_open],
+ ["timing.travel_startup_delay", "travel_startup_delay", c.travel_startup_delay],
+ ];
+
+ if (hasTiltTimes) {
+ rows.push(
+ ["timing.tilt_time_close", "tilt_time_close", c.tilt_time_close],
+ ["timing.tilt_time_open", "tilt_time_open", c.tilt_time_open],
+ ["timing.tilt_startup_delay", "tilt_startup_delay", c.tilt_startup_delay]
+ );
+ }
+
+ rows.push(["timing.min_movement_time", "min_movement_time", c.min_movement_time]);
+
+ return html`
+
+ `;
+ }
+
+ _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")}
+
+
+
+ `;
+ }
+
+ _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")}
+
+
+
+ `;
+ }
+
+ return html`
+
+
${this._t("calibration.label")}
+
+ ${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;
+ justify-content: flex-end;
+ gap: 12px;
+ padding: 4px 0;
+ }
+
+ .cover-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 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;
+ font-size: var(--paper-font-body1_-_font-size, 14px);
+ }
+
+ .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-status {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+ padding: 8px 0;
+ font-size: var(--paper-font-body1_-_font-size, 14px);
+ }
+
+ .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;
+ }
+
+ .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/manifest.json b/custom_components/cover_time_based/manifest.json
index b2e010e..3ac0621 100644
--- a/custom_components/cover_time_based/manifest.json
+++ b/custom_components/cover_time_based/manifest.json
@@ -2,12 +2,12 @@
"domain": "cover_time_based",
"name": "Cover Time Based",
"codeowners": ["@Sese-Schneider"],
+ "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"
- ],
+ "requirements": [],
"version": "3.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..3de4c25 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,164 @@
"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"
}
}
}
+ },
+ "card": {
+ "header": "Cover Time Based Configuration",
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "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 > Devices & Services > Helpers > Create Helper > Cover Time Based.",
+ "tabs": {
+ "device": "Device",
+ "calibration": "Calibration"
+ },
+ "device_type": {
+ "label": "Device Type",
+ "switch": "Control via switches",
+ "cover": "Wrap an existing cover entity"
+ },
+ "entities": {
+ "cover_entity": "Cover Entity",
+ "switch_entities": "Switch Entities",
+ "open_switch": "Open switch",
+ "close_switch": "Close switch",
+ "stop_switch": "Stop switch (optional)"
+ },
+ "input_mode": {
+ "label": "Input Mode",
+ "switch": "Switch (latching)",
+ "pulse": "Pulse (momentary)",
+ "toggle": "Toggle (same button)",
+ "pulse_time": "Pulse time"
+ },
+ "endpoint_runon": {
+ "label": "Endpoint Run-on Time",
+ "helper": "Additional travel time at endpoints (0%/100%) for position reset"
+ },
+ "tilt": {
+ "label": "Tilting",
+ "none": "Not supported",
+ "sequential": "Closes then tilts",
+ "dual_motor": "Separate tilt motor",
+ "inline": "Tilts inline with travel"
+ },
+ "tilt_motor": {
+ "label": "Tilt Motor",
+ "open_switch": "Tilt open switch",
+ "close_switch": "Tilt close switch",
+ "stop_switch": "Tilt stop switch (optional)",
+ "safe_position": "Safe tilt position",
+ "safe_position_helper": "Tilt moves here before travel (100 = fully open)",
+ "max_allowed_position": "Max tilt allowed position (optional)",
+ "max_allowed_helper": "Tilt only allowed when cover position is at or below this value (0 = closed, 100 = open)"
+ },
+ "timing": {
+ "attribute_header": "Attribute",
+ "value_header": "Value",
+ "not_set": "Not set",
+ "travel_time_close": "Travel time (close)",
+ "travel_time_open": "Travel time (open)",
+ "travel_startup_delay": "Travel startup delay",
+ "tilt_time_close": "Tilt time (close)",
+ "tilt_time_open": "Tilt time (open)",
+ "tilt_startup_delay": "Tilt startup delay",
+ "min_movement_time": "Minimum movement time"
+ },
+ "position": {
+ "label": "Current Position",
+ "helper": "Move cover to a known endpoint, then set position.",
+ "unknown": "Unknown",
+ "open": "Fully open",
+ "closed": "Fully closed",
+ "closed_tilt_open": "Fully closed, tilt open",
+ "closed_tilt_closed": "Fully closed, tilt closed"
+ },
+ "calibration": {
+ "label": "Timing Calibration",
+ "attribute_label": "Attribute",
+ "start": "Start",
+ "active": "Calibration Active",
+ "step": "Step {step}",
+ "cancel": "Cancel",
+ "finish": "Finish",
+ "set_position_first": "Set position to start calibration."
+ },
+ "controls": {
+ "cover_label": "Cover",
+ "tilt_label": "Tilt",
+ "open": "Open",
+ "stop": "Stop",
+ "close": "Close",
+ "tilt_open": "Tilt open",
+ "tilt_stop": "Tilt stop",
+ "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.",
+ "travel_time_open": "Start with cover closed and slats open. Click Finish when the cover is fully open.",
+ "tilt_time_close": "Start with cover closed but slats open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with cover and slats closed. Click Finish when the slats are open."
+ },
+ "dual_motor": {
+ "travel_time_close": "Start with cover open and slats in safe position. Click Finish when the cover is fully closed.",
+ "travel_time_open": "Start with cover closed and slats in safe position. Click Finish when the cover is fully open.",
+ "tilt_time_close": "Start with cover closed and slats open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with both cover and slats closed. Click Finish when the slats are fully open."
+ },
+ "inline": {
+ "travel_time_close": "Start with both cover and slats fully open. Click Finish when both are fully closed.",
+ "travel_time_open": "Start with both cover and slats fully closed. Click Finish when both are fully open.",
+ "tilt_time_close": "Start with slats fully open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with slats fully closed. Click Finish when the slats are fully open."
+ },
+ "none": {
+ "travel_time_close": "Click Finish when the cover is fully closed.",
+ "travel_time_open": "Click Finish when the cover is fully open."
+ },
+ "min_movement_time": "Click Finish as soon as you notice the cover moving."
+ }
}
-}
\ 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..c20faf7
--- /dev/null
+++ b/custom_components/cover_time_based/tilt_strategies/__init__.py
@@ -0,0 +1,19 @@
+"""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 .sequential import SequentialTilt
+
+__all__ = [
+ "DualMotorTilt",
+ "InlineTilt",
+ "MovementStep",
+ "SequentialTilt",
+ "TiltStrategy",
+ "TiltTo",
+ "TravelTo",
+]
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..1d66a68
--- /dev/null
+++ b/custom_components/cover_time_based/tilt_strategies/base.py
@@ -0,0 +1,81 @@
+"""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."""
+
+ @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..a7cafb9
--- /dev/null
+++ b/custom_components/cover_time_based/tilt_strategies/dual_motor.py
@@ -0,0 +1,91 @@
+"""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
+
+ @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 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)
+
+ def can_calibrate_tilt(self) -> bool:
+ return True
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/sequential.py b/custom_components/cover_time_based/tilt_strategies/sequential.py
new file mode 100644
index 0000000..f41fd5d
--- /dev/null
+++ b/custom_components/cover_time_based/tilt_strategies/sequential.py
@@ -0,0 +1,68 @@
+"""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 != 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..3de4c25 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,164 @@
"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"
}
}
}
+ },
+ "card": {
+ "header": "Cover Time Based Configuration",
+ "loading": "Loading...",
+ "saving": "Saving...",
+ "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 > Devices & Services > Helpers > Create Helper > Cover Time Based.",
+ "tabs": {
+ "device": "Device",
+ "calibration": "Calibration"
+ },
+ "device_type": {
+ "label": "Device Type",
+ "switch": "Control via switches",
+ "cover": "Wrap an existing cover entity"
+ },
+ "entities": {
+ "cover_entity": "Cover Entity",
+ "switch_entities": "Switch Entities",
+ "open_switch": "Open switch",
+ "close_switch": "Close switch",
+ "stop_switch": "Stop switch (optional)"
+ },
+ "input_mode": {
+ "label": "Input Mode",
+ "switch": "Switch (latching)",
+ "pulse": "Pulse (momentary)",
+ "toggle": "Toggle (same button)",
+ "pulse_time": "Pulse time"
+ },
+ "endpoint_runon": {
+ "label": "Endpoint Run-on Time",
+ "helper": "Additional travel time at endpoints (0%/100%) for position reset"
+ },
+ "tilt": {
+ "label": "Tilting",
+ "none": "Not supported",
+ "sequential": "Closes then tilts",
+ "dual_motor": "Separate tilt motor",
+ "inline": "Tilts inline with travel"
+ },
+ "tilt_motor": {
+ "label": "Tilt Motor",
+ "open_switch": "Tilt open switch",
+ "close_switch": "Tilt close switch",
+ "stop_switch": "Tilt stop switch (optional)",
+ "safe_position": "Safe tilt position",
+ "safe_position_helper": "Tilt moves here before travel (100 = fully open)",
+ "max_allowed_position": "Max tilt allowed position (optional)",
+ "max_allowed_helper": "Tilt only allowed when cover position is at or below this value (0 = closed, 100 = open)"
+ },
+ "timing": {
+ "attribute_header": "Attribute",
+ "value_header": "Value",
+ "not_set": "Not set",
+ "travel_time_close": "Travel time (close)",
+ "travel_time_open": "Travel time (open)",
+ "travel_startup_delay": "Travel startup delay",
+ "tilt_time_close": "Tilt time (close)",
+ "tilt_time_open": "Tilt time (open)",
+ "tilt_startup_delay": "Tilt startup delay",
+ "min_movement_time": "Minimum movement time"
+ },
+ "position": {
+ "label": "Current Position",
+ "helper": "Move cover to a known endpoint, then set position.",
+ "unknown": "Unknown",
+ "open": "Fully open",
+ "closed": "Fully closed",
+ "closed_tilt_open": "Fully closed, tilt open",
+ "closed_tilt_closed": "Fully closed, tilt closed"
+ },
+ "calibration": {
+ "label": "Timing Calibration",
+ "attribute_label": "Attribute",
+ "start": "Start",
+ "active": "Calibration Active",
+ "step": "Step {step}",
+ "cancel": "Cancel",
+ "finish": "Finish",
+ "set_position_first": "Set position to start calibration."
+ },
+ "controls": {
+ "cover_label": "Cover",
+ "tilt_label": "Tilt",
+ "open": "Open",
+ "stop": "Stop",
+ "close": "Close",
+ "tilt_open": "Tilt open",
+ "tilt_stop": "Tilt stop",
+ "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.",
+ "travel_time_open": "Start with cover closed and slats open. Click Finish when the cover is fully open.",
+ "tilt_time_close": "Start with cover closed but slats open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with cover and slats closed. Click Finish when the slats are open."
+ },
+ "dual_motor": {
+ "travel_time_close": "Start with cover open and slats in safe position. Click Finish when the cover is fully closed.",
+ "travel_time_open": "Start with cover closed and slats in safe position. Click Finish when the cover is fully open.",
+ "tilt_time_close": "Start with cover closed and slats open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with both cover and slats closed. Click Finish when the slats are fully open."
+ },
+ "inline": {
+ "travel_time_close": "Start with both cover and slats fully open. Click Finish when both are fully closed.",
+ "travel_time_open": "Start with both cover and slats fully closed. Click Finish when both are fully open.",
+ "tilt_time_close": "Start with slats fully open. Click Finish when the slats are fully closed.",
+ "tilt_time_open": "Start with slats fully closed. Click Finish when the slats are fully open."
+ },
+ "none": {
+ "travel_time_close": "Click Finish when the cover is fully closed.",
+ "travel_time_open": "Click Finish when the cover is fully open."
+ },
+ "min_movement_time": "Click Finish as soon as you notice the cover moving."
+ }
}
-}
\ 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..7ad5f28 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,160 @@
"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"
+ }
+ }
+ }
+ },
+ "card": {
+ "header": "Konfiguracja rolet sterowanych czasowo",
+ "loading": "Ładowanie...",
+ "saving": "Zapisywanie...",
+ "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",
+ "calibration": "Kalibracja"
+ },
+ "device_type": {
+ "label": "Typ urządzenia",
+ "switch": "Sterowanie przez przełączniki",
+ "cover": "Opakuj istniejącą encję rolety"
+ },
+ "entities": {
+ "cover_entity": "Encja rolety",
+ "switch_entities": "Encje przełączników",
+ "open_switch": "Przełącznik otwierania",
+ "close_switch": "Przełącznik zamykania",
+ "stop_switch": "Przełącznik zatrzymania (opcjonalny)"
+ },
+ "input_mode": {
+ "label": "Tryb wejścia",
+ "switch": "Przełącznik (zatrzaskowy)",
+ "pulse": "Impuls (chwilowy)",
+ "toggle": "Przełączanie (ten sam przycisk)",
+ "pulse_time": "Czas impulsu"
+ },
+ "endpoint_runon": {
+ "label": "Czas dobiegu na krańcach",
+ "helper": "Dodatkowy czas ruchu na krańcach (0%/100%) do resetowania pozycji"
+ },
+ "tilt": {
+ "label": "Nachylenie",
+ "none": "Nieobsługiwane",
+ "sequential": "Najpierw zamyka, potem nachyla",
+ "dual_motor": "Osobny silnik nachylenia",
+ "inline": "Nachylenie w trakcie ruchu"
+ },
+ "tilt_motor": {
+ "label": "Silnik nachylenia",
+ "open_switch": "Przełącznik otwierania nachylenia",
+ "close_switch": "Przełącznik zamykania nachylenia",
+ "stop_switch": "Przełącznik zatrzymania nachylenia (opcjonalny)",
+ "safe_position": "Bezpieczna pozycja nachylenia",
+ "safe_position_helper": "Nachylenie przesuwa się tu przed ruchem (100 = w pełni otwarte)",
+ "max_allowed_position": "Maks. dozwolona pozycja nachylenia (opcjonalna)",
+ "max_allowed_helper": "Nachylenie dozwolone tylko gdy pozycja rolety wynosi tyle lub mniej (0 = zamknięta, 100 = otwarta)"
+ },
+ "timing": {
+ "attribute_header": "Atrybut",
+ "value_header": "Wartość",
+ "not_set": "Nieustawione",
+ "travel_time_close": "Czas ruchu (zamykanie)",
+ "travel_time_open": "Czas ruchu (otwieranie)",
+ "travel_startup_delay": "Opóźnienie startu ruchu",
+ "tilt_time_close": "Czas nachylenia (zamykanie)",
+ "tilt_time_open": "Czas nachylenia (otwieranie)",
+ "tilt_startup_delay": "Opóźnienie startu nachylenia",
+ "min_movement_time": "Minimalny czas ruchu"
+ },
+ "position": {
+ "label": "Aktualna pozycja",
+ "helper": "Przesuń roletę do znanego krańca, a następnie ustaw pozycję.",
+ "unknown": "Nieznana",
+ "open": "W pełni otwarta",
+ "closed": "W pełni zamknięta",
+ "closed_tilt_open": "W pełni zamknięta, nachylenie otwarte",
+ "closed_tilt_closed": "W pełni zamknięta, nachylenie zamknięte"
+ },
+ "calibration": {
+ "label": "Kalibracja czasowa",
+ "attribute_label": "Atrybut",
+ "start": "Rozpocznij",
+ "active": "Kalibracja aktywna",
+ "step": "Krok {step}",
+ "cancel": "Anuluj",
+ "finish": "Zakończ",
+ "set_position_first": "Ustaw pozycję, aby rozpocząć kalibrację."
+ },
+ "controls": {
+ "cover_label": "Roleta",
+ "tilt_label": "Nachylenie",
+ "open": "Otwórz",
+ "stop": "Zatrzymaj",
+ "close": "Zamknij",
+ "tilt_open": "Otwórz nachylenie",
+ "tilt_stop": "Zatrzymaj nachylenie",
+ "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ć.",
+ "travel_time_open": "Zacznij z zamkniętą roletą i otwartymi listwami. Kliknij Zakończ, gdy roleta jest w pełni otwarta.",
+ "tilt_time_close": "Zacznij z zamkniętą roletą, ale otwartymi listwami. Kliknij Zakończ, gdy listwy są w pełni zamknięte.",
+ "tilt_time_open": "Zacznij z zamkniętą roletą i zamkniętymi listwami. Kliknij Zakończ, gdy listwy są otwarte."
+ },
+ "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.",
+ "travel_time_open": "Zacznij z zamkniętą roletą i listwami w bezpiecznej pozycji. Kliknij Zakończ, gdy roleta jest w pełni otwarta.",
+ "tilt_time_close": "Zacznij z zamkniętą roletą i otwartymi listwami. Kliknij Zakończ, gdy listwy są w pełni zamknięte.",
+ "tilt_time_open": "Zacznij z zamkniętą roletą i zamkniętymi listwami. Kliknij Zakończ, gdy listwy są w pełni otwarte."
+ },
+ "inline": {
+ "travel_time_close": "Zacznij z roletą i listwami w pełni otwartymi. Kliknij Zakończ, gdy obie są w pełni zamknięte.",
+ "travel_time_open": "Zacznij z roletą i listwami w pełni zamkniętymi. Kliknij Zakończ, gdy obie są w pełni otwarte.",
+ "tilt_time_close": "Zacznij z listwami w pełni otwartymi. Kliknij Zakończ, gdy listwy są w pełni zamknięte.",
+ "tilt_time_open": "Zacznij z listwami w pełni zamkniętymi. Kliknij Zakończ, gdy listwy są w pełni otwarte."
+ },
+ "none": {
+ "travel_time_close": "Kliknij Zakończ, gdy roleta jest w pełni zamknięta.",
+ "travel_time_open": "Kliknij Zakończ, gdy roleta jest w pełni otwarta."
+ },
+ "min_movement_time": "Kliknij Zakończ, gdy tylko zauważysz ruch rolety."
}
}
}
diff --git a/custom_components/cover_time_based/translations/pt.json b/custom_components/cover_time_based/translations/pt.json
index 5df2156..341364f 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,14 +36,168 @@
"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"
+ }
+ }
+ }
+ },
+ "card": {
+ "header": "Configuração de Estore Baseado em Tempo",
+ "loading": "A carregar...",
+ "saving": "A guardar...",
+ "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",
+ "calibration": "Calibração"
+ },
+ "device_type": {
+ "label": "Tipo de Dispositivo",
+ "switch": "Controlar através de interruptores",
+ "cover": "Encapsular uma entidade de estore existente"
+ },
+ "entities": {
+ "cover_entity": "Entidade de Estore",
+ "switch_entities": "Entidades de Interruptor",
+ "open_switch": "Interruptor de abrir",
+ "close_switch": "Interruptor de fechar",
+ "stop_switch": "Interruptor de parar (opcional)"
+ },
+ "input_mode": {
+ "label": "Modo de Entrada",
+ "switch": "Interruptor (travamento)",
+ "pulse": "Pulso (momentâneo)",
+ "toggle": "Alternar (mesmo botão)",
+ "pulse_time": "Duração do pulso"
+ },
+ "endpoint_runon": {
+ "label": "Tempo de Sobrecurso nos Extremos",
+ "helper": "Tempo de deslocamento adicional nos extremos (0%/100%) para reposição da posição"
+ },
+ "tilt": {
+ "label": "Inclinação",
+ "none": "Não suportado",
+ "sequential": "Fecha e depois inclina",
+ "dual_motor": "Motor de inclinação separado",
+ "inline": "Inclina durante o deslocamento"
+ },
+ "tilt_motor": {
+ "label": "Motor de Inclinação",
+ "open_switch": "Interruptor de abrir inclinação",
+ "close_switch": "Interruptor de fechar inclinação",
+ "stop_switch": "Interruptor de parar inclinação (opcional)",
+ "safe_position": "Posição de inclinação segura",
+ "safe_position_helper": "A inclinação move-se para aqui antes do deslocamento (100 = totalmente aberto)",
+ "max_allowed_position": "Posição máxima permitida de inclinação (opcional)",
+ "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",
+ "value_header": "Valor",
+ "not_set": "Não definido",
+ "travel_time_close": "Tempo de deslocamento (fechar)",
+ "travel_time_open": "Tempo de deslocamento (abrir)",
+ "travel_startup_delay": "Atraso de arranque do deslocamento",
+ "tilt_time_close": "Tempo de inclinação (fechar)",
+ "tilt_time_open": "Tempo de inclinação (abrir)",
+ "tilt_startup_delay": "Atraso de arranque da inclinação",
+ "min_movement_time": "Tempo mínimo de movimento"
+ },
+ "position": {
+ "label": "Posição Atual",
+ "helper": "Mova o estore para um extremo conhecido e defina a posição.",
+ "unknown": "Desconhecida",
+ "open": "Totalmente aberto",
+ "closed": "Totalmente fechado",
+ "closed_tilt_open": "Totalmente fechado, inclinação aberta",
+ "closed_tilt_closed": "Totalmente fechado, inclinação fechada"
+ },
+ "calibration": {
+ "label": "Calibração de Temporização",
+ "attribute_label": "Atributo",
+ "start": "Iniciar",
+ "active": "Calibração Ativa",
+ "step": "Passo {step}",
+ "cancel": "Cancelar",
+ "finish": "Concluir",
+ "set_position_first": "Defina a posição para iniciar a calibração."
+ },
+ "controls": {
+ "cover_label": "Estore",
+ "tilt_label": "Inclinação",
+ "open": "Abrir",
+ "stop": "Parar",
+ "close": "Fechar",
+ "tilt_open": "Inclinar abrir",
+ "tilt_stop": "Inclinar parar",
+ "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.",
+ "travel_time_open": "Comece com o estore fechado e as lâminas abertas. Clique em Concluir quando o estore estiver totalmente aberto.",
+ "tilt_time_close": "Comece com o estore fechado mas as lâminas abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.",
+ "tilt_time_open": "Comece com o estore e as lâminas fechados. Clique em Concluir quando as lâminas estiverem abertas."
+ },
+ "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.",
+ "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.",
+ "tilt_time_close": "Comece com o estore fechado e as lâminas abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.",
+ "tilt_time_open": "Comece com o estore e as lâminas fechados. Clique em Concluir quando as lâminas estiverem totalmente abertas."
+ },
+ "inline": {
+ "travel_time_close": "Comece com o estore e as lâminas totalmente abertos. Clique em Concluir quando ambos estiverem totalmente fechados.",
+ "travel_time_open": "Comece com o estore e as lâminas totalmente fechados. Clique em Concluir quando ambos estiverem totalmente abertos.",
+ "tilt_time_close": "Comece com as lâminas totalmente abertas. Clique em Concluir quando as lâminas estiverem totalmente fechadas.",
+ "tilt_time_open": "Comece com as lâminas totalmente fechadas. Clique em Concluir quando as lâminas estiverem totalmente abertas."
+ },
+ "none": {
+ "travel_time_close": "Clique em Concluir quando o estore estiver totalmente fechado.",
+ "travel_time_open": "Clique em Concluir quando o estore estiver totalmente aberto."
+ },
+ "min_movement_time": "Clique em Concluir assim que notar o estore a mover-se."
}
}
}
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..4997ed1
--- /dev/null
+++ b/custom_components/cover_time_based/travel_calculator.py
@@ -0,0 +1,199 @@
+"""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 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..5af248e
--- /dev/null
+++ b/custom_components/cover_time_based/websocket_api.py
@@ -0,0 +1,372 @@
+"""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_COVER_ENTITY_ID,
+ CONF_DEVICE_TYPE,
+ CONF_INPUT_MODE,
+ 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,
+ DEFAULT_ENDPOINT_RUNON_TIME,
+ DEFAULT_PULSE_TIME,
+ DEVICE_TYPE_COVER,
+ DEVICE_TYPE_SWITCH,
+ INPUT_MODE_PULSE,
+ INPUT_MODE_SWITCH,
+ INPUT_MODE_TOGGLE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "cover_time_based"
+
+# Map from WS field names to config entry option keys
+_FIELD_MAP = {
+ "device_type": CONF_DEVICE_TYPE,
+ "input_mode": CONF_INPUT_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,
+ "device_type": options.get(CONF_DEVICE_TYPE, DEVICE_TYPE_SWITCH),
+ "input_mode": options.get(CONF_INPUT_MODE, INPUT_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("device_type"): vol.In([DEVICE_TYPE_SWITCH, DEVICE_TYPE_COVER]),
+ vol.Optional("input_mode"): vol.In(
+ [INPUT_MODE_SWITCH, INPUT_MODE_PULSE, INPUT_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, max=600))
+ ),
+ vol.Optional("travel_time_open"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("tilt_time_close"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("tilt_time_open"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, 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})
+
+
+def _resolve_entity(hass: HomeAssistant, entity_id: str):
+ """Resolve an entity_id to a CoverTimeBased entity instance."""
+ from .cover import CoverTimeBased
+
+ component = hass.data.get("entity_components", {}).get("cover")
+ if component is None:
+ return None
+ entity = component.get_entity(entity_id)
+ if entity is None or not isinstance(entity, CoverTimeBased):
+ return None
+ return entity
+
+
+@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(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(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(hass, msg["entity_id"])
+ if entity is None:
+ connection.send_error(msg["id"], "not_found", "Entity not found")
+ return
+
+ try:
+ command = msg["command"]
+ if command == "open":
+ await entity._send_open()
+ elif command == "close":
+ await entity._send_close()
+ elif command == "stop":
+ await entity._send_stop()
+ elif command == "tilt_open":
+ if not entity._has_tilt_motor():
+ connection.send_error(
+ msg["id"], "not_supported", "Tilt motor not configured"
+ )
+ return
+ await entity._send_tilt_open()
+ elif command == "tilt_close":
+ if not entity._has_tilt_motor():
+ connection.send_error(
+ msg["id"], "not_supported", "Tilt motor not configured"
+ )
+ return
+ await entity._send_tilt_close()
+ elif command == "tilt_stop":
+ if not entity._has_tilt_motor():
+ connection.send_error(
+ msg["id"], "not_supported", "Tilt motor not configured"
+ )
+ return
+ await entity._send_tilt_stop()
+ 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/docs/plans/2026-02-13-state-monitoring-design.md b/docs/plans/2026-02-13-state-monitoring-design.md
new file mode 100644
index 0000000..c965924
--- /dev/null
+++ b/docs/plans/2026-02-13-state-monitoring-design.md
@@ -0,0 +1,68 @@
+# State Monitoring Design
+
+## Problem
+
+The cover_time_based integration is purely one-directional: it sends commands to switch entities but never listens to their state. If someone presses a physical button (or uses the Shelly app), the integration has no idea the cover is moving. The travel calculator stays out of sync with reality.
+
+## Goal
+
+Detect when switches are toggled externally (physical button, app, automation) and update the travel calculator accordingly, achieving full parity with HA-initiated commands. Physical button presses always target fully open (0%) or fully closed (100%).
+
+## Approach: State Change Listeners with Echo Filtering
+
+### Event Registration
+
+In `async_added_to_hass`, register `async_track_state_change_event` listeners on:
+- `_open_switch_entity_id`
+- `_close_switch_entity_id`
+- `_stop_switch_entity_id` (if present)
+
+Store unsubscribe callbacks. Clean up in `async_will_remove_from_hass`.
+
+### Echo Filtering
+
+When the integration sends a command, the hardware echoes back state changes. We must distinguish echoes from genuine external events.
+
+**Mechanism**: `_pending_switch: dict[str, bool]` tracks which switches have pending echoes.
+
+- Before `_async_handle_command` toggles a switch, set `_pending_switch[entity_id] = True`
+- When a state change fires for that entity and the flag is set, clear the flag and ignore the event
+- For pulse/toggle modes (ON then OFF), the flag persists across both transitions; cleared after the OFF echo
+- For switch mode (latching), cleared after the first echo
+- Safety net: clear pending flags after a timeout (3 seconds) in case an echo is lost
+
+### State Change Handler
+
+`_handle_switch_state_change(entity_id, old_state, new_state)` handles non-echo state changes per mode:
+
+**Switch mode** (latching):
+- `open_switch` ON: start opening (fully open)
+- `open_switch` OFF: stop
+- `close_switch` ON: start closing (fully closed)
+- `close_switch` OFF: stop
+- `stop_switch` ON: stop
+
+**Pulse mode** (momentary):
+- `open_switch` ON->OFF: start opening (fully open)
+- `close_switch` ON->OFF: start closing (fully closed)
+- `stop_switch` ON->OFF: stop
+
+**Toggle mode** (same button stops):
+- `open_switch` ON->OFF while not traveling: start opening (fully open)
+- `open_switch` ON->OFF while traveling up: stop
+- `open_switch` ON->OFF while traveling down: stop, then start opening
+- Same logic mirrored for `close_switch`
+
+### Skipping Hardware Commands for External Events
+
+When reacting to an external event, we must NOT send commands back to the switches (the hardware already toggled them). Use a `_triggered_externally` flag:
+
+- Handler sets `self._triggered_externally = True` before calling `async_open/close/stop_cover`
+- Those methods check the flag and skip `_async_handle_command` if set
+- Flag is cleared after the call completes
+
+This reuses all existing travel calculator logic (startup delays, direction changes, auto-stopping) without duplication.
+
+## Scope
+
+Applies to all input modes (switch, pulse, toggle). Only affects switch-based configurations (not `cover_entity_id`).
diff --git a/docs/plans/2026-02-13-state-monitoring.md b/docs/plans/2026-02-13-state-monitoring.md
new file mode 100644
index 0000000..fe7cbdb
--- /dev/null
+++ b/docs/plans/2026-02-13-state-monitoring.md
@@ -0,0 +1,531 @@
+# State Monitoring Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Detect when switches are toggled externally (physical button, Shelly app, automation) and update the travel calculator, achieving full parity with HA-initiated commands.
+
+**Architecture:** Register `async_track_state_change_event` listeners on the open/close/stop switch entities. Use a `_pending_switch` dict to filter echoes from HA-initiated commands. When an external state change is detected, delegate to the existing `async_open/close/stop_cover` methods but skip the hardware command (the switch already toggled). Physical presses always target fully open/closed.
+
+**Tech Stack:** Home Assistant `async_track_state_change_event`, existing `TravelCalculator` from xknx.
+
+**Working directory:** `/workspaces/ha-cover-time-based`
+
+**File to modify:** `custom_components/cover_time_based/cover.py`
+
+**No test framework exists in this repo** — this is a HA custom component without unit tests. Testing is done manually by deploying to HA. After each task, deploy with:
+```bash
+rm -Rf /workspaces/homeassistant-core/config/custom_components/cover_time_based && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/
+```
+
+---
+
+### Task 1: Add imports and instance variables
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:28-30` (imports)
+- Modify: `custom_components/cover_time_based/cover.py:307-310` (instance variables)
+
+**Step 1: Add `async_track_state_change_event` to imports**
+
+At line 28, the existing import block is:
+```python
+from homeassistant.helpers.event import (
+ async_track_time_interval,
+)
+```
+
+Change it to:
+```python
+from homeassistant.helpers.event import (
+ async_track_state_change_event,
+ async_track_time_interval,
+)
+```
+
+**Step 2: Add instance variables in `__init__`**
+
+After line 310 (`self._last_command = None`), add:
+```python
+ self._triggered_externally = False
+ self._pending_switch = {}
+ self._pending_switch_timers = {}
+ self._state_listener_unsubs = []
+```
+
+- `_triggered_externally`: flag to skip `_async_handle_command` when reacting to external events
+- `_pending_switch`: `dict[str, int]` tracking expected echo count per switch entity_id
+- `_pending_switch_timers`: `dict[str, callable]` timeout cleanup handles per switch entity_id
+- `_state_listener_unsubs`: list of unsubscribe callbacks for state listeners
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): add imports and instance variables"
+```
+
+---
+
+### Task 2: Register state listeners in `async_added_to_hass`
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:322-341` (async_added_to_hass)
+
+**Step 1: Add listener registration at the end of `async_added_to_hass`**
+
+After the existing tilt restoration code (line 341), add:
+```python
+
+ # Register state change listeners for switch entities
+ if self._open_switch_entity_id:
+ self._state_listener_unsubs.append(
+ async_track_state_change_event(
+ self.hass,
+ [self._open_switch_entity_id],
+ self._async_switch_state_changed,
+ )
+ )
+ if self._close_switch_entity_id:
+ self._state_listener_unsubs.append(
+ async_track_state_change_event(
+ self.hass,
+ [self._close_switch_entity_id],
+ self._async_switch_state_changed,
+ )
+ )
+ if self._stop_switch_entity_id:
+ self._state_listener_unsubs.append(
+ async_track_state_change_event(
+ self.hass,
+ [self._stop_switch_entity_id],
+ self._async_switch_state_changed,
+ )
+ )
+```
+
+**Step 2: Add `async_will_remove_from_hass` cleanup method**
+
+Add a new method after `async_added_to_hass` (after line 341):
+```python
+
+ async def async_will_remove_from_hass(self):
+ """Clean up state listeners."""
+ 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()
+```
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): register state listeners on switch entities"
+```
+
+---
+
+### Task 3: Add echo filtering to `_async_handle_command`
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:1148-1307` (_async_handle_command)
+
+**Context:** When `_async_handle_command` turns a switch ON or OFF, the hardware will echo back state changes. We need to mark those switches as "pending echo" so the state listener ignores them.
+
+**Step 1: Add a helper method `_mark_switch_pending`**
+
+Add this method somewhere before `_async_handle_command` (e.g., after `set_known_tilt_position` at line 1147):
+```python
+
+ def _mark_switch_pending(self, entity_id, expected_transitions):
+ """Mark a switch as having pending echo transitions to ignore.
+
+ Args:
+ entity_id: The switch entity ID.
+ expected_transitions: Number of state transitions to ignore (e.g., 2 for ON+OFF pulse).
+ """
+ self._pending_switch[entity_id] = self._pending_switch.get(entity_id, 0) + expected_transitions
+ _LOGGER.debug("_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:
+ _LOGGER.debug("_mark_switch_pending :: timeout clearing %s", entity_id)
+ del self._pending_switch[entity_id]
+ if entity_id in self._pending_switch_timers:
+ del self._pending_switch_timers[entity_id]
+
+ self._pending_switch_timers[entity_id] = async_track_time_interval(
+ self.hass, _clear_pending, timedelta(seconds=5)
+ )
+```
+
+Note: We use `async_track_time_interval` as a one-shot timer (it fires after 5s, clears the pending state, and then we should cancel the timer in `_clear_pending`). Actually, `async_track_time_interval` repeats. Instead, use `self.hass.async_call_later`:
+
+```python
+
+ 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
+ _LOGGER.debug("_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:
+ _LOGGER.debug("_mark_switch_pending :: timeout clearing %s", entity_id)
+ del self._pending_switch[entity_id]
+ if entity_id in self._pending_switch_timers:
+ del self._pending_switch_timers[entity_id]
+
+ self._pending_switch_timers[entity_id] = self.hass.helpers.event.async_call_later(
+ 5, _clear_pending
+ )
+```
+
+Actually, the simplest HA approach is `async_call_later` from the hass object:
+
+```python
+ 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
+ _LOGGER.debug("_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:
+ _LOGGER.debug("_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] = self.hass.loop.call_later(
+ 5, lambda: self.hass.async_create_task(_wrap_clear_pending())
+ )
+```
+
+Hmm, let's keep it simple. Use `asyncio.get_event_loop().call_later` or just use HA's built-in `async_call_later`:
+
+```python
+from homeassistant.helpers.event import async_call_later
+```
+
+Then:
+```python
+ 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
+ _LOGGER.debug("_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:
+ _LOGGER.debug("_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
+ )
+```
+
+**Step 2: Add `_mark_switch_pending` calls in `_async_handle_command`**
+
+In the CLOSE command section (lines 1159-1188), after the `else:` block that handles switch entities, add pending markers. The logic differs by input mode:
+
+For **SERVICE_CLOSE_COVER** (line 1149):
+- `turn_off(open_switch)` → 1 transition (OFF, but might already be off = 0 transitions). Mark 1 to be safe.
+- `turn_on(close_switch)` → 1 transition (ON)
+- If pulse/toggle: `turn_off(close_switch)` → 1 more transition (OFF). Total = 2 for close_switch.
+
+Add right after `else:` at line 1159 (before the service calls):
+```python
+ self._mark_switch_pending(self._open_switch_entity_id, 1)
+ if self._input_mode in (INPUT_MODE_PULSE, INPUT_MODE_TOGGLE):
+ self._mark_switch_pending(self._close_switch_entity_id, 2)
+ else:
+ self._mark_switch_pending(self._close_switch_entity_id, 1)
+ if self._stop_switch_entity_id is not None:
+ self._mark_switch_pending(self._stop_switch_entity_id, 1)
+```
+
+For **SERVICE_OPEN_COVER** (line 1190): same pattern mirrored:
+```python
+ self._mark_switch_pending(self._close_switch_entity_id, 1)
+ if self._input_mode in (INPUT_MODE_PULSE, INPUT_MODE_TOGGLE):
+ self._mark_switch_pending(self._open_switch_entity_id, 2)
+ else:
+ self._mark_switch_pending(self._open_switch_entity_id, 1)
+ if self._stop_switch_entity_id is not None:
+ self._mark_switch_pending(self._stop_switch_entity_id, 1)
+```
+
+For **SERVICE_STOP_COVER** (line 1230): depends on sub-branch:
+- Toggle mode with last_command = close: `turn_on` + `turn_off` on close_switch = 2 transitions
+- Toggle mode with last_command = open: `turn_on` + `turn_off` on open_switch = 2 transitions
+- Switch/pulse mode: `turn_off` on both switches (1 each), `turn_on` (+ maybe `turn_off`) on stop_switch
+
+Add `_mark_switch_pending` calls before each group of service calls in the STOP section.
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): add echo filtering to _async_handle_command"
+```
+
+---
+
+### Task 4: Add the `_triggered_externally` flag check
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py` — `async_close_cover`, `async_open_cover`, `async_stop_cover`
+
+**Step 1: In `async_close_cover` (line 529), skip `_async_handle_command` when triggered externally**
+
+Find line 578:
+```python
+ await self._async_handle_command(SERVICE_CLOSE_COVER)
+```
+
+Replace with:
+```python
+ if not self._triggered_externally:
+ await self._async_handle_command(SERVICE_CLOSE_COVER)
+```
+
+Also need to handle the STOP commands that are sent during direction changes (lines 548, 556). These should also be skipped when external. But since those are called via `async_stop_cover` (line 536, 540) which will check the flag itself, that's handled.
+
+Wait — lines 548 and 556 call `_async_handle_command(SERVICE_STOP_COVER)` directly, not through `async_stop_cover`. These also need the flag check:
+
+Line 548:
+```python
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+→
+```python
+ if not self._triggered_externally:
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+
+Line 556:
+```python
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+→
+```python
+ if not self._triggered_externally:
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+
+**Step 2: Same changes in `async_open_cover` (line 596)**
+
+Apply the same pattern to all `_async_handle_command` calls in `async_open_cover`.
+
+**Step 3: In `async_stop_cover` (line 737), skip `_async_handle_command`**
+
+Find line 746:
+```python
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+
+Replace with:
+```python
+ if not self._triggered_externally:
+ await self._async_handle_command(SERVICE_STOP_COVER)
+```
+
+**Step 4: Also check in tilt methods**
+
+Apply the same pattern to `async_close_cover_tilt` and `async_open_cover_tilt` — all their `_async_handle_command` calls should check `_triggered_externally`.
+
+**Step 5: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): skip hardware commands when triggered externally"
+```
+
+---
+
+### Task 5: Implement the state change handler
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py` — add new method `_async_switch_state_changed`
+
+**Step 1: Add the handler method**
+
+Add after the `_mark_switch_pending` method:
+
+```python
+
+ 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
+
+ _LOGGER.debug(
+ "_async_switch_state_changed :: %s: %s -> %s (pending=%s)",
+ entity_id, old_val, new_val,
+ self._pending_switch.get(entity_id, 0),
+ )
+
+ # 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()
+ _LOGGER.debug(
+ "_async_switch_state_changed :: echo filtered, remaining=%s",
+ self._pending_switch.get(entity_id, 0),
+ )
+ return
+
+ # External state change detected — handle per mode
+ self._triggered_externally = True
+ try:
+ if self._input_mode == INPUT_MODE_SWITCH:
+ await self._handle_switch_mode_state_change(entity_id, old_val, new_val)
+ elif self._input_mode in (INPUT_MODE_PULSE, INPUT_MODE_TOGGLE):
+ await self._handle_pulse_toggle_state_change(entity_id, old_val, new_val)
+ finally:
+ self._triggered_externally = False
+```
+
+**Step 2: Add switch mode handler**
+
+```python
+
+ async def _handle_switch_mode_state_change(self, entity_id, old_val, new_val):
+ """Handle external state change in switch (latching) mode."""
+ if entity_id == self._open_switch_entity_id:
+ if new_val == "on":
+ _LOGGER.debug("_handle_switch_mode_state_change :: external open detected")
+ await self.async_open_cover()
+ elif new_val == "off":
+ _LOGGER.debug("_handle_switch_mode_state_change :: external open-stop detected")
+ await self.async_stop_cover()
+ elif entity_id == self._close_switch_entity_id:
+ if new_val == "on":
+ _LOGGER.debug("_handle_switch_mode_state_change :: external close detected")
+ await self.async_close_cover()
+ elif new_val == "off":
+ _LOGGER.debug("_handle_switch_mode_state_change :: external close-stop detected")
+ await self.async_stop_cover()
+ elif entity_id == self._stop_switch_entity_id:
+ if new_val == "on":
+ _LOGGER.debug("_handle_switch_mode_state_change :: external stop detected")
+ await self.async_stop_cover()
+```
+
+**Step 3: Add pulse/toggle mode handler**
+
+```python
+
+ async def _handle_pulse_toggle_state_change(self, entity_id, old_val, new_val):
+ """Handle external state change in pulse or toggle mode.
+
+ In pulse/toggle mode, a physical press produces ON->OFF.
+ We react on the OFF transition (pulse complete).
+ """
+ if old_val != "on" or new_val != "off":
+ return
+
+ if entity_id == self._open_switch_entity_id:
+ _LOGGER.debug("_handle_pulse_toggle_state_change :: external open pulse detected")
+ await self.async_open_cover()
+ elif entity_id == self._close_switch_entity_id:
+ _LOGGER.debug("_handle_pulse_toggle_state_change :: external close pulse detected")
+ await self.async_close_cover()
+ elif entity_id == self._stop_switch_entity_id:
+ _LOGGER.debug("_handle_pulse_toggle_state_change :: external stop pulse detected")
+ await self.async_stop_cover()
+```
+
+Note: In toggle mode, `async_open_cover` and `async_close_cover` already handle the "already traveling same direction = stop" and "traveling opposite = stop first" logic, so this works correctly.
+
+**Step 4: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): implement state change handler with mode-specific logic"
+```
+
+---
+
+### Task 6: Add `async_call_later` import and verify
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:28-30` (imports)
+
+**Step 1: Add `async_call_later` to the event import**
+
+```python
+from homeassistant.helpers.event import (
+ async_call_later,
+ async_track_state_change_event,
+ async_track_time_interval,
+)
+```
+
+**Step 2: Verify the full file has no syntax errors**
+
+Deploy to HA and check logs:
+```bash
+rm -Rf /workspaces/homeassistant-core/config/custom_components/cover_time_based && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/
+```
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat(state-monitoring): add async_call_later import"
+```
+
+---
+
+### Task 7: Deploy and manual test
+
+**Step 1: Deploy to HA**
+```bash
+rm -Rf /workspaces/homeassistant-core/config/custom_components/cover_time_based && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/
+```
+
+**Step 2: Restart HA and test**
+- Open cover via HA UI → verify travel calc works, switches toggle, no double-trigger from echo
+- Press physical button → verify travel calc starts tracking, cover shows as opening/closing in HA
+- Press physical button again while moving → verify cover stops and travel calc stops
+- Check HA logs for `_async_switch_state_changed` debug messages
+- Verify echo filtering: HA-initiated commands should show "echo filtered" in logs
+- Verify external events: physical button presses should show "external open/close detected"
diff --git a/docs/plans/2026-02-13-ui-config-flow-design.md b/docs/plans/2026-02-13-ui-config-flow-design.md
new file mode 100644
index 0000000..7e23b7d
--- /dev/null
+++ b/docs/plans/2026-02-13-ui-config-flow-design.md
@@ -0,0 +1,97 @@
+# UI Config Flow Design
+
+## Overview
+
+Add UI-based configuration to the cover_time_based integration using Home Assistant's config entry + subentry pattern. YAML configuration remains fully supported for backward compatibility.
+
+## Architecture
+
+### Config Entry (one per integration instance)
+
+- `entry.data` — empty (no credentials or connection info needed)
+- `entry.options` — default timing values (travel times, tilt times, startup delays, etc.)
+- Options flow lets the user edit these defaults at any time
+
+### Subentries (one per cover entity)
+
+- `subentry.subentry_type` = `"cover"`
+- `subentry.data` — the cover's full config: name, entity IDs, input mode, and any timing overrides
+- Values not set in subentry data fall back to the integration defaults
+
+### YAML (backward compatibility)
+
+- `async_setup_platform()` remains unchanged — existing YAML configs keep working
+- YAML-created entities are completely independent of config entry entities
+- No migration path needed — users can use either or both
+
+### Default Resolution
+
+When creating a `CoverTimeBased` entity from a subentry:
+
+```
+subentry.data[key] → entry.options[key] → schema default
+```
+
+This mirrors the existing YAML pattern: `device config → defaults → schema default`.
+
+## Entity Form (Subentry Flow)
+
+Single form with three sections:
+
+### Main section (always visible)
+
+- **Name** — text field (required)
+- **Device type** — selector: "Control via switches" or "Wrap existing cover"
+- If switches: **Open switch**, **Close switch**, **Stop switch** (optional) — entity selectors filtered to switch/input_boolean domains
+- If existing cover: **Cover entity** — entity selector filtered to cover domain
+- **Input mode** — dropdown: switch / pulse / toggle (only shown for switch-based, not for cover_entity_id mode)
+- **Pulse time** — float (only shown when input mode is pulse or toggle)
+
+### Travel timing section (collapsed)
+
+- Travel time down, Travel time up
+- Tilt time down, Tilt time up
+- Travel moves with tilt (boolean)
+
+### Advanced section (collapsed)
+
+- Travel startup delay, Tilt startup delay
+- Min movement time
+- Travel delay at end
+
+Timing fields left blank in the subentry fall back to the integration defaults. The form shows the current default as placeholder text.
+
+## Options Flow (Integration Defaults)
+
+Single form with two collapsed sections (no name, entity IDs, or input mode):
+
+### Travel timing section
+
+- Travel time down (default: 30), Travel time up (default: 30)
+- Tilt time down, Tilt time up
+- Travel moves with tilt (default: false)
+
+### Advanced section
+
+- Travel startup delay, Tilt startup delay
+- Min movement time
+- Travel delay at end
+
+## Files
+
+### New files
+
+- `__init__.py` — `async_setup_entry`, forwards to cover platform
+- `config_flow.py` — ConfigFlow, OptionsFlow, SubentryFlow
+
+### Modified files
+
+- `manifest.json` — add `"config_flow": true`
+- `cover.py` — add `async_setup_entry` alongside existing `async_setup_platform`
+- `strings.json` — add config/options/subentry flow translations
+
+## Constraints
+
+- Do NOT use `ruff format` on existing files — only `ruff check`
+- Keep diffs to existing files minimal
+- `is_button` deprecation is YAML-only — the UI only exposes `input_mode`
diff --git a/docs/plans/2026-02-13-ui-config-flow-plan.md b/docs/plans/2026-02-13-ui-config-flow-plan.md
new file mode 100644
index 0000000..c37248f
--- /dev/null
+++ b/docs/plans/2026-02-13-ui-config-flow-plan.md
@@ -0,0 +1,817 @@
+# UI Config Flow Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add UI-based configuration to cover_time_based using HA's config entry + subentry pattern, while keeping YAML backward compatibility.
+
+**Architecture:** Single config entry holds integration-level defaults in `entry.options`. Each cover entity is a subentry with its own config in `subentry.data`. Values resolve: `subentry.data → entry.options → schema default`. YAML `async_setup_platform` remains unchanged.
+
+**Tech Stack:** Home Assistant config entries, ConfigSubentryFlow, OptionsFlow, voluptuous selectors
+
+**Constraints:** Do NOT use `ruff format` — only `ruff check`. Keep diffs to existing files minimal.
+
+---
+
+### Task 1: Update manifest.json
+
+**Files:**
+- Modify: `custom_components/cover_time_based/manifest.json`
+
+**Step 1: Add config_flow to manifest**
+
+Edit `manifest.json` to add `"config_flow": true`:
+
+```json
+{
+ "domain": "cover_time_based",
+ "name": "Cover Time Based",
+ "codeowners": ["@Sese-Schneider"],
+ "config_flow": true,
+ "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"
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add custom_components/cover_time_based/manifest.json
+git commit -m "feat: enable config flow in manifest"
+```
+
+---
+
+### Task 2: Create __init__.py
+
+**Files:**
+- Create: `custom_components/cover_time_based/__init__.py`
+
+**Step 1: Write __init__.py**
+
+```python
+"""Cover Time Based integration."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+PLATFORMS = ["cover"]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Cover Time Based from a config entry."""
+ 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)
+```
+
+**Step 2: Commit**
+
+```bash
+git add custom_components/cover_time_based/__init__.py
+git commit -m "feat: add __init__.py for config entry support"
+```
+
+---
+
+### Task 3: Create config_flow.py
+
+**Files:**
+- Create: `custom_components/cover_time_based/config_flow.py`
+
+**Step 1: Write config_flow.py**
+
+This file contains three flow handlers:
+- `CoverTimeBasedConfigFlow` — creates the integration entry (trivial, no user input needed beyond confirmation)
+- `CoverTimeBasedOptionsFlow` — edits integration-level defaults
+- `CoverTimeBasedSubentryFlow` — adds/reconfigures individual cover entities
+
+```python
+"""Config flow for Cover Time Based integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ ConfigSubentryFlow,
+ OptionsFlow,
+ SubentryFlowResult,
+)
+from homeassistant.core import callback
+from homeassistant.data_entry_flow import section
+from homeassistant.helpers.selector import (
+ BooleanSelector,
+ EntitySelector,
+ EntitySelectorConfig,
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+)
+
+from .cover import (
+ CONF_CLOSE_SWITCH_ENTITY_ID,
+ CONF_COVER_ENTITY_ID,
+ CONF_INPUT_MODE,
+ 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,
+ DEFAULT_PULSE_TIME,
+ DEFAULT_TRAVEL_TIME,
+ DOMAIN,
+ INPUT_MODE_PULSE,
+ INPUT_MODE_SWITCH,
+ INPUT_MODE_TOGGLE,
+)
+
+CONF_DEVICE_TYPE = "device_type"
+DEVICE_TYPE_SWITCH = "switch"
+DEVICE_TYPE_COVER = "cover"
+
+SECTION_TRAVEL_TIMING = "travel_timing"
+SECTION_ADVANCED = "advanced"
+
+TIMING_NUMBER_SELECTOR = NumberSelector(
+ NumberSelectorConfig(min=0, max=600, step=0.1, mode=NumberSelectorMode.BOX)
+)
+
+
+def _travel_timing_schema() -> vol.Schema:
+ """Return schema for travel timing section."""
+ return vol.Schema(
+ {
+ vol.Optional(CONF_TRAVELLING_TIME_DOWN): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TRAVELLING_TIME_UP): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TILTING_TIME_DOWN): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TILTING_TIME_UP): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TRAVEL_MOVES_WITH_TILT): BooleanSelector(),
+ }
+ )
+
+
+def _advanced_schema() -> vol.Schema:
+ """Return schema for advanced section."""
+ return vol.Schema(
+ {
+ vol.Optional(CONF_TRAVEL_STARTUP_DELAY): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TILT_STARTUP_DELAY): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_MIN_MOVEMENT_TIME): TIMING_NUMBER_SELECTOR,
+ vol.Optional(CONF_TRAVEL_DELAY_AT_END): TIMING_NUMBER_SELECTOR,
+ }
+ )
+
+
+class CoverTimeBasedConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Cover Time Based."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step — just create the entry."""
+ if user_input is not None:
+ return self.async_create_entry(
+ title="Cover Time Based",
+ data={},
+ options={
+ CONF_TRAVELLING_TIME_DOWN: DEFAULT_TRAVEL_TIME,
+ CONF_TRAVELLING_TIME_UP: DEFAULT_TRAVEL_TIME,
+ CONF_TRAVEL_MOVES_WITH_TILT: False,
+ },
+ )
+
+ return self.async_show_form(step_id="user")
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> CoverTimeBasedOptionsFlow:
+ """Get the options flow for this handler."""
+ return CoverTimeBasedOptionsFlow()
+
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this integration."""
+ return {"cover": CoverTimeBasedSubentryFlow}
+
+
+class CoverTimeBasedOptionsFlow(OptionsFlow):
+ """Handle options flow for editing integration defaults."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the integration defaults."""
+ if user_input is not None:
+ # Merge section data into flat options dict
+ options = {}
+ travel = user_input.get(SECTION_TRAVEL_TIMING, {})
+ advanced = user_input.get(SECTION_ADVANCED, {})
+ options.update(travel)
+ options.update(advanced)
+ return self.async_create_entry(title="", data=options)
+
+ # Build schema with current values as defaults
+ current = dict(self.config_entry.options)
+
+ schema = vol.Schema(
+ {
+ vol.Required(SECTION_TRAVEL_TIMING): section(
+ _travel_timing_schema(),
+ {"collapsed": False},
+ ),
+ vol.Required(SECTION_ADVANCED): section(
+ _advanced_schema(),
+ {"collapsed": True},
+ ),
+ }
+ )
+
+ # Build suggested values from current options
+ suggested = {
+ SECTION_TRAVEL_TIMING: {
+ k: current[k]
+ for k in (
+ CONF_TRAVELLING_TIME_DOWN,
+ CONF_TRAVELLING_TIME_UP,
+ CONF_TILTING_TIME_DOWN,
+ CONF_TILTING_TIME_UP,
+ CONF_TRAVEL_MOVES_WITH_TILT,
+ )
+ if k in current
+ },
+ SECTION_ADVANCED: {
+ k: current[k]
+ for k in (
+ CONF_TRAVEL_STARTUP_DELAY,
+ CONF_TILT_STARTUP_DELAY,
+ CONF_MIN_MOVEMENT_TIME,
+ CONF_TRAVEL_DELAY_AT_END,
+ )
+ if k in current
+ },
+ }
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(schema, suggested),
+ )
+
+
+class CoverTimeBasedSubentryFlow(ConfigSubentryFlow):
+ """Handle subentry flow for adding/editing a cover entity."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle adding a new cover subentry."""
+ return await self._async_handle_step(user_input, is_new=True)
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Handle reconfiguring an existing cover subentry."""
+ return await self._async_handle_step(user_input, is_new=False)
+
+ async def _async_handle_step(
+ self, user_input: dict[str, Any] | None, *, is_new: bool
+ ) -> SubentryFlowResult:
+ """Handle the cover configuration form."""
+ if user_input is not None:
+ return self._save(user_input, is_new=is_new)
+
+ # Build schema
+ schema = self._build_schema(is_new=is_new)
+
+ # Get suggested values for reconfigure
+ suggested = {}
+ if not is_new:
+ suggested = self._get_suggested_values()
+
+ step_id = "user" if is_new else "reconfigure"
+ return self.async_show_form(
+ step_id=step_id,
+ data_schema=self.add_suggested_values_to_schema(schema, suggested),
+ )
+
+ def _build_schema(self, *, is_new: bool) -> vol.Schema:
+ """Build the cover configuration schema."""
+ fields: dict[vol.Marker, Any] = {}
+
+ # Name (always required for new, shown for reconfigure too)
+ fields[vol.Required("name")] = TextSelector()
+
+ # Device type selector
+ fields[vol.Required(CONF_DEVICE_TYPE, default=DEVICE_TYPE_SWITCH)] = (
+ SelectSelector(
+ SelectSelectorConfig(
+ options=[DEVICE_TYPE_SWITCH, DEVICE_TYPE_COVER],
+ translation_key="device_type",
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ )
+
+ # Switch entity IDs
+ switch_selector = EntitySelector(
+ EntitySelectorConfig(domain=["switch", "input_boolean"])
+ )
+ fields[vol.Optional(CONF_OPEN_SWITCH_ENTITY_ID)] = switch_selector
+ fields[vol.Optional(CONF_CLOSE_SWITCH_ENTITY_ID)] = switch_selector
+ fields[vol.Optional(CONF_STOP_SWITCH_ENTITY_ID)] = switch_selector
+
+ # Cover entity ID
+ fields[vol.Optional(CONF_COVER_ENTITY_ID)] = EntitySelector(
+ EntitySelectorConfig(domain="cover")
+ )
+
+ # Input mode
+ fields[vol.Optional(CONF_INPUT_MODE, default=INPUT_MODE_SWITCH)] = (
+ SelectSelector(
+ SelectSelectorConfig(
+ options=[INPUT_MODE_SWITCH, INPUT_MODE_PULSE, INPUT_MODE_TOGGLE],
+ translation_key="input_mode",
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ )
+ )
+
+ # Pulse time
+ fields[vol.Optional(CONF_PULSE_TIME)] = NumberSelector(
+ NumberSelectorConfig(
+ min=0.1, max=10, step=0.1, mode=NumberSelectorMode.BOX
+ )
+ )
+
+ # Travel timing section (collapsed)
+ fields[vol.Required(SECTION_TRAVEL_TIMING)] = section(
+ _travel_timing_schema(),
+ {"collapsed": True},
+ )
+
+ # Advanced section (collapsed)
+ fields[vol.Required(SECTION_ADVANCED)] = section(
+ _advanced_schema(),
+ {"collapsed": True},
+ )
+
+ return vol.Schema(fields)
+
+ def _get_suggested_values(self) -> dict[str, Any]:
+ """Get suggested values from existing subentry data."""
+ data = dict(self._get_reconfigure_subentry().data)
+ suggested = {}
+
+ # Top-level fields
+ for key in (
+ "name",
+ CONF_DEVICE_TYPE,
+ CONF_OPEN_SWITCH_ENTITY_ID,
+ CONF_CLOSE_SWITCH_ENTITY_ID,
+ CONF_STOP_SWITCH_ENTITY_ID,
+ CONF_COVER_ENTITY_ID,
+ CONF_INPUT_MODE,
+ CONF_PULSE_TIME,
+ ):
+ if key in data:
+ suggested[key] = data[key]
+
+ # Section fields
+ travel_keys = (
+ CONF_TRAVELLING_TIME_DOWN,
+ CONF_TRAVELLING_TIME_UP,
+ CONF_TILTING_TIME_DOWN,
+ CONF_TILTING_TIME_UP,
+ CONF_TRAVEL_MOVES_WITH_TILT,
+ )
+ advanced_keys = (
+ CONF_TRAVEL_STARTUP_DELAY,
+ CONF_TILT_STARTUP_DELAY,
+ CONF_MIN_MOVEMENT_TIME,
+ CONF_TRAVEL_DELAY_AT_END,
+ )
+ suggested[SECTION_TRAVEL_TIMING] = {
+ k: data[k] for k in travel_keys if k in data
+ }
+ suggested[SECTION_ADVANCED] = {k: data[k] for k in advanced_keys if k in data}
+
+ return suggested
+
+ def _save(
+ self, user_input: dict[str, Any], *, is_new: bool
+ ) -> SubentryFlowResult:
+ """Save the subentry data."""
+ # Flatten sections into top-level data
+ data = {}
+ for key, value in user_input.items():
+ if key in (SECTION_TRAVEL_TIMING, SECTION_ADVANCED):
+ if isinstance(value, dict):
+ data.update(value)
+ else:
+ data[key] = value
+
+ name = data.pop("name")
+
+ if is_new:
+ return self.async_create_entry(title=name, data=data)
+
+ return self.async_update_reload_and_abort(
+ self._get_entry(),
+ self._get_reconfigure_subentry(),
+ title=name,
+ data=data,
+ )
+```
+
+**Step 2: Run lint check**
+
+Run: `ruff check custom_components/cover_time_based/config_flow.py`
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/config_flow.py
+git commit -m "feat: add config flow with subentry support"
+```
+
+---
+
+### Task 4: Add async_setup_entry to cover.py
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py`
+
+This is the key change — add `async_setup_entry` alongside the existing `async_setup_platform`, and extract a helper function for creating `CoverTimeBased` from a flat config dict (used by both YAML and config entry).
+
+**Step 1: Add async_setup_entry function**
+
+After the existing `async_setup_platform` function (line 268), add:
+
+```python
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up cover entities from a config entry's subentries."""
+ entities = []
+ for subentry in config_entry.subentries.values():
+ if subentry.subentry_type != "cover":
+ continue
+ entity = _entity_from_subentry(
+ config_entry, subentry
+ )
+ entities.append(entity)
+ async_add_entities(entities)
+
+ platform = entity_platform.current_platform.get()
+ platform.async_register_entity_service(
+ SERVICE_SET_KNOWN_POSITION, POSITION_SCHEMA, "set_known_position"
+ )
+ platform.async_register_entity_service(
+ SERVICE_SET_KNOWN_TILT_POSITION, TILT_POSITION_SCHEMA, "set_known_tilt_position"
+ )
+
+
+def _get_subentry_value(key, subentry_data, entry_options, schema_default=None):
+ """Get value with priority: subentry data > entry options > schema default."""
+ if key in subentry_data:
+ return subentry_data[key]
+ if key in entry_options:
+ return entry_options[key]
+ return schema_default
+
+
+def _entity_from_subentry(config_entry, subentry):
+ """Create a CoverTimeBased entity from a config subentry."""
+ data = dict(subentry.data)
+ defaults = dict(config_entry.options)
+
+ get = lambda key, default=None: _get_subentry_value(key, data, defaults, default)
+
+ device_type = data.get("device_type", "switch")
+
+ open_switch = data.get(CONF_OPEN_SWITCH_ENTITY_ID) if device_type == "switch" else None
+ close_switch = data.get(CONF_CLOSE_SWITCH_ENTITY_ID) if device_type == "switch" else None
+ stop_switch = data.get(CONF_STOP_SWITCH_ENTITY_ID) if device_type == "switch" else None
+ cover_entity_id = data.get(CONF_COVER_ENTITY_ID) if device_type == "cover" else None
+ input_mode = data.get(CONF_INPUT_MODE, INPUT_MODE_SWITCH) if device_type == "switch" else INPUT_MODE_SWITCH
+
+ return CoverTimeBased(
+ subentry.subentry_id,
+ subentry.title,
+ get(CONF_TRAVEL_MOVES_WITH_TILT, False),
+ get(CONF_TRAVELLING_TIME_DOWN, DEFAULT_TRAVEL_TIME),
+ get(CONF_TRAVELLING_TIME_UP, DEFAULT_TRAVEL_TIME),
+ get(CONF_TILTING_TIME_DOWN, None),
+ get(CONF_TILTING_TIME_UP, None),
+ get(CONF_TRAVEL_DELAY_AT_END, None),
+ get(CONF_MIN_MOVEMENT_TIME, None),
+ get(CONF_TRAVEL_STARTUP_DELAY, None),
+ get(CONF_TILT_STARTUP_DELAY, None),
+ open_switch,
+ close_switch,
+ stop_switch,
+ input_mode,
+ get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME),
+ cover_entity_id,
+ )
+```
+
+**Important:** Do NOT reformat any existing code. Only add new functions after line 268.
+
+**Step 2: Run lint check**
+
+Run: `ruff check custom_components/cover_time_based/cover.py`
+Fix any issues reported (do NOT run `ruff format`).
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover.py
+git commit -m "feat: add async_setup_entry for config entry covers"
+```
+
+---
+
+### Task 5: Update strings.json with translations
+
+**Files:**
+- Modify: `custom_components/cover_time_based/strings.json`
+
+**Step 1: Rewrite strings.json**
+
+The file must contain translations for: config flow steps, options flow, subentry flow steps, and the existing services. Structure:
+
+```json
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "Cover Time Based",
+ "description": "Set up the Cover Time Based integration. You can configure default timing values and then add individual covers."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "Default timing settings",
+ "description": "These defaults apply to all covers unless overridden in individual cover settings.",
+ "sections": {
+ "travel_timing": {
+ "name": "Travel timing",
+ "data": {
+ "travelling_time_down": "Travel time down (seconds)",
+ "travelling_time_up": "Travel time up (seconds)",
+ "tilting_time_down": "Tilt time down (seconds)",
+ "tilting_time_up": "Tilt time up (seconds)",
+ "travel_moves_with_tilt": "Travel moves with tilt"
+ }
+ },
+ "advanced": {
+ "name": "Advanced settings",
+ "data": {
+ "travel_startup_delay": "Travel startup delay (seconds)",
+ "tilt_startup_delay": "Tilt startup delay (seconds)",
+ "min_movement_time": "Minimum movement time (seconds)",
+ "travel_delay_at_end": "Travel delay at end (seconds)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "config_subentries": {
+ "cover": {
+ "entry_type": "Cover",
+ "initiate_flow": {
+ "user": "Add cover",
+ "reconfigure": "Reconfigure cover"
+ },
+ "abort": {
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "step": {
+ "user": {
+ "title": "Add a time-based cover",
+ "data": {
+ "name": "Name",
+ "device_type": "Device type",
+ "open_switch_entity_id": "Open switch",
+ "close_switch_entity_id": "Close switch",
+ "stop_switch_entity_id": "Stop switch",
+ "cover_entity_id": "Cover entity",
+ "input_mode": "Input mode",
+ "pulse_time": "Pulse time (seconds)"
+ },
+ "data_description": {
+ "device_type": "Choose 'switch' to control via switch entities, or 'cover' to wrap an existing cover entity.",
+ "input_mode": "Switch: latching relays. Pulse: momentary with separate stop. Toggle: same button starts and stops.",
+ "pulse_time": "Duration of button press for pulse/toggle modes."
+ },
+ "sections": {
+ "travel_timing": {
+ "name": "Travel timing",
+ "data": {
+ "travelling_time_down": "Travel time down (seconds)",
+ "travelling_time_up": "Travel time up (seconds)",
+ "tilting_time_down": "Tilt time down (seconds)",
+ "tilting_time_up": "Tilt time up (seconds)",
+ "travel_moves_with_tilt": "Travel moves with tilt"
+ }
+ },
+ "advanced": {
+ "name": "Advanced settings",
+ "data": {
+ "travel_startup_delay": "Travel startup delay (seconds)",
+ "tilt_startup_delay": "Tilt startup delay (seconds)",
+ "min_movement_time": "Minimum movement time (seconds)",
+ "travel_delay_at_end": "Travel delay at end (seconds)"
+ }
+ }
+ }
+ },
+ "reconfigure": {
+ "title": "Reconfigure cover",
+ "data": {
+ "name": "Name",
+ "device_type": "Device type",
+ "open_switch_entity_id": "Open switch",
+ "close_switch_entity_id": "Close switch",
+ "stop_switch_entity_id": "Stop switch",
+ "cover_entity_id": "Cover entity",
+ "input_mode": "Input mode",
+ "pulse_time": "Pulse time (seconds)"
+ },
+ "data_description": {
+ "device_type": "Choose 'switch' to control via switch entities, or 'cover' to wrap an existing cover entity.",
+ "input_mode": "Switch: latching relays. Pulse: momentary with separate stop. Toggle: same button starts and stops.",
+ "pulse_time": "Duration of button press for pulse/toggle modes."
+ },
+ "sections": {
+ "travel_timing": {
+ "name": "Travel timing",
+ "data": {
+ "travelling_time_down": "Travel time down (seconds)",
+ "travelling_time_up": "Travel time up (seconds)",
+ "tilting_time_down": "Tilt time down (seconds)",
+ "tilting_time_up": "Tilt time up (seconds)",
+ "travel_moves_with_tilt": "Travel moves with tilt"
+ }
+ },
+ "advanced": {
+ "name": "Advanced settings",
+ "data": {
+ "travel_startup_delay": "Travel startup delay (seconds)",
+ "tilt_startup_delay": "Tilt startup delay (seconds)",
+ "min_movement_time": "Minimum movement time (seconds)",
+ "travel_delay_at_end": "Travel delay at end (seconds)"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "device_type": {
+ "options": {
+ "switch": "Control via switches",
+ "cover": "Wrap existing cover"
+ }
+ },
+ "input_mode": {
+ "options": {
+ "switch": "Switch (latching)",
+ "pulse": "Pulse (momentary)",
+ "toggle": "Toggle (same button stops)"
+ }
+ }
+ },
+ "services": {
+ "set_known_position": {
+ "name": "Set cover position",
+ "description": "Sets a known position for the cover internally.",
+ "fields": {
+ "entity_id": {
+ "name": "Entity ID",
+ "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."
+ }
+ }
+ },
+ "set_known_tilt_position": {
+ "name": "Set cover tilt position",
+ "description": "Sets a known tilt position for the cover internally.",
+ "fields": {
+ "entity_id": {
+ "name": "Entity ID",
+ "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."
+ }
+ }
+ }
+ }
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add custom_components/cover_time_based/strings.json
+git commit -m "feat: add config flow and subentry translations"
+```
+
+---
+
+### Task 6: Deploy and test
+
+**Step 1: Run lint**
+
+Run: `ruff check custom_components/cover_time_based/`
+Fix any issues (do NOT run `ruff format`).
+
+**Step 2: Run type checker**
+
+Run: `npx pyright`
+Fix any fixable type errors.
+
+**Step 3: Deploy to HA**
+
+```bash
+rm -Rf /workspaces/homeassistant-core/config/custom_components/fado && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/
+```
+
+Wait — this is the wrong project. The deploy command for this project should be:
+
+```bash
+rm -Rf /workspaces/homeassistant-core/config/custom_components/cover_time_based && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/
+```
+
+**Step 4: Verify in HA**
+
+- Restart HA
+- Go to Settings → Devices & Services → Add Integration → "Cover Time Based"
+- Verify the config flow shows up
+- Create an integration entry
+- Add a cover subentry via the UI
+- Verify the cover entity appears
+- Test the options flow (edit defaults)
+- Test reconfigure on the subentry
+
+---
+
+### Task 7: Create PR
+
+**Step 1: Push branch and create PR**
+
+```bash
+git push -u origin feat/ui-config-flow
+```
+
+Create PR with summary of changes:
+- New config flow with subentry support
+- Options flow for integration-level defaults
+- YAML backward compatibility maintained
+- Translations for all flow steps
diff --git a/docs/plans/2026-02-15-input-mode-subclasses-design.md b/docs/plans/2026-02-15-input-mode-subclasses-design.md
new file mode 100644
index 0000000..314518e
--- /dev/null
+++ b/docs/plans/2026-02-15-input-mode-subclasses-design.md
@@ -0,0 +1,83 @@
+# Input Mode Subclasses Design
+
+## Goal
+
+Refactor `CoverTimeBased` into subclasses split by device type (switch vs cover entity) and input mode (switch/pulse/toggle) for both code clarity and extensibility.
+
+## Class Hierarchy
+
+```
+CoverTimeBased (abstract base)
+├── WrappedCoverTimeBased (cover entity delegation)
+└── SwitchCoverTimeBased (abstract, owns switch entity IDs)
+ ├── SwitchModeCover (latching relays)
+ ├── PulseModeCover (momentary pulse)
+ └── ToggleModeCover (same button stops)
+```
+
+- `CoverTimeBased`: Position/tilt tracking, timing, easing, startup delays. Defines abstract `_send_open()`, `_send_close()`, `_send_stop()`.
+- `WrappedCoverTimeBased`: Delegates open/close/stop to an underlying cover entity.
+- `SwitchCoverTimeBased`: Holds open/close/stop switch entity IDs. Still abstract — relay control differs per input mode.
+- `SwitchModeCover`: Latching relays — open turns on open relay, off close relay; stop turns both off.
+- `PulseModeCover`: Momentary pulse — pulses the appropriate switch for `pulse_time` seconds.
+- `ToggleModeCover`: Same button starts and stops. Overrides `async_open_cover`/`async_close_cover` so "same direction while moving = stop".
+
+## Base Class Behavior
+
+- **Stop before direction change** moves to the base class for ALL modes, not just toggle. If the cover is opening and you call close (or vice versa), it stops first.
+- **Same direction = stop** stays toggle-only, implemented as an override in `ToggleModeCover`.
+- Base class `async_stop_cover` simplifies — the toggle-specific guard logic moves to the subclass.
+
+## File Layout
+
+```
+custom_components/cover_time_based/
+├── cover.py → async_setup_entry, async_setup_platform, factory
+├── cover_base.py → CoverTimeBased (abstract base)
+├── cover_wrapped.py → WrappedCoverTimeBased
+├── cover_switch.py → SwitchCoverTimeBased (abstract mid-level)
+├── cover_switch_mode.py → SwitchModeCover
+├── cover_pulse_mode.py → PulseModeCover
+├── cover_toggle_mode.py → ToggleModeCover
+```
+
+## Factory Function
+
+`cover.py` contains the factory that picks the right subclass based on config:
+
+```python
+def _create_cover(options: dict, hass: HomeAssistant, ...) -> CoverTimeBased:
+ device_type = options.get(CONF_DEVICE_TYPE, DEVICE_TYPE_SWITCH)
+
+ if device_type == DEVICE_TYPE_COVER:
+ return WrappedCoverTimeBased(...)
+
+ input_mode = options.get(CONF_INPUT_MODE, INPUT_MODE_SWITCH)
+ if input_mode == INPUT_MODE_PULSE:
+ return PulseModeCover(...)
+ elif input_mode == INPUT_MODE_TOGGLE:
+ return ToggleModeCover(...)
+ else:
+ return SwitchModeCover(...)
+```
+
+Both `async_setup_entry` and `async_setup_platform` (YAML) use this factory. The factory accepts an options dict directly — `async_setup_entry` passes `entry.options`, YAML builds the dict manually.
+
+## YAML Backward Compatibility
+
+YAML is already deprecated. The YAML path maps its config keys into the same options dict format and calls the same factory. No new features added to YAML. Since YAML is always switch-based, it maps `device_type` to `DEVICE_TYPE_SWITCH` and reads `input_mode` from config.
+
+## Testing Approach
+
+Each subclass gets its own test file:
+
+```
+tests/
+├── test_cover_base.py → shared behavior (stop-before-direction-change, position tracking)
+├── test_cover_wrapped.py → delegation to underlying cover entity
+├── test_cover_switch_mode.py → latching relay on/off
+├── test_cover_pulse_mode.py → momentary pulse timing
+├── test_cover_toggle_mode.py → same-direction-stops, stop-before-reverse
+```
+
+Existing tests run first against the new code to confirm no behavior change, then subclass-specific tests are added.
diff --git a/docs/plans/2026-02-15-input-mode-subclasses-plan.md b/docs/plans/2026-02-15-input-mode-subclasses-plan.md
new file mode 100644
index 0000000..401fa6a
--- /dev/null
+++ b/docs/plans/2026-02-15-input-mode-subclasses-plan.md
@@ -0,0 +1,1483 @@
+# Input Mode Subclasses Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Refactor the monolithic `CoverTimeBased` class (~1400 lines) into a class hierarchy split by device type and input mode, with each subclass in its own file.
+
+**Architecture:** Two-level inheritance — device type first (wrapped cover vs switch-based), then input mode (switch/pulse/toggle). Abstract `_send_open`/`_send_close`/`_send_stop` methods replace the monolithic `_async_handle_command`. "Stop before direction change" moves to the base class for all modes; "same direction = stop" stays toggle-only.
+
+**Tech Stack:** Python, Home Assistant CoverEntity/RestoreEntity, xknx TravelCalculator, pytest with unittest.mock
+
+**Important:** Do NOT use `ruff format` — only `ruff check --fix`.
+
+---
+
+## Task 1: Set Up Test Infrastructure
+
+**Files:**
+- Create: `tests/__init__.py`
+- Create: `tests/conftest.py`
+- Create: `tests/test_relay_commands.py`
+
+**Step 1: Create test directory and conftest**
+
+```python
+# tests/__init__.py
+# (empty)
+```
+
+```python
+# tests/conftest.py
+"""Shared test fixtures for cover_time_based tests."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from custom_components.cover_time_based.cover import (
+ CoverTimeBased,
+ CONF_DEVICE_TYPE,
+ CONF_INPUT_MODE,
+ CONF_OPEN_SWITCH_ENTITY_ID,
+ CONF_CLOSE_SWITCH_ENTITY_ID,
+ CONF_STOP_SWITCH_ENTITY_ID,
+ CONF_COVER_ENTITY_ID,
+ CONF_PULSE_TIME,
+ CONF_TRAVELLING_TIME_DOWN,
+ CONF_TRAVELLING_TIME_UP,
+ DEFAULT_PULSE_TIME,
+ DEFAULT_TRAVEL_TIME,
+ DEVICE_TYPE_COVER,
+ DEVICE_TYPE_SWITCH,
+ INPUT_MODE_PULSE,
+ INPUT_MODE_SWITCH,
+ INPUT_MODE_TOGGLE,
+)
+
+
+def make_hass():
+ """Create a mock Home Assistant instance."""
+ hass = MagicMock()
+ hass.services = MagicMock()
+ hass.services.async_call = AsyncMock()
+ hass.async_create_task = lambda coro: asyncio.ensure_future(coro)
+ return hass
+
+
+import asyncio
+
+
+def make_cover(
+ input_mode=INPUT_MODE_SWITCH,
+ cover_entity_id=None,
+ open_switch="switch.open",
+ close_switch="switch.close",
+ stop_switch=None,
+ pulse_time=DEFAULT_PULSE_TIME,
+ travel_time_down=DEFAULT_TRAVEL_TIME,
+ travel_time_up=DEFAULT_TRAVEL_TIME,
+):
+ """Create a CoverTimeBased instance for testing."""
+ return CoverTimeBased(
+ device_id="test_cover",
+ name="Test Cover",
+ travel_moves_with_tilt=False,
+ travel_time_down=travel_time_down,
+ travel_time_up=travel_time_up,
+ tilt_time_down=None,
+ tilt_time_up=None,
+ travel_delay_at_end=None,
+ min_movement_time=None,
+ travel_startup_delay=None,
+ tilt_startup_delay=None,
+ open_switch_entity_id=open_switch if cover_entity_id is None else None,
+ close_switch_entity_id=close_switch if cover_entity_id is None else None,
+ stop_switch_entity_id=stop_switch if cover_entity_id is None else None,
+ input_mode=input_mode,
+ pulse_time=pulse_time,
+ cover_entity_id=cover_entity_id,
+ )
+```
+
+**Step 2: Write characterization tests for relay commands**
+
+```python
+# tests/test_relay_commands.py
+"""Characterization tests for _async_handle_command relay behavior.
+
+These tests capture the existing behavior of each input mode
+so we can verify the refactor doesn't change anything.
+"""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+import pytest
+
+from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_STOP_COVER
+
+from tests.conftest import make_cover, make_hass
+
+
+@pytest.fixture
+def hass():
+ return make_hass()
+
+
+# --- Switch Mode (latching relays) ---
+
+class TestSwitchModeRelays:
+ """Switch mode: relays stay on/off until explicitly changed."""
+
+ @pytest.mark.asyncio
+ async def test_close_turns_off_open_turns_on_close(self, hass):
+ cover = make_cover(input_mode="switch")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_CLOSE_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_open_turns_off_close_turns_on_open(self, hass):
+ cover = make_cover(input_mode="switch")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_OPEN_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_turns_off_both(self, hass):
+ cover = make_cover(input_mode="switch")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_with_stop_switch_turns_it_on(self, hass):
+ cover = make_cover(input_mode="switch", stop_switch="switch.stop")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.stop"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_close_with_stop_switch_turns_it_off(self, hass):
+ cover = make_cover(input_mode="switch", stop_switch="switch.stop")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_CLOSE_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.stop"}, False) in calls
+
+
+# --- Pulse Mode (momentary press) ---
+
+class TestPulseModeRelays:
+ """Pulse mode: press button briefly, then release."""
+
+ @pytest.mark.asyncio
+ async def test_close_pulses_close_switch(self, hass):
+ cover = make_cover(input_mode="pulse", pulse_time=0.01)
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_CLOSE_COVER)
+ calls = hass.services.async_call.call_args_list
+ # Should turn on close, then turn it off after pulse
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_open_pulses_open_switch(self, hass):
+ cover = make_cover(input_mode="pulse", pulse_time=0.01)
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_OPEN_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_with_stop_switch_pulses_it(self, hass):
+ cover = make_cover(input_mode="pulse", stop_switch="switch.stop", pulse_time=0.01)
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.stop"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.stop"}, False) in calls
+
+
+# --- Toggle Mode (same button starts and stops) ---
+
+class TestToggleModeRelays:
+ """Toggle mode: press direction button to start, press again to stop."""
+
+ @pytest.mark.asyncio
+ async def test_close_pulses_close_switch(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_CLOSE_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_after_close_pulses_close_switch(self, hass):
+ """In toggle mode, stop re-presses the last direction button."""
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ cover._last_command = SERVICE_CLOSE_COVER
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_after_open_pulses_open_switch(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ cover._last_command = SERVICE_OPEN_COVER
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_stop_with_no_last_command_does_nothing(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ cover._last_command = None
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ hass.services.async_call.assert_not_called()
+
+
+# --- Wrapped Cover Mode ---
+
+class TestWrappedCoverRelays:
+ """Wrapped cover: delegates to underlying cover entity."""
+
+ @pytest.mark.asyncio
+ async def test_close_calls_cover_close(self, hass):
+ cover = make_cover(cover_entity_id="cover.bedroom")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_CLOSE_COVER)
+ hass.services.async_call.assert_called_with(
+ "cover", "close_cover", {"entity_id": "cover.bedroom"}, False
+ )
+
+ @pytest.mark.asyncio
+ async def test_open_calls_cover_open(self, hass):
+ cover = make_cover(cover_entity_id="cover.bedroom")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_OPEN_COVER)
+ hass.services.async_call.assert_called_with(
+ "cover", "open_cover", {"entity_id": "cover.bedroom"}, False
+ )
+
+ @pytest.mark.asyncio
+ async def test_stop_calls_cover_stop(self, hass):
+ cover = make_cover(cover_entity_id="cover.bedroom")
+ cover.hass = hass
+ await cover._async_handle_command(SERVICE_STOP_COVER)
+ hass.services.async_call.assert_called_with(
+ "cover", "stop_cover", {"entity_id": "cover.bedroom"}, False
+ )
+```
+
+**Step 3: Run tests to verify they pass against current code**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_relay_commands.py -v`
+Expected: All tests PASS
+
+**Step 4: Commit**
+
+```bash
+git add tests/
+git commit -m "test: add characterization tests for relay command behavior"
+```
+
+---
+
+## Task 2: Write Toggle Behavior Tests
+
+**Files:**
+- Create: `tests/test_toggle_behavior.py`
+
+**Step 1: Write tests for toggle-specific behavior**
+
+```python
+# tests/test_toggle_behavior.py
+"""Characterization tests for toggle-specific open/close/stop behavior."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_STOP_COVER
+
+from tests.conftest import make_cover, make_hass
+
+
+@pytest.fixture
+def hass():
+ return make_hass()
+
+
+class TestToggleCloseWhileMoving:
+ """Toggle mode: close while already closing = stop."""
+
+ @pytest.mark.asyncio
+ async def test_close_while_closing_stops(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ # Simulate cover currently closing
+ cover.travel_calc.start_travel_down()
+ cover._last_command = SERVICE_CLOSE_COVER
+
+ with patch.object(cover, "async_stop_cover", new_callable=AsyncMock) as mock_stop:
+ await cover.async_close_cover()
+ mock_stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_open_while_opening_stops(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ cover.travel_calc.start_travel_up()
+ cover._last_command = SERVICE_OPEN_COVER
+
+ with patch.object(cover, "async_stop_cover", new_callable=AsyncMock) as mock_stop:
+ await cover.async_open_cover()
+ mock_stop.assert_called_once()
+
+
+class TestToggleStopGuard:
+ """Toggle mode: stop only sends relay command if something was active."""
+
+ @pytest.mark.asyncio
+ async def test_stop_when_idle_no_relay_command(self, hass):
+ """Toggle mode: calling stop when idle should NOT send relay command."""
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ # Nothing active — idle state
+ await cover.async_stop_cover()
+ hass.services.async_call.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_switch_mode_stop_when_idle_sends_relay_command(self, hass):
+ """Switch mode: calling stop when idle SHOULD send relay command."""
+ cover = make_cover(input_mode="switch")
+ cover.hass = hass
+ await cover.async_stop_cover()
+ assert hass.services.async_call.called
+
+
+class TestStopBeforeDirectionChange:
+ """All modes: stop before reversing direction (new base class behavior)."""
+
+ @pytest.mark.asyncio
+ async def test_close_while_opening_stops_first_toggle(self, hass):
+ cover = make_cover(input_mode="toggle", pulse_time=0.01)
+ cover.hass = hass
+ cover.travel_calc.start_travel_up()
+ cover._last_command = SERVICE_OPEN_COVER
+
+ with patch.object(cover, "async_stop_cover", new_callable=AsyncMock) as mock_stop:
+ await cover.async_close_cover()
+ mock_stop.assert_called_once()
+```
+
+**Step 2: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_toggle_behavior.py -v`
+Expected: All tests PASS
+
+**Step 3: Commit**
+
+```bash
+git add tests/test_toggle_behavior.py
+git commit -m "test: add characterization tests for toggle-specific behavior"
+```
+
+---
+
+## Task 3: Extract Base Class to cover_base.py
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_base.py`
+- Modify: `custom_components/cover_time_based/cover.py`
+
+**Step 1: Create cover_base.py**
+
+Move the `CoverTimeBased` class from `cover.py` to `cover_base.py`. Add abstract `_send_open`, `_send_close`, `_send_stop` methods. Refactor `_async_handle_command` to dispatch to them.
+
+Key changes to `_async_handle_command`:
+```python
+async def _async_handle_command(self, command, *args):
+ if command == SERVICE_CLOSE_COVER:
+ self._state = False
+ await self._send_close()
+ elif command == SERVICE_OPEN_COVER:
+ self._state = True
+ await self._send_open()
+ elif command == SERVICE_STOP_COVER:
+ self._state = True
+ await self._send_stop()
+ _LOGGER.debug("_async_handle_command :: %s", command)
+ self.async_write_ha_state()
+```
+
+Abstract methods added:
+```python
+from abc import abstractmethod
+
+@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."""
+```
+
+The base class keeps ALL existing logic: position tracking, tilt, auto-updater, delayed stops, startup delays, toggle guards in async_close_cover/async_open_cover/async_stop_cover. Constants, schemas, and YAML functions stay in `cover.py`.
+
+**Step 2: Update cover.py**
+
+Remove the class body from cover.py. Import `CoverTimeBased` from `cover_base`. Keep constants, schemas, `devices_from_config`, `async_setup_platform`, `async_setup_entry` in cover.py. The `CoverTimeBased` import from cover_base needs to be re-exported for backward compat.
+
+**Step 3: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/ -v`
+Expected: Tests fail because CoverTimeBased is now abstract (can't instantiate directly)
+
+**Step 4: Create a concrete test subclass in conftest**
+
+Update `tests/conftest.py` to create a concrete subclass for testing that implements the abstract methods with the current behavior:
+
+```python
+class ConcreteTestCover(CoverTimeBased):
+ """Concrete subclass for testing — reimplements original _async_handle_command relay logic."""
+
+ async def _send_open(self):
+ 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:
+ await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self._stop_switch_entity_id}, False)
+ if self._input_mode in ("pulse", "toggle"):
+ await asyncio.sleep(self._pulse_time)
+ await self.hass.services.async_call("homeassistant", "turn_off", {"entity_id": self._open_switch_entity_id}, False)
+
+ async def _send_close(self):
+ # Mirror of original close logic
+ ...
+
+ async def _send_stop(self):
+ # Mirror of original stop logic
+ ...
+```
+
+Update `make_cover` to return `ConcreteTestCover` instead of `CoverTimeBased`.
+
+**Step 5: Run tests again**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/ -v`
+Expected: All tests PASS
+
+**Step 6: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_base.py custom_components/cover_time_based/cover.py tests/conftest.py
+git commit -m "refactor: extract CoverTimeBased base class with abstract relay methods"
+```
+
+---
+
+## Task 4: Create WrappedCoverTimeBased
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_wrapped.py`
+- Create: `tests/test_cover_wrapped.py`
+
+**Step 1: Write failing tests**
+
+```python
+# tests/test_cover_wrapped.py
+"""Tests for WrappedCoverTimeBased."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from custom_components.cover_time_based.cover_wrapped import WrappedCoverTimeBased
+
+
+@pytest.fixture
+def hass():
+ from tests.conftest import make_hass
+ return make_hass()
+
+
+def make_wrapped_cover(cover_entity_id="cover.bedroom"):
+ return WrappedCoverTimeBased(
+ device_id="test_wrapped",
+ name="Test Wrapped",
+ travel_moves_with_tilt=False,
+ travel_time_down=30,
+ travel_time_up=30,
+ tilt_time_down=None,
+ tilt_time_up=None,
+ travel_delay_at_end=None,
+ min_movement_time=None,
+ travel_startup_delay=None,
+ tilt_startup_delay=None,
+ cover_entity_id=cover_entity_id,
+ )
+
+
+class TestWrappedCover:
+ @pytest.mark.asyncio
+ async def test_send_close_delegates(self, hass):
+ cover = make_wrapped_cover()
+ cover.hass = hass
+ await cover._send_close()
+ hass.services.async_call.assert_called_with(
+ "cover", "close_cover", {"entity_id": "cover.bedroom"}, False
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_open_delegates(self, hass):
+ cover = make_wrapped_cover()
+ cover.hass = hass
+ await cover._send_open()
+ hass.services.async_call.assert_called_with(
+ "cover", "open_cover", {"entity_id": "cover.bedroom"}, False
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_stop_delegates(self, hass):
+ cover = make_wrapped_cover()
+ cover.hass = hass
+ await cover._send_stop()
+ hass.services.async_call.assert_called_with(
+ "cover", "stop_cover", {"entity_id": "cover.bedroom"}, False
+ )
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_wrapped.py -v`
+Expected: FAIL — `cover_wrapped` module doesn't exist
+
+**Step 3: Implement WrappedCoverTimeBased**
+
+```python
+# custom_components/cover_time_based/cover_wrapped.py
+"""Wrapped cover — delegates open/close/stop to an underlying cover entity."""
+
+from .cover_base import CoverTimeBased
+
+
+class WrappedCoverTimeBased(CoverTimeBased):
+ """Cover that wraps an existing cover entity with time-based positioning."""
+
+ 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,
+ cover_entity_id,
+ ):
+ super().__init__(
+ device_id=device_id,
+ name=name,
+ travel_moves_with_tilt=travel_moves_with_tilt,
+ travel_time_down=travel_time_down,
+ travel_time_up=travel_time_up,
+ tilt_time_down=tilt_time_down,
+ tilt_time_up=tilt_time_up,
+ travel_delay_at_end=travel_delay_at_end,
+ min_movement_time=min_movement_time,
+ travel_startup_delay=travel_startup_delay,
+ tilt_startup_delay=tilt_startup_delay,
+ )
+ self._cover_entity_id = cover_entity_id
+
+ async def _send_open(self) -> None:
+ await self.hass.services.async_call(
+ "cover", "open_cover", {"entity_id": self._cover_entity_id}, False
+ )
+
+ async def _send_close(self) -> None:
+ await self.hass.services.async_call(
+ "cover", "close_cover", {"entity_id": self._cover_entity_id}, False
+ )
+
+ async def _send_stop(self) -> None:
+ await self.hass.services.async_call(
+ "cover", "stop_cover", {"entity_id": self._cover_entity_id}, False
+ )
+```
+
+Note: The base class constructor signature must be updated to remove switch/toggle-specific params. The base class should only accept the common timing/position params. Switch entity IDs, input_mode, pulse_time, and cover_entity_id move to their respective subclasses.
+
+**Step 4: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_wrapped.py -v`
+Expected: All PASS
+
+**Step 5: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_wrapped.py tests/test_cover_wrapped.py
+git commit -m "feat: add WrappedCoverTimeBased subclass"
+```
+
+---
+
+## Task 5: Create SwitchCoverTimeBased (Abstract Mid-Level)
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_switch.py`
+
+**Step 1: Implement SwitchCoverTimeBased**
+
+```python
+# custom_components/cover_time_based/cover_switch.py
+"""Abstract base for switch-controlled covers."""
+
+from .cover_base import CoverTimeBased
+
+
+class SwitchCoverTimeBased(CoverTimeBased):
+ """Abstract base for covers controlled via switch entities."""
+
+ 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,
+ ):
+ super().__init__(
+ device_id=device_id,
+ name=name,
+ travel_moves_with_tilt=travel_moves_with_tilt,
+ travel_time_down=travel_time_down,
+ travel_time_up=travel_time_up,
+ tilt_time_down=tilt_time_down,
+ tilt_time_up=tilt_time_up,
+ travel_delay_at_end=travel_delay_at_end,
+ min_movement_time=min_movement_time,
+ travel_startup_delay=travel_startup_delay,
+ 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
+```
+
+This is still abstract — `_send_open`/`_send_close`/`_send_stop` are not implemented.
+
+**Step 2: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_switch.py
+git commit -m "refactor: add SwitchCoverTimeBased abstract mid-level class"
+```
+
+---
+
+## Task 6: Create SwitchModeCover
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_switch_mode.py`
+- Create: `tests/test_cover_switch_mode.py`
+
+**Step 1: Write failing tests**
+
+```python
+# tests/test_cover_switch_mode.py
+"""Tests for SwitchModeCover — latching relay behavior."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, call
+
+import pytest
+
+from custom_components.cover_time_based.cover_switch_mode import SwitchModeCover
+from tests.conftest import make_hass
+
+
+def make_switch_cover(stop_switch=None):
+ return SwitchModeCover(
+ device_id="test_switch",
+ name="Test Switch",
+ travel_moves_with_tilt=False,
+ travel_time_down=30,
+ travel_time_up=30,
+ tilt_time_down=None,
+ tilt_time_up=None,
+ travel_delay_at_end=None,
+ min_movement_time=None,
+ travel_startup_delay=None,
+ tilt_startup_delay=None,
+ open_switch_entity_id="switch.open",
+ close_switch_entity_id="switch.close",
+ stop_switch_entity_id=stop_switch,
+ )
+
+
+@pytest.fixture
+def hass():
+ return make_hass()
+
+
+class TestSwitchModeCover:
+ @pytest.mark.asyncio
+ async def test_send_close(self, hass):
+ cover = make_switch_cover()
+ cover.hass = hass
+ await cover._send_close()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_open(self, hass):
+ cover = make_switch_cover()
+ cover.hass = hass
+ await cover._send_open()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_no_stop_switch(self, hass):
+ cover = make_switch_cover()
+ cover.hass = hass
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+ assert len(calls) == 2
+
+ @pytest.mark.asyncio
+ async def test_send_stop_with_stop_switch(self, hass):
+ cover = make_switch_cover(stop_switch="switch.stop")
+ cover.hass = hass
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.stop"}, False) in calls
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_switch_mode.py -v`
+Expected: FAIL — module doesn't exist
+
+**Step 3: Implement SwitchModeCover**
+
+```python
+# custom_components/cover_time_based/cover_switch_mode.py
+"""Switch mode cover — latching relays stay on/off."""
+
+from .cover_switch import SwitchCoverTimeBased
+
+
+class SwitchModeCover(SwitchCoverTimeBased):
+ """Cover controlled via latching relays (switch mode)."""
+
+ async def _send_open(self) -> None:
+ 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
+ )
+
+ async def _send_close(self) -> None:
+ 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
+ )
+
+ async def _send_stop(self) -> None:
+ 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
+ )
+```
+
+**Step 4: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_switch_mode.py -v`
+Expected: All PASS
+
+**Step 5: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_switch_mode.py tests/test_cover_switch_mode.py
+git commit -m "feat: add SwitchModeCover subclass for latching relays"
+```
+
+---
+
+## Task 7: Create PulseModeCover
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_pulse_mode.py`
+- Create: `tests/test_cover_pulse_mode.py`
+
+**Step 1: Write failing tests**
+
+```python
+# tests/test_cover_pulse_mode.py
+"""Tests for PulseModeCover — momentary pulse behavior."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, call
+
+import pytest
+
+from custom_components.cover_time_based.cover_pulse_mode import PulseModeCover
+from tests.conftest import make_hass
+
+
+def make_pulse_cover(stop_switch=None, pulse_time=0.01):
+ return PulseModeCover(
+ device_id="test_pulse",
+ name="Test Pulse",
+ travel_moves_with_tilt=False,
+ travel_time_down=30,
+ travel_time_up=30,
+ tilt_time_down=None,
+ tilt_time_up=None,
+ travel_delay_at_end=None,
+ min_movement_time=None,
+ travel_startup_delay=None,
+ tilt_startup_delay=None,
+ open_switch_entity_id="switch.open",
+ close_switch_entity_id="switch.close",
+ stop_switch_entity_id=stop_switch,
+ pulse_time=pulse_time,
+ )
+
+
+@pytest.fixture
+def hass():
+ return make_hass()
+
+
+class TestPulseModeCover:
+ @pytest.mark.asyncio
+ async def test_send_close_pulses(self, hass):
+ cover = make_pulse_cover()
+ cover.hass = hass
+ await cover._send_close()
+ calls = hass.services.async_call.call_args_list
+ # Turn on close, then turn off close (after pulse)
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_open_pulses(self, hass):
+ cover = make_pulse_cover()
+ cover.hass = hass
+ await cover._send_open()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_with_stop_switch_pulses(self, hass):
+ cover = make_pulse_cover(stop_switch="switch.stop")
+ cover.hass = hass
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.stop"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.stop"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_without_stop_switch(self, hass):
+ """Without stop switch, stop just turns off both direction switches."""
+ cover = make_pulse_cover()
+ cover.hass = hass
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.open"}, False) in calls
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_pulse_mode.py -v`
+Expected: FAIL
+
+**Step 3: Implement PulseModeCover**
+
+```python
+# custom_components/cover_time_based/cover_pulse_mode.py
+"""Pulse mode cover — momentary button press with separate stop."""
+
+from asyncio import sleep
+
+from .cover_switch import SwitchCoverTimeBased
+
+
+class PulseModeCover(SwitchCoverTimeBased):
+ """Cover controlled via momentary pulse buttons."""
+
+ def __init__(self, *, pulse_time, **kwargs):
+ super().__init__(**kwargs)
+ self._pulse_time = pulse_time
+
+ async def _send_open(self) -> None:
+ 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
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant", "turn_off", {"entity_id": self._open_switch_entity_id}, False
+ )
+
+ async def _send_close(self) -> None:
+ 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
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant", "turn_off", {"entity_id": self._close_switch_entity_id}, False
+ )
+
+ async def _send_stop(self) -> None:
+ 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
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant", "turn_off", {"entity_id": self._stop_switch_entity_id}, False
+ )
+```
+
+**Step 4: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_pulse_mode.py -v`
+Expected: All PASS
+
+**Step 5: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_pulse_mode.py tests/test_cover_pulse_mode.py
+git commit -m "feat: add PulseModeCover subclass for momentary buttons"
+```
+
+---
+
+## Task 8: Create ToggleModeCover
+
+**Files:**
+- Create: `custom_components/cover_time_based/cover_toggle_mode.py`
+- Create: `tests/test_cover_toggle_mode.py`
+
+**Step 1: Write failing tests**
+
+```python
+# tests/test_cover_toggle_mode.py
+"""Tests for ToggleModeCover — same button starts and stops."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+import pytest
+
+from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER
+
+from custom_components.cover_time_based.cover_toggle_mode import ToggleModeCover
+from tests.conftest import make_hass
+
+
+def make_toggle_cover(pulse_time=0.01):
+ return ToggleModeCover(
+ device_id="test_toggle",
+ name="Test Toggle",
+ travel_moves_with_tilt=False,
+ travel_time_down=30,
+ travel_time_up=30,
+ tilt_time_down=None,
+ tilt_time_up=None,
+ travel_delay_at_end=None,
+ min_movement_time=None,
+ travel_startup_delay=None,
+ tilt_startup_delay=None,
+ open_switch_entity_id="switch.open",
+ close_switch_entity_id="switch.close",
+ stop_switch_entity_id=None,
+ pulse_time=pulse_time,
+ )
+
+
+@pytest.fixture
+def hass():
+ return make_hass()
+
+
+class TestToggleModeRelays:
+ @pytest.mark.asyncio
+ async def test_send_close_pulses_close(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ await cover._send_close()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+ assert call("homeassistant", "turn_off", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_after_close_pulses_close(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ cover._last_command = SERVICE_CLOSE_COVER
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.close"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_after_open_pulses_open(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ cover._last_command = SERVICE_OPEN_COVER
+ await cover._send_stop()
+ calls = hass.services.async_call.call_args_list
+ assert call("homeassistant", "turn_on", {"entity_id": "switch.open"}, False) in calls
+
+ @pytest.mark.asyncio
+ async def test_send_stop_no_last_command_does_nothing(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ cover._last_command = None
+ await cover._send_stop()
+ hass.services.async_call.assert_not_called()
+
+
+class TestToggleModeOverrides:
+ """Toggle-specific: same direction while moving = stop."""
+
+ @pytest.mark.asyncio
+ async def test_close_while_closing_stops(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ cover.travel_calc.start_travel_down()
+ cover._last_command = SERVICE_CLOSE_COVER
+ with patch.object(cover, "async_stop_cover", new_callable=AsyncMock) as mock_stop:
+ await cover.async_close_cover()
+ mock_stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_open_while_opening_stops(self, hass):
+ cover = make_toggle_cover()
+ cover.hass = hass
+ cover.travel_calc.start_travel_up()
+ cover._last_command = SERVICE_OPEN_COVER
+ with patch.object(cover, "async_stop_cover", new_callable=AsyncMock) as mock_stop:
+ await cover.async_open_cover()
+ mock_stop.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_stop_when_idle_no_relay(self, hass):
+ """Toggle: stop when idle should NOT send relay command."""
+ cover = make_toggle_cover()
+ cover.hass = hass
+ await cover.async_stop_cover()
+ hass.services.async_call.assert_not_called()
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_toggle_mode.py -v`
+Expected: FAIL
+
+**Step 3: Implement ToggleModeCover**
+
+```python
+# custom_components/cover_time_based/cover_toggle_mode.py
+"""Toggle mode cover — same button starts and stops movement."""
+
+import logging
+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 where same button starts and stops movement."""
+
+ def __init__(self, *, pulse_time, **kwargs):
+ super().__init__(**kwargs)
+ self._pulse_time = pulse_time
+
+ async def _send_open(self) -> None:
+ 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
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant", "turn_off", {"entity_id": self._open_switch_entity_id}, False
+ )
+
+ async def _send_close(self) -> None:
+ 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
+ )
+ await sleep(self._pulse_time)
+ await self.hass.services.async_call(
+ "homeassistant", "turn_off", {"entity_id": self._close_switch_entity_id}, False
+ )
+
+ async def _send_stop(self) -> None:
+ 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("_send_stop :: toggle mode with no last command, skipping")
+
+ async def async_close_cover(self, **kwargs):
+ """Close: if already closing, treat as stop."""
+ if self.is_closing:
+ _LOGGER.debug("async_close_cover :: toggle mode, already closing, treating as stop")
+ await self.async_stop_cover()
+ return
+ await super().async_close_cover(**kwargs)
+
+ async def async_open_cover(self, **kwargs):
+ """Open: if already opening, treat as stop."""
+ if self.is_opening:
+ _LOGGER.debug("async_open_cover :: toggle mode, already opening, treating as stop")
+ await self.async_stop_cover()
+ return
+ await super().async_open_cover(**kwargs)
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop: only send relay command if something was actually 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())
+ )
+ self._cancel_startup_delay_task()
+ self._cancel_delay_task()
+ self._handle_stop()
+ self._enforce_tilt_constraints()
+ if was_active:
+ await self._send_stop()
+ self.async_write_ha_state()
+ self._last_command = None
+```
+
+**Step 4: Run tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/test_cover_toggle_mode.py -v`
+Expected: All PASS
+
+**Step 5: Commit**
+
+```bash
+git add custom_components/cover_time_based/cover_toggle_mode.py tests/test_cover_toggle_mode.py
+git commit -m "feat: add ToggleModeCover subclass with same-direction-stops"
+```
+
+---
+
+## Task 9: Update cover.py with Factory and Clean Up Base Class
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py`
+- Modify: `custom_components/cover_time_based/cover_base.py`
+
+**Step 1: Update cover.py**
+
+Replace `async_setup_entry` to use the factory function. Replace `devices_from_config` to use the factory. Remove the old `CoverTimeBased` class (now in `cover_base.py`). Keep constants, schemas, YAML deprecation.
+
+```python
+# In cover.py, the factory function:
+def _create_cover_from_options(options, hass_or_none=None, 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
+
+ device_type = options.get(CONF_DEVICE_TYPE, DEVICE_TYPE_SWITCH)
+
+ common = dict(
+ device_id=device_id,
+ name=name,
+ travel_moves_with_tilt=options.get(CONF_TRAVEL_MOVES_WITH_TILT, False),
+ travel_time_down=options.get(CONF_TRAVELLING_TIME_DOWN, DEFAULT_TRAVEL_TIME),
+ travel_time_up=options.get(CONF_TRAVELLING_TIME_UP, DEFAULT_TRAVEL_TIME),
+ tilt_time_down=options.get(CONF_TILTING_TIME_DOWN),
+ tilt_time_up=options.get(CONF_TILTING_TIME_UP),
+ travel_delay_at_end=options.get(CONF_TRAVEL_DELAY_AT_END),
+ min_movement_time=options.get(CONF_MIN_MOVEMENT_TIME),
+ travel_startup_delay=options.get(CONF_TRAVEL_STARTUP_DELAY),
+ tilt_startup_delay=options.get(CONF_TILT_STARTUP_DELAY),
+ )
+
+ if device_type == DEVICE_TYPE_COVER:
+ return WrappedCoverTimeBased(
+ cover_entity_id=options[CONF_COVER_ENTITY_ID],
+ **common,
+ )
+
+ switch_args = dict(
+ open_switch_entity_id=options[CONF_OPEN_SWITCH_ENTITY_ID],
+ close_switch_entity_id=options[CONF_CLOSE_SWITCH_ENTITY_ID],
+ stop_switch_entity_id=options.get(CONF_STOP_SWITCH_ENTITY_ID),
+ **common,
+ )
+
+ input_mode = options.get(CONF_INPUT_MODE, INPUT_MODE_SWITCH)
+ if input_mode == INPUT_MODE_PULSE:
+ return PulseModeCover(
+ pulse_time=options.get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME),
+ **switch_args,
+ )
+ elif input_mode == INPUT_MODE_TOGGLE:
+ return ToggleModeCover(
+ pulse_time=options.get(CONF_PULSE_TIME, DEFAULT_PULSE_TIME),
+ **switch_args,
+ )
+ else:
+ return SwitchModeCover(**switch_args)
+```
+
+Update `async_setup_entry`:
+```python
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ entity = _create_cover_from_options(
+ config_entry.options,
+ device_id=config_entry.entry_id,
+ name=config_entry.title,
+ )
+ async_add_entities([entity])
+ # ... register services
+```
+
+Update `devices_from_config` similarly to build an options dict and pass to factory.
+
+**Step 2: Clean up base class**
+
+Remove toggle/switch-specific code from `cover_base.py`:
+- Remove `self._input_mode`, `self._pulse_time`, `self._cover_entity_id`, `self._open_switch_entity_id`, etc. from base constructor
+- Remove toggle guard from `async_close_cover` and `async_open_cover` (toggle checks now in `ToggleModeCover`)
+- Simplify `async_stop_cover` — always send stop, toggle override handles the guard
+- Remove toggle guard from `set_known_position` and `set_known_tilt_position`
+
+Base class `async_stop_cover` becomes:
+```python
+async def async_stop_cover(self, **kwargs):
+ self._cancel_startup_delay_task()
+ self._cancel_delay_task()
+ self._handle_stop()
+ self._enforce_tilt_constraints()
+ await self._send_stop()
+ self.async_write_ha_state()
+ self._last_command = None
+```
+
+Base class `async_close_cover` — remove toggle block at top, add universal stop-before-direction-change:
+```python
+async def async_close_cover(self, **kwargs):
+ _LOGGER.debug("async_close_cover")
+
+ # Stop before direction change (all modes)
+ if self.is_opening:
+ _LOGGER.debug("async_close_cover :: currently opening, stopping first")
+ await self.async_stop_cover()
+
+ # ... rest of close logic unchanged
+```
+
+Same for `async_open_cover`:
+```python
+async def async_open_cover(self, **kwargs):
+ _LOGGER.debug("async_open_cover")
+
+ if self.is_closing:
+ _LOGGER.debug("async_open_cover :: currently closing, stopping first")
+ await self.async_stop_cover()
+
+ # ... rest of open logic unchanged
+```
+
+**Step 3: Update conftest.py and characterization tests**
+
+Update `tests/conftest.py` — remove the `ConcreteTestCover` monolith, update `make_cover` to use the factory or specific subclasses.
+
+Update `tests/test_relay_commands.py` to use specific subclasses instead of the generic `_async_handle_command`. Tests should still pass with the same assertions.
+
+**Step 4: Run all tests**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/ -v`
+Expected: All PASS
+
+**Step 5: Run ruff check**
+
+Run: `cd /workspaces/ha-cover-time-based && ruff check .`
+Expected: No errors (fix any that appear with `ruff check --fix .`)
+
+**Step 6: Commit**
+
+```bash
+git add custom_components/cover_time_based/ tests/
+git commit -m "refactor: wire up factory function and clean up base class
+
+- Replace monolithic _async_handle_command with subclass _send_open/_send_close/_send_stop
+- Move stop-before-direction-change to base class for all modes
+- Move same-direction-stops to ToggleModeCover override
+- Factory function creates correct subclass from options dict
+- Both UI config and YAML use same factory path"
+```
+
+---
+
+## Task 10: Update config_flow.py Imports
+
+**Files:**
+- Modify: `custom_components/cover_time_based/config_flow.py`
+
+**Step 1: Update imports**
+
+The config_flow currently imports constants from `cover.py`. Verify these still work after the refactor (constants should remain in `cover.py`). No code changes expected unless constants were moved.
+
+**Step 2: Run ruff check**
+
+Run: `cd /workspaces/ha-cover-time-based && ruff check .`
+
+**Step 3: Run pyright**
+
+Run: `cd /workspaces/ha-cover-time-based && npx pyright`
+Fix any new type errors in the refactored code.
+
+**Step 4: Run all tests one final time**
+
+Run: `cd /workspaces/ha-cover-time-based && python -m pytest tests/ -v`
+Expected: All PASS
+
+**Step 5: Commit (if any fixes needed)**
+
+```bash
+git add -A
+git commit -m "fix: resolve lint and type errors from refactor"
+```
+
+---
+
+## Task 11: Deploy and Manual Test
+
+**Step 1: Copy to HA**
+
+Run: `rm -Rf /workspaces/homeassistant-core/config/custom_components/fado && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/`
+
+Wait — this is the wrong copy command. The correct one for cover_time_based:
+
+Run: `rm -Rf /workspaces/homeassistant-core/config/custom_components/cover_time_based && cp -r /workspaces/ha-cover-time-based/custom_components/cover_time_based /workspaces/homeassistant-core/config/custom_components/`
+
+**Step 2: Restart HA and verify**
+
+- Check HA logs for errors on startup
+- Verify existing covers still work
+- Test each mode: switch, pulse, toggle, wrapped cover
diff --git a/docs/plans/2026-02-17-calibration-apis-design.md b/docs/plans/2026-02-17-calibration-apis-design.md
new file mode 100644
index 0000000..29efadc
--- /dev/null
+++ b/docs/plans/2026-02-17-calibration-apis-design.md
@@ -0,0 +1,163 @@
+# Calibration APIs Design
+
+## Overview
+
+Add `start_calibration` and `stop_calibration` services to help users measure and configure timing parameters for their covers. The services automate timing measurement and write results directly to the config entry.
+
+These services only work with config-entry-based covers (not YAML-configured covers, which are deprecated).
+
+A future dashboard configuration card will provide a UI on top of these APIs.
+
+## Services
+
+### `cover_time_based.start_calibration`
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `entity_id` | entity ID | yes | The cover to calibrate |
+| `attribute` | select | yes | The parameter to calibrate (see below) |
+| `timeout` | number (seconds) | yes | Safety timeout — auto-stops motor and discards results if exceeded |
+
+**Calibratable attributes:**
+
+| Attribute | Description |
+|---|---|
+| `travel_time_down` | Time for full downward travel |
+| `travel_time_up` | Time for full upward travel |
+| `tilt_time_down` | Time for full downward tilt (separate-phase covers only) |
+| `tilt_time_up` | Time for full upward tilt (separate-phase covers only) |
+| `travel_motor_overhead` | Motor startup/stop overhead per activation (travel) |
+| `tilt_motor_overhead` | Motor startup/stop overhead per activation (tilt) |
+| `min_movement_time` | Shortest relay activation that produces visible movement |
+
+**Validation on start:**
+
+- Fails if a calibration is already running on the entity.
+- Fails if the entity is not a config-entry-based cover.
+- Warns/fails if a prerequisite is missing:
+ - `travel_motor_overhead` requires `travel_time_down` or `travel_time_up` to be set.
+ - `tilt_motor_overhead` requires `tilt_time_down` or `tilt_time_up` to be set.
+ - `tilt_time_down`/`tilt_time_up` require `travel_moves_with_tilt=false` (separate-phase covers only).
+
+### `cover_time_based.stop_calibration`
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `entity_id` | entity ID | yes | The cover being calibrated |
+| `cancel` | boolean | no (default: false) | If true, stop motor and discard results without saving |
+
+**Three ways a test ends:**
+
+1. `stop_calibration(cancel=false)` — stop motor, calculate result, save to config entry, reload.
+2. `stop_calibration(cancel=true)` — stop motor, discard results.
+3. Timeout fires — stop motor, discard results, fire warning event.
+
+## Test Behaviors
+
+### Simple Time Tests (travel_time_down/up, tilt_time_down/up)
+
+1. `start_calibration` records a timestamp and starts moving the cover in the appropriate direction.
+2. The user watches the cover and calls `stop_calibration` when it reaches the desired endpoint.
+3. Elapsed time is saved as the attribute value.
+
+No position prerequisites are enforced — the user is responsible for starting the cover at the appropriate position (e.g. fully open before measuring `travel_time_down`).
+
+### Motor Overhead Tests (travel_motor_overhead, tilt_motor_overhead)
+
+These measure the time lost per relay activation due to motor startup inertia and stop overshoot.
+
+1. `start_calibration` begins an automated sequence:
+ - Move for 1/10th of the configured travel/tilt time.
+ - Pause for 2 seconds (`CALIBRATION_STEP_PAUSE`).
+ - Repeat.
+2. The user watches and calls `stop_calibration` when the cover reaches the endpoint.
+3. Calculation: if travel_time is 60s and each step is 6s, but it took 15 steps instead of 10, then 30s was lost across 15 activations. `overhead = (elapsed_movement_time - travel_time) / step_count` where `elapsed_movement_time = step_count * step_duration`.
+
+Wait, more precisely:
+- `step_duration = travel_time / 10`
+- `step_count` = number of steps completed when user calls stop
+- If the cover should have completed in 10 steps but took N steps: `overhead = ((N - 10) * step_duration) / N`
+- Simplified: `overhead = step_duration * (1 - 10/N)`
+
+Actually the simplest model:
+- Expected movement per step without overhead: `step_duration` (= travel_time / 10)
+- Actual movement per step: `travel_time / step_count` (since it took `step_count` steps to cover the full distance)
+- Lost time per step: `step_duration - (travel_time / step_count)`
+- So: `overhead = step_duration - (travel_time / step_count)`
+
+**One value for both directions** — motor overhead is a physical property of the motor, same in both directions. The test can be run in either direction.
+
+### Minimum Movement Time (min_movement_time)
+
+1. `start_calibration` begins an automated sequence:
+ - Send a 0.1s pulse, pause 2s.
+ - Send a 0.2s pulse, pause 2s.
+ - Send a 0.3s pulse, pause 2s.
+ - Continue incrementing by 0.1s.
+2. The user watches and calls `stop_calibration` when they first see the cover move.
+3. The duration of the last pulse sent is saved as `min_movement_time`.
+
+## Tilt and Cover Types
+
+Covers fall into two categories:
+
+1. **Separate-phase tilt** (`travel_moves_with_tilt=false`): Tilt happens as a distinct phase at the beginning or end of travel. The tilt mechanism engages before/after the travel mechanism. Tilt time needs to be measured independently — the user starts a close/open and marks when the tilt phase ends and travel begins.
+
+2. **Gradual tilt** (`travel_moves_with_tilt=true`): Tilt is proportional across the full travel range. `tilt_time = travel_time`, so no separate tilt time calibration is needed. The `tilt_time_down`/`tilt_time_up` calibration attributes are not available for these covers.
+
+## Config Refactor: Motor Overhead
+
+As part of this work, the existing timing parameters are merged:
+
+| Old parameters | New parameter |
+|---|---|
+| `travel_startup_delay` + `travel_delay_at_end` | `travel_motor_overhead` |
+| `tilt_startup_delay` | `tilt_motor_overhead` |
+
+The `travel_motor_overhead` value is applied as half before movement and half after movement internally. This simplifies the user-facing configuration from three values to two, and maps directly to what the calibration test measures.
+
+Since these APIs only work with config-entry-based covers and YAML is deprecated, this is a clean break with no backward-compatibility concerns.
+
+## State Management
+
+### Calibration state on the entity
+
+Stored as a private `_calibration` attribute (dataclass or dict):
+
+- `attribute` — what's being tested
+- `started_at` — timestamp
+- `timeout` — max duration
+- `timeout_task` — asyncio task for auto-stop on timeout
+- `step_count` — for overhead/min_movement tests, number of steps completed
+- `step_duration` — for overhead tests, duration of each step
+- `last_pulse_duration` — for min_movement_time, the most recent pulse length
+- `automation_task` — asyncio task running the automated step sequence
+
+### Extra state attributes (exposed while calibration is active)
+
+- `calibration_active: true`
+- `calibration_attribute: "travel_time_down"`
+- `calibration_step: 7` (for overhead/min_movement tests)
+
+These attributes enable a future dashboard card to show live calibration status.
+
+### Config update flow
+
+1. `stop_calibration` calculates the value.
+2. Updates `config_entry.options` with the new value.
+3. Calls `hass.config_entries.async_reload(entry.entry_id)` to apply.
+
+## Constants
+
+```python
+CALIBRATION_STEP_PAUSE = 2.0 # seconds between automated steps
+CALIBRATION_OVERHEAD_STEPS = 10 # number of steps per overhead test
+CALIBRATION_MIN_MOVEMENT_START = 0.1 # initial pulse duration for min_movement test
+CALIBRATION_MIN_MOVEMENT_INCREMENT = 0.1 # pulse duration increment
+```
+
+## Future Work
+
+- Dashboard configuration card providing a UI over these APIs
+- Visual feedback during calibration (e.g. progress indicators)
+- Guided calibration wizard that walks through all parameters in sequence
diff --git a/docs/plans/2026-02-17-calibration-apis-plan.md b/docs/plans/2026-02-17-calibration-apis-plan.md
new file mode 100644
index 0000000..d5b2453
--- /dev/null
+++ b/docs/plans/2026-02-17-calibration-apis-plan.md
@@ -0,0 +1,1130 @@
+# Calibration APIs Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add `start_calibration` and `stop_calibration` services that help users measure timing parameters by physically testing their covers, then auto-save the results to the config entry.
+
+**Architecture:** Two new entity services on `CoverTimeBased`. Calibration state lives as a `_calibration` dataclass on the entity. Three test types: simple timing (user-timed), motor overhead (automated 1/10th steps), and minimum movement time (incremental pulses). Results are written to `config_entry.options` and the entry is reloaded. As part of this work, `travel_startup_delay` + `travel_delay_at_end` are merged into `travel_motor_overhead`, and `tilt_startup_delay` becomes `tilt_motor_overhead`.
+
+**Tech Stack:** Python, Home Assistant config entries, asyncio, pytest, voluptuous schemas.
+
+**Design doc:** `docs/plans/2026-02-17-calibration-apis-design.md`
+
+---
+
+## Task 1: Merge startup delay + delay-at-end into motor overhead (config layer)
+
+Rename the config keys so the UI and storage use the new names. The internal behavior stays the same for now (split 50/50 into startup and end delay).
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:28-36` (constants)
+- Modify: `custom_components/cover_time_based/cover.py:148-170` (factory function)
+- Modify: `custom_components/cover_time_based/cover_base.py:34-42` (local constant copies)
+- Modify: `custom_components/cover_time_based/cover_base.py:46-91` (constructor)
+- Modify: `custom_components/cover_time_based/cover_base.py:187-217` (startup delay usage)
+- Modify: `custom_components/cover_time_based/cover_base.py:235-256` (extra_state_attributes)
+- Modify: `custom_components/cover_time_based/cover_base.py:751-791` (delay-at-end usage)
+- Modify: `custom_components/cover_time_based/config_flow.py` (options schema)
+- Modify: `custom_components/cover_time_based/strings.json`
+- Modify: `tests/conftest.py` (make_cover fixture)
+- Test: `tests/test_motor_overhead.py` (new)
+
+### Step 1: Write failing tests for motor overhead split
+
+Create `tests/test_motor_overhead.py` with tests that verify the 50/50 split behavior:
+
+```python
+"""Tests for travel_motor_overhead and tilt_motor_overhead config."""
+
+import pytest
+from unittest.mock import patch
+
+
+class TestTravelMotorOverhead:
+ """Test that travel_motor_overhead splits into startup delay and end delay."""
+
+ @pytest.mark.asyncio
+ async def test_overhead_splits_into_startup_and_end_delay(self, make_cover):
+ """Motor overhead of 2.0 should give 1.0 startup + 1.0 end delay."""
+ cover = make_cover(travel_motor_overhead=2.0)
+ assert cover._travel_startup_delay == 1.0
+ assert cover._travel_delay_at_end == 1.0
+
+ @pytest.mark.asyncio
+ async def test_no_overhead_gives_no_delays(self, make_cover):
+ """No motor overhead means no startup or end delay."""
+ cover = make_cover()
+ assert cover._travel_startup_delay is None
+ assert cover._travel_delay_at_end is None
+
+ @pytest.mark.asyncio
+ async def test_odd_overhead_splits_evenly(self, make_cover):
+ """Motor overhead of 1.5 should give 0.75 + 0.75."""
+ cover = make_cover(travel_motor_overhead=1.5)
+ assert cover._travel_startup_delay == 0.75
+ assert cover._travel_delay_at_end == 0.75
+
+
+class TestTiltMotorOverhead:
+ """Test that tilt_motor_overhead maps to tilt startup delay."""
+
+ @pytest.mark.asyncio
+ async def test_tilt_overhead_sets_startup_delay(self, make_cover):
+ """Tilt motor overhead should set the tilt startup delay."""
+ cover = make_cover(
+ tilt_time_down=5.0,
+ tilt_time_up=5.0,
+ tilt_motor_overhead=1.0,
+ )
+ assert cover._tilt_startup_delay == 0.5
+
+ @pytest.mark.asyncio
+ async def test_no_tilt_overhead(self, make_cover):
+ """No tilt motor overhead means no tilt startup delay."""
+ cover = make_cover(tilt_time_down=5.0, tilt_time_up=5.0)
+ assert cover._tilt_startup_delay is None
+```
+
+### Step 2: Run tests to verify they fail
+
+Run: `pytest tests/test_motor_overhead.py -v`
+Expected: FAIL — `make_cover` doesn't accept `travel_motor_overhead` or `tilt_motor_overhead` kwargs yet.
+
+### Step 3: Update constants in cover.py
+
+Replace in `cover.py` lines 33-36:
+```python
+# Old:
+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"
+
+# New:
+CONF_TRAVEL_MOTOR_OVERHEAD = "travel_motor_overhead"
+CONF_TILT_MOTOR_OVERHEAD = "tilt_motor_overhead"
+CONF_MIN_MOVEMENT_TIME = "min_movement_time"
+```
+
+Also remove the old constants. Keep `CONF_TRAVEL_DELAY_AT_END` and `CONF_TRAVEL_STARTUP_DELAY` and `CONF_TILT_STARTUP_DELAY` temporarily as aliases if needed for the YAML deprecation path, or remove them entirely if YAML parsing can be updated too.
+
+### Step 4: Update the factory function in cover.py
+
+In `_create_cover_from_options` (line 148), read the new config keys and split:
+
+```python
+travel_motor_overhead = options.get(CONF_TRAVEL_MOTOR_OVERHEAD)
+tilt_motor_overhead = options.get(CONF_TILT_MOTOR_OVERHEAD)
+
+common = dict(
+ ...
+ travel_delay_at_end=travel_motor_overhead / 2 if travel_motor_overhead else None,
+ min_movement_time=options.get(CONF_MIN_MOVEMENT_TIME),
+ travel_startup_delay=travel_motor_overhead / 2 if travel_motor_overhead else None,
+ tilt_startup_delay=tilt_motor_overhead / 2 if tilt_motor_overhead else None,
+)
+```
+
+Note: The `CoverTimeBased.__init__` signature stays the same internally — it still takes `travel_delay_at_end`, `travel_startup_delay`, `tilt_startup_delay` as separate values. The merge is done at the factory/config layer.
+
+### Step 5: Update conftest.py make_cover fixture
+
+Replace `travel_startup_delay`, `tilt_startup_delay`, `travel_delay_at_end` params with `travel_motor_overhead` and `tilt_motor_overhead`:
+
+```python
+def _make(
+ ...
+ travel_motor_overhead=None,
+ tilt_motor_overhead=None,
+ min_movement_time=None,
+):
+ ...
+ if travel_motor_overhead is not None:
+ options[CONF_TRAVEL_MOTOR_OVERHEAD] = travel_motor_overhead
+ if tilt_motor_overhead is not None:
+ options[CONF_TILT_MOTOR_OVERHEAD] = tilt_motor_overhead
+ if min_movement_time is not None:
+ options[CONF_MIN_MOVEMENT_TIME] = min_movement_time
+```
+
+### Step 6: Update extra_state_attributes in cover_base.py
+
+Replace the three separate attribute entries with two:
+```python
+if self._travel_startup_delay is not None:
+ overhead = (self._travel_startup_delay or 0) + (self._travel_delay_at_end or 0)
+ attr[CONF_TRAVEL_MOTOR_OVERHEAD] = overhead
+if self._tilt_startup_delay is not None:
+ attr[CONF_TILT_MOTOR_OVERHEAD] = self._tilt_startup_delay * 2
+```
+
+Or store `_travel_motor_overhead` and `_tilt_motor_overhead` as instance vars on the base class alongside the split values. This is cleaner:
+
+In `__init__`, add:
+```python
+self._travel_motor_overhead = travel_motor_overhead # stored for extra_state_attributes
+self._tilt_motor_overhead = tilt_motor_overhead
+```
+
+But wait — `__init__` doesn't receive the overhead values directly, it receives the split values. The cleanest approach: pass the overhead values through and store them, then derive the split values.
+
+Update `__init__` signature:
+```python
+def __init__(self, device_id, name, travel_moves_with_tilt, travel_time_down,
+ travel_time_up, tilt_time_down, tilt_time_up, travel_motor_overhead,
+ tilt_motor_overhead, min_movement_time):
+```
+
+And derive internally:
+```python
+self._travel_motor_overhead = travel_motor_overhead
+self._tilt_motor_overhead = tilt_motor_overhead
+self._min_movement_time = min_movement_time
+self._travel_startup_delay = travel_motor_overhead / 2 if travel_motor_overhead else None
+self._travel_delay_at_end = travel_motor_overhead / 2 if travel_motor_overhead else None
+self._tilt_startup_delay = tilt_motor_overhead / 2 if tilt_motor_overhead else None
+```
+
+Then the factory becomes simpler (just passes the raw values through) and `extra_state_attributes` can reference the overhead values directly.
+
+This requires updating all subclass constructors that call `super().__init__()` and the factory function.
+
+### Step 7: Update config_flow.py
+
+Replace `travel_startup_delay`, `travel_delay_at_end`, `tilt_startup_delay` with `travel_motor_overhead` and `tilt_motor_overhead` in the "Advanced" section of `_build_details_schema()`.
+
+### Step 8: Update strings.json and translations
+
+Replace the three old translation keys with two new ones.
+
+### Step 9: Update existing tests that use old parameter names
+
+Search all test files for `travel_startup_delay`, `travel_delay_at_end`, `tilt_startup_delay` and update to use `travel_motor_overhead` / `tilt_motor_overhead`.
+
+### Step 10: Run all tests
+
+Run: `pytest tests/ -v`
+Expected: ALL PASS
+
+### Step 11: Run linting
+
+Run: `ruff check . && ruff format . && npx pyright`
+
+### Step 12: Commit
+
+```bash
+git add -A
+git commit -m "refactor: merge startup/end delays into travel_motor_overhead and tilt_motor_overhead"
+```
+
+---
+
+## Task 2: Store config_entry reference on the entity
+
+The calibration services need to update `config_entry.options`. Currently the entity has no reference to its config entry.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover.py:311-322` (async_setup_entry)
+- Modify: `custom_components/cover_time_based/cover_base.py:46-91` (add _config_entry_id)
+- Test: `tests/test_calibration.py` (new — will grow across tasks)
+
+### Step 1: Write failing test
+
+```python
+"""Tests for calibration services."""
+
+import pytest
+from unittest.mock import patch, MagicMock
+
+
+class TestConfigEntryAccess:
+ """Test that config entry ID is available on the entity."""
+
+ @pytest.mark.asyncio
+ async def test_config_entry_id_stored(self, make_cover):
+ """Cover should store its config entry ID for later options update."""
+ cover = make_cover()
+ assert cover._config_entry_id is not None
+ assert cover._config_entry_id == "test_cover"
+```
+
+### Step 2: Run test to verify it fails
+
+Run: `pytest tests/test_calibration.py::TestConfigEntryAccess -v`
+Expected: FAIL — `_config_entry_id` attribute doesn't exist.
+
+### Step 3: Add _config_entry_id to CoverTimeBased.__init__
+
+In `cover_base.py`, add to `__init__`:
+```python
+self._config_entry_id = None # Set by async_setup_entry
+```
+
+### Step 4: Set it in async_setup_entry
+
+In `cover.py` `async_setup_entry`, after creating the entity:
+```python
+entity._config_entry_id = config_entry.entry_id
+```
+
+### Step 5: Set it in the test fixture
+
+In `conftest.py`, the `make_cover` fixture already passes `device_id="test_cover"`. Set `_config_entry_id` there too:
+```python
+cover = _create_cover_from_options(options, device_id="test_cover", name="Test Cover")
+cover.hass = make_hass()
+cover._config_entry_id = "test_cover"
+```
+
+### Step 6: Run test to verify it passes
+
+Run: `pytest tests/test_calibration.py::TestConfigEntryAccess -v`
+Expected: PASS
+
+### Step 7: Commit
+
+```bash
+git add -A
+git commit -m "feat: store config entry ID on cover entity for calibration support"
+```
+
+---
+
+## Task 3: Add CalibrationState dataclass and service schemas
+
+Define the data structures and voluptuous schemas for the two new services.
+
+**Files:**
+- Create: `custom_components/cover_time_based/calibration.py`
+- Modify: `custom_components/cover_time_based/cover.py` (add constants, schemas)
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests for CalibrationState
+
+```python
+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_down",
+ timeout=120.0,
+ )
+ assert state.attribute == "travel_time_down"
+ 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
+```
+
+### Step 2: Run test to verify it fails
+
+Run: `pytest tests/test_calibration.py::TestCalibrationState -v`
+Expected: FAIL — module doesn't exist.
+
+### Step 3: Create calibration.py
+
+```python
+"""Calibration support for cover_time_based."""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from asyncio import Task
+
+CALIBRATION_STEP_PAUSE = 2.0
+CALIBRATION_OVERHEAD_STEPS = 10
+CALIBRATION_MIN_MOVEMENT_START = 0.1
+CALIBRATION_MIN_MOVEMENT_INCREMENT = 0.1
+
+CALIBRATABLE_ATTRIBUTES = [
+ "travel_time_down",
+ "travel_time_up",
+ "tilt_time_down",
+ "tilt_time_up",
+ "travel_motor_overhead",
+ "tilt_motor_overhead",
+ "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
+ step_duration: float | None = None
+ last_pulse_duration: float | None = None
+ timeout_task: Task | None = field(default=None, repr=False)
+ automation_task: Task | None = field(default=None, repr=False)
+```
+
+### Step 4: Run test to verify it passes
+
+Run: `pytest tests/test_calibration.py::TestCalibrationState -v`
+Expected: PASS
+
+### Step 5: Commit
+
+```bash
+git add -A
+git commit -m "feat: add CalibrationState dataclass and calibration constants"
+```
+
+---
+
+## Task 4: Implement start_calibration for simple time tests
+
+Start with the simplest case: `travel_time_down` and `travel_time_up`. The service starts moving the cover and records the start time.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover_base.py` (add start_calibration, _calibration attr)
+- Modify: `custom_components/cover_time_based/cover.py` (register service)
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests
+
+```python
+class TestStartCalibrationTravelTime:
+ """Test start_calibration for travel_time_down/up."""
+
+ @pytest.mark.asyncio
+ async def test_start_travel_time_down(self, make_cover):
+ """Starting calibration for travel_time_down should move cover down."""
+ cover = make_cover()
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_time_down", timeout=120.0
+ )
+ assert cover._calibration is not None
+ assert cover._calibration.attribute == "travel_time_down"
+ cover.hass.services.async_call.assert_awaited()
+
+ @pytest.mark.asyncio
+ async def test_start_travel_time_up(self, make_cover):
+ """Starting calibration for travel_time_up should move cover up."""
+ cover = make_cover()
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_time_up", timeout=120.0
+ )
+ assert cover._calibration is not None
+ assert cover._calibration.attribute == "travel_time_up"
+
+ @pytest.mark.asyncio
+ async def test_cannot_start_while_calibrating(self, make_cover):
+ """Should raise if calibration already running."""
+ cover = make_cover()
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_time_down", timeout=120.0
+ )
+ with pytest.raises(Exception, match="[Cc]alibration already"):
+ await cover.start_calibration(
+ attribute="travel_time_up", timeout=120.0
+ )
+
+ @pytest.mark.asyncio
+ async def test_calibration_exposes_state_attributes(self, make_cover):
+ """Extra state attributes should include calibration status."""
+ cover = make_cover()
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_time_down", timeout=120.0
+ )
+ attrs = cover.extra_state_attributes
+ assert attrs["calibration_active"] is True
+ assert attrs["calibration_attribute"] == "travel_time_down"
+```
+
+### Step 2: Run tests to verify they fail
+
+Run: `pytest tests/test_calibration.py::TestStartCalibrationTravelTime -v`
+Expected: FAIL
+
+### Step 3: Implement start_calibration on CoverTimeBased
+
+In `cover_base.py`, add `_calibration = None` to `__init__`, then add:
+
+```python
+async def start_calibration(self, **kwargs):
+ """Start a calibration test for the specified attribute."""
+ attribute = kwargs["attribute"]
+ timeout = kwargs["timeout"]
+
+ if self._calibration is not None:
+ raise HomeAssistantError("Calibration already in progress")
+
+ from .calibration import CalibrationState
+ self._calibration = CalibrationState(attribute=attribute, timeout=timeout)
+
+ # Start timeout task
+ self._calibration.timeout_task = self.hass.async_create_task(
+ self._calibration_timeout()
+ )
+
+ # Start the appropriate test
+ if attribute in ("travel_time_down", "travel_time_up"):
+ await self._start_simple_time_test(attribute)
+ # Other types handled in later tasks
+
+ self.async_write_ha_state()
+
+async def _start_simple_time_test(self, attribute):
+ """Start a simple timing test — move in one direction."""
+ if attribute == "travel_time_down":
+ await self._async_handle_command(SERVICE_CLOSE_COVER)
+ else:
+ await self._async_handle_command(SERVICE_OPEN_COVER)
+
+async def _calibration_timeout(self):
+ """Auto-stop calibration after timeout."""
+ await sleep(self._calibration.timeout)
+ _LOGGER.warning("Calibration timed out for %s", self._calibration.attribute)
+ await self._send_stop()
+ self._calibration = None
+ self.async_write_ha_state()
+```
+
+Update `extra_state_attributes` to include calibration state:
+```python
+if self._calibration is not None:
+ attr["calibration_active"] = True
+ attr["calibration_attribute"] = self._calibration.attribute
+ if self._calibration.step_count > 0:
+ attr["calibration_step"] = self._calibration.step_count
+```
+
+### Step 4: Register the service in cover.py
+
+In `async_setup_entry`, add:
+```python
+platform.async_register_entity_service(
+ SERVICE_START_CALIBRATION,
+ vol.Schema({
+ vol.Required("attribute"): vol.In(CALIBRATABLE_ATTRIBUTES),
+ vol.Required("timeout"): vol.All(vol.Coerce(float), vol.Range(min=1)),
+ }),
+ "start_calibration",
+)
+```
+
+### Step 5: Run tests
+
+Run: `pytest tests/test_calibration.py::TestStartCalibrationTravelTime -v`
+Expected: PASS
+
+### Step 6: Commit
+
+```bash
+git add -A
+git commit -m "feat: implement start_calibration for simple travel time tests"
+```
+
+---
+
+## Task 5: Implement stop_calibration for simple time tests
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover_base.py`
+- Modify: `custom_components/cover_time_based/cover.py` (register service)
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests
+
+```python
+class TestStopCalibrationTravelTime:
+ """Test stop_calibration for travel_time_down/up."""
+
+ @pytest.mark.asyncio
+ async def test_stop_calculates_elapsed_time(self, make_cover):
+ """stop_calibration should calculate elapsed time."""
+ cover = make_cover()
+ cover._config_entry_id = "test_cover"
+
+ # Mock the config entry on hass
+ mock_entry = MagicMock()
+ mock_entry.options = dict(cover.hass.config_entries.async_get_entry().options or {})
+ cover.hass.config_entries.async_get_entry.return_value = mock_entry
+
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(attribute="travel_time_down", timeout=120.0)
+
+ # Fake elapsed time by backdating started_at
+ cover._calibration.started_at -= 45.0
+
+ result = await cover.stop_calibration()
+
+ 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):
+ """stop_calibration with cancel=True should discard results."""
+ cover = make_cover()
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(attribute="travel_time_down", timeout=120.0)
+ await cover.stop_calibration(cancel=True)
+ assert cover._calibration is None
+
+ @pytest.mark.asyncio
+ async def test_stop_without_active_calibration_raises(self, make_cover):
+ """stop_calibration with no active calibration should raise."""
+ cover = make_cover()
+ with pytest.raises(Exception, match="[Nn]o calibration"):
+ await cover.stop_calibration()
+```
+
+### Step 2: Run tests to verify they fail
+
+Run: `pytest tests/test_calibration.py::TestStopCalibrationTravelTime -v`
+Expected: FAIL
+
+### Step 3: Implement stop_calibration
+
+```python
+async def stop_calibration(self, **kwargs):
+ """Stop calibration and optionally save the result."""
+ if self._calibration is None:
+ raise HomeAssistantError("No calibration in progress")
+
+ cancel = kwargs.get("cancel", False)
+
+ # Cancel timeout task
+ if self._calibration.timeout_task and not self._calibration.timeout_task.done():
+ self._calibration.timeout_task.cancel()
+
+ # Cancel automation task if running
+ if self._calibration.automation_task 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["value"] = value
+ await self._save_calibration_result(self._calibration.attribute, value)
+
+ self._calibration = None
+ self.async_write_ha_state()
+ return result
+
+def _calculate_calibration_result(self):
+ """Calculate the calibration result based on test type."""
+ import time
+ elapsed = time.monotonic() - self._calibration.started_at
+ attribute = self._calibration.attribute
+
+ if attribute in ("travel_time_down", "travel_time_up",
+ "tilt_time_down", "tilt_time_up"):
+ return round(elapsed, 1)
+
+ # Other types handled in later tasks
+ return elapsed
+
+async def _save_calibration_result(self, attribute, value):
+ """Save the calibration result to config entry options."""
+ from .cover import CONF_TRAVELLING_TIME_DOWN, CONF_TRAVELLING_TIME_UP
+
+ # Map calibration attribute names to config entry option keys
+ ATTR_TO_CONF = {
+ "travel_time_down": CONF_TRAVELLING_TIME_DOWN,
+ "travel_time_up": CONF_TRAVELLING_TIME_UP,
+ "tilt_time_down": CONF_TILTING_TIME_DOWN,
+ "tilt_time_up": CONF_TILTING_TIME_UP,
+ "travel_motor_overhead": CONF_TRAVEL_MOTOR_OVERHEAD,
+ "tilt_motor_overhead": CONF_TILT_MOTOR_OVERHEAD,
+ "min_movement_time": CONF_MIN_MOVEMENT_TIME,
+ }
+
+ conf_key = ATTR_TO_CONF[attribute]
+ entry = self.hass.config_entries.async_get_entry(self._config_entry_id)
+ new_options = dict(entry.options)
+ new_options[conf_key] = value
+ self.hass.config_entries.async_update_entry(entry, options=new_options)
+```
+
+### Step 4: Register the service
+
+```python
+platform.async_register_entity_service(
+ SERVICE_STOP_CALIBRATION,
+ vol.Schema({
+ vol.Optional("cancel", default=False): cv.boolean,
+ }),
+ "stop_calibration",
+)
+```
+
+### Step 5: Run tests
+
+Run: `pytest tests/test_calibration.py::TestStopCalibrationTravelTime -v`
+Expected: PASS
+
+### Step 6: Commit
+
+```bash
+git add -A
+git commit -m "feat: implement stop_calibration for simple travel/tilt time tests"
+```
+
+---
+
+## Task 6: Implement start_calibration for tilt time tests
+
+Same as travel time but validates `travel_moves_with_tilt=false` and moves tilt instead of travel.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover_base.py`
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests
+
+```python
+class TestCalibrationTiltTime:
+ """Test calibration for tilt_time_down/up."""
+
+ @pytest.mark.asyncio
+ async def test_start_tilt_time_down(self, make_cover):
+ """Should start closing tilt."""
+ cover = make_cover(
+ tilt_time_down=5.0, tilt_time_up=5.0,
+ travel_moves_with_tilt=False,
+ )
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(attribute="tilt_time_down", timeout=30.0)
+ assert cover._calibration.attribute == "tilt_time_down"
+
+ @pytest.mark.asyncio
+ async def test_tilt_rejected_when_travel_moves_with_tilt(self, make_cover):
+ """Should reject tilt calibration when travel_moves_with_tilt=True."""
+ cover = make_cover(
+ tilt_time_down=5.0, tilt_time_up=5.0,
+ travel_moves_with_tilt=True,
+ )
+ with pytest.raises(Exception, match="travel_moves_with_tilt"):
+ await cover.start_calibration(attribute="tilt_time_down", timeout=30.0)
+```
+
+### Step 2: Implement validation and tilt movement
+
+Add to `start_calibration`:
+```python
+if attribute in ("tilt_time_down", "tilt_time_up"):
+ if self._travel_moves_with_tilt:
+ raise HomeAssistantError(
+ "Tilt time calibration not available when travel_moves_with_tilt is enabled"
+ )
+ await self._start_simple_time_test(attribute)
+```
+
+Update `_start_simple_time_test` to handle tilt direction:
+```python
+async def _start_simple_time_test(self, attribute):
+ if attribute in ("travel_time_down", "tilt_time_down"):
+ await self._async_handle_command(SERVICE_CLOSE_COVER)
+ else:
+ await self._async_handle_command(SERVICE_OPEN_COVER)
+```
+
+### Step 3: Run tests
+
+Run: `pytest tests/test_calibration.py::TestCalibrationTiltTime -v`
+Expected: PASS
+
+### Step 4: Commit
+
+```bash
+git add -A
+git commit -m "feat: add tilt time calibration with travel_moves_with_tilt validation"
+```
+
+---
+
+## Task 7: Implement motor overhead calibration (automated step test)
+
+The most complex test type: automated 1/10th steps with pause between each.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover_base.py`
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests
+
+```python
+class TestMotorOverheadCalibration:
+ """Test motor overhead calibration with automated steps."""
+
+ @pytest.mark.asyncio
+ async def test_prerequisite_travel_time_required(self, make_cover):
+ """Should fail if travel_time is not configured."""
+ cover = make_cover(travel_time_down=None, travel_time_up=None)
+ with pytest.raises(Exception, match="[Tt]ravel time"):
+ await cover.start_calibration(
+ attribute="travel_motor_overhead", timeout=300.0
+ )
+
+ @pytest.mark.asyncio
+ async def test_starts_automated_steps(self, make_cover):
+ """Should create an automation task for the step sequence."""
+ cover = make_cover(travel_time_down=60.0, travel_time_up=60.0)
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_motor_overhead", timeout=300.0
+ )
+ assert cover._calibration.automation_task is not None
+ assert cover._calibration.step_duration == 6.0 # 60 / 10
+
+ @pytest.mark.asyncio
+ async def test_overhead_calculation(self, make_cover):
+ """If 15 steps needed instead of 10, overhead = step_duration - (travel_time / 15)."""
+ cover = make_cover(travel_time_down=60.0, travel_time_up=60.0)
+ cover._config_entry_id = "test_cover"
+
+ mock_entry = MagicMock()
+ mock_entry.options = {}
+ cover.hass.config_entries.async_get_entry.return_value = mock_entry
+
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="travel_motor_overhead", timeout=300.0
+ )
+ # Simulate 15 steps completed
+ cover._calibration.step_count = 15
+ result = await cover.stop_calibration()
+
+ # overhead = 6.0 - (60.0 / 15) = 6.0 - 4.0 = 2.0
+ assert result["value"] == pytest.approx(2.0, abs=0.1)
+```
+
+### Step 2: Implement the overhead test
+
+Add to `start_calibration`:
+```python
+elif attribute in ("travel_motor_overhead", "tilt_motor_overhead"):
+ await self._start_overhead_test(attribute)
+```
+
+```python
+async def _start_overhead_test(self, attribute):
+ """Start an automated step test for motor overhead."""
+ if attribute == "travel_motor_overhead":
+ travel_time = self._travel_time_down or self._travel_time_up
+ if not travel_time:
+ raise HomeAssistantError("Travel time must be configured before testing motor overhead")
+ else:
+ travel_time = self._tilting_time_down or self._tilting_time_up
+ if not travel_time:
+ raise HomeAssistantError("Tilt time must be configured before testing motor overhead")
+
+ from .calibration import CALIBRATION_OVERHEAD_STEPS
+ step_duration = travel_time / CALIBRATION_OVERHEAD_STEPS
+ self._calibration.step_duration = step_duration
+
+ self._calibration.automation_task = self.hass.async_create_task(
+ self._run_overhead_steps(attribute, step_duration)
+ )
+
+async def _run_overhead_steps(self, attribute, step_duration):
+ """Execute the automated step sequence."""
+ from .calibration import CALIBRATION_STEP_PAUSE
+
+ is_travel = attribute == "travel_motor_overhead"
+ # Alternate direction each time? No — just go in one direction (close)
+ close_cmd = SERVICE_CLOSE_COVER
+
+ try:
+ while True:
+ # Move for step_duration
+ await self._async_handle_command(close_cmd)
+ await sleep(step_duration)
+ await self._send_stop()
+ self._calibration.step_count += 1
+ self.async_write_ha_state()
+
+ # Pause between steps
+ await sleep(CALIBRATION_STEP_PAUSE)
+ except asyncio.CancelledError:
+ pass
+```
+
+Add to `_calculate_calibration_result`:
+```python
+if attribute in ("travel_motor_overhead", "tilt_motor_overhead"):
+ step_duration = self._calibration.step_duration
+ step_count = self._calibration.step_count
+ if attribute == "travel_motor_overhead":
+ travel_time = self._travel_time_down or self._travel_time_up
+ else:
+ travel_time = self._tilting_time_down or self._tilting_time_up
+ overhead = step_duration - (travel_time / step_count)
+ return round(overhead, 2)
+```
+
+### Step 3: Run tests
+
+Run: `pytest tests/test_calibration.py::TestMotorOverheadCalibration -v`
+Expected: PASS
+
+### Step 4: Commit
+
+```bash
+git add -A
+git commit -m "feat: implement motor overhead calibration with automated step test"
+```
+
+---
+
+## Task 8: Implement min_movement_time calibration
+
+Sends incrementally longer pulses until the user sees movement.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/cover_base.py`
+- Test: `tests/test_calibration.py` (extend)
+
+### Step 1: Write failing tests
+
+```python
+class TestMinMovementTimeCalibration:
+ """Test min_movement_time calibration with incremental pulses."""
+
+ @pytest.mark.asyncio
+ async def test_starts_incremental_pulses(self, make_cover):
+ """Should create automation task for pulse sequence."""
+ 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):
+ """Result should be the last pulse duration sent."""
+ cover = make_cover()
+ cover._config_entry_id = "test_cover"
+
+ mock_entry = MagicMock()
+ mock_entry.options = {}
+ cover.hass.config_entries.async_get_entry.return_value = mock_entry
+
+ with patch.object(cover, "async_write_ha_state"):
+ await cover.start_calibration(
+ attribute="min_movement_time", timeout=60.0
+ )
+ # Simulate 5 pulses completed (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)
+```
+
+### Step 2: Implement
+
+Add to `start_calibration`:
+```python
+elif attribute == "min_movement_time":
+ self._calibration.automation_task = self.hass.async_create_task(
+ self._run_min_movement_pulses()
+ )
+```
+
+```python
+async def _run_min_movement_pulses(self):
+ """Send increasingly longer pulses."""
+ from .calibration import (
+ CALIBRATION_MIN_MOVEMENT_START,
+ CALIBRATION_MIN_MOVEMENT_INCREMENT,
+ CALIBRATION_STEP_PAUSE,
+ )
+
+ pulse_duration = CALIBRATION_MIN_MOVEMENT_START
+
+ try:
+ while True:
+ self._calibration.last_pulse_duration = pulse_duration
+ self._calibration.step_count += 1
+
+ await self._async_handle_command(SERVICE_CLOSE_COVER)
+ await sleep(pulse_duration)
+ await self._send_stop()
+ self.async_write_ha_state()
+
+ await sleep(CALIBRATION_STEP_PAUSE)
+ pulse_duration += CALIBRATION_MIN_MOVEMENT_INCREMENT
+ except asyncio.CancelledError:
+ pass
+```
+
+Add to `_calculate_calibration_result`:
+```python
+if attribute == "min_movement_time":
+ return round(self._calibration.last_pulse_duration, 2)
+```
+
+### Step 3: Run tests
+
+Run: `pytest tests/test_calibration.py::TestMinMovementTimeCalibration -v`
+Expected: PASS
+
+### Step 4: Commit
+
+```bash
+git add -A
+git commit -m "feat: implement min_movement_time calibration with incremental pulses"
+```
+
+---
+
+## Task 9: Add service descriptions and translations
+
+**Files:**
+- Modify: `custom_components/cover_time_based/services.yaml`
+- Modify: `custom_components/cover_time_based/strings.json`
+- Modify: `custom_components/cover_time_based/translations/en.json`
+
+### Step 1: Add to services.yaml
+
+```yaml
+start_calibration:
+ fields:
+ entity_id:
+ example: cover.blinds
+ attribute:
+ example: travel_time_down
+ timeout:
+ example: 120
+stop_calibration:
+ fields:
+ entity_id:
+ example: cover.blinds
+ cancel:
+ example: false
+```
+
+### Step 2: Add to strings.json
+
+Add service descriptions under the `services` key:
+```json
+"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": {
+ "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"
+ }
+ }
+},
+"stop_calibration": {
+ "name": "Stop calibration",
+ "description": "Stop an active calibration test. Calculates the result and saves it to the configuration unless cancelled.",
+ "fields": {
+ "cancel": {
+ "name": "Cancel",
+ "description": "If true, discard the test results without saving"
+ }
+ }
+}
+```
+
+### Step 3: Sync en.json
+
+Copy the services section from strings.json to translations/en.json.
+
+### Step 4: Commit
+
+```bash
+git add -A
+git commit -m "feat: add service descriptions and translations for calibration APIs"
+```
+
+---
+
+## Task 10: Update config_flow for motor overhead rename
+
+Update the options flow UI to show the new field names.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/config_flow.py`
+- Modify: `custom_components/cover_time_based/strings.json`
+- Test: `tests/test_config_flow.py` (update existing tests)
+
+### Step 1: Update _build_details_schema
+
+In the "Advanced" collapsible section, replace:
+- `travel_startup_delay` → remove
+- `travel_delay_at_end` → remove
+- `tilt_startup_delay` → remove
+- Add: `travel_motor_overhead` (NumberSelector 0-30, step 0.1)
+- Add: `tilt_motor_overhead` (NumberSelector 0-30, step 0.1)
+
+### Step 2: Update strings.json
+
+Replace translation keys for the removed fields with the new ones.
+
+### Step 3: Update config flow tests
+
+Update any test that references the old parameter names.
+
+### Step 4: Run all tests
+
+Run: `pytest tests/ -v`
+Expected: ALL PASS
+
+### Step 5: Run linting
+
+Run: `ruff check . && ruff format . && npx pyright`
+
+### Step 6: Commit
+
+```bash
+git add -A
+git commit -m "feat: update config flow UI for motor overhead parameters"
+```
+
+---
+
+## Task 11: Full integration test and cleanup
+
+Run the full test suite, fix any remaining issues, clean up imports.
+
+**Files:**
+- All modified files
+
+### Step 1: Run full test suite
+
+Run: `pytest tests/ -v`
+Expected: ALL PASS
+
+### Step 2: Run linting and type checking
+
+Run: `ruff check . && ruff format . && npx pyright`
+
+### Step 3: Final commit if needed
+
+```bash
+git add -A
+git commit -m "chore: cleanup and fix linting issues"
+```
diff --git a/docs/plans/2026-02-17-configuration-card-design.md b/docs/plans/2026-02-17-configuration-card-design.md
new file mode 100644
index 0000000..46783cb
--- /dev/null
+++ b/docs/plans/2026-02-17-configuration-card-design.md
@@ -0,0 +1,192 @@
+# Configuration Card Design
+
+## Goal
+
+Replace most of the multi-step config flow with a single Lovelace dashboard card that handles both initial configuration and calibration of cover_time_based entities.
+
+## Architecture
+
+A zero-build custom Lovelace card using LitElement (from CDN), following the same patterns as the ha-fado card. The card communicates with the backend via WebSocket API for reading/writing configuration, and via HA service calls for calibration. The config flow is stripped down to just collecting the entity name - all other configuration happens through the card.
+
+## Tech Stack
+
+- **Frontend**: Vanilla ES6 module with LitElement 2.4.0 from unpkg CDN
+- **Backend**: HA WebSocket API handlers for config read/write
+- **Communication**: `hass.callWS()` for config, `hass.callService()` for calibration
+- **State updates**: Entity state subscription for calibration status
+
+---
+
+## Card Design
+
+### Entity Selection
+
+At the top of the card, an entity picker filtered to `cover.cover_time_based_*` entities. Once selected, the card displays:
+- **Name** and **Entity ID**
+
+### Configuration Sections
+
+The card shows configuration in a progressive flow. Each section is shown based on the current state of configuration.
+
+#### 1. Device Type
+Radio/select:
+- **Control via switches** (`switch`)
+- **Wrap an existing cover entity** (`cover`)
+
+#### 2. Input Entities (shown after device type is selected)
+
+If **switches**:
+- Open switch entity (entity picker, filtered to `switch` domain)
+- Close switch entity (entity picker, filtered to `switch` domain)
+- Stop switch entity (optional, entity picker, filtered to `switch` domain)
+
+If **cover**:
+- Cover entity (entity picker, filtered to `cover` domain)
+
+#### 3. Input Mode
+Radio/select:
+- **Switch** - Latching relays (on/off stay in position)
+- **Pulse** - Momentary press, separate stop
+- **Toggle** - Same button starts and stops
+
+If pulse or toggle, show:
+- Pulse time (number input, seconds)
+
+#### 4. Tilt Support
+Toggle: **Cover supports tilting?** Yes/No
+
+If yes:
+- **Tilting happens:**
+ - Before opening and after closing (`travel_moves_with_tilt: false`)
+ - During opening/closing (`travel_moves_with_tilt: true`)
+
+#### 5. Timing Attributes Table (read-only)
+
+A table showing the relevant timing attributes and their current values. Which rows are shown depends on the configuration:
+
+| Attribute | Shown when | Value source |
+|-----------|-----------|--------------|
+| Travel time (close) | Always | `travelling_time_down` |
+| Travel time (open) | Always | `travelling_time_up` |
+| Travel motor overhead | Always | `travel_motor_overhead` |
+| Tilt time (close) | Tilt enabled | `tilting_time_down` |
+| Tilt time (open) | Tilt enabled | `tilting_time_up` |
+| Tilt motor overhead | Tilt enabled | `tilt_motor_overhead` |
+| Minimum movement time | Always | `min_movement_time` |
+
+Values come from `extra_state_attributes` on the selected entity.
+
+#### 6. Calibration Controls
+
+Below the table:
+- **Attribute** dropdown (same options as start_calibration service)
+- **Direction** dropdown (Open/Close, optional)
+- **Timeout** number input (default 120s)
+- **Go** button
+
+When calibration is active (detected via `calibration_active` state attribute):
+- Replace Go with **Stop** and **Cancel** buttons
+- Show the active attribute and step count
+- Disable the configuration sections above (prevent changes during calibration)
+
+### Save Behavior
+
+Each configuration section saves independently when the user changes a value (like the fado card pattern - save on change, not a single "Save All" button). The backend validates and applies immediately.
+
+After saving, the entity reloads (existing `async_update_options` listener handles this).
+
+---
+
+## Backend Design
+
+### WebSocket API
+
+Two new WebSocket commands in a new `websocket_api.py` module:
+
+#### `cover_time_based/get_config`
+
+**Input:** `{ type: "cover_time_based/get_config", entity_id: "cover.xxx" }`
+
+**Response:** Returns the config entry options for the entity:
+```json
+{
+ "entry_id": "abc123",
+ "device_type": "switch",
+ "input_mode": "switch",
+ "pulse_time": 1.0,
+ "open_switch_entity_id": "switch.open",
+ "close_switch_entity_id": "switch.close",
+ "stop_switch_entity_id": "switch.stop",
+ "cover_entity_id": null,
+ "travel_moves_with_tilt": false,
+ "travelling_time_down": 30,
+ "travelling_time_up": 30,
+ "tilting_time_down": null,
+ "tilting_time_up": null,
+ "travel_motor_overhead": null,
+ "tilt_motor_overhead": null,
+ "min_movement_time": null
+}
+```
+
+Resolves entity_id → config entry via the entity registry.
+
+#### `cover_time_based/update_config`
+
+**Input:** Partial update - only send fields that changed:
+```json
+{
+ "type": "cover_time_based/update_config",
+ "entity_id": "cover.xxx",
+ "device_type": "switch",
+ "input_mode": "pulse",
+ "pulse_time": 0.5
+}
+```
+
+**Validation:**
+- Same rules as the current config flow (tilt times must be paired, etc.)
+- Entity must belong to cover_time_based integration
+
+**Effect:**
+- Updates config entry options
+- Triggers entity reload via existing `async_update_options` listener
+
+### Frontend Registration
+
+In `__init__.py`:
+- Register static path `/cover_time_based_panel` pointing to `frontend/` directory
+- Register card JS via `frontend.add_extra_js_url()`
+- Register WebSocket API handlers
+
+### File Structure
+
+```
+custom_components/cover_time_based/
+├── frontend/
+│ └── cover-time-based-card.js # The card (single file)
+├── websocket_api.py # WS handlers
+├── __init__.py # Updated: static path + WS registration
+└── ... (existing files)
+```
+
+---
+
+## What Changes in Existing Code
+
+### Config Flow
+- **Keep as-is for now.** The config flow still works for creating entities. We can simplify it later (strip to name-only) once the card is proven.
+- The card provides an alternative way to configure, but doesn't replace the config flow yet.
+
+### __init__.py
+- Add frontend static path registration
+- Add WebSocket API registration
+- Add `"http"` to `dependencies` in manifest.json
+
+### manifest.json
+- Add `"dependencies": ["http"]`
+
+### No changes to:
+- cover_base.py (calibration logic stays)
+- cover.py (services stay)
+- calibration.py (constants stay)
diff --git a/docs/plans/2026-02-17-configuration-card-plan.md b/docs/plans/2026-02-17-configuration-card-plan.md
new file mode 100644
index 0000000..be9cd21
--- /dev/null
+++ b/docs/plans/2026-02-17-configuration-card-plan.md
@@ -0,0 +1,616 @@
+# Configuration Card Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a custom Lovelace card for configuring and calibrating cover_time_based entities, with WebSocket API backend.
+
+**Architecture:** Zero-build LitElement card (vanilla ES6 from CDN), WebSocket API for config CRUD, existing HA services for calibration. Card registered via `__init__.py`.
+
+**Tech Stack:** LitElement 2.4.0 (CDN), HA WebSocket API, voluptuous validation
+
+---
+
+### Task 1: Backend - WebSocket API module
+
+Create the WebSocket API handlers for reading and writing cover configuration.
+
+**Files:**
+- Create: `custom_components/cover_time_based/websocket_api.py`
+
+**Step 1: Create websocket_api.py**
+
+```python
+"""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 .cover import (
+ CONF_CLOSE_SWITCH_ENTITY_ID,
+ CONF_COVER_ENTITY_ID,
+ CONF_DEVICE_TYPE,
+ CONF_INPUT_MODE,
+ CONF_MIN_MOVEMENT_TIME,
+ CONF_OPEN_SWITCH_ENTITY_ID,
+ CONF_PULSE_TIME,
+ CONF_STOP_SWITCH_ENTITY_ID,
+ CONF_TILT_MOTOR_OVERHEAD,
+ CONF_TILTING_TIME_DOWN,
+ CONF_TILTING_TIME_UP,
+ CONF_TRAVEL_MOTOR_OVERHEAD,
+ CONF_TRAVEL_MOVES_WITH_TILT,
+ CONF_TRAVELLING_TIME_DOWN,
+ CONF_TRAVELLING_TIME_UP,
+ DEFAULT_PULSE_TIME,
+ DEFAULT_TRAVEL_TIME,
+ DEVICE_TYPE_COVER,
+ DEVICE_TYPE_SWITCH,
+ INPUT_MODE_PULSE,
+ INPUT_MODE_SWITCH,
+ INPUT_MODE_TOGGLE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "cover_time_based"
+
+
+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)
+
+
+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:
+ connection.send_error(msg["id"], "not_found", error)
+ return
+
+ options = config_entry.options
+ connection.send_result(
+ msg["id"],
+ {
+ "entry_id": config_entry.entry_id,
+ "device_type": options.get(CONF_DEVICE_TYPE, DEVICE_TYPE_SWITCH),
+ "input_mode": options.get(CONF_INPUT_MODE, INPUT_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),
+ "travel_moves_with_tilt": options.get(CONF_TRAVEL_MOVES_WITH_TILT, False),
+ "travelling_time_down": options.get(CONF_TRAVELLING_TIME_DOWN, DEFAULT_TRAVEL_TIME),
+ "travelling_time_up": options.get(CONF_TRAVELLING_TIME_UP, DEFAULT_TRAVEL_TIME),
+ "tilting_time_down": options.get(CONF_TILTING_TIME_DOWN),
+ "tilting_time_up": options.get(CONF_TILTING_TIME_UP),
+ "travel_motor_overhead": options.get(CONF_TRAVEL_MOTOR_OVERHEAD),
+ "tilt_motor_overhead": options.get(CONF_TILT_MOTOR_OVERHEAD),
+ "min_movement_time": options.get(CONF_MIN_MOVEMENT_TIME),
+ },
+ )
+
+
+@websocket_api.websocket_command(
+ {
+ "type": "cover_time_based/update_config",
+ vol.Required("entity_id"): str,
+ vol.Optional("device_type"): vol.In([DEVICE_TYPE_SWITCH, DEVICE_TYPE_COVER]),
+ vol.Optional("input_mode"): vol.In(
+ [INPUT_MODE_SWITCH, INPUT_MODE_PULSE, INPUT_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("travel_moves_with_tilt"): bool,
+ vol.Optional("travelling_time_down"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("travelling_time_up"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("tilting_time_down"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("tilting_time_up"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("travel_motor_overhead"): vol.Any(
+ None, vol.All(vol.Coerce(float), vol.Range(min=0, max=600))
+ ),
+ vol.Optional("tilt_motor_overhead"): 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))
+ ),
+ }
+)
+@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:
+ connection.send_error(msg["id"], "not_found", error)
+ return
+
+ # Build new options from existing + updates
+ new_options = dict(config_entry.options)
+
+ # Map WS field names to config entry option keys
+ field_map = {
+ "device_type": CONF_DEVICE_TYPE,
+ "input_mode": CONF_INPUT_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,
+ "travel_moves_with_tilt": CONF_TRAVEL_MOVES_WITH_TILT,
+ "travelling_time_down": CONF_TRAVELLING_TIME_DOWN,
+ "travelling_time_up": CONF_TRAVELLING_TIME_UP,
+ "tilting_time_down": CONF_TILTING_TIME_DOWN,
+ "tilting_time_up": CONF_TILTING_TIME_UP,
+ "travel_motor_overhead": CONF_TRAVEL_MOTOR_OVERHEAD,
+ "tilt_motor_overhead": CONF_TILT_MOTOR_OVERHEAD,
+ "min_movement_time": CONF_MIN_MOVEMENT_TIME,
+ }
+
+ # Skip keys that are WS metadata, not config fields
+ skip_keys = {"id", "type", "entity_id"}
+
+ for ws_key, conf_key in field_map.items():
+ if ws_key in msg and ws_key not in skip_keys:
+ 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})
+```
+
+**Step 2: Run tests to verify no import errors**
+
+Run: `cd /workspaces/ha-cover-time-based-config-helpers && python -c "from custom_components.cover_time_based.websocket_api import async_register_websocket_api; print('OK')"`
+
+**Step 3: Commit**
+
+```bash
+git add custom_components/cover_time_based/websocket_api.py
+git commit -m "feat: add WebSocket API for configuration card"
+```
+
+---
+
+### Task 2: Backend - Update __init__.py and manifest.json
+
+Register the frontend static path, card JS, and WebSocket API in the integration setup.
+
+**Files:**
+- Modify: `custom_components/cover_time_based/__init__.py`
+- Modify: `custom_components/cover_time_based/manifest.json`
+
+**Step 1: Update manifest.json**
+
+Add `"dependencies": ["http"]` (needed for static path registration). Keep keys sorted per CLAUDE.md rules (domain, name first, then alphabetical):
+
+```json
+{
+ "domain": "cover_time_based",
+ "name": "Cover Time Based",
+ "codeowners": ["@Sese-Schneider"],
+ "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"
+}
+```
+
+**Step 2: Update __init__.py**
+
+```python
+"""Cover Time Based integration."""
+
+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 .websocket_api import async_register_websocket_api
+
+DOMAIN = "cover_time_based"
+PLATFORMS: list[Platform] = [Platform.COVER]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Cover Time Based from a config entry."""
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ entry.async_on_unload(entry.add_update_listener(async_update_options))
+
+ # Register WebSocket API (idempotent - safe to call multiple times)
+ async_register_websocket_api(hass)
+
+ # Register frontend
+ 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,
+ )
+ ]
+ )
+
+ hass.data.setdefault(frontend.DATA_EXTRA_MODULE_URL, set())
+ frontend.add_extra_js_url(
+ hass, "/cover_time_based_panel/cover-time-based-card.js"
+ )
+
+ 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)
+```
+
+**Step 3: Run existing tests**
+
+Run: `cd /workspaces/ha-cover-time-based-config-helpers && python -m pytest tests/ -x -q`
+Expected: All 149 tests pass
+
+**Step 4: Commit**
+
+```bash
+git add custom_components/cover_time_based/__init__.py custom_components/cover_time_based/manifest.json
+git commit -m "feat: register frontend and WebSocket API in init"
+```
+
+---
+
+### Task 3: Frontend - Card skeleton with entity picker
+
+Create the card JS file with LitElement, entity picker, and card registration.
+
+**Files:**
+- Create: `custom_components/cover_time_based/frontend/cover-time-based-card.js`
+
+**Step 1: Create the card file**
+
+The card should:
+- Import LitElement from CDN
+- Register as `cover-time-based-card` custom element
+- Have a `setConfig(config)` method (required by Lovelace)
+- Accept `hass` property (set by HA when state changes)
+- Show an entity picker filtered to `cover` domain entities that belong to `cover_time_based`
+- When entity is selected, call `cover_time_based/get_config` via WS
+- Display the entity name and entity ID
+
+Entity filtering: use `hass.states` to find entities whose `entity_id` starts with `cover.` and check if they have `travelling_time_down` in their attributes (a marker for cover_time_based entities). Alternatively, filter by checking if the entity has the expected state attributes.
+
+The card renders:
+1. Header: "Cover Time Based Configuration"
+2. Entity dropdown: `