So this really interests me, because i think i might have done something very similar to this after taking inspiration from this post.
I am not very technical, so i asked Claude to explain what Claude did for me and ill paste it below, but in summary - I asked it to take the best of the HaRemote app, some example HA dashboards that I had made and build a solution that would be quick/responsive like the HaRemote app but allow custom card config and layouts etc.
The remote buttons are mostly hardcoded to always work on my tv (dpad, power, back, menu etc) and vol always does the sonos which means if the tv is on it does tv and if music is on it does that. I put in script triggers so that ch up and down dim any light in the room that is on, and long press controls the blinds. The light, curtain, music and ac buttons all open a specific dash of my app (which is also swipe-able) and the color buttons below launch tv apps like netflix, plex etc and they also call scripts for scenes on long press.
Screen Vid -
Vids in use IRL -
Claude explaining what it actually made —
How it talks to HA: one standard WebSocket (/api/websocket), long-lived token auth → get_states seeds an in-memory entity map → subscribe_events keeps it live → every control fires call_service. No cloud, no vendor integration, no polling.
┌──────────────┐ auth (long-lived token) ┌────────────────┐
│ HA100 app │ ────────────────────────────────► │ Home Assistant │
│ (native, │ ◄──────────────────────────────── │ │
│ Compose) │ auth_ok └────────────────┘
│ │
│ │ get_states → seeds an in-memory entity map
│ │ subscribe_events → state_changed keeps it live
│ │ call_service → every button/slider/tap
│ │ weather.get_forecasts → forecast card (return_response)
│ │ media_player/browse_media → media library dialog
│ │ ping/pong → 30s heartbeat + auto-reconnect
└──────────────┘
The entity map is a Kotlin StateFlow; every card observes it, so any state change in HA repaints only the affected cards, instantly.
Album art / camera-style images are fetched over plain HTTP with the same token.
Why it’s fast: native Compose (no WebView/JS/CSS), one socket + one StateFlow entity map so state changes repaint only affected cards, plain columns instead of heavy lists, server-pre-scaled images, downscale-fake-blur (Android 8.1 has no blur API), and config as one small JSON file.
| Technique | Effect |
|---|---|
| Native Jetpack Compose, no WebView | no JS engine, no DOM, no card_mod CSS |
| One WebSocket, one entity map | no polling, no REST chatter |
Plain Column + verticalScroll per page |
no heavy list machinery for ~10 cards |
LazyRow only where there are many images (Plex posters) |
offscreen posters never decode |
| Posters requested pre-scaled from the server (~140×210) | tiny decode, tiny memory |
| “Blur” = downscale-to-32px + upscale | real blur APIs don’t exist on Android 8.1 |
| Icons: vector Material icons + user PNGs from /sdcard | no icon fonts, no network icons |
| Config = one small JSON file read at launch/resume | no YAML parsing, no HA dashboard fetch |
Cold start to fully rendered is ~1–2 s; card taps fire a service call in single-digit ms.
The Cards: Cards are tiny Kotlin classes registered in an open registry — adding a new card type is one class + one registration line. Current set:
| Card type | What it does | HA services used |
|---|---|---|
clock_weather |
Device-clock time/date + current conditions + N-day forecast with min/max gradient bars | weather.get_forecasts |
bubble_light |
Bubble-card-style light pill: tap-to-toggle icon, drag anywhere to dim, fill tinted with the light’s live rgb_color; long-press opens a colour/brightness popup (swatches + colour-temp presets, shown only if the light supports them) |
light.turn_on/off (brightness_pct, rgb_color, color_temp_kelvin) |
picture_elements |
Floorplan image from /sdcard/astrion/floorplan.png with tappable icons at % positions; glow when on; supports an “all off” multi-target button |
light.toggle, light.turn_off |
climate |
Setpoint steppers (respects the entity’s real target_temp_step + min/max), HVAC mode chips, fan mode chips, dedicated off button |
climate.set_temperature, set_hvac_mode, set_fan_mode, turn_off |
cover |
Mushroom-horizontal tile: icon + name/state left, open/stop/close right | cover.open/stop/close_cover |
switch |
Toggle tile with icon + configurable on-colour (e.g. dark red for a heater) | switch.toggle |
media_player (compact) |
One row: round art, title/artist, vol−/vol+; tap body = play/pause; blurred-art background | media_player.* |
media_player (full) |
Big art, centred now-playing, vol−/prev/play/next/vol+, configurable action buttons at the top (e.g. Group/Ungroup scripts) | media_player.*, any script |
scene_grid |
Scene/script tiles — grid or horizontally swipeable row, per-tile colours, can be pinned to the bottom of a page | scene.turn_on / script.turn_on (by domain) |
button_grid |
Generic grid of buttons, each firing any service, with optional PNG icons from /sdcard | anything |
plex |
Native Plex browser: On Deck + Recently Added poster rows, tap → plays on the TV | Plex HTTP API + remote.turn_on (deep link) |
tv_remote |
On-screen D-pad/transport remote with a configurable command map | remote.send_command |
monitor |
Read-only sensor list with units | none (read-only) |
row |
Lays any two+ cards side by side | -- |
Pages/Navigation: The UI is a horizontal pager with dot indicators — swipe between pages, tap a dot, or press a physical shortcut button. Example layout:
Lights ◄──swipe──► Main ◄──swipe──► Media ◄──swipe──► Climate
14 light sliders clock/weather full player aircon + fan modes
+ pinned scene row floorplan group/ungroup heater switch
mini player playlist buttons covers
Plex browser
Hardware Buttons: The HA100’s buttons arrive as ordinary Android key events (keycode map extracted from the stock firmware), so the app intercepts them in dispatchKeyEvent — before the OS — and routes them through a config-defined table. Every button supports a tap action and an optional long-press (1.5 s) action; keys without a long-press binding keep Android’s native auto-repeat (hold-to-scroll / hold-to-ramp).
| Button | Tap | Hold (1.5 s) |
|---|---|---|
| D-pad ↑↓<–> | TV DPAD_* (auto-repeats while held) |
-- |
| OK | TV DPAD_CENTER |
script.long_center |
| Back / Home / Power | TV BACK / HOME / POWER |
-- |
| Vol ± | speaker volume_up/down (auto-repeats) |
-- |
| Mute | TV HOME |
-- |
| CH ▲ / CH ▼ | brightness up/down scripts | open / close blinds scripts |
| Light | -> Lights page | script.long_lights |
| Curtain | -> Main page | script.long_curtain |
| Music | -> Media page | script.long_music |
| Aircon | -> Climate page | script.long_aircon |
| Red / Green / Blue / Yellow | launch Netflix / Plex / ABC / VLC on the TV | script.long_red … script.long_yellow |
App launches use media_player.play_media with media_content_type: app against the Android-TV media player; TV keys use remote.send_command against the Android-TV remote entity. The script.long_* targets are just HA scripts — put anything in them.
Configuration: Everything above is driven by one JSON file on shared storage:
/sdcard/astrion/dashboard.json the whole layout + button map
/sdcard/astrion/floorplan.png floorplan render
/sdcard/astrion/icons/*.png custom button icons
-
On first launch the app writes its built-in default config out to that file, so there is always something to edit.
-
The file is re-read every time the app returns to the foreground — edit it with
adb push, or any on-device file manager, then reopen the app. No rebuild, no reinstall. -
If the JSON is broken, the app falls back to its compiled-in defaults and shows a small banner saying why — it never crashes over config.
Schema sketch:
{ "startPage": 1,
"pages": [
{ "name": "Lights", "cards": [
{ "type": "bubble_light", "options": { "entity_id": "light.living_room", "name": "Living Room" } },
{ "type": "scene_grid", "options": { "layout": "row", "pin": "bottom",
"scenes": [ { "entity_id": "scene.movie", "name": "Movie", "color": "#663F51B5" } ] } }
] }
],
"hotkeys": [ { "key": "LIGHT", "page": "Lights" },
{ "key": "UP", "service": "remote.send_command",
"entityId": "remote.tv", "data": { "command": "DPAD_UP" } } ],
"longHotkeys": [ { "key": "PAGE_UP", "service": "script.open_blinds" } ]
}
Key names for hotkeys/longHotkeys: UP DOWN LEFT RIGHT CENTER, PAGE_UP PAGE_DOWN, VOLUME_UP VOLUME_DOWN MUTE, BACK HOME POWER VOICE, LIGHT CURTAIN SCENE AC, CUSTOM_1..CUSTOM_4 (the colour row).
HaRemote App: The stock HaRemote app stays installed as the home/launcher app (the device firmware expects it i think, and it is useful in its own right and enables some natural remote features). A button mapped in Key Mapper (or any launcher shortcut) opens this app on demand — both live side by side.
Stack: Kotlin + Jetpack Compose (Material 3), OkHttp WebSocket, kotlinx.serialization. minSdk 26 / targetSdk 34, one activity, zero native code, ~20 MB debug APK.