What to build
Let a rider supply their board's PIN while linking, and use it so tune writes actually land on a PIN-locked board.
Two halves of one path:
Entering the PIN. The PIN is a row in the existing linking timeline, not a new wizard step. BoardLinkTimeline already renders the vertical linking checklist (connect / handshake / scan / transport / bms / identity); PIN becomes one more row in that sequence, with the input rendered inline in the row's content slot.
Row states follow the existing vocabulary:
- board has no pinlock firmware ->
absent, caption says so, exactly like the bms row's "No smart BMS"
- pinlock firmware, no PIN set on the board ->
done, nothing to type
- PIN set -> interactive: inline input in
content, validated against the board before saving
- rider declines ->
absent, linking still completes
The typed PIN is validated with COMM_LOCK_STATUS — pin_matches answers directly, so no write is attempted to find out. Declining leaves the Board Link outdated (telemetry works, firmware-dependent commands blocked) until the rider re-links.
This lands in both linking surfaces for free, since ScanStep and src/app/editBoard/link.tsx both render the same timeline.
There is deliberately no PIN management surface. Re-link is the whole recovery path: PIN changed in VESC Tool, PIN cleared by a firmware upgrade, or PIN skipped at link time all resolve by re-linking. EditBoardSettings already has a Re-link button and unlinking preserves the board record, so this costs the rider a probe, not their history.
Using the PIN. Refloat tune writes get wrapped in COMM_WRITE_UNLOCK_CMD (157), which unlocks for exactly one inner packet and re-locks after. Reads are untouched — a locked board answers telemetry normally, and only writes are dropped.
The failure mode is the hard part: a wrong PIN produces no reply at all, identical to a dropped packet. Blind retry is actively harmful, because each differing wrong PIN doubles a firmware-side cooldown that never resets until the board reboots. So a timed-out wrapped write must be classified with a COMM_LOCK_STATUS call, not retried.
Likely files
src/modules/board/components/BoardLinkTimeline.tsx - the linking checklist; STEP_KEYS, buildSteps, TransportPicker
src/components/base/StepTimeline.tsx - generic timeline; TimelineStep.content is the inline-UI slot, StepState the state vocabulary
modules/vescape-core/ios/config/ConfigRWController.swift - encodeAndSendWrite is the single write choke point
modules/vescape-core/ios/config/ConfigRWStates.kt peer + ConfigRWFsmTest - timeout classification and its tests
modules/vescape-core/ios/connection/BoardSessionController.swift - startLinkIntegrityProbe already gates on a persisted link capability (config.hasBms == true)
modules/vescape-core/ios/protocol/VescProtocol.swift - envelope encoding
Implementation hints
Envelope format, PIN as big-endian uint16:
[157][pin_hi][pin_lo][ ...inner packet... ]
The inner command's reply comes back through the same reply_func unchanged, so decoders need no changes — only the encoder wraps. Wrap at encodeAndSendWrite, not at the lower send(_ connection:_ payload:), or reads get wrapped too.
Prefer the 157 envelope over "unlock, write, re-lock" via COMM_WRITE_LOCK. The envelope leaves no window in which a second BLE link (boards with both internal and external BLE) can slip a write in.
startLinkIntegrityProbe is the right place to confirm lock state per session — lock_on_boot means a board comes up locked after every power cycle. It already has the shape:
_ = transport.sendPayload(config.transport.frame([UInt8(COMM_FW_VERSION)]))
_ = transport.sendPayload(RefloatConfigProtocol.buildGetInfo(transport: config.transport))
if config.hasBms == true { ...timeout... }
Probe with the saved PIN, never with 0, so the per-session check cannot bump the cooldown. markBmsMissing after a timeout is the template for concluding "no reply".
Cooldown detail worth knowing: resubmitting the same wrong PIN does not increase the cooldown (if (pin != writelock_pin_prev)), only a differing one does. Rider-facing copy should still hedge — "PIN rejected, wait a moment" — because a correct PIN also reports as non-matching while a cooldown is active, and the firmware exposes no remaining-cooldown value.
TransportPicker in BoardLinkTimeline.tsx is the pattern to copy for the inline input — it is attached via content on a row built by row(...), and lives in the picking phase, the one phase that waits for rider input. A PIN row that needs typing has the same "linking pauses for the rider" shape, so reuse that phase rather than inventing a parallel one.
The bms row is the precedent for the not-applicable case: row('bms', 'absent', 'No smart BMS'). A board without pinlock firmware should read the same way.
STEP_REACH / STEP_KEYS drive live progress states during probing; adding a key affects the completed: STEP_KEYS.length count.
Do not add a wizard step. StepTimeline is purely presentational and the caller owns the step list, so this is a change to buildSteps, not to AddBoardWizard.
New reusable UI needs a preview in the component showcase under src/app/settings/components/.
Acceptance criteria
Blocked by
Related
What to build
Let a rider supply their board's PIN while linking, and use it so tune writes actually land on a PIN-locked board.
Two halves of one path:
Entering the PIN. The PIN is a row in the existing linking timeline, not a new wizard step.
BoardLinkTimelinealready renders the vertical linking checklist (connect / handshake / scan / transport / bms / identity); PIN becomes one more row in that sequence, with the input rendered inline in the row'scontentslot.Row states follow the existing vocabulary:
absent, caption says so, exactly like thebmsrow's "No smart BMS"done, nothing to typecontent, validated against the board before savingabsent, linking still completesThe typed PIN is validated with
COMM_LOCK_STATUS—pin_matchesanswers directly, so no write is attempted to find out. Declining leaves the Board Linkoutdated(telemetry works, firmware-dependent commands blocked) until the rider re-links.This lands in both linking surfaces for free, since
ScanStepandsrc/app/editBoard/link.tsxboth render the same timeline.There is deliberately no PIN management surface. Re-link is the whole recovery path: PIN changed in VESC Tool, PIN cleared by a firmware upgrade, or PIN skipped at link time all resolve by re-linking.
EditBoardSettingsalready has a Re-link button and unlinking preserves the board record, so this costs the rider a probe, not their history.Using the PIN. Refloat tune writes get wrapped in
COMM_WRITE_UNLOCK_CMD(157), which unlocks for exactly one inner packet and re-locks after. Reads are untouched — a locked board answers telemetry normally, and only writes are dropped.The failure mode is the hard part: a wrong PIN produces no reply at all, identical to a dropped packet. Blind retry is actively harmful, because each differing wrong PIN doubles a firmware-side cooldown that never resets until the board reboots. So a timed-out wrapped write must be classified with a
COMM_LOCK_STATUScall, not retried.Likely files
src/modules/board/components/BoardLinkTimeline.tsx- the linking checklist;STEP_KEYS,buildSteps,TransportPickersrc/components/base/StepTimeline.tsx- generic timeline;TimelineStep.contentis the inline-UI slot,StepStatethe state vocabularymodules/vescape-core/ios/config/ConfigRWController.swift-encodeAndSendWriteis the single write choke pointmodules/vescape-core/ios/config/ConfigRWStates.ktpeer +ConfigRWFsmTest- timeout classification and its testsmodules/vescape-core/ios/connection/BoardSessionController.swift-startLinkIntegrityProbealready gates on a persisted link capability (config.hasBms == true)modules/vescape-core/ios/protocol/VescProtocol.swift- envelope encodingImplementation hints
Envelope format, PIN as big-endian
uint16:The inner command's reply comes back through the same
reply_funcunchanged, so decoders need no changes — only the encoder wraps. Wrap atencodeAndSendWrite, not at the lowersend(_ connection:_ payload:), or reads get wrapped too.Prefer the 157 envelope over "unlock, write, re-lock" via
COMM_WRITE_LOCK. The envelope leaves no window in which a second BLE link (boards with both internal and external BLE) can slip a write in.startLinkIntegrityProbeis the right place to confirm lock state per session —lock_on_bootmeans a board comes up locked after every power cycle. It already has the shape:Probe with the saved PIN, never with 0, so the per-session check cannot bump the cooldown.
markBmsMissingafter a timeout is the template for concluding "no reply".Cooldown detail worth knowing: resubmitting the same wrong PIN does not increase the cooldown (
if (pin != writelock_pin_prev)), only a differing one does. Rider-facing copy should still hedge — "PIN rejected, wait a moment" — because a correct PIN also reports as non-matching while a cooldown is active, and the firmware exposes no remaining-cooldown value.TransportPickerinBoardLinkTimeline.tsxis the pattern to copy for the inline input — it is attached viacontenton a row built byrow(...), and lives in thepickingphase, the one phase that waits for rider input. A PIN row that needs typing has the same "linking pauses for the rider" shape, so reuse that phase rather than inventing a parallel one.The
bmsrow is the precedent for the not-applicable case:row('bms', 'absent', 'No smart BMS'). A board without pinlock firmware should read the same way.STEP_REACH/STEP_KEYSdrive live progress states during probing; adding a key affects thecompleted: STEP_KEYS.lengthcount.Do not add a wizard step.
StepTimelineis purely presentational and the caller owns the step list, so this is a change tobuildSteps, not toAddBoardWizard.New reusable UI needs a preview in the component showcase under
src/app/settings/components/.Acceptance criteria
BoardLinkTimeline, not a new Add Board wizard stepabsenton boards without pinlock firmware, like thebmsrow doesoutdatedScanStep,editBoard/link) get it from the shared timelineCOMM_WRITE_UNLOCK_CMDwhen a PIN is savedCOMM_LOCK_STATUS, not blindly retried@parity; new UI has a showcase entryBlocked by
Related