From 2b9292381c8b522f3a59c8f0d173ca37d5ff343f Mon Sep 17 00:00:00 2001 From: scayac Date: Thu, 2 Apr 2026 17:58:01 +0200 Subject: [PATCH] feat: Enhance audio player and trigger management - Added volume control to the AudioPlayer class, allowing playback volume to be specified. - Updated trigger configuration to include volume settings in the JSON configuration. - Modified backend and frontend to handle volume settings during trigger creation and playback. - Improved frontend UI to include volume slider for triggers and updated dashboard to display volume levels. - Implemented initialization scripts for backend and frontend to create configuration files with default values. - Updated README files to reflect new configuration options and usage instructions. - Enhanced error handling and user feedback for trigger actions in the frontend. --- .gitignore | 3 +- backend/README_BACKEND.md | 9 + backend/app/audio_player.py | 4 +- backend/app/main.py | 2 + backend/app/models.py | 2 + backend/data/conf.json | 16 +- backend/init.py | 58 +++ frontend/README_FRONTEND.md | 41 +- frontend/app/__init__.py | 19 +- .../app/__pycache__/__init__.cpython-312.pyc | Bin 1196 -> 1777 bytes .../backend_client.cpython-312.pyc | Bin 5717 -> 5895 bytes .../app/__pycache__/routes.cpython-312.pyc | Bin 15620 -> 18833 bytes frontend/app/backend_client.py | 2 + frontend/app/routes.py | 59 ++- frontend/app/static/css/style.css | 11 + frontend/app/templates/dashboard.html | 413 +++++++++++++++--- frontend/app/templates/login.html | 2 +- frontend/init.py | 32 ++ frontend/systemd/pysonnerie-frontend.service | 1 - 19 files changed, 596 insertions(+), 78 deletions(-) create mode 100644 backend/init.py create mode 100644 frontend/init.py 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 aa90d0eb348e70bb6fa8d5777945ffd4dcee8bdf..b6997d18aa3491515d9b76851d77b6d5a89cb1c2 100644 GIT binary patch literal 1777 zcmZ`)O>7fK6rT02_s33%u~iI#x-Fz?76>jxq6i^MY8*-m2})9_Ne`>VJ4x28ciq`B z#HmxcQl+By0-{oKXz!`$fg`<@bES$^km8PlghZ*7TiS4_IQ7lii+d0J?6w_V<1IGtVpv`cqTdV ziaaZWu2}J}q*=vMEg7U*kLlVLFQ*GEX}YdQO=^3tKMuBZ#iR=$bbtV3~- z(3P?x$7x>{EcA=(pp2M`ejGy~^1(4shl zZV0+I&eS>LxfHt=lPr^(OIpcu=Z=+q&s~bw=-kjLn3js&l4sfOJWKevL@+h3;W|T0 z`>S<`b1|{;P*i5f!q*=F63%~cJH?#e#!*>>M^=*qNmHA6WJ^DY-<0!T7ZzO2DTRo{( za&S8i>I**&P5dw-jtrm&1KP;D;)A>ZyacB&i9;@g?kIq01<{ufr5&Ik6aixyQ4aKw zs2pjL_x}JFfx8?oZ$x&HF;*;08Oes%O6i&EEwl6PTJs5S^DzGCq`jn ze7ZP3ZComjUzq-Ii{B6|R;*_0Ny0CLsfOX26>J!UW1E|EwgVKi`n}t6z+3(dmXE!o>kkQvOM>aCUL1y^gNRT<%ytLW- z`l_?ud!#wqdgJY8;p?$4$8I(P>A;h>hC0?4fs||I4zJAwxs&(z1iANrIUM9Jb)26E z(*D*vr`p!TzC(dD@J#A|A}JeEe<1a*o_GYw$z9E{9q0+^QuspcS;_E4su7p$=8hOS z1|poPuCWA+u?9{)SH2~L1C>vbE;#npc6xk{{}+TA;b_Cx{}GWP9?xg*ymmqilDuVt!Xf|)AI3Np2ZDTS>HXmScWnB)MFFcsXKAQdTGFuo`^ zn9q~Ko5PvQRm;o>QYk*Un?+fi&+nG7TadqBh^wDVe6Xu?kZVZ1w`=6&i;S{@x3~}j zFG1d$e2_()?G{T)VoBoUyDU~JJVmTP(OVqx@p*~4sqyi*nDUEnv8AV$q~?{~VoT1? zOUq23tj?;)b&IVizqBN^xJYnvJgc1aEw;po!p0YcjW;-66gF${xiR?%Yp604 z&{?S^rA2vQZ)kGd;);(?D+LMwy;1~>V}>H($!%>WE diff --git a/frontend/app/__pycache__/backend_client.cpython-312.pyc b/frontend/app/__pycache__/backend_client.cpython-312.pyc index 92e81701721fe6c13786d834c2b5e5cabfbd8a77..9b896cbee8c6c4bba57644d57ee2b0049d44df3a 100644 GIT binary patch delta 393 zcmcbr)2_#RnwOW00SFX(&t<-t$ScXXZ=$+oeF{SgQw~QI2NOdoN0tys9u)vZMNmaR zyeu(HvDJ(aF-8U^26u)O))s~owp5BYCWQY(^E3raHc^NMeYdFB)8RFL^ YwJ%C)Uzc>cDCu;C!TAfzWK%H(0HQoz*#H0l delta 192 zcmZqIyQ;%`nwOW00SE%cw`5M8$ScX1HBsHth>5|SA%(StA%!iKV>L5Ko`E5XGleUK zy@e%;3oOe4l;vE_1eN2S`0ODg*X9$9Uzr&BHt%BzWnxs_EW$pQnNe%=CN33bM$gUH zd2<*UgEqVHOENNsO^y@z<5iPLP|CF>Z2}&|k)+$r-{Q8PhgLiMTK_s!X0Q8p+5r g`KPG)_sXWkhkWj( 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 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 @@ + +