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
+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();
}
}