Browse Source

First commit

master
scayac 1 month ago
commit
647a22cb2c
  1. 1
      .gitignore
  2. 5
      arduino/.gitignore
  3. 10
      arduino/.vscode/extensions.json
  4. 78
      arduino/README_ARDUINO.md
  5. 5
      arduino/platformio.ini
  6. 74
      arduino/src/main.cpp
  7. 123
      backend/README_BACKEND.md
  8. 0
      backend/app/__init__.py
  9. BIN
      backend/app/__pycache__/__init__.cpython-312.pyc
  10. BIN
      backend/app/__pycache__/audio_player.cpython-312.pyc
  11. BIN
      backend/app/__pycache__/config_store.cpython-312.pyc
  12. BIN
      backend/app/__pycache__/main.cpython-312.pyc
  13. BIN
      backend/app/__pycache__/models.cpython-312.pyc
  14. BIN
      backend/app/__pycache__/security.cpython-312.pyc
  15. BIN
      backend/app/__pycache__/serial_listener.cpython-312.pyc
  16. 53
      backend/app/audio_player.py
  17. 50
      backend/app/config_store.py
  18. 180
      backend/app/main.py
  19. 52
      backend/app/models.py
  20. 62
      backend/app/security.py
  21. 72
      backend/app/serial_listener.py
  22. 19
      backend/certs/cert.pem
  23. 28
      backend/certs/key.pem
  24. 34
      backend/data/conf.json
  25. 0
      backend/data/musiques/.gitkeep
  26. BIN
      backend/data/musiques/SONNERIE A.mp3
  27. BIN
      backend/data/musiques/SONNERIE B.mp3
  28. BIN
      backend/data/musiques/SONNERIE C.mp3
  29. BIN
      backend/data/musiques/SONNERIE D.mp3
  30. BIN
      backend/data/musiques/SONNERIE E.mp3
  31. BIN
      backend/data/musiques/SONNERIE F.mp3
  32. BIN
      backend/data/musiques/SONNERIE G.mp3
  33. BIN
      backend/data/musiques/SONNERIE H.mp3
  34. BIN
      backend/data/musiques/SONNERIE I.mp3
  35. BIN
      backend/data/musiques/SONNERIE J.mp3
  36. BIN
      backend/data/musiques/SONNERIE K.mp3
  37. BIN
      backend/data/musiques/SONNERIE L.mp3
  38. BIN
      backend/data/musiques/SONNERIE M.mp3
  39. BIN
      backend/data/musiques/SONNERIE N.mp3
  40. BIN
      backend/data/musiques/SONNERIE O.mp3
  41. BIN
      backend/data/musiques/SONNERIE P.mp3
  42. BIN
      backend/data/musiques/SONNERIE_P.mp3
  43. 3
      backend/requirements.txt
  44. 36
      backend/run.py
  45. 14
      backend/systemd/pysonnerie-backend.service
  46. 36
      frontend/README_FRONTEND.md
  47. 23
      frontend/app/__init__.py
  48. BIN
      frontend/app/__pycache__/__init__.cpython-312.pyc
  49. BIN
      frontend/app/__pycache__/backend_client.cpython-312.pyc
  50. BIN
      frontend/app/__pycache__/routes.cpython-312.pyc
  51. 104
      frontend/app/backend_client.py
  52. 326
      frontend/app/routes.py
  53. 444
      frontend/app/static/css/style.css
  54. 49
      frontend/app/templates/base.html
  55. 347
      frontend/app/templates/dashboard.html
  56. 23
      frontend/app/templates/login.html
  57. 2
      frontend/requirements.txt
  58. 8
      frontend/run.py

1
.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
.venv

5
arduino/.gitignore vendored

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
arduino/.vscode/extensions.json vendored

@ -0,0 +1,10 @@ @@ -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"
]
}

78
arduino/README_ARDUINO.md

@ -0,0 +1,78 @@ @@ -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`.

5
arduino/platformio.ini

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
[env:uno]
platform = atmelavr
board = uno
framework = arduino
monitor_speed = 115200

74
arduino/src/main.cpp

@ -0,0 +1,74 @@ @@ -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 <Arduino.h>
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<pin_number>".
// 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);
}

123
backend/README_BACKEND.md

@ -0,0 +1,123 @@ @@ -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://<host>:<port>`.
## 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.

0
backend/app/__init__.py

BIN
backend/app/__pycache__/__init__.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/audio_player.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/config_store.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/main.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/models.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/security.cpython-312.pyc

Binary file not shown.

BIN
backend/app/__pycache__/serial_listener.cpython-312.pyc

Binary file not shown.

53
backend/app/audio_player.py

@ -0,0 +1,53 @@ @@ -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

