commit 988a30cbf706602aaa6a207fad3f9bd0f8658266 Author: bot Date: Mon Sep 7 23:19:12 2026 +0200 Initial commit: Main Gate Controller HACS integration diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ad87426 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7af8e70 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..9e75fd0 --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47e114d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d2e1300 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..793a579 --- /dev/null +++ b/README.md @@ -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//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._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._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._running` erzeugt. Das `running`-Attribut am +> Statussensor liefert dieselbe Information ohne eine weitere Entity, und +> `sensor._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//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_` 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._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._open +├── sensor.py # sensor._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. diff --git a/custom_components/main_gate_controller/__init__.py b/custom_components/main_gate_controller/__init__.py new file mode 100644 index 0000000..cbd8ee6 --- /dev/null +++ b/custom_components/main_gate_controller/__init__.py @@ -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) diff --git a/custom_components/main_gate_controller/button.py b/custom_components/main_gate_controller/button.py new file mode 100644 index 0000000..dc47eca --- /dev/null +++ b/custom_components/main_gate_controller/button.py @@ -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() diff --git a/custom_components/main_gate_controller/config_flow.py b/custom_components/main_gate_controller/config_flow.py new file mode 100644 index 0000000..766fb0a --- /dev/null +++ b/custom_components/main_gate_controller/config_flow.py @@ -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), + ) diff --git a/custom_components/main_gate_controller/const.py b/custom_components/main_gate_controller/const.py new file mode 100644 index 0000000..266c8cb --- /dev/null +++ b/custom_components/main_gate_controller/const.py @@ -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" diff --git a/custom_components/main_gate_controller/coordinator.py b/custom_components/main_gate_controller/coordinator.py new file mode 100644 index 0000000..4511bb7 --- /dev/null +++ b/custom_components/main_gate_controller/coordinator.py @@ -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() diff --git a/custom_components/main_gate_controller/diagnostics.py b/custom_components/main_gate_controller/diagnostics.py new file mode 100644 index 0000000..796d127 --- /dev/null +++ b/custom_components/main_gate_controller/diagnostics.py @@ -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, + }, + } diff --git a/custom_components/main_gate_controller/manifest.json b/custom_components/main_gate_controller/manifest.json new file mode 100644 index 0000000..e307828 --- /dev/null +++ b/custom_components/main_gate_controller/manifest.json @@ -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" +} diff --git a/custom_components/main_gate_controller/sensor.py b/custom_components/main_gate_controller/sensor.py new file mode 100644 index 0000000..84d1ab9 --- /dev/null +++ b/custom_components/main_gate_controller/sensor.py @@ -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() diff --git a/custom_components/main_gate_controller/strings.json b/custom_components/main_gate_controller/strings.json new file mode 100644 index 0000000..37ed691 --- /dev/null +++ b/custom_components/main_gate_controller/strings.json @@ -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" + } + } + } + } +} diff --git a/custom_components/main_gate_controller/translations/de.json b/custom_components/main_gate_controller/translations/de.json new file mode 100644 index 0000000..e0cc29a --- /dev/null +++ b/custom_components/main_gate_controller/translations/de.json @@ -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…" + } + } + } + } +} diff --git a/custom_components/main_gate_controller/translations/en.json b/custom_components/main_gate_controller/translations/en.json new file mode 100644 index 0000000..37ed691 --- /dev/null +++ b/custom_components/main_gate_controller/translations/en.json @@ -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" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..1c40075 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Main Gate Controller", + "render_readme": true, + "homeassistant": "2024.12.0" +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8b60228 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..1d139ad --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +pytest>=8.0 +pytest-asyncio>=0.23 +pytest-homeassistant-custom-component>=0.13 +ruff>=0.6 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..02904a6 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100644 index 0000000..d25d5ed --- /dev/null +++ b/tests/test_button.py @@ -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 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..bf3c967 --- /dev/null +++ b/tests/test_config_flow.py @@ -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] diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..c7a998d --- /dev/null +++ b/tests/test_errors.py @@ -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 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..5361aa0 --- /dev/null +++ b/tests/test_init.py @@ -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") diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 0000000..bb82d63 --- /dev/null +++ b/tests/test_options_flow.py @@ -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 diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..d0a765e --- /dev/null +++ b/tests/test_sensor.py @@ -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