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:
@@ -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>
|
||||
Executable
+57
@@ -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 |
@@ -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>
|
||||
@@ -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'>🌙</div>"
|
||||
+ "<p>La lumière ne répond pas.</p>"
|
||||
+ "<a href='" + PAGE + "'>Ré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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user