50
backend/app/config_store.py

@ -0,0 +1,50 @@ @@ -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)

180
backend/app/main.py

@ -0,0 +1,180 @@ @@ -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"}

52
backend/app/models.py

@ -0,0 +1,52 @@ @@ -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

62
backend/app/security.py

@ -0,0 +1,62 @@ @@ -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

72
backend/app/serial_listener.py

@ -0,0 +1,72 @@ @@ -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)

19
backend/certs/cert.pem

@ -0,0 +1,19 @@ @@ -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-----

28
backend/certs/key.pem

@ -0,0 +1,28 @@ @@ -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-----

34
backend/data/conf.json

@ -0,0 +1,34 @@ @@ -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
}
}
}

0
backend/data/musiques/.gitkeep

BIN
backend/data/musiques/SONNERIE A.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE B.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE C.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE D.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE E.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE F.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE G.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE H.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE I.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE J.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE K.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE L.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE M.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE N.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE O.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE P.mp3

Binary file not shown.

BIN
backend/data/musiques/SONNERIE_P.mp3

Binary file not shown.

3
backend/requirements.txt

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pyserial==3.5

36
backend/run.py

@ -0,0 +1,36 @@ @@ -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()

14
backend/systemd/pysonnerie-backend.service

@ -0,0 +1,14 @@ @@ -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

36
frontend/README_FRONTEND.md

