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>
This commit is contained in:
2026-10-10 21:29:51 +02:00
co-authored by Claude Opus 5.5
commit e4bd58afa7
22 changed files with 1432 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# Secrets : jeton HA d'administration (accès complet à Home Assistant)
.env
.env.*
# Artefacts de compilation Android (APK, classes, dex)
android/build/
# Réglages locaux de Claude Code et historique de session
.claude/settings.local.json
.remember/
# Divers
*.keystore
*.jks
.DS_Store
# Valeurs réelles (IP, webhook, prénom…) et fichiers générés à partir des modèles
config.local.env
dist/
+80
View File
@@ -0,0 +1,80 @@
# Télécommande LED chambre — Home Assistant
## Objectif
Permettre à ma fille de piloter le ruban LED de sa chambre depuis son lecteur MP3 Android, via une page web ultra simple, **sans lui donner accès au reste de Home Assistant**.
## Matériel
- Ruban LED **RGB non adressable** : toute la longueur affiche la même couleur. Aucun effet de « vague » spatiale n'est possible, seulement des effets globaux (cycles, fondus, respiration).
- Driver **Zigbee Iderwel** (PWM), intégré à Home Assistant.
- **Dépôt public** : aucune valeur réelle (IP, ID du webhook, entité, prénom, identifiant Android) dans les fichiers versionnés. Elles sont dans `config.local.env` (non versionné, modèle : `config.example.env`). Les fichiers versionnés sont des modèles avec des marqueurs `__HA_IP__`, `__HA_PORT__`, `__WEBHOOK_ID__`, `__ENTITE__`, `__PRENOM__` ; `deploy.sh` génère `dist/` et déploie, `android/build.sh` génère `Config.java` et la config réseau. Ne jamais réintroduire de valeur réelle dans un fichier versionné.
- entity_id du ruban : `ENTITE` dans `config.local.env` (mode couleur `xy` ; `hs_color` accepté, converti par HA)
- intégration Zigbee : **Zigbee2MQTT**
- HA : HAOS sur Odroid N2, adresse `HA_IP:HA_PORT` ; accès SSH via l'alias `SSH_HOST` (**root** : lecture seule par défaut). Jeton HA de dev dans `.env` (non versionné).
- **Effets natifs du driver (`colorloop`, `breathe`…) peu fiables** : testés, ne pas les utiliser. Les effets passent par des scripts HA. Couleurs fixes, luminosité et `transition` fonctionnent bien.
- Le driver ne remonte pas l'attribut `effect` (toujours `None` dans HA).
- MP3 : **Fanvace M36, Android 13**, écran 640×1136 à 300 dpi (≈ 341 px CSS de large), WebView 108. Branché en ADB.
- **Pas de navigateur accessible** (app lock) : la page est affichée par un mini APK WebView (`android/`, code dans le paquet `app.lumiere`, identifiant installé = `APP_ID` appliqué via `--rename-manifest-package`), qui n'autorise que `http://HA_IP:HA_PORT/local/led/`. Recompiler avec `android/build.sh` (clé de debug `~/.android/debug.keystore` : garder la même pour les mises à jour), installer avec `adb install -r android/build/ma-lumiere.apk`. Si l'IP de HA change : modifier `config.local.env`, recompiler et réinstaller.
## Architecture
```
[Page HTML statique] --POST JSON--> [Webhook HA (local_only)] --> [Automatisation] --> light.* / scripts d'effets
/config/www/led/ /api/webhook/<id>
servie sur /local/led/
```
1. **Scripts d'effets** (`script.led_<nom>`) : boucles infinies (`repeat: while true`) de `light.turn_on` avec `transition`, en `mode: restart`.
2. **Automatisation webhook** : point d'entrée unique, qui ne peut agir que sur le ruban et sur une liste fermée de scripts.
3. **Page web** : gros boutons, aucune authentification, à ajouter sur l'écran d'accueil du MP3.
## Contrat du webhook (corps JSON)
| action | champs | effet |
|--------------|--------------------------|-----------------------------------------------------------------|
| `off` | — | arrête tous les effets et éteint le ruban (transition 1 s) |
| `couleur` | `teinte` (0–359) | arrête les effets, couleur fixe `hs_color: [teinte, 100]` |
| `luminosite` | `valeur` (5–100) | `brightness_pct`, valeur bornée côté HA |
| `effet` | `nom` (ex. `arc_en_ciel`) | arrête l'effet en cours et lance `script.led_<nom>` s'il figure dans la liste blanche |
## Contraintes et règles
- **Sécurité**
- Webhook en `local_only: true` et `allowed_methods: [POST]`, avec un ID aléatoire long.
- Liste blanche stricte des scripts dans l'automatisation ; ne jamais construire un entity_id arbitraire à partir du JSON.
- Toutes les valeurs numériques sont bornées et ont un `default` côté HA.
- Aucun jeton HA dans la page.
- **Zigbee** : ne pas inonder le réseau. Garder au moins 1 s entre deux commandes dans les scripts (idéalement 2–3 s avec `transition` égale au delay). Les fondus sont lissés par le driver.
- **Effets** : lancer un nouvel effet ou une couleur fixe arrête toujours l'effet en cours.
- **Pas de retour d'état** : le webhook ne renvoie rien. La page ne connaît pas l'état du ruban et l'UI ne doit pas faire semblant de le connaître (pas de toggle on/off avec un état affiché).
- **Page web**
- Un seul fichier HTML autonome (CSS et JS inline), sans dépendance externe ni CDN.
- JS compatible avec un vieux navigateur Android : ES5 de préférence, ou au minimum sans syntaxe récente (`?.`, `??`, etc.). Utiliser `fetch` s'il est disponible, sinon `XMLHttpRequest`.
- Pensée pour un petit écran tactile en portrait : grandes cibles tactiles, peu de texte, très visuelle (pastilles de couleur, icônes d'effets), retour visuel immédiat au tap.
- Balises `<meta name="viewport">` et `apple-mobile-web-app` / `manifest` minimal pour que le raccourci sur l'écran d'accueil ressemble à une appli.
- Interface en français, pour une enfant.
## Effets (en place dans `packages/led_chambre.yaml`)
- Calmes (≥ 2 s entre commandes) : `arc_en_ciel` (pas de 30°, 3 s), `respiration` (10 ↔ 100 %, 3 s), `coucher_de_soleil` (5 s), `ocean` (4 s), `veilleuse` (orange 5 %, extinction en fondu après 30 min).
- Boum (1 commande/s, le minimum) : `disco` (sauts de couleur), `boum` (flash 100 % puis fondu à 25 %), `tourbillon` (arc-en-ciel rapide). Pas de stroboscope.
- Le webhook remet 100 % quand on sort d'un effet qui baisse la luminosité (veilleuse, respiration, boum) ou quand le ruban est éteint.
- Piège : dans une palette en `variables`, une teinte `0` arrive vide dans `hs_color` et fait planter le script → utiliser `360` pour le rouge.
- Ajouter un effet = script `led_<nom>` + entrée dans `effets` et `tous_les_effets` de l'automatisation + tuile dans la page ; déployer avec `./deploy.sh` (copie, `ha core check`, `script.reload` / `automation.reload`, pas de redémarrage).
## Livrables attendus
1. `scripts.yaml` : les scripts d'effets
2. `automations.yaml` : l'automatisation webhook (ou un package `packages/led_chambre.yaml` regroupant les deux)
3. `www/led/index.html` : la page
4. Procédure de test :
- `curl -X POST -H "Content-Type: application/json" -d '{"action":"effet","nom":"arc_en_ciel"}' http://<ha>:8123/api/webhook/<id>`
- tester chaque action avec curl **avant** de toucher à la page
5. APK « Ma lumière » (`android/`) installé via ADB, à autoriser dans l'app lock
## Ordre de travail
1. Scripts d'effets, testés depuis Outils de développement → Actions
2. Automatisation webhook, testée avec curl
3. Page web, testée d'abord sur desktop en vue mobile, puis sur le MP3
+224
View File
@@ -0,0 +1,224 @@
# Ma lumière — télécommande LED pour une chambre d'enfant
Une appli Android sur le lecteur MP3 d'un enfant pilote le ruban LED de sa chambre via Home Assistant. L'enfant n'a accès à rien d'autre dans HA.
```
[Lecteur MP3 Android] [Home Assistant sur le LAN]
appli « Ma lumière »
(WebView plein écran) --GET--> /local/led/index.html (page servie par HA)
--POST--> /api/webhook/<id> (local_only, POST)
│
▼
automatisation (liste blanche)
│
▼
light.<ruban> / script.led_* (8 effets)
```
Ce document décrit l'installation et récapitule les interventions réalisées le 10 octobre 2026.
---
## 1. Installation
Ce dépôt est **public** : il ne contient aucune valeur réelle. Les fichiers versionnés sont des **modèles** avec des marqueurs, remplacés au déploiement par les valeurs de `config.local.env`.
| Marqueur | Variable | Exemple |
|---|---|---|
| `__HA_IP__` / `__HA_PORT__` | adresse de HA sur le LAN | `192.168.1.10` / `8123` |
| `__WEBHOOK_ID__` | ID du webhook, long et aléatoire | `led-chambre-$(openssl rand -hex 16)` |
| `__ENTITE__` | entité du ruban | `light.ruban_led` |
| `__PRENOM__` | prénom affiché dans la page | `Camille` |
| — | `SSH_HOST` : alias SSH vers HA | `homeassistant` |
| — | `APP_ID` : identifiant Android de l'appli | `fr.exemple.lumiere` |
**Étapes :**
1. Copier `config.example.env` en `config.local.env` et le remplir.
2. Une seule fois, dans `configuration.yaml` de HA, ajouter `packages: !include_dir_named packages` sous `homeassistant:`, puis redémarrer HA.
3. Lancer `./deploy.sh`. Il génère `dist/`, copie le package et la page sur HA via SSH, lance `ha core check`, puis recharge les scripts et les automatisations. Le rechargement utilise un jeton HA placé dans `.env` (`HA_TOKEN=…`), optionnel.
4. Compiler l'appli avec `android/build.sh` (il faut un SDK Android avec build-tools 30.0.2 et la plateforme 25), puis l'installer avec `adb install -r android/build/ma-lumiere.apk`.
**Fichiers jamais versionnés** (voir `.gitignore`) : `config.local.env`, `.env`, `dist/`, `android/build/`.
---
## 2. Contexte technique
| Élément | Valeur |
|---|---|
| Home Assistant | HAOS 2026.6 sur Odroid N2, en HTTP sur le LAN, accès distant uniquement via VPN WireGuard |
| Ruban | RGB non adressable, driver Zigbee Iderwel via **Zigbee2MQTT**, mode couleur `xy` |
| Lecteur MP3 | Fanvace M36, Android 13, écran 640×1136 à 300 dpi (≈ 341 × 530 px utiles), WebView 108, **navigateur bloqué par l'app lock** |
**Constats faits pendant les tests sur le ruban :**
- Couleurs fixes, luminosité et `transition` (fondus) fonctionnent bien.
- Les **effets natifs du driver** (`colorloop`, `breathe`…) **ne sont pas fiables** : ils ont été abandonnés au profit de scripts HA.
- Le driver ne remonte pas l'effet en cours : l'attribut `effect` vaut toujours `None` dans HA.
---
## 3. Interventions sur Home Assistant
### 3.1 Fichiers modifiés ou créés
| Fichier sur HA | Action |
|---|---|
| `/config/configuration.yaml` | **1 ligne ajoutée** sous `homeassistant:` : `packages: !include_dir_named packages` |
| `/config/packages/led_chambre.yaml` | **créé** : 8 scripts d'effets et l'automatisation webhook |
| `/config/www/led/` | **créé** : `index.html`, `manifest.json`, `icon.svg`, `icon-180/192/512.png` |
Aucun autre fichier existant n'a été modifié. En particulier, `scripts.yaml` et `automations.yaml` n'ont pas été touchés.
### 3.2 Sauvegardes laissées sur HA
| Sauvegarde | Contenu |
|---|---|
| `/config/configuration.yaml.bak-led-20261010` | `configuration.yaml` d'origine, avant l'ajout de `packages:` |
| `/config/led_chambre.yaml.bak-20261010-boum` | package avant l'ajout des effets disco/boum/tourbillon |
| `/config/led_chambre.yaml.bak-20261010-disco` | package avant la correction du bug disco |
Ces sauvegardes sont volontairement placées **hors** de `packages/`, pour que HA ne les charge pas.
### 3.3 Opérations effectuées
1. Vérification de la configuration avec `ha core check` avant chaque mise en service.
2. **Un redémarrage de HA**, pour activer `packages:`.
3. Ensuite, uniquement des rechargements à chaud (`script.reload`, `automation.reload`), sans redémarrage.
### 3.4 Contenu du package `led_chambre.yaml`
**Scripts d'effets**, tous en `mode: restart` :
| Script | Comportement | Rythme Zigbee |
|---|---|---|
| `led_arc_en_ciel` | tour des teintes par pas de 30° | 1 commande / 3 s |
| `led_respiration` | garde la couleur, luminosité 10 % ↔ 100 % | 1 / 3 s |
| `led_coucher_de_soleil` | orange → rose → rouge → violet | 1 / 5 s |
| `led_ocean` | bleu → turquoise → cyan → bleu profond | 1 / 4 s |
| `led_veilleuse` | orange à 5 %, puis extinction en fondu au bout de 30 min | — |
| `led_disco` | saut de couleur vive chaque seconde | 1 / s |
| `led_boum` | flash d'une couleur à 100 %, puis fondu à 25 % | 1 / s |
| `led_tourbillon` | arc-en-ciel rapide, pas de 45° | 1 / s |
Il n'y a pas de stroboscope : le réseau Zigbee ne le permet pas, et il présente un risque photosensible.
**Automatisation `led_chambre_webhook`** :
- Webhook avec `local_only: true`, `allowed_methods: [POST]` et un ID aléatoire long.
- `mode: restart` : si plusieurs commandes arrivent très vite, seule la dernière compte.
- Contrat du corps JSON :
| `action` | champ | effet |
|---|---|---|
| `off` | — | arrête tous les effets et éteint le ruban (fondu 1 s) |
| `couleur` | `teinte` 0–359 | arrête les effets et applique une couleur fixe |
| `luminosite` | `valeur` 5–100 | change la luminosité sans couper l'effet en cours |
| `effet` | `nom` | arrête l'effet en cours et lance `script.led_<nom>` s'il est dans la liste blanche |
- Liste blanche stricte : le JSON ne sert jamais à construire un entity_id. Les valeurs sont bornées et ont des valeurs par défaut, et toute autre requête est ignorée.
- La luminosité **revient automatiquement à 100 %** quand le ruban était éteint, ou quand on sort de la veilleuse, de la respiration ou de Boum.
---
## 4. Interventions sur le lecteur MP3
- Installation via ADB de l'appli **« Ma lumière »**, aujourd'hui en **version 1.1**.
- Aucun autre réglage n'a été modifié sur l'appareil.
- Le débogage USB a été activé pour l'installation.
**Ce que fait l'appli** : c'est un *wrapper* WebView de 37 Ko qui affiche en plein écran la page servie par HA.
- Elle n'autorise la navigation que vers `http://<HA>/local/led/`, et bloque toute requête vers une autre adresse.
- Le HTTP non chiffré n'est autorisé que vers l'IP de HA.
- Si HA ne répond pas, elle affiche « La lumière ne répond pas » avec un bouton Réessayer.
- Le cache est désactivé, et la page est rechargée à chaque retour dans l'appli (ajouté en v1.1). Une modification de la page sur HA apparaît donc sans réinstaller l'appli.
---
## 5. Fichiers du dépôt
```
├── CLAUDE.md consignes du projet
├── README.md ce document
├── config.example.env modèle de configuration (à copier en config.local.env)
├── deploy.sh génère dist/ depuis les modèles et déploie sur HA
├── packages/led_chambre.yaml modèle → /config/packages/led_chambre.yaml
├── www/led/ modèles → /config/www/led/
│ ├── index.html la page (HTML/CSS/JS ES5 en un seul fichier)
│ ├── manifest.json
│ └── icon.svg, icon-*.png
└── android/ sources de l'APK
├── build.sh compilation sans Gradle → build/ma-lumiere.apk
├── AndroidManifest.xml
├── res/ icônes, thème, network_security_config (modèle)
└── src/app/lumiere/MainActivity.java
```
**La page** est pensée pour tenir sur un seul écran du lecteur :
- en haut, Veilleuse et Éteindre ;
- 9 pastilles de couleur ;
- 4 niveaux de luminosité ;
- 4 animations calmes ;
- 3 animations « boum » au style néon.
Elle n'affiche aucun état du ruban, puisque le webhook ne renvoie rien. Un appui réussi fait apparaître un halo blanc ; une erreur fait trembler le bouton et affiche un message rouge.
---
## 6. Tests réalisés
- **Ruban, en direct** : couleurs, fondus, luminosité, puis effets natifs, qui se sont révélés peu fiables.
- **Webhook avec curl** :
- toutes les actions et les 8 effets fonctionnent, et un seul script tourne à la fois ;
- les entrées non valides sont ignorées sans erreur : nom hors liste blanche (`../light.autre`), action inconnue, corps non JSON ;
- les valeurs sont bornées (teinte 999 → 359, luminosité 1 → 5 %) ;
- une requête GET est refusée (405) ;
- les logs HA sont propres.
- **De bout en bout sur le lecteur** : des taps simulés via ADB (couleur, arc-en-ciel, veilleuse, éteindre) ont été vérifiés dans l'état HA.
- **Modèles** : les fichiers générés par `deploy.sh --render-only` sont identiques aux fichiers en service, hors commentaires. L'APK généré contient la bonne adresse et le bon identifiant.
**Bug corrigé :** `led_disco` plantait au bout d'environ 10 s, parce qu'une teinte `0` tirée d'une palette en `variables` arrivait vide dans `hs_color`. Le rouge vaut maintenant `360` dans les palettes de Disco et de Boum.
---
## 7. Maintenance
**Tester le webhook** (avec les valeurs de `config.local.env`) :
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"action":"effet","nom":"arc_en_ciel"}' \
http://<HA_IP>:<HA_PORT>/api/webhook/<WEBHOOK_ID>
```
**Modifier la page ou un effet :**
1. Modifier le modèle (`www/led/index.html` ou `packages/led_chambre.yaml`).
2. Lancer `./deploy.sh`.
3. Rouvrir l'appli sur le lecteur. Pas de réinstallation.
**Ajouter un effet :**
1. Ajouter le script `led_<nom>` dans le package.
2. Ajouter `<nom>` dans `effets` **et** dans `tous_les_effets` de l'automatisation.
3. Ajouter une tuile dans la page.
4. Lancer `./deploy.sh`.
**Recompiler l'appli**, par exemple si l'IP de HA change :
1. Mettre à jour `config.local.env`.
2. Lancer `android/build.sh`, puis `adb install -r android/build/ma-lumiere.apk`.
Il faut garder la même clé de signature, `~/.android/debug.keystore`. Sinon Android refusera la mise à jour et il faudra désinstaller l'appli d'abord.
**Tout retirer :**
1. Supprimer `/config/packages/led_chambre.yaml` et `/config/www/led/`.
2. Retirer la ligne `packages:` de `configuration.yaml`, ou restaurer la sauvegarde.
3. Lancer `ha core check`, puis redémarrer HA.
4. Désinstaller l'appli : `adb uninstall <APP_ID>`.
---
## 8. Reste à faire
- [ ] Autoriser « Ma lumière » dans l'**app lock** du lecteur.
- [ ] **Réserver l'IP** de HA sur la box, puisqu'elle est compilée dans l'appli.
- [ ] **Supprimer le jeton** HA de développement (profil HA → Sécurité) et le fichier `.env` quand ils ne sont plus utiles. L'appli n'en a pas besoin.
- [ ] Éventuellement, désactiver le **débogage USB** sur le lecteur.
- [ ] Éventuellement, supprimer les sauvegardes `*.bak*` dans `/config` une fois que tout est validé.
ℹ️ Le dossier `/local/` de HA est accessible sans authentification depuis le LAN. C'est voulu : la page ne contient aucun secret, et le webhook n'accepte que des commandes en liste blanche, uniquement depuis le réseau local.
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mini appli « Ma lumière » : affiche la télécommande LED (servie par HA) en plein écran.
L'identifiant réel (APP_ID) est appliqué par build.sh (option rename-manifest-package d'aapt2). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.lumiere"
android:versionCode="2"
android:versionName="1.1">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Plein">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Compile l'APK « Ma lumière » sans Gradle (aapt2 + javac + d8 + apksigner).
# Les valeurs réelles (IP de HA, identifiant de l'appli) viennent de
# ../config.local.env (non versionné, voir ../config.example.env).
# Résultat : build/ma-lumiere.apk
set -euo pipefail
cd "$(dirname "$0")"
[ -f ../config.local.env ] || { echo "../config.local.env manquant (copier config.example.env)" >&2; exit 1; }
set -a; . ../config.local.env; set +a
SDK="${ANDROID_HOME:-/opt/android-sdk}"
BT="$SDK/build-tools/30.0.2"
JAR="$SDK/platforms/android-25/android.jar"
KEYSTORE="$HOME/.android/debug.keystore"
rm -rf build && mkdir -p build/res build/gen/app/lumiere build/classes build/dex
echo "== valeurs locales ($APP_ID -> http://$HA_IP:$HA_PORT/)"
cp -r res build/res-src
sed -i "s/__HA_IP__/$HA_IP/g" build/res-src/xml/network_security_config.xml
if grep -rq "__[A-Z_]*__" build/res-src; then echo "marqueurs non remplacés dans res/" >&2; exit 1; fi
cat > build/gen/app/lumiere/Config.java <<EOF
package app.lumiere;
/** Généré par build.sh depuis config.local.env : ne pas modifier. */
final class Config {
static final String ORIGINE = "http://$HA_IP:$HA_PORT/";
private Config() {}
}
EOF
echo "== ressources"
"$BT/aapt2" compile --dir build/res-src -o build/res/res.zip
"$BT/aapt2" link -o build/base.apk -I "$JAR" \
--manifest AndroidManifest.xml \
--rename-manifest-package "$APP_ID" \
--min-sdk-version 24 --target-sdk-version 33 \
--java build/gen build/res/res.zip
echo "== java"
javac -source 1.8 -target 1.8 -encoding UTF-8 -Xlint:-options \
-bootclasspath "$JAR" -d build/classes \
$(find src build/gen -name '*.java')
echo "== dex"
"$BT/d8" --min-api 24 --lib "$JAR" --output build/dex $(find build/classes -name '*.class')
cp build/base.apk build/unsigned.apk
(cd build/dex && zip -q -j ../unsigned.apk classes.dex)
echo "== alignement et signature (clé de debug)"
"$BT/zipalign" -f -p 4 build/unsigned.apk build/aligned.apk
"$BT/apksigner" sign --ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
--out build/ma-lumiere.apk build/aligned.apk
"$BT/apksigner" verify build/ma-lumiere.apk
ls -l build/ma-lumiere.apk
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Ma lumière</string>
<style name="Plein" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/nuit</item>
<item name="android:statusBarColor">@color/nuit</item>
<item name="android:navigationBarColor">@color/nuit</item>
</style>
<color name="nuit">#141a3a</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- HTTP non chiffré autorisé uniquement vers Home Assistant sur le LAN. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">__HA_IP__</domain>
</domain-config>
</network-security-config>
+126
View File
@@ -0,0 +1,126 @@
package app.lumiere;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.io.ByteArrayInputStream;
/**
* Affiche la télécommande LED servie par Home Assistant, en plein écran.
* Toute navigation hors de /local/led/ est refusée, et toute requête
* hors de l'adresse de HA est bloquée.
*/
public class MainActivity extends Activity {
// Adresse de HA : générée par build.sh depuis config.local.env
private static final String ORIGINE = Config.ORIGINE;
private static final String PAGE = ORIGINE + "local/led/index.html";
private static final String DOSSIER = ORIGINE + "local/led/";
private static final String PAGE_ERREUR =
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
+ "<style>body{margin:0;min-height:100vh;display:flex;flex-direction:column;"
+ "align-items:center;justify-content:center;background:#141a3a;color:#fff;"
+ "font:bold 20px sans-serif;text-align:center;padding:24px;box-sizing:border-box}"
+ "p{margin:16px 0 32px}a{display:block;width:80%;max-width:300px;padding:20px 0;"
+ "border-radius:32px;background:#2b335f;border:3px solid #48528a;color:#fff;"
+ "text-decoration:none}</style></head><body>"
+ "<div style='font-size:64px'>&#127769;</div>"
+ "<p>La lumi&egrave;re ne r&eacute;pond pas.</p>"
+ "<a href='" + PAGE + "'>R&eacute;essayer</a></body></html>";
private WebView vue;
@Override
protected void onCreate(Bundle etat) {
super.onCreate(etat);
vue = new WebView(this);
vue.setBackgroundColor(0xFF141A3A);
vue.setLongClickable(false);
vue.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) { return true; }
});
WebSettings r = vue.getSettings();
r.setJavaScriptEnabled(true);
r.setDomStorageEnabled(false);
r.setAllowFileAccess(false);
r.setAllowContentAccess(false);
r.setSupportMultipleWindows(false);
r.setSupportZoom(false);
r.setBuiltInZoomControls(false);
r.setTextZoom(100); // ignore la taille de police système : la mise en page reste stable
r.setCacheMode(WebSettings.LOAD_NO_CACHE); // toujours la dernière version de la page
r.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
vue.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
// true = navigation refusée
return !req.getUrl().toString().startsWith(DOSSIER);
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView v, WebResourceRequest req) {
String url = req.getUrl().toString();
if (url.startsWith(ORIGINE) || url.startsWith("data:")) {
return null; // requête normale
}
return new WebResourceResponse("text/plain", "utf-8", 403, "Interdit", null,
new ByteArrayInputStream(new byte[0]));
}
@Override
public void onReceivedError(WebView v, WebResourceRequest req, WebResourceError err) {
if (req.isForMainFrame()) { afficherErreur(); }
}
@Override
public void onReceivedHttpError(WebView v, WebResourceRequest req, WebResourceResponse rep) {
if (req.isForMainFrame()) { afficherErreur(); }
}
});
setContentView(vue);
vue.loadUrl(PAGE);
}
private boolean enPause = false;
@Override
protected void onPause() {
super.onPause();
enPause = true;
}
@Override
protected void onResume() {
super.onResume();
// Au retour dans l'appli, recharger : la page sur HA a pu changer,
// ou HA être revenu après une coupure (écran d'erreur).
if (enPause) {
enPause = false;
vue.loadUrl(PAGE);
}
}
private void afficherErreur() {
vue.loadDataWithBaseURL(DOSSIER, PAGE_ERREUR, "text/html", "utf-8", null);
}
@Override
protected void onDestroy() {
if (vue != null) { vue.destroy(); }
super.onDestroy();
}
}
+20
View File
@@ -0,0 +1,20 @@
# Copier en config.local.env et remplir avec vos valeurs (ce fichier-là n'est pas versionné).
# Adresse de Home Assistant sur le LAN
HA_IP=192.168.1.10
HA_PORT=8123
# Alias SSH vers HA (add-on SSH), utilisé par deploy.sh
SSH_HOST=homeassistant
# ID du webhook : long et aléatoire, par ex. "led-chambre-$(openssl rand -hex 16)"
WEBHOOK_ID=led-chambre-a-remplacer
# Entité du ruban LED
ENTITE=light.ruban_led
# Prénom affiché dans la page (« La lumière de … »)
PRENOM=Camille
# Identifiant Android de l'appli
APP_ID=fr.exemple.lumiere
Executable
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Génère les fichiers réels à partir des modèles et les déploie sur Home Assistant.
#
# ./deploy.sh --render-only génère seulement dist/ (rien n'est envoyé)
# ./deploy.sh génère dist/, copie sur HA, vérifie la config
# et recharge scripts + automatisations
#
# Les valeurs réelles viennent de config.local.env (non versionné, voir
# config.example.env). Le rechargement utilise HA_TOKEN dans .env.
set -euo pipefail
cd "$(dirname "$0")"
[ -f config.local.env ] || { echo "config.local.env manquant (copier config.example.env)" >&2; exit 1; }
set -a; . ./config.local.env; set +a
echo "== génération de dist/"
rm -rf dist && mkdir -p dist/packages dist/www/led
python3 - <<'EOF'
import os, re, shutil, pathlib
valeurs = {k: os.environ[k] for k in ("HA_IP", "HA_PORT", "WEBHOOK_ID", "ENTITE", "PRENOM")}
fichiers = ["packages/led_chambre.yaml"] + [str(p) for p in sorted(pathlib.Path("www/led").iterdir())]
for src in fichiers:
dst = os.path.join("dist", src)
if src.endswith((".yaml", ".html", ".json", ".svg")):
texte = open(src, encoding="utf-8").read()
for k, v in valeurs.items():
texte = texte.replace("__%s__" % k, v)
reste = sorted(set(re.findall(r"__[A-Z_]+__", texte)))
if reste:
raise SystemExit("marqueurs non remplacés dans %s : %s" % (src, reste))
open(dst, "w", encoding="utf-8").write(texte)
else:
shutil.copyfile(src, dst)
print(" ", dst)
EOF
[ "${1:-}" = "--render-only" ] && exit 0
echo "== copie vers $SSH_HOST"
scp -q dist/packages/led_chambre.yaml "$SSH_HOST:/config/packages/led_chambre.yaml"
ssh "$SSH_HOST" 'mkdir -p /config/www/led'
scp -q dist/www/led/* "$SSH_HOST:/config/www/led/"
echo "== vérification de la configuration"
ssh "$SSH_HOST" 'ha core check'
echo "== rechargement"
if [ -f .env ]; then
set -a; . ./.env; set +a
for d in script automation; do
curl -s -m 30 -o /dev/null -w " $d.reload -> %{http_code}\n" -X POST \
-H "Authorization: Bearer $HA_TOKEN" "http://$HA_IP:$HA_PORT/api/services/$d/reload"
done
else
echo " pas de .env : recharger à la main (Outils de développement → YAML → Scripts et Automatisations)"
fi
+320
View File
@@ -0,0 +1,320 @@
# =============================================================================
# Télécommande LED chambre
# Ruban : __ENTITE__ (Zigbee2MQTT, driver Iderwel RGB non adressable)
#
# Généré par deploy.sh : dans le dépôt, ce fichier est un modèle dont les
# marqueurs (entre doubles soulignés) sont remplacés depuis config.local.env.
#
# Point d'entrée unique : webhook local (POST JSON), voir CLAUDE.md
# {"action": "off"}
# {"action": "couleur", "teinte": 0-359}
# {"action": "luminosite", "valeur": 5-100}
# {"action": "effet", "nom": "arc_en_ciel|respiration|coucher_de_soleil|ocean|veilleuse
# |disco|boum|tourbillon"}
#
# Règle Zigbee : au moins 2 s entre deux commandes dans les effets calmes,
# avec une transition égale au délai. Les effets « boum » (disco, boum,
# tourbillon) vont au minimum autorisé : 1 commande par seconde. Pas de
# stroboscope (réseau Zigbee, et risque photosensible).
# =============================================================================
script:
# --- Arc-en-ciel : tour complet des teintes par pas de 30°, 3 s par pas ---
# Ne touche pas à la luminosité : le curseur reste actif pendant l'effet.
led_arc_en_ciel:
alias: "LED chambre – arc-en-ciel"
icon: mdi:looks
mode: restart
sequence:
- repeat:
while: "{{ true }}"
sequence:
- repeat:
for_each: [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ repeat.item }}", 100]
transition: 3
- delay: 3
# --- Respiration : garde la couleur actuelle, luminosité 100 % <-> 10 % ---
led_respiration:
alias: "LED chambre – respiration"
icon: mdi:weather-windy
mode: restart
sequence:
- repeat:
while: "{{ true }}"
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
brightness_pct: 10
transition: 3
- delay: 3
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
brightness_pct: 100
transition: 3
- delay: 3
# --- Coucher de soleil : orange -> rose -> rouge -> violet, fondus de 5 s ---
led_coucher_de_soleil:
alias: "LED chambre – coucher de soleil"
icon: mdi:weather-sunset
mode: restart
sequence:
- repeat:
while: "{{ true }}"
sequence:
- repeat:
for_each:
- { h: 25, s: 100 } # orange
- { h: 340, s: 70 } # rose
- { h: 0, s: 100 } # rouge
- { h: 285, s: 90 } # violet
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ repeat.item.h }}", "{{ repeat.item.s }}"]
transition: 5
- delay: 5
# --- Océan : bleu -> turquoise -> cyan -> bleu profond, fondus de 4 s ---
led_ocean:
alias: "LED chambre – océan"
icon: mdi:waves
mode: restart
sequence:
- repeat:
while: "{{ true }}"
sequence:
- repeat:
for_each:
- { h: 215, s: 100 } # bleu
- { h: 172, s: 100 } # turquoise
- { h: 190, s: 80 } # cyan clair
- { h: 235, s: 100 } # bleu profond
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ repeat.item.h }}", "{{ repeat.item.s }}"]
transition: 4
- delay: 4
# --- Veilleuse : orange très faible, extinction en fondu après 30 min ---
led_veilleuse:
alias: "LED chambre – veilleuse"
icon: mdi:weather-night
mode: restart
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: [28, 100]
brightness_pct: 5
transition: 3
- delay: "00:29:00"
# Dernière minute : fondu jusqu'à l'extinction (total 30 min)
- action: light.turn_off
target:
entity_id: __ENTITE__
data:
transition: 60
# --- Disco : saut de couleur vive chaque seconde ---
# Index (n x 7) mod 10 : parcourt les 10 couleurs dans un ordre qui paraît
# aléatoire, sans jamais répéter deux fois la même d'affilée.
# Ne touche pas à la luminosité.
led_disco:
alias: "LED chambre – disco"
icon: mdi:mirror-rectangle
mode: restart
# Rouge = 360 et non 0 : une teinte 0 tirée de la palette arrivait vide
# dans hs_color et faisait planter le script.
variables:
palette: [360, 30, 55, 120, 170, 200, 235, 275, 300, 320]
sequence:
- repeat:
while: "{{ true }}"
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ palette[(repeat.index * 7) % 10] }}", 100]
transition: 0
- delay: 1
# --- Boum : flash d'une nouvelle couleur à 100 %, puis fondu à 25 % (2 s) ---
led_boum:
alias: "LED chambre – boum"
icon: mdi:speaker
mode: restart
variables:
palette: [320, 200, 55, 275, 360, 170, 30, 235] # rouge = 360, voir disco
sequence:
- repeat:
while: "{{ true }}"
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ palette[repeat.index % 8] }}", 100]
brightness_pct: 100
transition: 0
- delay: 1
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
brightness_pct: 25
transition: 1
- delay: 1
# --- Tourbillon : arc-en-ciel rapide, pas de 45°, fondus de 1 s ---
# Ne touche pas à la luminosité.
led_tourbillon:
alias: "LED chambre – tourbillon"
icon: mdi:tailwind
mode: restart
sequence:
- repeat:
while: "{{ true }}"
sequence:
- repeat:
for_each: [0, 45, 90, 135, 180, 225, 270, 315]
sequence:
- action: light.turn_on
target:
entity_id: __ENTITE__
data:
hs_color: ["{{ repeat.item }}", 100]
transition: 1
- delay: 1
automation:
- id: led_chambre_webhook
alias: "LED chambre – webhook télécommande"
description: >-
Point d'entrée unique de la page /local/led/. N'agit que sur
__ENTITE__ et sur la liste blanche des scripts led_*.
# La dernière commande gagne (taps rapides, curseur de luminosité)
mode: restart
max_exceeded: silent
triggers:
- trigger: webhook
webhook_id: __WEBHOOK_ID__
allowed_methods: [POST]
local_only: true
variables:
ruban: __ENTITE__
# Liste blanche : nom reçu -> script. Jamais d'entity_id construit depuis le JSON.
effets:
arc_en_ciel: script.led_arc_en_ciel
respiration: script.led_respiration
coucher_de_soleil: script.led_coucher_de_soleil
ocean: script.led_ocean
veilleuse: script.led_veilleuse
disco: script.led_disco
boum: script.led_boum
tourbillon: script.led_tourbillon
tous_les_effets:
- script.led_arc_en_ciel
- script.led_respiration
- script.led_coucher_de_soleil
- script.led_ocean
- script.led_veilleuse
- script.led_disco
- script.led_boum
- script.led_tourbillon
corps: "{{ trigger.json if trigger.json is defined and trigger.json is mapping else {} }}"
act: "{{ corps.get('action', '') | string }}"
nom: "{{ corps.get('nom', '') | string }}"
teinte: "{{ ([0, corps.get('teinte', 0) | int(0), 359] | sort)[1] }}"
valeur: "{{ ([5, corps.get('valeur', 100) | int(100), 100] | sort)[1] }}"
# Remettre 100 % si le ruban est éteint ou sort d'un effet qui
# baisse la luminosité (veilleuse 5 %, respiration 10 %, boum 25 %).
remettre_lumiere: >-
{{ is_state(ruban, 'off')
or is_state('script.led_veilleuse', 'on')
or is_state('script.led_respiration', 'on')
or is_state('script.led_boum', 'on') }}
actions:
- choose:
# --- off : arrête les effets et éteint ---
- conditions: "{{ act == 'off' }}"
sequence:
- action: script.turn_off
target:
entity_id: "{{ tous_les_effets }}"
- action: light.turn_off
target:
entity_id: "{{ ruban }}"
data:
transition: 1
# --- couleur fixe ---
- conditions: "{{ act == 'couleur' }}"
sequence:
- action: script.turn_off
target:
entity_id: "{{ tous_les_effets }}"
- if: "{{ remettre_lumiere }}"
then:
- action: light.turn_on
target:
entity_id: "{{ ruban }}"
data:
hs_color: ["{{ teinte }}", 100]
brightness_pct: 100
transition: 1
else:
- action: light.turn_on
target:
entity_id: "{{ ruban }}"
data:
hs_color: ["{{ teinte }}", 100]
transition: 1
# --- luminosité (ne coupe pas l'effet en cours) ---
- conditions: "{{ act == 'luminosite' }}"
sequence:
- action: light.turn_on
target:
entity_id: "{{ ruban }}"
data:
brightness_pct: "{{ valeur }}"
transition: 1
# --- effet de la liste blanche ---
- conditions: "{{ act == 'effet' and nom in effets }}"
sequence:
- action: script.turn_off
target:
entity_id: "{{ tous_les_effets }}"
# Allume à 100 % avant un effet qui ne règle pas la luminosité
- if: "{{ remettre_lumiere and nom not in ['veilleuse', 'respiration', 'boum'] }}"
then:
- action: light.turn_on
target:
entity_id: "{{ ruban }}"
data:
brightness_pct: 100
transition: 1
- action: script.turn_on
target:
entity_id: "{{ effets[nom] }}"
# Toute autre requête est ignorée silencieusement.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="ciel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a3370"/>
<stop offset="1" stop-color="#11163a"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="112" fill="url(#ciel)"/>
<g fill="none" stroke-width="34" transform="translate(0 40)">
<path d="M86 300a170 170 0 0 1 340 0" stroke="#ff3b3b"/>
<path d="M120 300a136 136 0 0 1 272 0" stroke="#ff9a1f"/>
<path d="M154 300a102 102 0 0 1 204 0" stroke="#ffe11f"/>
<path d="M188 300a68 68 0 0 1 136 0" stroke="#3be05a"/>
<path d="M222 300a34 34 0 0 1 68 0" stroke="#7a4dff"/>
</g>
<circle cx="412" cy="104" r="12" fill="#fff"/>
<circle cx="104" cy="128" r="9" fill="#fff"/>
<circle cx="440" cy="196" r="7" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 844 B

+447
View File
@@ -0,0 +1,447 @@
<!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>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "La lumière de __PRENOM__",
"short_name": "Ma lumière",
"start_url": "index.html",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#141a3a",
"theme_color": "#141a3a",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}