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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,23 @@ The flow asks for the light switch and the visibility signals:
the quiet-time hold (night movement, morning routine — same rule).
- **Out-of-sight rooms** — occupancy for rooms that *cannot* see the
light (home office). Sustained presence there allows daytime runs
while you are awake at home — the work-from-home case.
while you are awake at home — the work-from-home case. Refuge blocks
are deliberately tolerant: a coffee run through a viewer room neither
cuts the light nor resets anything (exposure grace), and short sensor
dropouts don't release the block (refuge hold). Settling within sight
of the light still cuts it.
- **Hard vetoes** — signals that force the light off regardless, e.g.
the TV in the same room.
- **Movement triggers** — entities whose state *changes* mean someone
is on the move near the sleep boundary, e.g. the bedroom door
contact: every change cuts the light instantly and starts the
quiet-time hold. Only transitions count — a door left open all day
never blocks the schedule.

All signals are optional, but with none configured the fail-safe keeps
the light permanently off. Timing tunables (anchor, quiet-time hold,
refuge confirmation, minimum block) live under Options → Timing.
refuge confirmation/hold, exposure grace, minimum block) live under
Options → Timing.

## Releases & channels

Expand Down
35 changes: 35 additions & 0 deletions custom_components/grow_conductor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Grow Conductor — photoperiod scheduling for grow lights nobody should see."""

from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .config import Config
from .const import DOMAIN, PLATFORMS
from .controller import GrowConductorController


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
controller = GrowConductorController(hass, entry, Config.from_options(dict(entry.options)))
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller

await controller.async_start()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Entity restores (enabled, target, budget seed) have been applied
# during platform setup — enforcement may begin (rule 4.1).
controller.arm()

entry.async_on_unload(entry.add_update_listener(_async_options_updated))
return True


async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
controller: GrowConductorController = hass.data[DOMAIN].pop(entry.entry_id)
controller.async_stop()
return unloaded
78 changes: 78 additions & 0 deletions custom_components/grow_conductor/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Typed view of the options contract (see const.py)."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from .const import (
CONF_ANCHOR,
CONF_CLEAR_HOLD,
CONF_EXPOSURE_GRACE,
CONF_HOME,
CONF_LIGHT,
CONF_MIN_BLOCK,
CONF_REFUGE_CONFIRM,
CONF_REFUGE_HOLD,
CONF_REFUGES,
CONF_SLEEP,
CONF_TRIGGERS,
CONF_VETOES,
CONF_VIEWERS,
DEFAULT_ANCHOR,
)
from .core.tunables import Tunables


def _anchor_minutes(anchor: str) -> int:
hours, minutes = anchor.split(":")[:2]
return int(hours) * 60 + int(minutes)


@dataclass(frozen=True)
class Config:
"""Validated per-entry configuration."""

light_entity: str
sleep_entity: str | None = None
home_entity: str | None = None
viewer_entities: tuple[str, ...] = ()
refuge_entities: tuple[str, ...] = ()
veto_entities: tuple[str, ...] = ()
trigger_entities: tuple[str, ...] = ()
tunables: Tunables = field(default_factory=Tunables)

@classmethod
def from_options(cls, options: dict[str, Any]) -> Config:
return cls(
light_entity=options[CONF_LIGHT],
sleep_entity=options.get(CONF_SLEEP) or None,
home_entity=options.get(CONF_HOME) or None,
viewer_entities=tuple(options.get(CONF_VIEWERS, ())),
refuge_entities=tuple(options.get(CONF_REFUGES, ())),
veto_entities=tuple(options.get(CONF_VETOES, ())),
trigger_entities=tuple(options.get(CONF_TRIGGERS, ())),
tunables=Tunables(
anchor_minutes=_anchor_minutes(options.get(CONF_ANCHOR, DEFAULT_ANCHOR)),
clear_hold_s=float(options.get(CONF_CLEAR_HOLD, Tunables().clear_hold_s)),
refuge_confirm_s=float(
options.get(CONF_REFUGE_CONFIRM, Tunables().refuge_confirm_s)
),
refuge_hold_s=float(options.get(CONF_REFUGE_HOLD, Tunables().refuge_hold_s)),
exposure_grace_s=float(
options.get(CONF_EXPOSURE_GRACE, Tunables().exposure_grace_s)
),
min_block_s=float(options.get(CONF_MIN_BLOCK, Tunables().min_block_s)),
),
)

@property
def input_entities(self) -> tuple[str, ...]:
"""Every entity that feeds the input snapshot (not the light itself).

