Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


<<<<<<< Updated upstream
## [1.62.1] - 2026-10-01

### Fixed
Expand All @@ -23,6 +24,62 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
### Fixed

- Reloading the integration failed with "RuntimeError: Added route will never be executed" followed by "has already been setup!" errors for the switch, sensor and select platforms, leaving the entities unavailable until a Home Assistant restart. The panel's static path is now registered once per Home Assistant runtime instead of on every entry setup ([#3](https://github.com/crandler/CoverAutomatic/issues/3)).
=======
## [2.0.0] - 2026-10-01

Major release consolidating all changes since 1.61.1.

### Added

**French language**
- French added alongside English and German throughout the integration: configuration panel, dashboard card, services and error messages, activity log and HA logbook, entities, facade devices, weather states and built-in scenarios (Everyday → Quotidien…). Follows the Home Assistant language, with French typography (narrow no-break space before ":"). The three languages share the same set of texts.

**Rules**
- **Home Assistant conditions**: any automation condition (state, numeric state, template, zone, sun…), edited with a form or in YAML and validated live.
- **Condition groups and NOT**: e.g. (A OR B) AND (C OR D). A negated condition is never met while its input is unavailable.
- **Scenarios per rule**: each rule selects the scenarios it applies in.
- **Dawn and dusk** conditions with an offset in minutes; sunrise/sunset and dawn/dusk pairs are complementary.
- **Outdoor air compared to the room** and **Room occupied** conditions, with an occupancy sensor per cover.
- **Safety rule**: acts even when the cover is paused, in manual mode, wind protected or with automation off. Never overrides the window lock; between rules, priority decides.
- **Duplicate a rule**; ▲▼ buttons to order conditions, groups and priorities.

**Covers**
- **Keep current position when the window opens**.
- **Travel time** entered or learned automatically, with a global default; removes false "manual" pauses on slow covers.
- **Resume when the position matches the rule** (only pauses caused by a manual move).
- **Lost commands resent automatically** (radio frame lost), never after a manual counter-order.

**Global settings**
- Configurable **wind protection position**.
- **Thresholds driven by an entity** (temperature setpoints, sunshine threshold), with the typed value as fallback.
- Configurable **"sun on facade" behaviour**, globally and per cover.
- **Room temperature colours**: by action needed or like a thermometer.

**Entities and dashboard**
- **Dashboard card** `custom:cover-automatic-card`: one line per cover; header with scenario, master switch, wind, counters and a "Resume all" button.
- **Sensors per cover**: active rule, target, position, comfort mode, pause end.
- **Global entities**: wind protection and number of covers paused / manual / locked.

**Log**
- **Per-cover filter**, with a button on the cover sheet.

### Changed

- **Cover sheet** reorganised into collapsible sections: General, Window, Room, Sun exposure, Automation.
- **Settings** reorganised (Sensors, Sun exposure…), with "How does it work?" blocks and options greyed when they have no effect.
- **Rule editor**: collapsible conditions, draft kept, warning before losing unsaved changes.
- **Scenarios tab**: rules listed by priority, disabled rules greyed, **Safety** badge.
- **Mobile**: cover sheet header reachable under the notch, shorter labels, focus and scroll position kept.
- Panel live updates through a dedicated WebSocket subscription instead of a bus event written to the recorder.

### Fixed

- **Window and wind**: the lock (window open) keeps priority over the wind during the whole storm; lock / wind state survives a restart; an unknown window sensor never lowers the cover; a removed sensor no longer blocks the cover forever.
- **False manual pauses removed**: slow covers, rule change during a move, window closed while the cover moves to the lock position.
- **Backups**: complete export; validated import (out-of-range values, unknown references, migrations); runtime state no longer restored from the file.
- Reloading the integration no longer fails; HA deprecation warning fixed; calculation cycles can no longer interleave; an unreadable rule is disabled instead of applying everywhere.
- Three full code audits; 1325 automated tests.
>>>>>>> Stashed changes

## [1.61.1] - 2026-08-18

Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,13 @@ After installation, all configuration is done via the **CoverAutomatic** sidebar

1. **Covers** - Add cover entities to manage
2. **Facades** - Define building facades by cardinal direction (with compass visualization)
<<<<<<< Updated upstream
3. **Rules** - Create automation rules with conditions (sun, temperature, time, weather, etc.); duplicate an existing rule to use it as a starting point
4. **Scenarios** - Define modes like "Summer", "Winter", "Vacation" to disable specific rules
=======
3. **Rules** - Create automation rules with conditions (sun, temperature, time, weather, etc.). Conditions can be negated (NOT) and organised in groups, e.g. (A OR B) AND (C OR D)
4. **Scenarios** - Define modes like "Summer", "Winter", "Vacation". Each rule chooses the scenarios it belongs to (all by default); inside a scenario a member rule can still be switched off temporarily
>>>>>>> Stashed changes
5. **Settings** - Configure sensors, comfort temperatures, wind protection, and more

