[Tech Talk / Demo Show] WebView Always Laggy? How We Combined HA Lovelace with Native RosCards for Seamless D-Pad Focus Tracking & Custom Hardware Mapping

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 dispatchKeyEventbefore 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_redscript.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.

1 Like