Skip to content

Commit 5edb37c

Browse files
eriedclaude
andcommitted
fix(kingsong): retry name + alarms queries until the wheel replies
Field report from a KS-16X owner on the latest firmware: 'EUC Planet only works after I open the official KingSong app first'. Once primed by the official app the wheel responds to ours fine, and the state survives across our reconnect until power-off. Diagnosis: the wheel silently drops writes during the first ~second after the phone subscribes to notifications. Our init sent 0x9B (queryName) and 0x98 (queryLimits) exactly once, so when those landed in the dead window the wheel never replied -- no model string, no alarm/max-speed reply, no connect chirp. The upstream reference adapter re-asks name and serial on every incoming frame until they arrive; one of those retries eventually lands and unlocks the wheel. Fix: track 'have we heard the 0xBB / 0xA4 replies yet', and from pollRealtime re-emit the corresponding init write each tick (capped to 10 attempts) once the wheel has started pushing any frame. Gated on firstFrameSeen so we don't pile retries into the same dead window the original writes already fell into. State resets on disconnect so a reconnect starts a fresh retry budget. KingSong-only change -- the polling loop's empty-default for V14 / Veteran / Begode / P6 is untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 66074c8 commit 5edb37c

1 file changed

Lines changed: 63 additions & 10 deletions

File tree

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

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,35 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
4444
*/
4545
@Volatile private var pendingEcho: ByteArray? = null
4646

47+
// ---- Connect-time write retry (KS-16X wake fix) -----------------------
48+
//
49+
// Some KS firmwares (KS-16X new revision in particular) silently drop
50+
// writes during the first ~second after the phone subscribes to
51+
// notifications. If the one-shot 0x9B / 0x98 we send during init lands
52+
// in that window, the wheel never replies with the name (0xBB) or the
53+
// alarm settings (0xA4) and never emits the connect-chirp the rider
54+
// expects -- and on the worst firmwares it then stays silent for the
55+
// whole session because no app-sent write has been acknowledged.
56+
//
57+
// Field reports: "EUC Planet works only after I run the official KS app
58+
// first" -- the official app retries 0x9B aggressively (it re-asks on
59+
// every incoming telemetry frame until name is non-empty), so eventually
60+
// one write lands and the wheel unlocks. Once unlocked, the state
61+
// appears to persist for the rest of the power-on cycle, which is why
62+
// our subsequent connects start working.
63+
//
64+
// Fix: track whether we've actually heard back from the wheel for the
65+
// two init queries (0xBB for name, 0xA4/0xB5 for alarms) and re-queue
66+
// the corresponding command from [pollRealtime] until we do. Capped to
67+
// a small number of attempts so a permanently silent wheel doesn't
68+
// spam the BLE stack for the whole session.
69+
@Volatile private var nameReceived: Boolean = false
70+
@Volatile private var limitsReceived: Boolean = false
71+
@Volatile private var firstFrameSeen: Boolean = false
72+
@Volatile private var nameRetryCount: Int = 0
73+
@Volatile private var limitsRetryCount: Int = 0
74+
private val maxRetries = 10
75+
4776
override fun bleProfile(): BleProfile = BleProfile.HM10
4877

4978
override fun notifyConnectingTo(deviceName: String?): DecodeResult.ModelName? {
@@ -57,23 +86,36 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
5786
)
5887

5988
// KingSong is push-only: once notifications are enabled the wheel
60-
// continuously streams 0xA9 realtime + 0xB9 trip frames at its own
61-
// cadence. We must NOT periodically write 0x98 (queryLimits) to it;
62-
// the only outgoing 0x98 should happen once during init when local
63-
// alarm values are still zero. Repeated 0x98 polls have been
64-
// observed to cause KS-16X to flash lights / chirp because some KS
65-
// firmwares interpret repeated alarm-limit reads as a re-configure
66-
// signal. Same push-only model as BegodeAdapter / VeteranAdapter.
89+
// streams 0xA9 / 0xB9 / 0xF5 / 0xF6 frames at its own cadence with no
90+
// periodic poll required. Same push-only model as BegodeAdapter /
91+
// VeteranAdapter.
6792
//
68-
// The only thing we ever want to send during the realtime loop is an
69-
// echo of an unsolicited 0xA4 settings frame that KingSong expects us
70-
// to bounce back; see [pendingEcho] / [onRawNotification].
93+
// Two things ride the poll tick:
94+
// 1. Echo of an unsolicited 0xA4 settings push (see [pendingEcho]).
95+
// 2. Retry of init writes (0x9B / 0x98) when we've started receiving
96+
// telemetry but haven't yet seen the replies the wheel owes us.
97+
// This unlocks KS-16X firmwares that drop the first ~second of
98+
// writes after subscribe.
7199
override fun pollRealtime(): ByteArray {
72100
val echo = pendingEcho
73101
if (echo != null) {
74102
pendingEcho = null
75103
return echo
76104
}
105+
// Don't start retrying until we've actually heard a frame from the
106+
// wheel -- if zero frames have arrived, the BLE stack itself isn't
107+
// ready yet and re-sending writes just queues them with the same
108+
// fate as the init writes.
109+
if (firstFrameSeen) {
110+
if (!nameReceived && nameRetryCount < maxRetries) {
111+
nameRetryCount++
112+
return KingsongCommands.queryName()
113+
}
114+
if (!limitsReceived && limitsRetryCount < maxRetries) {
115+
limitsRetryCount++
116+
return KingsongCommands.queryLimits()
117+
}
118+
}
77119
return ByteArray(0)
78120
}
79121

@@ -121,6 +163,10 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
121163
if (rawBytes.size < 20) return emptyList()
122164
if (rawBytes[0] != 0xAA.toByte() || rawBytes[1] != 0x55.toByte()) return emptyList()
123165

166+
// Any well-formed inbound frame counts as "the BLE pipe is awake" --
167+
// gates the retry path in pollRealtime().
168+
firstFrameSeen = true
169+
124170
val type = rawBytes[16].toInt() and 0xFF
125171
return when (type) {
126172
0xA9 -> {
@@ -174,6 +220,7 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
174220
listOf(DecodeResult.Telemetry(lastTelemetry))
175221
}
176222
0xBB -> {
223+
nameReceived = true
177224
val name = KingsongParser.parseModelName(rawBytes) ?: return emptyList()
178225
val resolved = KingsongModel.fromReportedName(name)
179226
if (resolved != null) detectedModel = resolved
@@ -213,6 +260,7 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
213260
listOf(DecodeResult.Telemetry(lastTelemetry))
214261
}
215262
0xA4, 0xB5 -> {
263+
limitsReceived = true
216264
val settings = KingsongParser.parseAlarmsAndMaxSpeed(rawBytes) ?: return emptyList()
217265
if (KingsongParser.requiresAlarmEcho(rawBytes)) {
218266
pendingEcho = KingsongParser.buildAlarmEcho(rawBytes)
@@ -229,6 +277,11 @@ class KingsongAdapter @Inject constructor() : WheelAdapter {
229277
lastTelemetry = WheelData()
230278
lastTempA9 = 0f
231279
lastTempB9 = 0f
280+
nameReceived = false
281+
limitsReceived = false
282+
firstFrameSeen = false
283+
nameRetryCount = 0
284+
limitsRetryCount = 0
232285
}
233286

234287
override fun inspectMessageTypes(): List<String> = listOf("KingSong realtime")

0 commit comments

Comments
 (0)