commit 647a22cb2cd5b2f257c0fb79d87557bd91e65a66 Author: scayac Date: Sun Mar 22 20:37:35 2026 +0100 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b694934 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/arduino/.gitignore b/arduino/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/arduino/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/arduino/.vscode/extensions.json b/arduino/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/arduino/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/arduino/README_ARDUINO.md b/arduino/README_ARDUINO.md new file mode 100644 index 0000000..92bd69b --- /dev/null +++ b/arduino/README_ARDUINO.md @@ -0,0 +1,78 @@ +# Arduino Uno - pySonnerie + +Firmware Arduino Uno compatible avec le backend existant. + +## Projet PlatformIO + +Le dossier `arduino/` est un projet PlatformIO: + +- `platformio.ini` +- `src/main.cpp` +- `pysonnerie_esp32.ino` (version Arduino IDE conservee) + +Configuration par defaut dans `platformio.ini`: + +- carte: `uno` +- framework: `arduino` +- liaison serie: `115200` + +## Comportement + +- Ouvre la liaison serie a `115200` bauds. +- Sur impulsion detectee, envoie une ligne au format exact: + - `GPIO2` + - `GPIO4` +- Le backend lit ligne par ligne (`readline`) et utilise cette valeur comme `trigger_id`. + +## Fichier principal + +- `src/main.cpp` + +## Configuration rapide + +Dans `src/main.cpp`: + +- `MONITORED_PINS`: liste des GPIO surveillees +- `ACTIVE_LOW`: + - `true` si capteur/bouton actif a l'etat bas (montage avec pull-up) + - `false` si actif a l'etat haut +- `DEBOUNCE_MS`: anti-rebond software + +## Cablage typique (bouton) + +- GPIO configure en `INPUT_PULLUP` +- Bouton entre GPIO et GND +- Appui => front actif => envoi `GPIOX` + +## Cote backend + +Verifier dans `backend/data/conf.json`: + +- `serial.enabled: true` +- `serial.port`: port reel (`/dev/ttyUSB0`, `/dev/ttyACM0`, ...) +- `serial.baudrate: 115200` +- Presence des triggers correspondants (`GPIO23`, etc.) + +## Test rapide + +1. Depuis `esp32/`, compiler: + +```bash +pio run +``` + +2. Flasher l'Arduino Uno (adapter le port): + +```bash +pio run -t upload --upload-port /dev/ttyUSB0 +``` + +3. Ouvrir le moniteur serie (optionnel): + +```bash +pio device monitor -b 115200 -p /dev/ttyUSB0 +``` + +4. Demarrer le backend. +5. Appuyer sur le bouton. +6. Verifier les logs backend: reception `Serial trigger received: GPIOX`. diff --git a/arduino/platformio.ini b/arduino/platformio.ini new file mode 100644 index 0000000..4b782ad --- /dev/null +++ b/arduino/platformio.ini @@ -0,0 +1,5 @@ +[env:uno] +platform = atmelavr +board = uno +framework = arduino +monitor_speed = 115200 diff --git a/arduino/src/main.cpp b/arduino/src/main.cpp new file mode 100644 index 0000000..98618b4 --- /dev/null +++ b/arduino/src/main.cpp @@ -0,0 +1,74 @@ +/* + pySonnerie Arduino Uno trigger sender (PlatformIO) + + - Monitors one or more GPIO inputs + - Sends messages in the format "GPIOX" over serial at 115200 baud + - Compatible with backend/app/serial_listener.py +*/ + +#include + +static const uint32_t SERIAL_BAUDRATE = 115200; + +// INPUT_PULLUP: idle HIGH, active LOW (button wired to GND). +static const bool ACTIVE_LOW = true; +static const uint32_t DEBOUNCE_MS = 40; +static const uint16_t POLL_DELAY_MS = 2; + +// Trigger format sent to backend: "GPIO". +// Arduino Uno default uses D2 here. +static const uint8_t MONITORED_PINS[] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; +static const size_t PIN_COUNT = sizeof(MONITORED_PINS) / sizeof(MONITORED_PINS[0]); + +struct PinState { + bool lastRead; + bool stableState; + uint32_t lastChangeMs; +}; + +PinState states[PIN_COUNT]; + +static inline bool isActiveLevel(bool rawLevel) { + return ACTIVE_LOW ? (rawLevel == LOW) : (rawLevel == HIGH); +} + +void setup() { + Serial.begin(SERIAL_BAUDRATE); + + for (size_t i = 0; i < PIN_COUNT; ++i) { + pinMode(MONITORED_PINS[i], INPUT_PULLUP); + const bool level = digitalRead(MONITORED_PINS[i]); + states[i] = {level, level, millis()}; + } +} + +void loop() { + const uint32_t now = millis(); + + for (size_t i = 0; i < PIN_COUNT; ++i) { + const uint8_t pin = MONITORED_PINS[i]; + const bool currentRead = digitalRead(pin); + + if (currentRead != states[i].lastRead) { + states[i].lastRead = currentRead; + states[i].lastChangeMs = now; + } + + // Accept state change only if stable for debounce duration. + if ((now - states[i].lastChangeMs) >= DEBOUNCE_MS && currentRead != states[i].stableState) { + const bool previousStable = states[i].stableState; + states[i].stableState = currentRead; + + const bool wasActive = isActiveLevel(previousStable); + const bool isActive = isActiveLevel(states[i].stableState); + + // Trigger only on active edge. + if (!wasActive && isActive) { + Serial.print("GPIO"); + Serial.println(pin); + } + } + } + + delay(POLL_DELAY_MS); +} diff --git a/backend/README_BACKEND.md b/backend/README_BACKEND.md new file mode 100644 index 0000000..758f99b --- /dev/null +++ b/backend/README_BACKEND.md @@ -0,0 +1,123 @@ +# pySonnerie Backend + +Backend Python avec API REST HTTPS authentifiee pour piloter la lecture audio a partir de triggers serie (ESP32). + +## Fonctions implementees + +- API REST securisee par authentification HTTP Basic +- HTTPS avec generation auto d'un certificat autosigne si absent +- Gestion des triggers via `data/conf.json` +- Lecture audio par trigger serie (`GPIOX`) ou via API de forgage manuel +- Arret de la sortie audio via API + +## Arborescence + +```text +backend/ + app/ + data/ + conf.json + musiques/ + certs/ # cree au premier lancement + run.py + requirements.txt +``` + +## Prerequis + +- Python 3.11+ +- `ffplay` installe (paquet ffmpeg) +- acces au port serie (exemple: `/dev/ttyUSB0`) + +## Installation + +```bash +cd backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Configuration + +Le fichier `data/conf.json` contient: + +- `server.host`, `server.port`, `server.tls_cert`, `server.tls_key` +- `auth.username`, `auth.password` +- `serial.enabled`, `serial.port`, `serial.baudrate`, `serial.timeout` +- `triggers` + +Exemple d'entree trigger: + +```json +"GPIO23": { + "name": "Bouton entree", + "type": "GPIO23", + "music_file": "bell.mp3", + "start_seconds": 2.5, + "end_seconds": 10.0 +} +``` + +## Lancement + +```bash +cd backend +source .venv/bin/activate +python run.py +``` + +API dispo sur `https://:`. + +## Endpoints REST + +Tous sauf `/api/health` exigent auth Basic. + +- `GET /api/health` +- `GET /api/config` +- `GET /api/triggers` +- `PUT /api/triggers/{trigger_id}` +- `PATCH /api/triggers/{trigger_id}` +- `DELETE /api/triggers/{trigger_id}` +- `GET /api/play/{trigger_id}` +- `GET /api/stop` + +### Exemples cURL + +```bash +curl -k -u admin:change-me https://127.0.0.1:8443/api/triggers +``` + +```bash +curl -k -u admin:change-me \ + -H "Content-Type: application/json" \ + -X PUT https://127.0.0.1:8443/api/triggers/GPIO23 \ + -d '{ + "name": "Bouton entree", + "type": "GPIO23", + "music_file": "bell.mp3", + "start_seconds": 0, + "end_seconds": null + }' +``` + +```bash +curl -k -u admin:change-me \ + https://127.0.0.1:8443/api/play/GPIO23 +``` + +```bash +curl -k -u admin:change-me https://127.0.0.1:8443/api/stop +``` + +## Service Debian + +Le fichier `systemd/pysonnerie-backend.service` est fourni comme base. + +```bash +sudo cp backend/systemd/pysonnerie-backend.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now pysonnerie-backend +``` + +Adapte les chemins `WorkingDirectory` et `ExecStart` avant activation. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7e68c82 Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/audio_player.cpython-312.pyc b/backend/app/__pycache__/audio_player.cpython-312.pyc new file mode 100644 index 0000000..679c8c4 Binary files /dev/null and b/backend/app/__pycache__/audio_player.cpython-312.pyc differ diff --git a/backend/app/__pycache__/config_store.cpython-312.pyc b/backend/app/__pycache__/config_store.cpython-312.pyc new file mode 100644 index 0000000..a8ccb9f Binary files /dev/null and b/backend/app/__pycache__/config_store.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..9864359 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..4709331 Binary files /dev/null and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/security.cpython-312.pyc b/backend/app/__pycache__/security.cpython-312.pyc new file mode 100644 index 0000000..2790120 Binary files /dev/null and b/backend/app/__pycache__/security.cpython-312.pyc differ diff --git a/backend/app/__pycache__/serial_listener.cpython-312.pyc b/backend/app/__pycache__/serial_listener.cpython-312.pyc new file mode 100644 index 0000000..a1366b3 Binary files /dev/null and b/backend/app/__pycache__/serial_listener.cpython-312.pyc differ diff --git a/backend/app/audio_player.py b/backend/app/audio_player.py new file mode 100644 index 0000000..eb2eb57 --- /dev/null +++ b/backend/app/audio_player.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import subprocess +import threading +from pathlib import Path +from typing import Optional + + +class AudioPlayer: + def __init__(self, music_dir: Path): + self.music_dir = music_dir + self._lock = threading.RLock() + self._proc: Optional[subprocess.Popen] = None + + def _resolve_music_path(self, music_file: str) -> Path: + candidate = (self.music_dir / music_file).resolve() + if self.music_dir.resolve() not in candidate.parents: + raise ValueError("music_file path traversal is forbidden") + if not candidate.exists() or not candidate.is_file(): + 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: + with self._lock: + music_path = self._resolve_music_path(music_file) + self.stop() + + cmd = [ + "ffplay", + "-nodisp", + "-autoexit", + "-loglevel", + "error", + "-ss", + str(start_seconds), + ] + if end_seconds is not None: + duration = end_seconds - start_seconds + if duration <= 0: + raise ValueError("end_seconds must be greater than start_seconds") + cmd += ["-t", str(duration)] + cmd.append(str(music_path)) + self._proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def stop(self) -> None: + with self._lock: + if self._proc and self._proc.poll() is None: + self._proc.terminate() + try: + self._proc.wait(timeout=2) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc = None diff --git a/backend/app/config_store.py b/backend/app/config_store.py new file mode 100644 index 0000000..96ba59e --- /dev/null +++ b/backend/app/config_store.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +import threading +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + + +class ConfigStore: + def __init__(self, conf_path: Path): + self.conf_path = conf_path + self._lock = threading.RLock() + + def load(self) -> Dict[str, Any]: + with self._lock: + with self.conf_path.open("r", encoding="utf-8") as conf_file: + return json.load(conf_file) + + def save(self, config: Dict[str, Any]) -> None: + with self._lock: + with self.conf_path.open("w", encoding="utf-8") as conf_file: + json.dump(config, conf_file, indent=2) + conf_file.write("\n") + + def get_copy(self) -> Dict[str, Any]: + return deepcopy(self.load()) + + def upsert_trigger(self, trigger_id: str, trigger_payload: Dict[str, Any]) -> Dict[str, Any]: + config = self.load() + config.setdefault("triggers", {})[trigger_id] = trigger_payload + self.save(config) + return config["triggers"][trigger_id] + + def patch_trigger(self, trigger_id: str, trigger_payload: Dict[str, Any]) -> Dict[str, Any]: + config = self.load() + triggers = config.setdefault("triggers", {}) + if trigger_id not in triggers: + raise KeyError(trigger_id) + triggers[trigger_id].update(trigger_payload) + self.save(config) + return triggers[trigger_id] + + def delete_trigger(self, trigger_id: str) -> None: + config = self.load() + triggers = config.setdefault("triggers", {}) + if trigger_id not in triggers: + raise KeyError(trigger_id) + del triggers[trigger_id] + self.save(config) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0ad7631 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict + +from fastapi import Depends, FastAPI, HTTPException, status + +from .audio_player import AudioPlayer +from .config_store import ConfigStore +from .models import TriggerConfig, TriggerPatch +from .security import make_auth_dependency +from .serial_listener import SerialTriggerListener, TriggerLogInfo + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s - %(message)s") + +BASE_DIR = Path(__file__).resolve().parents[1] +DATA_DIR = BASE_DIR / "data" +MUSIC_DIR = DATA_DIR / "musiques" +CONF_PATH = DATA_DIR / "conf.json" + +config_store = ConfigStore(CONF_PATH) +audio_player = AudioPlayer(MUSIC_DIR) +serial_listener: SerialTriggerListener | None = None + +app = FastAPI(title="pySonnerie Backend", version="1.0.0") + + +def _current_config() -> Dict[str, object]: + return config_store.get_copy() + + +auth_required = make_auth_dependency(_current_config) + + +def _resolve_trigger(config: Dict[str, object], trigger_id: str) -> tuple[str, Dict[str, object]]: + triggers = config.get("triggers", {}) + if not isinstance(triggers, dict): + raise KeyError(trigger_id) + + trigger = triggers.get(trigger_id) + if isinstance(trigger, dict): + return trigger_id, trigger + + # Accept serial payload mapped to trigger "type" in config when key differs. + matches = [ + (key, item) + for key, item in triggers.items() + if isinstance(item, dict) and item.get("type") == trigger_id + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError(f"Ambiguous trigger mapping for {trigger_id}") + + raise KeyError(trigger_id) + + +def _play_from_trigger(trigger_id: str) -> Dict[str, object]: + config = config_store.get_copy() + _resolved_key, trigger = _resolve_trigger(config, trigger_id) + audio_player.play( + music_file=trigger["music_file"], + start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), + end_seconds=trigger.get("end_seconds"), + ) + return trigger + + +@app.on_event("startup") +def startup_event() -> None: + global serial_listener + + DATA_DIR.mkdir(parents=True, exist_ok=True) + MUSIC_DIR.mkdir(parents=True, exist_ok=True) + + if not CONF_PATH.exists(): + raise RuntimeError(f"Missing config file: {CONF_PATH}") + + serial_conf = _current_config().get("serial", {}) + serial_listener = SerialTriggerListener(serial_conf, _serial_callback) + serial_listener.start() + + +@app.on_event("shutdown") +def shutdown_event() -> None: + audio_player.stop() + if serial_listener: + serial_listener.stop() + + +def _serial_callback(raw_message: str) -> TriggerLogInfo | None: + trigger_id = raw_message.strip() + try: + config = config_store.get_copy() + trigger_key, trigger = _resolve_trigger(config, trigger_id) + audio_player.play( + music_file=trigger["music_file"], + start_seconds=float(trigger.get("start_seconds", 0.0) or 0.0), + end_seconds=trigger.get("end_seconds"), + ) + + info: TriggerLogInfo = {"key": trigger_key} + name = trigger.get("name") + if isinstance(name, str) and name.strip(): + info["name"] = name + return info + except Exception as exc: # noqa: BLE001 + logging.getLogger("pysonnerie.api").warning("Trigger %s failed: %s", trigger_id, exc) + return None + + +@app.get("/api/health") +def health_check() -> Dict[str, str]: + return {"status": "ok"} + + +@app.get("/api/config", dependencies=[Depends(auth_required)]) +def get_config() -> Dict[str, object]: + return config_store.get_copy() + + +@app.get("/api/triggers", dependencies=[Depends(auth_required)]) +def list_triggers() -> Dict[str, object]: + config = config_store.get_copy() + return config.get("triggers", {}) + + +@app.put("/api/triggers/{trigger_id}", dependencies=[Depends(auth_required)]) +def upsert_trigger(trigger_id: str, payload: TriggerConfig) -> Dict[str, object]: + if payload.type != trigger_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="trigger_id must match payload.type", + ) + return config_store.upsert_trigger(trigger_id, payload.model_dump()) + + +@app.patch("/api/triggers/{trigger_id}", dependencies=[Depends(auth_required)]) +def patch_trigger(trigger_id: str, payload: TriggerPatch) -> Dict[str, object]: + updates = {key: value for key, value in payload.model_dump().items() if value is not None} + if not updates: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update") + + existing = config_store.get_copy().get("triggers", {}).get(trigger_id) + if not existing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") + + candidate = {**existing, **updates} + TriggerConfig.model_validate(candidate) + + try: + return config_store.patch_trigger(trigger_id, updates) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") + + +@app.delete("/api/triggers/{trigger_id}", dependencies=[Depends(auth_required)]) +def delete_trigger(trigger_id: str) -> Dict[str, str]: + try: + config_store.delete_trigger(trigger_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") + return {"status": "deleted"} + + +@app.get("/api/play/{trigger_id}", dependencies=[Depends(auth_required)]) +def force_play(trigger_id: str) -> Dict[str, str]: + try: + _play_from_trigger(trigger_id) + except KeyError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown trigger") + return {"status": "playing"} + + +@app.get("/api/stop", dependencies=[Depends(auth_required)]) +def stop_audio() -> Dict[str, str]: + audio_player.stop() + return {"status": "stopped"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..71cb61c --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + + +class TriggerConfig(BaseModel): + name: str = Field(..., min_length=1, max_length=80) + type: str = Field(..., pattern=r"^GPIO\d+$") + 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) + + @field_validator("end_seconds") + @classmethod + def validate_time_window(cls, end_seconds: Optional[float], info): + start_seconds = info.data.get("start_seconds", 0.0) + if end_seconds is not None and end_seconds <= start_seconds: + raise ValueError("end_seconds must be greater than start_seconds") + return end_seconds + + +class TriggerPatch(BaseModel): + name: Optional[str] = Field(default=None, min_length=1, max_length=80) + type: Optional[str] = Field(default=None, pattern=r"^GPIO\d+$") + 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) + + +class ForcePlayRequest(BaseModel): + trigger_id: Optional[str] = Field(default=None, min_length=1, max_length=80) + music_file: Optional[str] = Field(default=None, min_length=1, max_length=255) + start_seconds: float = Field(default=0.0, ge=0) + end_seconds: Optional[float] = Field(default=None, ge=0) + + @field_validator("end_seconds") + @classmethod + def validate_end_seconds(cls, end_seconds: Optional[float], info): + start_seconds = info.data.get("start_seconds", 0.0) + if end_seconds is not None and end_seconds <= start_seconds: + raise ValueError("end_seconds must be greater than start_seconds") + return end_seconds + + @field_validator("music_file") + @classmethod + def validate_play_source(cls, music_file: Optional[str], info): + trigger_id = info.data.get("trigger_id") + if not music_file and not trigger_id: + raise ValueError("trigger_id or music_file must be provided") + return music_file diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..de8580b --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import secrets +from pathlib import Path +from typing import Dict + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + + +security = HTTPBasic() + + +def verify_basic_auth(config: Dict[str, object], credentials: HTTPBasicCredentials) -> None: + auth_conf = config.get("auth", {}) + expected_username = str(auth_conf.get("username", "")) + expected_password = str(auth_conf.get("password", "")) + + user_ok = secrets.compare_digest(credentials.username, expected_username) + pass_ok = secrets.compare_digest(credentials.password, expected_password) + if not (user_ok and pass_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + + +def ensure_tls_certificate(cert_path: Path, key_path: Path) -> None: + if cert_path.exists() and key_path.exists(): + return + + cert_path.parent.mkdir(parents=True, exist_ok=True) + + import subprocess + + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-nodes", + "-newkey", + "rsa:2048", + "-keyout", + str(key_path), + "-out", + str(cert_path), + "-days", + "3650", + "-subj", + "/CN=pysonnerie.local", + ], + check=True, + ) + + +def make_auth_dependency(get_config): + def auth_dependency(credentials: HTTPBasicCredentials = Depends(security)) -> None: + verify_basic_auth(get_config(), credentials) + + return auth_dependency diff --git a/backend/app/serial_listener.py b/backend/app/serial_listener.py new file mode 100644 index 0000000..b3cf235 --- /dev/null +++ b/backend/app/serial_listener.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +import threading +from typing import Callable, Dict, Optional, TypedDict + +import serial + + +class TriggerLogInfo(TypedDict, total=False): + key: str + name: str + + +class SerialTriggerListener: + def __init__(self, serial_conf: Dict[str, object], on_trigger: Callable[[str], Optional[TriggerLogInfo]]): + self.serial_conf = serial_conf + self.on_trigger = on_trigger + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._logger = logging.getLogger("pysonnerie.serial") + + def start(self) -> None: + enabled = bool(self.serial_conf.get("enabled", False)) + if not enabled: + self._logger.info("Serial listener disabled in conf.json") + return + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2) + + def _run(self) -> None: + port = str(self.serial_conf.get("port", "")) + baudrate = int(self.serial_conf.get("baudrate", 115200)) + timeout = float(self.serial_conf.get("timeout", 1)) + + if not port: + self._logger.error("Serial port is not configured") + return + + self._logger.info("Starting serial listener on %s @ %s", port, baudrate) + while not self._stop_event.is_set(): + try: + with serial.Serial(port=port, baudrate=baudrate, timeout=timeout) as serial_conn: + while not self._stop_event.is_set(): + line = serial_conn.readline().decode("utf-8", errors="ignore").strip() + if not line: + continue + trigger_info = self.on_trigger(line) + if trigger_info and trigger_info.get("key") and trigger_info.get("name"): + self._logger.info( + "Serial trigger received: %s -> %s (%s)", + line, + trigger_info["key"], + trigger_info["name"], + ) + elif trigger_info and trigger_info.get("key"): + self._logger.info("Serial trigger received: %s -> %s", line, trigger_info["key"]) + elif trigger_info and trigger_info.get("name"): + self._logger.info("Serial trigger received: %s (%s)", line, trigger_info["name"]) + else: + self._logger.info("Serial trigger received: %s", line) + except serial.SerialException as exc: + self._logger.warning("Serial connection error: %s", exc) + self._stop_event.wait(2) diff --git a/backend/certs/cert.pem b/backend/certs/cert.pem new file mode 100644 index 0000000..2f25353 --- /dev/null +++ b/backend/certs/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUJZwx9bBXDLshmblwF+YZeuDvepwwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQcHlzb25uZXJpZS5sb2NhbDAeFw0yNjAzMTMxMzA4NDZa +Fw0zNjAzMTAxMzA4NDZaMBsxGTAXBgNVBAMMEHB5c29ubmVyaWUubG9jYWwwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2LJ8pv7BviwugobyGmYKfYSH5 +OpAfvT7uPJeulgFuOzdasqpgwjSrCmNAI4MWqC+/kQ7y0wQeMLaqolz7qBCHifek +RYeyGnkgPR7OFy4rL8ddqtJW5kKK7whLWlt9t9Ho19kYtVhnWINTjSC5XsQXm6Md +Hv5KDBqwXLKbMmVGGaXm5Mjec0mhFsHKWOtGjSpU21Ilpm80e1mlyaGNTudnLOxV +a/vXR1Erw09vf5UaTiuM5AX2ULBAqMTsJOWVT6Or9dMNfRU5fbYpqICsr/vL4kLW +59GWgDYHjmybc2ts0glkVTKjFBqxwqYUM0EaZQVOqRnSwfCp07z91RU/IDypAgMB +AAGjUzBRMB0GA1UdDgQWBBRoSIVmchSDOmgp+/9XlgI7ewaetDAfBgNVHSMEGDAW +gBRoSIVmchSDOmgp+/9XlgI7ewaetDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQAqVh8AXwxmSayxKLc9atY9BgPydMS4IEaryQAHLiQCUbsMWiRb +H+Qe/toU8C7XnL969XvoqHNAnexpwgIk+Sma3LyFPm4AIX9VVdxNWRK5d34q+Ue2 +i+OyMaA6p95Z8cVIJw+z3zWliEWu46m0o1Dxcop715xftxCW98yl/8lpC4lcpXRE +yJjBApJ5owvrHrjszxxE4IkSzNaI7cjDHZZM8PFfoxfIJW12AOH7wCxC+IhNzlWA +kijoTOdEvuopcKK2oHpnr+n8kCFTACrtiYXT2UNyiJhCy/if0BqmLd7JuMw+6Sm0 +SHpAQ8h+Y13lBn4wJBPFXdnFiNrV9UXilZEv +-----END CERTIFICATE----- diff --git a/backend/certs/key.pem b/backend/certs/key.pem new file mode 100644 index 0000000..8e50705 --- /dev/null +++ b/backend/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC2LJ8pv7Bviwug +obyGmYKfYSH5OpAfvT7uPJeulgFuOzdasqpgwjSrCmNAI4MWqC+/kQ7y0wQeMLaq +olz7qBCHifekRYeyGnkgPR7OFy4rL8ddqtJW5kKK7whLWlt9t9Ho19kYtVhnWINT +jSC5XsQXm6MdHv5KDBqwXLKbMmVGGaXm5Mjec0mhFsHKWOtGjSpU21Ilpm80e1ml +yaGNTudnLOxVa/vXR1Erw09vf5UaTiuM5AX2ULBAqMTsJOWVT6Or9dMNfRU5fbYp +qICsr/vL4kLW59GWgDYHjmybc2ts0glkVTKjFBqxwqYUM0EaZQVOqRnSwfCp07z9 +1RU/IDypAgMBAAECggEAI7Ms4baR8dnyxmDLnCj7Ixaa3MdwNPgWHAO9ydXVo+9w +0pJOtzV3sskPWCNdbhQ5fzYDEuztUqlDMr8sr5ho3NbZs9R4Whu7/uhbYBHE12u+ +iwmNvL/U7HsZSgMECOf798BM5/2pvF7pJXdzoUAD94hld7B6LrKejE8uJSHEHO9W +DX6K1mEs2R2+ytwswRR4JzIcF73p1VlYX5RtHBe2aZ1FjHJIO3uuIWei6eBUY1SW +AO+hBtKlMplhsxXXzdWCL8st2WYuWrvDZuG/ejBhlqeEDDdrjaioh2Sba5Y5L7gT +wCUwzsa4YbXtxK3xxl5lcM9BZbn/wYTvAPyHAoEYrQKBgQD9XLbw4nIDGtijvPdr +GKnI1Enk5/NthD+fvSxrjuXHOyVyJsuZ0F3PjA863U/GK2mWCXCkT77L2qZm5kf4 +HVef1pbMQWgD4U7ZVMXVk1vylIZDc+u/m3zN/ivTStpeqEAjOM1507JJe/penAZk +qINPCICw3ETTqMvX1wZvNhiOOwKBgQC4EiuaxYcZ8sQmIg1Ux6rJTNtF2zXBf7Fb +SbtbGUivPqxCrJtOcAA3wBRPiu0ghp+rOubasesXlOZvZSwGLA68/EXwHy65+stf +x14qkmyeLNUoE0h1TiZYY2FOlP7FzrDOTkZNeKmxTwLpoeu1/N8QkQ1liOOUVTG+ +e6vnxhe+awKBgBvPw50pnk5M8h73LUmqSWjsNLhV5djNvZYxU+DyrLJ0AaZIL+1Q +fBu+SiWyCYG9XjfEDYNb5ZvHAqElAh1wSyAWlDMTsvFKGDevIJBTPrKgLyTUYrqD +vO13yyPEgbgGTBQRtix7WoTKgS9FfUYrYU6ZplbDtyJs0wN8bQ5kJ8nlAoGATl3+ +DpDWaie/dvS8dEHl5npASMeBZXZ2DgWgLLLhDStDr8dI/+YTUakHfK4LMvPd+srD +Co0BKDMOwJJ0YdRUGgXVyNudyzSJbae14a4hbF5uCffbu4WgMbVt8kThC1pqUAtn +Rwh+Rqz68nkrn3mfhrAa4gWbzsVOvmhs0eq2pS0CgYAQYlA3KUA6MdysNrHoWReC +X5PqO4zDLXN7Jq+ulgr7DdcUO14L/VAoQgQWuUNF6tl+3GMuWZtbDiPtD1KDIk9t +s8nMwYt3W+59kvLylOG9+uLxGVm++o2crOqdWn4+a+tjZWQv3me7lcSkAwkmcKcI +yVLRBS6n8wzxe0SMwxyFCQ== +-----END PRIVATE KEY----- diff --git a/backend/data/conf.json b/backend/data/conf.json new file mode 100644 index 0000000..a09bb4f --- /dev/null +++ b/backend/data/conf.json @@ -0,0 +1,34 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8443, + "tls_cert": "certs/cert.pem", + "tls_key": "certs/key.pem" + }, + "auth": { + "username": "admin", + "password": "admin" + }, + "serial": { + "enabled": true, + "port": "/dev/ttyACM0", + "baudrate": 115200, + "timeout": 1 + }, + "triggers": { + "GPIO3": { + "name": "Test GPIO 23", + "type": "GPIO3", + "music_file": "SONNERIE A.mp3", + "start_seconds": 3.0, + "end_seconds": 10.0 + }, + "GPIO4": { + "name": "Test 2", + "type": "GPIO4", + "music_file": "SONNERIE D.mp3", + "start_seconds": 0.0, + "end_seconds": null + } + } +} diff --git a/backend/data/musiques/.gitkeep b/backend/data/musiques/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/musiques/SONNERIE A.mp3 b/backend/data/musiques/SONNERIE A.mp3 new file mode 100644 index 0000000..81d51d5 Binary files /dev/null and b/backend/data/musiques/SONNERIE A.mp3 differ diff --git a/backend/data/musiques/SONNERIE B.mp3 b/backend/data/musiques/SONNERIE B.mp3 new file mode 100644 index 0000000..93b37fc Binary files /dev/null and b/backend/data/musiques/SONNERIE B.mp3 differ diff --git a/backend/data/musiques/SONNERIE C.mp3 b/backend/data/musiques/SONNERIE C.mp3 new file mode 100644 index 0000000..fb4b9e4 Binary files /dev/null and b/backend/data/musiques/SONNERIE C.mp3 differ diff --git a/backend/data/musiques/SONNERIE D.mp3 b/backend/data/musiques/SONNERIE D.mp3 new file mode 100644 index 0000000..5e2a7d1 Binary files /dev/null and b/backend/data/musiques/SONNERIE D.mp3 differ diff --git a/backend/data/musiques/SONNERIE E.mp3 b/backend/data/musiques/SONNERIE E.mp3 new file mode 100644 index 0000000..aa1bac4 Binary files /dev/null and b/backend/data/musiques/SONNERIE E.mp3 differ diff --git a/backend/data/musiques/SONNERIE F.mp3 b/backend/data/musiques/SONNERIE F.mp3 new file mode 100644 index 0000000..8c5dad9 Binary files /dev/null and b/backend/data/musiques/SONNERIE F.mp3 differ diff --git a/backend/data/musiques/SONNERIE G.mp3 b/backend/data/musiques/SONNERIE G.mp3 new file mode 100644 index 0000000..c84a8db Binary files /dev/null and b/backend/data/musiques/SONNERIE G.mp3 differ diff --git a/backend/data/musiques/SONNERIE H.mp3 b/backend/data/musiques/SONNERIE H.mp3 new file mode 100644 index 0000000..b439d19 Binary files /dev/null and b/backend/data/musiques/SONNERIE H.mp3 differ diff --git a/backend/data/musiques/SONNERIE I.mp3 b/backend/data/musiques/SONNERIE I.mp3 new file mode 100644 index 0000000..2abde1c Binary files /dev/null and b/backend/data/musiques/SONNERIE I.mp3 differ diff --git a/backend/data/musiques/SONNERIE J.mp3 b/backend/data/musiques/SONNERIE J.mp3 new file mode 100644 index 0000000..e1a9639 Binary files /dev/null and b/backend/data/musiques/SONNERIE J.mp3 differ diff --git a/backend/data/musiques/SONNERIE K.mp3 b/backend/data/musiques/SONNERIE K.mp3 new file mode 100644 index 0000000..884fa5a Binary files /dev/null and b/backend/data/musiques/SONNERIE K.mp3 differ diff --git a/backend/data/musiques/SONNERIE L.mp3 b/backend/data/musiques/SONNERIE L.mp3 new file mode 100644 index 0000000..ce14663 Binary files /dev/null and b/backend/data/musiques/SONNERIE L.mp3 differ diff --git a/backend/data/musiques/SONNERIE M.mp3 b/backend/data/musiques/SONNERIE M.mp3 new file mode 100644 index 0000000..8b3940b Binary files /dev/null and b/backend/data/musiques/SONNERIE M.mp3 differ diff --git a/backend/data/musiques/SONNERIE N.mp3 b/backend/data/musiques/SONNERIE N.mp3 new file mode 100644 index 0000000..93ae762 Binary files /dev/null and b/backend/data/musiques/SONNERIE N.mp3 differ diff --git a/backend/data/musiques/SONNERIE O.mp3 b/backend/data/musiques/SONNERIE O.mp3 new file mode 100644 index 0000000..5665974 Binary files /dev/null and b/backend/data/musiques/SONNERIE O.mp3 differ diff --git a/backend/data/musiques/SONNERIE P.mp3 b/backend/data/musiques/SONNERIE P.mp3 new file mode 100644 index 0000000..c0f483e Binary files /dev/null and b/backend/data/musiques/SONNERIE P.mp3 differ diff --git a/backend/data/musiques/SONNERIE_P.mp3 b/backend/data/musiques/SONNERIE_P.mp3 new file mode 100644 index 0000000..c0f483e Binary files /dev/null and b/backend/data/musiques/SONNERIE_P.mp3 differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..67e9238 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pyserial==3.5 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..25e5ca5 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +import uvicorn + +from app.main import CONF_PATH +from app.security import ensure_tls_certificate + + +def main() -> None: + import json + + with CONF_PATH.open("r", encoding="utf-8") as conf_file: + conf = json.load(conf_file) + + server_conf = conf.get("server", {}) + host = server_conf.get("host", "0.0.0.0") + port = int(server_conf.get("port", 8443)) + + cert_path = (Path(__file__).resolve().parent / server_conf.get("tls_cert", "certs/cert.pem")).resolve() + key_path = (Path(__file__).resolve().parent / server_conf.get("tls_key", "certs/key.pem")).resolve() + + ensure_tls_certificate(cert_path, key_path) + + uvicorn.run( + "app.main:app", + host=host, + port=port, + ssl_certfile=str(cert_path), + ssl_keyfile=str(key_path), + ) + + +if __name__ == "__main__": + main() diff --git a/backend/systemd/pysonnerie-backend.service b/backend/systemd/pysonnerie-backend.service new file mode 100644 index 0000000..dbb8329 --- /dev/null +++ b/backend/systemd/pysonnerie-backend.service @@ -0,0 +1,14 @@ +[Unit] +Description=pySonnerie Backend API +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/pySonnerie/backend +ExecStart=/opt/pySonnerie/backend/.venv/bin/python /opt/pySonnerie/backend/run.py +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/frontend/README_FRONTEND.md b/frontend/README_FRONTEND.md new file mode 100644 index 0000000..a8930eb --- /dev/null +++ b/frontend/README_FRONTEND.md @@ -0,0 +1,36 @@ +# pySonnerie Frontend (Flask) + +Frontend web responsive en Flask pour piloter le backend pySonnerie deja en place. + +## Fonctions + +- Page de connexion (URL backend + identifiants Basic Auth) +- Tableau de bord de gestion des triggers (creation/modification/suppression) +- Lancement manuel d'un trigger (`/api/play/{trigger_id}`) +- Arret audio (`/api/stop`) +- Gestion du stockage audio dans `backend/data/musiques` (televersement, telechargement, suppression) + +## Installation + +```bash +cd frontend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Execution + +```bash +cd frontend +source .venv/bin/activate +python run.py +``` + +Application dispo sur `http://127.0.0.1:5000`. + +## Notes + +- Le frontend appelle le backend en HTTPS avec certificat autosigne (`verify=False`). +- Les fichiers audio sont manipules localement dans `backend/data/musiques`. +- Formats audio acceptes: `.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`. diff --git a/frontend/app/__init__.py b/frontend/app/__init__.py new file mode 100644 index 0000000..98a12bf --- /dev/null +++ b/frontend/app/__init__.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from flask import Flask + + +def create_app() -> Flask: + app = Flask(__name__) + + secret = os.getenv("FRONTEND_SECRET_KEY", "pysonnerie-frontend-dev-key") + 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 + + from .routes import ui + + app.register_blueprint(ui) + return app diff --git a/frontend/app/__pycache__/__init__.cpython-312.pyc b/frontend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..aa90d0e Binary files /dev/null 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 new file mode 100644 index 0000000..92e8170 Binary files /dev/null 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 new file mode 100644 index 0000000..b4dc6ce Binary files /dev/null and b/frontend/app/__pycache__/routes.cpython-312.pyc differ diff --git a/frontend/app/backend_client.py b/frontend/app/backend_client.py new file mode 100644 index 0000000..6d1c545 --- /dev/null +++ b/frontend/app/backend_client.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import requests + + +class BackendApiError(RuntimeError): + pass + + +def read_default_backend_url(project_root: Path) -> str: + conf_path = project_root / "backend" / "data" / "conf.json" + default_url = "https://127.0.0.1:8443" + + if not conf_path.exists(): + return default_url + + try: + conf = json.loads(conf_path.read_text(encoding="utf-8")) + except Exception: + return default_url + + server = conf.get("server", {}) if isinstance(conf, dict) else {} + host = str(server.get("host", "127.0.0.1")) + port = int(server.get("port", 8443)) + + if host in {"0.0.0.0", "::"}: + host = "127.0.0.1" + + return f"https://{host}:{port}" + + +class BackendClient: + def __init__(self, base_url: str, username: str, password: str) -> None: + self.base_url = base_url.rstrip("/") + self.auth = (username, password) + self.timeout = 8 + + def _request(self, method: str, path: str, json_payload: dict[str, Any] | None = None) -> Any: + url = f"{self.base_url}{path}" + try: + response = requests.request( + method=method, + url=url, + auth=self.auth, + json=json_payload, + timeout=self.timeout, + verify=False, + ) + except requests.RequestException as exc: + raise BackendApiError(f"Backend inaccessible: {exc}") from exc + + if response.status_code >= 400: + detail = response.text + try: + payload = response.json() + detail = payload.get("detail", detail) + except Exception: + pass + raise BackendApiError(f"Erreur HTTP {response.status_code}: {detail}") + + if not response.content: + return {} + + try: + return response.json() + except ValueError: + return {} + + def health(self) -> dict[str, Any]: + url = f"{self.base_url}/api/health" + try: + response = requests.get(url, timeout=self.timeout, verify=False) + response.raise_for_status() + return response.json() + except requests.RequestException as exc: + raise BackendApiError(f"Echec du controle de sante: {exc}") from exc + + def list_triggers(self) -> dict[str, Any]: + data = self._request("GET", "/api/triggers") + return data if isinstance(data, dict) else {} + + def upsert_trigger(self, trigger_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = self._request("PUT", f"/api/triggers/{trigger_id}", payload) + return data if isinstance(data, dict) else {} + + def patch_trigger(self, trigger_id: str, payload: dict[str, Any]) -> dict[str, Any]: + data = self._request("PATCH", f"/api/triggers/{trigger_id}", payload) + return data if isinstance(data, dict) else {} + + def delete_trigger(self, trigger_id: str) -> dict[str, Any]: + data = self._request("DELETE", f"/api/triggers/{trigger_id}") + return data if isinstance(data, dict) else {} + + def play_trigger(self, trigger_id: str) -> dict[str, Any]: + data = self._request("GET", f"/api/play/{trigger_id}") + return data if isinstance(data, dict) else {} + + def stop_audio(self) -> dict[str, Any]: + data = self._request("GET", "/api/stop") + return data if isinstance(data, dict) else {} diff --git a/frontend/app/routes.py b/frontend/app/routes.py new file mode 100644 index 0000000..8543464 --- /dev/null +++ b/frontend/app/routes.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from flask import ( + Blueprint, + Response, + current_app, + flash, + redirect, + render_template, + request, + send_file, + session, + url_for, +) +from werkzeug.utils import secure_filename + +from .backend_client import BackendApiError, BackendClient, read_default_backend_url + + +ui = Blueprint("ui", __name__, template_folder="templates", static_folder="static") + +GPIO_PATTERN = re.compile(r"^GPIO\d+$") +ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a"} + + +def _backend_client() -> BackendClient | None: + backend_url = session.get("backend_url") + username = session.get("backend_username") + password = session.get("backend_password") + if not backend_url or not username or not password: + return None + return BackendClient(str(backend_url), str(username), str(password)) + + +def _ensure_login() -> BackendClient | Response: + client = _backend_client() + if client is None: + flash("Veuillez vous connecter d'abord.", "error") + return redirect(url_for("ui.login")) + return client + + +def _music_dir() -> Path: + music_dir = current_app.config["MUSIC_DIR"] + music_dir.mkdir(parents=True, exist_ok=True) + return music_dir + + +def _list_audio_files() -> list[str]: + files: list[str] = [] + for item in _music_dir().iterdir(): + if item.is_file() and item.suffix.lower() in ALLOWED_AUDIO_EXTENSIONS: + files.append(item.name) + return sorted(files, key=str.lower) + + +def _parse_optional_float(value: str) -> float | None: + clean = value.strip() + if clean == "": + return None + return float(clean) + + +@ui.get("/") +def home() -> Response: + if _backend_client() is None: + return redirect(url_for("ui.login")) + return redirect(url_for("ui.dashboard")) + + +@ui.route("/login", methods=["GET", "POST"]) +def login() -> str | Response: + project_root: Path = current_app.config["PROJECT_ROOT"] + default_url = read_default_backend_url(project_root) + + if request.method == "POST": + backend_url = default_url + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + session["backend_url"] = backend_url + session["backend_username"] = username + session["backend_password"] = password + + client = _backend_client() + assert client is not None + try: + client.health() + client.list_triggers() + except BackendApiError: + return render_template( + "login.html", + login_error="Impossible de se connecter au service pour le moment. Verifiez que le backend est demarre puis reessayez.", + attempted_username=username, + ) + + flash("Connexion au backend reussie.", "success") + return redirect(url_for("ui.dashboard")) + + return render_template("login.html", login_error=None, attempted_username="") + + +@ui.get("/logout") +def logout() -> Response: + session.clear() + flash("Session fermee.", "info") + return redirect(url_for("ui.login")) + + +@ui.get("/dashboard") +def dashboard() -> str | Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + client = client_or_redirect + triggers: dict[str, dict] = {} + try: + raw = client.list_triggers() + triggers = {k: v for k, v in raw.items() if isinstance(v, dict)} + except BackendApiError as exc: + flash(f"Impossible de charger les triggers: {exc}", "error") + + audio_files = _list_audio_files() + + return render_template( + "dashboard.html", + triggers=triggers, + audio_files=audio_files, + backend_url=session.get("backend_url", ""), + username=session.get("backend_username", ""), + ) + + +@ui.post("/trigger/save") +def save_trigger() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + client = client_or_redirect + + original_id = request.form.get("original_id", "").strip() + trigger_type = request.form.get("type", "").strip() + name = request.form.get("name", "").strip() + music_file = request.form.get("music_file", "").strip() + start_raw = request.form.get("start_seconds", "0") + end_raw = request.form.get("end_seconds", "") + + if not GPIO_PATTERN.match(trigger_type): + flash("Le type doit respecter le format GPIO.", "error") + return redirect(url_for("ui.dashboard")) + + if not name or not music_file: + flash("Le nom et le fichier audio sont obligatoires.", "error") + return redirect(url_for("ui.dashboard")) + + try: + start_seconds = float(start_raw) + end_seconds = _parse_optional_float(end_raw) + except ValueError: + flash("Les temps de debut/fin doivent etre numeriques.", "error") + return redirect(url_for("ui.dashboard")) + + if start_seconds < 0 or (end_seconds is not None and end_seconds <= start_seconds): + flash("Fenetre temporelle invalide.", "error") + return redirect(url_for("ui.dashboard")) + + payload = { + "name": name, + "type": trigger_type, + "music_file": music_file, + "start_seconds": start_seconds, + "end_seconds": end_seconds, + } + + try: + if original_id and original_id != trigger_type: + client.upsert_trigger(trigger_type, payload) + client.delete_trigger(original_id) + else: + client.upsert_trigger(trigger_type, payload) + except BackendApiError as exc: + flash(f"Echec d'enregistrement du trigger: {exc}", "error") + return redirect(url_for("ui.dashboard")) + + flash(f"Trigger {trigger_type} enregistre.", "success") + return redirect(url_for("ui.dashboard")) + + +@ui.post("/trigger/delete") +def delete_trigger() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + client = client_or_redirect + + trigger_id = request.form.get("trigger_id", "").strip() + if not trigger_id: + flash("Identifiant du trigger manquant.", "error") + return redirect(url_for("ui.dashboard")) + + try: + client.delete_trigger(trigger_id) + flash(f"Trigger {trigger_id} supprime.", "success") + except BackendApiError as exc: + flash(f"Echec de suppression: {exc}", "error") + return redirect(url_for("ui.dashboard")) + + +@ui.post("/trigger/play") +def play_trigger() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + client = client_or_redirect + + trigger_id = request.form.get("trigger_id", "").strip() + if not trigger_id: + flash("Identifiant du trigger manquant.", "error") + return redirect(url_for("ui.dashboard")) + + try: + client.play_trigger(trigger_id) + flash(f"Trigger {trigger_id} demarre.", "success") + except BackendApiError as exc: + flash(f"Echec du lancement: {exc}", "error") + return redirect(url_for("ui.dashboard")) + + +@ui.post("/audio/stop") +def stop_audio() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + client = client_or_redirect + + try: + client.stop_audio() + flash("Audio arrete.", "info") + except BackendApiError as exc: + flash(f"Echec de l'arret audio: {exc}", "error") + return redirect(url_for("ui.dashboard")) + + +@ui.post("/audio/upload") +def upload_audio() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + audio = request.files.get("audio_file") + if audio is None or audio.filename is None or audio.filename.strip() == "": + flash("Selectionnez d'abord un fichier.", "error") + return redirect(url_for("ui.dashboard")) + + filename = secure_filename(audio.filename) + if filename == "": + flash("Nom de fichier invalide.", "error") + return redirect(url_for("ui.dashboard")) + + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_AUDIO_EXTENSIONS: + flash("Format audio non supporte.", "error") + return redirect(url_for("ui.dashboard")) + + existing_names = {item.name.lower() for item in _music_dir().iterdir() if item.is_file()} + if filename.lower() in existing_names: + flash("Un fichier avec ce nom existe deja.", "error") + return redirect(url_for("ui.dashboard")) + + destination = _music_dir() / filename + audio.save(destination) + flash(f"Fichier {filename} televerse.", "success") + return redirect(url_for("ui.dashboard")) + + +@ui.post("/audio/delete") +def delete_audio() -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + filename = request.form.get("filename", "").strip() + if not filename: + flash("Nom de fichier manquant.", "error") + return redirect(url_for("ui.dashboard")) + + target = _music_dir() / filename + if not target.exists() or not target.is_file(): + flash("Fichier introuvable.", "error") + return redirect(url_for("ui.dashboard")) + + target.unlink() + flash(f"Fichier {filename} supprime.", "success") + return redirect(url_for("ui.dashboard")) + + +@ui.get("/audio/download/") +def download_audio(filename: str) -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + safe_name = Path(filename).name + target = _music_dir() / safe_name + if not target.exists() or not target.is_file(): + flash("Fichier introuvable.", "error") + return redirect(url_for("ui.dashboard")) + return send_file(target, as_attachment=True) + + +@ui.get("/audio/stream/") +def stream_audio(filename: str) -> Response: + client_or_redirect = _ensure_login() + if isinstance(client_or_redirect, Response): + return client_or_redirect + + safe_name = Path(filename).name + target = _music_dir() / safe_name + if not target.exists() or not target.is_file(): + flash("Fichier introuvable.", "error") + return redirect(url_for("ui.dashboard")) + return send_file(target, as_attachment=False) diff --git a/frontend/app/static/css/style.css b/frontend/app/static/css/style.css new file mode 100644 index 0000000..2b1b1ea --- /dev/null +++ b/frontend/app/static/css/style.css @@ -0,0 +1,444 @@ +:root { + --bg: #f8f6ef; + --ink: #1f1c1a; + --panel: #fffef9; + --brand: #0f6f63; + --brand-strong: #0b544b; + --accent: #d66d2d; + --danger: #b13a2e; + --line: #d8d2c3; + --muted: #665e57; + --shadow: 0 12px 40px rgba(45, 37, 23, 0.12); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + min-height: 100%; +} + +body { + font-family: "Sora", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 15% -5%, #ffe8cc 0%, transparent 40%), + radial-gradient(circle at 100% 100%, #d5efe6 0%, transparent 40%), + var(--bg); + overflow-x: hidden; +} + +h1, +h2, +h3 { + font-family: "Space Grotesk", sans-serif; + margin: 0 0 0.4rem; +} + +.muted { + color: var(--muted); +} + +.bg-orb { + position: fixed; + border-radius: 999px; + pointer-events: none; + filter: blur(4px); + z-index: -1; +} + +.bg-orb-one { + width: 260px; + height: 260px; + background: rgba(214, 109, 45, 0.25); + top: -60px; + right: -40px; +} + +.bg-orb-two { + width: 220px; + height: 220px; + background: rgba(15, 111, 99, 0.18); + bottom: -70px; + left: -40px; +} + +.reveal { + animation: reveal-up 0.45s ease both; +} + +.delay-1 { + animation-delay: 0.05s; +} + +.delay-2 { + animation-delay: 0.11s; +} + +.delay-3 { + animation-delay: 0.16s; +} + +@keyframes reveal-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.flash-stack { + position: fixed; + top: 1rem; + right: 1rem; + display: grid; + gap: 0.5rem; + z-index: 50; +} + +.flash { + padding: 0.7rem 0.95rem; + border-radius: 12px; + box-shadow: var(--shadow); + font-size: 0.9rem; + max-width: min(82vw, 420px); + opacity: 1; + transform: translateY(0); + transition: opacity 0.35s ease, transform 0.35s ease; +} + +.flash.is-hiding { + opacity: 0; + transform: translateY(-6px); +} + +.flash-success { + background: #e3f7ef; + border: 1px solid #9cdcbc; +} + +.flash-error { + background: #fdeceb; + border: 1px solid #f2b2ad; +} + +.flash-info { + background: #edf4ff; + border: 1px solid #bed4f9; +} + +.auth-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1rem; +} + +.auth-card { + width: min(560px, 100%); + background: var(--panel); + border: 1px solid var(--line); + box-shadow: var(--shadow); + border-radius: 22px; + padding: 2rem; +} + +.auth-card h1 { + margin-bottom: 2rem; +} + +.auth-inline-error { + margin: -1rem 0 1rem; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid #f2b2ad; + background: #fdeceb; + color: #7a1f16; + font-size: 0.9rem; +} + +.form-grid { + display: grid; + gap: 0.9rem; +} + +.form-grid > * { + min-width: 0; +} + +label { + display: grid; + gap: 0.4rem; + font-size: 0.9rem; +} + +input, +button { + border-radius: 12px; + border: 1px solid var(--line); + font: inherit; + max-width: 100%; +} + +input { + display: block; + width: 100%; + background: #fff; + padding: 0.65rem 0.75rem; +} + +input[type="file"] { + min-width: 0; +} + +button, +.small-button, +.ghost-link { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.08s ease, background-color 0.2s ease; +} + +button { + background: var(--brand); + color: #fff; + padding: 0.65rem 0.85rem; + border: 0; +} + +button:hover, +.small-button:hover, +.ghost-link:hover { + transform: translateY(-1px); +} + +button.danger { + background: var(--danger); +} + +button.small { + padding: 0.45rem 0.6rem; + font-size: 0.82rem; +} + +.topbar { + margin: 1.2rem; + padding: 1rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.ghost-link { + padding: 0.65rem 0.85rem; + border-radius: 12px; + border: 1px solid var(--line); + color: var(--ink); +} + +.dashboard-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(12, minmax(0, 1fr)); + padding: 0 1.2rem 1.2rem; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 1rem; + box-shadow: var(--shadow); +} + +.panel:nth-child(1) { + grid-column: span 4; +} + +.panel:nth-child(2) { + grid-column: span 8; +} + +.panel:nth-child(3) { + grid-column: span 12; +} + +.time-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.8rem; +} + +.time-row > * { + min-width: 0; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border-bottom: 1px solid var(--line); + text-align: left; + padding: 0.45rem; + font-size: 0.9rem; +} + +.trigger-row { + cursor: pointer; +} + +.trigger-row:hover { + background: #f7f2e6; +} + +.inline-form { + display: inline-block; + margin-right: 0.35rem; +} + +.upload-row { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + margin-bottom: 0.75rem; +} + +.audio-player-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.75rem; + margin-bottom: 0.8rem; + background: #fff; +} + +#browser-audio-player { + width: 100%; + margin-top: 0.35rem; +} + +.audio-time { + margin: 0.5rem 0 0; + font-size: 0.9rem; + color: var(--muted); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(20, 18, 15, 0.45); + display: none; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 120; +} + +.modal-backdrop.is-open { + display: flex; +} + +.modal-card { + width: min(460px, 100%); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: var(--shadow); + padding: 1rem; +} + +.modal-actions { + margin-top: 0.9rem; + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.audio-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.6rem; +} + +.audio-list li { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.7rem; + display: flex; + justify-content: space-between; + gap: 0.8rem; + align-items: center; + background: #fff; +} + +.audio-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.small-button { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.45rem 0.6rem; + font-size: 0.82rem; + color: var(--ink); + background: #fff; +} + +@media (max-width: 1080px) { + .panel:nth-child(1), + .panel:nth-child(2), + .panel:nth-child(3) { + grid-column: span 12; + } +} + +@media (max-width: 640px) { + .topbar { + margin: 0.8rem; + flex-direction: column; + align-items: flex-start; + } + + .dashboard-grid { + padding: 0 0.8rem 0.8rem; + } + + .audio-list li { + flex-direction: column; + align-items: flex-start; + } + + .time-row { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/templates/base.html b/frontend/app/templates/base.html new file mode 100644 index 0000000..0dd4e0a --- /dev/null +++ b/frontend/app/templates/base.html @@ -0,0 +1,49 @@ + + + + + + pySonnerie - Interface de controle + + + + + + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} + + + + diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html new file mode 100644 index 0000000..f7cab96 --- /dev/null +++ b/frontend/app/templates/dashboard.html @@ -0,0 +1,347 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Tableau de bord

