980 lines
42 KiB
HTML
980 lines
42 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Réglages bouton — JH Photomaton{% endblock %}
|
|
|
|
|
|
{% block head %}
|
|
<style>
|
|
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
|
.settings-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1.5rem;
|
|
max-width: 1100px;
|
|
margin: 1.5rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
@media (max-width: 768px) { .settings-grid { grid-template-columns: 1fr; } }
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 1.5rem;
|
|
}
|
|
.card-title {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
margin-bottom: 1.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
}
|
|
|
|
/* ── Sliders ─────────────────────────────────────────────────────────────── */
|
|
.timing-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .4rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
.timing-label {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.timing-label span:first-child { color: var(--text-muted); font-size: .9rem; }
|
|
.timing-value {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 1.05rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
min-width: 5ch;
|
|
text-align: right;
|
|
}
|
|
/* Toggle switch */
|
|
.toggle-switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
|
|
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
|
.toggle-slider {
|
|
position: absolute; inset: 0; cursor: pointer;
|
|
background: var(--border); border-radius: 26px; transition: .3s;
|
|
}
|
|
.toggle-slider::before {
|
|
content: ''; position: absolute;
|
|
width: 20px; height: 20px; left: 3px; bottom: 3px;
|
|
background: #fff; border-radius: 50%; transition: .3s;
|
|
}
|
|
.toggle-switch input:checked + .toggle-slider { background: var(--primary); }
|
|
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(22px); }
|
|
|
|
input[type=range] {
|
|
width: 100%;
|
|
accent-color: var(--primary);
|
|
height: 6px;
|
|
cursor: pointer;
|
|
}
|
|
.timing-hint { font-size: .78rem; color: var(--text-muted); }
|
|
|
|
select.field {
|
|
width: 100%;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
border-radius: 6px;
|
|
padding: .45rem .7rem;
|
|
font-size: .95rem;
|
|
}
|
|
|
|
.btn-row { display: flex; gap: .75rem; margin-top: 1.5rem; flex-wrap: wrap; }
|
|
.btn { padding: .55rem 1.2rem; border: none; border-radius: 8px; cursor: pointer; font-size: .95rem; font-weight: 600; transition: opacity .2s; }
|
|
.btn:hover { opacity: .85; }
|
|
.btn-primary { background: var(--primary); color: #fff; }
|
|
.btn-success { background: #1a7340; color: #fff; }
|
|
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
|
.btn-sm { padding: .35rem .8rem; font-size: .85rem; }
|
|
|
|
/* ── Testeur ─────────────────────────────────────────────────────────────── */
|
|
.tester-area { display: flex; flex-direction: column; align-items: center; gap: 1.25rem; }
|
|
|
|
#big-btn {
|
|
width: 180px;
|
|
height: 180px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 35% 35%, #2a6db0, #0d3d6b);
|
|
border: 4px solid var(--primary);
|
|
box-shadow: 0 0 0 0 rgba(25, 108, 176, .5);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
gap: .4rem;
|
|
user-select: none;
|
|
touch-action: none;
|
|
transition: transform .1s, box-shadow .1s;
|
|
color: #fff;
|
|
font-size: .9rem;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
#big-btn:active, #big-btn.pressed {
|
|
transform: scale(.94);
|
|
box-shadow: 0 0 0 16px rgba(25, 108, 176, .2);
|
|
}
|
|
#big-btn .btn-icon { font-size: 2.2rem; pointer-events: none; }
|
|
#big-btn .btn-hint { font-size: .75rem; opacity: .7; pointer-events: none; }
|
|
|
|
/* Anneau de compte pendant multi-clic */
|
|
.click-dots { display: flex; gap: .4rem; justify-content: center; }
|
|
.click-dot {
|
|
width: 12px; height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--border);
|
|
transition: background .15s;
|
|
}
|
|
.click-dot.active { background: var(--primary); }
|
|
.click-dot.max { background: #1a7340; }
|
|
|
|
/* Résultat */
|
|
.result-box {
|
|
min-height: 52px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.4rem;
|
|
font-weight: 800;
|
|
letter-spacing: .05em;
|
|
border-radius: 10px;
|
|
padding: .5rem 1.5rem;
|
|
width: 100%;
|
|
transition: background .3s, color .3s;
|
|
background: var(--bg);
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
}
|
|
.result-box.res-click { background: rgba(25,108,176,.2); color: var(--primary); }
|
|
.result-box.res-long { background: rgba(224,123,0,.2); color: #e07b00; }
|
|
.result-box.res-error { background: rgba(192,0,0,.2); color: #e05050; }
|
|
|
|
/* Log */
|
|
.event-log {
|
|
width: 100%;
|
|
max-height: 160px;
|
|
overflow-y: auto;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: .5rem .75rem;
|
|
font-size: .82rem;
|
|
color: var(--text-muted);
|
|
}
|
|
.log-entry {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: .15rem 0;
|
|
border-bottom: 1px solid rgba(255,255,255,.04);
|
|
animation: fadeIn .3s ease;
|
|
}
|
|
.log-entry .log-val { color: var(--text); font-weight: 600; }
|
|
.log-entry .log-src { font-size: .75rem; opacity: .6; }
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
|
|
/* ── GPIO live ───────────────────────────────────────────────────────────── */
|
|
.gpio-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .4rem;
|
|
padding: .3rem .7rem;
|
|
border-radius: 20px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
font-size: .9rem;
|
|
font-weight: 600;
|
|
}
|
|
.gpio-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
|
|
.gpio-dot.live { background: #1a7340; animation: pulse 1s infinite; }
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
|
|
|
|
/* ── Status banner ───────────────────────────────────────────────────────── */
|
|
.status-banner {
|
|
max-width: 1100px;
|
|
margin: 1rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
.banner { padding: .65rem 1rem; border-radius: 8px; font-size: .9rem; display: none; }
|
|
.banner.show { display: block; }
|
|
.banner-ok { background: rgba(26,115,64,.15); border: 1px solid #1a7340; color: #4caf50; }
|
|
.banner-err { background: rgba(192,0,0,.12); border: 1px solid #c00; color: #e05050; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="status-banner">
|
|
<div class="banner banner-ok" id="banner-ok">✅ Réglages appliqués avec succès.</div>
|
|
<div class="banner banner-err" id="banner-err">❌ Erreur lors de l'application des réglages.</div>
|
|
</div>
|
|
|
|
<div class="settings-grid">
|
|
|
|
<!-- ── COLONNE GAUCHE : Timings ─────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="card">
|
|
<div class="card-title">⏱ Timing des clics</div>
|
|
|
|
<!-- Fenêtre multi-clic -->
|
|
<div class="timing-row">
|
|
<div class="timing-label">
|
|
<span>Fenêtre multi-clic</span>
|
|
<span class="timing-value" id="val-dc">{{ button.double_click_ms }} ms</span>
|
|
</div>
|
|
<input type="range" id="double-click-ms"
|
|
min="150" max="800" step="25"
|
|
value="{{ button.double_click_ms }}"
|
|
oninput="updateSlider('dc', this.value, ' ms')">
|
|
<div class="timing-hint">
|
|
Délai d'attente entre deux clics. Trop court = double-clic raté. Trop long = lenteur.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Long press -->
|
|
<div class="timing-row">
|
|
<div class="timing-label">
|
|
<span>Durée appui long</span>
|
|
<span class="timing-value" id="val-lp">{{ button.long_press_ms }} ms</span>
|
|
</div>
|
|
<input type="range" id="long-press-ms"
|
|
min="500" max="3000" step="100"
|
|
value="{{ button.long_press_ms }}"
|
|
oninput="updateSlider('lp', this.value, ' ms')">
|
|
<div class="timing-hint">
|
|
Durée minimale pour déclencher "impression" en maintenant le bouton appuyé.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Anti-rebond -->
|
|
<div class="timing-row">
|
|
<div class="timing-label">
|
|
<span>Anti-rebond (debounce)</span>
|
|
<span class="timing-value" id="val-db">{{ button.debounce_ms }} ms</span>
|
|
</div>
|
|
<input type="range" id="debounce-ms"
|
|
min="10" max="200" step="5"
|
|
value="{{ button.debounce_ms }}"
|
|
oninput="updateSlider('db', this.value, ' ms')">
|
|
<div class="timing-hint">
|
|
Filtre les faux contacts mécaniques. Augmenter si le bouton "rebondit".
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nombre max de clics -->
|
|
<div class="timing-row">
|
|
<div class="timing-label">
|
|
<span>Nombre max de clics détectés</span>
|
|
</div>
|
|
<select class="field" id="max-clicks">
|
|
{% for n in [1,2,3,4] %}
|
|
<option value="{{ n }}" {% if n == button.max_clicks %}selected{% endif %}>
|
|
{{ n }} clic{{ 's' if n > 1 else '' }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
<div class="timing-hint">
|
|
Correspond au nombre d'actions mappées (voir page Actions).
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Impression long press -->
|
|
<div class="timing-row">
|
|
<div class="timing-label">
|
|
<span>Impression via appui long</span>
|
|
<select class="field" id="print-enabled" style="width:auto">
|
|
<option value="true" {% if button.print_enabled %}selected{% endif %}>Activée</option>
|
|
<option value="false" {% if not button.print_enabled %}selected{% endif %}>Désactivée</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" onclick="applyTimings(false)">
|
|
▶ Appliquer (sans redémarrage)
|
|
</button>
|
|
<button class="btn btn-success" onclick="applyTimings(true)">
|
|
💾 Sauvegarder
|
|
</button>
|
|
</div>
|
|
|
|
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
|
|
<strong>Appliquer</strong> met les réglages en ligne immédiatement.<br>
|
|
<strong>Sauvegarder</strong> les persiste dans <code>settings.yaml</code> pour les redémarrages.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Contrôles interface photobooth-app -->
|
|
<div class="card" style="margin-top:1rem">
|
|
<div class="card-title">🎛 Interface photobooth-app</div>
|
|
|
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.5rem 0; border-bottom:1px solid var(--border);">
|
|
<div>
|
|
<div style="font-size:.95rem; font-weight:600;">Bouton Supprimer (review)</div>
|
|
<div style="font-size:.78rem; color:var(--text-muted); margin-top:.2rem;">
|
|
Affiche ou cache le bouton 🗑 sur l'écran de validation après chaque capture.<br>
|
|
Effectif immédiatement (modifie <code>userdata/private.css</code>).
|
|
</div>
|
|
</div>
|
|
<label class="toggle-switch" title="Bouton Supprimer visible dans photobooth-app">
|
|
<input type="checkbox" id="toggle-delete-btn"
|
|
{% if config.photobooth.show_delete_button %}checked{% endif %}
|
|
onchange="setDeleteButton(this.checked)">
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<div style="display:flex; align-items:center; gap:.75rem; margin-top:.75rem; font-size:.82rem; color:var(--text-muted);" id="delete-btn-status">
|
|
{% if config.photobooth.show_delete_button %}
|
|
✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>
|
|
{% else %}
|
|
🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions système -->
|
|
<div class="card" style="margin-top:1rem">
|
|
<div class="card-title">🖥 Affichage HDMI</div>
|
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
|
|
Rafraîchit le Chromium en kiosque connecté à l'écran HDMI.
|
|
Équivalent à appuyer sur <code>F5</code> sur le Pi.
|
|
</p>
|
|
<button class="btn btn-outline" onclick="refreshScreen()" id="btn-refresh-screen">
|
|
🔄 Rafraîchir l'écran (F5)
|
|
</button>
|
|
<span id="refresh-status" style="font-size:.82rem; color:var(--text-muted); margin-left:.75rem;"></span>
|
|
</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 style="display:flex; gap:.5rem; align-items:center; margin-top:.5rem; flex-wrap:wrap;">
|
|
<button class="btn btn-ghost btn-sm" onclick="measureAmbient()" id="btn-measure-lux">
|
|
📷 Tester la mesure
|
|
</button>
|
|
<span id="lux-status" style="font-size:.8rem; color:var(--text-muted);"></span>
|
|
</div>
|
|
<div id="lux-info" style="font-size:.78rem; margin-top:.4rem; color:var(--text-muted); display:none;">
|
|
Luminosité mesurée : <strong id="lux-val">—</strong> / 255 →
|
|
<span id="lux-desc">—</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Flash LED -->
|
|
<div class="card" style="margin-top:1rem">
|
|
<div class="card-title">⚡ Flash photo (LED)</div>
|
|
<p style="font-size:.82rem; color:var(--text-muted); margin-bottom:1rem;">
|
|
Les LEDs WS2812b ont une diode bleue plus efficace que la rouge.<br>
|
|
<code>[255,255,255]</code> produit un rendu à dominante bleue sur les photos.<br>
|
|
Réduisez le bleu et augmentez le rouge pour un blanc plus naturel.
|
|
</p>
|
|
|
|
<div style="display:grid; gap:.75rem;">
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-muted)">Couleur flash (R G B)</label>
|
|
<div style="display:flex; gap:.5rem; align-items:center; margin-top:.25rem; flex-wrap:wrap;">
|
|
<input type="number" id="flash-r" min="0" max="255" value="255"
|
|
style="width:70px" class="form-control" placeholder="R">
|
|
<input type="number" id="flash-g" min="0" max="255" value="200"
|
|
style="width:70px" class="form-control" placeholder="G">
|
|
<input type="number" id="flash-b" min="0" max="255" value="80"
|
|
style="width:70px" class="form-control" placeholder="B">
|
|
<div id="flash-preview" style="width:36px;height:36px;border-radius:50%;border:2px solid var(--border);background:rgb(255,200,80)"></div>
|
|
<input type="color" id="flash-colorpicker" value="#ffc850"
|
|
title="Aide visuelle (convertit en RGB approx)"
|
|
style="width:36px;height:36px;cursor:pointer;border:none;background:none"
|
|
onchange="colorPickerToRgb(this.value)">
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-muted)">
|
|
Durée du flash : <strong id="flash-dur-val">0.30</strong> s
|
|
</label>
|
|
<input type="range" id="flash-duration" min="0.05" max="1.0" step="0.05" value="0.30"
|
|
oninput="document.getElementById('flash-dur-val').textContent=parseFloat(this.value).toFixed(2)"
|
|
style="width:100%">
|
|
<div style="font-size:.72rem;color:var(--text-muted)">0.05 s (bref) — 1.0 s (long)</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-muted)">Nombre de flashs</label>
|
|
<select id="flash-count" class="form-control" style="width:100px; margin-top:.25rem">
|
|
<option value="1">1</option>
|
|
<option value="2" selected>2</option>
|
|
<option value="3">3</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:.5rem; flex-wrap:wrap;">
|
|
<button class="btn btn-ghost btn-sm" onclick="previewFlash()">💡 Tester le flash</button>
|
|
<button class="btn btn-primary btn-sm" onclick="saveFlash()">💾 Sauvegarder</button>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.25rem;">
|
|
<span style="font-size:.75rem; color:var(--text-muted); align-self:center;">Presets :</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,255,255,0.15,2)">Blanc pur (froid)</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,200,80,0.30,2)">Blanc chaud ★</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,220,120,0.25,2)">Blanc neutre</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,180,40,0.35,2)">Ambre</button>
|
|
</div>
|
|
|
|
<div style="margin-top:1rem; padding-top:.75rem; border-top:1px solid var(--border);">
|
|
<label style="font-size:.82rem; color:var(--text-muted)">⏱ Déclencher le flash
|
|
<strong id="pre-flash-val">0.5</strong> s avant la fin du countdown</label>
|
|
<input type="range" id="pre-flash-advance" min="0" max="3" step="0.1" value="0.5"
|
|
oninput="document.getElementById('pre-flash-val').textContent=parseFloat(this.value).toFixed(1)"
|
|
style="width:100%; margin-top:.4rem">
|
|
<div style="display:flex; justify-content:space-between; font-size:.72rem; color:var(--text-muted)">
|
|
<span>0 s (au moment de la capture)</span><span>3 s (très en avance)</span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" style="margin-top:.5rem" onclick="savePreFlash()">💾 Sauvegarder</button>
|
|
<span id="pre-flash-status" style="font-size:.82rem; color:var(--text-muted); margin-left:.5rem"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Valeurs recommandées -->
|
|
<div class="card" style="margin-top:1rem">
|
|
<div class="card-title">📋 Préréglages</div>
|
|
<div style="display:flex; flex-wrap:wrap; gap:.5rem;">
|
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(350, 1200, 30, 4)">Rapide</button>
|
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(400, 1500, 50, 4)">Normal (défaut)</button>
|
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(500, 2000, 80, 4)">Lent / senior</button>
|
|
<button class="btn btn-outline btn-sm" onclick="applyPreset(600, 2500, 100, 4)">Très lent</button>
|
|
</div>
|
|
<p style="font-size:.78rem; color:var(--text-muted); margin-top:.75rem;">
|
|
Les préréglages mettent à jour les sliders uniquement.
|
|
Cliquez ensuite sur Appliquer ou Sauvegarder.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── COLONNE DROITE : Testeur ─────────────────────────────────────────── -->
|
|
<div>
|
|
<div class="card">
|
|
<div class="card-title">🎯 Testeur de timings</div>
|
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:1rem;">
|
|
Simulation <strong>locale</strong> — utilise les valeurs des sliders en temps réel,
|
|
même avant de sauvegarder. Aucune action GPIO déclenchée.
|
|
</p>
|
|
|
|
<div class="tester-area">
|
|
|
|
<!-- Anneau de clics -->
|
|
<div class="click-dots" id="click-dots">
|
|
<div class="click-dot" id="dot-1"></div>
|
|
<div class="click-dot" id="dot-2"></div>
|
|
<div class="click-dot" id="dot-3"></div>
|
|
<div class="click-dot" id="dot-4"></div>
|
|
</div>
|
|
|
|
<!-- Grand bouton -->
|
|
<div id="big-btn"
|
|
onmousedown="btnDown(event)"
|
|
onmouseup="btnUp(event)"
|
|
onmouseleave="btnUp(event)"
|
|
ontouchstart="btnDown(event)"
|
|
ontouchend="btnUp(event)">
|
|
<span class="btn-icon">👆</span>
|
|
<span>Appuyer ici</span>
|
|
<span class="btn-hint">clic / multi-clic / maintenir</span>
|
|
</div>
|
|
|
|
<!-- Résultat -->
|
|
<div class="result-box" id="result-box">En attente…</div>
|
|
|
|
<!-- Log local -->
|
|
<div class="event-log" id="tester-log">
|
|
<div style="opacity:.4; text-align:center; padding:.5rem 0">Les événements apparaîtront ici</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- GPIO réel via WebSocket -->
|
|
<div class="card" style="margin-top:1rem">
|
|
<div class="card-title">
|
|
🔌 Bouton GPIO réel
|
|
<div class="gpio-badge" style="margin-left:auto">
|
|
<div class="gpio-dot" id="gpio-dot"></div>
|
|
<span id="gpio-label">En attente…</span>
|
|
</div>
|
|
</div>
|
|
<p style="font-size:.85rem; color:var(--text-muted); margin-bottom:.75rem;">
|
|
Événements reçus du bouton physique (GPIO {{ button.pin }}) via WebSocket.
|
|
</p>
|
|
<div class="event-log" id="gpio-log">
|
|
<div style="opacity:.4; text-align:center; padding:.5rem 0">Appuyer sur le bouton physique…</div>
|
|
</div>
|
|
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(1)">Sim. 1 clic</button>
|
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(2)">Sim. 2 clics</button>
|
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(3)">Sim. 3 clics</button>
|
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(4)">Sim. 4 clics</button>
|
|
<button class="btn btn-outline btn-sm" onclick="simulateGPIO(0)">Sim. Long press</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Sliders & valeurs
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
function updateSlider(key, val, unit) {
|
|
document.getElementById('val-' + key).textContent = val + unit;
|
|
}
|
|
|
|
function getTimings() {
|
|
return {
|
|
doubleClickMs: parseInt(document.getElementById('double-click-ms').value),
|
|
longPressMs: parseInt(document.getElementById('long-press-ms').value),
|
|
debounceMs: parseInt(document.getElementById('debounce-ms').value),
|
|
maxClicks: parseInt(document.getElementById('max-clicks').value),
|
|
};
|
|
}
|
|
|
|
function applyPreset(dc, lp, db, mc) {
|
|
document.getElementById('double-click-ms').value = dc;
|
|
document.getElementById('long-press-ms').value = lp;
|
|
document.getElementById('debounce-ms').value = db;
|
|
document.getElementById('max-clicks').value = mc;
|
|
updateSlider('dc', dc, ' ms');
|
|
updateSlider('lp', lp, ' ms');
|
|
updateSlider('db', db, ' ms');
|
|
showDots(0);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Apply / Save via API
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
async function applyTimings(save) {
|
|
const t = getTimings();
|
|
const printEnabled = document.getElementById('print-enabled').value === 'true';
|
|
|
|
try {
|
|
const r = await api('PUT', '/api/system/button/config', {
|
|
double_click_ms: t.doubleClickMs,
|
|
long_press_ms: t.longPressMs,
|
|
debounce_ms: t.debounceMs,
|
|
max_clicks: t.maxClicks,
|
|
print_enabled: printEnabled,
|
|
save: save,
|
|
});
|
|
|
|
document.getElementById('banner-ok').className = 'banner banner-ok show';
|
|
document.getElementById('banner-err').className = 'banner banner-err';
|
|
setTimeout(() => {
|
|
document.getElementById('banner-ok').className = 'banner banner-ok';
|
|
}, 3000);
|
|
|
|
showToast(save ? '✅ Réglages sauvegardés dans settings.yaml' : '▶ Timings appliqués (non sauvegardés)', 'success');
|
|
} catch(e) {
|
|
document.getElementById('banner-err').className = 'banner banner-err show';
|
|
document.getElementById('banner-ok').className = 'banner banner-ok';
|
|
showToast('Erreur : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Testeur de timings (simulation locale — AUCUN GPIO)
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
let testerClickCount = 0;
|
|
let testerClickTimer = null;
|
|
let testerLongTimer = null;
|
|
let testerLongFired = false;
|
|
|
|
function btnDown(e) {
|
|
e.preventDefault();
|
|
document.getElementById('big-btn').classList.add('pressed');
|
|
testerLongFired = false;
|
|
if (testerLongTimer) clearTimeout(testerLongTimer);
|
|
const { longPressMs } = getTimings();
|
|
|
|
testerLongTimer = setTimeout(() => {
|
|
testerLongFired = true;
|
|
if (testerClickTimer) clearTimeout(testerClickTimer);
|
|
testerClickCount = 0;
|
|
showDots(0);
|
|
showTesterResult('LONG PRESS', 'long');
|
|
addLog('tester-log', 'Long press', 'testeur');
|
|
}, longPressMs);
|
|
}
|
|
|
|
function btnUp(e) {
|
|
e.preventDefault();
|
|
document.getElementById('big-btn').classList.remove('pressed');
|
|
if (testerLongTimer) clearTimeout(testerLongTimer);
|
|
if (testerLongFired) return;
|
|
|
|
const { doubleClickMs, maxClicks } = getTimings();
|
|
testerClickCount++;
|
|
showDots(testerClickCount);
|
|
|
|
if (testerClickTimer) clearTimeout(testerClickTimer);
|
|
|
|
const delay = testerClickCount >= maxClicks ? 50 : doubleClickMs;
|
|
|
|
testerClickTimer = setTimeout(() => {
|
|
const n = testerClickCount;
|
|
testerClickCount = 0;
|
|
testerClickTimer = null;
|
|
showDots(0);
|
|
const label = n === 1 ? '1 CLIC' : `${n} CLICS`;
|
|
showTesterResult(label, 'click');
|
|
addLog('tester-log', label, 'testeur');
|
|
}, delay);
|
|
}
|
|
|
|
function showDots(count) {
|
|
const max = parseInt(document.getElementById('max-clicks').value);
|
|
for (let i = 1; i <= 4; i++) {
|
|
const d = document.getElementById('dot-' + i);
|
|
if (i > max) {
|
|
d.className = 'click-dot';
|
|
d.style.opacity = '.2';
|
|
} else {
|
|
d.style.opacity = '1';
|
|
d.className = 'click-dot' + (i <= count ? (count >= max ? ' max' : ' active') : '');
|
|
}
|
|
}
|
|
}
|
|
|
|
function showTesterResult(label, type) {
|
|
const box = document.getElementById('result-box');
|
|
box.textContent = label;
|
|
box.className = 'result-box res-' + (type === 'long' ? 'long' : type === 'click' ? 'click' : 'error');
|
|
setTimeout(() => { box.className = 'result-box'; }, 2500);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Simulation GPIO serveur
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
async function simulateGPIO(clicks) {
|
|
try {
|
|
await api('POST', `/api/system/button/simulate?clicks=${clicks}`);
|
|
showToast(`Simulation envoyée : ${clicks === 0 ? 'long press' : clicks + ' clic(s)'}`, 'info');
|
|
} catch(e) {
|
|
showToast('Erreur simulation : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// WebSocket — écoute du bouton GPIO réel
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
function onWsMessage(msg) {
|
|
if (msg.type !== 'button_event') return;
|
|
|
|
const dot = document.getElementById('gpio-dot');
|
|
const label = document.getElementById('gpio-label');
|
|
|
|
dot.classList.add('live');
|
|
setTimeout(() => dot.classList.remove('live'), 1500);
|
|
|
|
let text;
|
|
if (msg.clicks === 0) {
|
|
text = 'LONG PRESS → Impression';
|
|
label.textContent = '🖨 Long press';
|
|
} else {
|
|
const clicks = msg.clicks;
|
|
text = `${clicks} CLIC${clicks > 1 ? 'S' : ''} → ${msg.action || ''}`;
|
|
label.textContent = `${clicks} clic${clicks > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
addLog('gpio-log', text, 'GPIO');
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Helpers
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
function addLog(targetId, value, source) {
|
|
const log = document.getElementById(targetId);
|
|
|
|
// Vider le placeholder si présent
|
|
if (log.querySelector('[style*="opacity"]')) log.innerHTML = '';
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = 'log-entry';
|
|
const now = new Date().toLocaleTimeString('fr-FR');
|
|
entry.innerHTML = `
|
|
<span class="log-val">${value}</span>
|
|
<span class="log-src">[${source}] ${now}</span>
|
|
`;
|
|
log.insertBefore(entry, log.firstChild);
|
|
|
|
// Garder 20 lignes max
|
|
while (log.children.length > 20) log.removeChild(log.lastChild);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Bouton Supprimer photobooth-app
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
async function setDeleteButton(visible) {
|
|
try {
|
|
const r = await api('POST', `/api/system/ui/delete-button?visible=${visible}`);
|
|
const status = document.getElementById('delete-btn-status');
|
|
if (r.ok) {
|
|
status.innerHTML = visible
|
|
? '✅ Bouton Supprimer actuellement <strong style="color:#4caf50">visible</strong>'
|
|
: '🚫 Bouton Supprimer actuellement <strong style="color:#e07b00">caché</strong>';
|
|
showToast(visible ? '✅ Bouton Supprimer activé' : '🚫 Bouton Supprimer désactivé', 'success');
|
|
} else {
|
|
showToast('❌ Erreur écriture private.css', 'error');
|
|
// Remettre le toggle dans l'état précédent
|
|
document.getElementById('toggle-delete-btn').checked = !visible;
|
|
}
|
|
} catch(e) {
|
|
showToast('Erreur : ' + e.message, 'error');
|
|
document.getElementById('toggle-delete-btn').checked = !visible;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Refresh écran HDMI
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
async function refreshScreen() {
|
|
const btn = document.getElementById('btn-refresh-screen');
|
|
const status = document.getElementById('refresh-status');
|
|
btn.disabled = true;
|
|
status.textContent = 'Envoi…';
|
|
try {
|
|
const r = await api('POST', '/api/system/screen/refresh');
|
|
status.style.color = '#4caf50';
|
|
status.textContent = r.ok ? `✅ OK (${r.method})` : `⚠ ${r.error}`;
|
|
showToast('Écran rafraîchi', 'success');
|
|
} catch(e) {
|
|
status.style.color = '#e05050';
|
|
status.textContent = '❌ Erreur : ' + e.message;
|
|
showToast('Erreur refresh : ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
setTimeout(() => { status.textContent = ''; }, 4000);
|
|
}
|
|
}
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 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é fixe';
|
|
} catch(e) {
|
|
console.warn('brightness config:', e);
|
|
}
|
|
}
|
|
|
|
async function previewBrightness() {
|
|
const val = parseInt(document.getElementById('brightness-slider').value);
|
|
document.getElementById('brightness-val').textContent = val;
|
|
try {
|
|
await api('PUT', `/api/leds/brightness?value=${val}&save=false`);
|
|
showToast(`👁 Aperçu luminosité : ${val}`, 'info');
|
|
} catch(e) {
|
|
showToast('Erreur aperçu : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveBrightness() {
|
|
const val = parseInt(document.getElementById('brightness-slider').value);
|
|
try {
|
|
await api('PUT', `/api/leds/brightness?value=${val}`);
|
|
showToast(`✅ Luminosité ${val} sauvegardée`, 'success');
|
|
} catch(e) {
|
|
showToast('Erreur : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function previewFlash() {
|
|
try {
|
|
await api('POST', '/api/leds/effect?effect=capture');
|
|
showToast('💡 Flash déclenché', 'info');
|
|
} catch(e) {
|
|
showToast('Erreur flash : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function setFlashPreset(r, g, b, duration, flashes) {
|
|
document.getElementById('flash-r').value = r;
|
|
document.getElementById('flash-g').value = g;
|
|
document.getElementById('flash-b').value = b;
|
|
document.getElementById('flash-duration').value = duration;
|
|
document.getElementById('flash-dur-val').textContent = duration.toFixed(2);
|
|
document.getElementById('flash-count').value = flashes;
|
|
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
|
|
const hex = '#' + [r,g,b].map(v => v.toString(16).padStart(2,'0')).join('');
|
|
document.getElementById('flash-colorpicker').value = hex;
|
|
}
|
|
|
|
async function saveFlash() {
|
|
const r = parseInt(document.getElementById('flash-r').value) || 255;
|
|
const g = parseInt(document.getElementById('flash-g').value) || 200;
|
|
const b = parseInt(document.getElementById('flash-b').value) || 80;
|
|
const duration = parseFloat(document.getElementById('flash-duration').value) || 0.3;
|
|
const flashes = parseInt(document.getElementById('flash-count').value) || 2;
|
|
try {
|
|
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${duration}&flashes=${flashes}`);
|
|
showToast('✅ Flash sauvegardé', 'success');
|
|
} catch(e) {
|
|
showToast('Erreur : ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function colorPickerToRgb(hex) {
|
|
const r = parseInt(hex.slice(1,3), 16);
|
|
const g = parseInt(hex.slice(3,5), 16);
|
|
const b = parseInt(hex.slice(5,7), 16);
|
|
document.getElementById('flash-r').value = r;
|
|
document.getElementById('flash-g').value = g;
|
|
document.getElementById('flash-b').value = b;
|
|
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
|
|
}
|
|
|
|
async function loadFlashConfig() {
|
|
try {
|
|
const effects = await api('GET', '/api/leds/effects');
|
|
const cap = effects.capture;
|
|
if (!cap) return;
|
|
if (cap.color) {
|
|
document.getElementById('flash-r').value = cap.color[0] ?? 255;
|
|
document.getElementById('flash-g').value = cap.color[1] ?? 200;
|
|
document.getElementById('flash-b').value = cap.color[2] ?? 80;
|
|
document.getElementById('flash-preview').style.background =
|
|
`rgb(${cap.color[0]},${cap.color[1]},${cap.color[2]})`;
|
|
}
|
|
if (cap.flash_duration !== undefined) {
|
|
document.getElementById('flash-duration').value = cap.flash_duration;
|
|
document.getElementById('flash-dur-val').textContent = cap.flash_duration.toFixed(2);
|
|
}
|
|
if (cap.flashes !== undefined) {
|
|
document.getElementById('flash-count').value = cap.flashes;
|
|
}
|
|
} catch(e) { console.warn('loadFlashConfig:', e); }
|
|
// Charger le timing pre-flash
|
|
try {
|
|
const t = await api('GET', '/api/leds/flash-timing');
|
|
const v = t.pre_flash_advance ?? 0.5;
|
|
document.getElementById('pre-flash-advance').value = v;
|
|
document.getElementById('pre-flash-val').textContent = v.toFixed(1);
|
|
} catch(e) { console.warn('loadFlashTiming:', e); }
|
|
}
|
|
|
|
async function savePreFlash() {
|
|
const v = parseFloat(document.getElementById('pre-flash-advance').value);
|
|
const status = document.getElementById('pre-flash-status');
|
|
try {
|
|
await api('PUT', `/api/leds/flash-timing?pre_flash_advance=${v}`);
|
|
status.textContent = `✅ Sauvegardé (${v.toFixed(1)} s)`;
|
|
setTimeout(() => status.textContent = '', 3000);
|
|
} catch(e) {
|
|
status.textContent = '❌ Erreur';
|
|
}
|
|
}
|
|
|
|
async function measureAmbient() {
|
|
const btn = document.getElementById('btn-measure-lux');
|
|
const status = document.getElementById('lux-status');
|
|
const info = document.getElementById('lux-info');
|
|
btn.disabled = true;
|
|
status.textContent = '⏳ Mesure en cours…';
|
|
status.style.color = 'var(--text-muted)';
|
|
try {
|
|
const r = await api('POST', '/api/leds/measure-ambient');
|
|
if (r.ok) {
|
|
document.getElementById('lux-val').textContent = r.ambient_lux;
|
|
document.getElementById('lux-desc').textContent = r.description;
|
|
info.style.display = '';
|
|
status.textContent = '✅ Mesure réussie';
|
|
status.style.color = '#4caf50';
|
|
showToast(`✅ Luminosité : ${r.ambient_lux} — ${r.description}`, 'success');
|
|
} else {
|
|
status.textContent = '❌ ' + (r.error || 'Erreur inconnue');
|
|
status.style.color = '#e05050';
|
|
showToast('❌ Mesure impossible : ' + r.error, 'error');
|
|
}
|
|
} catch(e) {
|
|
status.textContent = '❌ Liveview inaccessible';
|
|
status.style.color = '#e05050';
|
|
showToast('❌ Liveview inaccessible (endpoint 404). Vérifiez que photobooth-app est démarré.', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
setTimeout(() => { status.textContent = ''; }, 6000);
|
|
}
|
|
}
|
|
|
|
async function setAutoBrightness(enabled) {
|
|
try {
|
|
await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}`);
|
|
document.getElementById('auto-brightness-status').textContent = enabled
|
|
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
|
|
: '⏸ Auto-flash désactivé — luminosité fixe';
|
|
showToast(enabled ? '✅ Auto-flash activé' : '⏸ Auto-flash désactivé', 'success');
|
|
} catch(e) {
|
|
showToast('Erreur : ' + e.message, 'error');
|
|
document.getElementById('toggle-auto-brightness').checked = !enabled;
|
|
}
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
loadBrightnessConfig();
|
|
loadFlashConfig();
|
|
</script>
|
|
{% endblock %} |