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
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,10 @@ disabled.)
|---|---|
| `select.<zone>_shade_mode` | current mode — **the only thing policy writes** |
| `sensor.<zone>_glare_position` | calculator output; attrs: `gamma`, `profile_angle`, `sun_in_window`, `constraint` (`direct` / `reflected` / `none` — what bound the position) |
| `sensor.<zone>_shade_target` | what the actuator wants; attrs: `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active`), `hold_until`, `last_command` |
| `sensor.<zone>_shade_target` | what the actuator wants; attrs: `zone_id`, `covers`, `enabled`, `mode`, `last_decision` (`command` / `in_sync` / `rate_limited` / `hold_active` / `disabled`), `hold_until`, `last_command` |
| `binary_sensor.<zone>_sun_in_window` | direct sun geometrically possible now |
| `binary_sensor.<zone>_shade_hold` | a human moved a cover; engine is standing down |
| `binary_sensor.<zone>_shade_hold` | a human moved a cover; engine is standing down; attr `hold_until` |
| `switch.<zone>_shade_control` | master on/off for the zone — off means the engine never commands these covers (no expiry, survives restarts); turning it back on reconciles immediately |

## Services

Expand All @@ -155,6 +156,47 @@ disabled.)
| `shade_engine.release` | clear a hold and reconcile — use in automations that must win over a manual move (e.g. privacy close at dusk) |
| `shade_engine.reconcile` | evaluate immediately, bypassing rate limit (`zone` optional) |

## Dashboard card

The integration bundles a Lovelace card and registers it as a frontend
resource automatically — no HACS frontend install, no manual resource entry.
Add it to any dashboard:

```yaml
type: custom:shade-engine-card
entity: sensor.kitchen_shade_target
```

or, equivalently, by zone id from your YAML config:

```yaml
type: custom:shade-engine-card
zone: kitchen
```

One card per zone shows:

- **Target / current / glare** positions side by side (current reads the
covers live; multiple covers show as `42 / 40`).
- **Mode chips** — every configured mode, tap to switch (writes the same
`select` your automations do).
- **Sun-in-window** indicator in the header.
- **Manual hold banner** with a live countdown to `hold_until` and a
**Release** button (`shade_engine.release`); when no hold is active, a
**Hold** button pauses the zone for its configured `hold_duration`.
- **Control toggle** — the zone's `switch.<zone>_shade_control`; off greys
the card and the engine stands down entirely.
- A status badge explaining the last decision (`In sync`, `Rate limited —
retrying`, `Manual hold`, `Control off`, `Moving`).

All sibling entities are derived from the target sensor's object-id prefix.
If you've renamed entities, point the card at them explicitly with
`mode_entity`, `hold_entity`, `sun_entity`, `glare_entity`, and
`switch_entity`; `title` overrides the header.

The card appears in the dashboard card picker as **Shade Engine Card**
(after one browser refresh following installation or upgrade).

## Behavior guarantees

- **Deferred, never dropped.** A move suppressed by the rate limit or
Expand All @@ -165,6 +207,10 @@ disabled.)
`binary_sensor` with a `hold_until` timestamp; `shade_engine.release`
clears it. A `forced` evaluation (mode change, reconcile service) bypasses
rate limiting but **never** bypasses a hold.
- **Off means off.** `switch.<zone>_shade_control` is a hard gate: while it
is off the engine never commands the zone's covers, manual moves are
adopted silently (no hold), and nothing — not even a forced reconcile —
overrides it. It restores across restarts.
- **Every non-move is explained.** `sensor.<zone>_shade_target` always says
why the engine last declined to act.

Expand Down
49 changes: 48 additions & 1 deletion custom_components/shade_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@

import logging
import math
from pathlib import Path

import voluptuous as vol

from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
Expand All @@ -30,6 +33,7 @@
async_track_time_interval,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from homeassistant.util import dt as dt_util
from datetime import timedelta

Expand Down Expand Up @@ -80,11 +84,19 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT]
PLATFORMS = [
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SELECT,
Platform.SWITCH,
]

SUN_ENTITY = "sun.sun"
TICK_INTERVAL = timedelta(seconds=60)

CARD_FILENAME = "shade-engine-card.js"
CARD_URL = f"/{DOMAIN}/{CARD_FILENAME}"


def _mode_target(value):
"""Validate one mode's target: an int, "glare", or a clamp mapping."""
Expand Down Expand Up @@ -411,6 +423,25 @@ def restore_mode(self, zone_id: str, mode: str) -> None:
if mode in zone.core.modes:
zone.core.mode = mode

async def async_set_enabled(self, zone_id: str, enabled: bool) -> None:
"""Turn the engine on or off for one zone.

Re-enabling reconciles immediately (bypassing the rate limit, but
never a hold) so the zone converges without waiting for a tick.
"""
zone = self.zones[zone_id]
if zone.core.enabled == enabled:
return
zone.core.enabled = enabled
_LOGGER.info("[%s] control %s", zone_id, "enabled" if enabled else "disabled")
async_dispatcher_send(self.hass, signal_zone_update(zone_id))
await self._evaluate(zone, forced=enabled)

@callback
def restore_enabled(self, zone_id: str, enabled: bool) -> None:
"""Adopt a restored on/off state at startup without commanding."""
self.zones[zone_id].core.enabled = enabled

