Initial commit: Main Gate Controller HACS integration
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Main Gate Controller",
|
||||
"render_readme": true,
|
||||
"homeassistant": "2024.12.0"
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
pytest-homeassistant-custom-component>=0.13
|
||||
ruff>=0.6
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user