Guidance for AI coding agents working in this repo. Humans: see README.md.
Hearth is one repo with two halves that talk over a small custom wire protocol:
app/— a native Android kiosk (Kotlin + Jetpack Compose) that turns a landscape Android device into an always-on Home Assistant dashboard and Wyoming voice satellite. Configured entirely from a web page the device serves on the LAN (no YAML, no HA labels).custom_components/hearth/— a slim HA custom integration that gives HA control of each device (media player, screen, brightness, toasts, TTS announce, view select, notify). Installed via HACS.
The two connect over the Hearth wire protocol: a Wyoming-style TCP server the
app runs on port 10700, advertised via mDNS _hearth._tcp.
JDK 17+ required (JAVA_HOME must point at one). The Android SDK path comes
from local.properties (sdk.dir=…).
# App — the gate. All three must pass before any commit.
./gradlew :app:testDebugUnitTest :app:assembleDebug :app:lintDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
# Integration — protocol-layer tests (Python stdlib + pytest only)
python3 -m pytest tests/integration -q
# Install / iterate on a device
adb install -r app/build/outputs/apk/debug/app-debug.apkRun the full gate green before every commit. This repo works directly on
master; keep commits small and focused.
lintDebug is part of the gate because the test policy is plain-JVM JUnit4 —
lint is the only automated check that sees the Android-framework surface (API
levels below the minSdk 27 floor, manifest and resource problems). It aborts
on errors; warnings are informational.
Versions are derived from git, never hand-edited. app/build.gradle.kts
computes versionCode from the commit count and versionName as
0.2.<commits>+<sha>[.dirty]. That string reaches HA as each device's
sw_version, so the HA device page tells you which build is on which device,
and a .dirty suffix means it was flashed from an uncommitted tree. Bump only
the baseVersion constant, and only for a real release. Anything cloning the
repo for a build needs full history (CI uses fetch-depth: 0); without .git
the version falls back to 0.2.1+nogit.
-
targetSdkstays at 34. Never bump it — it is what changes runtime behavior on the fleet.minSdkis 27 (lowered from 28 for the Shelly Wall Display, an Android 8.1 / Unisoc device); never raise it.compileSdkis also 34 today, but note that unliketargetSdkit has no runtime effect — it only sets the API surface available at compile time. Bumping it is the prerequisite for dependency updates (see below), and is a human decision, not an automatic one. -
No new dependencies on either side without explicit human approval. The app's deps (in
app/build.gradle.kts) are deliberately minimal — Compose BOM, coroutines, serialization, OkHttp, media3, NanoHTTPD, TensorFlow Lite. The integration has zero runtime/pip dependencies (manifest.jsonrequirementsis empty) — keep it that way; use only the Python stdlib. -
Dependency versions are ~18 months behind, and the fleet is not the reason. This was measured, not assumed (2026-07-24, on a scratch branch):
compileSdk 35+ Compose BOM 2025.01 + media3 1.5.1 + core-ktx 1.15.0 builds and packages clean withminSdk 27untouched.- The newest tier (core-ktx 1.19, media3 1.10.1, lifecycle 2.11,
activity 1.13, Compose BOM 2025.12) fails on AGP version floors —
requires Android Gradle plugin 8.9.1 / 9.1.0 or higher, against the 8.7.3 in use. Not one dependency complained aboutminSdk.
So the real constraint is the toolchain (AGP/Gradle), with
compileSdk 34as the secondary gate; device age governsminSdkonly, andminSdkblocks nothing here. NotecompileSdkhas no runtime effect — onlytargetSdkchanges behavior on device, and that stays at 34.Do not bump any of this on your own initiative. It is safe in principle but needs all four devices reflashed and eyeballed, so it is deliberate, human-scheduled work — the sequence is AGP/Gradle first, then
compileSdk, then the libraries. -
App tests are plain-JVM JUnit4 only — no instrumented tests, no Robolectric.
testOptions.unitTests.isReturnDefaultValues = trueis set so Android stubs return defaults; design testable logic as pure functions. -
Integration
codec.py/client.pyare HA-free and unit-tested — nohomeassistantimports in them. Entity platforms (*.py) stay thin. -
Kotlin 2.1.0, JVM target 17, Compose compiler via the Kotlin Compose plugin.
- Match the style of the surrounding code — naming, comment density, idioms. Comments explain why, not what; the codebase leans on them for non-obvious device/protocol behavior. Keep that.
- Prefer small, focused files with one clear responsibility.
- Config is web-driven: a versioned
DashConfigJSON in the app'sfilesDir, edited from the config page. There is no YAML/HA-label config path anymore — don't reintroduce one.
- The Kotlin package and the
applicationIdare bothcom.rar.hearth, so the on-device data path is/data/data/com.rar.hearth/. ChangingapplicationIdagain would force an uninstall on every device (Android treats it as a different app), which wipesfilesDir— the HA tokens, the PIN, and the device name. Don't.- The Kitchen Echo is the exception: it still runs the pre-rename build under
com.rar.echodash, deliberately left alone while its wake-capture run finishes. Anything reaching into that device —run-as,pm, capture pulls — needs the old id until it is migrated.
- The Kitchen Echo is the exception: it still runs the pre-rename build under
- Builds are signed with a stable keystore (
~/.hearth/hearth-release.jks, or theHEARTH_KEYSTOREenv vars in CI). Without it Gradle mints a throwaway key per machine and per CI run, and nothing can update anything in place. Builds stay debuggable on purpose —run-asis how app-private files come off the devices. App.kt—HearthAppcomposable (top-level state, screen routing, splash overlay);MainActivity,HearthApplication,BootReceiver. Per-session UI state must be hoisted here, above the shellCrossfade— aremember {}insideHomeViewis discarded on every view switch.AppDeps.kt— the hand-rolled DI container: construction and wiring for every long-lived subsystem, plus thestartConfigServer/startDashboard/startHearth/startVoice/startSendspinentry points. Split out ofApp.kt2026-07-24; no Compose state lives here.ha/— Home Assistant WebSocket client,EntityHub(onesubscribe_entitiesfeed), connection state.device/— the Hearth wire protocol + device integration.HearthServeris the port-10700 server;HearthMessagesthe codec (HearthIncoming/HearthParser/HearthOutgoing);MediaBridgeandKioskControllerhandle HA-driven control. (Formerly thevaca/package — renamed 2026-07-20; the wire protocol itself,_hearth._tcp.+ port 10700, is unchanged.) The Wyoming satellite lives invoice/(SatelliteServer/SatelliteSession), not here — see the note below about keeping the two apart.ui/— Compose screens;ui/panels/the right-rail panels;ui/theme/the Nunito type system and colors.data/—SettingsStore/DashConfigpersistence.web/— the NanoHTTPD config server + JSON API (PIN-gated, LAN-only).photos/,media/,voice/,night/,notify/,camera/,config/— feature subsystems (slideshow, ExoPlayer, wake word + timers, night mode, push/NWS notifications, camera streams, config models).
Voice is deliberately separate from the Hearth integration: the satellite speaks to HA core's Wyoming (port 10600) and works with or without Hearth installed. Don't entangle the two.
sendspin/ is a vendored copy of the MIT-licensed chrisuthe/SendSpinDroid
engine (see NOTICE for attribution and the exact upstream commit), trimmed to
the LOCAL WebSocket path only (no WebRTC/proxy/Noise) — Music Assistant
connects to Hearth by mDNS discovery, same as any other SendSpin player. The
vendored files carry small, documented Hearth adaptations: per-track ducking in
the three audio files (AudioSink / AudioTrackSink / SyncAudioPlayer.setVolume),
the stream-end role match + isPlayerStreamEnd extraction in
SendSpinProtocolHandler, and per-frame fault isolation + debug-level logging
in the transport — see NOTICE and git history for the exact delta. Keep that
in mind before reflexively re-syncing from upstream. The
sendspin/musicassistant/ subpackage is vendored from the same commit: the MA
JSON-RPC API client (models, Ktor WebSocket transport, MaCommandClient,
MaAuthHelper), trimmed to the library search/shelves/queue command surface
(no players/groups/favorites, playlist editing, podcasts/audiobooks, browse
folders, or WebRTC/proxy; SearchResults drops those result lists). Hearth
drives it through MaLibrary with isRemoteMode hard-wired false (LOCAL
path only) and authenticates with the MA token the config page's sign-in
stores in the web config (sendspin.maToken).
Landscape kiosk only. The fleet spans Android 8.1 → 13, which is why minSdk is
27 and why adaptive sizing (ui/model/AdaptiveGeometry.kt) exists:
| Device | OS / API | SoC | Panel |
|---|---|---|---|
| Echo Show 5 (×2) | LineageOS 18.1 / Android 11 (30) | MT8163 | 960×480 |
| Echo Show 8 | LineageOS 18.1 / Android 11 (30) | MT8183 | 1280×800 |
| Lenovo Tab M9 | Android 13 (33) | — | 1340×800 |
| Shelly Wall Display E500 | Android 8.1 (27) | Unisoc | 1280×800 |
-
The Shelly Wall Display sets the API floor. It is the only API-27 device; anything below
Build.VERSION_CODES.Pmust stay off theImageDecoderpath (photos/ImageDecoderPhotos.ktis isolated for exactly this reason — see theSDK_INT >= Pgate inphotos/AndroidPhotoDownloader.kt). -
The Echo Shows have no working camera (no HAL on the ROM). Don't re-probe.
-
Echo audio HAL is fragile. Prime the
AudioTrackbuffer beforeplay()(an empty start renders silent); pad short one-shots with ≥300 ms trailing silence (bare chirps get destroyed unplayed). Never rundumpsys media.audio_flinger— it crashes the audio HAL. -
Echo
screencapcan't read the Compose/hardware layer — it returns a stale window-background buffer. Verify on-device UI via the tablet (its screencap works) or by inspecting the window-background frame. -
res/font/nunito_variable.ttfis the single variable font (weights via thewghtaxis). Themelspectrogram.tflitewake-word asset is pre-patched (tools/patch-melspec-shape.py) — never replace it with a raw upstream copy.
The Hearth mark is a dark rounded tile (#12141C) with an off-white masonry
fireplace (#DCE0EA) and an ember→gold gradient flame. Assets: docs/logo.png,
ic_splash_lockup, and the adaptive launcher icon
(mipmap-anydpi-v26/ic_launcher → ic_launcher_background + ic_launcher_foreground).
- The "Hearth" wordmark is Nunito SemiBold baked to vector path outlines
(not live text) so it renders before Compose starts. If you regenerate it,
extract outlines from
res/font/nunito_variable.ttfinstanced towght=600. - The splash is version-split: API 30 uses the legacy
windowBackground(splash_background→ic_splash_lockup, wordmark baked in); API 31+ runs the system SplashScreen and ignoreswindowBackground, so the wordmark is supplied viawindowSplashScreenBrandingImageinres/values-v31/themes.xml(ic_wordmark). Change both if you change the splash. - The adaptive launcher icon's frame is inset within the 72 dp safe zone so no launcher mask (circle/squircle/rounded-square) clips it — it can't sit edge-to-edge like the splash tile.