Skip to content

Commit e1330f4

Browse files
eriedclaude
andcommitted
Service Mode: author command catalogues for KingSong / Veteran / Begode / Ninebot / V1
Each new wheel-family adapter now contributes diagnostic commands + inspect message types so Service Mode is research-grade across the whole multi-wheel branch. Authored in parallel by 5 agents reading each protocol's docs/protocols/<family>.md spec; bytes come from existing *Commands factories (extra factories added where needed). Final tally: KingSong: 12 commands (Q98/9B/63/E1, T88, T73_*, T87_*, T85_*) Veteran: 8 commands (Light, HornV3/Legacy, SETh/m/s, CLEARMETER) Begode: 11 commands (horn, light off/on/strobe, max-speed, power-off probe, FW/name queries) Ninebot: 15 commands (Z handshake + queries + writes, Legacy reads, max-speed) InMotion V1: 11 commands (PIN, slow/fast info, FW, light, horn, max-speed) Adapters also gained note(...) logging in their decode paths so the Inspect tab picks up live frames per family. Inspect prefixes: V14 realtime / P6 realtime / P6 detailed (InMotion V2) KingSong realtime Veteran realtime Begode realtime Ninebot realtime (with `(Z)` / `(Legacy)` body tags) InMotion V1 realtime / InMotion V1 slow-info Also drops the useP6Protocol gate on the V2 catalogue so the family picker shows the P6 commands regardless of what's connected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 65abf4e commit e1330f4

12 files changed

Lines changed: 488 additions & 3 deletions

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.eried.eucplanet.ble
22

3+
import com.eried.eucplanet.diagnostics.DiagnosticCommand
34
import javax.inject.Inject
45
import javax.inject.Singleton
56

@@ -102,4 +103,68 @@ class BegodeAdapter @Inject constructor() : WheelAdapter {
102103
detectedModel = null
103104
lightOn = false
104105
}
106+
107+
/**
108+
* Begode streams telemetry unsolicited at ~10 Hz; the parser logs every
109+
* 24-byte frame via DiagnosticsLogger.note with the `Begode realtime`
110+
* prefix so the Service Mode Inspect tab can scope to it. There is no
111+
* separate detail / extra channel — BMS, Live A, Live B and extras all
112+
* share the same envelope, distinguished by the tag byte at offset 18.
113+
*/
114+
override fun inspectMessageTypes(): List<String> = listOf("Begode realtime")
115+
116+
/**
117+
* Service Mode catalogue for the Begode / Gotway family. The protocol is
118+
* fire-and-forget ASCII (spec 6.1): commands write WRITE_NO_RESPONSE,
119+
* with no ack channel. The wheel only confirms via the next Live B
120+
* (0x04) frame or audibly. Every entry below is a control write — the
121+
* "queries" V and N do trigger ASCII banner replies but those land on
122+
* the same notify pipe as raw bytes, not framed packets.
123+
*
124+
* Each label encodes the byte sequence so a user report ("WY45b changed
125+
* the tiltback") maps unambiguously to a command. Max-speed writes use
126+
* the [BegodeCommands.setMaxSpeedSingleWrite] form: a single concatenated
127+
* 5-byte W-prefix sequence. Spec 6.2 expects pacing between the four
128+
* logical steps, so the Service Mode result also tells us whether this
129+
* firmware tolerates unspaced W-prefix sequences.
130+
*/
131+
override fun getDiagnosticCommands(): List<DiagnosticCommand> {
132+
val HORN = DiagnosticCommand.Category.HORN
133+
val LIGHT = DiagnosticCommand.Category.LIGHT
134+
val MODE = DiagnosticCommand.Category.MODE
135+
val QUERY = DiagnosticCommand.Category.QUERY
136+
137+
return listOf(
138+
// Control endpoint check: a beep means the wheel is reading writes.
139+
DiagnosticCommand("Tb_horn", "Beep horn, confirms writes land",
140+
BegodeCommands.horn(), HORN),
141+
142+
// 3-state light (spec 6.5). Light state echoes in next 0x04 frame.
143+
DiagnosticCommand("TE_light_off", "Turn light off (E)",
144+
BegodeCommands.lightOff(), LIGHT),
145+
DiagnosticCommand("TQ_light_on", "Turn light on (Q)",
146+
BegodeCommands.lightOn(), LIGHT),
147+
DiagnosticCommand("TT_light_strobe", "Set light to strobe (T)",
148+
BegodeCommands.lightStrobe(), LIGHT),
149+
150+
// Max-speed writes. Single-write W-prefix; pacing may be required.
151+
DiagnosticCommand("TWY25b", "Set max speed 25 km/h (unspaced)",
152+
BegodeCommands.setMaxSpeedSingleWrite(25), MODE),
153+
DiagnosticCommand("TWY35b", "Set max speed 35 km/h (unspaced)",
154+
BegodeCommands.setMaxSpeedSingleWrite(35), MODE),
155+
DiagnosticCommand("TWY45b", "Set max speed 45 km/h (unspaced)",
156+
BegodeCommands.setMaxSpeedSingleWrite(45), MODE),
157+
DiagnosticCommand("TWY80b", "Set max speed 80 km/h (unspaced)",
158+
BegodeCommands.setMaxSpeedSingleWrite(80), MODE),
159+
DiagnosticCommand("Tdq_no_max", "Disable max-speed cap (control)",
160+
BegodeCommands.disableMaxSpeed(), MODE),
161+
162+
// ASCII banner queries. Responses arrive as raw text on notify,
163+
// not as framed packets, so they show up in the Inspect raw view.
164+
DiagnosticCommand("QV_fw", "Request firmware banner (V)",
165+
BegodeCommands.queryFirmware(), QUERY),
166+
DiagnosticCommand("QN_name", "Request model name banner (N)",
167+
BegodeCommands.queryModelName(), QUERY),
168+
)
169+
}
105170
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,31 @@ object BegodeCommands {
6666
)
6767
}
6868

