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
+45
View File
@@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
name: Build & publish GitHub release
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Validate tag/manifest parity
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
version="v${tag#v}"
manifest_version="$(python -c 'import json,os,sys; \
p=os.path.join("custom_components","main_gate_controller","manifest.json"); \
sys.exit(0) if False else print(json.load(open(p))["version"])')"
echo "Tag version: $version"
echo "Manifest version: $manifest_version"
if [ "$version" != "$manifest_version" ]; then
echo "ERROR: Manifest version does not match the tag."
exit 1
fi
- name: Zip integration
shell: bash
run: |
cd custom_components
zip -r ../main_gate_controller.zip main_gate_controller
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: main_gate_controller.zip
+40
View File
@@ -0,0 +1,40 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
pytest:
name: pytest + ruff
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements_test.txt
- name: Ruff
run: ruff check custom_components tests
- name: Pytest
env:
PYTHONUTF8: "1"
run: |
pytest -q
+31
View File
@@ -0,0 +1,31 @@
name: Validate
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
hacs:
name: HACS validation
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: HACS action
uses: hacs/action@main
with:
category: integration
hassfest:
name: Hassfest validation
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Hassfest
uses: home-assistant/actions/hassfest@master
+39
View File
@@ -0,0 +1,39 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.venv/
venv/
env/
.python-version
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.ruff_cache/
.mypy_cache/
# Distribution / packaging
build/
dist/
*.egg-info/
*.egg
# Home Assistant
.config/
.storage/
deps/
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 [Your Name]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+314
View File
@@ -0,0 +1,314 @@
# Main Gate Controller
Eine vollständig über die Home-Assistant-UI konfigurierbare Custom Integration für
ein elektrisches Fußgängertor. Die Integration ersetzt eine bestehende Lösung aus
Skripten, Timern und Helpern und steuert das Tor indirekt über eine bereits
vorhandene Switch-Entity (z. B. `switch.shellyplusuni_8c4f00a6ce94_output_0`).
> Vor der ersten Veröffentlichung müssen die Platzhalter in `manifest.json`,
> `hacs.json`, dieser README und in `LICENSE` durch die tatsächlichen Werte
> (GitHub-Benutzername, Klarname) ersetzt werden. Die Platzhalter tragen das
> Präfix `YOUR_GITHUB_USERNAME`.
## Features
- **Mehrere Tore** – pro Tor wird ein eigener Config Entry angelegt.
- **Konfiguration über die UI** – inklusive modernem Entity-Selector für die
Switch-Entity, NumberSelectorn für Zeiten und EntitySelector für Notify-Entities.
- **Config Flow + Options Flow** – alle Parameter lassen sich nachträglich in
`Settings → Devices & Services → Main Gate Controller → Configure` ändern.
- **Mode: single** – ein laufender Ablauf kann nicht durch einen zweiten
Button-Press unterbrochen werden.
- **Robuste State Machine** – `closed → opening → open → closing → closed` mit
Debug-Log auf jedem Übergang. Eine fehlgeschlagene Benachrichtigung führt
nicht zum Abbruch. Ein fehlgeschlagener Switch-Service bricht den Ablauf ab
und setzt den Status zurück auf `closed`.
- **Synchronisation mit dem Sensor** – `remaining_seconds` wird während der
Offen-Phase jede Sekunde aktualisiert und steht sofort im Dashboard zur
Verfügung.
- **Restart-sicher** – nach einem Home-Assistant-Neustart beginnt der Status
mit `closed`, `running=false`. Ein ggf. unterbrochener Ablauf wird
protokolliert.
- **Diagnostics** – `home-assistant://developer-tools/...` Diagnose-Export ohne
sensible Daten.
## Ablauf
Wenn ein Tor geöffnet wird, führt die Integration exakt die folgende Sequenz
aus und nutzt ausschließlich Service-Calls auf `switch.turn_on` /
`switch.turn_off`:
```
1. Status: opening
2. (optional) Notification „Opening Main Gate (Pedestrians)“
3. switch.turn_off
4. warten (pulse_duration, Standard 1 s)
5. switch.turn_on
6. warten (pulse_duration)
7. switch.turn_off
8. Status: open
9. Countdown (open_duration, Standard 20 s), remaining_seconds zählt sekündlich herunter
10. Status: closing
11. (optional) Notification „Closing Main Gate“
12. switch.turn_on
13. warten (pulse_duration)
14. switch.turn_off
15. warten (pulse_duration)
16. Status: closed
```
## Installation via HACS (Custom Repository)
1. **HACS** in der Sidebar öffnen.
2. Auf das **⋮-Menü → Custom repositories** klicken.
3. Die URL dieses GitHub-Repositories eintragen, z. B.
`https://github.com/<your-username>/ha-main-gate-controller`.
4. **Typ** auf `Integration` stehen lassen.
5. **Repository hinzufügen** klicken.
6. In der HACS-Übersicht erscheint *Main Gate Controller* und kann über
**Download** installiert werden.
7. Falls erforderlich Home Assistant neu starten.
8. **Settings → Devices & Services → Add Integration** öffnen.
9. **Main Gate Controller** auswählen.
10. Das Gate konfigurieren (Name, Switch-Entity, Zeiten, optional Notifications).
## Manuelle Installation (ohne HACS)
```
custom_components/main_gate_controller/
```
in das Home-Assistant-Konfigurationsverzeichnis kopieren, Home Assistant
neu starten und denselben UI-Pfad ab Schritt 8 verwenden.
## Einrichtung
### Config Flow
Der Config Flow ist ein einzelner Schritt. Die folgenden Felder werden
abgefragt:
| Feld | Pflicht | Default | Beschreibung |
| ----------------------------- | :----: | -------------------------------------- | ----------------------------------------------------------------------- |
| `Gate name` | ✅ | `Main Gate` | Anzeigename – wird als Device-Name und Sensor-Entity-ID-Basis verwendet |
| `Gate switch` | ✅ | — | Switch-Entity zur Torsteuerung (Selector: `EntitySelector(domain="switch")`) |
| `Open duration (seconds)` | ✅ | `20` | Wie lange das Tor offengehalten wird |
| `Pulse duration (seconds)` | ✅ | `1.0` | Ein-/Aus-Pulsdauer der Switch-Impulse |
| `Send notifications` | ✅ | `false` | Aktiviert/deaktiviert alle Benachrichtigungen |
| `Notification targets` | ⛔ | leer | Notify-Entities (Selector: `EntitySelector(domain="notify", multiple=True)`) |
| `Opening notification text` | ⛔ | `Opening Main Gate (Pedestrians)` | Text der Öffnungs-Benachrichtigung |
| `Closing notification text` | ⛔ | `Closing Main Gate` | Text der Schließ-Benachrichtigung |
Wird `Send notifications` aktiviert ohne dass Targets ausgewählt sind, meldet
der Flow einen Fehler. Außerdem wird verhindert, dass dieselbe Switch-Entity
an mehrere Config Entries gebunden wird (Unique-ID-Schutz).
### Options Flow
Über **Settings → Devices & Services → Main Gate Controller → Configure**
lassen sich `Open duration`, `Pulse duration`, `Send notifications`,
`Notification targets` und beide Notification-Texte jederzeit nachjustieren.
Der Name und die Switch-Entity sind im Options Flow bewusst nicht editierbar;
dafür ist das Löschen und Neuanlegen eines Gates der vorgesehene Weg.
### Switch- und Notify-Domain
Die Integration geht davon aus, dass die ausgewählte Switch-Entity und die
ausgewählten Notify-Entities bereits in Home Assistant existieren (z. B. Shelly,
Sonoff, MQTT, Companion-App). Es gibt **keine** direkte Geräte-Abhängigkeit.
## Entities
Für jedes Gate (jeden Config Entry) wird ein gemeinsames Device angelegt mit
`manufacturer = Custom` und `model = Main Gate Controller`. Folgende Entities
gehören zum Device:
### Button `button.<gate>_open`
Startet den kompletten Ablauf. Friendly-Name: `Open`. Ist bereits ein Ablauf
aktiv, wird der Druck ignoriert (logischer `mode: single`). Wird sauber
protokolliert, nicht als Fehler.
### Sensor `sensor.<gate>_status`
Maschinenlesbarer State, standardmäßig einer von `closed`, `opening`, `open`,
`closing`. Attribute (immer vorhanden):
| Attribut | Typ | Beschreibung |
| -------------------- | -------------- | --------------------------------------------------- |
| `remaining_seconds` | `int \| null` | Sekunden bis zum automatischen Schließen |
| `duration` | `number` | Konfigurierte `Open duration` |
| `running` | `bool` | `True`, solange ein Ablauf läuft |
| `started_at` | `string\|null` | ISO 8601 UTC – Zyklusstart |
| `finishes_at` | `string\|null` | ISO 8601 UTC – berechnetes Ende der Öffnungsphase |
Während der `open`-Phase wird `remaining_seconds` ungefähr jede Sekunde
aktualisiert.
> **Bewusste Designentscheidung:** Es wird kein zusätzlicher
> `binary_sensor.<gate>_running` erzeugt. Das `running`-Attribut am
> Statussensor liefert dieselbe Information ohne eine weitere Entity, und
> `sensor.<gate>_status.options` enthält ohnehin alle Zustände.
## Beispiel Dashboard
Eine kompakte, einzelne Tile-Card reicht aus. Die Tile-Card unterstützt
das `state_content`-Feld, das beliebige Attribute der Entity anzeigen kann.
Wir übergeben den maschinenlesbaren State **und** die verbleibenden Sekunden:
```yaml
type: tile
entity: sensor.main_gate_status
name: Main Gate
icon: mdi:gate
state_content:
- state
- remaining_seconds
```
### Wer das Ergebnis in der „sprechenden“ Variante sehen will
Da der Sensor-State `closed/opening/open/closing` maschinenlesbar bleibt,
werden die deutschen Bezeichnungen „Geschlossen/Öffnet/Offen/Schließt“ nicht
im State selbst, sondern als Übersetzung im Frontend angezeigt. **Auf einer
Tile-Card sieht man also je nach Frontend-Sprache entweder die englische
oder die lokalisierte Variante – aber derselbe Sensor.**
Wer auf den englischen Buttons-State explizit verzichten will, kann alternativ
mit dem `mdi:clock-digital`-Icon und einer Text-Karte arbeiten:
```yaml
type: conditional
conditions:
- entity: sensor.main_gate_status
state: open
card:
type: markdown
content: >-
Open · {{ state_attr('sensor.main_gate_status', 'remaining_seconds') }}s
```
## Update-Prozess
HACS markiert neue Releases automatisch. Nach einer Aktualisierung empfiehlt
sich ein Home-Assistant-Neustart, weil die Integration während eines laufenden
Ablaufs nicht in-place ausgetauscht werden kann.
> Aktuell ist keine Reload-Annotation aktiv: ein bereits laufender Zyklus geht
> beim Update verloren und der Status wird auf `closed` zurückgesetzt. Das ist
> mit einem schief gestellten Tor-Position fehlertolerant (siehe
> *Einschränkungen*).
## Entwicklung
```bash
git clone https://github.com/<your-username>/ha-main-gate-controller
cd ha-main-gate-controller
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements_test.txt
# Statische Prüfung
ruff check custom_components tests
# Tests
pytest
```
Die Tests verwenden das Standard-Testwerkzeug
[`pytest-homeassistant-custom-component`](https://github.com/jaisenbe/pytest-homeassistant-custom-component),
welches eine bestimmte Version von Home Assistant pinnt. Damit lassen sich
Config-Flow, Entity-Lebenszyklus und asynchrone Service-Calls mit dem
echten HA-Testkern testen. Die Integration wird über den
`custom_components/`-Ordner direkt neben den Tests gefunden.
## Release-Prozess
1. Änderungen hochladen und prüfen, dass alle CI-Checks grün sind
(HACS validation, Hassfest, pytest).
2. Im Repository die Versionsnummer in `manifest.json` und im Tag
synchron anpassen. Der Release-Workflow
(`.github/workflows/release.yml`) erzwingt das. Ziel: `version: 0.1.0`
im Manifest ↔ Git-Tag `v0.1.0`.
3. Tag setzen und pushen:
```bash
git tag v0.1.0
git push origin v0.1.0
```
4. GitHub Actions baut das Release-Artefakt (Zip der Integration) und
veröffentlicht es auf der GitHub-Releases-Seite.
## Troubleshooting
### „cannot add entities“ / Integration lädt nicht
- Home Assistant neu starten, damit `custom_components/main_gate_controller`
einmal frisch gelesen wird.
- Logs:
```yaml
logger:
logs:
custom_components.main_gate_controller: debug
```
### Button bleibt ohne Wirkung
- Konfiguration prüfen: Settings → Devices & Services → Main Gate Controller →
Gerät → Entity → Status sollte `closed` sein. Wenn nicht, einmal manuell
über die Switch-Entity steuern, um sicher zu sein, dass das Relais
überhaupt reagiert.
### Benachrichtigungen kommen nicht an
- `Send notifications` aktiviert? Targets ausgewählt?
- Die Entity-Selector-Suche zeigt nur `notify.*`-Entities; bei
Companion-Apps darauf achten, dass die Mobile-App bereits ein
`notify.mobile_app_<phone>` angelegt hat.
### Status bleibt nach Stromausfall auf einer Phase hängen
- Die Integration ist **blind** für die reale Tor-Position: nach einem
Neustart beginnt `sensor.<gate>_status` mit `closed`. Wenn das Tor
während des Ausfalls halb offen geblieben ist, muss es von Hand
abgeschlossen werden.
## Architektur
```
custom_components/main_gate_controller/
├── __init__.py # async_setup_entry, async_unload_entry, DeviceInfo
├── manifest.json # Domain, Version, Codeowners, iot_class=calculated
├── const.py # Konstanten / Defaults
├── coordinator.py # MainGateController ← Zustandsmaschine, Countdown, Service-Calls
├── config_flow.py # MainGateConfigFlow + MainGateOptionsFlow + Selectors
├── button.py # button.<gate>_open
├── sensor.py # sensor.<gate>_status + Attribute
├── diagnostics.py # anonymisierter Diagnose-Export
├── strings.json # Übersetzungs-Schlüssel
└── translations/
├── en.json
└── de.json
```
Alle Service-Calls (`switch.turn_on/off`, `notify.send_message`) laufen
ausschließlich gegen die ausgewählten Home-Assistant-Entities. Es findet kein
direkter Zugriff auf Hersteller-Integrationen (z. B. Shelly) statt.
## Bekannte Einschränkungen / Design-Entscheidungen
- **Kein Binary-Sensor „Running“.** Der Statussensor liefert `running`
bereits als Attribut; eine zweite Entity würde keinen Mehrwert bieten.
- **Blind-Betrieb.** Die Integration hat keine Rückmeldung über die
tatsächliche Tor-Position. Wenn das Relais angezogen hat, heißt das
nicht zwingend, dass das Tor offen ist. Wer eine echtzeitnahe Statusanzeige
benötigt, sollte die Tor-Endlage zusätzlich an einen Home-Assistant-Sensor
melden und diesen Automations-Auslöser verwenden.
- **Restart mitten im Zyklus.** Beim Reload/Unload wird der laufende Zyklus
abgebrochen und der Status auf `closed` zurückgesetzt. Die reale
Tor-Position muss im Zweifel per Hand geprüft werden.
- **Switch-Fehler → safe default.** Schlägt ein `switch.turn_*`-Aufruf
fehl, wird der Zyklus abgebrochen und der Status auf `closed` gesetzt;
ein Alarm bleibt im Log. Im Zweifel ist das Relais möglicherweise
trotzdem ein- oder ausgeschaltet worden, das lässt sich aber nicht
garantiert ermitteln.
@@ -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"
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Main Gate Controller",
"render_readme": true,
"homeassistant": "2024.12.0"
}
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "ha-main-gate-controller"
version = "0.1.0"
description = "Home Assistant custom integration for a pedestrian gate controlled via a switch entity."
requires-python = ">=3.12"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
]
ignore = [
"B008", # function call in default argument (Home Assistant helpers use this)
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B011"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
asyncio_mode = "auto"
+4
View File
@@ -0,0 +1,4 @@
pytest>=8.0
pytest-asyncio>=0.23
pytest-homeassistant-custom-component>=0.13
ruff>=0.6
+149
View File
@@ -0,0 +1,149 @@
"""Shared fixtures for Main Gate Controller tests."""
from __future__ import annotations
from collections.abc import Generator
from dataclasses import dataclass, field
from typing import Any
import pytest
from homeassistant.core import HomeAssistant, ServiceCall
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.main_gate_controller.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,
)
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> Generator[None]:
"""Enable the bundled custom integration via PHACC's loader patch."""
yield
@dataclass
class ServiceCalls:
"""Helper to inspect service call recordings in a structured way."""
records: list[tuple[str, str, dict[str, Any]]] = field(default_factory=list)
def service(self, domain: str, service: str) -> list[dict[str, Any]]:
return [data for d, s, data in self.records if d == domain and s == service]
def order(self) -> list[tuple[str, str]]:
return [(d, s) for d, s, _ in self.records]
def reset(self) -> None:
self.records.clear()
@pytest.fixture
def service_calls(hass: HomeAssistant) -> ServiceCalls:
"""Register recording handlers for `switch` and `notify` services."""
helper = ServiceCalls()
async def _record(call: ServiceCall) -> None:
helper.records.append((call.domain, call.service, dict(call.data)))
hass.services.async_register("switch", "turn_on", _record)
hass.services.async_register("switch", "turn_off", _record)
hass.services.async_register("notify", "send_message", _record)
return helper
@pytest.fixture
def gate_switch(hass: HomeAssistant) -> str:
"""Provide a real-looking switch entity in the state machine."""
entity_id = "switch.main_gate_relay"
hass.states.async_set(entity_id, "off")
return entity_id
@pytest.fixture
def notify_entity(hass: HomeAssistant) -> str:
"""Provide a notify entity in the state machine (modern Notify entity platform)."""
entity_id = "notify.mobile_app_phone"
hass.states.async_set(entity_id, "unknown")
return entity_id
def make_entry_data(
*,
switch_entity_id: str,
name: str = DEFAULT_NAME,
open_duration: float = DEFAULT_OPEN_DURATION,
pulse_duration: float = DEFAULT_PULSE_DURATION,
notify_enabled: bool = DEFAULT_NOTIFY_ENABLED,
notify_entities: list[str] | None = None,
notify_opening_text: str = DEFAULT_NOTIFY_OPENING_TEXT,
notify_closing_text: str = DEFAULT_NOTIFY_CLOSING_TEXT,
) -> dict[str, Any]:
"""Build a default config-entry data dict."""
return {
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 or [],
CONF_NOTIFY_OPENING_TEXT: notify_opening_text,
CONF_NOTIFY_CLOSING_TEXT: notify_closing_text,
}
@pytest.fixture
def config_flow_user_input(gate_switch: str, notify_entity: str) -> dict[str, Any]:
"""User input ready to be submitted to the config flow."""
return make_entry_data(
switch_entity_id=gate_switch,
name="Main Gate",
open_duration=DEFAULT_OPEN_DURATION,
pulse_duration=DEFAULT_PULSE_DURATION,
notify_enabled=False,
notify_entities=[],
)
@pytest.fixture
async def loaded_entry(
hass: HomeAssistant,
gate_switch: str,
notify_entity: str | None,
) -> MockConfigEntry:
"""Provide a fully set up config entry with two entities."""
if notify_entity is None:
notify_entities: list[str] = []
else:
notify_entities = [notify_entity]
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=gate_switch,
name="Main Gate",
open_duration=20,
pulse_duration=1.0,
notify_enabled=bool(notify_entities),
notify_entities=notify_entities,
),
unique_id=gate_switch,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
+169
View File
@@ -0,0 +1,169 @@
"""Tests for the open/countdown/close sequence driven by the button."""
from __future__ import annotations
from typing import Any
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.main_gate_controller.const import (
DOMAIN,
GATE_STATE_CLOSED,
GATE_STATE_CLOSING,
GATE_STATE_OPEN,
GATE_STATE_OPENING,
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
SWITCH_DOMAIN,
)
from tests.conftest import ServiceCalls, make_entry_data
async def _press_button(hass: HomeAssistant, entry) -> None:
registry = er.async_get(hass)
entity_id = registry.async_get_entity_id(
"button", DOMAIN, f"{entry.entry_id}_open"
)
assert entity_id is not None
await hass.services.async_call(
"button", "press", {"entity_id": entity_id}, blocking=True
)
async def test_full_open_close_sequence_with_notifications(
service_calls: ServiceCalls, loaded_entry
):
"""Notifications-enabled entry runs the canonical sequence.
The spec dictates: ``Closing`` notification fires **before** the closing
switch pulses (state-machine transition closing -> closed is logged from
the coordinator inside the switch sequence).
"""
controller = loaded_entry.runtime_data
assert controller.async_trigger() is True
await controller.async_wait_until_idle()
expected = [
(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE), # Opening notification
(SWITCH_DOMAIN, SERVICE_TURN_OFF), # OPEN: OFF
(SWITCH_DOMAIN, SERVICE_TURN_ON), # OPEN: ON
(SWITCH_DOMAIN, SERVICE_TURN_OFF), # OPEN: OFF
(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE), # Closing notification (before close pulse)
(SWITCH_DOMAIN, SERVICE_TURN_ON), # CLOSE: ON
(SWITCH_DOMAIN, SERVICE_TURN_OFF), # CLOSE: OFF
]
assert service_calls.order() == expected
notifies = service_calls.service(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE)
assert notifies[0]["message"].startswith("Opening Main Gate")
assert notifies[1]["message"] == "Closing Main Gate"
targets = notifies[0]["target"]
if isinstance(targets, str):
targets = [targets]
assert targets == ["notify.mobile_app_phone"]
assert controller.status == GATE_STATE_CLOSED
assert controller.is_running is False
async def test_sequence_without_notifications(
service_calls: ServiceCalls, hass: HomeAssistant, gate_switch: str
):
"""Notifications disabled → only switch calls happen."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=gate_switch, name="Main Gate", notify_enabled=False
),
unique_id=gate_switch,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
assert controller.async_trigger() is True
await controller.async_wait_until_idle()
expected = [
(SWITCH_DOMAIN, SERVICE_TURN_OFF),
(SWITCH_DOMAIN, SERVICE_TURN_ON),
(SWITCH_DOMAIN, SERVICE_TURN_OFF),
(SWITCH_DOMAIN, SERVICE_TURN_ON),
(SWITCH_DOMAIN, SERVICE_TURN_OFF),
]
assert service_calls.order() == expected
assert controller.status == GATE_STATE_CLOSED
async def test_mode_single_ignores_second_trigger(
service_calls: ServiceCalls, loaded_entry
):
"""A second trigger during a running cycle does nothing (mode: single)."""
controller = loaded_entry.runtime_data
first = controller.async_trigger()
second = controller.async_trigger()
assert first is True
assert second is False
await controller.async_wait_until_idle()
switch_calls = sum(
1 for d, s, _ in service_calls.records if d == SWITCH_DOMAIN
)
assert switch_calls == 5
async def test_button_press_starts_cycle(
service_calls: ServiceCalls, hass: HomeAssistant, loaded_entry
):
"""Pressing the button entity starts the cycle via the service call."""
controller = loaded_entry.runtime_data
await _press_button(hass, loaded_entry)
await controller.async_wait_until_idle()
switch_calls = sum(
1 for d, s, _ in service_calls.records if d == SWITCH_DOMAIN
)
# 5 switch calls (no notify because loaded_entry is without notify targets).
assert switch_calls == 5
async def test_state_transitions_during_cycle(
hass: HomeAssistant, service_calls: ServiceCalls, loaded_entry
):
"""The status sensor goes opening → open → closing → closed."""
# Subscribe to state changes of the status sensor.
observed: list[str] = []
def _capture(event):
if event.data.get("entity_id", "").endswith("_status"):
new_state: dict[str, Any] = event.data.get("new_state")
if new_state is not None and new_state.state in {
GATE_STATE_CLOSED,
GATE_STATE_OPENING,
GATE_STATE_OPEN,
GATE_STATE_CLOSING,
}:
if not observed or observed[-1] != new_state.state:
observed.append(new_state.state)
hass.bus.async_listen("state_changed", _capture)
controller = loaded_entry.runtime_data
assert controller.async_trigger() is True
await controller.async_wait_until_idle()
# Let pending state_changed events propagate through the bus.
await hass.async_block_till_done()
# Opening and closing are short with pulse=1s; ``open`` is the dominant state.
seen = set(observed)
assert GATE_STATE_OPENING in seen
assert GATE_STATE_OPEN in seen
assert GATE_STATE_CLOSING in seen
assert GATE_STATE_CLOSED in seen
+131
View File
@@ -0,0 +1,131 @@
"""Tests for the user-initiated config flow."""
from __future__ import annotations
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.main_gate_controller.const import (
CONF_NAME,
CONF_NOTIFY_ENABLED,
CONF_NOTIFY_ENTITIES,
CONF_SWITCH_ENTITY_ID,
DOMAIN,
)
from tests.conftest import make_entry_data
async def _start_user_flow(hass: HomeAssistant) -> str:
"""Initiate the user config flow and return its flow_id."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
return result["flow_id"]
async def test_user_flow_creates_entry(
hass: HomeAssistant, gate_switch: str, notify_entity: str
):
"""Valid input creates a config entry."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(
switch_entity_id=gate_switch,
name="Main Gate",
open_duration=20,
pulse_duration=1.0,
notify_enabled=False,
notify_entities=[],
)
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Main Gate"
assert result["data"][CONF_SWITCH_ENTITY_ID] == gate_switch
assert result["data"][CONF_NAME] == "Main Gate"
async def test_duplicate_switch_aborts(hass: HomeAssistant, gate_switch: str):
"""Setting up the same switch twice aborts with ``already_configured``."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(switch_entity_id=gate_switch, name="Gate A")
first = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert first["type"] == FlowResultType.CREATE_ENTRY
# Start a second flow with the same switch.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
flow_id = result["flow_id"]
second = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert second["type"] == FlowResultType.ABORT
assert second["reason"] == "already_configured"
async def test_missing_notify_targets_shows_error(
hass: HomeAssistant, gate_switch: str, notify_entity: str
):
"""Enabling notifications without targets reports a validation error."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(
switch_entity_id=gate_switch,
name="Main Gate",
notify_enabled=True,
notify_entities=[],
)
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {CONF_NOTIFY_ENTITIES: "notify_no_targets"}
async def test_unknown_switch_entity_shows_error(
hass: HomeAssistant, gate_switch: str
):
"""A non-existent switch entity surfaces a ``switch_not_found`` error."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(switch_entity_id="switch.ghost")
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
# The error key is the field name; the exact key depends on whether the
# entity selector also rejects the value. Either path must surface an error.
assert any(k == CONF_SWITCH_ENTITY_ID for k in result["errors"])
async def test_blank_name_shows_error(hass: HomeAssistant, gate_switch: str):
"""An empty/whitespace-only name is rejected."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(switch_entity_id=gate_switch, name=" ")
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {CONF_NAME: "invalid_name"}
async def test_notify_with_targets_succeeds(
hass: HomeAssistant, gate_switch: str, notify_entity: str
):
"""Enabling notifications with at least one target is accepted."""
flow_id = await _start_user_flow(hass)
user_input = make_entry_data(
switch_entity_id=gate_switch,
name="Main Gate",
notify_enabled=True,
notify_entities=[notify_entity],
)
result = await hass.config_entries.flow.async_configure(flow_id, user_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_NOTIFY_ENABLED] is True
assert result["data"][CONF_NOTIFY_ENTITIES] == [notify_entity]
+188
View File
@@ -0,0 +1,188 @@
"""Tests covering error paths in the controller."""
from __future__ import annotations
import logging
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.main_gate_controller.const import (
DOMAIN,
GATE_STATE_CLOSED,
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
)
from tests.conftest import ServiceCalls, make_entry_data
def _make_failing_handler(fail_on_domain: str, fail_on_service: str, message: str):
async def _handler(call) -> None: # type: ignore[no-untyped-def]
if call.domain == fail_on_domain and call.service == fail_on_service:
raise HomeAssistantError(message)
# Recording handled elsewhere – this helper is only for failure simulation.
return _handler
async def test_notification_failure_continues_cycle(
hass: HomeAssistant, service_calls: ServiceCalls
):
"""If a notify.send_message call fails, the cycle still finishes normally."""
switch = "switch.main_gate_relay"
hass.states.async_set(switch, "off")
hass.states.async_set("notify.mobile_app_phone", "unknown")
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=switch,
name="Main Gate",
open_duration=1,
pulse_duration=0.1,
notify_enabled=True,
notify_entities=["notify.mobile_app_phone"],
),
unique_id=switch,
)
entry.add_to_hass(hass)
# Replace the recording notify handler with one that fails.
hass.services.async_remove(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE)
hass.services.async_register(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
_make_failing_handler(
NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, "deliberate failure"
),
)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
assert controller.async_trigger() is True
await controller.async_wait_until_idle()
assert controller.status == GATE_STATE_CLOSED
assert controller.is_running is False
async def test_notification_log_warning_emitted(
hass: HomeAssistant, service_calls: ServiceCalls, caplog
):
"""The notify failure path emits a warning via the module logger."""
switch = "switch.main_gate_relay"
hass.states.async_set(switch, "off")
hass.states.async_set("notify.mobile_app_phone", "unknown")
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=switch,
name="Main Gate",
open_duration=0.5,
pulse_duration=0.1,
notify_enabled=True,
notify_entities=["notify.mobile_app_phone"],
),
unique_id=switch,
)
entry.add_to_hass(hass)
hass.services.async_remove(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE)
async def _failing_notify(call) -> None:
raise HomeAssistantError("boom")
hass.services.async_register(NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, _failing_notify)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
with caplog.at_level(logging.WARNING):
controller.async_trigger()
await controller.async_wait_until_idle()
assert any(
"notification" in r.message.lower() and "boom" in r.message
for r in caplog.records
)
async def test_switch_failure_aborts_cycle(
hass: HomeAssistant, service_calls: ServiceCalls, caplog
):
"""A switch.turn_on failure aborts the cycle and resets state to closed."""
switch = "switch.main_gate_relay"
hass.states.async_set(switch, "off")
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=switch,
name="Main Gate",
open_duration=20,
pulse_duration=0.1,
notify_enabled=False,
notify_entities=[],
),
unique_id=switch,
)
entry.add_to_hass(hass)
# Replace the recording handler for switch.turn_off with a failing one.
hass.services.async_remove("switch", "turn_off")
async def _failing_turn_off(call) -> None:
raise HomeAssistantError("relay offline")
hass.services.async_register("switch", "turn_off", _failing_turn_off)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
with caplog.at_level(logging.ERROR):
controller.async_trigger()
await controller.async_wait_until_idle()
assert controller.status == GATE_STATE_CLOSED
assert controller.is_running is False
assert any(
"aborted" in r.message.lower() or "cycle aborted" in r.message.lower()
for r in caplog.records
)
async def test_unload_cancels_running_cycle(
hass: HomeAssistant, service_calls: ServiceCalls
):
"""Unloading an entry while a cycle runs cancels the background task."""
switch = "switch.main_gate_relay"
hass.states.async_set(switch, "off")
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=switch,
name="Main Gate",
open_duration=3,
pulse_duration=0.2,
notify_enabled=False,
notify_entities=[],
),
unique_id=switch,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
controller.async_trigger()
# Immediately start unloading.
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert controller.is_running is False
assert controller.status == GATE_STATE_CLOSED
+88
View File
@@ -0,0 +1,88 @@
"""Tests for entity creation and device info."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from custom_components.main_gate_controller.const import (
DOMAIN,
GATE_STATE_CLOSED,
MANUFACTURER,
MODEL,
)
async def test_setup_creates_entities_and_device(
hass: HomeAssistant, loaded_entry
):
"""A loaded entry creates one button, one sensor and one device."""
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
button_unique_id = f"{loaded_entry.entry_id}_open"
sensor_unique_id = f"{loaded_entry.entry_id}_status"
button_entity_id = entity_registry.async_get_entity_id(
Platform.BUTTON, DOMAIN, button_unique_id
)
sensor_entity_id = entity_registry.async_get_entity_id(
Platform.SENSOR, DOMAIN, sensor_unique_id
)
assert button_entity_id is not None
assert sensor_entity_id is not None
button_state = hass.states.get(button_entity_id)
sensor_state = hass.states.get(sensor_entity_id)
assert button_state is not None
assert sensor_state is not None
assert sensor_state.state == GATE_STATE_CLOSED
attrs = sensor_state.attributes
assert attrs["remaining_seconds"] is None
assert attrs["running"] is False
assert attrs["duration"] == 20.0
device = device_registry.async_get_device(identifiers={(DOMAIN, loaded_entry.entry_id)})
assert device is not None
assert device.name == "Main Gate"
assert device.manufacturer == MANUFACTURER
assert device.model == MODEL
async def test_setup_states_remain_consistent_after_restart(hass: HomeAssistant):
"""After a (logical) restart the state is closed and not running."""
from pytest_homeassistant_custom_component.common import MockConfigEntry
from tests.conftest import make_entry_data
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(switch_entity_id="switch.main_gate_relay", name="Main Gate"),
unique_id="switch.main_gate_relay",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
sensor_state = hass.states.get(f"sensor.{entry.title.lower().replace(' ', '_')}_status")
assert sensor_state is None or sensor_state.state == GATE_STATE_CLOSED
async def test_unload_entry(hass: HomeAssistant, loaded_entry):
"""Unloading removes entities and clears the runtime data attribute."""
# Capture the runtime data reference before unload – HA deletes the
# attribute on unload by design (see homeassistant.config_entries).
controller_before = loaded_entry.runtime_data
assert controller_before is not None
assert await hass.config_entries.async_unload(loaded_entry.entry_id)
await hass.async_block_till_done()
# The cycle was cancelled; the controller is reset to a safe closed state.
assert controller_before.is_running is False
assert controller_before.status == GATE_STATE_CLOSED
assert not hasattr(loaded_entry, "runtime_data")
+61
View File
@@ -0,0 +1,61 @@
"""Tests for the options flow and live settings updates."""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from custom_components.main_gate_controller.const import (
ATTR_DURATION,
CONF_OPEN_DURATION,
CONF_PULSE_DURATION,
DOMAIN,
)
async def test_options_flow_updates_settings(
hass: HomeAssistant, loaded_entry
):
"""The options flow updates the open duration and the sensor reflects it."""
# Trigger an initial state refresh so the entity is registered.
loaded_entry.runtime_data.async_publish_update()
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"
flow_id = result["flow_id"]
new_input = {
CONF_OPEN_DURATION: 5,
CONF_PULSE_DURATION: 0.5,
"notify_enabled": False,
"notify_entities": [],
"notify_opening_text": "Opening",
"notify_closing_text": "Closing",
}
result = await hass.config_entries.options.async_configure(flow_id, new_input)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert loaded_entry.options[CONF_OPEN_DURATION] == 5
assert loaded_entry.options[CONF_PULSE_DURATION] == 0.5
# Allow the update listener to push a refresh.
await asyncio.sleep(0)
await hass.async_block_till_done()
controller = loaded_entry.runtime_data
assert controller.open_duration == 5
assert controller.pulse_duration == 0.5
# The sensor attribute reflects the new duration as well.
from homeassistant.helpers import entity_registry as er
registry = er.async_get(hass)
sensor_id = registry.async_get_entity_id(
"sensor", DOMAIN, f"{loaded_entry.entry_id}_status"
)
assert sensor_id is not None
state = hass.states.get(sensor_id)
assert state.attributes[ATTR_DURATION] == 5
+76
View File
@@ -0,0 +1,76 @@
"""Tests for the countdown phase of the cycle."""
from __future__ import annotations
import asyncio
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.main_gate_controller.const import (
ATTR_DURATION,
ATTR_REMAINING_SECONDS,
ATTR_RUNNING,
DOMAIN,
GATE_STATE_OPEN,
)
from tests.conftest import make_entry_data
async def test_countdown_attributes_decrease(hass: HomeAssistant, service_calls):
"""remaining_seconds decreases over the open phase and finishes at zero."""
switch = "switch.fast_gate_relay"
hass.states.async_set(switch, "off")
entry = MockConfigEntry(
domain=DOMAIN,
title="Main Gate",
data=make_entry_data(
switch_entity_id=switch,
name="Main Gate",
open_duration=2,
pulse_duration=0.1,
notify_enabled=False,
notify_entities=[],
),
unique_id=switch,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
controller = entry.runtime_data
# Manually step into the open phase and capture attribute changes.
assert controller.async_trigger() is True
# Wait until the cycle is in the ``open`` state.
while controller.status != GATE_STATE_OPEN:
await asyncio.sleep(0.05)
snapshot1 = controller.extra_state_attributes.copy()
assert snapshot1[ATTR_DURATION] == 2
assert snapshot1[ATTR_RUNNING] is True
first_remaining = snapshot1[ATTR_REMAINING_SECONDS]
assert first_remaining is not None and first_remaining >= 1
# Let the countdown progress for ~1 second before re-checking.
await asyncio.sleep(1.0)
snapshot2 = controller.extra_state_attributes.copy()
assert snapshot2[ATTR_REMAINING_SECONDS] < first_remaining
assert snapshot2[ATTR_RUNNING] is True
# Wait for completion.
await controller.async_wait_until_idle()
after = controller.extra_state_attributes
assert after[ATTR_RUNNING] is False
assert after[ATTR_REMAINING_SECONDS] is None
async def test_duration_reflects_configured_value(
hass: HomeAssistant, loaded_entry
):
"""The ``duration`` attribute follows the configuration value."""
controller = loaded_entry.runtime_data
attrs = controller.extra_state_attributes
assert attrs[ATTR_DURATION] == 20