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..e7fef819 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,11 @@ class VescapeCoreModule : Module() { "onNavigation", "onRouteProgress", "onWeather", + "onHardwareDevice", + "onHardwareState", + "onHardwareMessage", + "onHardwareSensor", + "onHardwareSeries", ) // Native owns App Status truth; JS mirrors it. Push every successful refresh (late subscribers @@ -378,8 +384,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 +411,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..0f536772 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/HardwareLink.kt @@ -0,0 +1,415 @@ +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 + +/** + * How long notifications are gathered before one batch crosses into JS. + * + * The board can push fifty frames a second, and one JS callback per notification is enough to + * starve the JS thread on its own — timers stop firing and the UI stops answering touches long + * before any of that data is drawn. Buffering here costs a tenth of a second of latency and keeps + * every frame: nothing is dropped, it just arrives in groups. + */ +private const val MESSAGE_FLUSH_MS = 100L + +/** + * How often the decimated chart series crosses into JS. + * + * Four redraws a second reads as continuous on a scrolling chart, and it decouples the drawing + * cost from a link that can push fifty frames a second. + */ +private const val SERIES_FLUSH_MS = 250L + +/** + * 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 + private val pendingMessages = mutableListOf>() + private var flushScheduled = false + private val sensors = SensorLog() + private var seriesScheduled = false + + 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) { + // Claimed before the scan is stopped: `stopScan` publishes an idle phase when it ends a + // scan, and a listener that re-scans on idle would then race this connect and leave the + // link reporting "scanning" while it is connected. + phase = "connecting" + 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) + // Stamped here rather than at flush time, so batching never distorts the arrival times a + // consumer measures the link's rate from. + val atMs = System.currentTimeMillis().toDouble() + handler.post { + pendingMessages.add(mapOf("text" to text, "atMs" to atMs)) + if (!flushScheduled) { + flushScheduled = true + handler.postDelayed(::flushMessages, MESSAGE_FLUSH_MS) + } + } + } + + /** + * Turns a batch of notifications into what the screen actually shows: sensor frames go to the + * log and leave as numbers, anything else is console text. Frames never reach the console — + * fifty a second would scroll away every reply the board sends within a frame of it arriving. + */ + private fun flushMessages() { + flushScheduled = false + if (pendingMessages.isEmpty()) return + val batch = pendingMessages.toList() + pendingMessages.clear() + + var frames = 0 + val lines = batch.filter { message -> + val text = message["text"] as? String ?: return@filter false + val atMs = (message["atMs"] as? Double)?.toLong() ?: 0L + if (sensors.append(text, atMs)) { + frames += 1 + false + } else { + true + } + } + if (lines.isNotEmpty()) emit?.invoke("onHardwareMessage", mapOf("messages" to lines)) + if (frames == 0) return + + val rate = sensors.rate() + emit?.invoke( + "onHardwareSensor", + mapOf( + "keys" to sensors.keys(), + "values" to sensors.live(), + "ranges" to sensors.ranges(), + "hz" to rate.hz, + "dropped" to rate.dropped, + "readMs" to rate.readMs, + ), + ) + if (!seriesScheduled) { + seriesScheduled = true + handler.postDelayed(::flushSeries, SERIES_FLUSH_MS) + } + } + + private fun flushSeries() { + seriesScheduled = false + emit?.invoke( + "onHardwareSeries", + mapOf( + "series" to sensors.series().map { + mapOf("key" to it.key, "points" to it.points, "min" to it.min, "max" to it.max) + }, + ), + ) + } + + private fun clearGatt() { + completeWrite(false, -1, "Link closed before the write was acknowledged") + flushMessages() + // The history belongs to the link that gathered it: keeping it across a reconnect would + // draw one board's readings against another's clock. + sensors.clear() + emit?.invoke("onHardwareSeries", mapOf("series" to emptyList())) + 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/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt new file mode 100644 index 00000000..49580911 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt @@ -0,0 +1,231 @@ +package expo.modules.vescapecore.hardware + +import expo.modules.vescapecore.telemetry.LiveSeriesDownsampler + +/** + * A sensor frame as the board pushes it: a flat JSON object of numbers, one key per reading. + * Unknown keys are kept, so a newly wired sensor shows up without an app release. + * + * @parity /../vescape-hardware/src/main.cpp `sensorFrame` + */ +internal data class SensorFrame(val atMs: Long, val values: Map) + +/** One chart row, decimated and scaled, ready for the UI to draw without touching the history. */ +internal data class SensorSeries( + val key: String, + /** Flat `[ts0, v0, ts1, v1, ...]`, the cheapest shape to cross the bridge. */ + val points: DoubleArray, + val min: Double, + val max: Double, +) + +/** What the link is really doing, as opposed to what the board was asked for. */ +internal data class LinkRate(val hz: Double?, val dropped: Int, val readMs: Double?) + +/** + * The board's sensor history, and everything derived from it. + * + * This lives natively because the link can push fifty frames a second: parsing, clamping, + * buffering and decimating that in JS is enough to starve the JS thread on its own, long before + * any of it is drawn. JS is handed live numbers and a decimated series and renders them. + * + * Not thread-safe: every caller runs on the link's handler thread. + * + * @parity /src/modules/hardware/lib/sensorRuntime.ts + */ +internal class SensorLog( + /** + * How much history the charts show. The board is a live instrument here — what a sensor did + * half a minute ago is not what anyone is reading it for. + */ + private val historyMs: Long = 20_000L, + /** Hard cap, so a board reporting faster than expected cannot grow the buffer without bound. */ + private val maxFrames: Int = 2_000, +) { + private val frames = ArrayDeque() + + /** Keys seen on this link, in the order the board first sent them: the row order on screen. */ + private val keyOrder = mutableListOf() + + /** When each key first carried a value, so a chart knows how far back it may draw. */ + private val firstSeen = mutableMapOf() + + init { + declare() + } + + /** + * The rows a board is expected to fill, present from the first frame and reading their + * ceiling until a sensor says otherwise. Anything else the board sends is appended as it + * arrives, so new hardware still needs no app release. + */ + private fun declare() { + for (key in DECLARED_KEYS) { + keyOrder.add(key) + firstSeen[key] = 0L + } + } + + fun keys(): List = keyOrder.toList() + + /** + * Reads a device notification as a sensor frame and keeps it, or returns false when the text + * is anything else — echoes, boot chatter, a half-delivered write — which belongs in the + * console instead. + */ + fun append(text: String, atMs: Long): Boolean { + val frame = parseFrame(text, atMs) ?: return false + frames.addLast(frame) + // Trimmed by age rather than count: the window is a span of time, whatever rate fills it. + while (frames.size > maxFrames || (frames.firstOrNull()?.atMs ?: atMs) < atMs - historyMs) { + frames.removeFirst() + } + for (key in frame.values.keys) { + // First seen, not last: a key whose stamp moved with every frame would keep the chart + // from filling the gaps behind it. + if (firstSeen.putIfAbsent(key, atMs) == null) keyOrder.add(key) + } + return true + } + + fun clear() { + frames.clear() + keyOrder.clear() + firstSeen.clear() + declare() + } + + /** + * Latest value per key, in display units, in row order. A key the newest frame did not carry + * still gets a value — its ceiling — or NaN when it has none: a sensor that stopped answering + * must not leave a stale number standing. + */ + fun live(): DoubleArray { + val latest = frames.lastOrNull() + return DoubleArray(keyOrder.size) { index -> + val key = keyOrder[index] + readingValue(key, latest?.values?.get(key)) ?: Double.NaN + } + } + + /** + * The display range per key, as `[min0, max0, min1, max1, ...]` parallel to the rows, NaN + * where a reading has no fixed range. A row needs it to draw a value as a proportion rather + * than a number, and the range is part of the reading contract, not the screen's to guess. + */ + fun ranges(): DoubleArray { + val out = DoubleArray(keyOrder.size * 2) + for (index in keyOrder.indices) { + val spec = readingSpec(keyOrder[index]) + out[index * 2] = spec.min ?: Double.NaN + out[index * 2 + 1] = spec.max ?: Double.NaN + } + return out + } + + /** + * The board stamps every frame with `seq`, so a rate below the requested one can be told apart + * from notifications the phone dropped: a slow board keeps its sequence intact, a saturated + * link skips numbers. + */ + fun rate(): LinkRate { + val latest = frames.lastOrNull() ?: return LinkRate(null, 0, null) + val readMs = latest.values["readMs"] + val cutoff = latest.atMs - RATE_WINDOW_MS + val window = frames.filter { it.atMs >= cutoff } + if (window.size < 2) return LinkRate(null, 0, readMs) + + val span = latest.atMs - window.first().atMs + val hz = if (span > 0L) (window.size - 1) * 1000.0 / span else null + + var dropped = 0 + for (index in 1 until window.size) { + val previous = window[index - 1].values["seq"] ?: continue + val current = window[index].values["seq"] ?: continue + if (current > previous) dropped += (current - previous - 1).toInt() + } + return LinkRate(hz, dropped, readMs) + } + + /** + * One decimated series per chartable key, in row order. + * + * A key missing from a frame is a gap, not a zero: the ToF drops out when nothing is in range, + * and drawing that as a floor would read as an object right against the sensor. A sensor with + * a fixed range instead rides its ceiling, so both distance rows keep advancing on the same + * head — but only once it has answered at least once, so the ceiling never invents history. + */ + fun series(): List { + val out = mutableListOf() + val oldest = frames.firstOrNull()?.atMs ?: return out + for (key in keyOrder) { + val spec = readingSpec(key) + if (!spec.chart) continue + + val samples = mutableListOf() + val values = mutableListOf() + var min = Double.MAX_VALUE + var max = -Double.MAX_VALUE + // Answered before this window opened, so the ceiling may fill from its first frame. + var started = (firstSeen[key] ?: Long.MAX_VALUE) <= oldest + for (frame in frames) { + val raw = frame.values[key] + if (raw == null && !started) continue + val value = readingValue(key, raw) ?: continue + started = true + samples.add(frame) + values.add(value) + if (value < min) min = value + if (value > max) max = value + } + if (values.size < 2) continue + + val points = LiveSeriesDownsampler.downsampleMinMax( + rows = samples.indices.toList(), + bucketCount = BUCKET_COUNT, + windowMs = historyMs, + timestamp = { samples[it].atMs }, + value = { values[it] }, + ) + // A fixed range keeps a distance row on one scale; everything else fits its data. + val pad = maxOf((max - min) * 0.1, MIN_SPAN / 2) + out.add( + SensorSeries( + key = key, + points = points, + min = spec.min ?: (min - pad), + max = spec.max ?: (max + pad), + ), + ) + } + return out + } + + private companion object { + /** Rate window: short enough to react, long enough not to flicker. */ + const val RATE_WINDOW_MS = 3_000L + + /** + * Buckets per chart row. A phone chart is a few hundred pixels wide, so beyond this the + * history is several points per pixel: it costs a redraw and shows nothing extra. + */ + const val BUCKET_COUNT = 200 + + /** Flat series get a readable band rather than an axis collapsed onto one value. */ + const val MIN_SPAN = 1.0 + } +} + +/** Flat `"key": number` pairs. Nested objects are not part of the frame contract. */ +private val NUMBER_FIELD = Regex("\"([^\"]+)\"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][-+]?\\d+)?)") + +internal fun parseFrame(text: String, atMs: Long): SensorFrame? { + val trimmed = text.trim() + if (!trimmed.startsWith("{")) return null + val values = mutableMapOf() + for (match in NUMBER_FIELD.findAll(trimmed)) { + val value = match.groupValues[2].toDoubleOrNull() ?: continue + if (value.isFinite()) values[match.groupValues[1]] = value + } + return if (values.isEmpty()) null else SensorFrame(atMs, values) +} diff --git a/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorReadings.kt b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorReadings.kt new file mode 100644 index 00000000..b8d86c64 --- /dev/null +++ b/modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorReadings.kt @@ -0,0 +1,59 @@ +package expo.modules.vescapecore.hardware + +/** + * The numeric half of a board reading: what a raw frame value means, and what may be drawn from + * it. Labels, units and colors are the app's business and stay in JS; scale, range and + * chartability are the contract with the firmware and are decided here, once, for both the live + * numbers and the charts. + * + * @parity /src/modules/hardware/lib/sensorReadings.ts `READINGS` + */ +internal data class ReadingSpec( + /** Raw frame units to display units. Both distance sensors are shown in cm. */ + val scale: Double = 1.0, + /** + * Display range. Values are clamped into it, the chart axis is fixed to it, and a sensor that + * read nothing sits at its top: "no target" means "further than the range", not zero. + */ + val min: Double? = null, + val max: Double? = null, + /** Whether this reading is worth a chart row, or is a number to glance at. */ + val chart: Boolean = false, +) + +/** Useful reach for both distance sensors on a board. Anything past this is not a reading. */ +private const val DISTANCE_MAX_CM = 40.0 + +private val SPECS = mapOf( + "distanceMm" to ReadingSpec(scale = 0.1, min = 0.0, max = DISTANCE_MAX_CM, chart = true), + "rangeCm" to ReadingSpec(min = 0.0, max = DISTANCE_MAX_CM, chart = true), + "upMs" to ReadingSpec(scale = 0.001), +) + +private val UNKNOWN = ReadingSpec() + +/** + * Readings the app knows a board can take, in the order they are shown. + * + * A ranged sensor is declared rather than discovered: "nothing in reach" is a reading, and the + * firmware leaves the key out of the frame when it gets one. Waiting for a first echo before the + * row exists means the row appears late, and everything under it jumps when it does. + */ +internal val DECLARED_KEYS: List = SPECS.entries.filter { it.value.max != null }.map { it.key } + +internal fun readingSpec(key: String): ReadingSpec = SPECS[key] ?: UNKNOWN + +/** + * A raw frame value in display units, clamped to the key's range, or null when the sensor read + * nothing and has no ceiling to fall back on. + */ +internal fun readingValue(key: String, raw: Double?): Double? { + val spec = readingSpec(key) + if (raw == null) return spec.max + if (!raw.isFinite()) return null + val converted = raw * spec.scale + val min = spec.min + val max = spec.max + if (min == null || max == null) return converted + return converted.coerceIn(min, max) +} diff --git a/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/hardware/SensorLogTest.kt b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/hardware/SensorLogTest.kt new file mode 100644 index 00000000..8f62c194 --- /dev/null +++ b/modules/vescape-core/android/src/test/java/expo/modules/vescapecore/hardware/SensorLogTest.kt @@ -0,0 +1,110 @@ +package expo.modules.vescapecore.hardware + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SensorLogTest { + @Test + fun `keeps numbers and refuses console chatter`() { + val log = SensorLog() + assertTrue(log.append("""{"seq":1,"distanceMm":100,"name":"hw"}""", 1_000L)) + assertFalse(log.append("rate: 10.0 Hz", 1_010L)) + assertFalse(log.append("{}", 1_020L)) + // The distance rows are declared, so they exist before a sensor has answered. + assertEquals(listOf("distanceMm", "rangeCm", "seq"), log.keys()) + } + + @Test + fun `reports the delivered rate and the frames the link lost`() { + val log = SensorLog() + // Ten frames a second, with sequence 3 never arriving. + log.append("""{"seq":1,"readMs":4}""", 1_000L) + log.append("""{"seq":2,"readMs":4}""", 1_100L) + log.append("""{"seq":4,"readMs":5}""", 1_200L) + val rate = log.rate() + assertEquals(10.0, rate.hz!!, 0.01) + assertEquals(1, rate.dropped) + assertEquals(5.0, rate.readMs!!, 0.0) + } + + @Test + fun `scales and clamps into display units`() { + val log = SensorLog() + log.append("""{"distanceMm":8190,"rangeCm":-2,"upMs":5000}""", 1_000L) + assertEquals(listOf(40.0, 0.0, 5.0), log.live().toList()) + } + + @Test + fun `reads a declared sensor as far until it says otherwise`() { + val log = SensorLog() + assertEquals(listOf("distanceMm", "rangeCm"), log.keys()) + assertEquals(listOf(40.0, 40.0), log.live().toList()) + + log.append("""{"distanceMm":100}""", 1_000L) + // The ultrasonic said nothing; it is out of reach, not missing. + assertEquals(listOf(10.0, 40.0), log.live().toList()) + } + + @Test + fun `reports a range only for the readings that have one`() { + val log = SensorLog() + log.append("""{"distanceMm":100,"tempC":40}""", 1_000L) + assertEquals( + listOf(0.0, 40.0, 0.0, 40.0, Double.NaN, Double.NaN), + log.ranges().toList(), + ) + } + + @Test + fun `holds a ranged sensor at its ceiling but invents no history before it answered`() { + val log = SensorLog() + log.append("""{"rangeCm":12}""", 1_000L) + log.append("""{"distanceMm":100}""", 1_100L) + log.append("""{"distanceMm":120,"rangeCm":14}""", 1_200L) + val series = log.series().associateBy { it.key } + + // Row order is declared, so a sensor dropping out and returning cannot move the rows. + assertEquals(listOf("distanceMm", "rangeCm"), log.series().map { it.key }) + // Both ride their ceiling through the frames they missed. + assertEquals( + listOf(1_000.0, 40.0, 1_100.0, 10.0, 1_200.0, 12.0), + series["distanceMm"]!!.points.toList(), + ) + assertEquals(listOf(1_000.0, 12.0, 1_100.0, 40.0, 1_200.0, 14.0), series["rangeCm"]!!.points.toList()) + assertEquals(0.0, series["rangeCm"]!!.min, 0.0) + assertEquals(40.0, series["rangeCm"]!!.max, 0.0) + } + + @Test + fun `charts nothing that is not a distance`() { + val log = SensorLog() + log.append("""{"tempC":40,"heapKb":200,"seq":1}""", 1_000L) + log.append("""{"tempC":41,"heapKb":200,"seq":2}""", 1_100L) + assertEquals(listOf("distanceMm", "rangeCm"), log.series().map { it.key }) + } + + @Test + fun `drops frames older than the window`() { + val log = SensorLog(historyMs = 1_000L) + log.append("""{"distanceMm":100}""", 1_000L) + log.append("""{"distanceMm":110}""", 1_500L) + log.append("""{"distanceMm":120}""", 3_000L) + log.append("""{"distanceMm":130}""", 3_100L) + // The 1000 and 1500 frames fell out of the window; only the last two are left to draw. + assertEquals(listOf(3_000.0, 12.0, 3_100.0, 13.0), log.series().first().points.toList()) + } + + @Test + fun `forgets everything with the link`() { + val log = SensorLog() + log.append("""{"distanceMm":100}""", 1_000L) + log.clear() + // Back to the declared rows, reading far, as if the link had just opened. + assertEquals(listOf("distanceMm", "rangeCm"), log.keys()) + assertEquals(listOf(40.0, 40.0), log.live().toList()) + assertNull(log.rate().hz) + } +} diff --git a/modules/vescape-core/src/index.ts b/modules/vescape-core/src/index.ts index 17a209ef..6b6f21a2 100644 --- a/modules/vescape-core/src/index.ts +++ b/modules/vescape-core/src/index.ts @@ -2039,6 +2039,91 @@ 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 HardwareMessage { + /** UTF-8 decoded notification payload from the device. */ + text: string + atMs: number +} + +/** + * Notifications received since the last delivery, oldest first. + * + * Batched rather than one event per notification: the board can push fifty a second, and a JS + * callback each is enough to starve the JS thread before any of it is drawn. Every notification + * is here, with its own arrival time. + */ +export interface HardwareMessageEvent { + messages: HardwareMessage[] +} + +/** + * The board's live readings, already parsed, scaled and clamped natively — the link can push + * fifty frames a second, and doing that work in JS starves the JS thread before any of it is + * drawn. `values` is parallel to `keys`, in the order the board first sent each key; NaN is a + * sensor with nothing to say. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt `live` + */ +export interface HardwareSensorEvent { + keys: string[] + values: number[] + /** Flat `[min0, max0, ...]` parallel to `keys`, NaN where the reading has no fixed range. */ + ranges: number[] + /** Frames per second actually delivered, or null until there are two frames to time. */ + hz: number | null + /** Frames the board numbered but the app never received, inside the rate window. */ + dropped: number + /** What the board's newest frame cost to gather, the floor under any requested rate. */ + readMs: number | null +} + +/** + * One chart row, decimated natively to the window the screen draws. + * + * @parity /modules/vescape-core/android/src/main/java/expo/modules/vescapecore/hardware/SensorLog.kt `SensorSeries` + */ +export interface HardwareSeries { + key: string + /** Flat `[ts0, v0, ts1, v1, ...]`, in display units. */ + points: number[] + min: number + max: number +} + +export interface HardwareSeriesEvent { + series: HardwareSeries[] +} + /** * Event names must match the native `Events(...)` declarations exactly — a name only listed here * yields a listener that never fires. @@ -2093,6 +2178,16 @@ 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 + /** Lines the hardware device sent over Nordic UART, batched (Android only). */ + onHardwareMessage: (event: HardwareMessageEvent) => void + /** Live sensor readings, ~10x a second (Android only). */ + onHardwareSensor: (event: HardwareSensorEvent) => void + /** Decimated sensor history for the charts, ~4x a second (Android only). */ + onHardwareSeries: (event: HardwareSeriesEvent) => void } interface NativeEventEmitter void>> { @@ -2110,6 +2205,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 +3676,66 @@ 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) +} + +export function addHardwareSensorListener( + cb: (event: HardwareSensorEvent) => void, +): EventSubscription { + return emitter.addListener('onHardwareSensor', cb) +} + +export function addHardwareSeriesListener( + cb: (event: HardwareSeriesEvent) => void, +): EventSubscription { + return emitter.addListener('onHardwareSeries', cb) +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 9625c241..36d357a4 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..2a614075 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' @@ -136,7 +137,7 @@ export default function SettingsScreen() { {Platform.OS === 'android' && ( <> - Watch + Hardware router.push(routes.settingsWatch)} /> + router.push(routes.settingsSensors)} + /> )} diff --git a/src/app/settings/sensors.tsx b/src/app/settings/sensors.tsx new file mode 100644 index 00000000..e313553a --- /dev/null +++ b/src/app/settings/sensors.tsx @@ -0,0 +1,344 @@ +import { useMemo, 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 { ChartStack } from '@/components/charts/line/ChartStack' +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 { LiveNumber } from '@/modules/hardware/components/LiveNumber' +import { SensorBar } from '@/modules/hardware/components/SensorBar' +import { useHardwareLink } from '@/modules/hardware/hooks/useHardwareLink' +import { useSensorVersion } from '@/modules/hardware/hooks/useSensors' +import { buildSensorCharts } from '@/modules/hardware/lib/sensorCharts' +import { describeReadings } from '@/modules/hardware/lib/sensorReadings' +import { + linkDropped, + linkHz, + linkReadMs, + liveValue, + readSensorKeys, + readSensorRange, + readSensorSeries, +} from '@/modules/hardware/lib/sensorRuntime' +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 + +/** + * Rates the board can be retuned to from here. The board clamps anything it cannot hold, and the + * Link rows below say what it actually delivered, so these are requests rather than settings. + */ +const RATE_PRESETS = [1, 5, 10, 20, 30] 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 — Vescape's own + * sensors and controls, not the board. Android-only for now; the settings row that leads here is + * hidden on iOS. + */ +export default function SensorsSettingsScreen() { + const link = useHardwareLink() + const permissions = usePermissions() + const [draft, setDraft] = useState('') + const sensorVersion = useSensorVersion() + 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, + })), + ) + + // Both rebuilt when native publishes, which is four times a second at most, whatever rate the + // board is running at. + // eslint-disable-next-line react-hooks/exhaustive-deps + const readings = useMemo( + () => + describeReadings(readSensorKeys()).map((reading) => ({ + ...reading, + range: readSensorRange(reading.key), + })), + [sensorVersion], + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + const charts = useMemo(() => buildSensorCharts(readSensorSeries()), [sensorVersion]) + + 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} + + + + +