Your home's energy, on the phone.
Native iOS (SwiftUI) and Android (Jetpack Compose). Shared logic is Kotlin Multiplatform: pairing, passkeys, Noise, the relay, the session. The box at home is the record. This app is a cached projection of it. The cloud is blind.
Not a wrap of the web app. Same protocol,
same QR, same relay, same RP ID (app.ftw.energy).
SwiftUI / Compose
│
▼
shared (KMP) — enrollment, vault, Noise IK, frames, session, relay
│
▼
wss://relay.ftw.energy (encrypted)
│
▼
FTW box
Two taps: scan the QR on the box, Face ID / biometrics, the house.
V1 is Pair + Now. That is on main as of 2026-08-22. Not a wrap of the web
app. Not Flutter, not React Native.
In the apps today
- Scan or paste a v2 pairing QR (
https://app.ftw.energy/p#v2.…). - One passkey prompt at enroll. RP ID
app.ftw.energy. PRF saltftw.prf.v1.vault. - Noise_IK_25519_ChaChaPoly_SHA256 to the box through
wss://relay.ftw.energy. - Now shows headline plus grid / solar / battery / house from frozen field ids.
- Vault, site and last readings live in iOS Keychain / Android EncryptedSharedPreferences. Cold start paints from cache, then reconnects without Face ID. Forget wipes the store.
Proven here
| Check | Result |
|---|---|
./gradlew :shared:jvmTest |
Green |
Live box e2e (127.0.0.1:18080 + production relay) |
hello_ok + snapshot, phase streaming |
| iOS Simulator (iPhone 17, iOS 26.5) | Built and launched |
Android emulator FTW_Phone (API 35 ARM64) |
APK installed, Pair shown twice |
Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the ceremony and the real Noise / relay / box path. The Android emulator has no camera feed — paste the pairing link.
Not v1 (do not start these next)
Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN, WebRTC, push, App Store / Play listing.
Known holes
srcStateshould follow the Now fields'srcIdin the dict, not every driver on the site.- Wrap key is raw PRF bytes, not the web app's HKDF. A native vault will not open in the PWA, and the other way around.
PasskeyHost.enrollfrom Kotlin still blocks. The UIs call the async ceremony and skip that path.- Field ids in
Explanation.ktare still hand-written; they should come fromprotocol/registry.yaml.
JDK 21.
export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"
./gradlew :shared:jvmTestThat suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching the box's interop hex, 512-byte lane 0 frames, the vault, and a session that turns hello_ok + snap into Now state.
Start a box (make dev in srcfl/ftw, or
go run ./cmd/ftw with app_link.enabled: true). The box joins
wss://relay.ftw.energy — that origin is fixed on the box, so the client
meets it there.
export FTW_LIVE_BOX=127.0.0.1:8080 # or 18080 if the API port moved
export FTW_LIVE_RELAY=wss://relay.ftw.energy
./scripts/e2e-ftw.shThe test mints POST /api/app-link/pairing with {"role":"owner"}, runs the
shipped connectToSite path (Noise IK, pairing code in handshake message 1,
prologue ftw.session.v1: + box static), and asserts hello_ok plus a
snapshot that includes the frozen field ids.
iOS: open iosApp/iosApp.xcodeproj. SwiftUI Pair (camera QR + paste) and Now.
Xcode 16+, iOS 18 for passkey PRF. A Run Script build phase compiles the
Shared framework with
./gradlew :shared:embedAndSignAppleFrameworkForXcode.
Android: ./gradlew :androidApp:assembleDebug (minSdk 28). Pair uses CameraX
- ML Kit for the QR. Passkeys go through Credential Manager.
Emulator (AVD FTW_Phone, API 35 ARM64 Google APIs):
export ANDROID_HOME="$HOME/Android/sdk"
"$ANDROID_HOME/emulator/emulator" -avd FTW_Phone
adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk
adb shell am start -n energy.ftw.app/.MainActivityRP ID app.ftw.energy. PRF salt ftw.prf.v1.vault. Reading uses a local
wrapping copy so Now paints without a passkey prompt.
| Path | What |
|---|---|
shared/ |
KMP: identity, crypto, protocol, relay, session |
androidApp/ |
Compose UI |
iosApp/ |
SwiftUI UI |
protocol/registry.yaml |
Names shared with the box |
scripts/e2e-ftw.sh |
Live box e2e |
Apache-2.0. See LICENSE.