commit
647a22cb2c
58 changed files with 2261 additions and 0 deletions
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
.pio |
||||
.vscode/.browse.c_cpp.db* |
||||
.vscode/c_cpp_properties.json |
||||
.vscode/launch.json |
||||
.vscode/ipch |
||||
@ -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" |
||||
] |
||||
} |
||||
@ -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`. |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
[env:uno] |
||||
platform = atmelavr |
||||
board = uno |
||||
framework = arduino |
||||
monitor_speed = 115200 |
||||
@ -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); |
||||
} |
||||
@ -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. |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
||||
@ -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) |
||||
@ -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"} |
||||
@ -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 |
||||
@ -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 |
||||
@ -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) |
||||
@ -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----- |
||||
@ -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----- |
||||
@ -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 |
||||
} |
||||
} |
||||
} |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
fastapi==0.116.1 |
||||
uvicorn[standard]==0.35.0 |
||||
pyserial==3.5 |
||||
@ -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() |
||||
@ -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 |
||||
@ -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`. |
||||
@ -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 |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 {} |
||||
@ -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) |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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> |
||||
@ -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 %} |
||||
@ -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 %} |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.3 |
||||
requests==2.32.3 |
||||
Loading…
Reference in new issue