69+
/**
70+
* Diagnostic variant of [setMaxSpeed] that concatenates the entire
71+
* `W Y H L b` sequence into a single 5-byte write. Spec 6.2 says the
72+
* wheel expects ~100-200 ms of spacing between the four logical steps;
73+
* this single-write form is for Service Mode probing only, so the user
74+
* can see whether the connected firmware tolerates an unspaced sequence
75+
* (newer Begode FW does; older units may silently ignore the request).
76+
* The next 0x04 Live B frame's max-speed field at offset 10-11 is the
77+
* authoritative ack.
78+
*
79+
* Speed clamped to 0..99 km/h since the wire format is two ASCII digits.
80+
*/
81+
fun setMaxSpeedSingleWrite(kmh: Int): ByteArray {
82+
val clamped = kmh.coerceIn(0, 99)
83+
val high = ((clamped / 10) + 0x30).toByte()
84+
val low = ((clamped % 10) + 0x30).toByte()
85+
return byteArrayOf(
86+
'W'.code.toByte(),
87+
'Y'.code.toByte(),
88+
high,
89+
low,
90+
'b'.code.toByte()
91+
)
92+
}
93+
6994
/**
7095
* Set beeper volume 1..9 via the W-prefix sub-menu. Spec 6.2.
7196
* Returns null for out-of-range values so the caller can no-op cleanly.

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,16 @@ class BegodeParser {
109109
}
110110

111111
private fun decodeFrame(frame: ByteArray, model: BegodeModel?): DecodeResult? {
112-
return when (val tag = frame[18].toInt() and 0xFF) {
112+
val tag = frame[18].toInt() and 0xFF
113+
// Surface every 24-byte frame to the Service Mode Inspect tab. The tag
114+
// byte is what differentiates Live A / Live B / extras / BMS pages, so
115+
// we include it in the prefix-stamped line; the body covers all 24
116+
// bytes (header through terminator) so an investigator can sanity-
117+
// check framing as well as payload.
118+
com.eried.eucplanet.diagnostics.DiagnosticsLogger.note(
119+
"Begode realtime tag=0x${"%02x".format(tag)} len=${frame.size} body=${frame.joinToString(" ") { "%02x".format(it) }}"
120+
)
121+
return when (tag) {
113122
0x00 -> parseLiveA(frame, model)?.let { DecodeResult.Telemetry(it) }
114123
0x01 -> parseBmsSummary(frame, model)?.let { DecodeResult.Telemetry(it) }
115124
0x02, 0x03 -> null // BMS cell pages: cell-level UI not exposed yet, skip cleanly.

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.eried.eucplanet.ble
22

3+
import com.eried.eucplanet.diagnostics.DiagnosticCommand
4+
import com.eried.eucplanet.diagnostics.DiagnosticsLogger
35
import java.io.ByteArrayOutputStream
46
import javax.inject.Inject
57
import javax.inject.Singleton
@@ -148,6 +150,59 @@ class InMotionV1Adapter @Inject constructor() : WheelAdapter {
148150
detectedModel = null
149151
}
150152

153+
override fun inspectMessageTypes(): List<String> =
154+
listOf("InMotion V1 realtime", "InMotion V1 slow-info")
155+
156+
/**
157+
* Service Mode catalogue for the InMotion V1 family. Each entry is a
158+
* single-shot CAN frame the user can fire from the Wheel Diagnostics
159+
* dialog to probe the wheel and watch the live log. The label is
160+
* bytes-derived so reports map back to one packet without ambiguity.
161+
*/
162+
override fun getDiagnosticCommands(): List<DiagnosticCommand> {
163+
val QUERY = DiagnosticCommand.Category.QUERY
164+
val LIGHT = DiagnosticCommand.Category.LIGHT
165+
val HORN = DiagnosticCommand.Category.HORN
166+
val MODE = DiagnosticCommand.Category.MODE
167+
val OTHER = DiagnosticCommand.Category.OTHER
168+
169+
return listOf(
170+
// --- Read-only queries ---
171+
DiagnosticCommand("Q0113", "Poll realtime fast-info reply",
172+
InMotionV1Commands.getFastInfo(), QUERY),
173+
DiagnosticCommand("Q0114", "Dump settings via slow-info",
174+
InMotionV1Commands.getSlowInfo(), QUERY),
175+
DiagnosticCommand("Q0114_CELLS", "Read battery cell levels",
176+
InMotionV1Commands.getBatteryCells(), QUERY),
177+
DiagnosticCommand("Q0114_FW", "Read firmware version block",
178+
InMotionV1Commands.getFirmwareVersion(), QUERY),
179+
180+
// --- Lighting ---
181+
DiagnosticCommand("LIGHT_OFF", "Turn the headlight off",
182+
InMotionV1Commands.setLight(false), LIGHT),
183+
DiagnosticCommand("LIGHT_ON", "Turn the headlight on",
184+
InMotionV1Commands.setLight(true), LIGHT),
185+
186+
// --- Horn ---
187+
DiagnosticCommand("HORN_DED", "Beep via dedicated V8F/V10 opcode",
188+
InMotionV1Commands.hornDedicated(), HORN),
189+
DiagnosticCommand("HORN_SND4", "Beep via legacy playSound 4",
190+
InMotionV1Commands.hornLegacy(), HORN),
191+
192+
// --- Max speed writes ---
193+
DiagnosticCommand("MAXSPD_20", "Set tiltback to 20 km/h",
194+
InMotionV1Commands.setMaxSpeed(20f), MODE),
195+
DiagnosticCommand("MAXSPD_30", "Set tiltback to 30 km/h",
196+
InMotionV1Commands.setMaxSpeed(30f), MODE),
197+
198+
// --- PIN auth probe ---
199+
// Wheels without a PIN configured ignore this, so it is safe to
200+
// fire; replies confirm the auth endpoint is alive.
201+
DiagnosticCommand("PIN_000000", "Send 000000 placeholder PIN",
202+
InMotionV1Commands.sendPin("000000"), OTHER)
203+
)
204+
}
205+
151206
/**
152207
* Walk the buffer for the `55 55` trailer, skipping escape sequences so
153208
* an escaped `0xA5 0x55` byte inside the body isn't mistaken for the
@@ -175,11 +230,17 @@ class InMotionV1Adapter @Inject constructor() : WheelAdapter {
175230
return when (canId) {
176231
InMotionV1Protocol.CanId.FAST_INFO -> {
177232
val payload = InMotionV1Parser.extPayload(unwrapped)
233+
DiagnosticsLogger.note(
234+
"InMotion V1 realtime len=${payload.size} body=${payload.joinToString(" ") { "%02x".format(it) }}"
235+
)
178236
val telem = InMotionV1Parser.parseFastInfo(payload, detectedModel)
179237
if (telem != null) listOf(DecodeResult.Telemetry(telem)) else emptyList()
180238
}
181239
InMotionV1Protocol.CanId.SLOW_INFO -> {
182240
val payload = InMotionV1Parser.extPayload(unwrapped)
241+
DiagnosticsLogger.note(
242+
"InMotion V1 slow-info len=${payload.size} body=${payload.joinToString(" ") { "%02x".format(it) }}"
243+
)
183244
val info = InMotionV1Parser.parseSlowInfo(payload) ?: return emptyList()
184245
if (info.model != null) detectedModel = info.model
185246
val out = mutableListOf<DecodeResult>()

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,32 @@ object InMotionV1Commands {
2727
fun getSlowInfo(): ByteArray =
2828
InMotionV1Protocol.buildFrame(CanId.SLOW_INFO, ALL_FF, remote = true)
2929

30+
/**
31+
* Battery cell-level query: same CAN ID as slow-info, but the data slot
32+
* picks the cells page instead of the full settings dump. Remote-frame
33+
* request per spec section 6. Response shape is firmware-dependent and
34+
* not parsed by the adapter today; surfaced from Service Mode so the
35+
* raw reply can be inspected in the live log.
36+
*/
37+
fun getBatteryCells(): ByteArray =
38+
InMotionV1Protocol.buildFrame(
39+
CanId.SLOW_INFO,
40+
byteArrayOf(0x00, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00),
41+
remote = true
42+
)
43+
44+
/**
45+
* Firmware version query: same CAN ID as slow-info, data slot `20...` per
46+
* spec section 6. Returns a trimmed firmware-only reply on firmwares that
47+
* support it; older builds return the full slow-info anyway.
48+
*/
49+
fun getFirmwareVersion(): ByteArray =
50+
InMotionV1Protocol.buildFrame(
51+
CanId.SLOW_INFO,
52+
byteArrayOf(0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
53+
remote = true
54+
)
55+
3056
// --- Lighting ---
3157

3258
fun setLight(on: Boolean): ByteArray =

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,11 @@ class InMotionV2Adapter @Inject constructor() : WheelAdapter {
258258
* we know exactly which packet that was.
259259
*/
260260
override fun getDiagnosticCommands(): List<DiagnosticCommand> {
261-
if (!useP6Protocol) return emptyList()
261+
// Service Mode is research-grade — show the catalogue regardless of
262+
// whether the connected wheel is actually a P6. The wrap (extended
263+
// routing) is only sent when fired manually, so it doesn't affect
264+
// V14 / V12 telemetry. The user picks "InMotion V14 / V12 / P6" in
265+
// the family dropdown and gets the full P6 query / control set.
262266
val LIGHT = DiagnosticCommand.Category.LIGHT
263267
val MODE = DiagnosticCommand.Category.MODE
264268
val QUERY = DiagnosticCommand.Category.QUERY

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.eried.eucplanet.ble
22

3+
import com.eried.eucplanet.diagnostics.DiagnosticCommand
4+
import com.eried.eucplanet.diagnostics.DiagnosticsLogger
35
import javax.inject.Inject
46
import javax.inject.Singleton
57

@@ -86,6 +88,14 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
8688
val type = rawBytes[16].toInt() and 0xFF
8789
return when (type) {
8890
0xA9 -> {
91+
// Surface the realtime body so the Service Mode Inspect tab can
92+
// show it the same way it does V14 / P6 / Veteran. Use the
93+
// 14-byte payload at offsets 2..15 so the bytes match the
94+
// labelled offsets in docs/protocols/kingsong.md section 4.1.
95+
val body = rawBytes.copyOfRange(2, 16)
96+
DiagnosticsLogger.note(
97+
"KingSong realtime len=${body.size} body=${body.joinToString(" ") { "%02x".format(it) }}"
98+
)
8999
val telem = KingsongParser.parseLiveTelemetry(rawBytes, detectedModel)
90100
if (telem != null) listOf(DecodeResult.Telemetry(telem)) else emptyList()
91101
}
@@ -137,4 +147,65 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
137147
detectedModel = null
138148
pendingEcho = null
139149
}
150+
151+
override fun inspectMessageTypes(): List<String> = listOf("KingSong realtime")
152+
153+
/**
154+
* Service Mode catalogue for the KingSong family. Research-grade: a mix of
155+
* safe queries (so the user can dump identity, settings, and a BMS page
156+
* without writing anything) and writes that exercise documented control
157+
* paths (horn, light modes, pedal hardness, max-speed + alarms). Labels
158+
* are short and opcode-derived so a user can report "T98 returned alarms
159+
* = 25/35/45/55" and we know which packet that maps to.
160+
*
161+
* The catalogue intentionally omits power-off (`0x40`) and gyro
162+
* calibration (`0x89`): both are easy to fat-finger and have real-world
163+
* consequences if the user is mid-ride or the wheel is upright.
164+
*
165+
* Per docs/protocols/kingsong.md section 8 KingSong has no documented
166+
* lock command in the public protocol, so no lock toggle is offered.
167+
*/
168+
override fun getDiagnosticCommands(): List<DiagnosticCommand> {
169+
val LIGHT = DiagnosticCommand.Category.LIGHT
170+
val MODE = DiagnosticCommand.Category.MODE
171+
val QUERY = DiagnosticCommand.Category.QUERY
172+
val HORN = DiagnosticCommand.Category.HORN
173+
174+
return listOf(
175+
// --- Read-only queries: tap to inspect what the wheel returns ---
176+
DiagnosticCommand("Q98", "Read max-speed and alarm thresholds",
177+
KingsongCommands.queryLimits(), QUERY),
178+
DiagnosticCommand("Q9B", "Read model name and firmware version",
179+
KingsongCommands.queryName(), QUERY),
180+
DiagnosticCommand("Q63", "Read 17-char serial number",
181+
KingsongCommands.querySerial(), QUERY),
182+
DiagnosticCommand("QE1", "Read BMS1 serial",
183+
KingsongCommands.bmsQuery(KingsongCommands.Type.BMS1_SERIAL_REQ), QUERY),
184+
185+
// --- Horn ---
186+
DiagnosticCommand("T88", "Beep the horn once",
187+
KingsongCommands.horn(), HORN),
188+
189+
// --- Light: cycle off / on / auto ---
190+
DiagnosticCommand("T73_12", "Turn headlight off",
191+
KingsongCommands.setLightMode(0), LIGHT),
192+
DiagnosticCommand("T73_13", "Turn headlight on",
193+
KingsongCommands.setLightMode(1), LIGHT),
194+
DiagnosticCommand("T73_14", "Set headlight to auto mode",
195+
KingsongCommands.setLightMode(2), LIGHT),
196+
197+
// --- Pedal hardness: flip between the documented extremes ---
198+
DiagnosticCommand("T87_00", "Set pedals to soft mode",
199+
KingsongCommands.setPedalMode(0), MODE),
200+
DiagnosticCommand("T87_02", "Set pedals to hard mode",
201+
KingsongCommands.setPedalMode(2), MODE),
202+
203+
// --- Max-speed write at two safe values ---
204+
// Alarms slot below the tiltback ceiling, matching setMaxSpeed().
205+
DiagnosticCommand("T85_45", "Set tiltback 45 km/h, alarms 30/35/40",
206+
KingsongCommands.setMaxSpeedAndAlarms(30, 35, 40, 45), MODE),
207+
DiagnosticCommand("T85_60", "Set tiltback 60 km/h, alarms 40/46/53",
208+
KingsongCommands.setMaxSpeedAndAlarms(40, 46, 53, 60), MODE),
209+
)
210+
}
140211
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ object KingsongCommands {
3131
const val WHEEL_PARAM: Byte = 0x8A.toByte()
3232
const val QUERY_LIMITS: Byte = 0x98.toByte()
3333
const val NAME_REQ: Byte = 0x9B.toByte()
34+
const val BMS1_SERIAL_REQ: Byte = 0xE1.toByte()
3435
}
3536

3637
/**
@@ -145,4 +146,19 @@ object KingsongCommands {
145146
f[4] = v[0]
146147
f[5] = v[1]
147148
}
149+
150+
/**
151+
* BMS query frame. Per spec section 6, BMS request frames use a zeroed
152+
* trailer (`data[17..19] = 0x00`) instead of the standard `0x14 0x5A 0x5A`.
153+
* The wheel replies on the matching reply type (e.g. send `0xE1`, reply
154+
* on type `0xE1` for the serial frame).
155+
*/
156+
fun bmsQuery(type: Byte): ByteArray {
157+
val out = ByteArray(20)
158+
out[0] = HEADER0
159+
out[1] = HEADER1
160+
out[16] = type
161+
// Trailer slots intentionally left as 0x00.
162+
return out
163+
}
148164
}

0 commit comments

Comments
 (0)