Trigger entities are deliberately absent: their level is never
part of the snapshot (rule 1.3b) — the controller subscribes to
their transitions separately.
"""
singles = tuple(e for e in (self.sleep_entity, self.home_entity) if e)
return singles + self.viewer_entities + self.refuge_entities + self.veto_entities
196 changes: 196 additions & 0 deletions custom_components/grow_conductor/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Config and options flows.

One config entry per grow light. ``entry.data`` stays empty; every
setting lives in options (see const.py for the contract) so the options
flow can edit everything. The initial flow asks only for the light and
its visibility signals — timing tunables keep spec defaults and are
editable under Options → Timing.
"""

from __future__ import annotations

from typing import Any

import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import callback
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
TextSelector,
TimeSelector,
)

from .const import (
CONF_ANCHOR,
CONF_CLEAR_HOLD,
CONF_EXPOSURE_GRACE,
CONF_HOME,
CONF_LIGHT,
CONF_MIN_BLOCK,
CONF_REFUGE_CONFIRM,
CONF_REFUGE_HOLD,
CONF_REFUGES,
CONF_SLEEP,
CONF_TRIGGERS,
CONF_VETOES,
CONF_VIEWERS,
DEFAULT_ANCHOR,
DOMAIN,
)
from .core.tunables import Tunables

CONF_NAME = "name"

_LIGHT_SELECTOR = EntitySelector(EntitySelectorConfig(domain=["switch", "light", "input_boolean"]))
_BINARY_SELECTOR = EntitySelector(EntitySelectorConfig(domain=["binary_sensor", "input_boolean"]))
_HOME_SELECTOR = EntitySelector(
EntitySelectorConfig(
domain=["zone", "person", "device_tracker", "binary_sensor", "input_boolean", "sensor"]
)
)
_MULTI_OCCUPANCY_SELECTOR = EntitySelector(
EntitySelectorConfig(domain=["binary_sensor", "input_boolean"], multiple=True)
)
_VETO_SELECTOR = EntitySelector(
EntitySelectorConfig(
domain=["media_player", "binary_sensor", "switch", "input_boolean"], multiple=True
)
)


def _signals_schema(defaults: dict[str, Any]) -> vol.Schema:
def suggested(key: str) -> dict[str, Any]:
return {"suggested_value": defaults[key]} if defaults.get(key) else {}

return vol.Schema(
{
vol.Optional(CONF_SLEEP, description=suggested(CONF_SLEEP)): _BINARY_SELECTOR,
vol.Optional(CONF_HOME, description=suggested(CONF_HOME)): _HOME_SELECTOR,
vol.Optional(
CONF_VIEWERS, default=list(defaults.get(CONF_VIEWERS, []))
): _MULTI_OCCUPANCY_SELECTOR,
vol.Optional(
CONF_REFUGES, default=list(defaults.get(CONF_REFUGES, []))
): _MULTI_OCCUPANCY_SELECTOR,
vol.Optional(CONF_VETOES, default=list(defaults.get(CONF_VETOES, []))): _VETO_SELECTOR,
vol.Optional(
CONF_TRIGGERS, default=list(defaults.get(CONF_TRIGGERS, []))
): _MULTI_OCCUPANCY_SELECTOR,
}
)


def _seconds_selector(max_value: float) -> NumberSelector:
return NumberSelector(
NumberSelectorConfig(
min=0, max=max_value, step=10, mode=NumberSelectorMode.BOX, unit_of_measurement="s"
)
)


def _timing_schema(defaults: dict[str, Any]) -> vol.Schema:
tunables = Tunables()
return vol.Schema(
{
vol.Required(
CONF_ANCHOR, default=defaults.get(CONF_ANCHOR, DEFAULT_ANCHOR)
): TimeSelector(),
vol.Required(
CONF_CLEAR_HOLD, default=defaults.get(CONF_CLEAR_HOLD, tunables.clear_hold_s)
): _seconds_selector(7200),
vol.Required(
CONF_REFUGE_CONFIRM,
default=defaults.get(CONF_REFUGE_CONFIRM, tunables.refuge_confirm_s),
): _seconds_selector(3600),
vol.Required(
CONF_REFUGE_HOLD,
default=defaults.get(CONF_REFUGE_HOLD, tunables.refuge_hold_s),
): _seconds_selector(7200),
vol.Required(
CONF_EXPOSURE_GRACE,
default=defaults.get(CONF_EXPOSURE_GRACE, tunables.exposure_grace_s),
): _seconds_selector(3600),
vol.Required(
CONF_MIN_BLOCK, default=defaults.get(CONF_MIN_BLOCK, tunables.min_block_s)
): _seconds_selector(7200),
}
)


class GrowConductorConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1

def __init__(self) -> None:
self._name: str | None = None
self._light: str | None = None

async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_LIGHT])
self._abort_if_unique_id_configured()
self._name = user_input[CONF_NAME]
self._light = user_input[CONF_LIGHT]
return await self.async_step_signals()

schema = vol.Schema(
{
vol.Required(CONF_NAME): TextSelector(),
vol.Required(CONF_LIGHT): _LIGHT_SELECTOR,
}
)
return self.async_show_form(step_id="user", data_schema=schema)

async def async_step_signals(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if user_input is not None:
assert self._name is not None and self._light is not None
options = {CONF_LIGHT: self._light, **user_input}
return self.async_create_entry(title=self._name, data={}, options=options)

defaults = {CONF_HOME: "zone.home" if self.hass.states.get("zone.home") else None}
return self.async_show_form(step_id="signals", data_schema=_signals_schema(defaults))

@staticmethod
@callback
def async_get_options_flow(config_entry) -> GrowConductorOptionsFlow:
return GrowConductorOptionsFlow()


class GrowConductorOptionsFlow(OptionsFlow):
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return self.async_show_menu(step_id="init", menu_options=["signals", "timing"])

async def async_step_signals(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if user_input is not None:
options = {
k: v
for k, v in dict(self.config_entry.options).items()
if k
not in (
CONF_SLEEP,
CONF_HOME,
CONF_VIEWERS,
CONF_REFUGES,
CONF_VETOES,
CONF_TRIGGERS,
)
}
options.update(user_input)
return self.async_create_entry(data=options)
return self.async_show_form(
step_id="signals", data_schema=_signals_schema(dict(self.config_entry.options))
)

async def async_step_timing(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(data={**dict(self.config_entry.options), **user_input})
return self.async_show_form(
step_id="timing", data_schema=_timing_schema(dict(self.config_entry.options))
)
53 changes: 53 additions & 0 deletions custom_components/grow_conductor/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Constants and the options contract.

Options contract (house convention: ``entry.data`` stays empty, every
setting lives in ``entry.options`` so the options flow can edit all of
it; ``entry.title`` is the device name):

- ``light_entity`` (str, required) — the switch/light to conduct.
- ``sleep_entity`` (str, optional) — household-asleep binary signal.
- ``home_entity`` (str, optional) — anyone-home signal (zone, person,
device_tracker, binary sensor, or numeric count).
- ``viewer_entities`` (list[str]) — occupancy in rooms that can see the
light (ENGINE_SPEC rule 1.3).
- ``refuge_entities`` (list[str]) — occupancy that proves everyone is
settled out of sight (rule 1.7).
- ``veto_entities`` (list[str]) — hard "someone is watching" signals,
e.g. the TV media_player (rule 1.2).
- ``trigger_entities`` (list[str]) — momentary movement signals whose
state *transitions* pulse the viewer-activity clock (rule 1.3b),
e.g. the bedroom door contact. Their level is never read.
- ``anchor`` (str "HH:MM:SS") — plant-day boundary (rule 2.1).
- ``clear_hold_seconds`` / ``refuge_confirm_seconds`` /
``refuge_hold_seconds`` / ``exposure_grace_seconds`` /
``min_block_seconds`` (number) — ENGINE_SPEC §6 tunables.

Runtime knobs (``number.target_hours``, ``switch.enabled``) are entity
state restored by HA, never options.
"""

from __future__ import annotations

DOMAIN = "grow_conductor"

CONF_LIGHT = "light_entity"
CONF_SLEEP = "sleep_entity"
CONF_HOME = "home_entity"
CONF_VIEWERS = "viewer_entities"
CONF_REFUGES = "refuge_entities"
CONF_VETOES = "veto_entities"
CONF_TRIGGERS = "trigger_entities"
CONF_ANCHOR = "anchor"
CONF_CLEAR_HOLD = "clear_hold_seconds"
CONF_REFUGE_CONFIRM = "refuge_confirm_seconds"
CONF_REFUGE_HOLD = "refuge_hold_seconds"
CONF_EXPOSURE_GRACE = "exposure_grace_seconds"
CONF_MIN_BLOCK = "min_block_seconds"

DEFAULT_ANCHOR = "22:00:00"

# While the light burns with no events due, re-publish the lit-hours
# sensor on this cadence (quantization keeps the recorder quiet).
PUBLISH_INTERVAL_S = 360.0

PLATFORMS = ["number", "sensor", "switch"]
Loading
Loading