Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.13"
python-version: "3.14"

- name: Install dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
python-version: "3.14"

- name: Validate Python syntax
run: |
Expand Down
74 changes: 71 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by
- [`sensor.next_mode_at`](#sensornext_mode_at)
- [`sensor.cover_open_time`](#sensorcover_open_time)
- [`sensor.cover_close_time`](#sensorcover_close_time)
- [`binary_sensor.covers_left_open`](#binary_sensorcovers_left_open)
- [🛠️ Services](#️-services)
- [`homeshift.refresh_schedulers`](#homeshiftrefresh_schedulers)
- [`homeshift.sync_calendar`](#homeshiftsync_calendar)
Expand All @@ -35,6 +36,7 @@ Automatic day-mode and thermostat-mode management for Home Assistant, driven by
- [🗓️ Scheduler Integration](#️-scheduler-integration)
- [Thermostat Tags](#thermostat-tags)
- [🗓️ Daily Cover Schedule](#️-daily-cover-schedule)
- [When the Covers Close](#when-the-covers-close)
- [Individual Covers](#individual-covers)
- [☀️ Cover Heat Protection](#️-cover-heat-protection)
- [Reactive Close](#reactive-close)
Expand Down Expand Up @@ -75,6 +77,7 @@ At regular intervals (every 5 minutes by default), it reads your calendar, picks

## ✅ Requirements

- **Home Assistant 2026.9** or newer
- A **calendar** entity containing your work or schedule events
- A **calendar** entity for public holidays
- The [Scheduler integration](https://github.com/nielsfaber/scheduler-component) to automate scheduler switches
Expand Down Expand Up @@ -155,12 +158,45 @@ Shows the cover opening time computed for today by the Daily Cover Schedule feat
- **Only registered** when at least one cover is listed under **Individual Covers**.

### `sensor.cover_close_time`
Shows the daily cover closing time computed for today (today's sunset + offset) by the Daily Cover Schedule feature.
Shows the estimated daily cover closing time for today, computed by the Daily Cover Schedule feature from the [configured sun elevation](#when-the-covers-close).

- **Type:** Sensor (text)
- **Value:** `HH:MM` string (e.g. `21:40`), or `unknown` if not configured or not yet computed.
- **Attributes:**
- `sun_elevation` — the configured elevation the time was computed from
- `trigger` — `elevation` normally, `sunset` on a day the sun never reached it and the fallback stepped in
- **Only registered** when at least one cover is listed under **Individual Covers**.

### `binary_sensor.covers_left_open`
Warns that tonight's close had to leave covers up, and names them.

- **Type:** Binary sensor (`device_class: problem`)
- **Value:** `on` when at least one cover was skipped by the evening close, `off` otherwise.
- **Attributes:**
- `covers` — the entity ids left open, e.g. `["cover.volet_chambre"]`
- `window_sensors` — the sensor that blocked each one, e.g. `{"cover.volet_chambre": "binary_sensor.fenetre_chambre"}`
- `count`, `checked_on` — how many, and the day the close ran
- **Clears** when the next calendar day's schedule is computed, **not** when the window is closed — the cover stays up either way until someone acts on it.
- **Only registered** when at least one cover is listed under **Individual Covers**.
- **Survives a restart:** the list is persisted with the rest of the day's cover state.

A notification automation can read the list straight out of the attributes:

```yaml
automation:
- alias: Volets restés ouverts
triggers:
- trigger: state
entity_id: binary_sensor.covers_left_open
to: "on"
actions:
- action: notify.persistent_notification
data:
message: >
Volets non fermés ce soir :
{{ state_attr('binary_sensor.covers_left_open', 'covers') | join(', ') }}
```

---

## 🛠️ Services
Expand Down Expand Up @@ -201,7 +237,7 @@ All parameters can be changed at any time via **Settings → Devices & Services
| **Individual Covers** | — | The covers opened and closed daily, added one at a time, each with an optional window sensor and an optional My position button (separate from Cover Entities above); Cover Heat Protection's active window is derived from this schedule |
| **Open Time — *(per day mode)*** | `08:30` | One field per day mode: `sunrise`, `skip`, or a custom `HH:MM` value |
| **Earliest Open Time** | `07:00` | Floor time used when a day mode's Open Time is `sunrise` |
| **Close Offset From Sunset** | `10 min` | Covers close this many minutes relative to sunset, every day, for every mode. Positive = after sunset, negative = before (e.g. `-10` = 10 min before sunset) |
| **Sun Elevation At Closing** | `-2°` | Covers close when the descending sun reaches this many degrees above the horizon, every day, for every mode — the same scale as `{{ state_attr('sun.sun', 'elevation') }}`. `0°` is sunset, negative is below the horizon |

---

Expand Down Expand Up @@ -287,12 +323,42 @@ Once per day, shortly after midnight, HomeShift computes:
- a custom `HH:MM` value — a fixed clock time

Day modes sharing the same value effectively form a batch (e.g. both `Work` and `Remote` set to `sunrise`). A day mode with no value configured falls back to `08:30`. There's no separate "skip modes" list — set a mode's Open Time to `skip` directly.
- **Close time** — today's sunset plus/minus **Close Offset From Sunset**, always, for every day mode (closing is not mode-dependent)
- **Close time** — when the setting sun reaches the configured elevation, always, for every day mode (closing is not mode-dependent): see below.

A one-shot timer fires the open/close action at the exact scheduled minute; the periodic coordinator poll (5 minutes by default) acts as a fallback in case the timer is missed (e.g. a HA restart). Each action fires at most once per calendar day.

**`sensor.cover_open_time`** and **`sensor.cover_close_time`** reflect today's computed times, so you can display them on your dashboard.

### When the Covers Close

The covers close when the descending sun reaches **Sun Elevation At Closing** degrees above the horizon — the value you would read from `{{ state_attr('sun.sun', 'elevation') }}`, on the same scale. You are setting a **light level**, not a delay:

| Elevation | Roughly | Light |
| --------- | ------- | ----- |
| `+2°` | Sunset − ~15 min | The sun is still up |
| `0°` | Sunset | The sun touches the horizon |
| `-2°` *(default)* | Sunset + ~10 min | Dusk, still easy to see outside |
| `-4°` | Sunset + ~20-25 min | Room is dark |
| `-6°` | Sunset + ~35-40 min | End of civil twilight, artificial light needed |

The "roughly" column is a mid-latitude approximation: the delay an elevation works out to shifts a little with latitude and season, which is the point — the light level does not.

Degrees say nothing about when your covers will actually move, so the *Daily Cover Schedule* form turns the value into tonight's time, computed from your own location:

```
At -2°, that is 21:41 tonight.
```

`-2°` is the default because it is where the retired "sunset + 10 minutes" setting used to land, at every season. Pick `-4°` to wait until the room is genuinely dark (about a quarter of an hour later), or a positive value to close before the sun is down.

The form refuses an elevation the sun never reaches at your location — a positive value that the midwinter sun stays below, or any value above the polar circle in June. At mid-latitude nothing in the range can be refused: the sun sweeps all of it every day of the year. Should it become unreachable anyway (a home that moved, an entry restored elsewhere), the covers close at plain sunset rather than staying up all night:

```
WARNING ... Daily cover schedule: the sun never reaches 8.0° on 2026-06-21 — closing at sunset instead
```

> **Upgrading from 1.7.x:** the evening close used to be "sunset ± N minutes". That setting is gone; the entry migration converts your offset into the elevation it was landing on, so the covers keep moving at the time they moved before. The conversion is logged.

### Individual Covers

The covers driven by the daily schedule are added **one at a time** from the *Individual Covers* menu: pick the cover, and optionally a **window opening sensor** (does that window stand open?) and a **My position button** (how should this cover close?). Adding a cover that is already in the list updates its settings instead of duplicating it. A cover group is a cover entity like any other, so driving the whole house through one group is simply a single entry.
Expand Down Expand Up @@ -364,7 +430,9 @@ Whether the cover has already been closed by this automation today, and when the
| Manual override with timeout | ✅ | `number.override_duration` |
| Native daily cover open/close (no Scheduler entity needed) | ✅ | See [Daily Cover Schedule](#️-daily-cover-schedule) |
| Daily cover schedule state survives HA restart | ✅ | Persisted alongside the heat-protection cover state |
| Evening close driven by the sun's elevation | ✅ | See [When the Covers Close](#when-the-covers-close) |
| Skip the evening close when a window is open | ✅ | See [Individual Covers](#individual-covers) |
| Warning entity listing the covers left open | ✅ | `binary_sensor.covers_left_open` |
| Per-cover My position instead of a full close | ✅ | See [Individual Covers](#individual-covers) |
| Cover reactive heat close | ✅ | See [Reactive Close](#reactive-close); active window derived from Daily Cover Schedule; never reopens itself |
| Cover proactive forecast-based close | ✅ | See [Proactive Forecast-Based Close](#proactive-forecast-based-close) |
Expand Down
79 changes: 79 additions & 0 deletions custom_components/homeshift/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,37 @@
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform
from homeassistant.core import CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util

from .const import (
CLOSE_ELEVATION_MAX,
CLOSE_ELEVATION_MIN,
CONF_DAILY_COVER_CLOSE_ELEVATION,
CONF_DAILY_COVER_CLOSE_MODE,
CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES,
CONF_DAILY_COVER_ENTITIES,
CONF_DAILY_COVER_ITEMS,
CONF_ITEM_COVER,
CONF_ITEM_MY_BUTTON,
CONF_ITEM_WINDOW_SENSOR,
DEFAULT_DAILY_COVER_CLOSE_ELEVATION,
DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES,
DOMAIN,
LEGACY_CLOSE_MODE_ELEVATION,
SENSOR_NEXT_SCAN,
SERVICE_REFRESH_SCHEDULERS,
SERVICE_SYNC_CALENDAR,
)
from .coordinator import HomeShiftCoordinator
from .cover_manager import elevation_for_sunset_offset

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.SELECT, Platform.NUMBER, Platform.SENSOR, Platform.BINARY_SENSOR]

# Settings the v3 -> v4 migration folds into CONF_DAILY_COVER_CLOSE_ELEVATION.
_RETIRED_CLOSE_KEYS = frozenset({CONF_DAILY_COVER_CLOSE_MODE, CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES})


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate config entry to the current version."""
Expand Down Expand Up @@ -78,9 +91,75 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
len(items),
)

if entry.version < 4:
# v3 -> v4: the evening close no longer takes a delay in minutes
# around sunset, only the sun elevation to close at. Convert the
# stored offset into the elevation it was landing on, so the covers
# keep moving at the time they moved yesterday.
merged = {**entry.data, **entry.options}
elevation = _migrated_close_elevation(hass, merged)

data = {k: v for k, v in entry.data.items() if k not in _RETIRED_CLOSE_KEYS}
options = {k: v for k, v in entry.options.items() if k not in _RETIRED_CLOSE_KEYS}
if elevation is not None:
options[CONF_DAILY_COVER_CLOSE_ELEVATION] = elevation

hass.config_entries.async_update_entry(entry, data=data, options=options, version=4)
_LOGGER.info(
"HomeShift config entry migrated to version 4 (covers now close at %s° of sun elevation)",
elevation,
)

return True


def _migrated_close_elevation(hass: HomeAssistant, merged: dict) -> float | None:
"""Return the elevation a v3 entry should close at, or None to leave it unset.

An entry that already picked an elevation keeps it. Otherwise the stored
sunset offset — or the default the entry was silently running on — is
converted to the elevation it lands on, clamped to what the config flow
accepts. Returns None when the daily schedule drives no cover, so an
unused feature is not given a setting it never had.
"""
if not merged.get(CONF_DAILY_COVER_ITEMS):
return None

if merged.get(CONF_DAILY_COVER_CLOSE_MODE) == LEGACY_CLOSE_MODE_ELEVATION:
try:
return float(merged[CONF_DAILY_COVER_CLOSE_ELEVATION])
except (KeyError, TypeError, ValueError):
pass

try:
offset = int(merged.get(CONF_DAILY_COVER_CLOSE_OFFSET_MINUTES, DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES))
except (TypeError, ValueError):
offset = DEFAULT_DAILY_COVER_CLOSE_OFFSET_MINUTES

elevation = elevation_for_sunset_offset(hass, offset, dt_util.now().year)
if elevation is None:
_LOGGER.warning(
"HomeShift: could not convert the %s min sunset offset to a sun elevation "
"(no usable location) — falling back to %s°",
offset,
DEFAULT_DAILY_COVER_CLOSE_ELEVATION,
)
return DEFAULT_DAILY_COVER_CLOSE_ELEVATION

clamped = min(max(elevation, CLOSE_ELEVATION_MIN), CLOSE_ELEVATION_MAX)
if clamped != elevation:
_LOGGER.warning(
"HomeShift: a %s min sunset offset works out to %s° of sun elevation, outside the "
"%s°..%s° the config flow accepts — clamped to %s°",
offset,
elevation,
CLOSE_ELEVATION_MIN,
CLOSE_ELEVATION_MAX,
clamped,
)
return clamped


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HomeShift from a config entry."""
hass.data.setdefault(DOMAIN, {})
Expand Down
60 changes: 59 additions & 1 deletion custom_components/homeshift/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util

from .const import BINARY_SENSOR_COVER_HEAT_ACTIVE, CONF_COVER_ENTITIES, CONF_COVER_TEMP_SENSOR, DOMAIN
from .const import (
BINARY_SENSOR_COVER_HEAT_ACTIVE,
BINARY_SENSOR_COVERS_LEFT_OPEN,
CONF_COVER_ENTITIES,
CONF_COVER_TEMP_SENSOR,
CONF_DAILY_COVER_ITEMS,
DOMAIN,
)
from .coordinator import HomeShiftCoordinator

_LOGGER = logging.getLogger(__name__)
Expand All @@ -28,6 +35,8 @@ async def async_setup_entry(
entities = []
if config.get(CONF_COVER_ENTITIES) and config.get(CONF_COVER_TEMP_SENSOR):
entities.append(HomeShiftCoverHeatActiveSensor(coordinator, entry))
if config.get(CONF_DAILY_COVER_ITEMS):
entities.append(HomeShiftCoversLeftOpenSensor(coordinator, entry))
async_add_entities(entities)


Expand Down Expand Up @@ -90,3 +99,52 @@ def is_on(self) -> bool | None:
def device_info(self) -> dict:
"""Return device information."""
return _device_info(self._entry)


class HomeShiftCoversLeftOpenSensor(CoordinatorEntity[HomeShiftCoordinator], BinarySensorEntity):
"""Problem sensor: on when tonight's close had to leave covers up.

A cover whose window sensor reports the window open is skipped by the
evening close and only warned about in the log — which nobody reads. This
entity raises the same warning where it can be seen and acted on, and
lists the covers concerned in its attributes so a notification can name
them.

Only registered when the daily schedule drives at least one cover
(CONF_DAILY_COVER_ITEMS). Stays on until the next calendar day's schedule
is computed: closing the window does not bring the cover down, so the
warning outlives the open window that caused it.
"""

_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_has_entity_name = True
_attr_name = "Covers Left Open"
_attr_icon = "mdi:window-open-variant"

def __init__(self, coordinator: HomeShiftCoordinator, entry: ConfigEntry) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_{BINARY_SENSOR_COVERS_LEFT_OPEN}"
self._entry = entry

@property
def is_on(self) -> bool:
"""Return True when at least one cover was left open tonight."""
return bool(self.coordinator.covers_left_open)

@property
def extra_state_attributes(self) -> dict:
"""List the covers left open and the window sensor that blocked each."""
left_open = self.coordinator.covers_left_open
checked_on = self.coordinator.covers_left_open_date
return {
"count": len(left_open),
"covers": list(left_open),
"window_sensors": dict(left_open),
"checked_on": checked_on.isoformat() if checked_on else None,
}

@property
def device_info(self) -> dict:
"""Return device information."""
return _device_info(self._entry)
Loading
Loading