"""Fast voice routing for visual actions; Hermes still handles general tasks.""" from __future__ import annotations import os from datetime import datetime, timedelta, time from zoneinfo import ZoneInfo from . import ha_views as ha from . import alfred_intents as intents TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) def _label(s,e): return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}" def _event_summary(events, question): count=len(events) if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.' names=', '.join(ev['title'] for ev in events[:3]) if question: return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}." return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}." def _calendar(text, question=False): start,end,view=intents.period(text) payload=ha.get_events(start,end) content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(), 'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']} speech=_event_summary(payload['events'],question) if payload['errors']: speech+=' Certains calendriers sont indisponibles.' return content,speech def _create(text): calendars=ha.list_calendars() details, complete=intents.extract_create(text,calendars) if not complete: return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.' # No calendar mutation until a second explicit confirmation. intents.set_create(details) pretty=datetime.fromisoformat(details['date']).strftime('%d/%m') speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule." return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech def _delete(text): now=datetime.now(TZ) day=intents.parse_day(text,now) if day: start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1) else: start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45) data=ha.get_events(start,end) needle=intents.extract_search(text) events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events'] if not events: return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.' if len(events)!=1: return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran." ev=events[0] if not ev['can_delete']: return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.' intents.set_delete(ev) warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else '' return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}" def _confirm_delete(): e=intents.current_delete() if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.' # Recheck existence in HA to avoid deleting an event that changed meanwhile. start=datetime.fromisoformat(e['start'])-timedelta(days=1) end=datetime.fromisoformat(e['end'])+timedelta(days=1) found=ha.get_events(start,end,[e['calendar_id']])['events'] if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found): intents.clear_delete() return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.' ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None) intents.clear_delete() content,speech=_calendar('planning sur 7 jours') return content,'Événement supprimé. '+speech def _confirm_create(): data=intents.current_create() if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.' if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.' start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ) end=start+timedelta(minutes=int(data.get('duration_minutes',60))) ha.create_event(data['calendar_id'],data['title'],start,end) intents.clear_create() content,speech=_calendar('planning sur 7 jours') return content,'Événement ajouté. '+speech def route(text, last_view=None): intent=intents.classify(text) # Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?' if intent is None and last_view == 'calendar' and len(text.strip()) < 45: t=intents.simplify(text) if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')): intent={'kind':'calendar','question':True} if intent is None:return None kind=intent['kind'] if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.' if kind=='calendar':return _calendar(text, intent.get('question',False)) if kind=='create':return _create(text) if kind=='delete':return _delete(text) if kind=='confirm_delete': if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.' return _confirm_delete() if kind=='confirm_create':return _confirm_create() if kind=='cancel_pending': intents.cancel_pending() return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.' if kind=='temperatures': temps=ha.get_temperatures() return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant." if kind=='cameras': cameras=ha.list_cameras() return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles." return None