Initial commit: Main Gate Controller HACS integration
Tests / pytest + ruff (3.12) (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
Validate / Hassfest validation (push) Has been cancelled

This commit is contained in:
bot
2026-09-07 23:19:12 +02:00
commit 988a30cbf7
27 changed files with 2456 additions and 0 deletions
@@ -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"
}
}
}
}
}