Files
Alfredv2/alfred/app/services/alfred_timers.py
T

298 lines
13 KiB
Python
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.
"""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