@ -0,0 +1,36 @@ @@ -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`.

23
frontend/app/__init__.py

@ -0,0 +1,23 @@ @@ -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

BIN
frontend/app/__pycache__/__init__.cpython-312.pyc

Binary file not shown.

BIN
frontend/app/__pycache__/backend_client.cpython-312.pyc

Binary file not shown.

BIN
frontend/app/__pycache__/routes.cpython-312.pyc

Binary file not shown.

104
frontend/app/backend_client.py

@ -0,0 +1,104 @@ @@ -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 {}

326
frontend/app/routes.py

@ -0,0 +1,326 @@ @@ -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<number>.", "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/<path:filename>")
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/<path:filename>")
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)

444
frontend/app/static/css/style.css

@ -0,0 +1,444 @@ @@ -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;
}
}

49
frontend/app/templates/base.html

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>pySonnerie - Interface de controle</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('ui.static', filename='css/style.css') }}" />
</head>
<body>
<div class="bg-orb bg-orb-one"></div>
<div class="bg-orb bg-orb-two"></div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-stack">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<script>
(function () {
const stack = document.querySelector(".flash-stack");
if (!stack) return;
const flashes = Array.from(stack.querySelectorAll(".flash"));
flashes.forEach((flash, index) => {
const visibleDurationMs = 3600 + index * 250;
window.setTimeout(() => {
flash.classList.add("is-hiding");
window.setTimeout(() => {
flash.remove();
if (!stack.querySelector(".flash")) {
stack.remove();
}
}, 360);
}, visibleDurationMs);
});
})();
</script>
</body>
</html>

347
frontend/app/templates/dashboard.html

@ -0,0 +1,347 @@ @@ -0,0 +1,347 @@
{% extends "base.html" %}
{% block content %}
<header class="topbar reveal">
<div>
<h1>Tableau de bord</h1>
<p class="muted">Backend: {{ backend_url }} | Utilisateur: {{ username }}</p>
</div>
<div class="topbar-actions">
<form method="post" action="{{ url_for('ui.stop_audio') }}">
<button type="submit" class="danger">Arreter l'audio</button>
</form>
<a href="{{ url_for('ui.logout') }}" class="ghost-link">Deconnexion</a>
</div>
</header>
<main class="dashboard-grid">
<section class="panel reveal delay-1">
<h2>Ajouter ou modifier un trigger</h2>
<form method="post" action="{{ url_for('ui.save_trigger') }}" class="form-grid" id="trigger-form">
<label>
Identifiant du trigger existant (optionnel)
<input type="text" name="original_id" id="original_id" placeholder="GPIO3" />
</label>
<label>
Type du trigger (obligatoire)
<input type="text" name="type" id="trigger_type" placeholder="GPIO3" required />
</label>
<label>
Nom
<input type="text" name="name" id="trigger_name" placeholder="Bouton entree" required />
</label>
<label>
Fichier audio
<input list="audio-files" name="music_file" id="music_file" placeholder="bell.mp3" required />
<datalist id="audio-files">
{% for f in audio_files %}
<option value="{{ f }}"></option>
{% endfor %}
</datalist>
</label>
<div class="time-row">
<label>
Debut (s)
<input type="number" step="0.1" min="0" name="start_seconds" id="start_seconds" value="0" />
</label>
<label>
Fin (s, optionnel)
<input type="number" step="0.1" min="0" name="end_seconds" id="end_seconds" />
</label>
</div>
<button type="submit">Enregistrer le trigger</button>
</form>
</section>
<section class="panel reveal delay-2">
<h2>Liste des triggers</h2>
{% if triggers %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Type</th>
<th>Audio</th>
<th>Debut</th>
<th>Fin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for trigger_id, trigger in triggers.items() %}
<tr
class="trigger-row"
data-trigger-id="{{ trigger_id }}"
data-trigger-type="{{ trigger.get('type', '') }}"
data-trigger-name="{{ trigger.get('name', '') }}"
data-trigger-music="{{ trigger.get('music_file', '') }}"
data-trigger-start="{{ trigger.get('start_seconds', 0) }}"
data-trigger-end="{{ '' if trigger.get('end_seconds') is none else trigger.get('end_seconds') }}"
title="Cliquer pour charger ce trigger dans le formulaire"
>
<td>{{ trigger_id }}</td>
<td>{{ trigger.get('name', '') }}</td>
<td>{{ trigger.get('type', '') }}</td>
<td>{{ trigger.get('music_file', '') }}</td>
<td>{{ trigger.get('start_seconds', 0) }}</td>
<td>{{ trigger.get('end_seconds', '') }}</td>
<td>
<form method="post" action="{{ url_for('ui.play_trigger') }}" class="inline-form">
<input type="hidden" name="trigger_id" value="{{ trigger_id }}" />
<button type="submit" class="small">Lancer</button>
</form>
<form method="post" action="{{ url_for('ui.delete_trigger') }}" class="inline-form" onsubmit="return confirm('Supprimer le trigger {{ trigger_id }} ?')">
<input type="hidden" name="trigger_id" value="{{ trigger_id }}" />
<button type="submit" class="small danger">Supprimer</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Aucun trigger configure.</p>
{% endif %}
</section>
<section class="panel reveal delay-3">
<h2>Stockage audio</h2>
<div class="audio-player-card">
<p id="audio-now-playing" class="muted">Aucune lecture en cours.</p>
<audio id="browser-audio-player" controls preload="metadata"></audio>
<p class="audio-time" id="audio-time">Temps de lecture: 00:00 / 00:00</p>
</div>
<form method="post" action="{{ url_for('ui.upload_audio') }}" enctype="multipart/form-data" class="upload-row" id="upload-audio-form">
<input type="file" name="audio_file" id="audio_file_input" accept=".mp3,.wav,.ogg,.flac,.aac,.m4a" required />
<button type="submit">Televerser</button>
</form>
{% if audio_files %}
<ul class="audio-list">
{% for filename in audio_files %}
<li>
<span>{{ filename }}</span>
<div class="audio-actions">
<button
type="button"
class="small-button js-play-browser"
data-audio-url="{{ url_for('ui.stream_audio', filename=filename) }}"
data-audio-name="{{ filename }}"
>
Lire
</button>
<a href="{{ url_for('ui.download_audio', filename=filename) }}" class="small-button">Telecharger</a>
<form method="post" action="{{ url_for('ui.delete_audio') }}" class="inline-form js-delete-audio-form" data-filename="{{ filename }}">
<input type="hidden" name="filename" value="{{ filename }}" />
<button type="submit" class="small danger">Supprimer</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">Aucun fichier audio dans le stockage.</p>
{% endif %}
</section>
</main>
<div class="modal-backdrop" id="delete-audio-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="delete-audio-title">
<h3 id="delete-audio-title">Confirmer la suppression</h3>
<p id="delete-audio-message">Voulez-vous vraiment supprimer ce fichier ?</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="delete-audio-cancel">Annuler</button>
<button type="button" class="danger" id="delete-audio-confirm">Supprimer</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="duplicate-name-modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="duplicate-name-title">
<h3 id="duplicate-name-title">Nom deja utilise</h3>
<p id="duplicate-name-message">Ce nom existe deja dans les fichiers enregistres.</p>
<div class="modal-actions">
<button type="button" class="ghost-link" id="duplicate-name-close">Fermer</button>
</div>
</div>
</div>
<script>
(function () {
const form = document.getElementById("trigger-form");
if (!form) return;
const originalIdInput = document.getElementById("original_id");
const typeInput = document.getElementById("trigger_type");
const nameInput = document.getElementById("trigger_name");
const musicInput = document.getElementById("music_file");
const startInput = document.getElementById("start_seconds");
const endInput = document.getElementById("end_seconds");
const rows = document.querySelectorAll("tr.trigger-row");
rows.forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("form, button, a, input")) {
return;
}
if (originalIdInput) originalIdInput.value = row.dataset.triggerId || "";
if (typeInput) typeInput.value = row.dataset.triggerType || "";
if (nameInput) nameInput.value = row.dataset.triggerName || "";
if (musicInput) musicInput.value = row.dataset.triggerMusic || "";
if (startInput) startInput.value = row.dataset.triggerStart || "0";
if (endInput) endInput.value = row.dataset.triggerEnd || "";
typeInput?.focus();
form.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
})();
(function () {
const player = document.getElementById("browser-audio-player");
const timeLabel = document.getElementById("audio-time");
const nowPlaying = document.getElementById("audio-now-playing");
if (!player || !timeLabel || !nowPlaying) return;
const formatTime = (seconds) => {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds);
const minutes = Math.floor(total / 60);
const remain = total % 60;
return String(minutes).padStart(2, "0") + ":" + String(remain).padStart(2, "0");
};
const updateTimeLabel = () => {
const current = formatTime(player.currentTime || 0);
const total = formatTime(player.duration || 0);
timeLabel.textContent = "Temps de lecture: " + current + " / " + total;
};
player.addEventListener("timeupdate", updateTimeLabel);
player.addEventListener("loadedmetadata", updateTimeLabel);
player.addEventListener("ended", updateTimeLabel);
const playButtons = document.querySelectorAll(".js-play-browser");
playButtons.forEach((button) => {
button.addEventListener("click", () => {
const src = button.dataset.audioUrl;
const name = button.dataset.audioName || "Fichier audio";
if (!src) return;
if (player.src !== src) {
player.src = src;
}
player.play().catch(() => {
// Browsers may block autoplay in some contexts; user can press play manually.
});
nowPlaying.textContent = "Lecture: " + name;
updateTimeLabel();
});
});
})();
(function () {
const uploadForm = document.getElementById("upload-audio-form");
const fileInput = document.getElementById("audio_file_input");
const duplicateModal = document.getElementById("duplicate-name-modal");
const duplicateClose = document.getElementById("duplicate-name-close");
if (!uploadForm || !fileInput || !duplicateModal || !duplicateClose) return;
const existing = new Set([
{% for filename in audio_files %}
"{{ filename|lower|replace('\\', '\\\\')|replace('"', '\\"') }}",
{% endfor %}
]);
const openDuplicateModal = () => {
duplicateModal.classList.add("is-open");
duplicateModal.setAttribute("aria-hidden", "false");
duplicateClose.focus();
};
const closeDuplicateModal = () => {
duplicateModal.classList.remove("is-open");
duplicateModal.setAttribute("aria-hidden", "true");
};
uploadForm.addEventListener("submit", (event) => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
if (existing.has(file.name.toLowerCase())) {
event.preventDefault();
openDuplicateModal();
}
});
duplicateClose.addEventListener("click", closeDuplicateModal);
duplicateModal.addEventListener("click", (event) => {
if (event.target === duplicateModal) {
closeDuplicateModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && duplicateModal.classList.contains("is-open")) {
closeDuplicateModal();
}
});
})();
(function () {
const modal = document.getElementById("delete-audio-modal");
const message = document.getElementById("delete-audio-message");
const cancelBtn = document.getElementById("delete-audio-cancel");
const confirmBtn = document.getElementById("delete-audio-confirm");
if (!modal || !message || !cancelBtn || !confirmBtn) return;
let pendingForm = null;
const openModal = (form, filename) => {
pendingForm = form;
message.textContent = "Voulez-vous vraiment supprimer le fichier \"" + filename + "\" ?";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
confirmBtn.focus();
};
const closeModal = () => {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
pendingForm = null;
};
document.querySelectorAll(".js-delete-audio-form").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
const filename = form.dataset.filename || "ce fichier";
openModal(form, filename);
});
});
cancelBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal();
}
});
confirmBtn.addEventListener("click", () => {
if (pendingForm) {
pendingForm.submit();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal.classList.contains("is-open")) {
closeModal();
}
});
})();
</script>
{% endblock %}

23
frontend/app/templates/login.html

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<main class="auth-wrap">
<section class="auth-card reveal">
<h1>pySonnerie</h1>
{% if login_error %}
<p class="auth-inline-error">{{ login_error }}</p>
{% endif %}
<form method="post" class="form-grid">
<label>
Nom d'utilisateur
<input type="text" name="username" placeholder="admin" value="{{ attempted_username or '' }}" required />
</label>
<label>
Mot de passe
<input type="password" name="password" required />
</label>
<button type="submit">Se connecter</button>
</form>
</section>
</main>
{% endblock %}

2
frontend/requirements.txt

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
Flask==3.0.3
requests==2.32.3

8
frontend/run.py

@ -0,0 +1,8 @@ @@ -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)
Loading…
Cancel
Save