Add Home Assistant shopping API

This commit is contained in:
2026-05-03 14:43:00 +02:00
parent 4babf93179
commit 01b8128835
5 changed files with 440 additions and 2 deletions
+210
View File
@@ -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=<langes-zufälliges-token>
NOURI_HA_USER_ID=<optionale-nouri-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-app> NOURI_HA_TOKEN='ein-langes-zufälliges-token'
cloudron restart --app <nouri-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 <NOURI_HA_TOKEN>
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 <NOURI_HA_TOKEN>
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.
+4
View File
@@ -47,6 +47,8 @@ Wichtige Umgebungsvariablen:
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen - `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin` - `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_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
- `NOURI_VAPID_PRIVATE_KEY`: privater 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` - `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_SECURE_COOKIES=1`
- `NOURI_TIMEZONE=Europe/Berlin` - `NOURI_TIMEZONE=Europe/Berlin`
- `NOURI_SECRET_KEY=<eigenes-secret>` - `NOURI_SECRET_KEY=<eigenes-secret>`
- `NOURI_HA_TOKEN=<eigenes-home-assistant-token>`
- `NOURI_HA_USER_ID=<optionale-nouri-user-id>`
- `NOURI_VAPID_PUBLIC_KEY=<public-key>` - `NOURI_VAPID_PUBLIC_KEY=<public-key>`
- `NOURI_VAPID_PRIVATE_KEY=<private-key>` - `NOURI_VAPID_PRIVATE_KEY=<private-key>`
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io` - `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
+2
View File
@@ -125,6 +125,8 @@ def create_app() -> Flask:
APP_VERSION=app_version, APP_VERSION=app_version,
RELEASE_URL=release_url, RELEASE_URL=release_url,
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"), 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_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
+6 -2
View File
@@ -73,6 +73,10 @@ def url_with_scroll_position(url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) 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: def normalize_login_value(raw: str) -> str:
return raw.strip().lower() return raw.strip().lower()
@@ -136,10 +140,10 @@ def load_logged_in_user():
g.user = None g.user = None
endpoint = request.endpoint or "" 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")) 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") token = session.get("_csrf_token")
form_token = request.form.get("csrf_token") form_token = request.form.get("csrf_token")
if not token or token != form_token: if not token or token != form_token:
+218
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import functools
import secrets
from io import BytesIO from io import BytesIO
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
@@ -1009,6 +1011,143 @@ def normalize_new_item_name(value: str | None) -> str:
return " ".join((value or "").strip().split())[:120] 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( def schedule_shopping_need(
*, *,
item_id: int, 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")) 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")) @main_bp.route("/shopping", methods=("GET", "POST"))
@login_required @login_required
def shopping_list(): def shopping_list():