Skip to content

Commit 7bd8ade

Browse files
eriedclaude
andcommitted
Service Mode: Inspect tab, Attach picker, /euc/watch_info
- New Inspect tab between Commands and Raw: live byte-grid view of the most recent realtime frame for V14, P6 realtime, and P6 detailed message types. Tap a byte to log a structured comment with its offset and value (helps research unknown bytes like motor temp). - Comment row gains an attach icon next to Send, opening a dialog where the user can opt-in attach App config / Wheel state / Trips summary / Installed apps / Recent crashes as NOTE entries. - /euc/watch_info: watch sends Build + BuildConfig info to the phone on launch so the Service Mode log captures both sides of the pair. - V14 realtime body now logged as NOTE so the Inspect tab works for the V14 family the same way it does for P6. Also folds in the wheel_report.yml YAML quoting fix already on main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 544d0f3 commit 7bd8ade

9 files changed

Lines changed: 415 additions & 6 deletions

File tree

.github/ISSUE_TEMPLATE/wheel_report.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: Wheel report
2-
description: Report behavior on your wheel: works, broken, or partially working
2+
description: "Report behavior on your wheel: works, broken, or partially working"
33
title: "[Wheel report] "
44
labels: ["wheel-report"]
55
body:

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,16 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
327327
private fun decode(command: Byte, data: ByteArray): DecodeResult {
328328
return when (command.toInt() and 0x7F) {
329329
0x02 -> decodeMainInfoOrAuth(data)
330-
0x04 -> parseTelemetryForModel(data)?.let { DecodeResult.Telemetry(it) } ?: DecodeResult.Unknown
330+
0x04 -> {
331+
// Log the realtime body so the Service Mode Inspect tab can
332+
// surface it for V14-family models the same way it does the
333+
// P6 extended-routing 0x07 path. Same `<type> len=N body=...`
334+
// format both sides parse.
335+
com.eried.eucplanet.diagnostics.DiagnosticsLogger.note(
336+
"V14 realtime len=${data.size} body=${data.joinToString(" ") { "%02x".format(it) }}"
337+
)
338+
parseTelemetryForModel(data)?.let { DecodeResult.Telemetry(it) } ?: DecodeResult.Unknown
339+
}
331340
0x11 -> InMotionV2Parser.parseTotalStats(data)?.let { DecodeResult.TotalDistance(it.totalDistanceKm) } ?: DecodeResult.Unknown
332341
0x20 -> parseSettingsForModel(data)?.let { DecodeResult.Settings(it) } ?: DecodeResult.Unknown
333342
0x21 -> decodeP6Extended(data)

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

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import androidx.compose.animation.AnimatedVisibility
4242
import androidx.compose.material.icons.Icons
4343
import androidx.compose.material.icons.automirrored.filled.Redo
4444
import androidx.compose.material.icons.automirrored.filled.Undo
45+
import androidx.compose.material.icons.filled.AttachFile
4546
import androidx.compose.material.icons.filled.Backspace
4647
import androidx.compose.material.icons.filled.Close
4748
import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -53,6 +54,7 @@ import androidx.compose.material3.HorizontalDivider
5354
import androidx.compose.material3.AssistChip
5455
import androidx.compose.material3.AssistChipDefaults
5556
import androidx.compose.material3.Button
57+
import androidx.compose.material3.Checkbox
5658
import androidx.compose.material3.SegmentedButton
5759
import androidx.compose.material3.SegmentedButtonDefaults
5860
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -72,6 +74,7 @@ import androidx.compose.runtime.Composable
7274
import androidx.compose.runtime.LaunchedEffect
7375
import androidx.compose.runtime.collectAsState
7476
import androidx.compose.runtime.getValue
77+
import androidx.compose.runtime.mutableStateMapOf
7578
import androidx.compose.runtime.mutableStateOf
7679
import androidx.compose.runtime.remember
7780
import androidx.compose.runtime.setValue
@@ -162,13 +165,16 @@ fun WheelDiagnosticsDialog(
162165
Tab(selected = tab == 0, onClick = { tab = 0 },
163166
text = { Text("Commands") })
164167
Tab(selected = tab == 1, onClick = { tab = 1 },
168+
text = { Text("Inspect") })
169+
Tab(selected = tab == 2, onClick = { tab = 2 },
165170
text = { Text("Raw") })
166171
}
167172

168173
Box(modifier = Modifier.weight(2f, fill = true)) {
169174
when (tab) {
170175
0 -> CommandsTab(vm)
171-
1 -> RawTab(vm)
176+
1 -> InspectTab(vm)
177+
2 -> RawTab(vm)
172178
}
173179
}
174180
}
@@ -264,6 +270,7 @@ private fun LogPanel(modifier: Modifier = Modifier) {
264270
@Composable
265271
private fun CommentRow(vm: WheelDiagnosticsViewModel) {
266272
var comment by remember { mutableStateOf("") }
273+
var showAttachDialog by remember { mutableStateOf(false) }
267274
Row(
268275
modifier = Modifier.fillMaxWidth(),
269276
verticalAlignment = Alignment.CenterVertically
@@ -290,8 +297,78 @@ private fun CommentRow(vm: WheelDiagnosticsViewModel) {
290297
keyboardActions = KeyboardActions(onSend = { send() }),
291298
singleLine = true
292299
)
300+
IconButton(onClick = { showAttachDialog = true }) {
301+
Icon(Icons.Default.AttachFile, contentDescription = "Attach data")
302+
}
293303
IconButton(onClick = { send() }) { Icon(Icons.Default.Send, contentDescription = "Add") }
294304
}
305+
if (showAttachDialog) {
306+
AttachDataDialog(
307+
onDismiss = { showAttachDialog = false },
308+
onSubmit = { selected ->
309+
vm.attach(selected)
310+
showAttachDialog = false
311+
}
312+
)
313+
}
314+
}
315+
316+
/**
317+
* Opt-in attach picker. Each box maps to a [WheelDiagnosticsViewModel.AttachCategory];
318+
* confirming dumps the selected categories into the live log as NOTE entries.
319+
* Nothing leaves the device until the user shares the log themselves.
320+
*/
321+
@Composable
322+
private fun AttachDataDialog(
323+
onDismiss: () -> Unit,
324+
onSubmit: (Set<WheelDiagnosticsViewModel.AttachCategory>) -> Unit
325+
) {
326+
val selected = remember {
327+
mutableStateMapOf<WheelDiagnosticsViewModel.AttachCategory, Boolean>().apply {
328+
WheelDiagnosticsViewModel.AttachCategory.entries.forEach { put(it, false) }
329+
}
330+
}
331+
AlertDialog(
332+
onDismissRequest = onDismiss,
333+
title = { Text("Attach data to log") },
334+
text = {
335+
Column {
336+
Text(
337+
"Picks below are dumped into THIS log only. Nothing is sent until you share the log yourself.",
338+
style = MaterialTheme.typography.bodySmall,
339+
color = MaterialTheme.colorScheme.onSurfaceVariant
340+
)
341+
Spacer(Modifier.height(8.dp))
342+
WheelDiagnosticsViewModel.AttachCategory.entries.forEach { cat ->
343+
Row(
344+
modifier = Modifier
345+
.fillMaxWidth()
346+
.clickable { selected[cat] = !(selected[cat] ?: false) }
347+
.padding(vertical = 4.dp),
348+
verticalAlignment = Alignment.CenterVertically
349+
) {
350+
Checkbox(
351+
checked = selected[cat] ?: false,
352+
onCheckedChange = { selected[cat] = it }
353+
)
354+
Text(cat.label, style = MaterialTheme.typography.bodyMedium)
355+
}
356+
}
357+
}
358+
},
359+
confirmButton = {
360+
val any = selected.values.any { it }
361+
TextButton(
362+
enabled = any,
363+
onClick = {
364+
onSubmit(selected.filterValues { it }.keys)
365+
}
366+
) { Text("Attach") }
367+
},
368+
dismissButton = {
369+
TextButton(onClick = onDismiss) { Text("Cancel") }
370+
}
371+
)
295372
}
296373

297374
@Composable
@@ -423,6 +500,129 @@ private fun CommandsTab(vm: WheelDiagnosticsViewModel) {
423500
}
424501
}
425502

503+
/**
504+
* Live byte interpreter. Picks the most recent NOTE entry whose text starts
505+
* with the selected message type prefix and renders every byte as a small
506+
* tappable cell showing offset, hex, and decimal. Tapping a cell drops a
507+
* structured COMMENT into the log so the user can correlate the byte they
508+
* just clicked with whatever value the wheel's own UI is showing — useful
509+
* for finding unknown offsets like motor temp.
510+
*/
511+
@Composable
512+
private fun InspectTab(vm: WheelDiagnosticsViewModel) {
513+
val entries by vm.entries.collectAsState()
514+
val types = vm.inspectMessageTypes
515+
var selected by remember { mutableStateOf(types.first()) }
516+
var menuExpanded by remember { mutableStateOf(false) }
517+
518+
val latestBytes: List<Int> = remember(entries, selected) {
519+
val match = entries.lastOrNull {
520+
it.kind == DiagnosticsLogger.Kind.NOTE && it.text.startsWith("$selected len=")
521+
} ?: return@remember emptyList()
522+
match.text.substringAfter("body=", "").trim()
523+
.split(' ')
524+
.mapNotNull { runCatching { it.toInt(16) }.getOrNull() }
525+
}
526+
527+
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+
)
538+
}
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 }
547+
)
548+
}
549+
}
550+
}
551+
552+
Spacer(Modifier.height(6.dp))
553+
Text(
554+
"Tap a byte to log a comment with its offset and value.",
555+
style = MaterialTheme.typography.bodySmall,
556+
color = MaterialTheme.colorScheme.onSurfaceVariant
557+
)
558+
Spacer(Modifier.height(8.dp))
559+
560+
if (latestBytes.isEmpty()) {
561+
Text(
562+
"No \"$selected\" frames in the log yet. Connect the wheel and wait for one to arrive.",
563+
style = MaterialTheme.typography.bodySmall
564+
)
565+
return@Column
566+
}
567+
568+
// 6-column grid of byte cells. Lazy so 96-byte bodies don't slow
569+
// down recomposition when the underlying frame stream is at 5 Hz.
570+
LazyVerticalGrid(
571+
columns = GridCells.Fixed(6),
572+
modifier = Modifier.fillMaxSize()
573+
) {
574+
items(latestBytes.size) { off ->
575+
val v = latestBytes[off]
576+
ByteInspectCell(off, v) {
577+
vm.logByteInspection(selected, off, v)
578+
}
579+
}
580+
}
581+
}
582+
}
583+
584+
@Composable
585+
private fun ByteInspectCell(offset: Int, value: Int, onTap: () -> Unit) {
586+
val hex = remember(value) { "%02x".format(value) }
587+
Column(
588+
modifier = Modifier
589+
.padding(2.dp)
590+
.border(
591+
1.dp,
592+
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
593+
RoundedCornerShape(4.dp)
594+
)
595+
.clickable(onClick = onTap)
596+
.padding(4.dp),
597+
horizontalAlignment = Alignment.CenterHorizontally
598+
) {
599+
Text(
600+
"[$offset]",
601+
style = MaterialTheme.typography.labelSmall.copy(
602+
fontFamily = FontFamily.Monospace,
603+
fontSize = 9.sp
604+
),
605+
color = MaterialTheme.colorScheme.onSurfaceVariant
606+
)
607+
Text(
608+
hex,
609+
style = MaterialTheme.typography.labelMedium.copy(
610+
fontFamily = FontFamily.Monospace,
611+
fontSize = 12.sp
612+
),
613+
color = MaterialTheme.colorScheme.primary
614+
)
615+
Text(
616+
"$value",
617+
style = MaterialTheme.typography.labelSmall.copy(
618+
fontFamily = FontFamily.Monospace,
619+
fontSize = 9.sp
620+
),
621+
color = MaterialTheme.colorScheme.onSurfaceVariant
622+
)
623+
}
624+
}
625+
426626
@Composable
427627
private fun RawTab(vm: WheelDiagnosticsViewModel) {
428628
var raw by remember { mutableStateOf("") }

0 commit comments

Comments
 (0)