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