+

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

+
+
+
+ +
+ Deconnexion +
+
+ +
+
+

Ajouter ou modifier un trigger

+
+ + + + +
+ + +
+ +
+
+ +
+

Liste des triggers

+ {% if triggers %} +
+ + + + + + + + + + + + + + {% for trigger_id, trigger in triggers.items() %} + + + + + + + + + + {% endfor %} + +
IDNomTypeAudioDebutFinActions
{{ 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 %} +
+ +
+

Stockage audio

+
+

Aucune lecture en cours.

+ +

Temps de lecture: 00:00 / 00:00

+
+ +
+ + +
+ + {% if audio_files %} +
    + {% for filename in audio_files %} +
  • + {{ filename }} +
    + + Telecharger +
    + + +
    +
    +
  • + {% endfor %} +
+ {% else %} +

Aucun fichier audio dans le stockage.

+ {% endif %} +
+
+ + + + + + +{% endblock %} diff --git a/frontend/app/templates/login.html b/frontend/app/templates/login.html new file mode 100644 index 0000000..67ce59b --- /dev/null +++ b/frontend/app/templates/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

pySonnerie

+ {% if login_error %} +

{{ login_error }}

+ {% endif %} +
+ + + +
+
+
+{% endblock %} diff --git a/frontend/requirements.txt b/frontend/requirements.txt new file mode 100644 index 0000000..76d9083 --- /dev/null +++ b/frontend/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +requests==2.32.3 diff --git a/frontend/run.py b/frontend/run.py new file mode 100644 index 0000000..0ecaf78 --- /dev/null +++ b/frontend/run.py @@ -0,0 +1,8 @@ +from app import create_app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=False)