From 1cb2630cecc9b9bbba0226605deb26d53b25995a Mon Sep 17 00:00:00 2001 From: Otto Date: Wed, 10 Jun 2026 21:59:38 +0200 Subject: [PATCH] integrate heating rule fix and quality workflow --- .env.example | 3 +++ .gitea/workflows/quality.yml | 31 ++++++++++++++++++++++ .gitignore | 1 + README.md | 50 ++++++++++++++++++++++++++++++++++++ app/rules/heating.py | 22 +++++++++++++--- tests/rules/test_heating.py | 30 +++++++++++++++++++--- 6 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/quality.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca83d23 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env for local development. Do not commit real tokens. +SILLYHOME_HA_URL=http://homeassistant.local:8123 +SILLYHOME_HA_TOKEN=replace-with-a-long-lived-access-token diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml new file mode 100644 index 0000000..6ac3961 --- /dev/null +++ b/.gitea/workflows/quality.yml @@ -0,0 +1,31 @@ +name: Quality + +on: + push: + branches: + - "**" + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install project + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + + - name: Run tests + run: pytest -q + + - name: Run Ruff + run: ruff check . + + - name: Run Mypy + run: mypy app tests diff --git a/.gitignore b/.gitignore index fcf17b6..6892607 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ .env .env.local .env.* +!.env.example diff --git a/README.md b/README.md index e983512..e4ac589 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,53 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens - Automationen vorschlagen und direkt generieren - Lokal-first ohne Cloudpflicht - Erweiterbar, testbar, dokumentiert + +## Lokaler Quickstart + +Voraussetzung ist Python 3.11 oder neuer. + +```bash +python -m venv .venv +. .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +cp .env.example .env +``` + +In `.env` müssen für echte Home-Assistant-Daten diese Werte gesetzt werden: + +```bash +SILLYHOME_HA_URL=http://homeassistant.local:8123 +SILLYHOME_HA_TOKEN= +``` + +Alternativ werden aus Kompatibilitätsgründen auch `HA_URL` und `HA_TOKEN` gelesen. +Tokens bleiben lokal und dürfen nicht committed, geloggt oder in Issues kopiert werden. + +API starten: + +```bash +uvicorn app.main:app --reload +``` + +Nützliche Checks: + +```bash +curl http://127.0.0.1:8000/health +curl http://127.0.0.1:8000/v1/entities +``` + +Die interaktive API-Dokumentation liegt unter `http://127.0.0.1:8000/docs`. + +## Qualität + +Vor jedem Pull Request lokal laufen lassen: + +```bash +pytest -q +ruff check . +mypy app tests +``` + +Der Gitea-Actions-Workflow in `.gitea/workflows/quality.yml` führt dieselben Checks für +Pushes und Pull Requests aus. diff --git a/app/rules/heating.py b/app/rules/heating.py index 994711a..82e3cf6 100644 --- a/app/rules/heating.py +++ b/app/rules/heating.py @@ -6,10 +6,26 @@ from app.ha.models import HaEntitySummary from app.rules.recommender import Rule +HEATING_SENSOR_DEVICE_CLASSES = frozenset({"temperature", "humidity"}) +HEATING_BINARY_SENSOR_DEVICE_CLASSES = frozenset({"occupancy", "presence"}) + + class HeatingRule(Rule): def matches(self, entities: Sequence[HaEntitySummary]) -> bool: - domains = {item.domain for item in entities} - return "climate" in domains or "sensor" in domains + for entity in entities: + if entity.domain == "climate": + return True + if ( + entity.domain == "sensor" + and entity.device_class in HEATING_SENSOR_DEVICE_CLASSES + ): + return True + if ( + entity.domain == "binary_sensor" + and entity.device_class in HEATING_BINARY_SENSOR_DEVICE_CLASSES + ): + return True + return False def recommendation(self, entities: Sequence[HaEntitySummary]) -> str: - return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit." \ No newline at end of file + return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit." diff --git a/tests/rules/test_heating.py b/tests/rules/test_heating.py index da4d753..198c065 100644 --- a/tests/rules/test_heating.py +++ b/tests/rules/test_heating.py @@ -5,8 +5,16 @@ from app.rules.heating import HeatingRule from app.rules.recommender import Recommender -def _sensor(entity_id: str) -> HaEntitySummary: - return HaEntitySummary(entity_id=entity_id, domain="sensor") +def _sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary: + return HaEntitySummary(entity_id=entity_id, domain="sensor", device_class=device_class) + + +def _binary_sensor(entity_id: str, device_class: str | None = None) -> HaEntitySummary: + return HaEntitySummary( + entity_id=entity_id, + domain="binary_sensor", + device_class=device_class, + ) def _climate(entity_id: str) -> HaEntitySummary: @@ -16,11 +24,25 @@ def _climate(entity_id: str) -> HaEntitySummary: def test_heating_rule_triggers() -> None: rule = HeatingRule() assert rule.matches([_climate("climate.living_room")]) - assert rule.matches([_sensor("sensor.temperature_living")]) + assert rule.matches([_sensor("sensor.temperature_living", device_class="temperature")]) + assert rule.matches([_sensor("sensor.humidity_bath", device_class="humidity")]) + assert rule.matches([_binary_sensor("binary_sensor.occupancy_living", "occupancy")]) + assert rule.matches([_binary_sensor("binary_sensor.presence_entry", "presence")]) + + +def test_heating_rule_ignores_non_relevant_sensors() -> None: + rule = HeatingRule() + assert not rule.matches([_sensor("sensor.temperature_living")]) + assert not rule.matches([_sensor("sensor.power", device_class="power")]) + assert not rule.matches([_sensor("sensor.voltage", device_class="voltage")]) + assert not rule.matches([_sensor("sensor.door", device_class="door")]) + assert not rule.matches([_sensor("sensor.window", device_class="window")]) + assert not rule.matches([_sensor("sensor.light", device_class="illuminance")]) + assert not rule.matches([_binary_sensor("binary_sensor.window", device_class="window")]) def test_recommender_uses_rule() -> None: recommender = Recommender(rules=[HeatingRule()]) assert recommender.run([_climate("climate.living_room")]) == [ "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit." - ] \ No newline at end of file + ]