Skip to content

Commit 65abf4e

Browse files
eriedclaude
andcommitted
Service Mode: wheel-family pickers in Commands and Inspect tabs
WheelAdapter gains familyDisplayName + inspectMessageTypes() so each adapter declares what it contributes to Service Mode. CompositeWheelAdapter exposes allFamilies so the ViewModel can browse every family's catalogue without owning the wiring itself. Inspect tab: family dropdown + message-type dropdown side-by-side. Families with no inspect prefixes are filtered out. InMotion V2 currently contributes V14 realtime / P6 realtime / P6 detailed. Commands tab: family dropdown at the top. Empty families show "(empty)" in the picker; selecting one shows a "no commands defined yet — use Raw" message. As we author each family's catalogue (in follow-up commits), the empty markers go away automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 07c30df commit 65abf4e

10 files changed

Lines changed: 220 additions & 70 deletions

app/src/main/java/com/eried/eucplanet/ble/BegodeAdapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import javax.inject.Singleton
2525
@Singleton
2626
class BegodeAdapter @Inject constructor() : WheelAdapter {
2727
override val familyId = "begode"
28+
override val familyDisplayName = "Begode / Gotway"
2829
override val capabilities = WheelCapabilities.BEGODE
2930

3031
@Volatile private var detectedModel: BegodeModel? = null

app/src/main/java/com/eried/eucplanet/ble/CompositeWheelAdapter.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ class CompositeWheelAdapter @Inject constructor(
2929

3030
@Volatile private var active: WheelAdapter = inmotion
3131

32+
/**
33+
* Every wheel-family adapter the Composite knows about, in the order
34+
* they should appear in the Service Mode wheel-family picker. Exposed
35+
* here so the diagnostics ViewModel can browse every family's command
36+
* catalogue / inspect prefixes without owning its own wiring.
37+
*/
38+
val allFamilies: List<WheelAdapter> = listOf(
39+
inmotion, kingsong, veteran, begode, ninebot, inmotionV1
40+
)
41+
3242
override val familyId: String get() = active.familyId
3343
override val capabilities: WheelCapabilities get() = active.capabilities
3444

app/src/main/java/com/eried/eucplanet/ble/InMotionV1Adapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import javax.inject.Singleton
2323
class InMotionV1Adapter @Inject constructor() : WheelAdapter {
2424

2525
override val familyId = "inmotion_v1"
26+
override val familyDisplayName = "InMotion V1 / V3 / V5 / V8"
2627
override val capabilities = WheelCapabilities.INMOTION_V1
2728

2829
@Volatile private var detectedModel: InMotionV1Model? = null

app/src/main/java/com/eried/eucplanet/ble/InMotionV2Adapter.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import javax.inject.Singleton
2121
class InMotionV2Adapter @Inject constructor() : WheelAdapter {
2222

2323
override val familyId: String = "inmotion_v2"
24+
override val familyDisplayName: String = "InMotion V14 / V12 / P6"
2425
override val capabilities: WheelCapabilities = WheelCapabilities.INMOTION_V2
2526

27+
override fun inspectMessageTypes(): List<String> =
28+
listOf("V14 realtime", "P6 realtime", "P6 detailed")
29+
2630
/**
2731
* Detected model from the wheel's MainInfo response. Set the first time
2832
* [decode] sees a CarType packet on each connection.

app/src/main/java/com/eried/eucplanet/ble/KingsongAdapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import javax.inject.Singleton
1919
@Singleton
2020
class KingsongAdapter @Inject constructor() : WheelAdapter {
2121
override val familyId = "kingsong"
22+
override val familyDisplayName = "KingSong"
2223
override val capabilities = WheelCapabilities.KINGSONG
2324

2425
@Volatile private var detectedModel: KingsongModel? = null

app/src/main/java/com/eried/eucplanet/ble/NinebotAdapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class NinebotAdapter @Inject constructor() : WheelAdapter {
6868
@Volatile private var settingsCursor: Int = 0
6969

7070
override val familyId = "ninebot"
71+
override val familyDisplayName = "Ninebot"
7172
override val capabilities: WheelCapabilities
7273
get() = if (activeProtocol == NinebotProtocol.Z)
7374
WheelCapabilities.NINEBOT_Z

app/src/main/java/com/eried/eucplanet/ble/VeteranAdapter.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import javax.inject.Singleton
2929
@Singleton
3030
class VeteranAdapter @Inject constructor() : WheelAdapter {
3131
override val familyId = "veteran"
32+
override val familyDisplayName = "Veteran"
3233
override val capabilities = WheelCapabilities.VETERAN
3334

3435
@Volatile private var detectedModel: VeteranModel? = null

app/src/main/java/com/eried/eucplanet/ble/WheelAdapter.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ interface WheelAdapter {
183183
* the dialog; adapters override when they have hypotheses to test.
184184
*/
185185
fun getDiagnosticCommands(): List<com.eried.eucplanet.diagnostics.DiagnosticCommand> = emptyList()
186+
187+
/**
188+
* Friendly name for the wheel family. Used in Service Mode's wheel-family
189+
* pickers so the user can browse any family's catalogue regardless of
190+
* what's actually connected. Defaults to [familyId].
191+
*/
192+
val familyDisplayName: String get() = familyId
193+
194+
/**
195+
* Service Mode "Inspect" tab subscribes to NOTE entries whose text starts
196+
* with one of these prefixes. Adapters that log realtime / detail bodies
197+
* via DiagnosticsLogger.note() should list those prefixes here so the
198+
* picker offers them. Default empty.
199+
*/
200+
fun inspectMessageTypes(): List<String> = emptyList()
186201
}
187202

188203
/**

app/src/main/java/com/eried/eucplanet/diagnostics/WheelDiagnosticsDialog.kt

Lines changed: 150 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -436,61 +436,101 @@ private fun LogRow(e: DiagnosticsLogger.Entry) {
436436

437437
@Composable
438438
private fun CommandsTab(vm: WheelDiagnosticsViewModel) {
439-
// Re-fetch when the wheel reports its model — without this re-key, opening
440-
// Service Mode before connecting freezes the list at empty even after the
441-
// P6 identifies itself.
439+
// Wheel-family picker at the top so the catalogue is browsable
440+
// regardless of what's actually connected — useful when the user wants
441+
// to research a different family than the one they're paired with.
442442
val model by vm.modelName.collectAsState()
443-
val cmds = remember(model) { vm.diagnosticCommands() }
444-
if (cmds.isEmpty()) {
445-
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
446-
Text(
447-
if (model == null)
448-
"Connect to a wheel to see its diagnostic commands.\nMeanwhile use the Raw tab to send arbitrary bytes."
449-
else
450-
"No diagnostic commands defined for ${model}.\nUse the Raw tab to send arbitrary bytes.",
451-
style = MaterialTheme.typography.bodyMedium,
452-
color = MaterialTheme.colorScheme.onSurfaceVariant
453-
)
443+
val families = remember(model) { vm.allWheelFamilies() }
444+
var selectedFamily by remember(families) { mutableStateOf(families.firstOrNull()) }
445+
var familyMenuExpanded by remember { mutableStateOf(false) }
446+
val cmds = selectedFamily?.commands ?: emptyList()
447+
448+
Column(modifier = Modifier.fillMaxSize()) {
449+
Box(modifier = Modifier.padding(top = 8.dp, start = 4.dp)) {
450+
OutlinedButton(onClick = { familyMenuExpanded = true }) {
451+
Text(selectedFamily?.displayName ?: "(no families)")
452+
Spacer(Modifier.width(6.dp))
453+
Icon(
454+
imageVector = Icons.Default.KeyboardArrowDown,
455+
contentDescription = null
456+
)
457+
}
458+
androidx.compose.material3.DropdownMenu(
459+
expanded = familyMenuExpanded,
460+
onDismissRequest = { familyMenuExpanded = false }
461+
) {
462+
families.forEach { f ->
463+
androidx.compose.material3.DropdownMenuItem(
464+
text = {
465+
Row(verticalAlignment = Alignment.CenterVertically) {
466+
Text(f.displayName)
467+
if (f.commands.isEmpty()) {
468+
Spacer(Modifier.width(6.dp))
469+
Text(
470+
"(empty)",
471+
style = MaterialTheme.typography.labelSmall,
472+
color = MaterialTheme.colorScheme.onSurfaceVariant
473+
)
474+
}
475+
}
476+
},
477+
onClick = { selectedFamily = f; familyMenuExpanded = false }
478+
)
479+
}
480+
}
454481
}
455-
return
456-
}
457-
458-
val grouped = remember(cmds) { cmds.groupBy { it.category } }
459482

460-
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 8.dp)) {
461-
grouped.forEach { (category, list) ->
462-
item {
483+
if (cmds.isEmpty()) {
484+
Box(
485+
modifier = Modifier.fillMaxSize(),
486+
contentAlignment = Alignment.Center
487+
) {
463488
Text(
464-
category.name,
465-
style = MaterialTheme.typography.labelMedium,
466-
color = MaterialTheme.colorScheme.primary,
467-
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)
489+
"No diagnostic commands defined for ${selectedFamily?.displayName ?: "this family"} yet.\nUse the Raw tab to send arbitrary bytes.",
490+
style = MaterialTheme.typography.bodyMedium,
491+
color = MaterialTheme.colorScheme.onSurfaceVariant
468492
)
469493
}
470-
item {
471-
LazyVerticalGrid(
472-
columns = GridCells.Adaptive(110.dp),
473-
contentPadding = PaddingValues(2.dp),
474-
horizontalArrangement = Arrangement.spacedBy(6.dp),
475-
verticalArrangement = Arrangement.spacedBy(6.dp),
476-
modifier = Modifier
477-
.fillMaxWidth()
478-
.heightIn(max = (((list.size + 2) / 3) * 64 + 16).dp)
479-
) {
480-
items(list) { cmd ->
481-
OutlinedButton(
482-
onClick = { vm.fireCommand(cmd) },
483-
modifier = Modifier.fillMaxWidth()
484-
) {
485-
Column {
486-
Text(cmd.label, style = MaterialTheme.typography.labelLarge)
487-
Text(
488-
cmd.description,
489-
style = MaterialTheme.typography.labelSmall,
490-
color = MaterialTheme.colorScheme.onSurfaceVariant,
491-
maxLines = 2,
492-
overflow = TextOverflow.Ellipsis
493-
)
494+
return@Column
495+
}
496+
497+
val grouped = remember(cmds) { cmds.groupBy { it.category } }
498+
499+
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 8.dp)) {
500+
grouped.forEach { (category, list) ->
501+
item {
502+
Text(
503+
category.name,
504+
style = MaterialTheme.typography.labelMedium,
505+
color = MaterialTheme.colorScheme.primary,
506+
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)
507+
)
508+
}
509+
item {
510+
LazyVerticalGrid(
511+
columns = GridCells.Adaptive(110.dp),
512+
contentPadding = PaddingValues(2.dp),
513+
horizontalArrangement = Arrangement.spacedBy(6.dp),
514+
verticalArrangement = Arrangement.spacedBy(6.dp),
515+
modifier = Modifier
516+
.fillMaxWidth()
517+
.heightIn(max = (((list.size + 2) / 3) * 64 + 16).dp)
518+
) {
519+
items(list) { cmd ->
520+
OutlinedButton(
521+
onClick = { vm.fireCommand(cmd) },
522+
modifier = Modifier.fillMaxWidth()
523+
) {
524+
Column {
525+
Text(cmd.label, style = MaterialTheme.typography.labelLarge)
526+
Text(
527+
cmd.description,
528+
style = MaterialTheme.typography.labelSmall,
529+
color = MaterialTheme.colorScheme.onSurfaceVariant,
530+
maxLines = 2,
531+
overflow = TextOverflow.Ellipsis
532+
)
533+
}
494534
}
495535
}
496536
}
@@ -511,9 +551,25 @@ private fun CommandsTab(vm: WheelDiagnosticsViewModel) {
511551
@Composable
512552
private fun InspectTab(vm: WheelDiagnosticsViewModel) {
513553
val entries by vm.entries.collectAsState()
514-
val types = vm.inspectMessageTypes
515-
var selected by remember { mutableStateOf(types.first()) }
554+
// Folderized: pick a wheel family first, then a message type within it.
555+
// Families with no inspect prefixes are filtered out so the picker only
556+
// shows actionable rows.
557+
val families = remember { vm.allWheelFamilies().filter { it.inspectPrefixes.isNotEmpty() } }
558+
var selectedFamily by remember { mutableStateOf(families.firstOrNull()) }
559+
val types = selectedFamily?.inspectPrefixes ?: emptyList()
560+
var selected by remember(selectedFamily) {
561+
mutableStateOf(types.firstOrNull() ?: "")
562+
}
563+
var familyMenuExpanded by remember { mutableStateOf(false) }
516564
var menuExpanded by remember { mutableStateOf(false) }
565+
if (selectedFamily == null) {
566+
Text(
567+
"No wheel families publish realtime traces yet.",
568+
style = MaterialTheme.typography.bodySmall,
569+
modifier = Modifier.padding(16.dp)
570+
)
571+
return
572+
}
517573

518574
val latestBytes: List<Int> = remember(entries, selected) {
519575
val match = entries.lastOrNull {
@@ -525,27 +581,52 @@ private fun InspectTab(vm: WheelDiagnosticsViewModel) {
525581
}
526582

527583
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
528-
// Type picker. Just one or two options today, so a tiny dropdown is
529-
// sufficient — no need for the heavier SimpleDropdown helper.
530-
Box {
531-
OutlinedButton(onClick = { menuExpanded = true }) {
532-
Text(selected)
533-
Spacer(Modifier.width(6.dp))
534-
Icon(
535-
imageVector = Icons.Default.KeyboardArrowDown,
536-
contentDescription = null
537-
)
584+
// Two dropdowns side-by-side: pick a wheel family, then pick a
585+
// message type within it. Families with multiple prefixes show all
586+
// their options; families with one prefix auto-pick it.
587+
Row(verticalAlignment = Alignment.CenterVertically) {
588+
Box {
589+
OutlinedButton(onClick = { familyMenuExpanded = true }) {
590+
Text(selectedFamily?.displayName ?: "")
591+
Spacer(Modifier.width(6.dp))
592+
Icon(
593+
imageVector = Icons.Default.KeyboardArrowDown,
594+
contentDescription = null
595+
)
596+
}
597+
androidx.compose.material3.DropdownMenu(
598+
expanded = familyMenuExpanded,
599+
onDismissRequest = { familyMenuExpanded = false }
600+
) {
601+
families.forEach { f ->
602+
androidx.compose.material3.DropdownMenuItem(
603+
text = { Text(f.displayName) },
604+
onClick = { selectedFamily = f; familyMenuExpanded = false }
605+
)
606+
}
607+
}
538608
}
539-
androidx.compose.material3.DropdownMenu(
540-
expanded = menuExpanded,
541-
onDismissRequest = { menuExpanded = false }
542-
) {
543-
types.forEach { t ->
544-
androidx.compose.material3.DropdownMenuItem(
545-
text = { Text(t) },
546-
onClick = { selected = t; menuExpanded = false }
609+
Spacer(Modifier.width(8.dp))
610+
Box {
611+
OutlinedButton(onClick = { menuExpanded = true }) {
612+
Text(selected.ifEmpty { "(message)" })
613+
Spacer(Modifier.width(6.dp))
614+
Icon(
615+
imageVector = Icons.Default.KeyboardArrowDown,
616+
contentDescription = null
547617
)
548618
}
619+
androidx.compose.material3.DropdownMenu(
620+
expanded = menuExpanded,
621+
onDismissRequest = { menuExpanded = false }
622+
) {
623+
types.forEach { t ->
624+
androidx.compose.material3.DropdownMenuItem(
625+
text = { Text(t) },
626+
onClick = { selected = t; menuExpanded = false }
627+
)
628+
}
629+
}
549630
}
550631
}
551632

app/src/main/java/com/eried/eucplanet/diagnostics/WheelDiagnosticsViewModel.kt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,35 @@ class WheelDiagnosticsViewModel @Inject constructor(
159159

160160
fun diagnosticCommands(): List<DiagnosticCommand> = wheelAdapter.getDiagnosticCommands()
161161

162+
/**
163+
* Service Mode is research-grade: a user with a V14 in front of them
164+
* still benefits from being able to browse the KingSong or Veteran
165+
* command catalogue. We expose every family's catalogue keyed by its
166+
* display name so the picker UI can show them all, regardless of what's
167+
* actually connected.
168+
*/
169+
data class WheelFamily(
170+
val displayName: String,
171+
val commands: List<DiagnosticCommand>,
172+
val inspectPrefixes: List<String>
173+
)
174+
175+
fun allWheelFamilies(): List<WheelFamily> {
176+
val composite = wheelAdapter as? com.eried.eucplanet.ble.CompositeWheelAdapter
177+
?: return listOf(WheelFamily(
178+
displayName = wheelAdapter.familyDisplayName,
179+
commands = wheelAdapter.getDiagnosticCommands(),
180+
inspectPrefixes = wheelAdapter.inspectMessageTypes()
181+
))
182+
return composite.allFamilies.map {
183+
WheelFamily(
184+
displayName = it.familyDisplayName,
185+
commands = it.getDiagnosticCommands(),
186+
inspectPrefixes = it.inspectMessageTypes()
187+
)
188+
}
189+
}
190+
162191
fun fireCommand(cmd: DiagnosticCommand) {
163192
DiagnosticsLogger.cmd(cmd.label, cmd.bytes)
164193
bleManager.writeCommand(cmd.bytes)
@@ -264,7 +293,13 @@ class WheelDiagnosticsViewModel @Inject constructor(
264293

265294
/** Message-type prefixes the Inspect tab can subscribe to. The strings
266295
* must match the prefix the adapter writes via DiagnosticsLogger.note. */
267-
val inspectMessageTypes: List<String> = listOf("V14 realtime", "P6 realtime", "P6 detailed")
296+
/**
297+
* Flat list of inspect prefixes across all wheel families. Kept for
298+
* back-compat with the existing single-list dropdown until the UI
299+
* fully migrates to the per-family picker via [allWheelFamilies].
300+
*/
301+
val inspectMessageTypes: List<String>
302+
get() = allWheelFamilies().flatMap { it.inspectPrefixes }
268303

269304
/**
270305
* Categories the user can opt into when attaching extra context to the

0 commit comments

Comments
 (0)