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:
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=56211FDAP0015L" />
|
||||
|
||||
@@ -83,6 +83,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState()
|
||||
val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState()
|
||||
val obuHardware by mqttViewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by mqttViewModel.usbSerialState.collectAsState()
|
||||
val espRxMode by mqttViewModel.espRxMode.collectAsState()
|
||||
|
||||
MicrOBUTheme(darkTheme = state.darkTheme) {
|
||||
val view = LocalView.current
|
||||
@@ -135,6 +137,7 @@ class MainActivity : AppCompatActivity() {
|
||||
mqttConnectionState = mqttConnectionState,
|
||||
activeTransport = activeTransport,
|
||||
obuHardware = obuHardware,
|
||||
usbSerialState = usbSerialState,
|
||||
usbCableConnected = usbConnected,
|
||||
obuStationTypeWarning = obuStationTypeWarning,
|
||||
obuStationType = obuStationType,
|
||||
@@ -250,6 +253,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onMqttPrefsChange = mqttViewModel::updatePrefs,
|
||||
obuHardware = obuHardware,
|
||||
onObuHardwareChange = mqttViewModel::setObuHardware,
|
||||
espRxMode = espRxMode,
|
||||
onEspRxModeChange = mqttViewModel::setEspRxMode,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -299,7 +304,13 @@ class MainActivity : AppCompatActivity() {
|
||||
AboutSettingsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(Screen.Connection.route) {
|
||||
ConnectionSetupScreen()
|
||||
// Must be the Activity-scoped instance (same one MainActivity holds
|
||||
// and MqttTopicViewerScreen is given below), not the default
|
||||
// hiltViewModel() — that would create a separate instance scoped to
|
||||
// this NavBackStackEntry, whose onCleared() (fired the moment you
|
||||
// navigate away) would disconnect the shared UsbSerialTransport out
|
||||
// from under every other screen still using it.
|
||||
ConnectionSetupScreen(viewModel = mqttViewModel)
|
||||
}
|
||||
composable(Screen.Map.route) {
|
||||
MapScreen(gnss = state.gnss)
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.hawhamburg.micr0bu.data.GnssReading
|
||||
import com.hawhamburg.micr0bu.data.SensorRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.UseCaseAlertPreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.SerialFrameType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.asn1.RealAsn1UperCodec
|
||||
@@ -69,12 +71,15 @@ class CamUseCaseRepository @Inject constructor(
|
||||
private val prefs: UseCaseAlertPreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camCodec: RealAsn1UperCodec,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val engine = UseCaseDetectionEngine()
|
||||
private val sensorRepository = SensorRepository(context)
|
||||
|
||||
@Volatile private var espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE
|
||||
|
||||
private val _ownStationId = MutableStateFlow<Long?>(null)
|
||||
/** The ego OBU's own station ID, learned from `v2x/rx/obu_gnss`. Null until known. */
|
||||
val ownStationId: StateFlow<Long?> = _ownStationId.asStateFlow()
|
||||
@@ -163,9 +168,14 @@ class CamUseCaseRepository @Inject constructor(
|
||||
scope.launch {
|
||||
usbSerialTransport.incomingFrames.collect { frame ->
|
||||
if (frame.type != SerialFrameType.CAM_RX) return@collect
|
||||
if (espRxMode == EspRxMode.SEND_ONLY) return@collect
|
||||
handleCamFromSerial(frame.payload)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
obuHardwarePrefs.espRxModeFlow.collect { espRxMode = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -24,6 +25,7 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
) {
|
||||
private object Keys {
|
||||
val OBU_HARDWARE = stringPreferencesKey("obu_hardware")
|
||||
val ESP_RX_MODE = stringPreferencesKey("esp_rx_mode")
|
||||
}
|
||||
|
||||
val obuHardwareFlow: Flow<ObuHardware> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
@@ -33,4 +35,17 @@ class ObuHardwarePreferences @Inject constructor(
|
||||
suspend fun setObuHardware(hardware: ObuHardware) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.OBU_HARDWARE] = hardware.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the ESP32-C5 path processes received CAM traffic or only transmits — see
|
||||
* [EspRxMode]'s KDoc for what this does and doesn't actually control. Defaults to
|
||||
* [EspRxMode.SEND_AND_RECEIVE] (full duplex, today's existing behavior).
|
||||
*/
|
||||
val espRxModeFlow: Flow<EspRxMode> = context.obuHardwareDataStore.data.map { prefs ->
|
||||
EspRxMode.entries.firstOrNull { it.id == prefs[Keys.ESP_RX_MODE] } ?: EspRxMode.SEND_AND_RECEIVE
|
||||
}
|
||||
|
||||
suspend fun setEspRxMode(mode: EspRxMode) {
|
||||
context.obuHardwareDataStore.edit { prefs -> prefs[Keys.ESP_RX_MODE] = mode.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ enum class ObuHardware(val id: String) {
|
||||
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] and
|
||||
* [com.hawhamburg.micr0bu.domain.cam.PhoneCamBuilder].
|
||||
*
|
||||
* **Placeholder hardware option as of this writing** — the actual frame protocol between
|
||||
* phone and ESP32-C5 firmware is not yet defined (pending translation of the existing
|
||||
* ESP32 C firmware's logic to the Kotlin side). UI/settings exist so the option is visible
|
||||
* and selectable, but connecting will not yet do anything real.
|
||||
* Real serial link + CAM UPER codec are implemented on both sides — see
|
||||
* [com.hawhamburg.micr0bu.data.transport.UsbSerialTransport] (phone) and
|
||||
* `obu-firmware/main/serial_link.c` (firmware). See also [EspRxMode] for the send-only vs
|
||||
* send-and-receive toggle (Settings > Connection).
|
||||
*/
|
||||
ESP32_C5("esp32_c5"),
|
||||
}
|
||||
|
||||
@@ -18,12 +18,62 @@ object SerialFrameType {
|
||||
* 802.11/LLC-SNAP/GeoNetworking/BTP-B framing by the firmware's `gn_unwrap.c`. */
|
||||
const val CAM_RX: Int = 0x02
|
||||
|
||||
/** ESP32 -> phone: 1-byte heartbeat (0 = ok), independent of CAM traffic. */
|
||||
/** ESP32 -> phone: periodic heartbeat + drop counters, independent of CAM traffic.
|
||||
* Payload layout is [EspLinkStatus] — see its KDoc. */
|
||||
const val STATUS: Int = 0x03
|
||||
}
|
||||
|
||||
/** Max payload this link carries — matches `SERIAL_LINK_MAX_PAYLOAD` in the firmware. */
|
||||
const val SERIAL_LINK_MAX_PAYLOAD = 160
|
||||
/**
|
||||
* Max payload this link carries — MUST match `SERIAL_LINK_MAX_PAYLOAD` in the firmware.
|
||||
*
|
||||
* Raised from 160 to 512: the old value was reasoned from `cam.c`'s 96-byte encode buffer, which
|
||||
* only ever described *our own* minimal CAM. A third-party CAM off the air carrying a path-history
|
||||
* or special-vehicle container comfortably exceeds 160, and both sides' framing treats
|
||||
* `len > MAX` as an unrecoverable frame boundary and resyncs — i.e. those stations would silently
|
||||
* never appear in the UI. 512 clears any realistic CAM; the actual upstream ceiling on the RX path
|
||||
* is the firmware's 400-byte promiscuous capture buffer (`rx_item_t.data` in `main.c`), so nothing
|
||||
* bigger can reach this link anyway.
|
||||
*
|
||||
* The two sides must be updated together — a phone at 512 talking to firmware still at 160 has
|
||||
* every large frame rejected by the firmware's resync branch. Reflash and reinstall as one step.
|
||||
*/
|
||||
const val SERIAL_LINK_MAX_PAYLOAD = 512
|
||||
|
||||
/**
|
||||
* Decoded [SerialFrameType.STATUS] payload: `[status:1][oversizeDrops:2 LE][txFailures:2 LE]
|
||||
* [rxCrcErrors:2 LE]` (7 bytes). Counters are free-running totals since firmware boot and
|
||||
* saturate at 0xFFFF rather than wrapping.
|
||||
*
|
||||
* Exists so the phone can tell "link alive, no traffic" from "link dead", and so firmware-side
|
||||
* drops — which otherwise only reach `ESP_LOGW` on the flashing port that the phone isn't
|
||||
* watching — are visible in the app during bench testing.
|
||||
*/
|
||||
data class EspLinkStatus(
|
||||
/** 0 = ok. Non-zero values reserved for future firmware-side fault flags. */
|
||||
val status: Int,
|
||||
/** CAMs the firmware refused to forward because they exceeded [SERIAL_LINK_MAX_PAYLOAD]. */
|
||||
val oversizeDrops: Int,
|
||||
/** `esp_wifi_80211_tx()` failures — a CAM reached the radio but didn't go out. */
|
||||
val txFailures: Int,
|
||||
/** Frames from the phone the firmware dropped on CRC mismatch. */
|
||||
val rxCrcErrors: Int,
|
||||
) {
|
||||
companion object {
|
||||
const val PAYLOAD_SIZE = 7
|
||||
|
||||
/** Returns null if [payload] isn't a well-formed status payload (e.g. older firmware). */
|
||||
fun parse(payload: ByteArray): EspLinkStatus? {
|
||||
if (payload.size < PAYLOAD_SIZE) return null
|
||||
fun u16(i: Int) = (payload[i].toInt() and 0xFF) or ((payload[i + 1].toInt() and 0xFF) shl 8)
|
||||
return EspLinkStatus(
|
||||
status = payload[0].toInt() and 0xFF,
|
||||
oversizeDrops = u16(1),
|
||||
txFailures = u16(3),
|
||||
rxCrcErrors = u16(5),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SYNC0: Byte = 0xAA.toByte()
|
||||
private const val SYNC1: Byte = 0x55.toByte()
|
||||
|
||||
@@ -8,17 +8,30 @@ import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
|
||||
import com.hoho.android.usbserial.driver.ProbeTable
|
||||
import com.hoho.android.usbserial.driver.UsbSerialDriver
|
||||
import com.hoho.android.usbserial.driver.UsbSerialPort
|
||||
import com.hoho.android.usbserial.driver.UsbSerialProber
|
||||
import com.hoho.android.usbserial.util.SerialInputOutputManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -26,20 +39,39 @@ import javax.inject.Singleton
|
||||
enum class UsbSerialState { DISCONNECTED, DEVICE_ATTACHED, PERMISSION_REQUESTED, CONNECTED, ERROR }
|
||||
|
||||
private const val ACTION_USB_PERMISSION = "com.hawhamburg.micr0bu.USB_SERIAL_PERMISSION"
|
||||
private const val TAG = "UsbSerialTransport"
|
||||
|
||||
/**
|
||||
* UART connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
|
||||
* USB-serial connection handler for the ESP32-C5 (Phase 03 second OBU option, requirements doc
|
||||
* Section 13). Unlike [UsbNetworkDetector] (CiT One, USB-C tethering → virtual Ethernet → MQTT
|
||||
* broker), the ESP32-C5 has no MQTT broker or IP network at all — it's reachable only as a USB
|
||||
* serial (UART) device, framed per [SerialFrameType] (see that file's KDoc — must stay
|
||||
* bit-for-bit compatible with the firmware's `obu-firmware/main/serial_link.c`).
|
||||
* CDC-ACM serial device (over its native USB Serial/JTAG peripheral, not a UART bridge), framed
|
||||
* per [SerialFrameType] (see that file's KDoc — must stay bit-for-bit compatible with the
|
||||
* firmware's `obu-firmware/main/serial_link.c`).
|
||||
*
|
||||
* Built on `com.github.mik3y:usb-serial-for-android`, which auto-detects CDC-ACM (what the
|
||||
* ESP32-C5's native USB is expected to enumerate as) plus common USB-UART bridge chips as a
|
||||
* fallback, so this doesn't need to hardcode a specific driver class.
|
||||
* Built on `com.github.mik3y:usb-serial-for-android`. The ESP32-C5 exposes the serial link over
|
||||
* its native USB Serial/JTAG peripheral (see firmware `serial_link.c`), which enumerates as a
|
||||
* standard CDC-ACM device but under Espressif's own VID/PID (0x303A / 0x1001) rather than one of
|
||||
* the specific vendor devices (Arduino, FTDI, etc.) the library's *default* prober already knows
|
||||
* about — `UsbSerialProber.getDefaultProber()` alone will not recognize it. [customProber] starts
|
||||
* from the library's stock probe table and adds that VID/PID -> [CdcAcmSerialDriver] mapping on
|
||||
* top, so both the ESP32-C5 and every device the library already supports keep working.
|
||||
*
|
||||
* Baud rate is fixed at 115200 to match `SERIAL_LINK_BAUD` in the firmware — if that ever
|
||||
* changes on the firmware side, [BAUD_RATE] here must change with it.
|
||||
* Plug the phone into the board's NATIVE USB-C port (not the separate UART-bridge/flashing
|
||||
* port) — only that port is wired to this peripheral. See obu-firmware/FLASHING.md.
|
||||
*
|
||||
* Baud rate is not applicable here — USB Serial/JTAG has no baud concept; `setParameters` below
|
||||
* is a no-op the library requires anyway for API-shape reasons but the value is otherwise unused.
|
||||
*
|
||||
* ## Ownership
|
||||
* This is a `@Singleton` shared by the UI ([com.hawhamburg.micr0bu.viewmodel.MqttViewModel]), the
|
||||
* foreground [com.hawhamburg.micr0bu.service.TripRecordingService]'s
|
||||
* [com.hawhamburg.micr0bu.service.CamTransmitLoop], and the bench
|
||||
* [com.hawhamburg.micr0bu.service.CamPinger]. The port is therefore closed on exactly three
|
||||
* events, none of which is a UI object being torn down: an explicit user Disconnect, the device
|
||||
* detaching, or the app process dying. In particular `MqttViewModel.onCleared()` deliberately does
|
||||
* NOT call [disconnect] — swiping the Activity away mid-recording would otherwise close the port
|
||||
* out from under a still-running foreground service, which would keep beaconing into a dead port.
|
||||
*/
|
||||
@Singleton
|
||||
class UsbSerialTransport @Inject constructor(
|
||||
@@ -47,10 +79,38 @@ class UsbSerialTransport @Inject constructor(
|
||||
) {
|
||||
companion object {
|
||||
private const val BAUD_RATE = 115_200
|
||||
|
||||
// Espressif USB Serial/JTAG controller's fixed default VID/PID (shared across
|
||||
// ESP32-C3/C6/H2/C5 etc. when using the on-chip USB Serial/JTAG peripheral rather than
|
||||
// an external USB-UART bridge chip).
|
||||
private const val ESPRESSIF_VID = 0x303A
|
||||
private const val ESP_USB_SERIAL_JTAG_PID = 0x1001
|
||||
|
||||
/**
|
||||
* Declare the link dead if nothing at all arrives for this long. The firmware sends a
|
||||
* [SerialFrameType.STATUS] heartbeat at 1 Hz independent of CAM traffic, so three missed
|
||||
* beats is unambiguous rather than just a quiet radio.
|
||||
*/
|
||||
private const val LINK_TIMEOUT_MS = 3_500L
|
||||
private const val WATCHDOG_POLL_MS = 500L
|
||||
}
|
||||
|
||||
private val customProber: UsbSerialProber = run {
|
||||
// Start from the library's stock table (getDefaultProbeTable() returns a fresh, mutable
|
||||
// copy) rather than an empty ProbeTable, so adding the ESP32-C5 doesn't silently drop
|
||||
// support for every device the library already knows.
|
||||
val table: ProbeTable = UsbSerialProber.getDefaultProbeTable()
|
||||
table.addProduct(ESPRESSIF_VID, ESP_USB_SERIAL_JTAG_PID, CdcAcmSerialDriver::class.java)
|
||||
UsbSerialProber(table)
|
||||
}
|
||||
|
||||
private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
/** Guards [port]/[ioManager] and the open/close transitions. */
|
||||
private val lock = Any()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val _state = MutableStateFlow(UsbSerialState.DISCONNECTED)
|
||||
val state: StateFlow<UsbSerialState> = _state.asStateFlow()
|
||||
|
||||
@@ -58,54 +118,106 @@ class UsbSerialTransport @Inject constructor(
|
||||
/** Every valid frame the ESP32 sends (CAM_RX and STATUS) — callers filter by [DecodedFrame.type]. */
|
||||
val incomingFrames: SharedFlow<DecodedFrame> = _incomingFrames.asSharedFlow()
|
||||
|
||||
private val _linkStatus = MutableStateFlow<EspLinkStatus?>(null)
|
||||
/** Latest firmware heartbeat + drop counters, or null if none received yet. */
|
||||
val linkStatus: StateFlow<EspLinkStatus?> = _linkStatus.asStateFlow()
|
||||
|
||||
private val _consecutiveWriteFailures = MutableStateFlow(0)
|
||||
/**
|
||||
* Consecutive failed [sendCamTx] calls (reset to 0 by any success). Non-zero means CAMs are
|
||||
* being built and dropped on the floor — surfaced in the UI so "Sent: 240" can't quietly mean
|
||||
* "240 writes failed".
|
||||
*/
|
||||
val consecutiveWriteFailures: StateFlow<Int> = _consecutiveWriteFailures.asStateFlow()
|
||||
|
||||
private val decoder = SerialFrameDecoder()
|
||||
|
||||
@Volatile private var port: UsbSerialPort? = null
|
||||
@Volatile private var ioManager: SerialInputOutputManager? = null
|
||||
@Volatile private var pendingDevice: UsbDevice? = null
|
||||
@Volatile private var openDeviceName: String? = null
|
||||
@Volatile private var lastFrameAtMs: Long = 0L
|
||||
@Volatile private var watchdogJob: Job? = null
|
||||
|
||||
private val permissionReceiver = object : BroadcastReceiver() {
|
||||
private val usbReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_USB_PERMISSION) return
|
||||
synchronized(this) {
|
||||
when (intent.action) {
|
||||
ACTION_USB_PERMISSION -> {
|
||||
val device: UsbDevice? = intent.getUsbDeviceExtra()
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (granted && device != null) {
|
||||
Log.i(TAG, "usbReceiver: permission granted, opening device")
|
||||
openDevice(device)
|
||||
} else {
|
||||
Log.w(TAG, "usbReceiver: permission denied or device null " +
|
||||
"(granted=$granted, device=$device)")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
}
|
||||
}
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
val device: UsbDevice? = intent.getUsbDeviceExtra()
|
||||
// Without this the state stays CONNECTED after an unplug until some later
|
||||
// write happens to throw — i.e. the UI lies about the link for as long as
|
||||
// nothing is being sent.
|
||||
if (device != null && device.deviceName == openDeviceName) {
|
||||
Log.i(TAG, "usbReceiver: our device detached, closing port")
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var receiverRegistered = false
|
||||
|
||||
/**
|
||||
* Finds the first attached USB-serial-capable device, requests permission if needed, and
|
||||
* opens it. Safe to call repeatedly (e.g. from a "retry" UI action) — no-ops if already
|
||||
* connected.
|
||||
* Finds the attached ESP32-C5, requests permission if needed, and opens it. Safe to call
|
||||
* repeatedly (e.g. from a "retry" UI action) — no-ops if already connected.
|
||||
*/
|
||||
fun connect() {
|
||||
if (_state.value == UsbSerialState.CONNECTED) return
|
||||
|
||||
ensureReceiverRegistered()
|
||||
|
||||
val availableDrivers: List<UsbSerialDriver> =
|
||||
UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
|
||||
val driver = availableDrivers.firstOrNull()
|
||||
if (driver == null) {
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
if (_state.value == UsbSerialState.CONNECTED) {
|
||||
Log.i(TAG, "connect(): already connected, no-op")
|
||||
return
|
||||
}
|
||||
|
||||
ensureReceiverRegistered()
|
||||
logAttachedUsbDevices()
|
||||
|
||||
val availableDrivers: List<UsbSerialDriver> = customProber.findAllDrivers(usbManager)
|
||||
Log.i(TAG, "connect(): customProber found ${availableDrivers.size} driver(s)")
|
||||
|
||||
// Select by VID/PID rather than by list position: with the stock probe table merged in,
|
||||
// findAllDrivers() legitimately returns other serial devices (a hub-attached adapter, a
|
||||
// CiT One), and firstOrNull() would be a coin flip between them.
|
||||
val espDriver = availableDrivers.firstOrNull {
|
||||
it.device.vendorId == ESPRESSIF_VID && it.device.productId == ESP_USB_SERIAL_JTAG_PID
|
||||
}
|
||||
val driver = espDriver ?: availableDrivers.firstOrNull()
|
||||
if (driver == null) {
|
||||
Log.w(TAG, "connect(): no matching driver found among attached USB devices " +
|
||||
"(see device list logged above) - either nothing is attached at the Android " +
|
||||
"USB level, or it's attached but its VID/PID doesn't match any entry in " +
|
||||
"customProber's table")
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (espDriver == null) {
|
||||
Log.w(TAG, "connect(): no Espressif 0x303A/0x1001 device found - falling back to " +
|
||||
"the first serial device the prober matched. This is NOT the ESP32-C5; if the " +
|
||||
"link behaves oddly, check you're on the board's native USB-C port, not the " +
|
||||
"UART-bridge/flashing port.")
|
||||
}
|
||||
|
||||
val device = driver.device
|
||||
Log.i(TAG, "connect(): matched device vid=0x${device.vendorId.toString(16)} " +
|
||||
"pid=0x${device.productId.toString(16)} name=${device.deviceName} " +
|
||||
"ports=${driver.ports.size}")
|
||||
_state.value = UsbSerialState.DEVICE_ATTACHED
|
||||
|
||||
if (usbManager.hasPermission(device)) {
|
||||
Log.i(TAG, "connect(): permission already granted, opening directly")
|
||||
openDevice(device)
|
||||
} else {
|
||||
pendingDevice = device
|
||||
Log.i(TAG, "connect(): requesting USB permission from user")
|
||||
_state.value = UsbSerialState.PERMISSION_REQUESTED
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
@@ -115,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) {
|
||||
val driver = UsbSerialProber.getDefaultProber().probeDevice(device)
|
||||
synchronized(lock) {
|
||||
val driver = customProber.probeDevice(device)
|
||||
if (driver == null || driver.ports.isEmpty()) {
|
||||
Log.w(TAG, "openDevice(): probeDevice returned null or no ports for " +
|
||||
"vid=0x${device.vendorId.toString(16)} pid=0x${device.productId.toString(16)}")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
return
|
||||
}
|
||||
val connection = usbManager.openDevice(device)
|
||||
if (connection == null) {
|
||||
Log.w(TAG, "openDevice(): usbManager.openDevice() returned null - permission not " +
|
||||
"actually granted, or Android couldn't claim the device")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
return
|
||||
}
|
||||
@@ -132,19 +285,47 @@ class UsbSerialTransport @Inject constructor(
|
||||
newPort.open(connection)
|
||||
newPort.setParameters(BAUD_RATE, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "openDevice(): port.open()/setParameters() threw", e)
|
||||
runCatching { newPort.close() }
|
||||
_state.value = UsbSerialState.ERROR
|
||||
return
|
||||
}
|
||||
|
||||
// Assert DTR/RTS. CdcAcmSerialDriver does NOT do this on open(), and the ESP32's
|
||||
// native USB Serial/JTAG endpoint gates its TX on the host having opened the CDC
|
||||
// line — without DTR the plausible failure is the nastiest kind: enumerates, opens,
|
||||
// reports CONNECTED, phone->ESP32 CAM_TX works, and ESP32->phone CAM_RX is simply
|
||||
// silent forever. Wrapped in runCatching because setDTR/setRTS can throw
|
||||
// (IOException / UnsupportedOperationException) depending on the driver, and that
|
||||
// must not kill an otherwise working link.
|
||||
// TODO(bring-up): confirm empirically whether DTR is actually required here, then
|
||||
// record the answer in serial_link.h next to the VID/PID note. Test both ways.
|
||||
runCatching {
|
||||
newPort.setDTR(true)
|
||||
newPort.setRTS(true)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "openDevice(): DTR/RTS not supported by this driver - if no CAM_RX " +
|
||||
"frames ever arrive, this is the first thing to investigate", it)
|
||||
}
|
||||
|
||||
Log.i(TAG, "openDevice(): port opened successfully")
|
||||
|
||||
port = newPort
|
||||
openDeviceName = device.deviceName
|
||||
val manager = SerialInputOutputManager(newPort, object : SerialInputOutputManager.Listener {
|
||||
override fun onNewData(data: ByteArray) {
|
||||
lastFrameAtMs = SystemClock.elapsedRealtime()
|
||||
val frames = decoder.onBytes(data)
|
||||
frames.forEach { _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) {
|
||||
Log.e(TAG, "SerialInputOutputManager.onRunError()", e)
|
||||
_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).
|
||||
manager.start()
|
||||
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_linkStatus.value = null
|
||||
lastFrameAtMs = SystemClock.elapsedRealtime()
|
||||
_state.value = UsbSerialState.CONNECTED
|
||||
startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the link to [UsbSerialState.ERROR] once the firmware's 1 Hz STATUS heartbeat has been
|
||||
* missing for [LINK_TIMEOUT_MS]. Without this, "connected" only ever means "the port opened
|
||||
* at some point in the past" — which on a bench is exactly the wrong thing to believe.
|
||||
*/
|
||||
private fun startWatchdog() {
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(WATCHDOG_POLL_MS)
|
||||
if (_state.value != UsbSerialState.CONNECTED) continue
|
||||
val silentFor = SystemClock.elapsedRealtime() - lastFrameAtMs
|
||||
if (silentFor > LINK_TIMEOUT_MS) {
|
||||
Log.w(TAG, "watchdog: no frame from ESP32 for ${silentFor}ms (heartbeat " +
|
||||
"expected at 1 Hz) - marking link ERROR. Either the firmware is wedged/" +
|
||||
"not running, or the host->device direction opened but device->host " +
|
||||
"never did (see the DTR note in openDevice()).")
|
||||
_state.value = UsbSerialState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes [camUperBytes] as a [SerialFrameType.CAM_TX] frame and writes it to the port.
|
||||
* No-op (returns false) if not currently connected — callers (the CAM transmit loop) should
|
||||
* treat that as "this beacon interval's CAM didn't go out," not a fatal error; the next one
|
||||
* is only ~1s away and will retry naturally.
|
||||
* is only ~1s away and will retry naturally. Repeated failures are counted in
|
||||
* [consecutiveWriteFailures] so they can't stay invisible.
|
||||
*
|
||||
* Blocking: writes with a 200 ms timeout, so call from a background dispatcher.
|
||||
*/
|
||||
fun sendCamTx(camUperBytes: ByteArray): Boolean {
|
||||
val p = port ?: return false
|
||||
val p = port
|
||||
if (p == null) {
|
||||
_consecutiveWriteFailures.update { it + 1 }
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val frame = SerialFrameEncoder.encode(SerialFrameType.CAM_TX, camUperBytes)
|
||||
p.write(frame, /* timeout ms */ 200)
|
||||
_consecutiveWriteFailures.value = 0
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val failures = _consecutiveWriteFailures.updateAndGet { it + 1 }
|
||||
Log.w(TAG, "sendCamTx(): write failed (consecutive failures: $failures)", e)
|
||||
_state.value = UsbSerialState.ERROR
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
synchronized(lock) {
|
||||
watchdogJob?.cancel()
|
||||
watchdogJob = null
|
||||
ioManager?.stop() // stops the manager's own internal background thread
|
||||
ioManager = null
|
||||
runCatching { port?.close() }
|
||||
port = null
|
||||
openDeviceName = null
|
||||
_linkStatus.value = null
|
||||
_consecutiveWriteFailures.value = 0
|
||||
_state.value = UsbSerialState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureReceiverRegistered() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION)
|
||||
val filter = IntentFilter(ACTION_USB_PERMISSION).apply {
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
// NOT_EXPORTED is correct for both actions: ACTION_USB_PERMISSION is ours, and
|
||||
// ACTION_USB_DEVICE_DETACHED is a protected system broadcast, which is delivered to
|
||||
// context-registered receivers regardless of the exported flag.
|
||||
context.registerReceiver(usbReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(permissionReceiver, filter)
|
||||
context.registerReceiver(usbReceiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.viewmodel.MqttViewModel
|
||||
|
||||
private val UsbGreen = Color(0xFF4CAF50)
|
||||
@@ -64,6 +65,7 @@ fun ConnectionSetupScreen(
|
||||
val activeTransport by viewModel.activeTransport.collectAsState()
|
||||
val mqttPrefs by viewModel.mqttPrefs.collectAsState()
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
val isConnecting = connectionState == MqttConnectionState.CONNECTING
|
||||
@@ -226,11 +228,27 @@ fun ConnectionSetupScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── ESP32-C5 placeholder card ───────────────────────────────────────
|
||||
// Real USB-serial connection handling lives in UsbSerialTransport, which is a
|
||||
// stub until the ESP32 firmware protocol is translated to Kotlin (Section 13).
|
||||
// ── ESP32-C5 real connection card (Phase 03) ────────────────────────
|
||||
// Backed by UsbSerialTransport (native USB Serial/JTAG CDC-ACM link) - see that
|
||||
// class's KDoc for the VID/PID (0x303A/0x1001) and native-vs-UART-bridge port note.
|
||||
val isEspConnected = usbSerialState == UsbSerialState.CONNECTED
|
||||
val isEspBusy = usbSerialState == UsbSerialState.DEVICE_ATTACHED ||
|
||||
usbSerialState == UsbSerialState.PERMISSION_REQUESTED
|
||||
val espContainerColor = when {
|
||||
isEspConnected -> UsbGreenBg
|
||||
isEspBusy -> UsbAmberBg
|
||||
else -> UsbGrayBg
|
||||
}
|
||||
val (espColor, espStateLabel) = when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> UsbGreen to stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> UsbAmber to stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> UsbAmber to stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> Color(0xFFFF5252) to stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> UsbGray to stringResource(R.string.conn_esp32_state_disconnected)
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = UsbGrayBg),
|
||||
colors = CardDefaults.cardColors(containerColor = espContainerColor),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
@@ -241,22 +259,57 @@ fun ConnectionSetupScreen(
|
||||
Icon(
|
||||
Icons.Default.Usb,
|
||||
contentDescription = null,
|
||||
tint = UsbGray,
|
||||
tint = espColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.conn_esp32_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = UsbGray,
|
||||
color = espColor,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.conn_esp32_phase3_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (isEspConnected) Icons.Default.Link else Icons.Default.LinkOff,
|
||||
null,
|
||||
tint = espColor,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(espStateLabel, style = MaterialTheme.typography.bodySmall, color = espColor)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (isEspConnected) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.disconnectUsbSerial() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color(0xFFFF5252),
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Default.LinkOff, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.conn_disconnect))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.connectUsbSerial() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isEspBusy,
|
||||
) {
|
||||
Icon(Icons.Default.Link, null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.conn_esp32_connect))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.viewmodel.SensorUiState
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@@ -62,6 +63,7 @@ fun DashboardScreen(
|
||||
mqttConnectionState: MqttConnectionState,
|
||||
activeTransport: TransportType = TransportType.USB_C,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
usbCableConnected: Boolean = false,
|
||||
obuStationTypeWarning: Boolean = false,
|
||||
obuStationType: Int? = null,
|
||||
@@ -254,7 +256,13 @@ fun DashboardScreen(
|
||||
onClick = onNavigateToSensors,
|
||||
)
|
||||
|
||||
// For ESP32-C5, "connected" means the USB-serial link, not MQTT (which doesn't exist on
|
||||
// that path at all — see MqttRepository.activeTransport's KDoc). Using mqttConnected
|
||||
// here unconditionally would leave this card permanently gray/disconnected on ESP32-C5
|
||||
// even once the serial link is actually up.
|
||||
val isEsp32 = obuHardware == ObuHardware.ESP32_C5
|
||||
val mqttConnected = mqttConnectionState == MqttConnectionState.CONNECTED
|
||||
val obuConnected = if (isEsp32) usbSerialState == UsbSerialState.CONNECTED else mqttConnected
|
||||
val transportIcon = when (activeTransport) {
|
||||
TransportType.USB_C -> Icons.Default.Usb
|
||||
TransportType.USB_SERIAL -> Icons.Default.Usb
|
||||
@@ -280,10 +288,10 @@ fun DashboardScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val tint = if (mqttConnected) Color(0xFF4CAF50)
|
||||
val tint = if (obuConnected) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Icon(
|
||||
if (mqttConnected) transportIcon else transportInactiveIcon,
|
||||
if (obuConnected) transportIcon else transportInactiveIcon,
|
||||
null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(28.dp),
|
||||
@@ -296,7 +304,15 @@ fun DashboardScreen(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = when (mqttConnectionState) {
|
||||
text = if (isEsp32) {
|
||||
when (usbSerialState) {
|
||||
UsbSerialState.CONNECTED -> stringResource(R.string.conn_esp32_state_connected)
|
||||
UsbSerialState.DEVICE_ATTACHED -> stringResource(R.string.conn_esp32_state_device_attached)
|
||||
UsbSerialState.PERMISSION_REQUESTED -> stringResource(R.string.conn_esp32_state_permission_requested)
|
||||
UsbSerialState.ERROR -> stringResource(R.string.conn_esp32_state_error)
|
||||
UsbSerialState.DISCONNECTED -> stringResource(R.string.dash_tap_to_connect)
|
||||
}
|
||||
} else when (mqttConnectionState) {
|
||||
MqttConnectionState.CONNECTED -> stringResource(R.string.dash_mqtt_connected)
|
||||
MqttConnectionState.CONNECTING -> stringResource(R.string.dash_mqtt_connecting)
|
||||
MqttConnectionState.ERROR -> stringResource(R.string.dash_mqtt_error)
|
||||
|
||||
@@ -65,7 +65,9 @@ import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MessageDirection
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttMessage
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.domain.cam.CamParser
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.AlertLevel
|
||||
@@ -118,6 +120,11 @@ fun MqttTopicViewerScreen(
|
||||
val obuHardware by viewModel.obuHardware.collectAsState()
|
||||
val ownCamPosition by viewModel.ownCamPosition.collectAsState()
|
||||
val remoteCamPositions by viewModel.remoteCamPositions.collectAsState()
|
||||
val usbSerialState by viewModel.usbSerialState.collectAsState()
|
||||
val camPingerActive by viewModel.camPingerActive.collectAsState()
|
||||
val camPingerSentCount by viewModel.camPingerSentCount.collectAsState()
|
||||
val camSendFailures by viewModel.camSendFailures.collectAsState()
|
||||
val espLinkStatus by viewModel.espLinkStatus.collectAsState()
|
||||
|
||||
// Sort: sys/ topics first (heartbeat/health), then alphabetical
|
||||
val sortedTopics = topicMessages.keys.sortedWith(
|
||||
@@ -196,11 +203,19 @@ fun MqttTopicViewerScreen(
|
||||
activeDenmUseCase = activeDenmUseCase,
|
||||
useCaseAlerts = useCaseAlerts,
|
||||
showDenmTrigger = obuHardware == ObuHardware.CIT_ONE,
|
||||
showCamPinger = obuHardware == ObuHardware.ESP32_C5,
|
||||
usbSerialState = usbSerialState,
|
||||
camPingerActive = camPingerActive,
|
||||
camPingerSentCount = camPingerSentCount,
|
||||
camSendFailures = camSendFailures,
|
||||
espLinkStatus = espLinkStatus,
|
||||
ownCamPosition = ownCamPosition,
|
||||
remoteCamPositions = remoteCamPositions,
|
||||
onSelectTopic = { viewModel.selectTopic(it) },
|
||||
onSendDenm = { viewModel.sendDenm() },
|
||||
onStopDenm = { viewModel.stopDenm() },
|
||||
onStartCamPinger = { viewModel.startCamPinger() },
|
||||
onStopCamPinger = { viewModel.stopCamPinger() },
|
||||
)
|
||||
} else {
|
||||
MessageDetailPane(
|
||||
@@ -224,11 +239,19 @@ private fun TopicListPane(
|
||||
activeDenmUseCase: String?,
|
||||
useCaseAlerts: List<UseCaseAlert>,
|
||||
showDenmTrigger: Boolean = true,
|
||||
showCamPinger: Boolean = false,
|
||||
usbSerialState: UsbSerialState = UsbSerialState.DISCONNECTED,
|
||||
camPingerActive: Boolean = false,
|
||||
camPingerSentCount: Int = 0,
|
||||
camSendFailures: Int = 0,
|
||||
espLinkStatus: EspLinkStatus? = null,
|
||||
ownCamPosition: com.hawhamburg.micr0bu.domain.cam.Cam? = null,
|
||||
remoteCamPositions: Map<Long, com.hawhamburg.micr0bu.domain.cam.Cam> = emptyMap(),
|
||||
onSelectTopic: (String) -> Unit,
|
||||
onSendDenm: () -> Unit,
|
||||
onStopDenm: () -> Unit,
|
||||
onStartCamPinger: () -> Unit = {},
|
||||
onStopCamPinger: () -> Unit = {},
|
||||
) {
|
||||
val isConnected = connectionState == MqttConnectionState.CONNECTED
|
||||
var viewMode by rememberSaveable { mutableStateOf(TopicViewMode.LIST) }
|
||||
@@ -256,6 +279,21 @@ private fun TopicListPane(
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── CAM Pinger card — ESP32-C5-only manual bench test, mirrors the DENM card above ──
|
||||
if (showCamPinger) {
|
||||
CamPingerCard(
|
||||
usbConnected = usbSerialState == UsbSerialState.CONNECTED,
|
||||
pingerActive = camPingerActive,
|
||||
sentCount = camPingerSentCount,
|
||||
sendFailures = camSendFailures,
|
||||
linkStatus = espLinkStatus,
|
||||
onStart = onStartCamPinger,
|
||||
onStop = onStopCamPinger,
|
||||
)
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
|
||||
}
|
||||
|
||||
// ── List / Map toggle — the raw topic list stays available either way (Section 13
|
||||
// asks for the map "in addition to", not instead of, the topic list). ──────────────
|
||||
Row(
|
||||
@@ -653,6 +691,141 @@ private fun DenmTxCard(
|
||||
}
|
||||
}
|
||||
|
||||
// ── CAM Pinger card (ESP32-C5 only) ───────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CamPingerCard(
|
||||
usbConnected: Boolean,
|
||||
pingerActive: Boolean,
|
||||
sentCount: Int,
|
||||
sendFailures: Int,
|
||||
linkStatus: EspLinkStatus?,
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (pingerActive) ConnectedGreenBg
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = null,
|
||||
tint = if (pingerActive) ConnectedGreen
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = if (pingerActive) ConnectedGreen
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (pingerActive) {
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_sent_count, sentCount),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ConnectedGreen,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// ── Link diagnostics ──────────────────────────────────────────────
|
||||
// "Sent: 240" is meaningless on its own if all 240 writes failed, or if the ESP32
|
||||
// accepted them and the radio rejected every one. These two lines are the difference
|
||||
// between a bench session that tells you something and one that doesn't.
|
||||
if (sendFailures > 0) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_send_failures, sendFailures),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ErrorRed,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
linkStatus?.let { s ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.mqtt_cam_pinger_fw_counters,
|
||||
s.txFailures, s.oversizeDrops, s.rxCrcErrors,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (s.txFailures > 0 || s.oversizeDrops > 0 || s.rxCrcErrors > 0)
|
||||
ErrorRed else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
if (!usbConnected) {
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_not_connected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (pingerActive) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Circle,
|
||||
contentDescription = null,
|
||||
tint = ConnectedGreen,
|
||||
modifier = Modifier.size(8.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.mqtt_cam_pinger_active),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = ConnectedGreen,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onStop,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF5252)),
|
||||
) {
|
||||
Text(stringResource(R.string.mqtt_stop_pinger))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onStart,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.mqtt_start_pinger))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Direction badge ───────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hawhamburg.micr0bu.R
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
@@ -165,6 +166,8 @@ fun ConnectionSettingsScreen(
|
||||
onMqttPrefsChange: (MqttPrefs) -> Unit,
|
||||
obuHardware: ObuHardware = ObuHardware.CIT_ONE,
|
||||
onObuHardwareChange: (ObuHardware) -> Unit = {},
|
||||
espRxMode: EspRxMode = EspRxMode.SEND_AND_RECEIVE,
|
||||
onEspRxModeChange: (EspRxMode) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
SubScreen(stringResource(R.string.settings_connection), onBack) {
|
||||
@@ -209,6 +212,47 @@ fun ConnectionSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// CAM reception mode — see EspRxMode's KDoc for the caveat that this is purely
|
||||
// an app-side filter, not a physical radio-receiver toggle (the ESP32 must keep
|
||||
// its receiver on for TX to keep working at all).
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val isSendOnly = espRxMode == EspRxMode.SEND_ONLY
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_ONLY) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_only), fontWeight = if (isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEspRxModeChange(EspRxMode.SEND_AND_RECEIVE) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = if (!isSendOnly) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
contentColor = if (!isSendOnly) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(stringResource(R.string.settings_esp32_rx_mode_send_and_receive), fontWeight = if (!isSendOnly) FontWeight.Bold else FontWeight.Normal) }
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.settings_esp32_rx_mode_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,17 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs
|
||||
import com.hawhamburg.micr0bu.data.mqtt.MqttRepository
|
||||
import com.hawhamburg.micr0bu.data.mqtt.ObuHardwarePreferences
|
||||
import com.hawhamburg.micr0bu.data.transport.EspLinkStatus
|
||||
import com.hawhamburg.micr0bu.data.transport.EspRxMode
|
||||
import com.hawhamburg.micr0bu.data.transport.ObuHardware
|
||||
import com.hawhamburg.micr0bu.data.transport.TransportType
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialState
|
||||
import com.hawhamburg.micr0bu.data.transport.UsbSerialTransport
|
||||
import com.hawhamburg.micr0bu.domain.denm.DenmUseCase
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert
|
||||
import com.hawhamburg.micr0bu.domain.usecase.UseCaseType
|
||||
import com.hawhamburg.micr0bu.service.CamPinger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -33,6 +38,8 @@ class MqttViewModel @Inject constructor(
|
||||
private val usbDetector: UsbNetworkDetector,
|
||||
private val camUseCaseRepository: CamUseCaseRepository,
|
||||
private val obuHardwarePrefs: ObuHardwarePreferences,
|
||||
private val usbSerialTransport: UsbSerialTransport,
|
||||
private val camPinger: CamPinger,
|
||||
) : ViewModel() {
|
||||
|
||||
// ── MQTT connection & messages ────────────────────────────────────────────
|
||||
@@ -58,6 +65,19 @@ class MqttViewModel @Inject constructor(
|
||||
viewModelScope.launch { obuHardwarePrefs.setObuHardware(hardware) }
|
||||
}
|
||||
|
||||
/**
|
||||
* ESP32-C5-only: whether received CAM traffic is processed or discarded — see [EspRxMode]'s
|
||||
* KDoc for the important caveat that this doesn't actually disable the ESP32's receiver
|
||||
* (it can't, without also breaking TX).
|
||||
*/
|
||||
val espRxMode: StateFlow<EspRxMode> = obuHardwarePrefs.espRxModeFlow.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly, EspRxMode.SEND_AND_RECEIVE,
|
||||
)
|
||||
|
||||
fun setEspRxMode(mode: EspRxMode) {
|
||||
viewModelScope.launch { obuHardwarePrefs.setEspRxMode(mode) }
|
||||
}
|
||||
|
||||
/** True when a 192.168.42.x USB-C tethering network is detected. */
|
||||
val usbConnected: StateFlow<Boolean> = usbDetector.usbNetwork
|
||||
.map { it != null }
|
||||
@@ -66,6 +86,27 @@ class MqttViewModel @Inject constructor(
|
||||
/** Auto-detected OBU gateway IP on the USB interface. */
|
||||
val detectedObuIp: StateFlow<String?> = usbDetector.detectedGatewayIp
|
||||
|
||||
/** ESP32-C5 USB-serial link state (Phase 03) — see [UsbSerialTransport]. */
|
||||
val usbSerialState: StateFlow<UsbSerialState> = usbSerialTransport.state
|
||||
|
||||
/** Latest firmware heartbeat + drop counters, null until the first STATUS frame arrives. */
|
||||
val espLinkStatus: StateFlow<EspLinkStatus?> = usbSerialTransport.linkStatus
|
||||
|
||||
/** Non-zero means CAMs are being built and dropped — see [UsbSerialTransport.sendCamTx]. */
|
||||
val camSendFailures: StateFlow<Int> = usbSerialTransport.consecutiveWriteFailures
|
||||
|
||||
// ── ESP32-C5 CAM pinger (manual bench test, Phase 03) ─────────────────────
|
||||
// The ESP32-C5-path equivalent of the CiT One's manual DENM trigger below — a fixed-
|
||||
// location 1 Hz CAM ping the user starts/stops from the V2X Monitor screen to verify the
|
||||
// serial link + ESP32 TX/RX radio path independent of GNSS movement or trip recording.
|
||||
// See CamPinger's KDoc.
|
||||
|
||||
val camPingerActive: StateFlow<Boolean> = camPinger.isActive
|
||||
val camPingerSentCount: StateFlow<Int> = camPinger.sentCount
|
||||
|
||||
fun startCamPinger() = camPinger.start()
|
||||
fun stopCamPinger() = camPinger.stop()
|
||||
|
||||
// ── Prefs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
val mqttPrefs: StateFlow<MqttPrefs> = prefs.prefsFlow.stateIn(
|
||||
@@ -158,6 +199,11 @@ class MqttViewModel @Inject constructor(
|
||||
fun connect() = repo.connect()
|
||||
fun disconnect() = repo.disconnect()
|
||||
|
||||
/** Connect/disconnect the ESP32-C5 USB-serial link — separate from [connect]/[disconnect],
|
||||
* which drive the CiT One's MQTT-over-USB-C/Wi-Fi path. See [ConnectionSetupScreen]. */
|
||||
fun connectUsbSerial() = usbSerialTransport.connect()
|
||||
fun disconnectUsbSerial() = usbSerialTransport.disconnect()
|
||||
|
||||
fun selectTopic(topic: String?) { _selectedTopic.value = topic }
|
||||
fun setAutoScroll(enabled: Boolean) { _autoScroll.value = enabled }
|
||||
|
||||
@@ -189,5 +235,12 @@ class MqttViewModel @Inject constructor(
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repo.disconnect()
|
||||
camPinger.stop()
|
||||
// Deliberately NOT usbSerialTransport.disconnect(): the transport is an app-scoped
|
||||
// @Singleton also held by the foreground TripRecordingService (via CamTransmitLoop).
|
||||
// Closing it here would tear the port down when the Activity goes away — e.g. swiping
|
||||
// the app from Recents mid-recording — leaving the still-running service beaconing into
|
||||
// a dead port. The port closes on explicit user Disconnect, on USB detach (handled
|
||||
// inside the transport), or with the process. See UsbSerialTransport's "Ownership" KDoc.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_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_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_scanning">Suche läuft…</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_cit_one">CiT One</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 & 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_transport_usbc">USB-C</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_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 -->
|
||||
<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>
|
||||
|
||||
@@ -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_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_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_scanning">Scanning…</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_cit_one">CiT One</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 & 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_transport_usbc">USB-C</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_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 -->
|
||||
<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>
|
||||
|
||||
+1
-1
Submodule its-g5-receiver-firmware updated: 91d6511fe6...66123b1d67
@@ -5,12 +5,14 @@
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# UPER bytes, for forwarding to the phone over serial_link.
|
||||
idf_component_register(
|
||||
SRCS "main.c" "denm.c" "geonet.c" "dot11p.c" "tx_custom.c" "serial_link.c" "gn_unwrap.c"
|
||||
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
|
||||
)
|
||||
|
||||
@@ -111,7 +111,11 @@ static void tx_radio_task(void *arg)
|
||||
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,
|
||||
BENCH_LATITUDE_TENMICRODEG, BENCH_LONGITUDE_TENMICRODEG,
|
||||
BTP_PORT_CAM, gn_payload, sizeof(gn_payload));
|
||||
@@ -120,7 +124,7 @@ static void tx_radio_task(void *arg)
|
||||
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,
|
||||
sizeof(frame), false);
|
||||
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).
|
||||
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, frame_len, true);
|
||||
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);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "CAM sent (%d bytes) @ %d MHz", frame_len, TX_FREQ_MHZ);
|
||||
|
||||
+111
-41
@@ -1,8 +1,10 @@
|
||||
#include "serial_link.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "driver/uart.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "driver/usb_serial_jtag.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
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;
|
||||
|
||||
// ---- 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) ----
|
||||
// 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
|
||||
@@ -41,28 +66,37 @@ static bool send_frame(uint8_t type, const uint8_t *payload, int len)
|
||||
head[1] = (uint8_t)(len & 0xFF);
|
||||
head[2] = (uint8_t)((len >> 8) & 0xFF);
|
||||
|
||||
uint16_t crc;
|
||||
{
|
||||
// Compute CRC over head+payload without a combined buffer copy: CRC is a running
|
||||
// state, so feed it in two calls worth of bytes by concatenating into a small stack
|
||||
// buffer (payload is capped at SERIAL_LINK_MAX_PAYLOAD, so head+payload comfortably
|
||||
// fits on the stack).
|
||||
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
|
||||
memcpy(crc_buf, head, 3);
|
||||
if (len > 0) memcpy(crc_buf + 3, payload, (size_t)len);
|
||||
crc = crc16_ccitt_false(crc_buf, (size_t)(3 + len));
|
||||
// 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
|
||||
// 4 KB task stack, and send_frame() is called from several tasks. Guarded by s_tx_mutex below
|
||||
// so the shared buffer (and the four-part write) can't interleave between callers.
|
||||
static uint8_t s_crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
|
||||
|
||||
if (s_tx_mutex && xSemaphoreTake(s_tx_mutex, pdMS_TO_TICKS(200)) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "send_frame: tx mutex timeout, dropping frame");
|
||||
return false;
|
||||
}
|
||||
|
||||
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 crc_bytes[2] = {(uint8_t)(crc & 0xFF), (uint8_t)((crc >> 8) & 0xFF)};
|
||||
|
||||
// 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).
|
||||
// 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;
|
||||
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, sync, sizeof(sync));
|
||||
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, head, sizeof(head));
|
||||
if (len > 0) wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, payload, (size_t)len);
|
||||
wrote += uart_write_bytes(SERIAL_LINK_UART_NUM, crc_bytes, sizeof(crc_bytes));
|
||||
wrote += usb_serial_jtag_write_bytes(sync, sizeof(sync), write_timeout);
|
||||
wrote += usb_serial_jtag_write_bytes(head, sizeof(head), write_timeout);
|
||||
if (len > 0) wrote += usb_serial_jtag_write_bytes(payload, (size_t)len, write_timeout);
|
||||
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));
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
|
||||
payload[0] = (uint8_t)rssi;
|
||||
memcpy(payload + 1, cam_uper, (size_t)cam_len);
|
||||
return send_frame(SERIAL_MSG_CAM_RX, payload, 1 + cam_len);
|
||||
// static, not stack (515 bytes at MAX_PAYLOAD 512); only rx_forward_task calls this, and
|
||||
// send_frame's mutex covers the handoff onto the wire.
|
||||
static uint8_t s_cam_rx_payload[SERIAL_LINK_MAX_PAYLOAD];
|
||||
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)
|
||||
{
|
||||
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 ----
|
||||
// Runs in its own task, byte-at-a-time off the UART driver's RX ring buffer (via
|
||||
// uart_read_bytes with a short timeout, not raw ISR access - simplest correct option for a
|
||||
// link this slow/small; revisit if CAM traffic volume ever makes this a bottleneck).
|
||||
// Runs in its own task, byte-at-a-time off the USB Serial/JTAG driver's RX ring buffer (via
|
||||
// usb_serial_jtag_read_bytes with a short timeout, not raw ISR access - simplest correct option
|
||||
// for a link this slow/small; revisit if CAM traffic volume ever makes this a bottleneck).
|
||||
typedef enum {
|
||||
WAIT_SYNC0,
|
||||
WAIT_SYNC1,
|
||||
@@ -106,12 +169,16 @@ static void rx_task(void *arg)
|
||||
uint8_t type = 0;
|
||||
uint16_t len = 0;
|
||||
uint16_t payload_idx = 0;
|
||||
uint8_t payload[SERIAL_LINK_MAX_PAYLOAD];
|
||||
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;
|
||||
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;
|
||||
|
||||
switch (state) {
|
||||
@@ -153,7 +220,6 @@ static void rx_task(void *arg)
|
||||
case WAIT_CRC_HI: {
|
||||
crc_recv |= (uint16_t)byte << 8;
|
||||
|
||||
uint8_t crc_buf[3 + SERIAL_LINK_MAX_PAYLOAD];
|
||||
crc_buf[0] = type;
|
||||
crc_buf[1] = (uint8_t)(len & 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);
|
||||
}
|
||||
} 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;
|
||||
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;
|
||||
|
||||
uart_config_t cfg = {
|
||||
.baud_rate = SERIAL_LINK_BAUD,
|
||||
.data_bits = UART_DATA_8_BITS,
|
||||
.parity = UART_PARITY_DISABLE,
|
||||
.stop_bits = UART_STOP_BITS_1,
|
||||
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
|
||||
.source_clk = UART_SCLK_DEFAULT,
|
||||
s_tx_mutex = xSemaphoreCreateMutex();
|
||||
if (!s_tx_mutex) {
|
||||
// Fail loudly rather than silently running unserialized: interleaved frames would look
|
||||
// like random CRC errors on the phone, which is a miserable thing to debug.
|
||||
ESP_LOGE(TAG, "failed to create tx mutex");
|
||||
abort();
|
||||
}
|
||||
|
||||
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(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));
|
||||
ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&cfg));
|
||||
|
||||
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",
|
||||
SERIAL_LINK_UART_NUM, SERIAL_LINK_TX_GPIO, SERIAL_LINK_RX_GPIO, SERIAL_LINK_BAUD);
|
||||
xTaskCreate(status_task, "serial_link_status", 2560, NULL, 4, NULL);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
// Binary framing for the phone <-> ESP32-C5 link (Phase 03). Deliberately NOT the same UART as
|
||||
// the ESP-IDF console/ESP_LOG output (UART0, see sdkconfig CONFIG_ESP_CONSOLE_UART_NUM=0) -
|
||||
// mixing binary frames with human-readable log text on one wire would corrupt both. This runs
|
||||
// on a dedicated UART (see SERIAL_LINK_UART_NUM / TX / RX pins below - CHANGE THESE to match
|
||||
// your board's actual wiring from the USB-C connector's UART bridge to ESP32-C5 GPIOs).
|
||||
// Binary framing for the phone <-> ESP32-C5 link (Phase 03). This runs over the ESP32-C5's
|
||||
// native USB Serial/JTAG peripheral (driver/usb_serial_jtag.h) - the same physical USB-C port
|
||||
// used for JTAG, exposed to the host as a fixed-VID/PID (0x303A/0x1001) USB CDC-ACM device.
|
||||
// Deliberately NOT the same wire as the ESP-IDF console/ESP_LOG output, which stays on the
|
||||
// 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):
|
||||
// [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
|
||||
// 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.
|
||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: 1-byte heartbeat (0 = ok), sent periodically so
|
||||
// the phone can distinguish "link idle" from "link dead" independent of CAM traffic.
|
||||
// SERIAL_MSG_STATUS (0x03), ESP32 -> phone: heartbeat + counters, sent at 1 Hz so the phone can
|
||||
// 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_RX 0x02
|
||||
#define SERIAL_MSG_STATUS 0x03
|
||||
|
||||
// CHANGE THESE to match your board's actual USB-C -> UART bridge wiring. UART0 is already
|
||||
// claimed by the console/ESP_LOG; picking UART1 here to stay clear of it. These are common
|
||||
// free GPIOs on ESP32-C5 devkits but are NOT guaranteed free on your specific board - check
|
||||
// your schematic before flashing.
|
||||
#define SERIAL_LINK_UART_NUM 1
|
||||
#define SERIAL_LINK_TX_GPIO 4
|
||||
#define SERIAL_LINK_RX_GPIO 5
|
||||
#define SERIAL_LINK_BAUD 115200
|
||||
// USB Serial/JTAG has no baud rate or GPIO pins to configure - it's a fixed on-chip USB device
|
||||
// controller wired directly to the native USB-C port's D+/D- lines in silicon. RX/TX buffer
|
||||
// sizes for usb_serial_jtag_driver_install() (see serial_link.c) are sized generously relative
|
||||
// to SERIAL_LINK_MAX_PAYLOAD below.
|
||||
#define SERIAL_LINK_USB_BUF_SIZE 1024
|
||||
|
||||
// Max CAM payload this link will carry. cam.c sizes its own encode buffer at 96 bytes; 160
|
||||
// gives headroom for the RX path's extra station_id+rssi prefix plus margin.
|
||||
#define SERIAL_LINK_MAX_PAYLOAD 160
|
||||
// Max CAM payload this link will carry. MUST match SERIAL_LINK_MAX_PAYLOAD in the app's
|
||||
// SerialFrame.kt - a mismatch means every frame above the smaller of the two is rejected by that
|
||||
// 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,
|
||||
// after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's context - keep it fast,
|
||||
// it blocks the next frame's parsing) whenever a complete, checksummed SERIAL_MSG_CAM_TX frame
|
||||
// arrives from the phone.
|
||||
// Initializes the USB Serial/JTAG driver and its background RX-framing and 1 Hz heartbeat tasks.
|
||||
// Call once from app_main, after nvs/event loop init. `on_cam_tx` is invoked (from the RX task's
|
||||
// context - keep it fast, it blocks the next frame's parsing) whenever a complete, checksummed
|
||||
// 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);
|
||||
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).
|
||||
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);
|
||||
|
||||
// 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
|
||||
|
||||
+2104
-424
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user