### Example: heat protection by outdoor temperature
Expand All @@ -188,6 +193,34 @@ The rule closes the cover only while the sun actually hits that facade **and**
the outdoor temperature is above the threshold, and releases it again once
either condition clears.

### Conditions on any entity (Home Assistant conditions)

The **Entities** group of the *Add condition* menu adds a condition evaluated
by Home Assistant itself, in the same format as the `condition:` section of an
automation:

- **Entity state** / **Entity numeric value** open a form: pick the entity,
optionally an attribute, then the states (proposed as buttons, e.g. the zones
of a person), *is / is not*, a minimum duration, or above / below / between.
- **Home Assistant condition (YAML)** accepts any automation condition —
templates, zones, devices, nested `and` / `or` / `not`:

```yaml
condition: or
conditions:
- condition: state
entity_id: media_player.living_room
state: [playing, paused]
- condition: template
value_template: "{{ states('sensor.lux') | float(0) > 20000 }}"
```

Each card has a **Form / YAML** switch, is checked by Home Assistant while you
type and shows the entities it watches (rules react to them immediately). An
invalid or incomplete condition is kept but never matches and is marked in red.
Existing "State is" / "Numeric value" conditions keep working and can be
converted with one click.

### Created Entities

For each managed cover, the integration creates:
Expand All @@ -201,8 +234,15 @@ For each facade:
| Entity | Description |
|--------|-------------|
| `sensor.*_sun` | Sun on facade indicator (on/off) |
<<<<<<< Updated upstream
| `sensor.*_sun_entry` | Time when sun enters facade today (real sun path at your location) |
| `sensor.*_sun_exit` | Time when sun leaves facade today |
=======
| `sensor.*_sun_entry` | Time when sun enters facade (today, computed from the real solar path of your location) |
| `sensor.*_sun_exit` | Time when sun leaves facade |
>>>>>>> Stashed changes

Entities are created and removed automatically when covers or facades are added or deleted in the panel -- no reload needed.

Global:
- `select.cover_automatic_scenario` - Active scenario selector
Expand Down Expand Up @@ -235,13 +275,15 @@ By design. After startup, CoverAutomatic waits **120 seconds** before applying a

CoverAutomatic detected a **manual override**: the cover was moved by something other than CoverAutomatic itself — a wall switch, a remote, another automation, or the HA UI. Automation for that cover pauses for the configured pause duration (global or per-cover) so your manual choice is respected, then resumes automatically. Resume earlier via the **X** button in the panel or the `cover_automatic.resume` service.

For slow covers that report neither intermediate positions nor *opening* / *closing*, the integration waits for the cover's **travel time** (measured automatically, or set per cover under *Advanced → Travel time*) before judging a position as manual. If a slow cover is still paused wrongly right after an automatic move, set its travel time a little above the real one.

### A rule matches but the cover doesn't move

Check in this order:

1. **Status priority** — WIND_PROTECTED, LOCKED (window open), VENTING (window tilted) and PAUSED all override rule evaluation. The covers table in the panel shows the current status and the winning rule per cover.
2. **Master switch / per-cover automation toggle** — both must be on.
3. **Active scenario** — scenarios can disable specific rules.
3. **Active scenario** — only rules that belong to the active scenario (and are not switched off in it) are considered.
4. **Minimum time between changes** — position changes are rate-limited by the configured interval; the move happens on a later scan.

### A sun rule doesn't shade
Expand Down
103 changes: 100 additions & 3 deletions custom_components/cover_automatic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
import pathlib
from dataclasses import dataclass

from homeassistant.components.frontend import async_register_built_in_panel, async_remove_panel
from homeassistant.components.frontend import (
add_extra_js_url,
async_register_built_in_panel,
async_remove_panel,
)
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
Expand All @@ -18,6 +22,7 @@
from .api import async_setup_api
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
from .coordinator import CoverAutomaticCoordinator
from .entities import async_cleanup_orphan_entities
from .services import async_setup_services, async_unload_services
from .storage import ActivityLogStorage, CoverAutomaticStorage

Expand All @@ -35,13 +40,56 @@ class CoverAutomaticRuntimeData:
type CoverAutomaticConfigEntry = ConfigEntry[CoverAutomaticRuntimeData]

PLATFORMS_LIST: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.SENSOR,
Platform.SELECT,
]

CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

PANEL_JS_URL = "/cover_automatic/panel.js"
# Dashboard card, loaded on every frontend page (custom:cover-automatic-card)
CARD_JS_URL = "/cover_automatic/cover-automatic-card.js"
# hass.data[DOMAIN] flag: static panel route already registered this HA run
_STATIC_PATH_REGISTERED = "static_path_registered"


