"""Minuteurs + chronomètres indépendants d'Hermes ; stockage JSON durable. Le temps est exprimé à partir de l'horloge UTC, pour survivre à un redémarrage (et continuer à avancer pendant l'arrêt de Flask). Les durées sont limitées pour éviter les mauvaises interprétations des demandes vocales. """ from __future__ import annotations import json import os import re import secrets import threading import time from pathlib import Path from urllib.parse import urlsplit from flask import Blueprint, jsonify, request ROOT = Path(__file__).resolve().parents[1] DEFAULT_PATH = ROOT / 'data' / 'timers.json' MAX_SECONDS = 30 * 24 * 3600 MAX_ACTIVE = 20 class TimerError(ValueError): pass class TimerStore: def __init__(self, path: Path, clock=None, on_change=None, on_finished=None): self.path = Path(path) self.clock = clock or time.time self.on_change = on_change or (lambda snapshot: None) self.on_finished = on_finished or (lambda timer: None) self.lock = threading.RLock() self.items: dict[str, dict] = {} self._read() def _read(self): if not self.path.exists(): return try: data = json.loads(self.path.read_text(encoding='utf-8')) if not isinstance(data, list): raise ValueError('Mauvais format JSON') for item in data: if (isinstance(item, dict) and isinstance(item.get('id'), str) and item.get('kind') in ('timer', 'stopwatch') and item.get('state') in ('running', 'paused', 'finished', 'cancelled')): self.items[item['id']] = item except (OSError, ValueError, TypeError) as exc: raise RuntimeError(f'Impossible de charger {self.path} : {exc}') from exc def _persist(self): self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_name(self.path.name + '.tmp.' + secrets.token_hex(3)) try: with tmp.open('w', encoding='utf-8') as out: json.dump(list(self.items.values()), out, ensure_ascii=False, indent=2) out.flush() os.fsync(out.fileno()) os.chmod(tmp, 0o600) os.replace(tmp, self.path) finally: tmp.unlink(missing_ok=True) @staticmethod def _duration(number, *, allow_zero=False): if isinstance(number, bool) or not isinstance(number, (int, float)): raise TimerError('Durée numérique attendue en secondes.') if number < (0 if allow_zero else 1) or number > MAX_SECONDS: raise TimerError('Durée autorisée : de 1 seconde à 30 jours.') return float(number) @staticmethod def _label(label, kind): label = ' '.join(str(label or '').split()).strip() if not label: label = 'Minuteur' if kind == 'timer' else 'Chronomètre' if len(label) > 80 or any(ord(x) < 32 for x in label): raise TimerError('Nom trop long ou invalide (80 caractères maximum).') return label def _view(self, item, now): duration = item.get('duration') elapsed = float(item.get('elapsed', 0)) if item['state'] == 'running': elapsed += max(0.0, now - float(item['started_at'])) remaining = max(0.0, duration - elapsed) if duration is not None else None result = {k: item[k] for k in ('id', 'label', 'kind', 'state', 'created_at')} result.update(duration=duration, elapsed=round(elapsed, 2), remaining=round(remaining, 2) if remaining is not None else None) return result def snapshot(self, *, include_finished=False): with self.lock: now = self.clock() self._expire_locked(now) items = [self._view(x, now) for x in self.items.values() if include_finished or x['state'] in ('running', 'paused')] return sorted(items, key=lambda x: (x['state'] != 'running', x['created_at'])) def _expire_locked(self, now): finished = [] for item in self.items.values(): if item['state'] != 'running' or item.get('duration') is None: continue elapsed = item['elapsed'] + max(0.0, now - item['started_at']) if elapsed + 0.005 >= item['duration']: item['elapsed'] = item['duration'] item['started_at'] = None item['state'] = 'finished' item['finished_at'] = now finished.append(item.copy()) if finished: self._persist() self.on_change(self._unexpired_snapshot(now)) for item in finished: self.on_finished(item) def _unexpired_snapshot(self, now): return sorted([self._view(x, now) for x in self.items.values() if x['state'] in ('running', 'paused')], key=lambda x: x['created_at']) def start(self, kind, seconds=None, label=None): if kind not in ('timer', 'stopwatch'): raise TimerError('Type attendu : timer ou stopwatch.') if kind == 'timer' and seconds is None: raise TimerError('Il faut préciser la durée du minuteur.') if seconds is not None: seconds = self._duration(seconds) with self.lock: now = self.clock() self._expire_locked(now) if sum(1 for x in self.items.values() if x['state'] in ('running', 'paused')) >= MAX_ACTIVE: raise TimerError('Maximum de 20 minuteurs ou chronomètres en cours.') ident = secrets.token_hex(5) item = {'id': ident, 'label': self._label(label, kind), 'kind': kind, 'duration': seconds, 'elapsed': 0.0, 'started_at': now, 'created_at': now, 'state': 'running'} self.items[ident] = item # Conserver au plus 80 éléments historiques. old = sorted((x for x in self.items.values() if x['state'] in ('finished', 'cancelled')), key=lambda x: x['created_at']) for x in old[:-80]: self.items.pop(x['id'], None) self._persist() self.on_change(self._unexpired_snapshot(now)) return self._view(item, now) def _resolve(self, ident): if not isinstance(ident, str): raise TimerError('Identifiant attendu.') ident = ident.strip() if ident in self.items: return self.items[ident] candidates = [x for x in self.items.values() if x['state'] in ('running', 'paused') and (x['label'].casefold() == ident.casefold() or x['id'].startswith(ident))] if len(candidates) == 1: return candidates[0] if not candidates: raise TimerError('Minuteur ou chronomètre introuvable.') raise TimerError('Plusieurs minuteurs portent ce nom : préciser l’identifiant.') def action(self, action, ident, seconds=None): if action not in ('pause', 'resume', 'cancel', 'add_time'): raise TimerError('Action inconnue.') with self.lock: now = self.clock() self._expire_locked(now) item = self._resolve(ident) if item['state'] in ('cancelled', 'finished'): raise TimerError('Ce minuteur est déjà terminé.') if action == 'pause': if item['state'] != 'running': raise TimerError('Déjà en pause.') item['elapsed'] += max(0, now - item['started_at']) item['started_at'] = None item['state'] = 'paused' elif action == 'resume': if item['state'] != 'paused': raise TimerError('Ce minuteur n’est pas en pause.') item['started_at'] = now item['state'] = 'running' elif action == 'cancel': if item['state'] == 'running': item['elapsed'] += max(0, now - item['started_at']) item['started_at'] = None item['state'] = 'cancelled' else: if item['duration'] is None: raise TimerError('Chronomètre libre : aucune durée maximale à prolonger.') amount = self._duration(seconds) item['duration'] = self._duration(item['duration'] + amount) self._persist() self.on_change(self._unexpired_snapshot(now)) return self._view(item, now) def cancel_all(self): with self.lock: now = self.clock() affected = [] for item in self.items.values(): if item['state'] not in ('running', 'paused'): continue if item['state'] == 'running': item['elapsed'] += max(0, now - item['started_at']) item['started_at'] = None item['state'] = 'cancelled' affected.append(self._view(item, now)) if affected: self._persist() self.on_change(self._unexpired_snapshot(now)) return affected def tick(self): with self.lock: self._expire_locked(self.clock()) def run(self, stop_event=None, interval=0.5): stop_event = stop_event or threading.Event() while not stop_event.wait(interval): try: self.tick() except Exception as exc: print('[ALFRED TIMERS] erreur boucle :', repr(exc), flush=True) def register(app, socketio, speak_async, audio_settings): def changed(items): socketio.emit('alfred_timers_changed', {'items': items}) def finished(item): payload = {'id': item['id'], 'label': item['label'], 'kind': item['kind']} socketio.emit('alfred_timer_finished', payload) if not audio_settings.speaker_muted(): msg = f"Le minuteur {item['label']} est terminé." if item['kind'] == 'timer' else f"Le chronomètre {item['label']} est terminé." try: speak_async(msg) except Exception as exc: print('[ALFRED TIMERS] alerte vocale :', repr(exc), flush=True) store = TimerStore(Path(os.getenv('ALFRED_TIMERS_DB', str(DEFAULT_PATH))), on_change=changed, on_finished=finished) bp = Blueprint('alfred_timers', __name__) def safe_post(): # MCP fonctionne uniquement depuis la boucle locale (aucun accès LAN). if request.remote_addr in ('127.0.0.1', '::1') and request.headers.get('X-Alfred-MCP-Local') == 'timers': return True origin = request.headers.get('Origin') if request.headers.get('X-Alfred-Request') != '1': return False fetch_site = request.headers.get('Sec-Fetch-Site', '').lower() if fetch_site == 'cross-site': return False if not origin: # Chromium peut omettre Origin sur certaines requêtes même origine. return fetch_site == 'same-origin' p = urlsplit(origin) return p.scheme in ('http','https') and p.netloc.lower() == request.host.lower() @bp.get('/api/timers') def list_timers(): return jsonify({'items': store.snapshot(), 'server_time': store.clock()}) @bp.post('/api/timers/action') def timers_action(): if not safe_post(): return jsonify({'error': 'Requête non autorisée (origine ou en-tête MCP invalide).'}), 403 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({'error': 'Objet JSON attendu.'}), 400 command = data.get('action') try: if command == 'create_timer': item = store.start('timer', data.get('seconds'), data.get('label')) elif command == 'create_stopwatch': item = store.start('stopwatch', data.get('seconds'), data.get('label')) elif command == 'cancel_all': cancelled = store.cancel_all() return jsonify({'ok': True, 'cancelled': cancelled, 'items': store.snapshot()}) else: item = store.action(command, data.get('id'), data.get('seconds')) return jsonify({'ok': True, 'item': item, 'items': store.snapshot()}) except TimerError as exc: return jsonify({'error': str(exc)}), 400 except OSError as exc: print('[ALFRED TIMERS] écriture échouée:', repr(exc), flush=True) return jsonify({'error': 'Stockage des minuteurs indisponible.'}), 503 app.register_blueprint(bp) threading.Thread(target=store.run, name='alfred-timers', daemon=True).start() return store