From 01b81288357853269f00e8e7247c29658cf8a0d5 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 3 May 2026 14:43:00 +0200 Subject: [PATCH] Add Home Assistant shopping API --- HOME_ASSISTANT.md | 210 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 + nouri/__init__.py | 2 + nouri/auth.py | 8 +- nouri/main.py | 218 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 HOME_ASSISTANT.md diff --git a/HOME_ASSISTANT.md b/HOME_ASSISTANT.md new file mode 100644 index 0000000..3f3226e --- /dev/null +++ b/HOME_ASSISTANT.md @@ -0,0 +1,210 @@ +# Home Assistant + +Nouri stellt für Home Assistant eine kleine token-geschützte JSON-API bereit. Sie ist für Sprachbefehle wie diese gedacht: + +- `Füge Erbsen auf die Einkaufsliste hinzu` +- `Füge Erbsen mit Hinweis TK auf die Einkaufsliste hinzu` +- `Füge Blumenerde als Einkaufsartikel hinzu` +- `Füge Skyr als Lebensmittel hinzu` + +## Nouri konfigurieren + +Setze in der Nouri-Umgebung: + +```bash +NOURI_HA_TOKEN= +NOURI_HA_USER_ID= +``` + +`NOURI_HA_USER_ID` ist optional. Ohne diesen Wert nutzt Nouri zuerst den ersten aktiven Admin und danach den ersten aktiven Nutzer. + +Lokal zum Testen: + +```bash +export NOURI_HA_TOKEN='ein-langes-zufälliges-token' +flask --app wsgi run --debug +``` + +Auf Cloudron: + +```bash +cloudron env set --app NOURI_HA_TOKEN='ein-langes-zufälliges-token' +cloudron restart --app +``` + +## Home Assistant vorbereiten + +Lege in Home Assistant in `secrets.yaml` diese Werte an: + +```yaml +nouri_ha_token: ein-langes-zufälliges-token +nouri_url: https://deine-nouri-domain.example.org +``` + +Wenn Home Assistant lokal auf Nouri zugreift, nimm die aus Home Assistant erreichbare Adresse, zum Beispiel: + +```yaml +nouri_url: http://192.168.178.50:5000 +``` + +`127.0.0.1` funktioniert nur, wenn Home Assistant und Nouri im selben Prozess- oder Container-Kontext laufen. + +## Eintrag hinzufügen + +```http +POST /api/home-assistant/shopping +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "name": "Erbsen", + "note": "TK" +} +``` + +Wenn Nouri den Eintrag kennt, wird er direkt auf die Einkaufsliste gesetzt. `note` landet im bestehenden Feld `Einkaufshinweis`. + +Wenn Nouri den Eintrag nicht kennt, kommt eine Rückfrage-Antwort: + +```json +{ + "ok": true, + "status": "needs_confirmation", + "name": "Erbsen", + "note": "TK", + "options": ["food", "shopping"], + "message": "Erbsen kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?" +} +``` + +## Rückfrage bestätigen + +```http +POST /api/home-assistant/shopping/confirm +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "name": "Erbsen", + "note": "TK", + "create_as": "food" +} +``` + +Mögliche Werte für `create_as`: + +- `food` für Lebensmittel +- `shopping` für reine Einkaufsartikel + +Alternativ kann der erste Endpoint mit `confirm_create: true` verwendet werden: + +```json +{ + "name": "Blumenerde", + "create_as": "shopping", + "confirm_create": true +} +``` + +## REST Commands + +Trage in `configuration.yaml` ein: + +```yaml +rest_command: + nouri_shopping: + url: "{{ url }}/api/home-assistant/shopping" + method: post + headers: + X-Nouri-Token: !secret nouri_ha_token + Content-Type: "application/json" + payload: > + { + "name": "{{ name }}", + "note": "{{ note | default('') }}", + "create_as": "{{ create_as | default('') }}", + "confirm_create": {{ confirm_create | default(false) | tojson }} + } + + nouri_shopping_confirm: + url: "{{ url }}/api/home-assistant/shopping/confirm" + method: post + headers: + X-Nouri-Token: !secret nouri_ha_token + Content-Type: "application/json" + payload: > + { + "name": "{{ name }}", + "note": "{{ note | default('') }}", + "create_as": "{{ create_as }}" + } +``` + +Danach Home Assistant neu starten oder die YAML-Konfiguration neu laden. + +## Manuell testen + +In Home Assistant: + +1. Öffne `Entwicklerwerkzeuge`. +2. Öffne `Aktionen`. +3. Wähle `rest_command.nouri_shopping`. +4. Nutze zum Testen: + +```yaml +url: !secret nouri_url +name: Erbsen +note: TK +``` + +Wenn `Erbsen` in Nouri existiert, wird es auf die Einkaufsliste gesetzt. Wenn nicht, antwortet Nouri mit `needs_confirmation`. + +## Assist-Sprachsätze + +Minimal in `configuration.yaml`: + +```yaml +conversation: + intents: + NouriShopping: + - "füge {name} auf die einkaufsliste hinzu" + - "füge {name} mit hinweis {note} auf die einkaufsliste hinzu" + - "füge {name} als lebensmittel hinzu" + - "füge {name} als einkaufsartikel hinzu" +``` + +Dazu ein Intent Script: + +```yaml +intent_script: + NouriShopping: + action: + - action: rest_command.nouri_shopping + data: + url: !secret nouri_url + name: "{{ name }}" + note: "{{ note | default('') }}" + create_as: > + {% if 'lebensmittel' in text | default('') | lower %}food{% elif 'einkaufsartikel' in text | default('') | lower %}shopping{% else %}{% endif %} + confirm_create: false + response_variable: nouri_response + - stop: "Nouri Antwort" + response_variable: nouri_response + speech: + text: > + {{ action_response.content.message }} +``` + +## Rückfrage-Logik + +Der erste Schritt ist damit fertig: bekannte Lebensmittel oder Einkaufsartikel landen direkt auf der Nouri-Einkaufsliste. + +Wenn Nouri mit `needs_confirmation` antwortet, muss Home Assistant im nächsten Schritt nachfragen: + +> Soll ich das als Lebensmittel oder als Einkaufsartikel anlegen? + +Danach ruft Home Assistant `/api/home-assistant/shopping/confirm` mit `create_as: food` oder `create_as: shopping` auf. Diese zweite Stufe kann je nach Setup über Assist, eine Automation, eine mobile Benachrichtigung oder später über Alexa umgesetzt werden. diff --git a/README.md b/README.md index 72ca801..dcbfda4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen: - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen - `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin` +- `NOURI_HA_TOKEN`: optionaler Bearer Token fuer die Home-Assistant-API +- `NOURI_HA_USER_ID`: optionaler Nutzer, unter dem Home Assistant Eintraege anlegt - `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push - `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push - `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io` @@ -102,6 +104,8 @@ Für eine saubere produktive Installation sind diese Werte sinnvoll: - `NOURI_SECURE_COOKIES=1` - `NOURI_TIMEZONE=Europe/Berlin` - `NOURI_SECRET_KEY=` +- `NOURI_HA_TOKEN=` +- `NOURI_HA_USER_ID=` - `NOURI_VAPID_PUBLIC_KEY=` - `NOURI_VAPID_PRIVATE_KEY=` - `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io` diff --git a/nouri/__init__.py b/nouri/__init__.py index a03c5ff..b7a01e2 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -125,6 +125,8 @@ def create_app() -> Flask: APP_VERSION=app_version, RELEASE_URL=release_url, TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"), + HOME_ASSISTANT_TOKEN=os.environ.get("NOURI_HA_TOKEN", "").strip(), + HOME_ASSISTANT_USER_ID=os.environ.get("NOURI_HA_USER_ID", "").strip(), VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), diff --git a/nouri/auth.py b/nouri/auth.py index c2c54c6..2dd3e98 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str: return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) +def is_home_assistant_api_endpoint(endpoint: str) -> bool: + return endpoint.startswith("main.home_assistant_") + + def normalize_login_value(raw: str) -> str: return raw.strip().lower() @@ -136,10 +140,10 @@ def load_logged_in_user(): g.user = None endpoint = request.endpoint or "" - if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}: + if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"} and not is_home_assistant_api_endpoint(endpoint): return redirect(url_for("auth.setup")) - if request.method == "POST" and endpoint != "static": + if request.method == "POST" and endpoint != "static" and not is_home_assistant_api_endpoint(endpoint): token = session.get("_csrf_token") form_token = request.form.get("csrf_token") if not token or token != form_token: diff --git a/nouri/main.py b/nouri/main.py index 999d2e0..c7e3d42 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections import defaultdict from datetime import date, datetime, timedelta +import functools +import secrets from io import BytesIO from itertools import product from pathlib import Path @@ -1009,6 +1011,143 @@ def normalize_new_item_name(value: str | None) -> str: return " ".join((value or "").strip().split())[:120] +def normalize_home_assistant_create_kind(value: str | None) -> str | None: + normalized = (value or "").strip().lower() + if normalized in {"food", "lebensmittel"}: + return "food" + if normalized in {"shopping", "article", "artikel", "einkaufsartikel"}: + return "shopping" + return None + + +def home_assistant_api_user(): + configured_user_id = current_app.config.get("HOME_ASSISTANT_USER_ID", "") + if str(configured_user_id).isdigit(): + return get_db().execute( + """ + SELECT users.*, + households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE users.is_active = 1 + AND users.id = ? + LIMIT 1 + """, + (int(configured_user_id),), + ).fetchone() + + row = get_db().execute( + """ + SELECT users.*, + households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE users.is_active = 1 + AND users.role = 'admin' + ORDER BY users.id + LIMIT 1 + """, + ).fetchone() + if row is not None: + return row + + return get_db().execute( + """ + SELECT users.*, + households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE users.is_active = 1 + ORDER BY users.id + LIMIT 1 + """ + ).fetchone() + + +def home_assistant_api_required(view): + @functools.wraps(view) + def wrapped_view(*args, **kwargs): + configured_token = current_app.config.get("HOME_ASSISTANT_TOKEN", "") + if not configured_token: + return jsonify({ + "ok": False, + "status": "not_configured", + "message": "Home Assistant ist in Nouri noch nicht konfiguriert.", + }), 503 + + auth_header = request.headers.get("Authorization", "") + bearer_token = auth_header.removeprefix("Bearer ").strip() if auth_header.startswith("Bearer ") else "" + request_token = bearer_token or request.headers.get("X-Nouri-Token", "").strip() + if not request_token or not secrets.compare_digest(request_token, configured_token): + return jsonify({ + "ok": False, + "status": "unauthorized", + "message": "Der Home-Assistant-Token passt nicht.", + }), 401 + + api_user = home_assistant_api_user() + if api_user is None: + return jsonify({ + "ok": False, + "status": "no_user", + "message": "In Nouri gibt es noch keinen aktiven Nutzer fuer Home Assistant.", + }), 503 + + g.user = api_user + return view(*args, **kwargs) + + return wrapped_view + + +def home_assistant_payload() -> dict: + payload = request.get_json(silent=True) + return payload if isinstance(payload, dict) else {} + + +def home_assistant_truthy(value) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "ja", "on"} + + +def item_api_payload(item: dict) -> dict: + return { + "id": item["id"], + "name": item["name"], + "kind": item["kind"], + "visibility": item["visibility"], + } + + +def shopping_api_message(status: str, item_name: str, note: str = "") -> str: + note_suffix = f" mit Hinweis {note}" if note else "" + if status == "added": + return f"{item_name} wurde{note_suffix} auf die Einkaufsliste gesetzt." + if status == "duplicate": + return f"{item_name} steht{note_suffix} schon auf der Einkaufsliste." + return f"{item_name} wurde verarbeitet." + + +def add_item_to_shopping_api(item: dict, note: str) -> dict: + result = ensure_item_or_missing_components_are_shopped( + item["id"], + g.user["id"], + item["visibility"], + shopping_note=note, + ) + status = "added" if result["count"] else "duplicate" + return { + "ok": True, + "status": status, + "item": item_api_payload(item), + "note": note, + "result": result, + "message": shopping_api_message(status, item["name"], note), + } + + def schedule_shopping_need( *, item_id: int, @@ -4588,6 +4727,85 @@ def item_remove_from_shopping(item_id: int): return redirect(request.referrer or url_for("main.shopping_list")) +@main_bp.post("/api/home-assistant/shopping") +@home_assistant_api_required +def home_assistant_shopping(): + payload = home_assistant_payload() + item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query")) + note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note")) + create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind")) + confirm_create = home_assistant_truthy(payload.get("confirm_create")) + + if not item_name: + return jsonify({ + "ok": False, + "status": "missing_name", + "message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.", + }), 400 + + item = find_shopping_item_by_name(item_name) + if item is not None: + return jsonify(add_item_to_shopping_api(item, note)) + + if confirm_create: + if create_as is None: + return jsonify({ + "ok": False, + "status": "missing_create_kind", + "message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.", + "name": item_name, + "note": note, + "options": ["food", "shopping"], + }), 400 + existing_before_create = find_shopping_item_by_name(item_name) + item = create_shopping_search_item(item_name, create_as) + response = add_item_to_shopping_api(item, note) + response["created"] = existing_before_create is None + return jsonify(response) + + suggested_kind = create_as or "shopping" + return jsonify({ + "ok": True, + "status": "needs_confirmation", + "name": item_name, + "note": note, + "suggested_create_as": suggested_kind, + "options": ["food", "shopping"], + "message": f"{item_name} kenne ich noch nicht. Soll ich es als Lebensmittel oder Einkaufsartikel anlegen?", + }) + + +@main_bp.post("/api/home-assistant/shopping/confirm") +@home_assistant_api_required +def home_assistant_shopping_confirm(): + payload = home_assistant_payload() + item_name = normalize_new_item_name(payload.get("name") or payload.get("item") or payload.get("query")) + note = normalize_shopping_note(payload.get("note") or payload.get("shopping_note")) + create_as = normalize_home_assistant_create_kind(payload.get("create_as") or payload.get("kind")) + + if not item_name: + return jsonify({ + "ok": False, + "status": "missing_name", + "message": "Bitte einen Namen fuer den Einkaufswunsch uebergeben.", + }), 400 + if create_as is None: + return jsonify({ + "ok": False, + "status": "missing_create_kind", + "message": "Bitte bestaetige, ob der Eintrag als Lebensmittel oder Einkaufsartikel angelegt werden soll.", + "name": item_name, + "note": note, + "options": ["food", "shopping"], + }), 400 + + existing_before_create = find_shopping_item_by_name(item_name) + item = create_shopping_search_item(item_name, create_as) + response = add_item_to_shopping_api(item, note) + response["created"] = existing_before_create is None + return jsonify(response) + + @main_bp.route("/shopping", methods=("GET", "POST")) @login_required def shopping_list():