async def _async_register_card_resource(hass: HomeAssistant, url: str) -> bool:
"""Declare the dashboard card as a Lovelace resource (storage mode).

Lovelace resources are loaded with the dashboards, so the card is
defined when they are built (the extra module URL could arrive too late
and the card then showed "Configuration error" until a refresh). The
entry is updated when the version changes. Returns False when the
resources cannot be managed (YAML mode, Lovelace not loaded).
"""
try:
from homeassistant.components.lovelace.const import LOVELACE_DATA
from homeassistant.components.lovelace.resources import ResourceStorageCollection
except ImportError:
return False
data = hass.data.get(LOVELACE_DATA)
resources = getattr(data, "resources", None)
if not isinstance(resources, ResourceStorageCollection):
return False
try:
if not resources.loaded:
await resources.async_load()
resources.loaded = True
base = url.split("?", 1)[0]
for item in resources.async_items():
if str(item.get("url", "")).split("?", 1)[0] != base:
continue
if item.get("url") != url or item.get("type") != "module":
await resources.async_update_item(item["id"], {"res_type": "module", "url": url})
return True
await resources.async_create_item({"res_type": "module", "url": url})
except Exception as err: # noqa: BLE001 -- never block the integration setup
_LOGGER.warning("Could not register the dashboard card resource: %s", err)
return False
return True


def _cleanup_removed_entities(hass: HomeAssistant) -> None:
"""Remove orphan entities from prior versions (pre-1.52.0: per-cover pause_duration)."""
Expand Down Expand Up @@ -85,6 +133,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoverAutomaticConfigEntr
await log_storage.async_load()

_cleanup_removed_entities(hass)
# Entities of covers/facades deleted while HA was running an older
# version (or deleted before a restart) are dropped here.
async_cleanup_orphan_entities(hass, entry, storage)

async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle options update by reloading entry to recreate entities."""
Expand Down Expand Up @@ -137,29 +188,75 @@ async def async_options_updated(hass: HomeAssistant, config_entry: ConfigEntry)
# Setup WebSocket API for config panel
async_setup_api(hass, storage, coordinator, version=panel_version)

<<<<<<< Updated upstream
# Register custom panel (version query for cache busting); the static path
# behind js_url is registered once in async_setup
=======
# Register custom panel (version query for cache busting). aiohttp routes
# cannot be removed, and registering the same GET route twice raises
# RuntimeError -- so the static path is registered once per HA run and
# survives config entry reloads.
domain_data: dict = hass.data.setdefault(DOMAIN, {})
panel_dir = pathlib.Path(__file__).parent / "panel"
panel_stamp = await _async_asset_stamp(hass, panel_dir / "cover-automatic-panel.js")
if not domain_data.get(_STATIC_PATH_REGISTERED):
await hass.http.async_register_static_paths(
[
StaticPathConfig(PANEL_JS_URL, str(panel_dir / "cover-automatic-panel.js"), False),
StaticPathConfig(CARD_JS_URL, str(panel_dir / "cover-automatic-card.js"), False),
]
)
domain_data[_STATIC_PATH_REGISTERED] = True
card_stamp = await _async_asset_stamp(hass, panel_dir / "cover-automatic-card.js")
card_url = f"{CARD_JS_URL}?v={panel_version}{card_stamp}"
if not await _async_register_card_resource(hass, card_url):
# YAML resources / no Lovelace: load the card with every page.
add_extra_js_url(hass, card_url)
>>>>>>> Stashed changes
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title="CoverAutomatic",
sidebar_icon="mdi:blinds",
frontend_url_path="cover-automatic",
require_admin=True,
# update=True: a failed unload can leave the panel registered; a
# later setup must replace it instead of raising "Overwriting panel".
update=True,
config={
"_panel_custom": {
"name": "cover-automatic-panel",
"js_url": f"/cover_automatic/panel.js?v={panel_version}",
"js_url": f"{PANEL_JS_URL}?v={panel_version}{panel_stamp}",
"embed_iframe": False,
}
},
)

entry.async_on_unload(coordinator.async_shutdown)
# coordinator.async_shutdown is registered on unload by
# DataUpdateCoordinator itself (config_entry is passed to it).

return True


async def _async_asset_stamp(hass: HomeAssistant, path: pathlib.Path) -> str:
"""Modification time of a bundled asset, for cache busting.

The version alone is not enough: files updated without a version bump
would keep being served from the browser cache. Read in the executor
(stat is file I/O); "" when unavailable, the version is then used alone.
"""
try:
mtime = await hass.async_add_executor_job(_asset_mtime, path)
except (OSError, TypeError):
return ""
return f"-{mtime}" if isinstance(mtime, int) else ""


def _asset_mtime(path: pathlib.Path) -> int:
"""Integer mtime of a file (executor)."""
return int(path.stat().st_mtime)


async def async_unload_entry(hass: HomeAssistant, entry: CoverAutomaticConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS_LIST)
Expand Down
Loading