feat: relay inversé, LED idle solid, luminosité manuelle, auto-flash lux
This commit is contained in:
@@ -51,6 +51,42 @@ async def leds_off(request: Request):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leds/brightness")
|
||||||
|
async def get_brightness(request: Request):
|
||||||
|
cfg = request.app.state.config.leds
|
||||||
|
return {"brightness": cfg.brightness, "auto_brightness": cfg.auto_brightness}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/leds/brightness")
|
||||||
|
async def set_brightness(
|
||||||
|
request: Request,
|
||||||
|
value: int = Query(..., ge=0, le=255),
|
||||||
|
save: bool = Query(default=True),
|
||||||
|
):
|
||||||
|
"""Regle la luminosite globale (0-255) et l'applique immediatement."""
|
||||||
|
led = request.app.state.led_service
|
||||||
|
led.set_brightness(value)
|
||||||
|
if save:
|
||||||
|
config_svc = request.app.state.config_service
|
||||||
|
config_svc.save_led_brightness(value)
|
||||||
|
return {"ok": True, "brightness": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/leds/auto-brightness")
|
||||||
|
async def set_auto_brightness(
|
||||||
|
request: Request,
|
||||||
|
enabled: bool = Query(...),
|
||||||
|
save: bool = Query(default=True),
|
||||||
|
):
|
||||||
|
"""Active ou desactive la luminosite automatique du flash selon l'ambiance."""
|
||||||
|
cfg = request.app.state.config.leds
|
||||||
|
cfg.auto_brightness = enabled
|
||||||
|
if save:
|
||||||
|
config_svc = request.app.state.config_service
|
||||||
|
config_svc.save_led_auto_brightness(enabled)
|
||||||
|
return {"ok": True, "auto_brightness": enabled}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/leds/effects")
|
@router.get("/leds/effects")
|
||||||
async def get_effects(request: Request):
|
async def get_effects(request: Request):
|
||||||
"""Retourne la configuration de tous les effets LED."""
|
"""Retourne la configuration de tous les effets LED."""
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ async def photobooth_webhook(
|
|||||||
match event_key:
|
match event_key:
|
||||||
case "counting":
|
case "counting":
|
||||||
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
|
||||||
|
if request.app.state.config.leds.auto_brightness:
|
||||||
|
pb = request.app.state.photobooth_service
|
||||||
|
asyncio.create_task(_analyze_ambient_lux(led, pb))
|
||||||
|
|
||||||
case "capture":
|
case "capture":
|
||||||
led.play("capture")
|
led.play("capture")
|
||||||
@@ -58,3 +61,21 @@ async def _delayed_relay_on(btn, delay: float):
|
|||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
if btn:
|
if btn:
|
||||||
btn.relay_on()
|
btn.relay_on()
|
||||||
|
|
||||||
|
|
||||||
|
async def _analyze_ambient_lux(led, pb):
|
||||||
|
"""Capture une frame du liveview et calcule la luminosite moyenne."""
|
||||||
|
try:
|
||||||
|
snapshot = await pb.get_liveview_snapshot()
|
||||||
|
if not snapshot:
|
||||||
|
logger.debug("Analyse lux: aucun snapshot disponible")
|
||||||
|
return
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
img = Image.open(io.BytesIO(snapshot)).convert("L").resize((80, 60))
|
||||||
|
pixels = list(img.getdata())
|
||||||
|
avg = sum(pixels) / len(pixels)
|
||||||
|
led.set_ambient_lux(avg)
|
||||||
|
logger.info("Luminosite ambiante: %.1f / 255", avg)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Analyse lux echouee: %s", e)
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ class ButtonService:
|
|||||||
try:
|
try:
|
||||||
self._relay = OutputDevice(
|
self._relay = OutputDevice(
|
||||||
self._cfg.relay_pin,
|
self._cfg.relay_pin,
|
||||||
active_high=True,
|
active_high=False,
|
||||||
initial_value=True,
|
initial_value=False,
|
||||||
)
|
)
|
||||||
self._button = Button(
|
self._button = Button(
|
||||||
self._cfg.pin,
|
self._cfg.pin,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class LEDConfig:
|
|||||||
pin: int = 18
|
pin: int = 18
|
||||||
count: int = 35
|
count: int = 35
|
||||||
brightness: int = 180
|
brightness: int = 180
|
||||||
|
auto_brightness: bool = False
|
||||||
freq_hz: int = 800000
|
freq_hz: int = 800000
|
||||||
dma: int = 10
|
dma: int = 10
|
||||||
strip_type: str = "WS2812"
|
strip_type: str = "WS2812"
|
||||||
@@ -140,6 +141,7 @@ class ConfigService:
|
|||||||
pin=led_raw.get("pin", 18),
|
pin=led_raw.get("pin", 18),
|
||||||
count=led_raw.get("count", 35),
|
count=led_raw.get("count", 35),
|
||||||
brightness=led_raw.get("brightness", 180),
|
brightness=led_raw.get("brightness", 180),
|
||||||
|
auto_brightness=led_raw.get("auto_brightness", False),
|
||||||
freq_hz=led_raw.get("freq_hz", 800000),
|
freq_hz=led_raw.get("freq_hz", 800000),
|
||||||
dma=led_raw.get("dma", 10),
|
dma=led_raw.get("dma", 10),
|
||||||
strip_type=led_raw.get("strip_type", "WS2812"),
|
strip_type=led_raw.get("strip_type", "WS2812"),
|
||||||
@@ -221,6 +223,24 @@ class ConfigService:
|
|||||||
self.load()
|
self.load()
|
||||||
return self._config
|
return self._config
|
||||||
|
|
||||||
|
def save_led_brightness(self, brightness: int):
|
||||||
|
with open(self.path, encoding="utf-8") as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
raw.setdefault("leds", {})["brightness"] = brightness
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
if self._config:
|
||||||
|
self._config.leds.brightness = brightness
|
||||||
|
|
||||||
|
def save_led_auto_brightness(self, enabled: bool):
|
||||||
|
with open(self.path, encoding="utf-8") as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
raw.setdefault("leds", {})["auto_brightness"] = enabled
|
||||||
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
if self._config:
|
||||||
|
self._config.leds.auto_brightness = enabled
|
||||||
|
|
||||||
def save_led_effect(self, effect_name: str, effect_data: dict):
|
def save_led_effect(self, effect_name: str, effect_data: dict):
|
||||||
with open(self.path, encoding="utf-8") as f:
|
with open(self.path, encoding="utf-8") as f:
|
||||||
raw = yaml.safe_load(f) or {}
|
raw = yaml.safe_load(f) or {}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class LEDService:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._on_change_cb: Callable | None = None
|
self._on_change_cb: Callable | None = None
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._ambient_lux: float = -1.0 # -1 = inconnu
|
||||||
|
|
||||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -111,6 +112,20 @@ class LEDService:
|
|||||||
def set_color(self, r: int, g: int, b: int):
|
def set_color(self, r: int, g: int, b: int):
|
||||||
self._fill(WS_Color(r, g, b))
|
self._fill(WS_Color(r, g, b))
|
||||||
|
|
||||||
|
def set_brightness(self, value: int):
|
||||||
|
"""Regle la luminosite globale (0-255) et l'applique immediatement."""
|
||||||
|
value = max(0, min(255, value))
|
||||||
|
self._cfg.brightness = value
|
||||||
|
if HAS_WS281X and self._strip:
|
||||||
|
self._strip.setBrightness(value)
|
||||||
|
self._strip.show()
|
||||||
|
logger.info("Luminosite LED: %d", value)
|
||||||
|
|
||||||
|
def set_ambient_lux(self, lux: float):
|
||||||
|
"""Definit la luminosite ambiante mesuree (0-255 echelle pixel)."""
|
||||||
|
self._ambient_lux = lux
|
||||||
|
logger.debug("Lux ambiant: %.1f", lux)
|
||||||
|
|
||||||
# ── Thread principal ──────────────────────────────────────────────────────
|
# ── Thread principal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
@@ -154,8 +169,14 @@ class LEDService:
|
|||||||
def _effect_idle(self):
|
def _effect_idle(self):
|
||||||
cfg = self._cfg.get_effect("idle")
|
cfg = self._cfg.get_effect("idle")
|
||||||
c = cfg.color
|
c = cfg.color
|
||||||
speed = cfg.speed
|
|
||||||
|
|
||||||
|
if cfg.mode == "solid":
|
||||||
|
self._fill(WS_Color(c[0], c[1], c[2]))
|
||||||
|
self._cmd_event.wait()
|
||||||
|
return
|
||||||
|
|
||||||
|
# mode "breathe" (defaut)
|
||||||
|
speed = cfg.speed
|
||||||
step = 0
|
step = 0
|
||||||
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
while not self._cmd_event.is_set() and not self._stop_event.is_set():
|
||||||
t = (1 + math.sin(step * 0.1)) / 2
|
t = (1 + math.sin(step * 0.1)) / 2
|
||||||
@@ -202,9 +223,23 @@ class LEDService:
|
|||||||
flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
flash_color = WS_Color(cfg.color[0], cfg.color[1], cfg.color[2])
|
||||||
off = WS_Color(0, 0, 0)
|
off = WS_Color(0, 0, 0)
|
||||||
|
|
||||||
# Boost luminosite max pendant le flash
|
# Calcul de la luminosite du flash
|
||||||
|
# Si auto_brightness activee et lux connu, on adapte. Sinon boost a 255.
|
||||||
|
if self._cfg.auto_brightness and self._ambient_lux >= 0:
|
||||||
|
lux = self._ambient_lux
|
||||||
|
if lux < 50:
|
||||||
|
flash_brightness = 255 # salle sombre → flash plein
|
||||||
|
elif lux < 180:
|
||||||
|
# lineaire : 255 a 50 lux → 120 a 180 lux
|
||||||
|
flash_brightness = int(255 - (lux - 50) / 130 * 135)
|
||||||
|
else:
|
||||||
|
flash_brightness = 80 # salle tres claire → flash reduit
|
||||||
|
logger.debug("Flash brightness auto: %d (lux=%.1f)", flash_brightness, lux)
|
||||||
|
else:
|
||||||
|
flash_brightness = 255
|
||||||
|
|
||||||
if HAS_WS281X and self._strip:
|
if HAS_WS281X and self._strip:
|
||||||
self._strip.setBrightness(255)
|
self._strip.setBrightness(flash_brightness)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for _ in range(cfg.flashes):
|
for _ in range(cfg.flashes):
|
||||||
|
|||||||
@@ -162,5 +162,49 @@ class PhotoboothService:
|
|||||||
"""Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
|
"""Lit l'état actuel depuis le fichier private.css (ou depuis la config)."""
|
||||||
return self._cfg.show_delete_button
|
return self._cfg.show_delete_button
|
||||||
|
|
||||||
|
async def get_liveview_snapshot(self) -> bytes | None:
|
||||||
|
"""Tente de recuperer une frame JPEG du liveview de photobooth-app.
|
||||||
|
|
||||||
|
Essaie dans l'ordre :
|
||||||
|
1. /api/stream/snapshot — endpoint snapshot direct (si disponible)
|
||||||
|
2. /stream.mjpg — flux MJPEG classique, extrait la premiere frame
|
||||||
|
3. /api/stream — flux MJPEG alternatif
|
||||||
|
|
||||||
|
Retourne des bytes JPEG ou None si rien n'est disponible.
|
||||||
|
"""
|
||||||
|
# 1. Snapshot direct
|
||||||
|
for path in ("/api/stream/snapshot",):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||||
|
r = await client.get(f"{self._base}{path}")
|
||||||
|
if r.status_code == 200:
|
||||||
|
ct = r.headers.get("content-type", "")
|
||||||
|
if "jpeg" in ct or "image" in ct:
|
||||||
|
return r.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Snapshot %s: %s", path, e)
|
||||||
|
|
||||||
|
# 2. Premiere frame d'un flux MJPEG
|
||||||
|
for path in ("/stream.mjpg", "/api/stream"):
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
async with client.stream("GET", f"{self._base}{path}") as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
buf = b""
|
||||||
|
async for chunk in resp.aiter_bytes(4096):
|
||||||
|
buf += chunk
|
||||||
|
start = buf.find(b"\xff\xd8")
|
||||||
|
if start >= 0:
|
||||||
|
end = buf.find(b"\xff\xd9", start)
|
||||||
|
if end >= 0:
|
||||||
|
return buf[start:end + 2]
|
||||||
|
if len(buf) > 500_000:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("MJPEG %s: %s", path, e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
|||||||
@@ -42,13 +42,14 @@ leds:
|
|||||||
pin: 18
|
pin: 18
|
||||||
count: 35
|
count: 35
|
||||||
brightness: 180 # Luminosite normale (0-255)
|
brightness: 180 # Luminosite normale (0-255)
|
||||||
|
auto_brightness: false # Ajustement automatique du flash selon la luminosite ambiante
|
||||||
freq_hz: 800000
|
freq_hz: 800000
|
||||||
dma: 10
|
dma: 10
|
||||||
strip_type: "WS2812"
|
strip_type: "WS2812"
|
||||||
effects:
|
effects:
|
||||||
idle:
|
idle:
|
||||||
color: [0, 30, 80]
|
color: [0, 30, 80]
|
||||||
mode: "breathe"
|
mode: "solid"
|
||||||
speed: 0.025
|
speed: 0.025
|
||||||
|
|
||||||
countdown:
|
countdown:
|
||||||
|
|||||||
@@ -361,6 +361,51 @@ select.field {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Luminosité LED -->
|
||||||
|
<div class="card" style="margin-top:1rem">
|
||||||
|
<div class="card-title">💡 Luminosité LED</div>
|
||||||
|
|
||||||
|
<div style="display:grid; gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted)">
|
||||||
|
Luminosité ambiante : <strong id="brightness-val">180</strong> / 255
|
||||||
|
</label>
|
||||||
|
<input type="range" id="brightness-slider" min="0" max="255" step="5" value="180"
|
||||||
|
oninput="document.getElementById('brightness-val').textContent=this.value"
|
||||||
|
style="width:100%">
|
||||||
|
<div style="font-size:.72rem; color:var(--text-muted)">
|
||||||
|
Appliquée en permanence (idle, countdown, captured…). Le flash peut dépasser cette valeur.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="previewBrightness()">👁 Aperçu</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="saveBrightness()">💾 Sauvegarder</button>
|
||||||
|
<span id="brightness-status" style="font-size:.82rem; color:var(--text-muted)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--border); padding-top:1rem;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; font-size:.9rem;">🌞 Luminosité flash automatique</div>
|
||||||
|
<div style="font-size:.78rem; color:var(--text-muted); margin-top:.2rem;">
|
||||||
|
Analyse une frame de la caméra avant la prise pour adapter le flash.<br>
|
||||||
|
Salle sombre → flash plein (255) | Salle claire → flash réduit (80+).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle" style="flex-shrink:0; margin-left:1rem;">
|
||||||
|
<input type="checkbox" id="toggle-auto-brightness"
|
||||||
|
onchange="setAutoBrightness(this.checked)">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="auto-brightness-status" style="font-size:.78rem; color:var(--text-muted); margin-top:.5rem;">
|
||||||
|
⏳ Chargement…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Flash LED -->
|
<!-- Flash LED -->
|
||||||
<div class="card" style="margin-top:1rem">
|
<div class="card" style="margin-top:1rem">
|
||||||
<div class="card-title">⚡ Flash photo (LED)</div>
|
<div class="card-title">⚡ Flash photo (LED)</div>
|
||||||
@@ -744,6 +789,58 @@ async function refreshScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Luminosité LED
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function loadBrightnessConfig() {
|
||||||
|
try {
|
||||||
|
const d = await api('GET', '/api/leds/brightness');
|
||||||
|
document.getElementById('brightness-slider').value = d.brightness ?? 180;
|
||||||
|
document.getElementById('brightness-val').textContent = d.brightness ?? 180;
|
||||||
|
const auto = d.auto_brightness ?? false;
|
||||||
|
document.getElementById('toggle-auto-brightness').checked = auto;
|
||||||
|
document.getElementById('auto-brightness-status').textContent = auto
|
||||||
|
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
|
||||||
|
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
|
||||||
|
} catch(e) { console.warn('loadBrightnessConfig:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewBrightness() {
|
||||||
|
const v = parseInt(document.getElementById('brightness-slider').value);
|
||||||
|
await api('PUT', `/api/leds/brightness?value=${v}&save=false`);
|
||||||
|
showToast(`Luminosité ${v}/255 appliquée`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBrightness() {
|
||||||
|
const v = parseInt(document.getElementById('brightness-slider').value);
|
||||||
|
const status = document.getElementById('brightness-status');
|
||||||
|
try {
|
||||||
|
await api('PUT', `/api/leds/brightness?value=${v}&save=true`);
|
||||||
|
status.textContent = '✅ Sauvegardé';
|
||||||
|
showToast(`Luminosité ${v}/255 sauvegardée`, 'success');
|
||||||
|
} catch(e) {
|
||||||
|
status.textContent = '❌ Erreur';
|
||||||
|
showToast('Erreur : ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAutoBrightness(enabled) {
|
||||||
|
const statusEl = document.getElementById('auto-brightness-status');
|
||||||
|
try {
|
||||||
|
await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}&save=true`);
|
||||||
|
statusEl.textContent = enabled
|
||||||
|
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
|
||||||
|
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
|
||||||
|
showToast(enabled ? '✅ Auto-luminosité activée' : '⏸ Auto-luminosité désactivée', 'success');
|
||||||
|
} catch(e) {
|
||||||
|
showToast('Erreur : ' + e.message, 'error');
|
||||||
|
document.getElementById('toggle-auto-brightness').checked = !enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBrightnessConfig();
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// Flash LED
|
// Flash LED
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user