Compare commits
4
Commits
6cf75644d8
...
44982a9b3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44982a9b3c | ||
|
|
ddb1bfac6a | ||
|
|
4605698b02 | ||
|
|
1c11cb6a2f |
+202
-6
@@ -16,6 +16,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@@ -32,6 +33,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
@@ -370,6 +372,37 @@ def clean_text_for_tts(text: str) -> str:
|
||||
return t.strip()
|
||||
|
||||
|
||||
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
|
||||
|
||||
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
|
||||
# gerundeter Bearing / 45 (mod 8).
|
||||
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
|
||||
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
|
||||
|
||||
|
||||
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Distanz in Metern zwischen zwei GPS-Punkten."""
|
||||
r = 6371000.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
|
||||
|
||||
|
||||
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
y = math.sin(dl) * math.cos(p2)
|
||||
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
||||
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||
|
||||
|
||||
def _heading_word(bearing: float) -> str:
|
||||
return _COMPASS_ADV[round(bearing / 45.0) % 8]
|
||||
|
||||
|
||||
# ── STT Engine ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1175,8 +1208,28 @@ class ARIABridge:
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
})
|
||||
lat, lon = float(lat), float(lon)
|
||||
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
|
||||
self._last_location = {"lat": lat, "lon": lon}
|
||||
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
|
||||
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
|
||||
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
|
||||
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
|
||||
MIN_MOVE_M = 25.0
|
||||
anchor = getattr(self, "_heading_anchor", None)
|
||||
if anchor:
|
||||
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
|
||||
if moved >= MIN_MOVE_M:
|
||||
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
|
||||
self._last_heading = _heading_word(_bg)
|
||||
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
|
||||
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||
else:
|
||||
self._heading_anchor = {"lat": lat, "lon": lon}
|
||||
except Exception:
|
||||
return
|
||||
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
|
||||
self._maybe_reverse_geocode(float(lat), float(lon))
|
||||
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
|
||||
# oder langsam ist, blockt das nicht den GPS-Pfad.
|
||||
try:
|
||||
@@ -1184,6 +1237,130 @@ class ARIABridge:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
|
||||
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
|
||||
(~1 km Raster) und im Hintergrund. Ergebnis landet in
|
||||
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
|
||||
try:
|
||||
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
|
||||
except Exception:
|
||||
return
|
||||
if key == getattr(self, "_geocoded_key", None):
|
||||
return # diese Gegend schon aufgeloest
|
||||
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
|
||||
# Stadt bis der neue Geocode da ist.
|
||||
self._geocoded_key = key
|
||||
self._last_place_name = ""
|
||||
try:
|
||||
asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
|
||||
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
|
||||
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
|
||||
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
|
||||
(Overpass-Milestone). Setzt self._last_place_name. Fehler → still
|
||||
(Praefix faellt auf reine Koordinaten zurueck)."""
|
||||
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
|
||||
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
|
||||
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
|
||||
|
||||
def _fetch():
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={
|
||||
# Nominatim verlangt einen aussagekraeftigen User-Agent.
|
||||
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=8) as r:
|
||||
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
addr = data.get("address") or {}
|
||||
extra = data.get("extratags") or {}
|
||||
road = addr.get("road") or ""
|
||||
house = addr.get("house_number") or ""
|
||||
city = (addr.get("city") or addr.get("town") or addr.get("village")
|
||||
or addr.get("municipality") or addr.get("county") or "")
|
||||
plz = addr.get("postcode") or ""
|
||||
state = addr.get("state") or ""
|
||||
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
|
||||
|
||||
# Strasse + Hausnummer
|
||||
street = (f"{road} {house}".strip()) if road else ""
|
||||
# PLZ + Ort
|
||||
citypart = (f"{plz} {city}".strip()) if plz else city
|
||||
|
||||
segs: list[str] = []
|
||||
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
|
||||
is_road = bool(ref) or any(w in road.lower()
|
||||
for w in ("autobahn", "bundesstrasse", "bundesstraße",
|
||||
"kreisstrasse", "kreisstraße"))
|
||||
if is_road:
|
||||
label = ref or road
|
||||
km = await self._overpass_km(lat, lon)
|
||||
if km:
|
||||
label = f"{label} bei km {km}"
|
||||
if label:
|
||||
segs.append(label)
|
||||
if street:
|
||||
segs.append(street)
|
||||
if citypart:
|
||||
segs.append(citypart)
|
||||
if state and (state not in citypart):
|
||||
segs.append(state)
|
||||
|
||||
name = ", ".join(s for s in segs if s)
|
||||
if not name:
|
||||
name = data.get("name") or ""
|
||||
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
|
||||
if name and getattr(self, "_geocoded_key", None) == key:
|
||||
self._last_place_name = name
|
||||
logger.info("[geo] %.5f,%.5f → %s", lat, lon, name)
|
||||
|
||||
async def _overpass_km(self, lat: float, lon: float) -> str:
|
||||
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
|
||||
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
|
||||
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
|
||||
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
|
||||
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
|
||||
query = (f"[out:json][timeout:8];"
|
||||
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
|
||||
|
||||
def _fetch():
|
||||
try:
|
||||
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
|
||||
req = urllib.request.Request(ov, data=body, headers={
|
||||
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=12) as r:
|
||||
return json.loads(r.read().decode("utf-8", "ignore"))
|
||||
except Exception as exc:
|
||||
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
|
||||
return None
|
||||
|
||||
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
|
||||
if not isinstance(d, dict):
|
||||
return ""
|
||||
best_dist = ""
|
||||
best_sq = 1e18
|
||||
for e in (d.get("elements") or []):
|
||||
tags = e.get("tags") or {}
|
||||
dist = tags.get("distance") or tags.get("milestone:distance") or ""
|
||||
elat, elon = e.get("lat"), e.get("lon")
|
||||
if not dist or elat is None or elon is None:
|
||||
continue
|
||||
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
|
||||
if sq < best_sq:
|
||||
best_sq = sq
|
||||
best_dist = str(dist)
|
||||
return best_dist.replace(".", ",") if best_dist else ""
|
||||
|
||||
async def _trigger_brain_check_now(self) -> None:
|
||||
"""Brain-Endpoint POST /triggers/check-now anstossen."""
|
||||
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
|
||||
@@ -1557,14 +1734,33 @@ class ARIABridge:
|
||||
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
|
||||
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
|
||||
)
|
||||
if location and isinstance(location, dict):
|
||||
lat = location.get("lat")
|
||||
lon = location.get("lon") or location.get("lng")
|
||||
# Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
|
||||
# (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
|
||||
loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
|
||||
else getattr(self, "_last_location", None)
|
||||
if isinstance(loc, dict):
|
||||
lat = loc.get("lat")
|
||||
lon = loc.get("lon") or loc.get("lng")
|
||||
if lat is not None and lon is not None:
|
||||
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
|
||||
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
|
||||
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
|
||||
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
|
||||
place = getattr(self, "_last_place_name", "")
|
||||
where = f"{place} " if place else ""
|
||||
heading = getattr(self, "_last_heading", "")
|
||||
bearing = getattr(self, "_last_bearing", None)
|
||||
if heading and bearing is not None:
|
||||
moving = f", unterwegs {heading} ({bearing} Grad)"
|
||||
elif heading:
|
||||
moving = f", unterwegs {heading}"
|
||||
else:
|
||||
moving = ""
|
||||
parts.append(
|
||||
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
|
||||
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
|
||||
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
|
||||
f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
|
||||
f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
|
||||
f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
|
||||
f"nicht von dir aus, ausser er fragt explizit danach.]"
|
||||
)
|
||||
if parts:
|
||||
return " ".join(parts) + " " + text
|
||||
|
||||
Reference in New Issue
Block a user