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
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-07-01T14:14:38.599482500Z"> <DropdownSelection timestamp="2026-08-05T15:07:39.343414600Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
@@ -83,6 +83,8 @@ class MainActivity : AppCompatActivity() {
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState() val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState() val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
val obuHardware by mqttViewModel.obuHardware.collectAsState() val obuHardware by mqttViewModel.obuHardware.collectAsState()
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
val espRxMode by mqttViewModel.espRxMode.collectAsState()
MicrOBUTheme(darkTheme = state.darkTheme) { MicrOBUTheme(darkTheme = state.darkTheme) {
val view = LocalView.current val view = LocalView.current
@@ -135,6 +137,7 @@ class MainActivity : AppCompatActivity() {
mqttConnectionState = mqttConnectionState, mqttConnectionState = mqttConnectionState,
activeTransport = activeTransport, activeTransport = activeTransport,
obuHardware = obuHardware, obuHardware = obuHardware,
usbSerialState = usbSerialState,
usbCableConnected = usbConnected, usbCableConnected = usbConnected,
obuStationTypeWarning = obuStationTypeWarning, obuStationTypeWarning = obuStationTypeWarning,
obuStationType = obuStationType, obuStationType = obuStationType,
@@ -250,6 +253,8 @@ class MainActivity : AppCompatActivity() {
onMqttPrefsChange = mqttViewModel::updatePrefs, onMqttPrefsChange = mqttViewModel::updatePrefs,
obuHardware = obuHardware, obuHardware = obuHardware,
onObuHardwareChange = mqttViewModel::setObuHardware, onObuHardwareChange = mqttViewModel::setObuHardware,
espRxMode = espRxMode,
onEspRxModeChange = mqttViewModel::setEspRxMode,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -299,7 +304,13 @@ class MainActivity : AppCompatActivity() {
AboutSettingsScreen(onBack = { navController.popBackStack() }) AboutSettingsScreen(onBack = { navController.popBackStack() })
} }
composable(Screen.Connection.route) { 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) { composable(Screen.Map.route) {
MapScreen(gnss = state.gnss) 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.SensorRepository
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository 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.mqtt.UseCaseAlertPreferences
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.SerialFrameType import com.hawhamburg.micr0bu.data.transport.SerialFrameType
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
@@ -69,12 +71,15 @@ class CamUseCaseRepository @Inject constructor(
private val prefs: UseCaseAlertPreferences, private val prefs: UseCaseAlertPreferences,
private val usbSerialTransport: UsbSerialTransport, private val usbSerialTransport: UsbSerialTransport,
private val camCodec: RealAsn1UperCodec, private val camCodec: RealAsn1UperCodec,
private val obuHardwarePrefs: ObuHardwarePreferences,
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val engine = UseCaseDetectionEngine() private val engine = UseCaseDetectionEngine()
private val sensorRepository = SensorRepository(context) private val sensorRepository = SensorRepository(context)
@Volatile private var espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE
private val _ownStationId = MutableStateFlow<Long?>(null) private val _ownStationId = MutableStateFlow<Long?>(null)
/** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */ /** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */
val ownStationId: StateFlow<Long?> = _ownStationId.asStateFlow() val ownStationId: StateFlow<Long?> = _ownStationId.asStateFlow()
@@ -163,9 +168,14 @@ class CamUseCaseRepository @Inject constructor(
scope.launch { scope.launch {
usbSerialTransport.incomingFrames.collect { frame -> usbSerialTransport.incomingFrames.collect { frame ->
if (frame.type != SerialFrameType.CAM_RX) return@collect if (frame.type != SerialFrameType.CAM_RX) return@collect
if (espRxMode == EspRxMode.SEND_ONLY) return@collect
handleCamFromSerial(frame.payload) handleCamFromSerial(frame.payload)
} }
} }
scope.launch {
obuHardwarePrefs.espRxModeFlow.collect { espRxMode = it }
}
} }
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) { 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.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.hawhamburg.micr0bu.data.transport.EspRxMode
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -24,6 +25,7 @@ class ObuHardwarePreferences @Inject constructor(
) { ) {
private object Keys { private object Keys {
val OBU_HARDWARE = stringPreferencesKey("obu_hardware") val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
} }
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs -> val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
@@ -33,4 +35,17 @@ class ObuHardwarePreferences @Inject constructor(
suspend fun setObuHardware(hardware: ObuHardware) { suspend fun setObuHardware(hardware: ObuHardware) {
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id } 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.data.transport.UsbSerialTransport] and
* [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder]. * [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder].
* *
* **Placeholder hardware option as of this writing** — the actual frame protocol between * Real serial link + CAM UPER codec are implemented on both sides — see
* phone and ESP32-C5 firmware is not yet defined (pending translation of the existing * [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] (phone) and
* ESP32 C firmware's logic to the Kotlin side). UI/settings exist so the option is visible * `obu-firmware/main/serial_link.c` (firmware). See also [EspRxMode] for the send-only vs
* and selectable, but connecting will not yet do anything real. * send-and-receive toggle (Settings > Connection).
*/ */
ESP32_C5("esp32_c5"), 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`. */ * 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
const val CAM_RX: Int = 0x02 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 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 SYNC0: Byte = 0xAA.toByte()
private const val SYNC1: Byte = 0x55.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.UsbDevice
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Build 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.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.driver.UsbSerialProber
import com.hoho.android.usbserial.util.SerialInputOutputManager import com.hoho.android.usbserial.util.SerialInputOutputManager
import dagger.hilt.android.qualifiers.ApplicationContext 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,20 +39,39 @@ import javax.inject.Singleton
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR } 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 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 * 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 * 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 * CDC-ACM serial device (over its native USB Serial/JTAG peripheral, not a UART bridge), framed
* bit-for-bit compatible with the firmware's `obu-firmware/main/serial_link.c`). * 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 * Built on `com.github.mik3y:usb-serial-for-android`. The ESP32-C5 exposes the serial link over
* ESP32-C5's native USB is expected to enumerate as) plus common USB-UART bridge chips as a * its native USB Serial/JTAG peripheral (see firmware `serial_link.c`), which enumerates as a
* fallback, so this doesn't need to hardcode a specific driver class. * 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 * Plug the phone into the board's NATIVE USB-C port (not the separate UART-bridge/flashing
* changes on the firmware side, [BAUD_RATE] here must change with it. * 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 @Singleton
class UsbSerialTransport @Inject constructor( class UsbSerialTransport @Inject constructor(
@@ -47,10 +79,38 @@ class UsbSerialTransport @Inject constructor(
) { ) {
companion object { companion object {
private const val BAUD_RATE = 115_200 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 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) private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
val state: StateFlow<UsbSerialState> = _state.asStateFlow() val state: StateFlow<UsbSerialState> = _state.asStateFlow()
@@ -58,54 +118,106 @@ class UsbSerialTransport @Inject constructor(
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */ /** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow() 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() private val decoder = SerialFrameDecoder()
@Volatile private var port: UsbSerialPort? = null @Volatile private var port: UsbSerialPort? = null
@Volatile private var ioManager: SerialInputOutputManager? = 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) { override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action != ACTION_USB_PERMISSION) return when (intent.action) {
synchronized(this) { ACTION_USB_PERMISSION -> {
val device: UsbDevice? = intent.getUsbDeviceExtra() val device: UsbDevice? = intent.getUsbDeviceExtra()
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted && device != null) { if (granted && device != null) {
Log.i(TAG, "usbReceiver: permission granted, opening device")
openDevice(device) openDevice(device)
} else { } else {
Log.w(TAG, "usbReceiver: permission denied or device null " +
"(granted=$granted, device=$device)")
_state.value = UsbSerialState.ERROR _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()
}
}
}
} }
} }
private var receiverRegistered = false private var receiverRegistered = false
/** /**
* Finds the first attached USB-serial-capable device, requests permission if needed, and * Finds the attached ESP32-C5, requests permission if needed, and opens it. Safe to call
* opens it. Safe to call repeatedly (e.g. from a "retry" UI action) — no-ops if already * repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
* connected.
*/ */
fun connect() { fun connect() {
if (_state.value == UsbSerialState.CONNECTED) return if (_state.value == UsbSerialState.CONNECTED) {
Log.i(TAG, "connect(): already connected, no-op")
ensureReceiverRegistered()
val availableDrivers: List<UsbSerialDriver> =
UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
val driver = availableDrivers.firstOrNull()
if (driver == null) {
_state.value = UsbSerialState.DISCONNECTED
return 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 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 _state.value = UsbSerialState.DEVICE_ATTACHED
if (usbManager.hasPermission(device)) { if (usbManager.hasPermission(device)) {
Log.i(TAG, "connect(): permission already granted, opening directly")
openDevice(device) openDevice(device)
} else { } else {
pendingDevice = device Log.i(TAG, "connect(): requesting USB permission from user")
_state.value = UsbSerialState.PERMISSION_REQUESTED _state.value = UsbSerialState.PERMISSION_REQUESTED
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val permissionIntent = PendingIntent.getBroadcast( val permissionIntent = PendingIntent.getBroadcast(
@@ -115,14 +227,55 @@ 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) { private fun openDevice(device: UsbDevice) {
val driver = UsbSerialProber.getDefaultProber().probeDevice(device) synchronized(lock) {
val driver = customProber.probeDevice(device)
if (driver == null || driver.ports.isEmpty()) { 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 _state.value = UsbSerialState.ERROR
return return
} }
val connection = usbManager.openDevice(device) val connection = usbManager.openDevice(device)
if (connection == null) { 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 _state.value = UsbSerialState.ERROR
return return
} }
@@ -132,19 +285,47 @@ class UsbSerialTransport @Inject constructor(
newPort.open(connection) newPort.open(connection)
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
runCatching { newPort.close() } runCatching { newPort.close() }
_state.value = UsbSerialState.ERROR _state.value = UsbSerialState.ERROR
return 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 port = newPort
openDeviceName = device.deviceName
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener { val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) { override fun onNewData(data: ByteArray) {
lastFrameAtMs = SystemClock.elapsedRealtime()
val frames = decoder.onBytes(data) val frames = decoder.onBytes(data)
frames.forEach { _incomingFrames.tryEmit(it) } frames.forEach { frame ->
if (frame.type == SerialFrameType.STATUS) {
EspLinkStatus.parse(frame.payload)?.let { _linkStatus.value = it }
}
_incomingFrames.tryEmit(frame)
}
} }
override fun onRunError(e: Exception) { override fun onRunError(e: Exception) {
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
_state.value = UsbSerialState.ERROR _state.value = UsbSerialState.ERROR
} }
}) })
@@ -155,43 +336,93 @@ class UsbSerialTransport @Inject constructor(
// longer exposes for external use - see the two earlier compile errors this replaced). // longer exposes for external use - see the two earlier compile errors this replaced).
manager.start() manager.start()
_consecutiveWriteFailures.value = 0
_linkStatus.value = null
lastFrameAtMs = SystemClock.elapsedRealtime()
_state.value = UsbSerialState.CONNECTED _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. * 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 * 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 * 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 { fun sendCamTx(camUperBytes: ByteArray): Boolean {
val p = port ?: return false val p = port
if (p == null) {
_consecutiveWriteFailures.update { it + 1 }
return false
}
return try { return try {
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes) val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
p.write(frame, /* timeout ms */ 200) p.write(frame, /* timeout ms */ 200)
_consecutiveWriteFailures.value = 0
true true
} catch (e: Exception) { } catch (e: Exception) {
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
_state.value = UsbSerialState.ERROR _state.value = UsbSerialState.ERROR
false false
} }
} }
fun disconnect() { fun disconnect() {
synchronized(lock) {
watchdogJob?.cancel()
watchdogJob = null
ioManager?.stop() // stops the manager's own internal background thread ioManager?.stop() // stops the manager's own internal background thread
ioManager = null ioManager = null
runCatching { port?.close() } runCatching { port?.close() }
port = null port = null
openDeviceName = null
_linkStatus.value = null
_consecutiveWriteFailures.value = 0
_state.value = UsbSerialState.DISCONNECTED _state.value = UsbSerialState.DISCONNECTED
} }
}
private fun ensureReceiverRegistered() { private fun ensureReceiverRegistered() {
if (receiverRegistered) return 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) { 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 { } else {
@Suppress("UnspecifiedRegisterReceiverFlag") @Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(permissionReceiver, filter) context.registerReceiver(usbReceiver, filter)
} }
receiverRegistered = true receiverRegistered = true
} }
@@ -44,6 +44,7 @@ import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
private val UsbGreen = Color(0xFF4CAF50) private val UsbGreen = Color(0xFF4CAF50)
@@ -64,6 +65,7 @@ fun ConnectionSetupScreen(
val activeTransport by viewModel.activeTransport.collectAsState() val activeTransport by viewModel.activeTransport.collectAsState()
val mqttPrefs by viewModel.mqttPrefs.collectAsState() val mqttPrefs by viewModel.mqttPrefs.collectAsState()
val obuHardware by viewModel.obuHardware.collectAsState() val obuHardware by viewModel.obuHardware.collectAsState()
val usbSerialState by viewModel.usbSerialState.collectAsState()
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
val isConnecting = connectionState == MqttConnectionState.CONNECTING val isConnecting = connectionState == MqttConnectionState.CONNECTING
@@ -226,11 +228,27 @@ fun ConnectionSetupScreen(
} }
} }
} else { } else {
// ── ESP32-C5 placeholder card ─────────────────────────────────────── // ── ESP32-C5 real connection card (Phase 03) ────────────────────────
// Real USB-serial connection handling lives in UsbSerialTransport, which is a // Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
// stub until the ESP32 firmware protocol is translated to Kotlin (Section 13). // 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( Card(
colors = CardDefaults.cardColors(containerColor = UsbGrayBg), colors = CardDefaults.cardColors(containerColor = espContainerColor),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
@@ -241,22 +259,57 @@ fun ConnectionSetupScreen(
Icon( Icon(
Icons.Default.Usb, Icons.Default.Usb,
contentDescription = null, contentDescription = null,
tint = UsbGray, tint = espColor,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
Text( Text(
stringResource(R.string.conn_esp32_title), stringResource(R.string.conn_esp32_title),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = UsbGray, color = espColor,
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.conn_esp32_phase3_desc), Row(
style = MaterialTheme.typography.bodyMedium, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.transport.ObuHardware import com.hawhamburg.micr0bu.data.transport.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.viewmodel.SensorUiState import com.hawhamburg.micr0bu.viewmodel.SensorUiState
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -62,6 +63,7 @@ fun DashboardScreen(
mqttConnectionState: MqttConnectionState, mqttConnectionState: MqttConnectionState,
activeTransport: TransportType = TransportType.USB_C, activeTransport: TransportType = TransportType.USB_C,
obuHardware: ObuHardware = ObuHardware.CIT_ONE, obuHardware: ObuHardware = ObuHardware.CIT_ONE,
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
usbCableConnected: Boolean = false, usbCableConnected: Boolean = false,
obuStationTypeWarning: Boolean = false, obuStationTypeWarning: Boolean = false,
obuStationType: Int? = null, obuStationType: Int? = null,
@@ -254,7 +256,13 @@ fun DashboardScreen(
onClick = onNavigateToSensors, 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 mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
val transportIcon = when (activeTransport) { val transportIcon = when (activeTransport) {
TransportType.USB_C -> Icons.Default.Usb TransportType.USB_C -> Icons.Default.Usb
TransportType.USB_SERIAL -> Icons.Default.Usb TransportType.USB_SERIAL -> Icons.Default.Usb
@@ -280,10 +288,10 @@ fun DashboardScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
val tint = if (mqttConnected) Color(0xFF4CAF50) val tint = if (obuConnected) Color(0xFF4CAF50)
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
Icon( Icon(
if (mqttConnected) transportIcon else transportInactiveIcon, if (obuConnected) transportIcon else transportInactiveIcon,
null, null,
tint = tint, tint = tint,
modifier = Modifier.size(28.dp), modifier = Modifier.size(28.dp),
@@ -296,7 +304,15 @@ fun DashboardScreen(
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( 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.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting) MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting)
MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error) 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.MessageDirection
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage 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.ObuHardware
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
import com.hawhamburg.micr0bu.domain.cam.CamParser import com.hawhamburg.micr0bu.domain.cam.CamParser
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
@@ -118,6 +120,11 @@ fun MqttTopicViewerScreen(
val obuHardware by viewModel.obuHardware.collectAsState() val obuHardware by viewModel.obuHardware.collectAsState()
val ownCamPosition by viewModel.ownCamPosition.collectAsState() val ownCamPosition by viewModel.ownCamPosition.collectAsState()
val remoteCamPositions by viewModel.remoteCamPositions.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 // Sort: sys/ topics first (heartbeat/health), then alphabetical
val sortedTopics = topicMessages.keys.sortedWith( val sortedTopics = topicMessages.keys.sortedWith(
@@ -196,11 +203,19 @@ fun MqttTopicViewerScreen(
activeDenmUseCase = activeDenmUseCase, activeDenmUseCase = activeDenmUseCase,
useCaseAlerts = useCaseAlerts, useCaseAlerts = useCaseAlerts,
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE, showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
usbSerialState = usbSerialState,
camPingerActive = camPingerActive,
camPingerSentCount = camPingerSentCount,
camSendFailures = camSendFailures,
espLinkStatus = espLinkStatus,
ownCamPosition = ownCamPosition, ownCamPosition = ownCamPosition,
remoteCamPositions = remoteCamPositions, remoteCamPositions = remoteCamPositions,
onSelectTopic = { viewModel.selectTopic(it) }, onSelectTopic = { viewModel.selectTopic(it) },
onSendDenm = { viewModel.sendDenm() }, onSendDenm = { viewModel.sendDenm() },
onStopDenm = { viewModel.stopDenm() }, onStopDenm = { viewModel.stopDenm() },
onStartCamPinger = { viewModel.startCamPinger() },
onStopCamPinger = { viewModel.stopCamPinger() },
) )
} else { } else {
MessageDetailPane( MessageDetailPane(
@@ -224,11 +239,19 @@ private fun TopicListPane(
activeDenmUseCase: String?, activeDenmUseCase: String?,
useCaseAlerts: List<UseCaseAlert>, useCaseAlerts: List<UseCaseAlert>,
showDenmTrigger: Boolean = true, 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, ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(), remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(),
onSelectTopic: (String) -> Unit, onSelectTopic: (String) -> Unit,
onSendDenm: () -> Unit, onSendDenm: () -> Unit,
onStopDenm: () -> Unit, onStopDenm: () -> Unit,
onStartCamPinger: () -> Unit = {},
onStopCamPinger: () -> Unit = {},
) { ) {
val isConnected = connectionState == MqttConnectionState.CONNECTED val isConnected = connectionState == MqttConnectionState.CONNECTED
var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) } var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) }
@@ -256,6 +279,21 @@ private fun TopicListPane(
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) 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 // ── 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). ────────────── // asks for the map "in addition to", not instead of, the topic list). ──────────────
Row( 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 ─────────────────────────────────────────────────────────── // ── Direction badge ───────────────────────────────────────────────────────────
@Composable @Composable
@@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.R
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs 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.data.transport.ObuHardware
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
@@ -165,6 +166,8 @@ fun ConnectionSettingsScreen(
onMqttPrefsChange: (MqttPrefs) -> Unit, onMqttPrefsChange: (MqttPrefs) -> Unit,
obuHardware: ObuHardware = ObuHardware.CIT_ONE, obuHardware: ObuHardware = ObuHardware.CIT_ONE,
onObuHardwareChange: (ObuHardware) -> Unit = {}, onObuHardwareChange: (ObuHardware) -> Unit = {},
espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE,
onEspRxModeChange: (EspRxMode) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
SubScreen(stringResource(R.string.settings_connection), onBack) { SubScreen(stringResource(R.string.settings_connection), onBack) {
@@ -209,6 +212,47 @@ fun ConnectionSettingsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp), 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.MqttPrefs
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences 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.ObuHardware
import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.TransportType
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector 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.denm.DenmUseCase
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
import com.hawhamburg.micr0bu.service.CamPinger
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -33,6 +38,8 @@ class MqttViewModel @Inject constructor(
private val usbDetector: UsbNetworkDetector, private val usbDetector: UsbNetworkDetector,
private val camUseCaseRepository: CamUseCaseRepository, private val camUseCaseRepository: CamUseCaseRepository,
private val obuHardwarePrefs: ObuHardwarePreferences, private val obuHardwarePrefs: ObuHardwarePreferences,
private val usbSerialTransport: UsbSerialTransport,
private val camPinger: CamPinger,
) : ViewModel() { ) : ViewModel() {
// ── MQTT connection & messages ──────────────────────────────────────────── // ── MQTT connection & messages ────────────────────────────────────────────
@@ -58,6 +65,19 @@ class MqttViewModel @Inject constructor(
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) } 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. */ /** True when a 192.168.42.x USB-C tethering network is detected. */
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
.map { it != null } .map { it != null }
@@ -66,6 +86,27 @@ class MqttViewModel @Inject constructor(
/** Auto-detected OBU gateway IP on the USB interface. */ /** Auto-detected OBU gateway IP on the USB interface. */
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp 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 ───────────────────────────────────────────────────────────────── // ── Prefs ─────────────────────────────────────────────────────────────────
val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn( val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn(
@@ -158,6 +199,11 @@ class MqttViewModel @Inject constructor(
fun connect() = repo.connect() fun connect() = repo.connect()
fun disconnect() = repo.disconnect() 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 selectTopic(topic: String?) { _selectedTopic.value = topic }
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled } fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
@@ -189,5 +235,12 @@ class MqttViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
repo.disconnect() 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.
} }
} }
+22 -1
View File
@@ -64,6 +64,12 @@
<string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth-Verbindung ist für Phase 03 geplant und noch nicht implementiert.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string> <string name="conn_esp32_title">ESP32-C5 (USB Seriell)</string>
<string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig — dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string> <string name="conn_esp32_phase3_desc">Die USB-Seriell-Verbindung zum ESP32-C5 ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig — dafür muss zuerst das ESP32-Firmware-Protokoll nach Kotlin übersetzt werden.</string>
<string name="conn_esp32_state_disconnected">Nicht verbunden — ESP32-C5 an den nativen USB-C-Port anschließen und Verbinden antippen.</string>
<string name="conn_esp32_state_device_attached">Gerät erkannt, wird geöffnet…</string>
<string name="conn_esp32_state_permission_requested">Warte auf USB-Berechtigung…</string>
<string name="conn_esp32_state_connected">Verbunden</string>
<string name="conn_esp32_state_error">Verbindungsfehler — Kabel und nativen USB-C-Port prüfen und erneut versuchen.</string>
<string name="conn_esp32_connect">Mit ESP32-C5 verbinden</string>
<string name="conn_scan">Geräte suchen</string> <string name="conn_scan">Geräte suchen</string>
<string name="conn_scanning">Suche läuft…</string> <string name="conn_scanning">Suche läuft…</string>
<string name="conn_usb_title">USB-C (Primär)</string> <string name="conn_usb_title">USB-C (Primär)</string>
@@ -163,7 +169,11 @@
<string name="settings_obu_hardware">OBU-Hardware</string> <string name="settings_obu_hardware">OBU-Hardware</string>
<string name="settings_obu_hardware_cit_one">CiT One</string> <string name="settings_obu_hardware_cit_one">CiT One</string>
<string name="settings_obu_hardware_esp32">ESP32-C5</string> <string name="settings_obu_hardware_esp32">ESP32-C5</string>
<string name="settings_obu_hardware_esp32_note">Die ESP32-C5-Unterstützung ist für Phase 03 vorgesehen, aber noch nicht funktionsfähig. Die CAM-Erzeugung wechselt zum Smartphone; USB-Seriell-Verbindung und DENM-Auslöser sind Platzhalter, bis das Firmware-Protokoll übersetzt ist.</string> <string name="settings_obu_hardware_esp32_note">Der ESP32-C5 arbeitet als „dummer" Transceiver: CAM wird auf dem Smartphone erstellt und kodiert, über USB-Seriell an den ESP32 gesendet und über ITS-G5 gesendet. Auf diesem Pfad gibt es keinen MQTT-Broker und keine DENM-Use-Case-Engine — siehe den CAM-Pinger im V2X-Monitor für ein manuelles Testwerkzeug.</string>
<string name="settings_esp32_rx_mode">CAM-Empfang</string>
<string name="settings_esp32_rx_mode_send_only">Nur senden</string>
<string name="settings_esp32_rx_mode_send_and_receive">Senden &amp; Empfangen</string>
<string name="settings_esp32_rx_mode_desc">„Nur senden" ignoriert von nahen Stationen empfangene CAM (der ESP32 empfängt sie physisch weiterhin — der Empfänger kann nicht abgeschaltet werden, ohne auch das Senden zu unterbrechen — sie werden nur nicht von der App verarbeitet). Nützlich, um den Sendepfad isoliert zu testen.</string>
<string name="settings_usb_transport">Aktiver Transport</string> <string name="settings_usb_transport">Aktiver Transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">WLAN</string> <string name="settings_transport_wifi">WLAN</string>
@@ -192,6 +202,17 @@
<string name="mqtt_denm_show">Letztes TX anzeigen</string> <string name="mqtt_denm_show">Letztes TX anzeigen</string>
<string name="mqtt_denm_hide">Ausblenden</string> <string name="mqtt_denm_hide">Ausblenden</string>
<!-- CAM-Pinger — nur ESP32-C5, manueller Bank-Test, Gegenstück zur DENM-TX-Karte oben -->
<string name="mqtt_cam_pinger_title">CAM-Pinger (manueller Test)</string>
<string name="mqtt_cam_pinger_desc">Fester Standort, 1-Hz-CAM-Ping — prüft die serielle Verbindung und den ESP32-Funkpfad ohne GNSS-Bewegung oder Fahrtaufzeichnung.</string>
<string name="mqtt_cam_pinger_not_connected">ESP32-C5 verbinden, um den CAM-Pinger zu aktivieren</string>
<string name="mqtt_cam_pinger_active">Sendet — 1 CAM/s über die serielle Verbindung</string>
<string name="mqtt_cam_pinger_sent_count">Gesendet: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Schreibfehler: %1$d in Folge — CAMs erreichen den ESP32 nicht</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: TX-Fehler %1$d · zu groß %2$d · CRC-Fehler %3$d</string>
<string name="mqtt_start_pinger">Pinger starten</string>
<string name="mqtt_stop_pinger">Pinger stoppen</string>
<!-- CAM-basiertes Use-Case-Alarm-Panel --> <!-- CAM-basiertes Use-Case-Alarm-Panel -->
<string name="mqtt_usecase_panel_title">CAM-Use-Case-Alarme</string> <string name="mqtt_usecase_panel_title">CAM-Use-Case-Alarme</string>
<string name="mqtt_usecase_not_connected">Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten</string> <string name="mqtt_usecase_not_connected">Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten</string>
+22 -1
View File
@@ -65,6 +65,12 @@
<string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string> <string name="conn_bluetooth_phase3_desc">Bluetooth connection is planned for Phase 03 and is not yet implemented.</string>
<string name="conn_esp32_title">ESP32-C5 (USB Serial)</string> <string name="conn_esp32_title">ESP32-C5 (USB Serial)</string>
<string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional — it needs the ESP32 firmware protocol translated to Kotlin first.</string> <string name="conn_esp32_phase3_desc">USB-serial connection to the ESP32-C5 is scaffolded for Phase 03 but not yet functional — it needs the ESP32 firmware protocol translated to Kotlin first.</string>
<string name="conn_esp32_state_disconnected">Not connected — plug the ESP32-C5 into the native USB-C port and tap Connect.</string>
<string name="conn_esp32_state_device_attached">Device detected, opening…</string>
<string name="conn_esp32_state_permission_requested">Waiting for USB permission…</string>
<string name="conn_esp32_state_connected">Connected</string>
<string name="conn_esp32_state_error">Connection error — check the cable and native USB-C port, then try again.</string>
<string name="conn_esp32_connect">Connect to ESP32-C5</string>
<string name="conn_scan">Scan for Devices</string> <string name="conn_scan">Scan for Devices</string>
<string name="conn_scanning">Scanning…</string> <string name="conn_scanning">Scanning…</string>
<string name="conn_usb_title">USB-C (Primary)</string> <string name="conn_usb_title">USB-C (Primary)</string>
@@ -164,7 +170,11 @@
<string name="settings_obu_hardware">OBU Hardware</string> <string name="settings_obu_hardware">OBU Hardware</string>
<string name="settings_obu_hardware_cit_one">CiT One</string> <string name="settings_obu_hardware_cit_one">CiT One</string>
<string name="settings_obu_hardware_esp32">ESP32-C5</string> <string name="settings_obu_hardware_esp32">ESP32-C5</string>
<string name="settings_obu_hardware_esp32_note">ESP32-C5 support is scaffolded for Phase 03 but not yet functional. CAM generation moves to the phone; USB-serial connection and DENM trigger are placeholders pending firmware protocol translation.</string> <string name="settings_obu_hardware_esp32_note">ESP32-C5 acts as a "dumb" transceiver: CAM is built and encoded on the phone, sent to the ESP32 over USB serial, and broadcast over ITS-G5. No MQTT broker or DENM use-case engine on this path — see the V2X Monitor screen\'s CAM Pinger for a manual test tool.</string>
<string name="settings_esp32_rx_mode">CAM Reception</string>
<string name="settings_esp32_rx_mode_send_only">Send Only</string>
<string name="settings_esp32_rx_mode_send_and_receive">Send &amp; Receive</string>
<string name="settings_esp32_rx_mode_desc">"Send Only" ignores CAM received from nearby stations (still physically received by the ESP32 — its receiver can\'t be turned off without also breaking transmit — just not processed by the app). Useful for isolating TX-path testing.</string>
<string name="settings_usb_transport">Active transport</string> <string name="settings_usb_transport">Active transport</string>
<string name="settings_transport_usbc">USB-C</string> <string name="settings_transport_usbc">USB-C</string>
<string name="settings_transport_wifi">Wi-Fi</string> <string name="settings_transport_wifi">Wi-Fi</string>
@@ -193,6 +203,17 @@
<string name="mqtt_denm_show">Show last TX</string> <string name="mqtt_denm_show">Show last TX</string>
<string name="mqtt_denm_hide">Hide</string> <string name="mqtt_denm_hide">Hide</string>
<!-- CAM Pinger — ESP32-C5-only manual bench test, equivalent to the DENM TX card above -->
<string name="mqtt_cam_pinger_title">CAM Pinger (Manual Test)</string>
<string name="mqtt_cam_pinger_desc">Fixed-location 1 Hz CAM ping — verifies the serial link and ESP32 radio path without needing GNSS movement or a trip recording.</string>
<string name="mqtt_cam_pinger_not_connected">Connect the ESP32-C5 to enable the CAM pinger</string>
<string name="mqtt_cam_pinger_active">Pinging — 1 CAM/s over the serial link</string>
<string name="mqtt_cam_pinger_sent_count">Sent: %1$d</string>
<string name="mqtt_cam_pinger_send_failures">Write failures: %1$d consecutive — CAMs are not reaching the ESP32</string>
<string name="mqtt_cam_pinger_fw_counters">ESP32: tx fail %1$d · oversize %2$d · crc err %3$d</string>
<string name="mqtt_start_pinger">Start Pinger</string>
<string name="mqtt_stop_pinger">Stop Pinger</string>
<!-- CAM-based Use Case Alert panel --> <!-- CAM-based Use Case Alert panel -->
<string name="mqtt_usecase_panel_title">CAM Use Case Alerts</string> <string name="mqtt_usecase_panel_title">CAM Use Case Alerts</string>
<string name="mqtt_usecase_not_connected">Connect to the OBU to evaluate CAM-based use cases</string> <string name="mqtt_usecase_not_connected">Connect to the OBU to evaluate CAM-based use cases</string>
Submodule its-g5-receiver-firmware updated: 91d6511fe6...66123b1d67
+4 -2
View File
@@ -5,12 +5,14 @@
# phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are # phone and sent down over serial_link, so this firmware never encodes CAM itself. cam.c/.h are
# left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete. # left on disk as the byte-exact reference the Kotlin encoder was ported from - do not delete.
# #
# serial_link.c/.h - binary phone<->ESP32 UART framing (Phase 03). # serial_link.c/.h - binary phone<->ESP32 framing over the native USB Serial/JTAG port
# (Phase 03; changed from a GPIO UART1 wire to native USB because neither
# USB-C port on the ESP32-C5-WIFI6-KIT was actually routed to that UART).
# gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM # gn_unwrap.c/.h - strips 802.11/LLC-SNAP/GeoNetworking/BTP-B off received frames down to CAM
# UPER bytes, for forwarding to the phone over serial_link. # UPER bytes, for forwarding to the phone over serial_link.
idf_component_register( idf_component_register(
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c" SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES esp_event esp_netif nvs_flash driver esp_phy REQUIRES esp_event esp_netif nvs_flash driver esp_phy esp_driver_gpio esp_driver_usb_serial_jtag
PRIV_REQUIRES esp_wifi PRIV_REQUIRES esp_wifi
) )
+9 -2
View File
@@ -111,7 +111,11 @@ static void tx_radio_task(void *arg)
continue; continue;
} }
uint8_t gn_payload[160]; // static, not stack: these are sized off SERIAL_LINK_MAX_PAYLOAD (raised to 512), so on
// the stack they'd be ~1.2 KB of this task's 4 KB. Safe as statics - tx_radio_task is a
// singleton, created once in app_main. Both wrap functions bounds-check against the size
// passed in and return <= 0 on overflow, so an oversized CAM is rejected, not written past.
static uint8_t gn_payload[SERIAL_LINK_MAX_PAYLOAD + 64];
int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE, int gn_len = geonet_wrap_shb(item.data, item.len, pseudonym_mac, STATION_TYPE,
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG, BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
BTP_PORT_CAM, gn_payload, sizeof(gn_payload)); BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
@@ -120,7 +124,7 @@ static void tx_radio_task(void *arg)
continue; continue;
} }
uint8_t frame[300]; static uint8_t frame[SERIAL_LINK_MAX_PAYLOAD + 192];
int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame, int frame_len = dot11p_build_frame(gn_payload, gn_len, pseudonym_mac, frame,
sizeof(frame), false); sizeof(frame), false);
if (frame_len <= 0) { if (frame_len <= 0) {
@@ -132,6 +136,9 @@ static void tx_radio_task(void *arg)
// during Phase 2 bring-up (see git history for the tx_custom.c A/B test that led here). // during Phase 2 bring-up (see git history for the tx_custom.c A/B test that led here).
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, frame_len, true); esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, frame_len, true);
if (err != ESP_OK) { if (err != ESP_OK) {
// Also counted into the heartbeat so the phone can see it - from the app's side a CAM
// that reached the radio but didn't go out otherwise looks identical to one that did.
serial_link_note_tx_failure();
ESP_LOGW(TAG, "esp_wifi_80211_tx failed: %d", err); ESP_LOGW(TAG, "esp_wifi_80211_tx failed: %d", err);
} else { } else {
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz", frame_len, TX_FREQ_MHZ); ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz", frame_len, TX_FREQ_MHZ);
+111 -41
View File
@@ -1,8 +1,10 @@
#include "serial_link.h" #include "serial_link.h"
#include <string.h> #include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "driver/uart.h" #include "freertos/semphr.h"
#include "driver/usb_serial_jtag.h"
#include "esp_log.h" #include "esp_log.h"
static const char *TAG = "serial_link"; static const char *TAG = "serial_link";
@@ -12,6 +14,29 @@ static const char *TAG = "serial_link";
static serial_link_cam_tx_cb_t s_on_cam_tx; static serial_link_cam_tx_cb_t s_on_cam_tx;
// ---- Counters reported to the phone in every heartbeat (see SERIAL_MSG_STATUS in the header).
// Saturating rather than wrapping: "65535 drops" reads as "lots and still going", whereas a wrap
// back to a small number during a long bench run reads as "the problem went away".
static uint16_t s_oversize_drops;
static uint16_t s_tx_failures;
static uint16_t s_rx_crc_errors;
// Serializes send_frame(): it writes a frame as four separate usb_serial_jtag_write_bytes() calls
// and shares one static CRC scratch buffer, and it's now called from three tasks (rx_forward for
// CAM_RX, the heartbeat task for STATUS, and potentially others). Without this, two frames could
// interleave on the wire and both would be discarded by the phone's framer.
static SemaphoreHandle_t s_tx_mutex;
static inline void bump(uint16_t *counter)
{
if (*counter < 0xFFFF) (*counter)++;
}
void serial_link_note_tx_failure(void)
{
bump(&s_tx_failures);
}
// ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ---- // ---- CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xorout) ----
// Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table // Bytewise (no table) - frames here are at most SERIAL_LINK_MAX_PAYLOAD + 3 bytes, so table
// lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly // lookup isn't worth the flash/RAM tradeoff. MUST match the Kotlin-side implementation exactly
@@ -41,28 +66,37 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
head[1] = (uint8_t)(len & 0xFF); head[1] = (uint8_t)(len & 0xFF);
head[2] = (uint8_t)((len >> 8) & 0xFF); head[2] = (uint8_t)((len >> 8) & 0xFF);
uint16_t crc; // Concatenate head+payload to CRC them in one pass. static, not stack: at
{ // SERIAL_LINK_MAX_PAYLOAD = 512 this buffer is 515 bytes, which is a meaningful bite out of a
// Compute CRC over head+payload without a combined buffer copy: CRC is a running // 4 KB task stack, and send_frame() is called from several tasks. Guarded by s_tx_mutex below
// state, so feed it in two calls worth of bytes by concatenating into a small stack // so the shared buffer (and the four-part write) can't interleave between callers.
// buffer (payload is capped at SERIAL_LINK_MAX_PAYLOAD, so head+payload comfortably static uint8_t s_crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
// fits on the stack).
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD]; if (s_tx_mutex && xSemaphoreTake(s_tx_mutex, pdMS_TO_TICKS(200)) != pdTRUE) {
memcpy(crc_buf, head, 3); ESP_LOGW(TAG, "send_frame: tx mutex timeout, dropping frame");
if (len > 0) memcpy(crc_buf + 3, payload, (size_t)len); return false;
crc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
} }
memcpy(s_crc_buf, head, 3);
if (len > 0) memcpy(s_crc_buf + 3, payload, (size_t)len);
uint16_t crc = crc16_ccitt_false(s_crc_buf, (size_t)(3 + len));
uint8_t sync[2] = {SYNC0, SYNC1}; uint8_t sync[2] = {SYNC0, SYNC1};
uint8_t crc_bytes[2] = {(uint8_t)(crc & 0xFF), (uint8_t)((crc >> 8) & 0xFF)}; uint8_t crc_bytes[2] = {(uint8_t)(crc & 0xFF), (uint8_t)((crc >> 8) & 0xFF)};
// Four separate writes rather than one assembled buffer - simplest given payload is // Four separate writes rather than one assembled buffer - simplest given payload is
// already wherever the caller has it (avoids a second copy of up to 160 bytes). // already wherever the caller has it (avoids a second copy of up to 160 bytes).
// usb_serial_jtag_write_bytes() blocks up to the given tick timeout if the host isn't
// reading fast enough; 100ms is generous for a ~160-byte frame at USB full-speed and keeps
// a wedged/disconnected host from hanging the radio TX/RX tasks indefinitely.
const TickType_t write_timeout = pdMS_TO_TICKS(100);
int wrote = 0; int wrote = 0;
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, sync, sizeof(sync)); wrote += usb_serial_jtag_write_bytes(sync, sizeof(sync), write_timeout);
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, head, sizeof(head)); wrote += usb_serial_jtag_write_bytes(head, sizeof(head), write_timeout);
if (len > 0) wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, payload, (size_t)len); if (len > 0) wrote += usb_serial_jtag_write_bytes(payload, (size_t)len, write_timeout);
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, crc_bytes, sizeof(crc_bytes)); wrote += usb_serial_jtag_write_bytes(crc_bytes, sizeof(crc_bytes), write_timeout);
if (s_tx_mutex) xSemaphoreGive(s_tx_mutex);
return wrote == (int)(sizeof(sync) + sizeof(head) + len + sizeof(crc_bytes)); return wrote == (int)(sizeof(sync) + sizeof(head) + len + sizeof(crc_bytes));
} }
@@ -70,24 +104,53 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len) bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len)
{ {
if (cam_len < 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD - 1) { if (cam_len < 0 || cam_len > SERIAL_LINK_MAX_PAYLOAD - 1) {
ESP_LOGW(TAG, "send_cam_rx: cam_len too large (%d)", cam_len); // Counted, not just logged: this log line goes to the flashing port, which nobody is
// watching during a phone bench session - so the symptom would be "that station just
// never shows up in the app" with no visible cause.
bump(&s_oversize_drops);
ESP_LOGW(TAG, "send_cam_rx: cam_len too large (%d), total oversize drops %u",
cam_len, s_oversize_drops);
return false; return false;
} }
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD]; // static, not stack (515 bytes at MAX_PAYLOAD 512); only rx_forward_task calls this, and
payload[0] = (uint8_t)rssi; // send_frame's mutex covers the handoff onto the wire.
memcpy(payload + 1, cam_uper, (size_t)cam_len); static uint8_t s_cam_rx_payload[SERIAL_LINK_MAX_PAYLOAD];
return send_frame(SERIAL_MSG_CAM_RX, payload, 1 + cam_len); s_cam_rx_payload[0] = (uint8_t)rssi;
memcpy(s_cam_rx_payload + 1, cam_uper, (size_t)cam_len);
return send_frame(SERIAL_MSG_CAM_RX, s_cam_rx_payload, 1 + cam_len);
} }
bool serial_link_send_status(uint8_t status) bool serial_link_send_status(uint8_t status)
{ {
return send_frame(SERIAL_MSG_STATUS, &status, 1); // [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE] - keep in lockstep
// with EspLinkStatus.parse() in the app's SerialFrame.kt.
uint8_t payload[7];
payload[0] = status;
payload[1] = (uint8_t)(s_oversize_drops & 0xFF);
payload[2] = (uint8_t)((s_oversize_drops >> 8) & 0xFF);
payload[3] = (uint8_t)(s_tx_failures & 0xFF);
payload[4] = (uint8_t)((s_tx_failures >> 8) & 0xFF);
payload[5] = (uint8_t)(s_rx_crc_errors & 0xFF);
payload[6] = (uint8_t)((s_rx_crc_errors >> 8) & 0xFF);
return send_frame(SERIAL_MSG_STATUS, payload, sizeof(payload));
}
// 1 Hz heartbeat. This is what lets the phone tell "link alive, radio quiet" from "link dead" -
// the app's watchdog (UsbSerialTransport.kt) marks the link ERROR after 3 missed beats. Keep the
// period in step with LINK_TIMEOUT_MS over there.
static void status_task(void *arg)
{
(void)arg;
while (1) {
serial_link_send_status(0);
vTaskDelay(pdMS_TO_TICKS(1000));
}
} }
// ---- RX framing state machine ---- // ---- RX framing state machine ----
// Runs in its own task, byte-at-a-time off the UART driver's RX ring buffer (via // Runs in its own task, byte-at-a-time off the USB Serial/JTAG driver's RX ring buffer (via
// uart_read_bytes with a short timeout, not raw ISR access - simplest correct option for a // usb_serial_jtag_read_bytes with a short timeout, not raw ISR access - simplest correct option
// link this slow/small; revisit if CAM traffic volume ever makes this a bottleneck). // for a link this slow/small; revisit if CAM traffic volume ever makes this a bottleneck).
typedef enum { typedef enum {
WAIT_SYNC0, WAIT_SYNC0,
WAIT_SYNC1, WAIT_SYNC1,
@@ -106,12 +169,16 @@ static void rx_task(void *arg)
uint8_t type = 0; uint8_t type = 0;
uint16_t len = 0; uint16_t len = 0;
uint16_t payload_idx = 0; uint16_t payload_idx = 0;
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
uint16_t crc_recv = 0; uint16_t crc_recv = 0;
// static, not stack: at SERIAL_LINK_MAX_PAYLOAD = 512 these two are >1 KB together, a quarter
// of this task's 4 KB stack. Safe as statics because rx_task is a singleton - one instance,
// created once in serial_link_init().
static uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
static uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
uint8_t byte; uint8_t byte;
while (1) { while (1) {
int n = uart_read_bytes(SERIAL_LINK_UART_NUM, &byte, 1, pdMS_TO_TICKS(50)); int n = usb_serial_jtag_read_bytes(&byte, 1, pdMS_TO_TICKS(50));
if (n <= 0) continue; if (n <= 0) continue;
switch (state) { switch (state) {
@@ -153,7 +220,6 @@ static void rx_task(void *arg)
case WAIT_CRC_HI: { case WAIT_CRC_HI: {
crc_recv |= (uint16_t)byte << 8; crc_recv |= (uint16_t)byte << 8;
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
crc_buf[0] = type; crc_buf[0] = type;
crc_buf[1] = (uint8_t)(len & 0xFF); crc_buf[1] = (uint8_t)(len & 0xFF);
crc_buf[2] = (uint8_t)((len >> 8) & 0xFF); crc_buf[2] = (uint8_t)((len >> 8) & 0xFF);
@@ -167,7 +233,9 @@ static void rx_task(void *arg)
ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type); ESP_LOGW(TAG, "rx: unexpected frame type 0x%02x from phone, ignoring", type);
} }
} else { } else {
ESP_LOGW(TAG, "rx: CRC mismatch (got %04x want %04x), dropping frame", crc_recv, crc_calc); bump(&s_rx_crc_errors);
ESP_LOGW(TAG, "rx: CRC mismatch (got %04x want %04x), dropping frame (total %u)",
crc_recv, crc_calc, s_rx_crc_errors);
} }
state = WAIT_SYNC0; state = WAIT_SYNC0;
break; break;
@@ -180,20 +248,22 @@ void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx)
{ {
s_on_cam_tx = on_cam_tx; s_on_cam_tx = on_cam_tx;
uart_config_t cfg = { s_tx_mutex = xSemaphoreCreateMutex();
.baud_rate = SERIAL_LINK_BAUD, if (!s_tx_mutex) {
.data_bits = UART_DATA_8_BITS, // Fail loudly rather than silently running unserialized: interleaved frames would look
.parity = UART_PARITY_DISABLE, // like random CRC errors on the phone, which is a miserable thing to debug.
.stop_bits = UART_STOP_BITS_1, ESP_LOGE(TAG, "failed to create tx mutex");
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE, abort();
.source_clk = UART_SCLK_DEFAULT, }
usb_serial_jtag_driver_config_t cfg = {
.tx_buffer_size = SERIAL_LINK_USB_BUF_SIZE,
.rx_buffer_size = SERIAL_LINK_USB_BUF_SIZE,
}; };
ESP_ERROR_CHECK(uart_driver_install(SERIAL_LINK_UART_NUM, 1024, 1024, 0, NULL, 0)); ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&cfg));
ESP_ERROR_CHECK(uart_param_config(SERIAL_LINK_UART_NUM, &cfg));
ESP_ERROR_CHECK(uart_set_pin(SERIAL_LINK_UART_NUM, SERIAL_LINK_TX_GPIO, SERIAL_LINK_RX_GPIO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
xTaskCreate(rx_task, "serial_link_rx", 4096, NULL, 6, NULL); xTaskCreate(rx_task, "serial_link_rx", 4096, NULL, 6, NULL);
ESP_LOGI(TAG, "serial_link up on UART%d, TX=GPIO%d RX=GPIO%d @ %d baud", xTaskCreate(status_task, "serial_link_status", 2560, NULL, 4, NULL);
SERIAL_LINK_UART_NUM, SERIAL_LINK_TX_GPIO, SERIAL_LINK_RX_GPIO, SERIAL_LINK_BAUD); ESP_LOGI(TAG, "serial_link up on native USB Serial/JTAG (VID 0x303A / PID 0x1001), "
"max payload %d, 1 Hz heartbeat", SERIAL_LINK_MAX_PAYLOAD);
} }
+46 -23
View File
@@ -4,11 +4,18 @@
#include <stddef.h> #include <stddef.h>
#include <stdbool.h> #include <stdbool.h>
// Binary framing for the phone <-> ESP32-C5 link (Phase 03). Deliberately NOT the same UART as // Binary framing for the phone <-> ESP32-C5 link (Phase 03). This runs over the ESP32-C5's
// the ESP-IDF console/ESP_LOG output (UART0, see sdkconfig CONFIG_ESP_CONSOLE_UART_NUM=0) - // native USB Serial/JTAG peripheral (driver/usb_serial_jtag.h) - the same physical USB-C port
// mixing binary frames with human-readable log text on one wire would corrupt both. This runs // used for JTAG, exposed to the host as a fixed-VID/PID (0x303A/0x1001) USB CDC-ACM device.
// on a dedicated UART (see SERIAL_LINK_UART_NUM / TX / RX pins below - CHANGE THESE to match // Deliberately NOT the same wire as the ESP-IDF console/ESP_LOG output, which stays on the
// your board's actual wiring from the USB-C connector's UART bridge to ESP32-C5 GPIOs). // OTHER USB-C port (the UART-bridge one, UART0, see sdkconfig CONFIG_ESP_CONSOLE_UART_NUM=0) -
// mixing binary frames with human-readable log text on one wire would corrupt both, and this
// way they're physically separate ports so there's no risk of that regardless.
//
// On the Android side, connect the phone (via USB-OTG) to the board's NATIVE USB-C port, not
// the UART-bridge/flashing port. The usb-serial-for-android library's default prober doesn't
// know Espressif's 0x303A/0x1001 VID/PID, so UsbSerialTransport.kt registers it manually via a
// custom ProbeTable pointed at CdcAcmSerialDriver - see that file's KDoc.
// //
// Frame format (both directions, symmetric): // Frame format (both directions, symmetric):
// [0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE] // [0xAA][0x55][type:1][length:2 LE][payload: length bytes][crc16:2 LE]
@@ -26,29 +33,39 @@
// framing by gn_unwrap.c. The phone never sees raw 802.11 frames. No station id is carried // framing by gn_unwrap.c. The phone never sees raw 802.11 frames. No station id is carried
// separately - CAM's own ItsPduHeader.stationID (the first field inside the UPER bytes) is // separately - CAM's own ItsPduHeader.stationID (the first field inside the UPER bytes) is
// already the meaningful identifier; see gn_unwrap.h for why a second one isn't added here. // already the meaningful identifier; see gn_unwrap.h for why a second one isn't added here.
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: 1-byte heartbeat (0 = ok), sent periodically so // SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
// the phone can distinguish "link idle" from "link dead" independent of CAM traffic. // distinguish "link idle" from "link dead" independent of CAM traffic (the app's watchdog in
// UsbSerialTransport.kt declares the link dead after 3 missed beats). Payload is 7 bytes:
// [status:1][oversize_drops:2 LE][tx_failures:2 LE][rx_crc_errors:2 LE]
// status 0 = ok. The counters are free-running totals since boot, saturating at 0xFFFF.
// They exist because the alternative - ESP_LOGW on the flashing port - is invisible to the
// phone, which is the only thing watching during a bench session. Mirrored by EspLinkStatus
// in the app's SerialFrame.kt.
#define SERIAL_MSG_CAM_TX 0x01 #define SERIAL_MSG_CAM_TX 0x01
#define SERIAL_MSG_CAM_RX 0x02 #define SERIAL_MSG_CAM_RX 0x02
#define SERIAL_MSG_STATUS 0x03 #define SERIAL_MSG_STATUS 0x03
// CHANGE THESE to match your board's actual USB-C -> UART bridge wiring. UART0 is already // USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
// claimed by the console/ESP_LOG; picking UART1 here to stay clear of it. These are common // controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
// free GPIOs on ESP32-C5 devkits but are NOT guaranteed free on your specific board - check // sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
// your schematic before flashing. // to SERIAL_LINK_MAX_PAYLOAD below.
#define SERIAL_LINK_UART_NUM 1 #define SERIAL_LINK_USB_BUF_SIZE 1024
#define SERIAL_LINK_TX_GPIO 4
#define SERIAL_LINK_RX_GPIO 5
#define SERIAL_LINK_BAUD 115200
// Max CAM payload this link will carry. cam.c sizes its own encode buffer at 96 bytes; 160 // Max CAM payload this link will carry. MUST match SERIAL_LINK_MAX_PAYLOAD in the app's
// gives headroom for the RX path's extra station_id+rssi prefix plus margin. // SerialFrame.kt - a mismatch means every frame above the smaller of the two is rejected by that
#define SERIAL_LINK_MAX_PAYLOAD 160 // side's "length exceeds max, resync" branch, silently.
//
// Raised from 160 to 512: 160 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 it, and those stations would then never reach the
// phone at all. 512 clears any realistic CAM; the real upstream ceiling on the RX path is
// rx_item_t.data (400 bytes) in main.c, so nothing larger can get here anyway.
#define SERIAL_LINK_MAX_PAYLOAD 512
// Initializes the dedicated UART and its background RX-framing task. Call once from app_main, // Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
// after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's context - keep it fast, // Call once from app_main, after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's
// it blocks the next frame's parsing) whenever a complete, checksummed SERIAL_MSG_CAM_TX frame // context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
// arrives from the phone. // SERIAL_MSG_CAM_TX frame arrives from the phone.
typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len); typedef void (*serial_link_cam_tx_cb_t)(const uint8_t *cam_uper, int cam_len);
void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx); void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
@@ -57,7 +74,13 @@ void serial_link_init(serial_link_cam_tx_cb_t on_cam_tx);
// end-to-end ack - the phone may still drop it, e.g. serial buffer overrun). // end-to-end ack - the phone may still drop it, e.g. serial buffer overrun).
bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len); bool serial_link_send_cam_rx(int8_t rssi, const uint8_t *cam_uper, int cam_len);
// Sends a 1-byte SERIAL_MSG_STATUS heartbeat frame. // Sends one SERIAL_MSG_STATUS heartbeat frame immediately (status byte + the current counters).
// Normally unnecessary to call by hand - serial_link_init() starts a task that does this at 1 Hz.
bool serial_link_send_status(uint8_t status); bool serial_link_send_status(uint8_t status);
// Records a failed esp_wifi_80211_tx() so it shows up in the next heartbeat's tx_failures
// counter. Called from main.c's tx_radio_task - a CAM that reached the radio but didn't go out is
// otherwise indistinguishable, from the phone's side, from one that transmitted fine.
void serial_link_note_tx_failure(void);
#endif #endif
+2104 -424
View File
File diff suppressed because it is too large Load Diff