Skip to content

Latest commit

 

History

History
211 lines (180 loc) · 12 KB

File metadata and controls

211 lines (180 loc) · 12 KB

AGENTS.md

Guidance for AI coding agents working in this repo. Humans: see README.md.

What this is

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.

Build, test, run

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.apk

Run 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.

Hard constraints — do not break these

  • targetSdk stays at 34. Never bump it — it is what changes runtime behavior on the fleet. minSdk is 27 (lowered from 28 for the Shelly Wall Display, an Android 8.1 / Unisoc device); never raise it. compileSdk is also 34 today, but note that unlike targetSdk it 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.json requirements is 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 with minSdk 27 untouched.
    • 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 floorsrequires Android Gradle plugin 8.9.1 / 9.1.0 or higher, against the 8.7.3 in use. Not one dependency complained about minSdk.

    So the real constraint is the toolchain (AGP/Gradle), with compileSdk 34 as the secondary gate; device age governs minSdk only, and minSdk blocks nothing here. Note compileSdk has no runtime effect — only targetSdk changes 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 = true is set so Android stubs return defaults; design testable logic as pure functions.

  • Integration codec.py / client.py are HA-free and unit-tested — no homeassistant imports in them. Entity platforms (*.py) stay thin.

  • Kotlin 2.1.0, JVM target 17, Compose compiler via the Kotlin Compose plugin.

Conventions

  • 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 DashConfig JSON in the app's filesDir, edited from the config page. There is no YAML/HA-label config path anymore — don't reintroduce one.

App architecture (app/src/main/java/com/rar/hearth/)

  • The Kotlin package and the applicationId are both com.rar.hearth, so the on-device data path is /data/data/com.rar.hearth/. Changing applicationId again would force an uninstall on every device (Android treats it as a different app), which wipes filesDir — 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.
  • Builds are signed with a stable keystore (~/.hearth/hearth-release.jks, or the HEARTH_KEYSTORE env 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-as is how app-private files come off the devices.
  • App.ktHearthApp composable (top-level state, screen routing, splash overlay); MainActivity, HearthApplication, BootReceiver. Per-session UI state must be hoisted here, above the shell Crossfade — a remember {} inside HomeView is discarded on every view switch.
  • AppDeps.kt — the hand-rolled DI container: construction and wiring for every long-lived subsystem, plus the startConfigServer / startDashboard / startHearth / startVoice / startSendspin entry points. Split out of App.kt 2026-07-24; no Compose state lives here.
  • ha/ — Home Assistant WebSocket client, EntityHub (one subscribe_entities feed), connection state.
  • device/the Hearth wire protocol + device integration. HearthServer is the port-10700 server; HearthMessages the codec (HearthIncoming/HearthParser/ HearthOutgoing); MediaBridge and KioskController handle HA-driven control. (Formerly the vaca/ package — renamed 2026-07-20; the wire protocol itself, _hearth._tcp. + port 10700, is unchanged.) The Wyoming satellite lives in voice/ (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 / DashConfig persistence.
  • 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).

Device / hardware notes

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.P must stay off the ImageDecoder path (photos/ImageDecoderPhotos.kt is isolated for exactly this reason — see the SDK_INT >= P gate in photos/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 AudioTrack buffer before play() (an empty start renders silent); pad short one-shots with ≥300 ms trailing silence (bare chirps get destroyed unplayed). Never run dumpsys media.audio_flinger — it crashes the audio HAL.

  • Echo screencap can'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.ttf is the single variable font (weights via the wght axis). The melspectrogram.tflite wake-word asset is pre-patched (tools/patch-melspec-shape.py) — never replace it with a raw upstream copy.

Branding / splash / icon

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_launcheric_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.ttf instanced to wght=600.
  • The splash is version-split: API 30 uses the legacy windowBackground (splash_backgroundic_splash_lockup, wordmark baked in); API 31+ runs the system SplashScreen and ignores windowBackground, so the wordmark is supplied via windowSplashScreenBrandingImage in res/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.