"""Dona V9: daily-life cockpit. Local-first, explicit writes, no credentials stored here. A deterministic first-pass router. All unrecognised messages return None to the existing Hermes conversation. This module never mutates Hermes sessions. """ from __future__ import annotations import json import os import re import sqlite3 import threading import unicodedata from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo from flask import Blueprint, jsonify, request VERSION = '9.0' TZ = ZoneInfo(os.getenv('TZ', 'Europe/Paris')) _DB_LOCK = threading.RLock() MAX_MEMORY = 100 MAX_PINS = 12 # Original, clearly labelled suggestions. No claim to represent third-party recipes. RECIPES = [ dict(id='quiche', title='Quiche lorraine (variante familiale)', minutes=45, ingredients=['pâte brisée','œufs','crème','lardons','lait'], steps=['Préchauffe le four à 180 °C.','Dépose la pâte dans un moule et répartis les lardons préalablement revenus.','Bats 3 œufs avec 20 cl de crème et un peu de lait, puis verse sur la pâte.','Enfourne environ 35 minutes, jusqu’à cuisson complète.'], tags=['oeuf','quiche','facile']), dict(id='riz-tomate', title='Riz aux tomates et haricots', minutes=25, ingredients=['riz','tomates','haricots verts','oignon','huile d’olive'], steps=['Fais cuire le riz selon les instructions du paquet.','Fais revenir l’oignon et les tomates coupées.','Ajoute les haricots verts cuits et le riz, puis mélange.'], tags=['vegetarien','riz','tomate','haricot']), dict(id='omelette', title='Omelette aux légumes', minutes=20, ingredients=['œufs','tomates','courgette','fromage'], steps=['Coupe et fais revenir les légumes.','Bats les œufs et verse-les dans la poêle.','Ajoute un peu de fromage et cuis jusqu’à ce que les œufs soient pris.'], tags=['oeuf','facile']), dict(id='pates', title='Pâtes sauce tomate maison', minutes=25, ingredients=['pâtes','tomates','oignon','ail','huile d’olive'], steps=['Fais cuire les pâtes.','Laisse mijoter tomates, ail et oignon 15 minutes.','Mélange avec les pâtes.'], tags=['vegetarien','facile']), dict(id='gratin', title='Gratin de pommes de terre et légumes', minutes=55, ingredients=['pommes de terre','courgette','crème','fromage'], steps=['Préchauffe le four à 190 °C.','Coupe les légumes en fines rondelles.','Verse la crème, parsème de fromage et cuis jusqu’à tendreté.'], tags=['vegetarien']), dict(id='poulet-riz', title='Poulet au riz et carottes', minutes=35, ingredients=['poulet','riz','carottes','oignon'], steps=['Fais cuire le riz.','Fais dorer le poulet puis cuis-le complètement avec carottes et oignon.','Sers ensemble.'], tags=['famille','riz']), dict(id='soupe', title='Soupe douce de légumes', minutes=40, ingredients=['pommes de terre','carottes','poireau','eau'], steps=['Lave et coupe les légumes.','Laisse cuire dans l’eau 30 minutes.','Mixe et ajuste l’assaisonnement.'], tags=['vegetarien','hiver']), dict(id='salade', title='Salade de riz composée', minutes=20, ingredients=['riz','tomates','maïs','thon','huile d’olive'], steps=['Fais cuire puis refroidir le riz.','Ajoute les légumes et le thon égoutté.','Assaisonne et conserve au frais.'], tags=['riz','rapide']), dict(id='galettes', title='Galettes de pommes de terre', minutes=30, ingredients=['pommes de terre','œufs','farine','huile'], steps=['Râpe les pommes de terre et presse-les.','Mélange avec l’œuf et un peu de farine.','Fais cuire les galettes à cœur sur les deux faces.'], tags=['famille']), dict(id='chili-doux', title='Chili doux aux haricots rouges', minutes=30, ingredients=['haricots rouges','tomates','riz','oignon','maïs'], steps=['Fais revenir l’oignon et les tomates.','Ajoute haricots rouges et maïs puis réchauffe.','Sers avec le riz.'], tags=['vegetarien','riz']), dict(id='croque', title='Croque-monsieur et crudités', minutes=15, ingredients=['pain de mie','jambon','fromage','tomates'], steps=['Garnis le pain de mie de jambon et fromage.','Passe au four ou à l’appareil jusqu’à ce que le fromage fonde.','Sers avec des crudités.'], tags=['rapide','famille']), dict(id='poisson', title='Poisson au four et légumes', minutes=35, ingredients=['filet de poisson','pommes de terre','carottes','huile d’olive'], steps=['Préchauffe à 190 °C.','Fais précuire les pommes de terre.','Enfourne le poisson et les légumes jusqu’à cuisson complète du poisson.'], tags=['famille']), ] def norm(text): text = unicodedata.normalize('NFKD', str(text or '').casefold().replace('œ','oe').replace('æ','ae')) return re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).strip() def _data_dir(): return Path(os.getenv('DONA_V9_DATA_DIR', '/opt/alfred/app/data')).resolve() def _connect(): root = _data_dir() root.mkdir(parents=True, exist_ok=True, mode=0o700) db = sqlite3.connect(str(root / 'dona_v9.sqlite3'), timeout=8) db.row_factory = sqlite3.Row db.execute('CREATE TABLE IF NOT EXISTS memories (id INTEGER PRIMARY KEY, text TEXT NOT NULL, created_at TEXT NOT NULL)') db.execute('CREATE TABLE IF NOT EXISTS pins (id INTEGER PRIMARY KEY, title TEXT NOT NULL, kind TEXT NOT NULL, prompt TEXT NOT NULL, created_at TEXT NOT NULL)') db.commit() return db def _now(): return datetime.now(TZ) def memories(): with _DB_LOCK, _connect() as db: return [dict(r) for r in db.execute('SELECT * FROM memories ORDER BY id DESC LIMIT ?', (MAX_MEMORY,))] def remember(text): text = str(text or '').strip() if not 3 <= len(text) <= 400: raise ValueError('Souvenir entre 3 et 400 caractères requis.') with _DB_LOCK, _connect() as db: if db.execute('SELECT COUNT(*) FROM memories').fetchone()[0] >= MAX_MEMORY: raise ValueError('Mémoire pleine : supprime d’abord un ancien souvenir.') db.execute('INSERT INTO memories(text,created_at) VALUES (?,?)', (text, _now().isoformat())) db.commit() return text def forget_id(value): with _DB_LOCK, _connect() as db: cursor = db.execute('DELETE FROM memories WHERE id=?', (int(value),)) db.commit() return bool(cursor.rowcount) def pins(): with _DB_LOCK, _connect() as db: return [dict(r) for r in db.execute('SELECT * FROM pins ORDER BY id DESC LIMIT ?', (MAX_PINS,))] def add_pin(kind, title, prompt): if kind not in ('recipe','menu','briefing','monitor','documents','routine','daily'): raise ValueError('Type de carte non pris en charge.') if not title or len(title)>100 or len(prompt)>300: raise ValueError('Carte invalide.') with _DB_LOCK, _connect() as db: if db.execute('SELECT COUNT(*) FROM pins').fetchone()[0] >= MAX_PINS: raise ValueError('12 cartes maximum.') db.execute('INSERT INTO pins(title,kind,prompt,created_at) VALUES (?,?,?,?)', (title,kind,prompt,_now().isoformat())) db.commit() def remove_pin(value): with _DB_LOCK, _connect() as db: row=db.execute('DELETE FROM pins WHERE id=?', (int(value),)) db.commit() return bool(row.rowcount) def match_recipes(ingredients): have = [norm(x) for x in ingredients if len(str(x).strip())>=2] have = [x for x in have if x][:20] scored=[] for r in RECIPES: found=[x for x in r['ingredients'] if any(y in norm(x) or norm(x) in y for y in have)] if found:scored.append({**r, 'matched':found,'missing':[x for x in r['ingredients'] if x not in found], 'score':len(found)}) return sorted(scored,key=lambda x:(-x['score'],len(x['missing']),x['minutes']))[:8] def recipe_search(query): q=norm(query) q=re.sub(r'^(?:de la|des|du|de l|de|la|le|l|un|une)\s+', '',q) if not q:return list(RECIPES)[:8] matches=[r for r in RECIPES if q in norm(r['title']) or any(q in norm(t) for t in r['tags'])] if not matches: terms=[w for w in q.split() if len(w)>3 and w not in ('recette','montre','faire','comment')] matches=[r for r in RECIPES if any(t in norm(r['title']) for t in terms)] return matches[:8] def _menu(days=3, people=2, child=True): days=max(1,min(int(days),7)) chosen=['poulet-riz','pates','gratin','quiche','poisson','chili-doux','soupe'][:days] index={r['id']:r for r in RECIPES} menu=[]; shopping=[] for i,rid in enumerate(chosen): r=index[rid] menu.append({'day':i+1,'title':r['title'],'recipe':r}) shopping.extend(r['ingredients']) return {'days':days,'people':people,'child':child,'meals':menu,'shopping':list(dict.fromkeys(shopping)), 'note':'Propositions familiales originales, sans allergies ni restrictions connues. Quantités exactes à adapter aux portions et à l’appétit.'} def _events(days): from services import ha_views start=_now();end=start+timedelta(days=days) raw=ha_views.get_events(start,end) return [{k:str(e.get(k) or '')[:220] for k in ('title','start','end','location')} for e in raw.get('events',[])[:35]],raw.get('errors',[]) def _weather(): from services import ha_weather raw=ha_weather.get_weather() current=raw.get('current') or {} return {'description':current.get('description',''), 'temperature':current.get('temperature'), 'unit':raw.get('temperature_unit','°C'), 'source':'Home Assistant'} def briefing(days=1, planning=False): result={'date':_now().date().isoformat(),'days':days,'events':[],'weather':None,'errors':[]} try:result['events'],errors=_events(days);result['errors']+=errors except Exception as exc:result['errors'].append('Agenda Home Assistant indisponible : '+type(exc).__name__) try:result['weather']=_weather() except Exception as exc:result['errors'].append('Météo Home Assistant indisponible : '+type(exc).__name__) if planning:result['suggested_menu']=_menu(min(days,3)) return result def _docs_dir(): return Path(os.getenv('DONA_V9_DOCS_DIR','/opt/alfred/documents')).resolve() def docs_search(query): """No file operations outside the explicitly configured documents directory.""" root=_docs_dir() if not root.is_dir():return {'found':[],'note':f'Dossier documentaire absent : {root}'} words=[x for x in norm(query).split() if len(x)>=3][:8] if not words:return {'found':[],'note':'Précise au moins un terme de recherche.'} found=[];scanned=0 for p in root.rglob('*'): if len(found)>25 or scanned>=400:break if p.is_symlink() or not p.is_file() or p.suffix.lower() not in ('.txt','.md','.pdf'):continue try: if not p.resolve().is_relative_to(root) or p.stat().st_size>3_000_000:continue scanned+=1 if p.suffix.lower()=='.pdf': try: from pypdf import PdfReader except ImportError:continue reader=PdfReader(str(p),strict=False) paragraphs=[(f'page {i+1}',(page.extract_text() or '')[:40000]) for i,page in enumerate(reader.pages[:45])] else:paragraphs=[('texte',p.read_text(encoding='utf-8',errors='replace')[:60000])] for section,body in paragraphs: low=norm(body) if all(w in low or w in norm(p.name) for w in words): at=min((low.find(w) for w in words if w in low),default=0) # Approximate snippet position, result shown as an excerpt only. snippet=body[max(0,at-150):at+480].replace('\n',' ').strip() found.append({'file':str(p.relative_to(root)),'section':section,'snippet':snippet[:600]}) break except (OSError,ValueError,RuntimeError,PermissionError):continue except Exception:continue # Corrupt PDFs may raise library-specific exceptions. return {'found':found[:25],'note':('Résultats extraits des fichiers locaux.' if found else 'Aucun passage correspondant. PDF : paquet pypdf facultatif.')} def routine_preview(): configured=[x.strip() for x in os.getenv('DONA_V9_BEDTIME_LIGHTS','').split(',') if x.strip()] entities=[x for x in configured if re.fullmatch(r'light\.[a-z0-9_]+',x)][:30] return {'name':'Bonne nuit','lights':entities,'executable':bool(entities), 'message':'Aucune action sans confirmation explicite. Configure DONA_V9_BEDTIME_LIGHTS pour activer l’extinction des lumières.' if not entities else 'Extinction des seules lumières autorisées, après confirmation.'} def routine_execute(): preview=routine_preview() if not preview['lights']:raise ValueError('Aucune lumière autorisée : routine inactive.') from services import ha_views # Trusted, administrator-configured entity IDs only. No user-provided service/action. ha_views._request('POST','/api/services/light/turn_off',payload={'entity_id':preview['lights']}) return {'done':preview['lights'],'message':'Commande envoyée à Home Assistant.'} def supervision(): """Hermes/MCP, new isolated session; no invented metric when offline.""" try: from services import dona_dynamic, dona_dashboard_agent board=dona_dynamic.build_dashboard('Tableau de bord Proxmox et Wazuh : état réel des nœuds et alertes disponibles',dona_dashboard_agent.ask_dashboard) return {'dashboard':board, 'note':'Données rapportées par Hermes et ses outils MCP ; vérifier la date et les sources.'} except Exception as exc: return {'dashboard':None,'note':'Supervision indisponible : '+type(exc).__name__+'. Vérifie les MCP Proxmox/Wazuh.'} def child_quiz(): return {'questions':[ {'question':'Combien font 7 × 8 ?','answer':'56'}, {'question':'Quel est le nom de la planète rouge ?','answer':'Mars'}, {'question':'Quel organe permet de respirer ?','answer':'Les poumons'}, ],'note':'Quiz familial local. Aucun accès aux outils d’administration ou à la domotique.'} def _intent(text): s=str(text or '').strip() if not 3 <= len(s) <= 500:return None lower=norm(s) # Let the existing YouTube navigation and movie/video search router own those requests. if re.search(r'\b(video|youtube|waze|google maps|itineraire|trajet)\b',lower):return None if re.search(r'\b(retiens que|souviens toi que|memorise que)\b',lower): m=re.search(r'(?:retiens que|souviens toi que|memorise que)\s+(.+)$',s,re.I) if m:return ('remember',m.group(1).strip()) if re.search(r'\b(que sais tu de moi|montre mes souvenirs|mes souvenirs)\b',lower):return ('memories','') if re.search(r'\b(quels? problemes?.{0,30}(serveurs?|proxmox|wazuh)|supervision|etat.{0,30}(serveurs?|proxmox|wazuh))\b',lower):return ('monitor','') if re.search(r'\b(bonne nuit|je vais me coucher|routine du soir)\b',lower):return ('routine','') if re.search(r'\b(quiz|question de culture generale|petit jeu de questions)\b',lower):return ('child','') if re.search(r'\b(document|documentation|manuel|mode d emploi|dans mes fichiers|dans mes documents)\b',lower) and re.search(r'\b(cherche|recherche|retrouve|trouve|montre|lis|explique)\b',lower): q=re.sub(r'^(?:dona|alfred)\s*[, :]?','',s,flags=re.I) q=re.sub(r'\b(?:cherche|recherche|retrouve|trouve|montre|lis|explique)(?:[ -]moi)?\b','',q,flags=re.I) q=re.sub(r'\b(?:dans|sur)\s+(?:mes|la|le|les)\s+(?:documents?|documentation|fichiers?|manuels?)\b','',q,flags=re.I) return ('documents',q.strip(' .?:!')) if re.search(r'\b(organise|planifie|planification)\b',lower) and re.search(r'\b(journee|demain|semaine)\b',lower):return ('daily','') if re.search(r'\b(briefing|resume|programme|quoi.{0,30}(attend|prevu)|qu est ce qui m attend)\b',lower) and re.search(r'\b(aujourd hui|demain|journee|matin|agenda|calendrier|briefing|semaine)\b',lower):return ('briefing','') if re.search(r'\b(menu|menus|repas|manger|cuisiner|recettes|idees de recettes)\b',lower) and re.search(r'\b(jours?|semaine|idees?|propose|prepare)\b',lower):return ('menu','') m=re.search(r'\b(?:j ai|il me reste|avec)\s+(?:des? |du |de la |d )?(.{5,180}?)\s+(?:que faire|quoi faire|quoi cuisiner|que cuisiner|pour cuisiner|\?)',lower) if m: original=re.search(r"(?:j[’']ai|il me reste|avec)\s+(.{5,180}?)\s+(?:que faire|quoi faire|quoi cuisiner|que cuisiner|pour cuisiner|\?)",s,re.I) value=original.group(1) if original else m.group(1) return ('ingredients',value) if re.search(r'\b(recette|cuisiner|preparer)\b',lower): q=re.sub(r'^.*?\b(?:recette (?:de |du |de la |de l |d |des |pour )?|comment (?:preparer|cuisiner) |cuisiner |preparer )','',lower).strip() if q:return ('recipe',q) return None def is_request(text):return _intent(text) is not None def handle(text): intent=_intent(text) if not intent:return None kind,arg=intent if kind=='remember': item=remember(arg) return {'kind':'memories','title':'Souvenir enregistré','memories':memories(),'speech':'Je retiens : '+item} if kind=='memories':return {'kind':'memories','title':'Mes souvenirs','memories':memories(),'speech':'Voici les souvenirs enregistrés avec ton accord.'} if kind=='recipe': rec=recipe_search(arg) return {'kind':'recipe','title':'Recettes','recipes':rec,'speech':('Voici '+str(len(rec))+' recettes possibles.' if rec else 'Je ne trouve pas cette recette dans le petit catalogue local. Une source externe pourra être ajoutée ensuite.'),'source':'Suggestions locales, sans API externe'} if kind=='ingredients': ing=[re.sub(r'^(?:des?|du|de la|de l|d)\s+', '',x).strip(' ,.;') for x in re.split(r'\s*,\s*|\s+et\s+',arg) if x.strip(' ,.;')] rec=match_recipes(ing) return {'kind':'recipe','title':'À cuisiner avec ce que tu as','recipes':rec,'speech':('J’ai trouvé '+str(len(rec))+' idées. Je te montre aussi les ingrédients manquants.' if rec else 'Je n’ai pas trouvé de recette correspondante dans le catalogue local.'),'source':'Suggestions locales, ingrédients manquants indiqués'} if kind=='menu': match=re.search(r'\b([1-7])\s*jours?\b',norm(text)) days=int(match.group(1)) if match else 3 payload=_menu(days) return {'kind':'menu','title':'Menus pour '+str(days)+' jours', 'menu':payload,'speech':'Voici des idées de repas pour '+str(days)+' jours et une liste de courses à vérifier.'} if kind=='briefing': payload=briefing(1) return {'kind':'briefing','title':'Briefing du jour','briefing':payload,'speech':_brief_speech(payload)} if kind=='daily': payload=briefing(3,True) return {'kind':'daily','title':'Organiser les prochains jours','briefing':payload,'speech':_brief_speech(payload)+' Je te propose aussi un menu familial.'} if kind=='documents': data=docs_search(arg) return {'kind':'documents','title':'Bibliothèque documentaire','documents':data,'speech':str(len(data['found']))+' document(s) correspondant(s).'} if kind=='routine':return {'kind':'routine','title':'Routine du soir','routine':routine_preview(),'speech':'Je t’affiche les actions prévues. Je ne change rien sans confirmation.'} if kind=='child':return {'kind':'child','title':'Quiz enfant','quiz':child_quiz(),'speech':'Voici trois questions pour jouer.'} if kind=='monitor': data=supervision() return {'kind':'monitor','title':'Supervision','monitor':data,'speech':'Voici le résultat de supervision. Vérifie les sources indiquées.'} return None def _brief_speech(data): n=len(data.get('events',[]));w=data.get('weather') or {} speech='Tu as '+str(n)+' événement'+('s' if n!=1 else '')+' dans le planning.' if w.get('temperature') is not None:speech+=' Il fait environ '+str(w['temperature'])+' degrés.' if data.get('errors'):speech+=' Certaines données ne sont pas disponibles.' return speech def handle_and_emit(text,socketio): try:result=handle(text) except (ValueError,sqlite3.Error,OSError) as exc: result={'kind':'error','title':'Dona','error':str(exc)[:240],'speech':str(exc)[:200]} if result: socketio.emit('dona_suite_v9',result) return result.get('speech') or '' return None def _authorized(): if not request.is_json or request.headers.get('X-Alfred-Request')!='1':return False origin=request.headers.get('Origin') if origin: from urllib.parse import urlsplit value=urlsplit(origin) return value.scheme in ('http','https') and value.netloc==request.host return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none') def add_to_alfred_courses(items, list_module=None): """Use Alfred's own deduplicating add_item(kind, label), SQLite and event schema. Items that are already active in the original list are not reported as added. Never creates a new shopping list, Home Assistant todo list, or local copy. """ if list_module is None: from services import alfred_lists as list_module added=[]; already_present=[]; seen=set() for raw in items: label=list_module.clean_label(raw, 'courses') key=list_module.fold(label) if key in seen:continue seen.add(key) try: result=list_module.add_item('courses',label) except Exception as exc: raise RuntimeError(f'Ajout partiel : {len(added)} ajouté(s), {len(already_present)} déjà présent(s).') from exc item=result['item']['label'] if result.get('already_present'):already_present.append(item) else:added.append(item) return {'added':added,'already_present':already_present} def register_suite(app,socketio): bp=Blueprint('dona_suite_v9', __name__) @bp.get('/api/dona/v9/config') def v9_config(): return jsonify(ok=True,version=VERSION,docs_dir=str(_docs_dir()),routine=routine_preview(),features=['cuisine','memoire','briefing','routines','documents','supervision','enfant','journee','cartes']) @bp.get('/api/dona/v9/memory') def v9_memory():return jsonify(ok=True,memories=memories()) @bp.post('/api/dona/v9/memory') def v9_save_memory(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 try:return jsonify(ok=True,text=remember((request.get_json(silent=True) or {}).get('text',''))) except ValueError as exc:return jsonify(ok=False,error=str(exc)),400 @bp.post('/api/dona/v9/memory/delete') def v9_delete_memory(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 try:return jsonify(ok=forget_id((request.get_json(silent=True) or {}).get('id'))) except (TypeError,ValueError):return jsonify(ok=False,error='ID invalide.'),400 @bp.get('/api/dona/v9/pins') def v9_pins():return jsonify(ok=True,pins=pins()) @bp.post('/api/dona/v9/pins') def v9_add_pin(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 x=request.get_json(silent=True) or {} try:add_pin(x.get('kind'),str(x.get('title','')),str(x.get('prompt',''))) except ValueError as exc:return jsonify(ok=False,error=str(exc)),400 return jsonify(ok=True) @bp.post('/api/dona/v9/pins/delete') def v9_delete_pin(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 try:return jsonify(ok=remove_pin((request.get_json(silent=True) or {}).get('id'))) except (TypeError,ValueError):return jsonify(ok=False,error='ID invalide.'),400 @bp.post('/api/dona/v9/ask') def v9_ask(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 text=(request.get_json(silent=True) or {}).get('text','') try: output=handle(text) if output is None:return jsonify(ok=False,error='Commande non reconnue, utilise la conversation normale pour une question générale.'),400 return jsonify(ok=True,result=output) except (ValueError,OSError,sqlite3.Error) as exc:return jsonify(ok=False,error=str(exc)),400 @bp.post('/api/dona/v9/shopping/add') def v9_shopping(): """Bridge to the EXISTING Alfred Listes SQLite module, never a new list.""" if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 data=request.get_json(silent=True) or {} items=data.get('items') if not isinstance(items,list) or not 1<=len(items)<=50 or any(not isinstance(x,str) or not 2<=len(x.strip())<=100 for x in items): return jsonify(ok=False,error='Liste invalide.'),400 try: result=add_to_alfred_courses(items) except Exception as exc: app.logger.warning('Dona V9 course list: %s',type(exc).__name__) return jsonify(ok=False,error='Liste de courses Alfred indisponible ou pleine. Vérifie les logs.'),503 if result['added']: socketio.emit('alfred_lists_changed',{'source':'dona_v9_cuisine','kind':'courses','action':'add_item'}) return jsonify(ok=True,**result) @bp.post('/api/dona/v9/routine/execute') def v9_routine_execute(): if not _authorized():return jsonify(ok=False,error='Requête non autorisée.'),403 data=request.get_json(silent=True) or {} if data.get('confirm')!='Eteindre les lumieres autorisees':return jsonify(ok=False,error='Confirmation explicite requise.'),400 try:return jsonify(ok=True,result=routine_execute()) except Exception as exc: app.logger.warning('Dona routine refused: %s',type(exc).__name__) return jsonify(ok=False,error='Routine indisponible : '+type(exc).__name__),503 app.register_blueprint(bp)