Files
ha-led-remote/www/led/index.html
T
bachirandClaude Opus 5.5 e4bd58afa7 Télécommande LED pour chambre d'enfant via Home Assistant
- Package HA : 8 scripts d'effets (calmes et « boum ») et une
  automatisation webhook local_only avec liste blanche stricte
- Page web autonome (HTML/CSS/JS ES5) tenant sur un écran de lecteur MP3
- Mini APK Android (WebView verrouillée) compilé sans Gradle
- Modèles + config.local.env non versionné : aucune valeur réelle
  (IP, ID du webhook, entité, prénom) dans le dépôt ; deploy.sh génère
  et déploie, android/build.sh injecte la configuration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:29:51 +02:00

448 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ma lumière</title>
<meta name="theme-color" content="#141a3a">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Ma lumière">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="apple-touch-icon" href="icon-180.png">
<style>
* { -webkit-box-sizing: border-box; box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #141a3a; }
html { height: 100%; }
body {
min-height: 100%;
color: #fff;
font-family: "Trebuchet MS", "Roboto", "Droid Sans", sans-serif;
font-weight: bold;
background: #141a3a;
background: -webkit-linear-gradient(top, #1d2452 0%, #141a3a 60%, #0d1129 100%);
background: linear-gradient(to bottom, #1d2452 0%, #141a3a 60%, #0d1129 100%);
background-attachment: fixed;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Tout doit tenir sur un écran d'environ 340 x 530 px (Fanvace M36) */
.page { max-width: 420px; margin: 0 auto; padding: 10px 8px 12px; }
button {
font: inherit; color: inherit; border: 0; cursor: pointer;
-webkit-appearance: none; appearance: none;
touch-action: manipulation;
-webkit-transition: -webkit-transform .12s ease-out;
transition: transform .12s ease-out;
}
button:focus { outline: none; }
button.press, button:active { -webkit-transform: scale(.9); transform: scale(.9); }
.clear:after { content: ""; display: table; clear: both; }
.bloc { margin-top: 8px; }
/* ---- En-tête : titre, puis Veilleuse + Éteindre ---- */
.title { font-size: 16px; text-align: center; padding: 0 0 4px; }
.title span { color: #ffd38a; }
.pill {
display: block; width: 100%; height: 50px; border-radius: 25px;
background: #2b335f; border: 3px solid #48528a;
font-size: 16px; line-height: 44px; text-align: center; white-space: nowrap;
}
.pill svg { width: 24px; height: 24px; vertical-align: middle; margin: -3px 6px 0 0; }
.pill-nuit { background: #0b0f2a; border-color: #333c78; }
.pill-nuit small { font-size: 12px; color: #ffd38a; }
/* ---- Pastilles de couleur (5 + 4) ---- */
.cell5 { float: left; width: 20%; padding: 5px; }
.rang2 { padding: 0 10%; }
.rang2 .cell5 { width: 25%; }
.sw {
display: block; width: 100%; height: 0; padding: 0 0 100% 0;
border-radius: 50%; position: relative;
box-shadow: inset 0 -5px 0 rgba(0,0,0,.18), 0 3px 8px rgba(0,0,0,.35);
}
/* reflet */
.sw:before {
content: ""; position: absolute; left: 20%; top: 12%; width: 32%; height: 22%;
border-radius: 50%; background: rgba(255,255,255,.35);
}
/* ---- Luminosité (4 boutons) ---- */
.cell4 { float: left; width: 25%; padding: 4px; }
.lum {
display: block; width: 100%; height: 56px; border-radius: 16px;
background: #2b335f; border: 3px solid #48528a; position: relative;
}
.lum svg { position: absolute; left: 50%; top: 50%; }
.l1 svg { width: 16px; height: 16px; margin: -8px 0 0 -8px; opacity: .45; }
.l2 svg { width: 24px; height: 24px; margin: -12px 0 0 -12px; opacity: .65; }
.l3 svg { width: 32px; height: 32px; margin: -16px 0 0 -16px; opacity: .85; }
.l4 svg { width: 40px; height: 40px; margin: -20px 0 0 -20px; }
/* ---- Effets : 4 calmes + 3 « boum » ---- */
.cell3 { float: left; width: 33.333%; padding: 4px; }
.cell2 { float: left; width: 50%; padding: 4px; }
.fx {
display: block; width: 100%; height: 92px; border-radius: 18px;
position: relative; overflow: hidden; text-align: center;
box-shadow: inset 0 -5px 0 rgba(0,0,0,.2), 0 3px 8px rgba(0,0,0,.35);
}
.fx svg { position: absolute; left: 50%; top: 10px; margin-left: -30px; width: 60px; height: 42px; }
.fx b {
position: absolute; left: 2px; right: 2px; bottom: 9px;
font-size: 14px; text-shadow: 0 1px 3px rgba(0,0,0,.6);
}
/* tuiles étroites (4 par rangée) : nom sur 2 lignes possible */
.cell4 .fx svg { width: 52px; height: 38px; margin-left: -26px; }
.cell4 .fx b { font-size: 12px; line-height: 14px; bottom: 7px; }
.fx-arc { background: #3a6fd8; background: -webkit-linear-gradient(top, #5fa8ff, #2f5fc8); background: linear-gradient(to bottom, #5fa8ff, #2f5fc8); }
.fx-resp { background: #6b3fb8; background: -webkit-linear-gradient(top, #9a62e6, #5a2fa0); background: linear-gradient(to bottom, #9a62e6, #5a2fa0); }
.fx-sun { background: #e0653a; background: -webkit-linear-gradient(top, #ffb347, #f0607a 55%, #7b3fa8); background: linear-gradient(to bottom, #ffb347, #f0607a 55%, #7b3fa8); }
.fx-sea { background: #1477b8; background: -webkit-linear-gradient(top, #2fc4d8, #1a5fb4 60%, #12357a); background: linear-gradient(to bottom, #2fc4d8, #1a5fb4 60%, #12357a); }
/* tuiles néon (boum) */
.fx-neon { background: #150a2e; border: 3px solid #ff4fc0; box-shadow: 0 0 12px rgba(255,79,192,.45), inset 0 -5px 0 rgba(0,0,0,.3); }
.fx-disco { -webkit-animation: neon 3s steps(1) infinite; animation: neon 3s steps(1) infinite; }
.fx-boum { border-color: #29b8ff; box-shadow: 0 0 12px rgba(41,184,255,.5), inset 0 -5px 0 rgba(0,0,0,.3); }
.fx-tourb { border-color: #ffd91f; box-shadow: 0 0 12px rgba(255,217,31,.45), inset 0 -5px 0 rgba(0,0,0,.3); }
.fx-tourb svg { -webkit-animation: tourne 4s linear infinite; animation: tourne 4s linear infinite; }
@-webkit-keyframes neon {
0% { border-color: #ff4fc0; } 25% { border-color: #29b8ff; }
50% { border-color: #ffd91f; } 75% { border-color: #a14dff; }
}
@keyframes neon {
0% { border-color: #ff4fc0; } 25% { border-color: #29b8ff; }
50% { border-color: #ffd91f; } 75% { border-color: #a14dff; }
}
@-webkit-keyframes tourne { to { -webkit-transform: rotate(360deg); } }
@keyframes tourne { to { transform: rotate(360deg); } }
/* bulle qui respire */
.breath {
position: absolute; left: 50%; top: 10px; width: 42px; height: 42px; margin-left: -21px;
border-radius: 50%; background: #fff;
box-shadow: 0 0 16px 5px rgba(255,255,255,.7);
-webkit-animation: breath 6s ease-in-out infinite;
animation: breath 6s ease-in-out infinite;
}
@-webkit-keyframes breath {
0%, 100% { -webkit-transform: scale(.55); opacity: .35; }
50% { -webkit-transform: scale(1); opacity: 1; }
}
@keyframes breath {
0%, 100% { transform: scale(.55); opacity: .35; }
50% { transform: scale(1); opacity: 1; }
}
/* ---- Retour visuel après envoi ---- */
.ok { -webkit-animation: ok .6s ease-out; animation: ok .6s ease-out; }
@-webkit-keyframes ok {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,.9); }
100% { box-shadow: 0 0 0 14px rgba(255,255,255,0); }
}
@keyframes ok {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,.9); }
100% { box-shadow: 0 0 0 14px rgba(255,255,255,0); }
}
.err { -webkit-animation: err .4s linear; animation: err .4s linear; }
@-webkit-keyframes err {
0%, 100% { -webkit-transform: translateX(0); }
25% { -webkit-transform: translateX(-8px); }
75% { -webkit-transform: translateX(8px); }
}
@keyframes err {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
.toast {
position: fixed; left: 16px; right: 16px; bottom: 16px;
padding: 14px; border-radius: 16px; text-align: center; font-size: 16px;
background: #d9415e; box-shadow: 0 6px 18px rgba(0,0,0,.4);
opacity: 0; visibility: hidden;
-webkit-transition: opacity .25s, visibility .25s;
transition: opacity .25s, visibility .25s;
}
.toast.show { opacity: 1; visibility: visible; }
@media (prefers-reduced-motion: reduce) {
.breath, .fx-disco, .fx-tourb svg { -webkit-animation: none; animation: none; }
.breath { opacity: .8; }
}
@media (max-width: 300px) {
.pill { font-size: 14px; }
.fx b, .cell4 .fx b { font-size: 11px; }
}
</style>
</head>
<body ontouchstart="">
<div class="page">
<h1 class="title">La lumière de <span>__PRENOM__</span></h1>
<div class="clear">
<div class="cell2">
<button type="button" class="pill pill-nuit" data-action="effet" data-nom="veilleuse" aria-label="Veilleuse, s'éteint dans 30 minutes">
<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M17 3a13 13 0 1 0 13 17A10.5 10.5 0 0 1 17 3z" fill="#ffd38a"/></svg>Veilleuse <small>30&nbsp;min</small>
</button>
</div>
<div class="cell2">
<button type="button" class="pill" data-action="off" aria-label="Éteindre">
<svg viewBox="0 0 32 32" aria-hidden="true"><path d="M10 7.5a11 11 0 1 0 12 0" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/><path d="M16 3v12" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/></svg>Éteindre
</button>
</div>
</div>
<!-- Couleurs -->
<div class="bloc">
<div class="clear">
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="0" style="background:#ff2a2a" aria-label="Rouge"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="25" style="background:#ff8a1f" aria-label="Orange"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="50" style="background:#ffd91f" aria-label="Jaune"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="120" style="background:#2fe05a" aria-label="Vert"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="170" style="background:#1fe0c4" aria-label="Turquoise"></button></div>
</div>
<div class="clear rang2">
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="200" style="background:#29b8ff" aria-label="Bleu ciel"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="235" style="background:#3d5cff" aria-label="Bleu"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="275" style="background:#a14dff" aria-label="Violet"></button></div>
<div class="cell5"><button type="button" class="sw" data-action="couleur" data-teinte="320" style="background:#ff4fc0" aria-label="Rose"></button></div>
</div>
</div>
<!-- Plus ou moins fort -->
<div class="bloc clear">
<div class="cell4"><button type="button" class="lum l1" data-action="luminosite" data-valeur="10" aria-label="Très doux"><svg viewBox="0 0 32 32" aria-hidden="true"><use xlink:href="#soleil" href="#soleil"/></svg></button></div>
<div class="cell4"><button type="button" class="lum l2" data-action="luminosite" data-valeur="40" aria-label="Doux"><svg viewBox="0 0 32 32" aria-hidden="true"><use xlink:href="#soleil" href="#soleil"/></svg></button></div>
<div class="cell4"><button type="button" class="lum l3" data-action="luminosite" data-valeur="70" aria-label="Fort"><svg viewBox="0 0 32 32" aria-hidden="true"><use xlink:href="#soleil" href="#soleil"/></svg></button></div>
<div class="cell4"><button type="button" class="lum l4" data-action="luminosite" data-valeur="100" aria-label="Très fort"><svg viewBox="0 0 32 32" aria-hidden="true"><use xlink:href="#soleil" href="#soleil"/></svg></button></div>
</div>
<!-- Animations calmes -->
<div class="bloc clear">
<div class="cell4">
<button type="button" class="fx fx-arc" data-action="effet" data-nom="arc_en_ciel" aria-label="Arc-en-ciel">
<svg viewBox="0 0 64 44" aria-hidden="true" fill="none" stroke-width="5">
<path d="M6 40a26 26 0 0 1 52 0" stroke="#ff3b3b"/>
<path d="M11 40a21 21 0 0 1 42 0" stroke="#ff9a1f"/>
<path d="M16 40a16 16 0 0 1 32 0" stroke="#ffe11f"/>
<path d="M21 40a11 11 0 0 1 22 0" stroke="#3be05a"/>
<path d="M26 40a6 6 0 0 1 12 0" stroke="#7a4dff"/>
</svg>
<b>Arc-en-ciel</b>
</button>
</div>
<div class="cell4">
<button type="button" class="fx fx-resp" data-action="effet" data-nom="respiration" aria-label="Respiration">
<span class="breath"></span>
<b>Respire</b>
</button>
</div>
<div class="cell4">
<button type="button" class="fx fx-sea" data-action="effet" data-nom="ocean" aria-label="Océan">
<svg viewBox="0 0 64 44" aria-hidden="true" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round">
<path d="M4 12q7-8 14 0t14 0t14 0t14 0"/>
<path d="M4 24q7-8 14 0t14 0t14 0t14 0" opacity=".8"/>
<path d="M4 36q7-8 14 0t14 0t14 0t14 0" opacity=".6"/>
</svg>
<b>Océan</b>
</button>
</div>
<div class="cell4">
<button type="button" class="fx fx-sun" data-action="effet" data-nom="coucher_de_soleil" aria-label="Coucher de soleil">
<svg viewBox="0 0 64 44" aria-hidden="true">
<path d="M12 34a20 20 0 0 1 40 0z" fill="#ffe08a"/>
<rect x="4" y="34" width="56" height="3" rx="1.5" fill="#fff"/>
<rect x="16" y="40" width="32" height="2.5" rx="1.25" fill="#ffe08a" opacity=".7"/>
</svg>
<b>Coucher de soleil</b>
</button>
</div>
</div>
<!-- Animations boum -->
<div class="bloc clear">
<div class="cell3">
<button type="button" class="fx fx-neon fx-disco" data-action="effet" data-nom="disco" aria-label="Disco">
<svg viewBox="0 0 64 44" aria-hidden="true">
<path d="M32 0v6" stroke="#9aa3d6" stroke-width="2"/>
<circle cx="32" cy="24" r="17" fill="#c9d1ff"/>
<g fill="none" stroke="#6f78b8" stroke-width="1.6">
<ellipse cx="32" cy="24" rx="17" ry="6"/>
<path d="M15 24h34M32 7v34"/>
<ellipse cx="32" cy="24" rx="7" ry="17"/>
</g>
<path d="M8 8l2 4 4 2-4 2-2 4-2-4-4-2 4-2z" fill="#ff4fc0"/>
<path d="M56 26l1.5 3 3 1.5-3 1.5-1.5 3-1.5-3-3-1.5 3-1.5z" fill="#29b8ff"/>
<path d="M54 4l1.2 2.4 2.4 1.2-2.4 1.2L54 11.2l-1.2-2.4-2.4-1.2 2.4-1.2z" fill="#ffd91f"/>
</svg>
<b>Disco</b>
</button>
</div>
<div class="cell3">
<button type="button" class="fx fx-neon fx-boum" data-action="effet" data-nom="boum" aria-label="Boum">
<svg viewBox="0 0 64 44" aria-hidden="true">
<rect x="20" y="2" width="24" height="40" rx="5" fill="#2b335f" stroke="#29b8ff" stroke-width="2"/>
<circle cx="32" cy="12" r="4.5" fill="#29b8ff"/>
<circle cx="32" cy="29" r="9" fill="#29b8ff"/>
<circle cx="32" cy="29" r="3.5" fill="#150a2e"/>
<g fill="none" stroke-width="3" stroke-linecap="round">
<path d="M12 14a14 14 0 0 0 0 16" stroke="#ff4fc0"/>
<path d="M6 9a22 22 0 0 0 0 26" stroke="#ffd91f"/>
<path d="M52 14a14 14 0 0 1 0 16" stroke="#ff4fc0"/>
<path d="M58 9a22 22 0 0 1 0 26" stroke="#ffd91f"/>
</g>
</svg>
<b>Boum</b>
</button>
</div>
<div class="cell3">
<button type="button" class="fx fx-neon fx-tourb" data-action="effet" data-nom="tourbillon" aria-label="Tourbillon">
<svg viewBox="0 0 64 44" aria-hidden="true">
<defs>
<linearGradient id="arcenciel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ff3b3b"/>
<stop offset=".25" stop-color="#ffe11f"/>
<stop offset=".5" stop-color="#3be05a"/>
<stop offset=".75" stop-color="#29b8ff"/>
<stop offset="1" stop-color="#a14dff"/>
</linearGradient>
</defs>
<path d="M32 22a3 3 0 0 1 6 0a6 6 0 0 1-12 0a9 9 0 0 1 18 0a12 12 0 0 1-24 0a15 15 0 0 1 30 0a18 18 0 0 1-18 18"
fill="none" stroke="url(#arcenciel)" stroke-width="3.5" stroke-linecap="round"/>
</svg>
<b>Tourbillon</b>
</button>
</div>
</div>
</div>
<div class="toast" id="toast">Oups&nbsp;! La lumière ne répond pas.</div>
<!-- Icône soleil réutilisée par les boutons de luminosité -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<g id="soleil" fill="#ffd38a" stroke="#ffd38a" stroke-width="2.6" stroke-linecap="round">
<circle cx="16" cy="16" r="6.5" stroke="none"/>
<path d="M16 2.5v3.5M16 26v3.5M2.5 16h3.5M26 16h3.5M6.5 6.5l2.5 2.5M23 23l2.5 2.5M6.5 25.5L9 23M23 9l2.5-2.5"/>
</g>
</defs>
</svg>
<script>
/* ES5 uniquement : doit tourner sur un vieux navigateur Android. */
(function () {
'use strict';
var WEBHOOK = '/api/webhook/__WEBHOOK_ID__';
var ECART_MIN = 700; // ms entre deux envois, pour ménager le réseau Zigbee
var dernierEnvoi = 0;
var enCours = false;
var enAttente = null; // seule la dernière demande compte
var minuterie = null;
var toast = document.getElementById('toast');
var toastMinuterie = null;
function maintenant() { return new Date().getTime(); }
function poster(corps, fini) {
var json = JSON.stringify(corps);
if (window.fetch) {
window.fetch(WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: json
}).then(function (r) { fini(r.ok); }, function () { fini(false); });
return;
}
var xhr = new XMLHttpRequest();
xhr.open('POST', WEBHOOK, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) { fini(xhr.status >= 200 && xhr.status < 300); }
};
xhr.send(json);
}
function rejouerAnimation(el, classe) {
el.className = el.className.replace(/\s*\b(ok|err)\b/g, '');
void el.offsetWidth; // force le navigateur à relancer l'animation
el.className += ' ' + classe;
}
function montrerErreur() {
toast.className = 'toast show';
if (toastMinuterie) { clearTimeout(toastMinuterie); }
toastMinuterie = setTimeout(function () { toast.className = 'toast'; }, 2500);
}
function pomper() {
if (enCours || !enAttente) { return; }
var attente = dernierEnvoi + ECART_MIN - maintenant();
if (attente > 0) {
if (!minuterie) {
minuterie = setTimeout(function () { minuterie = null; pomper(); }, attente);
}
return;
}
var tache = enAttente;
enAttente = null;
enCours = true;
dernierEnvoi = maintenant();
poster(tache.corps, function (ok) {
enCours = false;
rejouerAnimation(tache.el, ok ? 'ok' : 'err');
if (!ok) { montrerErreur(); }
pomper();
});
}
function envoyer(corps, el) {
enAttente = { corps: corps, el: el };
pomper();
}
function boutonDe(el) {
while (el && el !== document) {
if (el.getAttribute && el.getAttribute('data-action')) { return el; }
el = el.parentNode;
}
return null;
}
// Retour visuel immédiat au toucher (avant même le "click")
document.addEventListener('touchstart', function (e) {
var b = boutonDe(e.target);
if (b) { b.className += ' press'; }
}, false);
function relacher() {
var btns = document.querySelectorAll('.press');
for (var i = 0; i < btns.length; i++) {
btns[i].className = btns[i].className.replace(/\s*\bpress\b/g, '');
}
}
document.addEventListener('touchend', relacher, false);
document.addEventListener('touchcancel', relacher, false);
document.addEventListener('click', function (e) {
var b = boutonDe(e.target);
if (!b) { return; }
var action = b.getAttribute('data-action');
var corps = { action: action };
if (action === 'couleur') { corps.teinte = parseInt(b.getAttribute('data-teinte'), 10); }
if (action === 'luminosite') { corps.valeur = parseInt(b.getAttribute('data-valeur'), 10); }
if (action === 'effet') { corps.nom = b.getAttribute('data-nom'); }
envoyer(corps, b);
}, false);
}());
</script>
</body>
</html>