From 12007f02ac54d9fa24d44f648f30e1532c4afbaf Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Tue, 1 Sep 2026 23:46:50 +0200 Subject: [PATCH 01/10] Add Hardware settings for ESP32 link --- .../modules/vescapecore/VescapeCoreModule.kt | 26 ++ .../vescapecore/hardware/HardwareLink.kt | 325 ++++++++++++++++++ modules/vescape-core/src/index.ts | 99 ++++++ src/app/_layout.tsx | 1 + src/app/settings.tsx | 13 + src/app/settings/hardware.tsx | 205 +++++++++++ src/modules/hardware/hooks/useHardwareLink.ts | 83 +++++ src/modules/hardware/store/hardwareStore.ts | 56 +++ src/navigation/routes.ts | 2 + 9 files changed, 810 insertions(+) create mode 100644 modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt create mode 100644 src/app/settings/hardware.tsx create mode 100644 src/modules/hardware/hooks/useHardwareLink.ts create mode 100644 src/modules/hardware/store/hardwareStore.ts diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt index 1e9b187a..300f6efc 100644 --- a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt @@ -5,6 +5,7 @@ import expo.modules.vescapecore.alerts.normalizedAlertBeepCount import expo.modules.vescapecore.alerts.normalizedAlertRepeatSeconds import expo.modules.vescapecore.alerts.AlertCoordinator import expo.modules.vescapecore.appstatus.AppStatusCoordinator +import expo.modules.vescapecore.hardware.HardwareLink import expo.modules.vescapecore.weather.WeatherCoordinator import expo.modules.vescapecore.auth.NativeAuthCoordinator import expo.modules.vescapecore.service.BoardProbeAutoStartGate @@ -183,6 +184,9 @@ class VescapeCoreModule : Module() { "onNavigation", "onRouteProgress", "onWeather", + "onHardwareDevice", + "onHardwareState", + "onHardwareMessage", ) // Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers @@ -378,8 +382,13 @@ class VescapeCoreModule : Module() { OnActivityResult { _, result -> companionPresence.onActivityResult(result.requestCode, result.resultCode) } + // Hardware Link is Android-only for now; the sink survives module reloads through OnDestroy. + HardwareLink.emit = { name, body -> mainHandler.post { sendEvent(name, body) } } + OnDestroy { frontendActive = false + HardwareLink.emit = null + HardwareLink.stopScan() observedEvents.clear() // Detach the JS-facing emit sink so the process-singleton registry doesn't keep the destroyed // module reachable (mirrors iOS OnDestroy nulling `onChange`). A fresh module re-attaches in @@ -400,6 +409,23 @@ class VescapeCoreModule : Module() { } Function("scan") { startScan(resetRetries = true) } + /** + * Vescape hardware device (ESP32 running `vescape-hardware`) over raw Nordic UART. + * TODO(ios parity): Android-only by request; no Swift peer yet. + * @parity /modules/vescape-core/src/index.ts `hardwareStartScan` + */ + Function("hardwareStartScan") { HardwareLink.startScan(context.applicationContext) } + Function("hardwareStopScan") { HardwareLink.stopScan() } + Function("hardwareConnect") { id: String -> HardwareLink.connect(context.applicationContext, id) } + Function("hardwareDisconnect") { HardwareLink.disconnect() } + // Async: resolves only once the device acknowledges the write (or it times out), so the JS + // console can tell "delivered" apart from "the stack took it". + AsyncFunction("hardwareSend") { text: String, promise: Promise -> + HardwareLink.send(text) { ok, status, detail -> + promise.resolve(mapOf("ok" to ok, "status" to status, "detail" to detail)) + } + } + Function("getHardwareState") { HardwareLink.state() } Function("stopScan") { stopScanInternal() } Function("exitApp") { CoreForegroundService.exitApp(context.applicationContext) } Function("startLocationUpdates") { startLocationUpdates() } diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt new file mode 100644 index 00000000..3cea474a --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt @@ -0,0 +1,325 @@ +package expo.modules.vescapecore.hardware + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.util.UUID + +private const val TAG = "VescapeHardware" + +/** Nordic UART Service, as the Vescape-HW firmware advertises it. */ +private val NUS_SERVICE_UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e") +private val NUS_TX_UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e") +private val NUS_RX_UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e") +private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + +/** Advertised name prefix the firmware uses. Boards speak NUS too, so the name is the filter. */ +private const val HARDWARE_NAME_PREFIX = "Vescape-HW" + +private const val REQUESTED_MTU = 517 + +/** How long to wait for the peer to acknowledge a write before calling it lost. */ +private const val WRITE_TIMEOUT_MS = 5_000L + +/** + * Standalone link to a Vescape hardware device (ESP32-S3 running the `vescape-hardware` firmware). + * Deliberately separate from the board session: this is a raw Nordic UART pipe with no VESC packet + * framing, no reconnect state machine, and no recording. + * + * TODO(ios parity): Android-only for now, by request. The iOS peer has no `HardwareLink`. + * @parity /modules/vescape-core/src/index.ts `HardwareStateEvent` + */ +@SuppressLint("MissingPermission") +object HardwareLink { + /** Set by the Expo module so the link can push state without holding a module reference. */ + var emit: ((String, Map) -> Unit)? = null + + private val handler = Handler(Looper.getMainLooper()) + + private var phase = "idle" + private var error: String? = null + private var deviceId: String? = null + private var deviceName: String? = null + + private var scanContext: Context? = null + private var scanCallback: ScanCallback? = null + private var gatt: BluetoothGatt? = null + private var txChar: BluetoothGattCharacteristic? = null + private var pendingWrite: ((Boolean, Int, String?) -> Unit)? = null + private var writeTimeout: Runnable? = null + + fun state(): Map = mapOf( + "phase" to phase, + "deviceId" to deviceId, + "deviceName" to deviceName, + "error" to error, + ) + + fun startScan(context: Context) { + stopScan() + scanContext = context.applicationContext + val scanner = adapterScanner(context) ?: run { + fail("Bluetooth is off or unavailable") + return + } + val cb = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val name = result.scanRecord?.deviceName ?: result.device.name ?: return + if (!name.startsWith(HARDWARE_NAME_PREFIX)) return + emit?.invoke( + "onHardwareDevice", + mapOf( + "id" to result.device.address, + "name" to name, + "rssi" to result.rssi, + ), + ) + } + + override fun onBatchScanResults(results: MutableList) { + results.forEach { onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, it) } + } + + override fun onScanFailed(errorCode: Int) { + scanCallback = null + fail("Scan failed: $errorCode") + } + } + scanCallback = cb + scanner.startScan( + null, + ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), + cb, + ) + error = null + setPhase("scanning") + } + + fun stopScan() { + val cb = scanCallback ?: return + val context = scanContext + scanCallback = null + scanContext = null + try { + context?.let { adapterScanner(it)?.stopScan(cb) } + } catch (e: Exception) { + Log.w(TAG, "scan stop failed: ${e.message}") + } + if (phase == "scanning") setPhase("idle") + } + + fun connect(context: Context, id: String) { + stopScan() + clearGatt() + val adapter = bluetoothManager(context)?.adapter ?: run { + fail("Bluetooth is off or unavailable") + return + } + val device = try { + adapter.getRemoteDevice(id) + } catch (e: IllegalArgumentException) { + fail("Unknown device address $id") + return + } + deviceId = id + deviceName = device.name + error = null + setPhase("connecting") + gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + + fun disconnect() { + clearGatt() + deviceId = null + deviceName = null + error = null + setPhase("idle") + } + + /** + * Writes UTF-8 bytes on the TX characteristic and reports the peer's acknowledgement. + * + * Deliberately a write *with* response: this is a debug console, and "the local stack queued + * it" is not the answer the rider is looking for when nothing comes back. GATT allows one + * outstanding write at a time, so a second send while one is in flight is refused rather than + * queued. + */ + fun send(text: String, onResult: (ok: Boolean, status: Int, detail: String?) -> Unit) { + val target = gatt + val characteristic = txChar + if (target == null || characteristic == null) { + onResult(false, -1, "Not connected") + return + } + if (pendingWrite != null) { + onResult(false, -1, "A write is already in flight") + return + } + pendingWrite = onResult + val timeout = Runnable { completeWrite(false, -1, "Timed out waiting for the device") } + writeTimeout = timeout + handler.postDelayed(timeout, WRITE_TIMEOUT_MS) + + val bytes = text.toByteArray(Charsets.UTF_8) + val queued = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + target.writeCharacteristic( + characteristic, + bytes, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, + ) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + run { + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = bytes + target.writeCharacteristic(characteristic) + } + } + if (!queued) completeWrite(false, -1, "The Bluetooth stack refused the write") + } + + private fun completeWrite(ok: Boolean, status: Int, detail: String?) { + writeTimeout?.let { handler.removeCallbacks(it) } + writeTimeout = null + val callback = pendingWrite ?: return + pendingWrite = null + callback(ok, status, detail) + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (g !== gatt) { + try { g.close() } catch (e: Exception) { Log.w(TAG, "stale close: ${e.message}") } + return + } + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + g.requestMtu(REQUESTED_MTU) + } else { + clearGatt() + if (phase != "idle") { + if (status == BluetoothGatt.GATT_SUCCESS) setPhase("idle") + else fail("Disconnected (status $status)") + } + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + handler.post { if (g === gatt) g.discoverServices() } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + if (g !== gatt) return@post + val service = g.getService(NUS_SERVICE_UUID) ?: run { + clearGatt() + fail("Device does not expose the Nordic UART service") + return@post + } + txChar = service.getCharacteristic(NUS_TX_UUID) + val rx = service.getCharacteristic(NUS_RX_UUID) + if (txChar == null || rx == null) { + clearGatt() + fail("Nordic UART characteristics missing") + return@post + } + g.setCharacteristicNotification(rx, true) + val cccd = rx.getDescriptor(CCCD_UUID) + if (cccd == null) { + setPhase("connected") + return@post + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } else { + @Suppress("DEPRECATION") + run { + cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(cccd) + } + } + } + } + + override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + if (g !== gatt) return + handler.post { + completeWrite( + status == BluetoothGatt.GATT_SUCCESS, + status, + if (status == BluetoothGatt.GATT_SUCCESS) null else "GATT status $status", + ) + } + } + + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + handler.post { if (g === gatt) setPhase("connected") } + } + + // Pre-API-33 delivery path; the value-carrying overload below covers newer devices. + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + @Suppress("DEPRECATION") + deliver(g, characteristic.uuid, characteristic.value ?: return) + } + + override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { + deliver(g, characteristic.uuid, value) + } + } + + private fun deliver(g: BluetoothGatt, uuid: UUID, value: ByteArray) { + if (g !== gatt || uuid != NUS_RX_UUID) return + val text = String(value, Charsets.UTF_8) + handler.post { + emit?.invoke( + "onHardwareMessage", + mapOf("text" to text, "atMs" to System.currentTimeMillis().toDouble()), + ) + } + } + + private fun clearGatt() { + completeWrite(false, -1, "Link closed before the write was acknowledged") + txChar = null + val g = gatt ?: return + gatt = null + try { + g.disconnect() + g.close() + } catch (e: Exception) { + Log.w(TAG, "gatt cleanup failed: ${e.message}") + } + } + + private fun bluetoothManager(context: Context): BluetoothManager? = + context.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + + private fun adapterScanner(context: Context) = + bluetoothManager(context)?.adapter?.bluetoothLeScanner + + private fun fail(message: String) { + Log.w(TAG, message) + error = message + setPhase("error") + } + + private fun setPhase(next: String) { + phase = next + if (next != "error") error = null + emit?.invoke("onHardwareState", state()) + } +} diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 17a209ef..bfd191ed 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -2039,6 +2039,42 @@ export type CriticalRideNotificationPermissionStatus = | 'ephemeral' | 'unknown' +/** + * Vescape hardware device (ESP32-S3 running the `vescape-hardware` firmware), reachable over a raw + * Nordic UART link. Separate from the board session: no VESC framing, no reconnect, no recording. + * + * TODO(ios parity): Android-only by request. Calling these on iOS throws — guard with `Platform.OS`. + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt + */ +export type HardwarePhase = 'idle' | 'scanning' | 'connecting' | 'connected' | 'error' + +export interface HardwareStateEvent { + phase: HardwarePhase + deviceId: string | null + deviceName: string | null + error: string | null +} + +export interface HardwareDeviceEvent { + id: string + name: string + rssi: number +} + +/** Outcome of a write, as the device acknowledged it. */ +export interface HardwareWriteResult { + ok: boolean + /** Raw Android GATT status, or -1 when the write never reached the peer. */ + status: number + detail: string | null +} + +export interface HardwareMessageEvent { + /** UTF-8 decoded notification payload from the device. */ + text: string + atMs: number +} + /** * Event names must match the native `Events(...)` declarations exactly — a name only listed here * yields a listener that never fires. @@ -2093,6 +2129,12 @@ type VescapeCoreEvents = { onRouteProgress: (event: RouteProgressEvent) => void /** Native forecast, on every successful refresh and on subscribe. */ onWeather: (event: WeatherEvent) => void + /** A Vescape hardware device seen during a Hardware Link scan (Android only). */ + onHardwareDevice: (event: HardwareDeviceEvent) => void + /** Hardware Link phase changed (Android only). */ + onHardwareState: (event: HardwareStateEvent) => void + /** A line the hardware device sent over Nordic UART (Android only). */ + onHardwareMessage: (event: HardwareMessageEvent) => void } interface NativeEventEmitter void>> { @@ -2110,6 +2152,12 @@ interface NativeEventEmitter & { scan(): void stopScan(): void + hardwareStartScan(): void + hardwareStopScan(): void + hardwareConnect(id: string): void + hardwareDisconnect(): void + hardwareSend(text: string): Promise + getHardwareState(): HardwareStateEvent exitApp(): void startLocationUpdates(): void stopLocationUpdates(): void @@ -3575,3 +3623,54 @@ export function addGroupRideErrorListener( ): EventSubscription { return emitter.addListener('onGroupRideError', cb) } + +/** + * Hardware Link controls. Android-only for now — callers must guard with `Platform.OS`. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/VescapeCoreModule.kt `hardwareStartScan` + */ +export function hardwareStartScan(): void { + native.hardwareStartScan() +} + +export function hardwareStopScan(): void { + native.hardwareStopScan() +} + +export function hardwareConnect(id: string): void { + native.hardwareConnect(id) +} + +export function hardwareDisconnect(): void { + native.hardwareDisconnect() +} + +/** + * Sends UTF-8 text on the device's TX characteristic. Resolves once the device acknowledges the + * write, so a resolved `ok: true` means delivered, not merely queued. + */ +export function hardwareSend(text: string): Promise { + return native.hardwareSend(text) +} + +export function getHardwareState(): HardwareStateEvent { + return native.getHardwareState() +} + +export function addHardwareDeviceListener( + cb: (event: HardwareDeviceEvent) => void, +): EventSubscription { + return emitter.addListener('onHardwareDevice', cb) +} + +export function addHardwareStateListener( + cb: (event: HardwareStateEvent) => void, +): EventSubscription { + return emitter.addListener('onHardwareState', cb) +} + +export function addHardwareMessageListener( + cb: (event: HardwareMessageEvent) => void, +): EventSubscription { + return emitter.addListener('onHardwareMessage', cb) +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 9625c241..502678a3 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -206,6 +206,7 @@ function RootLayout() { + diff --git a/src/app/settings.tsx b/src/app/settings.tsx index 10004663..b296d347 100644 --- a/src/app/settings.tsx +++ b/src/app/settings.tsx @@ -21,6 +21,7 @@ import { EngineIcon, MapTrifoldIcon, PaletteIcon, + CpuIcon, } from 'phosphor-react-native' import { routes } from '@/navigation/routes' @@ -147,6 +148,18 @@ export default function SettingsScreen() { onPress={() => router.push(routes.settingsWatch)} /> + + Hardware + + + router.push(routes.settingsHardware)} + /> + )} diff --git a/src/app/settings/hardware.tsx b/src/app/settings/hardware.tsx new file mode 100644 index 00000000..a886b733 --- /dev/null +++ b/src/app/settings/hardware.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react' +import { ScrollView, StyleSheet, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useShallow } from 'zustand/react/shallow' +import { CpuIcon } from 'phosphor-react-native' + +import { theme } from '@/constants/theme' +import { Text } from '@/components/base/Text' +import { Button } from '@/components/base/Button' +import { DeviceRow } from '@/components/base/DeviceRow' +import { Input } from '@/components/forms/Input' +import { IconHero } from '@/components/settings/IconHero' +import { SettingsCard } from '@/components/settings/SettingsCard' +import { SettingsSectionTitle } from '@/components/settings/SettingsSectionTitle' +import { usePermissions } from '@/modules/settings/hooks/usePermissions' +import { useHardwareLink } from '@/modules/hardware/hooks/useHardwareLink' +import { useHardwareStore } from '@/modules/hardware/store/hardwareStore' + +const PHASE_LABEL = { + idle: 'Not connected', + scanning: 'Scanning', + connecting: 'Connecting', + connected: 'Connected', + error: 'Error', +} as const + +const PHASE_COLOR = { + idle: theme.neutral.textMuted, + scanning: theme.palette.sky.color, + connecting: theme.palette.amber.color, + connected: theme.palette.green.color, + error: theme.status.error.color, +} as const + +const LINE_PREFIX = { rx: '<', tx: '>', error: '!' } as const + +const LINE_COLOR = { + rx: theme.palette.green.text, + tx: theme.neutral.textMuted, + error: theme.status.error.text, +} as const + +/** + * Hardware Link console for the ESP32-S3 running the `vescape-hardware` firmware. Android-only for + * now; the settings row that leads here is hidden on iOS. + */ +export default function HardwareSettingsScreen() { + const link = useHardwareLink() + const permissions = usePermissions() + const [draft, setDraft] = useState('') + const { phase, deviceName, deviceId, error, devices, lines } = useHardwareStore( + useShallow((s) => ({ + phase: s.phase, + deviceName: s.deviceName, + deviceId: s.deviceId, + error: s.error, + devices: s.devices, + lines: s.lines, + })), + ) + + const connected = phase === 'connected' + const scanning = phase === 'scanning' + + const startScan = async () => { + await permissions.request() + link.scan() + } + + const send = () => { + const text = draft.trim() + if (!text) return + void link.send(text) + setDraft('') + } + + return ( + + + + + + + {PHASE_LABEL[phase]} + {deviceName ?? deviceId ?? 'No device'} + {error ? {error} : null} + + + + +