diff --git a/.gitignore b/.gitignore index 2150000..7f73587 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv __pycache__ *.pyc -certs \ No newline at end of file +certs +conf.json \ No newline at end of file diff --git a/backend/README_BACKEND.md b/backend/README_BACKEND.md index 999719d..562ca3c 100644 --- a/backend/README_BACKEND.md +++ b/backend/README_BACKEND.md @@ -41,6 +41,15 @@ Si présence d'un proxy, la dernière commande sera `pip install -r requirements ## Configuration +Le script `init.py` crée `data/conf.json` avec des valeurs par défaut et un mot de passe admin aléatoire : + +```bash +cd backend +python init.py +``` + +Le mot de passe généré est affiché une seule fois dans le terminal. Le fichier est créé avec les permissions `600`. + Le fichier `data/conf.json` contient: - `server.host`, `server.port`, `server.tls_cert`, `server.tls_key` diff --git a/backend/app/audio_player.py b/backend/app/audio_player.py index eb2eb57..d015bf7 100644 --- a/backend/app/audio_player.py +++ b/backend/app/audio_player.py @@ -20,7 +20,7 @@ class AudioPlayer: raise FileNotFoundError(f"Music file not found: {music_file}") return candidate - def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None) -> None: + def play(self, music_file: str, start_seconds: float = 0.0, end_seconds: Optional[float] = None, volume: int = 80) -> None: with self._lock: music_path = self._resolve_music_path(music_file) self.stop() @@ -31,6 +31,8 @@ class AudioPlayer: "-autoexit", "-loglevel", "error", + "-volume", + str(max(0, min(100, volume))), "-ss", str(start_seconds), ] diff --git a/backend/app/main.py b/backend/app/main.py index 0ad7631..1ca3846 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -64,6 +64,7 @@ def _play_from_trigger(trigger_id: str) -> Dict[str, object]: music_file=trigger["music_file"], start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), end_seconds=trigger.get("end_seconds"), + volume=int(trigger.get("volume", 80)), ) return trigger @@ -99,6 +100,7 @@ def _serial_callback(raw_message: str) -> TriggerLogInfo | None: music_file=trigger["music_file"], start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), end_seconds=trigger.get("end_seconds"), + volume=int(trigger.get("volume", 80)), ) info: TriggerLogInfo = {"key": trigger_key} diff --git a/backend/app/models.py b/backend/app/models.py index 71cb61c..4114d00 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,6 +11,7 @@ class TriggerConfig(BaseModel): music_file: str = Field(..., min_length=1, max_length=255) start_seconds: Optional[float] = Field(default=0.0, ge=0) end_seconds: Optional[float] = Field(default=None, ge=0) + volume: int = Field(default=80, ge=0, le=100) @field_validator("end_seconds") @classmethod @@ -27,6 +28,7 @@ class TriggerPatch(BaseModel): music_file: Optional[str] = Field(default=None, min_length=1, max_length=255) start_seconds: Optional[float] = Field(default=None, ge=0) end_seconds: Optional[float] = Field(default=None, ge=0) + volume: Optional[int] = Field(default=None, ge=0, le=100) class ForcePlayRequest(BaseModel): diff --git a/backend/data/conf.json b/backend/data/conf.json index a09bb4f..2c87e84 100644 --- a/backend/data/conf.json +++ b/backend/data/conf.json @@ -10,25 +10,27 @@ "password": "admin" }, "serial": { - "enabled": true, + "enabled": false, "port": "/dev/ttyACM0", "baudrate": 115200, "timeout": 1 }, "triggers": { "GPIO3": { - "name": "Test GPIO 23", + "name": "Test", "type": "GPIO3", "music_file": "SONNERIE A.mp3", - "start_seconds": 3.0, - "end_seconds": 10.0 + "start_seconds": 0.0, + "end_seconds": null, + "volume": 80 }, "GPIO4": { - "name": "Test 2", + "name": "Test", "type": "GPIO4", - "music_file": "SONNERIE D.mp3", + "music_file": "SONNERIE A.mp3", "start_seconds": 0.0, - "end_seconds": null + "end_seconds": null, + "volume": 80 } } } diff --git a/backend/init.py b/backend/init.py new file mode 100644 index 0000000..e3ae114 --- /dev/null +++ b/backend/init.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Initialisation du backend pySonnerie. + +Cree backend/data/conf.json avec des valeurs par defaut si le fichier +n'existe pas encore. Le mot de passe admin est genere aleatoirement. +""" +from __future__ import annotations + +import json +import secrets +import sys +from pathlib import Path + +CONF_PATH = Path(__file__).resolve().parent / "data" / "conf.json" + +DEFAULT_CONF = { + "server": { + "host": "0.0.0.0", + "port": 8443, + "tls_cert": "certs/cert.pem", + "tls_key": "certs/key.pem", + }, + "auth": { + "username": "admin", + "password": None, # remplace par un mot de passe aleatoire + }, + "serial": { + "enabled": False, + "port": "/dev/ttyACM0", + "baudrate": 115200, + "timeout": 1, + }, + "triggers": {}, +} + + +def main() -> None: + if CONF_PATH.exists(): + print(f"[info] {CONF_PATH} existe deja, aucune modification.") + sys.exit(0) + + CONF_PATH.parent.mkdir(parents=True, exist_ok=True) + (CONF_PATH.parent / "musiques").mkdir(parents=True, exist_ok=True) + + conf = DEFAULT_CONF.copy() + password = secrets.token_urlsafe(16) + conf["auth"] = {"username": "admin", "password": password} + + CONF_PATH.write_text(json.dumps(conf, indent=2) + "\n", encoding="utf-8") + CONF_PATH.chmod(0o600) + + print(f"[ok] {CONF_PATH} cree (permissions 600).") + print(f"[ok] Identifiants par defaut : admin / {password}") + print("[!] Notez ce mot de passe, il ne sera pas reaffiche.") + + +if __name__ == "__main__": + main() diff --git a/frontend/README_FRONTEND.md b/frontend/README_FRONTEND.md index 44e1163..a993480 100644 --- a/frontend/README_FRONTEND.md +++ b/frontend/README_FRONTEND.md @@ -20,6 +20,37 @@ pip install -r requirements.txt ``` Si présence d'un proxy, la dernière commande sera `pip install -r requirements.txt --proxy http://proxy:port`. +## Configuration + +La configuration du frontend se fait via `frontend/data/conf.json`. + +Le script `init.py` crée ce fichier automatiquement avec une clé aléatoire : + +```bash +cd frontend +python init.py +``` + +Le fichier généré est : + +```json +{ + "secret_key": "" +} +``` + +Générer une clé sécurisée manuellement (si besoin d'éditer le fichier) : + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +Le fichier est créé avec les permissions `600`. Pour les restreindre manuellement : + +```bash +chmod 600 frontend/data/conf.json +``` + ## Execution ```bash @@ -41,13 +72,19 @@ source .venv/bin/activate pip install -r requirements.txt ``` -Copier le service fourni: +Créer et sécuriser le fichier de configuration : + +```bash +sudo -u www-data python /opt/pySonnerie/frontend/init.py +``` + +Copier le service fourni : ```bash sudo cp systemd/pysonnerie-frontend.service /etc/systemd/system/ ``` -Adapter au besoin les variables dans le fichier de service (`FRONTEND_SECRET_KEY`, `FRONTEND_BIND`) puis activer: +Adapter au besoin la variable `FRONTEND_BIND` dans le fichier de service, puis activer : ```bash sudo systemctl daemon-reload diff --git a/frontend/app/__init__.py b/frontend/app/__init__.py index 98a12bf..4078aa8 100644 --- a/frontend/app/__init__.py +++ b/frontend/app/__init__.py @@ -1,18 +1,31 @@ from __future__ import annotations -import os +import json from pathlib import Path from flask import Flask +def _load_secret_key(project_root: Path) -> str: + conf_path = project_root / "frontend" / "data" / "conf.json" + if conf_path.exists(): + try: + conf = json.loads(conf_path.read_text(encoding="utf-8")) + key = conf.get("secret_key", "") + if key: + return str(key) + except Exception: + pass + return "pysonnerie-frontend-dev-key" + + def create_app() -> Flask: app = Flask(__name__) - secret = os.getenv("FRONTEND_SECRET_KEY", "pysonnerie-frontend-dev-key") + project_root = Path(__file__).resolve().parents[2] + secret = _load_secret_key(project_root) app.config["SECRET_KEY"] = secret - project_root = Path(__file__).resolve().parents[2] app.config["PROJECT_ROOT"] = project_root app.config["MUSIC_DIR"] = project_root / "backend" / "data" / "musiques" app.config["MAX_CONTENT_LENGTH"] = 128 * 1024 * 1024 diff --git a/frontend/app/__pycache__/__init__.cpython-312.pyc b/frontend/app/__pycache__/__init__.cpython-312.pyc index aa90d0e..b6997d1 100644 Binary files a/frontend/app/__pycache__/__init__.cpython-312.pyc and b/frontend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/frontend/app/__pycache__/backend_client.cpython-312.pyc b/frontend/app/__pycache__/backend_client.cpython-312.pyc index 92e8170..9b896cb 100644 Binary files a/frontend/app/__pycache__/backend_client.cpython-312.pyc and b/frontend/app/__pycache__/backend_client.cpython-312.pyc differ diff --git a/frontend/app/__pycache__/routes.cpython-312.pyc b/frontend/app/__pycache__/routes.cpython-312.pyc index b4dc6ce..85b04cf 100644 Binary files a/frontend/app/__pycache__/routes.cpython-312.pyc and b/frontend/app/__pycache__/routes.cpython-312.pyc differ diff --git a/frontend/app/backend_client.py b/frontend/app/backend_client.py index 6d1c545..2b5e426 100644 --- a/frontend/app/backend_client.py +++ b/frontend/app/backend_client.py @@ -5,7 +5,9 @@ from pathlib import Path from typing import Any import requests +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class BackendApiError(RuntimeError): pass diff --git a/frontend/app/routes.py b/frontend/app/routes.py index c71f477..42ae1e1 100644 --- a/frontend/app/routes.py +++ b/frontend/app/routes.py @@ -8,6 +8,7 @@ from flask import ( Response, current_app, flash, + jsonify, redirect, render_template, request, @@ -139,6 +140,8 @@ def dashboard() -> str | Response: def save_trigger() -> Response: client_or_redirect = _ensure_login() if isinstance(client_or_redirect, Response): + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Non connecte"}), 401 return client_or_redirect client = client_or_redirect @@ -148,25 +151,32 @@ def save_trigger() -> Response: music_file = request.form.get("music_file", "").strip() start_raw = request.form.get("start_seconds", "0") end_raw = request.form.get("end_seconds", "") + volume_raw = request.form.get("volume", "80") - if not GPIO_PATTERN.match(trigger_type): - flash("Le type doit respecter le format GPIO.", "error") + def _err(msg: str) -> Response: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": msg}), 400 + flash(msg, "error") return redirect(url_for("ui.dashboard")) + if not GPIO_PATTERN.match(trigger_type): + return _err("Le type doit respecter le format GPIO.") + if not name or not music_file: - flash("Le nom et le fichier audio sont obligatoires.", "error") - return redirect(url_for("ui.dashboard")) + return _err("Le nom et le fichier audio sont obligatoires.") try: start_seconds = float(start_raw) end_seconds = _parse_optional_float(end_raw) + volume = int(volume_raw) except ValueError: - flash("Les temps de debut/fin doivent être numériques.", "error") - return redirect(url_for("ui.dashboard")) + return _err("Les temps de debut/fin et le volume doivent être numériques.") if start_seconds < 0 or (end_seconds is not None and end_seconds <= start_seconds): - flash("Fenêtre temporelle invalide.", "error") - return redirect(url_for("ui.dashboard")) + return _err("Fenêtre temporelle invalide.") + + if not (0 <= volume <= 100): + return _err("Le volume doit être compris entre 0 et 100.") payload = { "name": name, @@ -174,6 +184,7 @@ def save_trigger() -> Response: "music_file": music_file, "start_seconds": start_seconds, "end_seconds": end_seconds, + "volume": volume, } try: @@ -183,9 +194,19 @@ def save_trigger() -> Response: else: client.upsert_trigger(trigger_type, payload) except BackendApiError as exc: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": str(exc)}), 502 flash(f"Echec d'enregistrement du trigger: {exc}", "error") return redirect(url_for("ui.dashboard")) + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({ + "ok": True, + "message": f"Trigger {trigger_type} enregistre.", + "trigger_id": trigger_type, + "original_id": original_id or trigger_type, + "trigger": payload, + }) flash(f"Trigger {trigger_type} enregistré.", "success") return redirect(url_for("ui.dashboard")) @@ -194,18 +215,26 @@ def save_trigger() -> Response: def delete_trigger() -> Response: client_or_redirect = _ensure_login() if isinstance(client_or_redirect, Response): + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Non connecte"}), 401 return client_or_redirect client = client_or_redirect trigger_id = request.form.get("trigger_id", "").strip() if not trigger_id: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Identifiant du trigger manquant."}), 400 flash("Identifiant du trigger manquant.", "error") return redirect(url_for("ui.dashboard")) try: client.delete_trigger(trigger_id) + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": True, "message": f"Trigger {trigger_id} supprime.", "trigger_id": trigger_id}) flash(f"Trigger {trigger_id} supprime.", "success") except BackendApiError as exc: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": f"Echec de suppression: {exc}"}), 500 flash(f"Echec de suppression: {exc}", "error") return redirect(url_for("ui.dashboard")) @@ -214,18 +243,26 @@ def delete_trigger() -> Response: def play_trigger() -> Response: client_or_redirect = _ensure_login() if isinstance(client_or_redirect, Response): + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Non connecte"}), 401 return client_or_redirect client = client_or_redirect trigger_id = request.form.get("trigger_id", "").strip() if not trigger_id: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Identifiant du trigger manquant."}), 400 flash("Identifiant du trigger manquant.", "error") return redirect(url_for("ui.dashboard")) try: client.play_trigger(trigger_id) + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": True, "message": f"Trigger {trigger_id} demarre."}) flash(f"Trigger {trigger_id} demarré.", "success") except BackendApiError as exc: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": str(exc)}), 502 flash(f"Echec du lancement: {exc}", "error") return redirect(url_for("ui.dashboard")) @@ -234,13 +271,19 @@ def play_trigger() -> Response: def stop_audio() -> Response: client_or_redirect = _ensure_login() if isinstance(client_or_redirect, Response): + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": "Non connecte"}), 401 return client_or_redirect client = client_or_redirect try: client.stop_audio() + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": True, "message": "Audio arrete."}) flash("Audio arrete.", "info") except BackendApiError as exc: + if request.headers.get("X-Requested-With") == "fetch": + return jsonify({"ok": False, "error": str(exc)}), 502 flash(f"Echec de l'arrêt audio: {exc}", "error") return redirect(url_for("ui.dashboard")) diff --git a/frontend/app/static/css/style.css b/frontend/app/static/css/style.css index d100b1f..9a08007 100644 --- a/frontend/app/static/css/style.css +++ b/frontend/app/static/css/style.css @@ -293,6 +293,17 @@ button.small { min-width: 0; } +.volume-row { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.volume-row input[type="range"] { + width: 100%; + accent-color: var(--brand); +} + .table-wrap { overflow-x: auto; } diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html index f7cab96..15a0fdd 100644 --- a/frontend/app/templates/dashboard.html +++ b/frontend/app/templates/dashboard.html @@ -7,9 +7,7 @@

Backend: {{ backend_url }} | Utilisateur: {{ username }}

-
- -
+ Deconnexion
@@ -49,62 +47,73 @@ +

Liste des triggers

- {% if triggers %} -
- - - - - - - - - - +

Aucun trigger configure.

+
+
IDNomTypeAudioDebutFinActions
+ + + + + + + + + + + + + + {% for trigger_id, trigger in triggers.items() %} + + + + + + + + + - - - {% for trigger_id, trigger in triggers.items() %} - - - - - - - - - - {% endfor %} - -
IDNomTypeAudioDebutFinVolumeActions
{{ trigger_id }}{{ trigger.get('name', '') }}{{ trigger.get('type', '') }}{{ trigger.get('music_file', '') }}{{ trigger.get('start_seconds', 0) }}{{ trigger.get('end_seconds', '') }}{{ trigger.get('volume', 80) }} + + +
{{ trigger_id }}{{ trigger.get('name', '') }}{{ trigger.get('type', '') }}{{ trigger.get('music_file', '') }}{{ trigger.get('start_seconds', 0) }}{{ trigger.get('end_seconds', '') }} -
- - -
-
- - -
-
-
- {% else %} -

Aucun trigger configure.

- {% endif %} + {% endfor %} + + +
@@ -160,6 +169,17 @@ + +