def _schedule_hold_expiry(self, zone: Zone) -> None:
if (timer := self._hold_timers.pop(zone.zone_id, None)) is not None:
timer()
Expand Down Expand Up @@ -449,6 +480,22 @@ async def async_reconcile(self, zone_id: str | None) -> None:

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Shade Engine from YAML configuration."""
# Serve the bundled Lovelace card and register it as a frontend resource
# so `custom:shade-engine-card` works with zero manual resource setup.
# Registered even when the YAML is gone, so existing dashboards degrade
# to the card's own "entity not found" message rather than a red box.
await hass.http.async_register_static_paths(
[
StaticPathConfig(
CARD_URL,
str(Path(__file__).parent / "www" / CARD_FILENAME),
cache_headers=True,
)
]
)
integration = await async_get_integration(hass, DOMAIN)
add_extra_js_url(hass, f"{CARD_URL}?v={integration.version}")

conf = config.get(DOMAIN)
if conf is None:
# YAML was removed; drop the imported entry so entities don't linger.
Expand Down
12 changes: 11 additions & 1 deletion custom_components/shade_engine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# Reasons an evaluation may decline to command. Exposed on the target sensor
# so "why didn't it move?" is answerable from the UI.
REASON_COMMAND = "command"
REASON_DISABLED = "disabled"
REASON_HOLD = "hold_active"
REASON_IN_SYNC = "in_sync"
REASON_RATE_LIMITED = "rate_limited"
Expand Down Expand Up @@ -68,6 +69,7 @@ class ZoneCore:
modes: dict[str, ModeTarget]
motion: MotionConfig
mode: str
enabled: bool = True
last_commanded: dict[str, int] = field(default_factory=dict)
last_command_ts: float | None = None
hold_until: float | None = None
Expand Down Expand Up @@ -117,6 +119,11 @@ def report_position(self, cover: str, position: int, now: float) -> bool:
return False
# A human moved this cover: adopt their position and stand down.
self.last_commanded[cover] = position
if not self.enabled:
# Control is off; the engine wasn't going to move anyway, so a
# manual move needs no hold. Adopting the baseline above keeps a
# later re-enable from misreading this position as manual.
return False
self.start_hold(now)
return True

Expand All @@ -133,7 +140,7 @@ def evaluate(

``current`` maps cover -> reported position (None when unavailable).
``forced`` bypasses rate limiting (mode changes, explicit services)
but never bypasses an active hold.
but never bypasses an active hold or a disabled zone.
"""
# Adopt baselines for covers we have never commanded. Without this,
# the first manual move after startup is mistaken for the baseline in
Expand All @@ -144,6 +151,9 @@ def evaluate(
if position is not None:
self.last_commanded.setdefault(cover, position)

if not self.enabled:
return Decision(REASON_DISABLED)

if self.hold_active(now):
return Decision(REASON_HOLD)

Expand Down
4 changes: 2 additions & 2 deletions custom_components/shade_engine/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
"name": "Shade Engine",
"codeowners": ["@vfilby"],
"config_flow": true,
"dependencies": ["sun"],
"dependencies": ["frontend", "http", "sun"],
"documentation": "https://github.com/vfilby/shade-engine",
"integration_type": "hub",
"iot_class": "calculated",
"issue_tracker": "https://github.com/vfilby/shade-engine/issues",
"requirements": [],
"single_config_entry": true,
"version": "0.3.0"
"version": "0.4.0"
}
5 changes: 5 additions & 0 deletions custom_components/shade_engine/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ def extra_state_attributes(self) -> dict:
core = self._zone.core
decision = self._zone.last_decision
return {
# zone_id and covers let the bundled Lovelace card find the zone
# (service calls take the zone id) and show live cover positions.
"zone_id": self._zone.zone_id,
"covers": list(core.covers),
"enabled": core.enabled,
"mode": core.mode,
"last_decision": decision.reason if decision else None,
"hold_until": (
Expand Down
72 changes: 72 additions & 0 deletions custom_components/shade_engine/switch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Per-zone control switch: is the engine allowed to move this zone at all."""

from __future__ import annotations

from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity

from .const import DOMAIN, signal_zone_update
from .entity import zone_device_info


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
engine = hass.data[DOMAIN]
async_add_entities(
ShadeControlSwitch(engine, zone) for zone in engine.zones.values()
)


class ShadeControlSwitch(SwitchEntity, RestoreEntity):
"""Off means the engine never commands this zone's covers.

Unlike a hold this has no expiry; it survives restarts. Turning it back
on reconciles immediately (bypassing the rate limit, never a hold).
"""

_attr_should_poll = False
_attr_has_entity_name = True
_attr_icon = "mdi:robot"

def __init__(self, engine, zone) -> None:
self._engine = engine
self._zone = zone
self._attr_unique_id = f"{DOMAIN}_{zone.zone_id}_control"
self._attr_name = "Shade control"
self._attr_device_info = zone_device_info(zone)

@property
def is_on(self) -> bool:
return self._zone.core.enabled

async def async_turn_on(self, **kwargs) -> None:
await self._engine.async_set_enabled(self._zone.zone_id, True)

async def async_turn_off(self, **kwargs) -> None:
await self._engine.async_set_enabled(self._zone.zone_id, False)

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
if (last := await self.async_get_last_state()) is not None:
self._engine.restore_enabled(
self._zone.zone_id, last.state != STATE_OFF
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
signal_zone_update(self._zone.zone_id),
self._handle_update,
)
)

@callback
def _handle_update(self) -> None:
self.async_write_ha_state()
Loading
Loading