commit e4bd58afa7ddccd97d746a1133e123265004e4ee Author: bach Date: Sat Oct 10 21:29:51 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e1a41f --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3ebc75f --- /dev/null +++ b/CLAUDE.md @@ -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/ + servie sur /local/led/ +``` + +1. **Scripts d'effets** (`script.led_`) : 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_` 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 `` 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_` + 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://:8123/api/webhook/` + - 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..550cec8 --- /dev/null +++ b/README.md @@ -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/ (local_only, POST) + │ + ▼ + automatisation (liste blanche) + │ + ▼ + light. / 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_` 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:///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://:/api/webhook/ +``` + +**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_` dans le package. +2. Ajouter `` 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 `. + +--- + +## 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. diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..887b94b --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/android/build.sh b/android/build.sh new file mode 100755 index 0000000..b1d262d --- /dev/null +++ b/android/build.sh @@ -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 < + + Ma lumière + + + + #141a3a + diff --git a/android/res/xml/network_security_config.xml b/android/res/xml/network_security_config.xml new file mode 100644 index 0000000..1a5a872 --- /dev/null +++ b/android/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + __HA_IP__ + + diff --git a/android/src/app/lumiere/MainActivity.java b/android/src/app/lumiere/MainActivity.java new file mode 100644 index 0000000..7017968 --- /dev/null +++ b/android/src/app/lumiere/MainActivity.java @@ -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 = + "" + + "" + + "" + + "
🌙
" + + "

La lumière ne répond pas.

" + + "Réessayer"; + + 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(); + } +} diff --git a/config.example.env b/config.example.env new file mode 100644 index 0000000..3fd9a78 --- /dev/null +++ b/config.example.env @@ -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 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..2d1b99e --- /dev/null +++ b/deploy.sh @@ -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 diff --git a/packages/led_chambre.yaml b/packages/led_chambre.yaml new file mode 100644 index 0000000..dfd55d5 --- /dev/null +++ b/packages/led_chambre.yaml @@ -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. diff --git a/www/led/icon-180.png b/www/led/icon-180.png new file mode 100644 index 0000000..2bf6ee6 Binary files /dev/null and b/www/led/icon-180.png differ diff --git a/www/led/icon-192.png b/www/led/icon-192.png new file mode 100644 index 0000000..9a17328 Binary files /dev/null and b/www/led/icon-192.png differ diff --git a/www/led/icon-512.png b/www/led/icon-512.png new file mode 100644 index 0000000..df6aa73 Binary files /dev/null and b/www/led/icon-512.png differ diff --git a/www/led/icon.svg b/www/led/icon.svg new file mode 100644 index 0000000..b087392 --- /dev/null +++ b/www/led/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/www/led/index.html b/www/led/index.html new file mode 100644 index 0000000..1496460 --- /dev/null +++ b/www/led/index.html @@ -0,0 +1,447 @@ + + + + + +Ma lumière + + + + + + + + + + + +
+ +

La lumière de __PRENOM__

+
+
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
Oups ! La lumière ne répond pas.
+ + + + + + + diff --git a/www/led/manifest.json b/www/led/manifest.json new file mode 100644 index 0000000..7aef267 --- /dev/null +++ b/www/led/manifest.json @@ -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" } + ] +}