Phase 03: ESP32-C5 serial link hardening + bench diagnostics

Enumeration:
- Merge the library's stock probe table instead of replacing it, so adding
  Espressif 0x303A/0x1001 doesn't drop every other supported device
- Select the ESP32-C5 by VID/PID rather than list position
- Log USB interface descriptors to distinguish CDC data from the JTAG interface
Lifecycle:
- Don't close the shared port in MqttViewModel.onCleared() - the foreground
  recording service outlives the ViewModel and would beacon into a dead port
- Handle ACTION_USB_DEVICE_DETACHED so the UI stops reporting a stale link
- Implement the STATUS heartbeat on both sides (1 Hz) plus a phone-side watchdog
- Surface write failures and firmware drop counters on the CAM Pinger card
Protocol:
- Assert DTR/RTS on open (unverified on hardware - see FLASHING.md step 5)
- Raise SERIAL_LINK_MAX_PAYLOAD 160 -> 512 on both sides; real third-party CAMs
  exceed 160 and were being silently dropped at the resync branch
- Move the enlarged buffers off task stacks; serialize send_frame with a mutex
Firmware and app must be updated together - a 512/160 mismatch fails silently.
This commit is contained in:
Ashin Walpola
2026-08-10 11:38:01 +02:00
parent 33c4ec5998
commit 64fb78590e
20 changed files with 3076 additions and 596 deletions
@@ -83,6 +83,8 @@ class MainActivity : AppCompatActivity() {
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
val espRxMode by mqttViewModel.espRxMode.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current
@@ -135,6 +137,7 @@ class MainActivity : AppCompatActivity() {
mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport,
obuHardware = obuHardware,
usbSerialState = usbSerialState,
usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType,
@@ -250,6 +253,8 @@ class MainActivity : AppCompatActivity() {
onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware,
espRxMode = espRxMode,
onEspRxModeChange = mqttViewModel::setEspRxMode,
onBack = { navController.popBackStack() },
)
}
@@ -299,7 +304,13 @@ class MainActivity : AppCompatActivity() {
AboutSettingsScreen(onBack = { navController.popBackStack() })
}
composable(Screen.Connection.route) {
ConnectionSetupScreen()
// Must be the Activity-scoped instance (same one MainActivity holds
// and MqttTopicViewerScreen is given below), not the default
// hiltViewModel() — that would create a separate instance scoped to
// this NavBackStackEntry, whose onCleared() (fired the moment you
// navigate away) would disconnect the shared UsbSerialTransport out
// from under every other screen still using it.
ConnectionSetupScreen(viewModel = mqttViewModel)
}
composable(Screen.Map.route) {
MapScreen(gnss = state.gnss)
@@ -5,7 +5,9 @@ import com.hawhamburg.micr0bu.data.GnssReading
import com.hawhamburg.micr0bu.data.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
@@ -69,12 +71,15 @@ class CamUseCaseRepository @Inject constructor(
private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport,
private val camCodec: RealAsn1UperCodec,
private val obuHardwarePrefs: ObuHardwarePreferences,
@ApplicationContext private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val engine = UseCaseDetectionEngine()
private val sensorRepository = SensorRepository(context)
@Volatile private var espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE
private val _ownStationId = MutableStateFlow<Long?>(null)
/** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */
val ownStationId: StateFlow<Long?> = _ownStationId.asStateFlow()
@@ -163,9 +168,14 @@ class CamUseCaseRepository @Inject constructor(
scope.launch {
usbSerialTransport.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.CAM_RX) return@collect
if (espRxMode == EspRxMode.SEND_ONLY) return@collect
handleCamFromSerial(frame.payload)
}
}
scope.launch {
obuHardwarePrefs.espRxModeFlow.collect { espRxMode = it }
}
}
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
@@ -24,6 +25,7 @@ class ObuHardwarePreferences @Inject constructor(
) {
private object Keys {
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
}
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
@@ -33,4 +35,17 @@ class ObuHardwarePreferences @Inject constructor(
suspend fun setObuHardware(hardware: ObuHardware) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
}
/**
* Whether the ESP32-C5 path processes received CAM traffic or only transmits — see
* [EspRxMode]'s KDoc for what this does and doesn't actually control. Defaults to
* [EspRxMode.SEND_AND_RECEIVE] (full duplex, today's existing behavior).
*/
val espRxModeFlow: Flow<EspRxMode> = context.obuHardwareDataStore.data.map { prefs ->
EspRxMode.entries.firstOrNull { it.id == prefs[Keys.ESP_RX_MODE] } ?: EspRxMode.SEND_AND_RECEIVE
}
suspend fun setEspRxMode(mode: EspRxMode) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
}
}
@@ -23,10 +23,10 @@ enum class ObuHardware(val id: String) {
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] and
* [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder].
*
* **Placeholder hardware option as of this writing** — the actual frame protocol between
* phone and ESP32-C5 firmware is not yet defined (pending translation of the existing
* ESP32 C firmware's logic to the Kotlin side). UI/settings exist so the option is visible
* and selectable, but connecting will not yet do anything real.
* Real serial link + CAM UPER codec are implemented on both sides — see
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] (phone) and
* `obu-firmware/main/serial_link.c` (firmware). See also [EspRxMode] for the send-only vs
* send-and-receive toggle (Settings > Connection).
*/
ESP32_C5("esp32_c5"),
}
@@ -18,12 +18,62 @@ object SerialFrameType {
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
const val CAM_RX: Int = 0x02
/** ESP32 -> phone: 1-byte heartbeat (0 = ok), independent of CAM traffic. */
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
* Payload layout is [EspLinkStatus] — see its KDoc. */
const val STATUS: Int = 0x03
}
/** Max payload this link carries — matches `SERIAL_LINK_MAX_PAYLOAD` in the firmware. */
const val SERIAL_LINK_MAX_PAYLOAD = 160
/**
* Max payload this link carries — MUST match `SERIAL_LINK_MAX_PAYLOAD` in the firmware.
*
* Raised from 160 to 512: the old value was reasoned from `cam.c`'s 96-byte encode buffer, which
* only ever described *our own* minimal CAM. A third-party CAM off the air carrying a path-history
* or special-vehicle container comfortably exceeds 160, and both sides' framing treats
* `len > MAX` as an unrecoverable frame boundary and resyncs — i.e. those stations would silently
* never appear in the UI. 512 clears any realistic CAM; the actual upstream ceiling on the RX path
* is the firmware's 400-byte promiscuous capture buffer (`rx_item_t.data` in `main.c`), so nothing
* bigger can reach this link anyway.
*
* The two sides must be updated together — a phone at 512 talking to firmware still at 160 has
* every large frame rejected by the firmware's resync branch. Reflash and reinstall as one step.
*/
const val SERIAL_LINK_MAX_PAYLOAD = 512
/**
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
* saturate at 0xFFFF rather than wrapping.
*
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
* watching — are visible in the app during bench testing.
*/
data class EspLinkStatus(
/** 0 = ok. Non-zero values reserved for future firmware-side fault flags. */
val status: Int,
/** CAMs the firmware refused to forward because they exceeded [SERIAL_LINK_MAX_PAYLOAD]. */
val oversizeDrops: Int,
/** `esp_wifi_80211_tx()` failures — a CAM reached the radio but didn't go out. */
val txFailures: Int,
/** Frames from the phone the firmware dropped on CRC mismatch. */
val rxCrcErrors: Int,
) {
companion object {
const val PAYLOAD_SIZE = 7
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
fun parse(payload: ByteArray): EspLinkStatus? {
if (payload.size < PAYLOAD_SIZE) return null
fun u16(i: Int) = (payload[i].toInt() and 0xFF) or ((payload[i + 1].toInt() and 0xFF) shl 8)
return EspLinkStatus(
status = payload[0].toInt() and 0xFF,
oversizeDrops = u16(1),
txFailures = u16(3),
rxCrcErrors = u16(5),
)
}
}
}
private const val SYNC0: Byte = 0xAA.toByte()
private const val SYNC1: Byte = 0x55.toByte()
@@ -8,17 +8,30 @@ import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.SystemClock
import android.util.Log
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
import com.hoho.android.usbserial.driver.ProbeTable
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,20 +39,39 @@ import javax.inject.Singleton
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
private const val TAG = "UsbSerialTransport"
/**
* UART connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
* USB-serial connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
* Section 13). Unlike [UsbNetworkDetector] (CiT One, USB-C tethering → virtual Ethernet → MQTT
* broker), the ESP32-C5 has no MQTT broker or IP network at all — it's reachable only as a USB
* serial (UART) device, framed per [SerialFrameType] (see that file's KDoc — must stay
* bit-for-bit compatible with the firmware's `obu-firmware/main/serial_link.c`).
* CDC-ACM serial device (over its native USB Serial/JTAG peripheral, not a UART bridge), framed
* per [SerialFrameType] (see that file's KDoc — must stay bit-for-bit compatible with the
* firmware's `obu-firmware/main/serial_link.c`).
*
* Built on `com.github.mik3y:usb-serial-for-android`, which auto-detects CDC-ACM (what the
* ESP32-C5's native USB is expected to enumerate as) plus common USB-UART bridge chips as a
* fallback, so this doesn't need to hardcode a specific driver class.
* Built on `com.github.mik3y:usb-serial-for-android`. The ESP32-C5 exposes the serial link over
* its native USB Serial/JTAG peripheral (see firmware `serial_link.c`), which enumerates as a
* standard CDC-ACM device but under Espressif's own VID/PID (0x303A / 0x1001) rather than one of
* the specific vendor devices (Arduino, FTDI, etc.) the library's *default* prober already knows
* about — `UsbSerialProber.getDefaultProber()` alone will not recognize it. [customProber] starts
* from the library's stock probe table and adds that VID/PID -> [CdcAcmSerialDriver] mapping on
* top, so both the ESP32-C5 and every device the library already supports keep working.
*
* Baud rate is fixed at 115200 to match `SERIAL_LINK_BAUD` in the firmware — if that ever
* changes on the firmware side, [BAUD_RATE] here must change with it.
* Plug the phone into the board's NATIVE USB-C port (not the separate UART-bridge/flashing
* port) — only that port is wired to this peripheral. See obu-firmware/FLASHING.md.
*
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
*
* ## Ownership
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
* [com.hawhamburg.micr0bu.service.CamTransmitLoop], and the bench
* [com.hawhamburg.micr0bu.service.CamPinger]. The port is therefore closed on exactly three
* events, none of which is a UI object being torn down: an explicit user Disconnect, the device
* detaching, or the app process dying. In particular `MqttViewModel.onCleared()` deliberately does
* NOT call [disconnect] — swiping the Activity away mid-recording would otherwise close the port
* out from under a still-running foreground service, which would keep beaconing into a dead port.
*/
@Singleton
class UsbSerialTransport @Inject constructor(
@@ -47,10 +79,38 @@ class UsbSerialTransport @Inject constructor(
) {
companion object {
private const val BAUD_RATE = 115_200
// Espressif USB Serial/JTAG controller's fixed default VID/PID (shared across
// ESP32-C3/C6/H2/C5 etc. when using the on-chip USB Serial/JTAG peripheral rather than
// an external USB-UART bridge chip).
private const val ESPRESSIF_VID = 0x303A
private const val ESP_USB_SERIAL_JTAG_PID = 0x1001
/**
* Declare the link dead if nothing at all arrives for this long. The firmware sends a
* [SerialFrameType.STATUS] heartbeat at 1 Hz independent of CAM traffic, so three missed
* beats is unambiguous rather than just a quiet radio.
*/
private const val LINK_TIMEOUT_MS = 3_500L
private const val WATCHDOG_POLL_MS = 500L
}
private val customProber: UsbSerialProber = run {
// Start from the library's stock table (getDefaultProbeTable() returns a fresh, mutable
// copy) rather than an empty ProbeTable, so adding the ESP32-C5 doesn't silently drop
// support for every device the library already knows.
val table: ProbeTable = UsbSerialProber.getDefaultProbeTable()
table.addProduct(ESPRESSIF_VID, ESP_USB_SERIAL_JTAG_PID, CdcAcmSerialDriver::class.java)
UsbSerialProber(table)
}
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** Guards [port]/[ioManager] and the open/close transitions. */
private val lock = Any()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
@@ -58,22 +118,50 @@ class UsbSerialTransport @Inject constructor(
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
/** Latest firmware heartbeat + drop counters, or null if none received yet. */
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
private val _consecutiveWriteFailures = MutableStateFlow(0)
/**
* Consecutive failed [sendCamTx] calls (reset to 0 by any success). Non-zero means CAMs are
* being built and dropped on the floor — surfaced in the UI so "Sent: 240" can't quietly mean
* "240 writes failed".
*/
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
private val decoder = SerialFrameDecoder()
@Volatile private var port: UsbSerialPort? = null
@Volatile private var ioManager: SerialInputOutputManager? = null
@Volatile private var pendingDevice: UsbDevice? = null
@Volatile private var openDeviceName: String? = null
@Volatile private var lastFrameAtMs: Long = 0L
@Volatile private var watchdogJob: Job? = null
private val permissionReceiver = object : BroadcastReceiver() {
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_USB_PERMISSION) return
synchronized(this) {
val device: UsbDevice? = intent.getUsbDeviceExtra()
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted && device != null) {
openDevice(device)
} else {
_state.value = UsbSerialState.ERROR
when (intent.action) {
ACTION_USB_PERMISSION -> {
val device: UsbDevice? = intent.getUsbDeviceExtra()
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted && device != null) {
Log.i(TAG, "usbReceiver: permission granted, opening device")
openDevice(device)
} else {
Log.w(TAG, "usbReceiver: permission denied or device null " +
"(granted=$granted, device=$device)")
_state.value = UsbSerialState.ERROR
}
}
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
val device: UsbDevice? = intent.getUsbDeviceExtra()
// Without this the state stays CONNECTED after an unplug until some later
// write happens to throw — i.e. the UI lies about the link for as long as
// nothing is being sent.
if (device != null && device.deviceName == openDeviceName) {
Log.i(TAG, "usbReceiver: our device detached, closing port")
disconnect()
}
}
}
}
@@ -82,30 +170,54 @@ class UsbSerialTransport @Inject constructor(
private var receiverRegistered = false
/**
* Finds the first attached USB-serial-capable device, requests permission if needed, and
* opens it. Safe to call repeatedly (e.g. from a "retry" UI action) — no-ops if already
* connected.
* Finds the attached ESP32-C5, requests permission if needed, and opens it. Safe to call
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
*/
fun connect() {
if (_state.value == UsbSerialState.CONNECTED) return
ensureReceiverRegistered()
val availableDrivers: List<UsbSerialDriver> =
UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
val driver = availableDrivers.firstOrNull()
if (driver == null) {
_state.value = UsbSerialState.DISCONNECTED
if (_state.value == UsbSerialState.CONNECTED) {
Log.i(TAG, "connect(): already connected, no-op")
return
}
ensureReceiverRegistered()
logAttachedUsbDevices()
val availableDrivers: List<UsbSerialDriver> = customProber.findAllDrivers(usbManager)
Log.i(TAG, "connect(): customProber found ${availableDrivers.size} driver(s)")
// Select by VID/PID rather than by list position: with the stock probe table merged in,
// findAllDrivers() legitimately returns other serial devices (a hub-attached adapter, a
// CiT One), and firstOrNull() would be a coin flip between them.
val espDriver = availableDrivers.firstOrNull {
it.device.vendorId == ESPRESSIF_VID && it.device.productId == ESP_USB_SERIAL_JTAG_PID
}
val driver = espDriver ?: availableDrivers.firstOrNull()
if (driver == null) {
Log.w(TAG, "connect(): no matching driver found among attached USB devices " +
"(see device list logged above) - either nothing is attached at the Android " +
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
"customProber's table")
_state.value = UsbSerialState.DISCONNECTED
return
}
if (espDriver == null) {
Log.w(TAG, "connect(): no Espressif 0x303A/0x1001 device found - falling back to " +
"the first serial device the prober matched. This is NOT the ESP32-C5; if the " +
"link behaves oddly, check you're on the board's native USB-C port, not the " +
"UART-bridge/flashing port.")
}
val device = driver.device
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
"ports=${driver.ports.size}")
_state.value = UsbSerialState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) {
Log.i(TAG, "connect(): permission already granted, opening directly")
openDevice(device)
} else {
pendingDevice = device
Log.i(TAG, "connect(): requesting USB permission from user")
_state.value = UsbSerialState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val permissionIntent = PendingIntent.getBroadcast(
@@ -115,83 +227,202 @@ class UsbSerialTransport @Inject constructor(
}
}
/**
* Logs every USB device Android's UsbManager currently sees attached, with each device's
* interface descriptors, independent of whether usb-serial-for-android's prober recognizes
* any of them. This is the key diagnostic:
* - Nothing listed at all → the problem is at the Android/cable/OTG level, below this app
* (charge-only cable, OTG not enabled, wrong port on the board).
* - Listed, but customProber finds 0 drivers → a VID/PID or driver-matching issue here.
* - Listed with the expected interfaces but no data flows → see the DTR note in [openDevice].
*
* On the interface dump: the ESP32-C5's USB Serial/JTAG is a *composite* device. Expect a CDC
* communications interface (class 2) + a CDC data interface (class 10) + a vendor-specific
* JTAG interface (class 255). [CdcAcmSerialDriver] must land on the CDC data pair, not the
* JTAG one — compare this dump against the `ports=` count logged in [connect].
*/
private fun logAttachedUsbDevices() {
val devices = usbManager.deviceList.values
if (devices.isEmpty()) {
Log.w(TAG, "logAttachedUsbDevices(): UsbManager.deviceList is EMPTY - Android sees " +
"no USB device attached at all. This means the issue is below the app (cable, " +
"OTG mode, or the ESP32 side), not a driver-matching problem.")
return
}
devices.forEach { d ->
Log.i(TAG, "logAttachedUsbDevices(): vid=0x${d.vendorId.toString(16)} " +
"pid=0x${d.productId.toString(16)} name=${d.deviceName} " +
"class=${d.deviceClass} subclass=${d.deviceSubclass} " +
"interfaceCount=${d.interfaceCount}")
for (i in 0 until d.interfaceCount) {
val itf = d.getInterface(i)
Log.i(TAG, " itf[$i] id=${itf.id} class=${itf.interfaceClass} " +
"subclass=${itf.interfaceSubclass} proto=${itf.interfaceProtocol} " +
"endpoints=${itf.endpointCount}")
}
}
}
private fun openDevice(device: UsbDevice) {
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
if (driver == null || driver.ports.isEmpty()) {
_state.value = UsbSerialState.ERROR
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
_state.value = UsbSerialState.ERROR
return
}
val newPort = driver.ports[0]
try {
newPort.open(connection)
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (e: Exception) {
runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR
return
}
port = newPort
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
val frames = decoder.onBytes(data)
frames.forEach { _incomingFrames.tryEmit(it) }
}
override fun onRunError(e: Exception) {
synchronized(lock) {
val driver = customProber.probeDevice(device)
if (driver == null || driver.ports.isEmpty()) {
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
_state.value = UsbSerialState.ERROR
return
}
val connection = usbManager.openDevice(device)
if (connection == null) {
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
"actually granted, or Android couldn't claim the device")
_state.value = UsbSerialState.ERROR
return
}
})
ioManager = manager
// This version of the library manages its own background thread internally -
// SerialInputOutputManager.start()/stop() rather than the older pattern of the caller
// submitting it to an Executor/Thread as a Runnable (which this version's class no
// longer exposes for external use - see the two earlier compile errors this replaced).
manager.start()
_state.value = UsbSerialState.CONNECTED
val newPort = driver.ports[0]
try {
newPort.open(connection)
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (e: Exception) {
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR
return
}
// Assert DTR/RTS. CdcAcmSerialDriver does NOT do this on open(), and the ESP32's
// native USB Serial/JTAG endpoint gates its TX on the host having opened the CDC
// line — without DTR the plausible failure is the nastiest kind: enumerates, opens,
// reports CONNECTED, phone->ESP32 CAM_TX works, and ESP32->phone CAM_RX is simply
// silent forever. Wrapped in runCatching because setDTR/setRTS can throw
// (IOException / UnsupportedOperationException) depending on the driver, and that
// must not kill an otherwise working link.
// TODO(bring-up): confirm empirically whether DTR is actually required here, then
// record the answer in serial_link.h next to the VID/PID note. Test both ways.
runCatching {
newPort.setDTR(true)
newPort.setRTS(true)
}.onFailure {
Log.w(TAG, "openDevice(): DTR/RTS not supported by this driver - if no CAM_RX " +
"frames ever arrive, this is the first thing to investigate", it)
}
Log.i(TAG, "openDevice(): port opened successfully")
port = newPort
openDeviceName = device.deviceName
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
lastFrameAtMs = SystemClock.elapsedRealtime()
val frames = decoder.onBytes(data)
frames.forEach { frame ->
if (frame.type == SerialFrameType.STATUS) {
EspLinkStatus.parse(frame.payload)?.let { _linkStatus.value = it }
}
_incomingFrames.tryEmit(frame)
}
}
override fun onRunError(e: Exception) {
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
_state.value = UsbSerialState.ERROR
}
})
ioManager = manager
// This version of the library manages its own background thread internally -
// SerialInputOutputManager.start()/stop() rather than the older pattern of the caller
// submitting it to an Executor/Thread as a Runnable (which this version's class no
// longer exposes for external use - see the two earlier compile errors this replaced).
manager.start()
_consecutiveWriteFailures.value = 0
_linkStatus.value = null
lastFrameAtMs = SystemClock.elapsedRealtime()
_state.value = UsbSerialState.CONNECTED
startWatchdog()
}
}
/**
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
*/
private fun startWatchdog() {
watchdogJob?.cancel()
watchdogJob = scope.launch {
while (isActive) {
delay(WATCHDOG_POLL_MS)
if (_state.value != UsbSerialState.CONNECTED) continue
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
if (silentFor > LINK_TIMEOUT_MS) {
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
"not running, or the host->device direction opened but device->host " +
"never did (see the DTR note in openDevice()).")
_state.value = UsbSerialState.ERROR
}
}
}
}
/**
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
* treat that as "this beacon interval's CAM didn't go out," not a fatal error; the next one
* is only ~1s away and will retry naturally.
* is only ~1s away and will retry naturally. Repeated failures are counted in
* [consecutiveWriteFailures] so they can't stay invisible.
*
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
*/
fun sendCamTx(camUperBytes: ByteArray): Boolean {
val p = port ?: return false
val p = port
if (p == null) {
_consecutiveWriteFailures.update { it + 1 }
return false
}
return try {
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
p.write(frame, /* timeout ms */ 200)
_consecutiveWriteFailures.value = 0
true
} catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
_state.value = UsbSerialState.ERROR
false
}
}
fun disconnect() {
ioManager?.stop() // stops the manager's own internal background thread
ioManager = null
runCatching { port?.close() }
port = null
_state.value = UsbSerialState.DISCONNECTED
synchronized(lock) {
watchdogJob?.cancel()
watchdogJob = null
ioManager?.stop() // stops the manager's own internal background thread
ioManager = null
runCatching { port?.close() }
port = null
openDeviceName = null
_linkStatus.value = null
_consecutiveWriteFailures.value = 0
_state.value = UsbSerialState.DISCONNECTED
}
}
private fun ensureReceiverRegistered() {
if (receiverRegistered) return
val filter = IntentFilter(ACTION_USB_PERMISSION)
val filter = IntentFilter(ACTION_USB_PERMISSION).apply {
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
// NOT_EXPORTED is correct for both actions: ACTION_USB_PERMISSION is ours, and
// ACTION_USB_DEVICE_DETACHED is a protected system broadcast, which is delivered to
// context-registered receivers regardless of the exported flag.
context.registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(permissionReceiver, filter)
context.registerReceiver(usbReceiver, filter)
}
receiverRegistered = true
}
@@ -44,6 +44,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50)
@@ -64,6 +65,7 @@ fun ConnectionSetupScreen(
val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -226,11 +228,27 @@ fun ConnectionSetupScreen(
}
}
} else {
// ── ESP32-C5 placeholder card ───────────────────────────────────────
// Real USB-serial connection handling lives in UsbSerialTransport, which is a
// stub until the ESP32 firmware protocol is translated to Kotlin (Section 13).
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
val espContainerColor = when {
isEspConnected -> UsbGreenBg
isEspBusy -> UsbAmberBg
else -> UsbGrayBg
}
val (espColor, espStateLabel) = when (usbSerialState) {
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
}
Card(
colors = CardDefaults.cardColors(containerColor = UsbGrayBg),
colors = CardDefaults.cardColors(containerColor = espContainerColor),
shape = RoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
@@ -241,22 +259,57 @@ fun ConnectionSetupScreen(
Icon(
Icons.Default.Usb,
contentDescription = null,
tint = UsbGray,
tint = espColor,
modifier = Modifier.size(20.dp),
)
Text(
stringResource(R.string.conn_esp32_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = UsbGray,
color = espColor,
)
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.conn_esp32_phase3_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
if (isEspConnected) Icons.Default.Link else Icons.Default.LinkOff,
null,
tint = espColor,
modifier = Modifier.size(14.dp),
)
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
}
Spacer(Modifier.height(12.dp))
if (isEspConnected) {
OutlinedButton(
onClick = { viewModel.disconnectUsbSerial() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF5252),
),
) {
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_disconnect))
}
} else {
Button(
onClick = { viewModel.connectUsbSerial() },
modifier = Modifier.fillMaxWidth(),
enabled = !isEspBusy,
) {
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.conn_esp32_connect))
}
}
}
}
}
@@ -52,6 +52,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlin.math.sqrt
@@ -62,6 +63,7 @@ fun DashboardScreen(
mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null,
@@ -254,7 +256,13 @@ fun DashboardScreen(
onClick = onNavigateToSensors,
)
// For ESP32-C5, "connected" means the USB-serial link, not MQTT (which doesn't exist on
// that path at all — see MqttRepository.activeTransport's KDoc). Using mqttConnected
// here unconditionally would leave this card permanently gray/disconnected on ESP32-C5
// even once the serial link is actually up.
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
val transportIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb
@@ -280,10 +288,10 @@ fun DashboardScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val tint = if (mqttConnected) Color(0xFF4CAF50)
val tint = if (obuConnected) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.onSurfaceVariant
Icon(
if (mqttConnected) transportIcon else transportInactiveIcon,
if (obuConnected) transportIcon else transportInactiveIcon,
null,
tint = tint,
modifier = Modifier.size(28.dp),
@@ -296,7 +304,15 @@ fun DashboardScreen(
fontWeight = FontWeight.SemiBold,
)
Text(
text = when (mqttConnectionState) {
text = if (isEsp32) {
when (usbSerialState) {
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
}
} else when (mqttConnectionState) {
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting)
MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error)
@@ -65,7 +65,9 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
@@ -118,6 +120,11 @@ fun MqttTopicViewerScreen(
val obuHardware by viewModel.obuHardware.collectAsState()
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState()
val camPingerActive by viewModel.camPingerActive.collectAsState()
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
val camSendFailures by viewModel.camSendFailures.collectAsState()
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
// Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith(
@@ -196,11 +203,19 @@ fun MqttTopicViewerScreen(
activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount,
camSendFailures = camSendFailures,
espLinkStatus = espLinkStatus,
ownCamPosition = ownCamPosition,
remoteCamPositions = remoteCamPositions,
onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() },
onStartCamPinger = { viewModel.startCamPinger() },
onStopCamPinger = { viewModel.stopCamPinger() },
)
} else {
MessageDetailPane(
@@ -224,11 +239,19 @@ private fun TopicListPane(
activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>,
showDenmTrigger: Boolean = true,
showCamPinger: Boolean = false,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
camPingerActive: Boolean = false,
camPingerSentCount: Int = 0,
camSendFailures: Int = 0,
espLinkStatus: EspLinkStatus? = null,
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(),
onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit,
onStopDenm: () -> Unit,
onStartCamPinger: () -> Unit = {},
onStopCamPinger: () -> Unit = {},
) {
val isConnected = connectionState == MqttConnectionState.CONNECTED
var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) }
@@ -256,6 +279,21 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
if (showCamPinger) {
CamPingerCard(
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
pingerActive = camPingerActive,
sentCount = camPingerSentCount,
sendFailures = camSendFailures,
linkStatus = espLinkStatus,
onStart = onStartCamPinger,
onStop = onStopCamPinger,
)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
}
// ── List / Map toggle — the raw topic list stays available either way (Section 13
// asks for the map "in addition to", not instead of, the topic list). ──────────────
Row(
@@ -653,6 +691,141 @@ private fun DenmTxCard(
}
}
// ── CAM Pinger card (ESP32-C5 only) ───────────────────────────────────────────
@Composable
private fun CamPingerCard(
usbConnected: Boolean,
pingerActive: Boolean,
sentCount: Int,
sendFailures: Int,
linkStatus: EspLinkStatus?,
onStart: () -> Unit,
onStop: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = if (pingerActive) ConnectedGreenBg
else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = null,
tint = if (pingerActive) ConnectedGreen
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
stringResource(R.string.mqtt_cam_pinger_title),
style = MaterialTheme.typography.labelLarge,
color = if (pingerActive) ConnectedGreen
else MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.weight(1f))
if (pingerActive) {
Text(
stringResource(R.string.mqtt_cam_pinger_sent_count, sentCount),
style = MaterialTheme.typography.labelSmall,
color = ConnectedGreen,
fontFamily = FontFamily.Monospace,
)
}
}
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.mqtt_cam_pinger_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ── Link diagnostics ──────────────────────────────────────────────
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
// accepted them and the radio rejected every one. These two lines are the difference
// between a bench session that tells you something and one that doesn't.
if (sendFailures > 0) {
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.mqtt_cam_pinger_send_failures, sendFailures),
style = MaterialTheme.typography.labelSmall,
color = ErrorRed,
fontFamily = FontFamily.Monospace,
)
}
linkStatus?.let { s ->
Spacer(Modifier.height(6.dp))
Text(
stringResource(
R.string.mqtt_cam_pinger_fw_counters,
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
),
style = MaterialTheme.typography.labelSmall,
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
Spacer(Modifier.height(10.dp))
if (!usbConnected) {
Text(
stringResource(R.string.mqtt_cam_pinger_not_connected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (pingerActive) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(bottom = 8.dp),
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = ConnectedGreen,
modifier = Modifier.size(8.dp),
)
Text(
stringResource(R.string.mqtt_cam_pinger_active),
style = MaterialTheme.typography.bodySmall,
color = ConnectedGreen,
)
}
Button(
onClick = onStop,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF5252)),
) {
Text(stringResource(R.string.mqtt_stop_pinger))
}
} else {
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.mqtt_start_pinger))
}
}
}
}
}
// ── Direction badge ───────────────────────────────────────────────────────────
@Composable
@@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
@@ -165,6 +166,8 @@ fun ConnectionSettingsScreen(
onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {},
espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE,
onEspRxModeChange: (EspRxMode) -> Unit = {},
onBack: () -> Unit,
) {
SubScreen(stringResource(R.string.settings_connection), onBack) {
@@ -209,6 +212,47 @@ fun ConnectionSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
// CAM reception mode — see EspRxMode's KDoc for the caveat that this is purely
// an app-side filter, not a physical radio-receiver toggle (the ESP32 must keep
// its receiver on for TX to keep working at all).
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.settings_esp32_rx_mode),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val isSendOnly = espRxMode == EspRxMode.SEND_ONLY
OutlinedButton(
onClick = { onEspRxModeChange(EspRxMode.SEND_ONLY) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_only), fontWeight = if (isSendOnly) FontWeight.Bold else FontWeight.Normal) }
OutlinedButton(
onClick = { onEspRxModeChange(EspRxMode.SEND_AND_RECEIVE) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (!isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
contentColor = if (!isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
),
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_and_receive), fontWeight = if (!isSendOnly) FontWeight.Bold else FontWeight.Normal) }
}
Text(
stringResource(R.string.settings_esp32_rx_mode_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
@@ -9,12 +9,17 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.service.CamPinger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -33,6 +38,8 @@ class MqttViewModel @Inject constructor(
private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val camPinger: CamPinger,
) : ViewModel() {
// ── MQTT connection & messages ────────────────────────────────────────────
@@ -58,6 +65,19 @@ class MqttViewModel @Inject constructor(
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
}
/**
* ESP32-C5-only: whether received CAM traffic is processed or discarded — see [EspRxMode]'s
* KDoc for the important caveat that this doesn't actually disable the ESP32's receiver
* (it can't, without also breaking TX).
*/
val espRxMode: StateFlow<EspRxMode> = obuHardwarePrefs.espRxModeFlow.stateIn(
viewModelScope, SharingStarted.Eagerly, EspRxMode.SEND_AND_RECEIVE,
)
fun setEspRxMode(mode: EspRxMode) {
viewModelScope.launch { obuHardwarePrefs.setEspRxMode(mode) }
}
/** True when a 192.168.42.x USB-C tethering network is detected. */
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
.map { it != null }
@@ -66,6 +86,27 @@ class MqttViewModel @Inject constructor(
/** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
// location 1 Hz CAM ping the user starts/stops from the V2X Monitor screen to verify the
// serial link + ESP32 TX/RX radio path independent of GNSS movement or trip recording.
// See CamPinger's KDoc.
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
fun startCamPinger() = camPinger.start()
fun stopCamPinger() = camPinger.stop()
// ── Prefs ─────────────────────────────────────────────────────────────────
val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn(
@@ -158,6 +199,11 @@ class MqttViewModel @Inject constructor(
fun connect() = repo.connect()
fun disconnect() = repo.disconnect()
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
fun connectUsbSerial() = usbSerialTransport.connect()
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
@@ -189,5 +235,12 @@ class MqttViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
repo.disconnect()
camPinger.stop()
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
// the app from Recents mid-recording — leaving the still-running service beaconing into
// a dead port. The port closes on explicit user Disconnect, on USB detach (handled
// inside the transport), or with the process. See UsbSerialTransport's "Ownership" KDoc.
}
}