Skip to content

Commit 3515c82

Browse files
eriedclaude
andcommitted
Service Mode: Raw presets per family + WRAP_KINGSONG + cleaner inspect labels
Raw tab "Insert preset" gains a family dropdown. Picking a family fills the chip row from that family's DiagnosticCommand catalogue, so the single source of truth holds and authoring a family lights up both Commands and Raw at once. Tap a chip to seed the input box (vs the Commands tab which fires immediately). WrapMode adds WRAP_KINGSONG: takes the user's bytes as `<type-hex> <up-to-14 payload>` and emits the 20-byte `aa 55 [payload pad to 14] [type] 14 5a 5a` frame. KingsongCommands.wrapArbitrary() exposes the otherwise-private frame() builder for this. Inspect tab message-type dropdown now strips the family-name prefix from each label ("KingSong realtime" -> "Realtime", "InMotion V1 slow-info" -> "Slow-info") since the family is already shown in the picker to the left. Single-option families now render the dropdown disabled rather than hiding it, for visual consistency across families. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 563505a commit 3515c82

3 files changed

Lines changed: 147 additions & 68 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,25 @@ object KingsongCommands {
3434
const val BMS1_SERIAL_REQ: Byte = 0xE1.toByte()
3535
}
3636

37+
/**
38+
* Public wrap entry point for Service Mode's Raw tab. The user types
39+
* `<type-hex> <up-to-14 bytes of payload>`; we emit the 20-byte frame
40+
* with the standard header / trailer. Payload longer than 14 bytes is
41+
* truncated to fit (the wheel only ever cares about the first ~14
42+
* bytes anyway).
43+
*/
44+
fun wrapArbitrary(typeAndPayload: ByteArray): ByteArray {
45+
if (typeAndPayload.isEmpty()) return ByteArray(0)
46+
val type = typeAndPayload[0]
47+
val payload = if (typeAndPayload.size > 1) typeAndPayload.copyOfRange(1, typeAndPayload.size) else byteArrayOf()
48+
return frame(type) { f ->
49+
val n = minOf(payload.size, 14) // bytes 2..15 are payload (14 slots)
50+
for (i in 0 until n) {
51+
f[2 + i] = payload[i]
52+
}
53+
}
54+
}
55+
3756
/**
3857
* Build a 20-byte frame with the given type byte. Payload bytes default
3958
* to zero; callers fill in the type-specific slots before sending.

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

Lines changed: 110 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import androidx.compose.runtime.getValue
7777
import androidx.compose.runtime.mutableStateMapOf
7878
import androidx.compose.runtime.mutableStateOf
7979
import androidx.compose.runtime.remember
80+
import androidx.compose.runtime.saveable.rememberSaveable
8081
import androidx.compose.runtime.setValue
8182
import androidx.compose.ui.Alignment
8283
import androidx.compose.ui.Modifier
@@ -565,6 +566,30 @@ private fun CommandsTab(vm: WheelDiagnosticsViewModel) {
565566
}
566567
}
567568

569+
/**
570+
* Strip the family-display-name prefix from an inspect message-type string
571+
* so the dropdown label reads tightly. The family is already named in the
572+
* picker to the left, so "KingSong realtime" becomes "Realtime",
573+
* "InMotion V1 slow-info" becomes "Slow-info", and so on. Multi-prefix
574+
* families that don't share a common stem (InMotion V2: "V14 realtime",
575+
* "P6 realtime", "P6 detailed") fall through and render unchanged.
576+
*/
577+
private fun shortInspectLabel(prefix: String, familyDisplayName: String): String {
578+
val candidates = listOf(
579+
familyDisplayName,
580+
familyDisplayName.split(" / ").first(),
581+
familyDisplayName.split(" ").take(2).joinToString(" "),
582+
familyDisplayName.split(" ").first()
583+
).filter { it.isNotBlank() }.distinct()
584+
for (c in candidates) {
585+
val trimmed = prefix.removePrefix(c).trimStart()
586+
if (trimmed != prefix && trimmed.isNotEmpty()) {
587+
return trimmed.replaceFirstChar { it.uppercase() }
588+
}
589+
}
590+
return prefix.replaceFirstChar { it.uppercase() }
591+
}
592+
568593
/**
569594
* Live byte interpreter. Picks the most recent NOTE entry whose text starts
570595
* with the selected message type prefix and renders every byte as a small
@@ -632,38 +657,36 @@ private fun InspectTab(vm: WheelDiagnosticsViewModel) {
632657
}
633658
}
634659
Spacer(Modifier.width(8.dp))
635-
// Single-prefix families (KingSong, Veteran, Begode, Ninebot, V1)
636-
// just say "Realtime" — they only have one stream so the second
637-
// dropdown would be a single-item menu. Multi-prefix families
638-
// (InMotion V2: V14 / P6 realtime / P6 detailed) keep the picker.
639-
if (types.size > 1) {
640-
Box {
641-
OutlinedButton(onClick = { menuExpanded = true }) {
642-
Text(selected.ifEmpty { "(message)" })
643-
Spacer(Modifier.width(6.dp))
644-
Icon(
645-
imageVector = Icons.Default.KeyboardArrowDown,
646-
contentDescription = null
660+
// Always show the message-type dropdown; disable it when there's
661+
// only one option so the UI is consistent across families. The
662+
// label strips the family-name prefix (e.g. "KingSong realtime"
663+
// displays as "Realtime", "InMotion V1 slow-info" as "Slow-info")
664+
// since the family is already named in the picker to the left.
665+
val familyName = selectedFamily?.displayName ?: ""
666+
val singleOption = types.size <= 1
667+
Box {
668+
OutlinedButton(
669+
onClick = { if (!singleOption) menuExpanded = true },
670+
enabled = !singleOption
671+
) {
672+
Text(shortInspectLabel(selected, familyName).ifEmpty { "(message)" })
673+
Spacer(Modifier.width(6.dp))
674+
Icon(
675+
imageVector = Icons.Default.KeyboardArrowDown,
676+
contentDescription = null
677+
)
678+
}
679+
androidx.compose.material3.DropdownMenu(
680+
expanded = menuExpanded,
681+
onDismissRequest = { menuExpanded = false }
682+
) {
683+
types.forEach { t ->
684+
androidx.compose.material3.DropdownMenuItem(
685+
text = { Text(shortInspectLabel(t, familyName)) },
686+
onClick = { selected = t; menuExpanded = false }
647687
)
648688
}
649-
androidx.compose.material3.DropdownMenu(
650-
expanded = menuExpanded,
651-
onDismissRequest = { menuExpanded = false }
652-
) {
653-
types.forEach { t ->
654-
androidx.compose.material3.DropdownMenuItem(
655-
text = { Text(t) },
656-
onClick = { selected = t; menuExpanded = false }
657-
)
658-
}
659-
}
660689
}
661-
} else if (types.size == 1) {
662-
Text(
663-
"Realtime",
664-
style = MaterialTheme.typography.labelMedium,
665-
color = MaterialTheme.colorScheme.onSurfaceVariant
666-
)
667690
}
668691
}
669692

@@ -788,46 +811,66 @@ private fun RawTab(vm: WheelDiagnosticsViewModel) {
788811
.verticalScroll(rememberScrollState())
789812
.padding(top = 4.dp)
790813
) {
814+
// Per-family preset library. Each family's chips draw from the
815+
// same DiagnosticCommand catalogue the Commands tab uses, so the
816+
// single source of truth holds and authoring a new family's
817+
// commands lights both tabs up at once. Tap a chip to seed the
818+
// input box (vs the Commands tab which fires immediately).
819+
val presetFamilies = remember { vm.allWheelFamilies().filter { it.commands.isNotEmpty() } }
820+
var presetFamilyIdx by rememberSaveable { mutableStateOf(0) }
821+
var presetMenuOpen by remember { mutableStateOf(false) }
822+
val activePresetFamily = presetFamilies.getOrNull(presetFamilyIdx)
791823
CollapsibleSection(title = "Insert preset", defaultExpanded = false) {
792-
Row(modifier = Modifier.horizontalScroll(rememberScrollState()).padding(top = 4.dp)) {
793-
val chips = listOf(
794-
"60 50 00 00" to "Light off",
795-
"60 50 01 01" to "Light on",
796-
"60 51 18 01" to "Horn",
797-
"60 2f 00" to "Auto-headlight off",
798-
"60 2f 01" to "Auto-headlight on",
799-
"60 4e 00" to "DRL? off",
800-
"60 4e 01" to "DRL? on",
801-
"60 24 00" to "25 km/h clamp off",
802-
"60 24 01" to "25 km/h clamp on",
803-
"60 31 01" to "Lock",
804-
"60 31 00" to "Unlock",
805-
"02 06" to "Info bundle",
806-
"02 07" to "Realtime",
807-
"20 20" to "Settings page A",
808-
"20 21" to "Settings B (untried)",
809-
"20 22" to "Settings C (untried)",
810-
"11" to "Total stats"
824+
if (activePresetFamily == null) {
825+
Text(
826+
"No families publish presets yet.",
827+
style = MaterialTheme.typography.bodySmall,
828+
color = MaterialTheme.colorScheme.onSurfaceVariant
811829
)
812-
chips.forEach { (bytes, desc) ->
813-
AssistChip(
814-
onClick = { appendBytes(bytes) },
815-
label = {
816-
Column {
817-
Text(
818-
bytes,
819-
style = MaterialTheme.typography.labelMedium
820-
.copy(fontFamily = FontFamily.Monospace)
821-
)
822-
Text(
823-
desc,
824-
style = MaterialTheme.typography.labelSmall,
825-
color = MaterialTheme.colorScheme.onSurfaceVariant
826-
)
827-
}
828-
},
829-
modifier = Modifier.padding(end = 6.dp).height(56.dp)
830-
)
830+
} else {
831+
Box(modifier = Modifier.padding(top = 4.dp, bottom = 6.dp)) {
832+
OutlinedButton(onClick = { presetMenuOpen = true }) {
833+
Text(activePresetFamily.displayName)
834+
Spacer(Modifier.width(6.dp))
835+
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
836+
}
837+
androidx.compose.material3.DropdownMenu(
838+
expanded = presetMenuOpen,
839+
onDismissRequest = { presetMenuOpen = false }
840+
) {
841+
presetFamilies.forEachIndexed { idx, fam ->
842+
androidx.compose.material3.DropdownMenuItem(
843+
text = { Text(fam.displayName) },
844+
onClick = { presetFamilyIdx = idx; presetMenuOpen = false }
845+
)
846+
}
847+
}
848+
}
849+
Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
850+
activePresetFamily.commands.forEach { cmd ->
851+
val hex = remember(cmd.bytes) {
852+
cmd.bytes.joinToString(" ") { "%02x".format(it) }
853+
}
854+
AssistChip(
855+
onClick = { appendBytes(hex) },
856+
label = {
857+
Column {
858+
Text(
859+
cmd.label,
860+
style = MaterialTheme.typography.labelMedium
861+
)
862+
Text(
863+
cmd.description,
864+
style = MaterialTheme.typography.labelSmall,
865+
color = MaterialTheme.colorScheme.onSurfaceVariant,
866+
maxLines = 1,
867+
overflow = TextOverflow.Ellipsis
868+
)
869+
}
870+
},
871+
modifier = Modifier.padding(end = 6.dp).height(56.dp)
872+
)
873+
}
831874
}
832875
}
833876
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,20 @@ class WheelDiagnosticsViewModel @Inject constructor(
193193
bleManager.writeCommand(cmd.bytes)
194194
}
195195

196-
enum class WrapMode { LITERAL, WRAP_EXTENDED, WRAP_V14_SHORT }
196+
/**
197+
* Frame format the Raw tab wraps the user's bytes in before sending.
198+
*
199+
* - LITERAL: bytes go on the wire as typed (Begode / Veteran / V1
200+
* research path; also any protocol where the user wants full control).
201+
* - WRAP_EXTENDED: InMotion V2 extended-routing
202+
* `aa aa 16 LL 02 21 [user...] [xor]`. First user byte is the cmd.
203+
* - WRAP_V14_SHORT: InMotion V14 short-form
204+
* `aa aa 16 LL [flags] [cmd] [data...] [xor]`. First user byte is cmd.
205+
* - WRAP_KINGSONG: KingSong 20-byte
206+
* `aa 55 [00*14] [type] 14 5a 5a` with user bytes filling the 14
207+
* payload slots and the LAST user byte going into [type].
208+
*/
209+
enum class WrapMode { LITERAL, WRAP_EXTENDED, WRAP_V14_SHORT, WRAP_KINGSONG }
197210

198211
/** Result of attempting to wrap user-typed hex into bytes. The dialog
199212
* shows [bytes] in the read-only "Bytes to send" box on success and
@@ -266,6 +279,10 @@ class WheelDiagnosticsViewModel @Inject constructor(
266279
if (bytes.size > 1) bytes.copyOfRange(1, bytes.size) else byteArrayOf()
267280
)
268281
}
282+
WrapMode.WRAP_KINGSONG -> {
283+
if (bytes.isEmpty()) return WrapResult(null, "Need at least 1 byte (type)")
284+
com.eried.eucplanet.ble.KingsongCommands.wrapArbitrary(bytes)
285+
}
269286
}
270287
return WrapResult(wrapped, null)
271288
}

0 commit comments

Comments
 (0)