Initial commit: Main Gate Controller HACS integration
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""The Main Gate Controller integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import CONF_NAME, DEFAULT_NAME, DOMAIN, MANUFACTURER, MODEL
|
||||
from .coordinator import MainGateController
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]
|
||||
|
||||
type MainGateConfigEntry = ConfigEntry[MainGateController]
|
||||
|
||||
|
||||
@callback
|
||||
def build_device_info(entry: ConfigEntry) -> DeviceInfo:
|
||||
"""Return the ``DeviceInfo`` shared by all entities of one gate."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.data.get(CONF_NAME, DEFAULT_NAME),
|
||||
manufacturer=MANUFACTURER,
|
||||
model=MODEL,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MainGateConfigEntry) -> bool:
|
||||
"""Set up a gate from a config entry."""
|
||||
controller = MainGateController(hass, entry)
|
||||
entry.runtime_data = controller
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def _async_on_options_updated(
|
||||
hass: HomeAssistant, updated_entry: ConfigEntry
|
||||
) -> None:
|
||||
# Trigger entity refresh so attribute changes (e.g. duration) show up.
|
||||
controller.async_publish_update()
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_on_options_updated))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MainGateConfigEntry) -> bool:
|
||||
"""Unload a config entry, cancelling any in-progress gate cycle."""
|
||||
controller: MainGateController = entry.runtime_data
|
||||
await controller.async_shutdown()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Button entity that triggers a Main Gate Controller cycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MainGateConfigEntry, build_device_info
|
||||
from .coordinator import MainGateController
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MainGateConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Open button for a gate."""
|
||||
controller: MainGateController = entry.runtime_data
|
||||
async_add_entities([MainGateOpenButton(controller, entry)])
|
||||
|
||||
|
||||
class MainGateOpenButton(ButtonEntity):
|
||||
"""Button that runs the full open/countdown/close cycle."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "open"
|
||||
_attr_icon = "mdi:gate-open"
|
||||
|
||||
def __init__(self, controller: MainGateController, entry: MainGateConfigEntry) -> None:
|
||||
self._controller = controller
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_open"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return build_device_info(self._entry)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press: start the cycle if idle.
|
||||
|
||||
Behaviour is ``mode: single`` — a second press while a cycle is in
|
||||
progress is silently ignored and an info-level log entry is written.
|
||||
"""
|
||||
self._controller.async_trigger()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Config flow for the Main Gate Controller integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
OptionsFlow,
|
||||
OptionsFlowWithConfigEntry,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
TextSelector,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_NAME,
|
||||
CONF_NOTIFY_CLOSING_TEXT,
|
||||
CONF_NOTIFY_ENABLED,
|
||||
CONF_NOTIFY_ENTITIES,
|
||||
CONF_NOTIFY_OPENING_TEXT,
|
||||
CONF_OPEN_DURATION,
|
||||
CONF_PULSE_DURATION,
|
||||
CONF_SWITCH_ENTITY_ID,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_NOTIFY_CLOSING_TEXT,
|
||||
DEFAULT_NOTIFY_ENABLED,
|
||||
DEFAULT_NOTIFY_OPENING_TEXT,
|
||||
DEFAULT_OPEN_DURATION,
|
||||
DEFAULT_PULSE_DURATION,
|
||||
DOMAIN,
|
||||
MAX_OPEN_DURATION,
|
||||
MAX_PULSE_DURATION,
|
||||
MIN_OPEN_DURATION,
|
||||
MIN_PULSE_DURATION,
|
||||
NOTIFY_DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
)
|
||||
from .coordinator import get_setting
|
||||
|
||||
_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
|
||||
vol.Required(CONF_SWITCH_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(domain=SWITCH_DOMAIN)
|
||||
),
|
||||
vol.Required(CONF_OPEN_DURATION, default=DEFAULT_OPEN_DURATION): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=MIN_OPEN_DURATION,
|
||||
max=MAX_OPEN_DURATION,
|
||||
step=1,
|
||||
unit_of_measurement="s",
|
||||
mode="box",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PULSE_DURATION, default=DEFAULT_PULSE_DURATION): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=MIN_PULSE_DURATION,
|
||||
max=MAX_PULSE_DURATION,
|
||||
step=0.1,
|
||||
unit_of_measurement="s",
|
||||
mode="box",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_NOTIFY_ENABLED, default=DEFAULT_NOTIFY_ENABLED): BooleanSelector(),
|
||||
vol.Optional(CONF_NOTIFY_ENTITIES, default=[]): EntitySelector(
|
||||
EntitySelectorConfig(domain=NOTIFY_DOMAIN, multiple=True)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_NOTIFY_OPENING_TEXT, default=DEFAULT_NOTIFY_OPENING_TEXT
|
||||
): TextSelector(),
|
||||
vol.Optional(
|
||||
CONF_NOTIFY_CLOSING_TEXT, default=DEFAULT_NOTIFY_CLOSING_TEXT
|
||||
): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _options_schema(entry: ConfigEntry) -> vol.Schema:
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_OPEN_DURATION,
|
||||
default=float(get_setting(entry, CONF_OPEN_DURATION, DEFAULT_OPEN_DURATION)),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=MIN_OPEN_DURATION,
|
||||
max=MAX_OPEN_DURATION,
|
||||
step=1,
|
||||
unit_of_measurement="s",
|
||||
mode="box",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_PULSE_DURATION,
|
||||
default=float(get_setting(entry, CONF_PULSE_DURATION, DEFAULT_PULSE_DURATION)),
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=MIN_PULSE_DURATION,
|
||||
max=MAX_PULSE_DURATION,
|
||||
step=0.1,
|
||||
unit_of_measurement="s",
|
||||
mode="box",
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_NOTIFY_ENABLED,
|
||||
default=bool(get_setting(entry, CONF_NOTIFY_ENABLED, DEFAULT_NOTIFY_ENABLED)),
|
||||
): BooleanSelector(),
|
||||
vol.Optional(
|
||||
CONF_NOTIFY_ENTITIES,
|
||||
default=list(get_setting(entry, CONF_NOTIFY_ENTITIES, []) or []),
|
||||
): EntitySelector(
|
||||
EntitySelectorConfig(domain=NOTIFY_DOMAIN, multiple=True)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_NOTIFY_OPENING_TEXT,
|
||||
default=get_setting(
|
||||
entry, CONF_NOTIFY_OPENING_TEXT, DEFAULT_NOTIFY_OPENING_TEXT
|
||||
),
|
||||
): TextSelector(),
|
||||
vol.Optional(
|
||||
CONF_NOTIFY_CLOSING_TEXT,
|
||||
default=get_setting(
|
||||
entry, CONF_NOTIFY_CLOSING_TEXT, DEFAULT_NOTIFY_CLOSING_TEXT
|
||||
),
|
||||
): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Return a dict of validation errors keyed by field name."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
name = user_input.get(CONF_NAME)
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
errors[CONF_NAME] = "invalid_name"
|
||||
|
||||
switch = user_input.get(CONF_SWITCH_ENTITY_ID)
|
||||
if not isinstance(switch, str) or not switch.startswith(f"{SWITCH_DOMAIN}."):
|
||||
errors[CONF_SWITCH_ENTITY_ID] = "invalid_switch_domain"
|
||||
elif hass.states.get(switch) is None:
|
||||
errors[CONF_SWITCH_ENTITY_ID] = "switch_not_found"
|
||||
|
||||
if user_input.get(CONF_NOTIFY_ENABLED) and not user_input.get(CONF_NOTIFY_ENTITIES):
|
||||
errors[CONF_NOTIFY_ENTITIES] = "notify_no_targets"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@callback
|
||||
def _validate_options(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
errors: dict[str, str] = {}
|
||||
if user_input.get(CONF_NOTIFY_ENABLED) and not user_input.get(CONF_NOTIFY_ENTITIES):
|
||||
errors[CONF_NOTIFY_ENTITIES] = "notify_no_targets"
|
||||
return errors
|
||||
|
||||
|
||||
class MainGateConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the user-initiated config flow for a single gate."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
switch = user_input.get(CONF_SWITCH_ENTITY_ID)
|
||||
if isinstance(switch, str):
|
||||
await self.async_set_unique_id(switch)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
errors = _validate_input(self.hass, user_input)
|
||||
if not errors:
|
||||
title = str(user_input[CONF_NAME]).strip() or DEFAULT_NAME
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=_USER_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
return MainGateOptionsFlow(config_entry)
|
||||
|
||||
|
||||
class MainGateOptionsFlow(OptionsFlowWithConfigEntry):
|
||||
"""Handle the options flow for an existing gate."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
errors = _validate_options(user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
_options_schema(self.config_entry), user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_options_schema(self.config_entry),
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Constants for the Main Gate Controller integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import Logger, getLogger
|
||||
|
||||
DOMAIN = "main_gate_controller"
|
||||
LOGGER: Logger = getLogger(__package__)
|
||||
|
||||
MANUFACTURER = "Custom"
|
||||
MODEL = "Main Gate Controller"
|
||||
|
||||
# Configuration fields
|
||||
CONF_NAME = "name"
|
||||
CONF_SWITCH_ENTITY_ID = "switch_entity_id"
|
||||
CONF_OPEN_DURATION = "open_duration"
|
||||
CONF_PULSE_DURATION = "pulse_duration"
|
||||
CONF_NOTIFY_ENABLED = "notify_enabled"
|
||||
CONF_NOTIFY_ENTITIES = "notify_entities"
|
||||
CONF_NOTIFY_OPENING_TEXT = "notify_opening_text"
|
||||
CONF_NOTIFY_CLOSING_TEXT = "notify_closing_text"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_NAME = "Main Gate"
|
||||
DEFAULT_OPEN_DURATION = 20
|
||||
DEFAULT_PULSE_DURATION = 1.0
|
||||
DEFAULT_NOTIFY_ENABLED = False
|
||||
DEFAULT_NOTIFY_OPENING_TEXT = "Opening Main Gate (Pedestrians)"
|
||||
DEFAULT_NOTIFY_CLOSING_TEXT = "Closing Main Gate"
|
||||
|
||||
# Validation bounds
|
||||
MIN_OPEN_DURATION = 1
|
||||
MAX_OPEN_DURATION = 3600
|
||||
MIN_PULSE_DURATION = 0.1
|
||||
MAX_PULSE_DURATION = 10
|
||||
|
||||
# Gate states (machine-readable)
|
||||
GATE_STATE_CLOSED = "closed"
|
||||
GATE_STATE_OPENING = "opening"
|
||||
GATE_STATE_OPEN = "open"
|
||||
GATE_STATE_CLOSING = "closing"
|
||||
|
||||
# Sensor attributes
|
||||
ATTR_REMAINING_SECONDS = "remaining_seconds"
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_RUNNING = "running"
|
||||
ATTR_STARTED_AT = "started_at"
|
||||
ATTR_FINISHES_AT = "finishes_at"
|
||||
|
||||
# Service targets
|
||||
SWITCH_DOMAIN = "switch"
|
||||
NOTIFY_DOMAIN = "notify"
|
||||
SERVICE_SEND_MESSAGE = "send_message"
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Controller that orchestrates the open/countdown/close cycle of a gate.
|
||||
|
||||
The controller owns the lifecycle of a single ``ConfigEntry`` (one gate).
|
||||
Entities subscribe to state changes through :meth:`async_add_listener`. The
|
||||
controller uses :func:`asyncio.sleep` only; there is no blocking sleep, no
|
||||
``time.sleep`` and no direct vendor protocol. A switch is operated through the
|
||||
Home Assistant ``switch.turn_on`` / ``switch.turn_off`` services, so any switch
|
||||
backend (Shelly, MQTT, GPIO, …) is supported transparently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_DURATION,
|
||||
ATTR_FINISHES_AT,
|
||||
ATTR_REMAINING_SECONDS,
|
||||
ATTR_RUNNING,
|
||||
ATTR_STARTED_AT,
|
||||
CONF_NOTIFY_CLOSING_TEXT,
|
||||
CONF_NOTIFY_ENABLED,
|
||||
CONF_NOTIFY_ENTITIES,
|
||||
CONF_NOTIFY_OPENING_TEXT,
|
||||
CONF_OPEN_DURATION,
|
||||
CONF_PULSE_DURATION,
|
||||
CONF_SWITCH_ENTITY_ID,
|
||||
DEFAULT_NOTIFY_CLOSING_TEXT,
|
||||
DEFAULT_NOTIFY_OPENING_TEXT,
|
||||
DEFAULT_OPEN_DURATION,
|
||||
DEFAULT_PULSE_DURATION,
|
||||
GATE_STATE_CLOSED,
|
||||
GATE_STATE_CLOSING,
|
||||
GATE_STATE_OPEN,
|
||||
GATE_STATE_OPENING,
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
SWITCH_DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GateControlError(Exception):
|
||||
"""Raised when a switch service call required to actuate the gate fails."""
|
||||
|
||||
|
||||
@callback
|
||||
def get_setting(entry: ConfigEntry, key: str, default: Any = None) -> Any:
|
||||
"""Return a setting, with options taking precedence over initial data."""
|
||||
if key in entry.options:
|
||||
return entry.options[key]
|
||||
return entry.data.get(key, default)
|
||||
|
||||
|
||||
class MainGateController:
|
||||
"""Run the open/countdown/close sequence for a single gate.
|
||||
|
||||
Concurrency model: ``mode: single``. While a cycle is in progress,
|
||||
:meth:`async_trigger` returns ``False`` and ignores the press. The
|
||||
controller also exposes :meth:`async_wait_until_idle` for tests.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self._state: str = GATE_STATE_CLOSED
|
||||
self._running: bool = False
|
||||
self._remaining_seconds: int | None = None
|
||||
self._cycle_started_at: datetime | None = None
|
||||
self._finishes_at: datetime | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._listeners: set[Callable[[], None]] = set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings (read live so the options flow takes effect without reload)
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def switch_entity_id(self) -> str:
|
||||
"""Switch entity id of the relay that actuates the gate."""
|
||||
return self.entry.data[CONF_SWITCH_ENTITY_ID]
|
||||
|
||||
@property
|
||||
def open_duration(self) -> float:
|
||||
"""Configured duration (seconds) the gate stays open."""
|
||||
return float(get_setting(self.entry, CONF_OPEN_DURATION, DEFAULT_OPEN_DURATION))
|
||||
|
||||
@property
|
||||
def pulse_duration(self) -> float:
|
||||
"""Configured pulse duration (seconds)."""
|
||||
return float(get_setting(self.entry, CONF_PULSE_DURATION, DEFAULT_PULSE_DURATION))
|
||||
|
||||
@property
|
||||
def notify_enabled(self) -> bool:
|
||||
return bool(get_setting(self.entry, CONF_NOTIFY_ENABLED, False))
|
||||
|
||||
@property
|
||||
def notify_entities(self) -> list[str]:
|
||||
raw = get_setting(self.entry, CONF_NOTIFY_ENTITIES, []) or []
|
||||
return list(raw)
|
||||
|
||||
@property
|
||||
def notify_opening_text(self) -> str:
|
||||
return str(
|
||||
get_setting(self.entry, CONF_NOTIFY_OPENING_TEXT, DEFAULT_NOTIFY_OPENING_TEXT)
|
||||
)
|
||||
|
||||
@property
|
||||
def notify_closing_text(self) -> str:
|
||||
return str(
|
||||
get_setting(self.entry, CONF_NOTIFY_CLOSING_TEXT, DEFAULT_NOTIFY_CLOSING_TEXT)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State / status
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""Current gate state (closed/opening/open/closing)."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""True while a full open/countdown/close cycle is in progress."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""State attributes exposed by the status sensor."""
|
||||
attrs: dict[str, Any] = {
|
||||
ATTR_REMAINING_SECONDS: self._remaining_seconds,
|
||||
ATTR_DURATION: self.open_duration,
|
||||
ATTR_RUNNING: self._running,
|
||||
}
|
||||
if self._cycle_started_at is not None:
|
||||
attrs[ATTR_STARTED_AT] = _iso(self._cycle_started_at)
|
||||
if self._finishes_at is not None:
|
||||
attrs[ATTR_FINISHES_AT] = _iso(self._finishes_at)
|
||||
return attrs
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Listener registration
|
||||
# ------------------------------------------------------------------
|
||||
@callback
|
||||
def async_add_listener(self, update_callback: Callable[[], None]) -> Callable[[], None]:
|
||||
"""Register a listener. Returns a function that unregisters it."""
|
||||
self._listeners.add(update_callback)
|
||||
|
||||
@callback
|
||||
def _remove() -> None:
|
||||
self._listeners.discard(update_callback)
|
||||
|
||||
return _remove
|
||||
|
||||
@callback
|
||||
def async_publish_update(self) -> None:
|
||||
"""Notify all subscribed entities that something changed."""
|
||||
for update in list(self._listeners):
|
||||
update()
|
||||
|
||||
def _set_state(self, new_state: str) -> None:
|
||||
if self._state != new_state:
|
||||
_LOGGER.debug(
|
||||
"Gate '%s': %s -> %s", self.entry.title, self._state, new_state
|
||||
)
|
||||
self._state = new_state
|
||||
self.async_publish_update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cycle control
|
||||
# ------------------------------------------------------------------
|
||||
@callback
|
||||
def async_trigger(self) -> bool:
|
||||
"""Start a full open/countdown/close cycle. ``mode: single`` semantics."""
|
||||
if self._running:
|
||||
_LOGGER.info(
|
||||
"Gate '%s': trigger ignored, a cycle is already running (mode: single)",
|
||||
self.entry.title,
|
||||
)
|
||||
return False
|
||||
self._running = True
|
||||
self._cycle_started_at = _utc_now()
|
||||
self._task = self.hass.async_create_task(self._async_run_cycle())
|
||||
return True
|
||||
|
||||
async def async_wait_until_idle(self) -> None:
|
||||
"""Wait until the currently running cycle finishes (test helper)."""
|
||||
task = self._task
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Cancel any running cycle (called from async_unload_entry)."""
|
||||
task = self._task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Gate '%s': background cycle raised during shutdown",
|
||||
self.entry.title,
|
||||
)
|
||||
self._running = False
|
||||
self._remaining_seconds = None
|
||||
self._cycle_started_at = None
|
||||
self._finishes_at = None
|
||||
self._set_state(GATE_STATE_CLOSED)
|
||||
self._listeners.clear()
|
||||
|
||||
async def _async_run_cycle(self) -> None:
|
||||
self._cycle_started_at = _utc_now()
|
||||
try:
|
||||
await self._async_phase_open()
|
||||
await self._async_phase_countdown()
|
||||
await self._async_phase_close()
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.warning(
|
||||
"Gate '%s': cycle cancelled during %s; status reset to closed",
|
||||
self.entry.title,
|
||||
self._state,
|
||||
)
|
||||
raise
|
||||
except GateControlError as err:
|
||||
_LOGGER.error(
|
||||
"Gate '%s': cycle aborted during %s: %s", self.entry.title, self._state, err
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Gate '%s': unexpected error during cycle", self.entry.title)
|
||||
finally:
|
||||
self._running = False
|
||||
self._remaining_seconds = None
|
||||
self._cycle_started_at = None
|
||||
self._finishes_at = None
|
||||
self._task = None
|
||||
self._set_state(GATE_STATE_CLOSED)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phases
|
||||
# ------------------------------------------------------------------
|
||||
async def _async_phase_open(self) -> None:
|
||||
pulse = self.pulse_duration
|
||||
self._finishes_at = None
|
||||
self._set_state(GATE_STATE_OPENING)
|
||||
await self._async_send_notification(self.notify_opening_text)
|
||||
await self._async_call_switch(False)
|
||||
await asyncio.sleep(pulse)
|
||||
await self._async_call_switch(True)
|
||||
await asyncio.sleep(pulse)
|
||||
await self._async_call_switch(False)
|
||||
|
||||
async def _async_phase_countdown(self) -> None:
|
||||
self._set_state(GATE_STATE_OPEN)
|
||||
duration = self.open_duration
|
||||
finishes = _utc_now() + timedelta(seconds=duration)
|
||||
self._finishes_at = finishes
|
||||
try:
|
||||
while True:
|
||||
remaining = (finishes - _utc_now()).total_seconds()
|
||||
self._remaining_seconds = int(max(0, math.ceil(remaining)))
|
||||
self.async_publish_update()
|
||||
if remaining <= 0:
|
||||
return
|
||||
await asyncio.sleep(min(1.0, remaining))
|
||||
finally:
|
||||
self._remaining_seconds = 0
|
||||
self.async_publish_update()
|
||||
|
||||
async def _async_phase_close(self) -> None:
|
||||
pulse = self.pulse_duration
|
||||
self._set_state(GATE_STATE_CLOSING)
|
||||
self._finishes_at = None
|
||||
await self._async_send_notification(self.notify_closing_text)
|
||||
await self._async_call_switch(True)
|
||||
await asyncio.sleep(pulse)
|
||||
await self._async_call_switch(False)
|
||||
await asyncio.sleep(pulse)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service helpers
|
||||
# ------------------------------------------------------------------
|
||||
async def _async_call_switch(self, turn_on: bool) -> None:
|
||||
service = SERVICE_TURN_ON if turn_on else SERVICE_TURN_OFF
|
||||
entity_id = self.switch_entity_id
|
||||
_LOGGER.debug(
|
||||
"Gate '%s': calling switch.%s on %s", self.entry.title, service, entity_id
|
||||
)
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise GateControlError(
|
||||
f"switch.{service} on '{entity_id}' failed: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_send_notification(self, message: str) -> None:
|
||||
"""Best-effort notification. Failures are logged and ignored."""
|
||||
if not self.notify_enabled:
|
||||
return
|
||||
targets = self.notify_entities
|
||||
if not targets:
|
||||
_LOGGER.debug(
|
||||
"Gate '%s': notifications enabled but no targets configured", self.entry.title
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Gate '%s': sending notification '%s' to %s",
|
||||
self.entry.title,
|
||||
message,
|
||||
targets,
|
||||
)
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{"message": message, "target": targets},
|
||||
blocking=True,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning(
|
||||
"Gate '%s': notification '%s' failed (gate continues anyway): %s",
|
||||
self.entry.title,
|
||||
message,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Return the current UTC time as an aware ``datetime``."""
|
||||
return dt_util.utcnow()
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
"""Render a datetime in ISO 8601 form, with ``+00:00`` for UTC."""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.isoformat()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Main Gate Controller integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_get_config_for_diagnostics
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MainGateConfigEntry
|
||||
from .coordinator import MainGateController
|
||||
|
||||
|
||||
async def async_get_diagnostics(
|
||||
hass: HomeAssistant, entry: MainGateConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a single gate.
|
||||
|
||||
No secrets are stored in this integration — there is no host, username,
|
||||
password or token — so the whole config entry and controller state can be
|
||||
reported verbatim.
|
||||
"""
|
||||
controller: MainGateController = entry.runtime_data
|
||||
return {
|
||||
"config": await async_get_config_for_diagnostics(hass, entry),
|
||||
"state": {
|
||||
"status": controller.status,
|
||||
"is_running": controller.is_running,
|
||||
**controller.extra_state_attributes,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "main_gate_controller",
|
||||
"name": "Main Gate Controller",
|
||||
"codeowners": ["@YOUR_GITHUB_USERNAME"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/YOUR_GITHUB_USERNAME/ha-main-gate-controller",
|
||||
"integration_type": "helper",
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/YOUR_GITHUB_USERNAME/ha-main-gate-controller/issues",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Sensor that reports the current state of a Main Gate Controller cycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MainGateConfigEntry, build_device_info
|
||||
from .const import (
|
||||
GATE_STATE_CLOSED,
|
||||
GATE_STATE_CLOSING,
|
||||
GATE_STATE_OPEN,
|
||||
GATE_STATE_OPENING,
|
||||
)
|
||||
from .coordinator import MainGateController
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MainGateConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the status sensor for a gate."""
|
||||
controller: MainGateController = entry.runtime_data
|
||||
async_add_entities([MainGateStatusSensor(controller, entry)])
|
||||
|
||||
|
||||
_STATUS_ICONS = {
|
||||
GATE_STATE_CLOSED: "mdi:gate",
|
||||
GATE_STATE_OPENING: "mdi:gate-arrow-right",
|
||||
GATE_STATE_OPEN: "mdi:gate-open",
|
||||
GATE_STATE_CLOSING: "mdi:gate-arrow-left",
|
||||
}
|
||||
|
||||
|
||||
class MainGateStatusSensor(SensorEntity):
|
||||
"""Sensor exposing the current gate state and countdown metadata."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "status"
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
# Stable set of states (used by tile-card pickers / dashboards)
|
||||
_attr_options: list[str] = [
|
||||
GATE_STATE_CLOSED,
|
||||
GATE_STATE_OPENING,
|
||||
GATE_STATE_OPEN,
|
||||
GATE_STATE_CLOSING,
|
||||
]
|
||||
|
||||
def __init__(self, controller: MainGateController, entry: MainGateConfigEntry) -> None:
|
||||
self._controller = controller
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_status"
|
||||
self._unsub: callable = lambda: None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return build_device_info(self._entry)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self._controller.status
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return self._controller.extra_state_attributes
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return _STATUS_ICONS.get(self._controller.status, "mdi:gate")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self._unsub = self._controller.async_add_listener(self._async_write)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
self._unsub()
|
||||
self._unsub = lambda: None
|
||||
|
||||
@callback
|
||||
def _async_write(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add a gate",
|
||||
"description": "Configure a pedestrian gate controlled via an existing Home Assistant switch entity.",
|
||||
"data": {
|
||||
"name": "Gate name",
|
||||
"switch_entity_id": "Gate switch",
|
||||
"open_duration": "Open duration (seconds)",
|
||||
"pulse_duration": "Pulse duration (seconds)",
|
||||
"notify_enabled": "Send notifications",
|
||||
"notify_entities": "Notification targets",
|
||||
"notify_opening_text": "Opening notification text",
|
||||
"notify_closing_text": "Closing notification text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Please enter a gate name.",
|
||||
"invalid_switch_domain": "Only switch entities can be selected.",
|
||||
"switch_not_found": "This switch entity does not exist.",
|
||||
"notify_no_targets": "Select at least one notification target or disable notifications."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "A gate is already configured for this switch."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Gate settings",
|
||||
"data": {
|
||||
"open_duration": "Open duration (seconds)",
|
||||
"pulse_duration": "Pulse duration (seconds)",
|
||||
"notify_enabled": "Send notifications",
|
||||
"notify_entities": "Notification targets",
|
||||
"notify_opening_text": "Opening notification text",
|
||||
"notify_closing_text": "Closing notification text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"notify_no_targets": "Select at least one notification target or disable notifications."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"open": {
|
||||
"name": "Open"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"closed": "Closed",
|
||||
"opening": "Opening\u2026",
|
||||
"open": "Open",
|
||||
"closing": "Closing\u2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Tor hinzufügen",
|
||||
"description": "Konfiguriere ein Fußgängertor, das über eine vorhandene Home-Assistant-Switch-Entity gesteuert wird.",
|
||||
"data": {
|
||||
"name": "Torname",
|
||||
"switch_entity_id": "Tor-Relais (Switch)",
|
||||
"open_duration": "Öffnungsdauer (Sekunden)",
|
||||
"pulse_duration": "Pulsdauer (Sekunden)",
|
||||
"notify_enabled": "Benachrichtigungen senden",
|
||||
"notify_entities": "Benachrichtigungsziele",
|
||||
"notify_opening_text": "Text der Öffnungs-Benachrichtigung",
|
||||
"notify_closing_text": "Text der Schließ-Benachrichtigung"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Bitte gib einen Tornamen ein.",
|
||||
"invalid_switch_domain": "Es können nur Switch-Entities ausgewählt werden.",
|
||||
"switch_not_found": "Diese Switch-Entity existiert nicht.",
|
||||
"notify_no_targets": "Wähle mindestens ein Benachrichtigungsziel aus oder deaktiviere die Benachrichtigungen."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Für dieses Relais ist bereits ein Tor konfiguriert."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Toreinstellungen",
|
||||
"data": {
|
||||
"open_duration": "Öffnungsdauer (Sekunden)",
|
||||
"pulse_duration": "Pulsdauer (Sekunden)",
|
||||
"notify_enabled": "Benachrichtigungen senden",
|
||||
"notify_entities": "Benachrichtigungsziele",
|
||||
"notify_opening_text": "Text der Öffnungs-Benachrichtigung",
|
||||
"notify_closing_text": "Text der Schließ-Benachrichtigung"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"notify_no_targets": "Wähle mindestens ein Benachrichtigungsziel aus oder deaktiviere die Benachrichtigungen."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"open": {
|
||||
"name": "Öffnen"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"closed": "Geschlossen",
|
||||
"opening": "Öffnet…",
|
||||
"open": "Offen",
|
||||
"closing": "Schließt…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add a gate",
|
||||
"description": "Configure a pedestrian gate controlled via an existing Home Assistant switch entity.",
|
||||
"data": {
|
||||
"name": "Gate name",
|
||||
"switch_entity_id": "Gate switch",
|
||||
"open_duration": "Open duration (seconds)",
|
||||
"pulse_duration": "Pulse duration (seconds)",
|
||||
"notify_enabled": "Send notifications",
|
||||
"notify_entities": "Notification targets",
|
||||
"notify_opening_text": "Opening notification text",
|
||||
"notify_closing_text": "Closing notification text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Please enter a gate name.",
|
||||
"invalid_switch_domain": "Only switch entities can be selected.",
|
||||
"switch_not_found": "This switch entity does not exist.",
|
||||
"notify_no_targets": "Select at least one notification target or disable notifications."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "A gate is already configured for this switch."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Gate settings",
|
||||
"data": {
|
||||
"open_duration": "Open duration (seconds)",
|
||||
"pulse_duration": "Pulse duration (seconds)",
|
||||
"notify_enabled": "Send notifications",
|
||||
"notify_entities": "Notification targets",
|
||||
"notify_opening_text": "Opening notification text",
|
||||
"notify_closing_text": "Closing notification text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"notify_no_targets": "Select at least one notification target or disable notifications."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"open": {
|
||||
"name": "Open"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"closed": "Closed",
|
||||
"opening": "Opening\u2026",
|
||||
"open": "Open",
|
||||
"closing": "Closing\u2026"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user