Files
Alfredv2/alfred/app/services/dona_navigation.py.bak
T
2026-10-30 22:10:03 +01:00

287 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dona V8.3: keyless, user-triggered navigation + optional HA phone hand-off.
Geocoding with Nominatim (low volume; cache + <=1 request/s); OSRM demo for
personal experimentation, switch to a private OSRM for reliable routes.
No geolocation is inferred. No credentials are stored in this module.
"""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
import re
import threading
import time
from urllib.parse import urlencode, urlsplit
import requests
from flask import Blueprint, jsonify, request
MAX_DEST=180
_AGENT='DonaNavigation/8.3 (private household assistant; contact: '+os.getenv('DONA_NAV_CONTACT','local-admin')+')'
_NOMINATIM='https://nominatim.openstreetmap.org'
_OSRM_DEMO='https://router.project-osrm.org'
_LOCK=threading.RLock()
_NEXT_REQUEST={'geo':0.0,'route':0.0}
_CACHE={}
_CACHE_READY=False
_CACHE_LIMIT=400
_CACHE_TTL=30*86400
class NavigationError(RuntimeError):
pass
def _config_url(name, default):
val=os.getenv(name,default).strip().rstrip('/')
u=urlsplit(val)
if not (u.scheme in ('http','https') and u.hostname and not u.username and not u.password and not u.fragment and not u.query):
raise NavigationError(f'Adresse {name} invalide.')
return val
def _cache_file():
return Path(os.getenv('DONA_NAV_CACHE_FILE','/opt/alfred/app/cache/dona_navigation.json'))
def _get_cache():
global _CACHE_READY
if not _CACHE_READY:
try:
raw=json.loads(_cache_file().read_text(encoding='utf-8'))
if isinstance(raw,dict):_CACHE.update({str(k):v for k,v in raw.items() if isinstance(v,dict)})
except (OSError,ValueError): pass
_CACHE_READY=True
return _CACHE
def _save_cache():
try:
path=_cache_file();path.parent.mkdir(parents=True,exist_ok=True)
entries=sorted(_CACHE.items(),key=lambda x:x[1].get('at',0),reverse=True)[:_CACHE_LIMIT]
tmp=path.with_name(path.name+'.tmp')
tmp.write_text(json.dumps(dict(entries),ensure_ascii=False,separators=(',',':')),encoding='utf-8')
tmp.replace(path)
except OSError:
pass # caching is optional; never block navigation
def _cached(key):
with _LOCK:
rec=_get_cache().get(key)
if rec and time.time()-float(rec.get('at',0))<_CACHE_TTL:
return rec.get('data')
return None
def _remember(key,data):
with _LOCK:
_get_cache()[key]={'at':time.time(),'data':data}
_save_cache()
def _request(kind, base, suffix, params):
# All endpoint hosts are configured by the server administrator, never by callers.
with _LOCK:
wait=max(0.0,_NEXT_REQUEST[kind]-time.monotonic())
if wait:time.sleep(wait)
_NEXT_REQUEST[kind]=time.monotonic()+1.1
try:
resp=requests.get(base+suffix,params=params,timeout=(4,11),headers={
'User-Agent':_AGENT, 'Accept':'application/json', 'Accept-Language':'fr'})
resp.raise_for_status()
if len(resp.content)>1_500_000:raise NavigationError('Réponse cartographique trop volumineuse.')
return resp.json()
except (requests.RequestException,ValueError) as exc:
raise NavigationError('Service cartographique momentanément indisponible.') from exc
def _place(value):
if not isinstance(value,str) or not 2<=len(value.strip())<=MAX_DEST:
raise NavigationError('Indique une destination de 2 à 180 caractères.')
text=' '.join(value.strip().split())
if re.search(r'[\x00-\x1f]',text):raise NavigationError('Destination invalide.')
return text
def _coord(lat,lon):
try: a,b=float(lat),float(lon)
except (ValueError,TypeError):raise NavigationError('Coordonnées invalides.') from None
if not math.isfinite(a) or not math.isfinite(b) or not (-90<=a<=90 and -180<=b<=180):
raise NavigationError('Coordonnées hors limites.')
return round(a,6),round(b,6)
def search_location(query):
query=_place(query)
key='geo:'+query.casefold()
hit=_cached(key)
if hit is not None:return hit
base=_config_url('DONA_NAV_GEOCODER_URL',_NOMINATIM)
raw=_request('geo',base,'/search',{'q':query,'format':'jsonv2','limit':3,'accept-language':'fr'})
results=[]
for entry in raw if isinstance(raw,list) else []:
if not isinstance(entry,dict):continue
try:lat,lon=_coord(entry.get('lat'),entry.get('lon'))
except NavigationError:continue
results.append({'name':str(entry.get('display_name') or query)[:220], 'lat':lat,'lon':lon})
_remember(key,results)
return results
def _origin_text(origin=None):
# No guessed location, ever. Caller or administrator must provide one.
val=(origin or os.getenv('DONA_NAV_ORIGIN','')).strip()
return _place(val) if val else None
def external_links(dest,dest_point=None):
query=_place(dest)
if dest_point:
lat,lon=_coord(dest_point['lat'],dest_point['lon'])
waze='https://www.waze.com/ul?'+urlencode({'ll':f'{lat},{lon}','navigate':'yes','utm_source':'DonaNavigation'})
maps='https://www.google.com/maps/dir/?'+urlencode({'api':'1','destination':f'{lat},{lon}','travelmode':'driving'})
else:
waze='https://www.waze.com/ul?'+urlencode({'q':query,'navigate':'yes','utm_source':'DonaNavigation'})
maps='https://www.google.com/maps/dir/?'+urlencode({'api':'1','destination':query,'travelmode':'driving'})
return {'waze':waze,'google_maps':maps}
def _route_between(start,end):
key=f"route:{start['lat']},{start['lon']}:{end['lat']},{end['lon']}"
hit=_cached(key)
if hit is not None:return hit
base=_config_url('DONA_NAV_ROUTER_URL',_OSRM_DEMO)
coords=f"{start['lon']},{start['lat']};{end['lon']},{end['lat']}"
raw=_request('route',base,'/route/v1/driving/'+coords,{'overview':'simplified','geometries':'geojson','steps':'false'})
routes=raw.get('routes',[]) if isinstance(raw,dict) and raw.get('code')=='Ok' else []
if not routes:raise NavigationError('Aucun itinéraire routier calculable.')
r=routes[0]
coords=(r.get('geometry') or {}).get('coordinates') or []
line=[]
for p in coords[:700]:
if not isinstance(p,list) or len(p)<2:continue
lat,lon=_coord(p[1],p[0]);line.append([lat,lon])
# Need a meaningful route before returning a claimed ETA.
if len(line)<2:raise NavigationError('Géométrie d’itinéraire indisponible.')
meters=float(r['distance']);seconds=float(r['duration'])
if any(not math.isfinite(x) or x<0 for x in (meters,seconds)):
raise NavigationError('Durée de trajet invalide.')
out={'distance_km':round(meters/1000,1),'duration_min':round(seconds/60),'line':line,
'traffic_included':False,'provider':'OSRM'}
_remember(key,out)
return out
def build(destination,origin=None):
dest=_place(destination)
result={'destination':dest,'origin':None,'place':None,'route':None,'note':'', 'links':external_links(dest),
'attribution':'© OpenStreetMap contributors'}
try:
hits=search_location(dest)
if hits:
result['place']=hits[0];result['links']=external_links(dest,hits[0])
else:result['note']='Adresse non trouvée ; ouvre Waze pour vérifier la destination.'
except NavigationError as exc:result['note']=str(exc)+' Le lien Waze reste disponible.'
try: start=_origin_text(origin)
except NavigationError as exc:result['note']=str(exc);start=None
if start and result['place']:
result['origin']=start
try:
matches=search_location(start)
if matches:result['route']=_route_between(matches[0],result['place']);result['origin_place']=matches[0]
else:result['note']='Point de départ introuvable : trajet non calculé.'
except NavigationError as exc:result['note']=str(exc)+' Tu peux ouvrir Waze pour calculer l’itinéraire.'
elif not start and not result['note']:
result['note']='Renseigne un point de départ pour calculer la durée. Sans départ, seule la destination est affichée.'
return result
# Explicit speech routing. Does not intercept ordinary conversation or media controls.
_NAV_RE=re.compile(r'\b(?:waze|gps|google maps|itin[eé]raire|trajet|navigation|destination|route vers|temps pour (?:aller|me rendre)|aller [aà] )\b',re.I)
_SEND_RE=re.compile(r'\b(?:envoie|envoies|transf[eè]re|partage|sur mon t[eé]l[eé]phone|à mon t[eé]l[eé]phone)\b',re.I)
def is_request(text):
return isinstance(text,str) and 3<=len(text)<500 and bool(_NAV_RE.search(text))
def is_send(text):return bool(_SEND_RE.search(text or ''))
def extract_destination(text):
s=str(text or '').strip()
s=re.sub(r'^(?:dona|alfred)[,\s]+','',s,flags=re.I)
patterns=[r'^.*?\b(?:itin[eé]raire|trajet|destination|route)\s+(?:pour|vers|jusqu[\'’]?[aà]|[aà])\s+',
r'^.*?\b(?:aller|rendre)\s+(?:[aà]|au|aux|en)\s+',
r'^.*?\b(?:waze|google maps|gps)\s+(?:pour|vers|jusqu[\'’]?[aà]|[aà])\s+',
r'^.*?\b(?:jusqu[\'’]?[aà])\s+']
for pat in patterns:
found=re.search(pat,s,re.I)
if found:s=s[found.end():];break
s=re.sub(r'\s+(?:sur|dans)\s+(?:waze|google maps)\s*$','',s,flags=re.I)
s=s.rstrip(' .?!;,')
return s[:MAX_DEST] if 2<=len(s)<=MAX_DEST else None
# DONA_NAV_FIX_V831: same validation for config display and real HA sends.
def _notify_service_name():
service=os.getenv('DONA_NAV_HA_NOTIFY_SERVICE','').strip()
match=re.fullmatch(r'notify\.([a-z0-9_]+)',service)
return match.group(1) if match else None
def _notify_url(destination,url):
service_name=_notify_service_name()
if not service_name:raise NavigationError('Configure un service Home Assistant valide dans DONA_NAV_HA_NOTIFY_SERVICE (notify.*).')
base=(os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/')
token=(os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip()
if not base or not token:raise NavigationError('Home Assistant : HA_URL ou HA_TOKEN manquant.')
u=urlsplit(base)
if u.scheme not in ('http','https') or not u.hostname or u.username or u.password:
raise NavigationError('Adresse Home Assistant invalide.')
payload={'title':'Itinéraire Dona','message':'Ouvrir Waze pour '+destination,
'data':{'url':url,'clickAction':url}}
try:
res=requests.post(base+'/api/services/notify/'+service_name,json=payload,
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},timeout=10)
res.raise_for_status()
except requests.RequestException as exc:
raise NavigationError('Notification Home Assistant refusée ou inaccessible.') from exc
return True
def send_to_phone(destination):
dest=_place(destination)
# Only links generated on server; never a caller-provided URL.
return _notify_url(dest,external_links(dest)['waze'])
def _allowed_request():
if not request.is_json or request.headers.get('X-Alfred-Request')!='1':return False
origin=request.headers.get('Origin')
if origin:
try:
u=urlsplit(origin)
return u.scheme in ('http','https') and u.netloc==request.host
except ValueError:return False
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
def register_navigation(app,socketio):
bp=Blueprint('dona_navigation_v83',__name__)
@bp.post('/api/dona/navigation/search')
def query():
if not _allowed_request():return jsonify(ok=False,error='Requête refusée.'),403
data=request.get_json(silent=True) or {}
try:return jsonify(ok=True,**build(data.get('destination'),data.get('origin')))
except NavigationError as exc:return jsonify(ok=False,error=str(exc)),400
@bp.post('/api/dona/navigation/send')
def notify():
if not _allowed_request():return jsonify(ok=False,error='Requête refusée.'),403
try:
dest=_place((request.get_json(silent=True) or {}).get('destination'))
send_to_phone(dest)
return jsonify(ok=True,message='Notification Waze envoyée au téléphone configuré.')
except NavigationError as exc:return jsonify(ok=False,error=str(exc)),400
@bp.get('/api/dona/navigation/config')
def settings():
return jsonify(ok=True,origin=os.getenv('DONA_NAV_ORIGIN',''),
phone_configured=bool(_notify_service_name()))
app.register_blueprint(bp)
def handle_and_emit(text,socketio,state,state_lock):
dest=extract_destination(text)
if not dest:
msg='Indique une destination, par exemple : montre-moi le trajet vers Metz.'
result={'type':'dona_navigation','error':msg}
else:
result={'type':'dona_navigation', **build(dest)}
msg='Voici la destination '+dest+'.'
if result.get('route'):
msg+=f" Distance : {result['route']['distance_km']} kilomètres ; durée indicative : {result['route']['duration_min']} minutes, hors trafic."
if is_send(text):
try:send_to_phone(dest);result['sent']=True;msg+=' Le lien Waze a été envoyé au téléphone configuré.'
except NavigationError as exc:result['send_error']=str(exc);msg+=' Je n’ai pas pu envoyer la notification : '+str(exc)
with state_lock:
state['content']=result
socketio.emit('alfred_render',result)
return msg