Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

/edl/
/qdl.js/
/provisioning/
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions deploy-preview.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -e

BRANCH="${1:-$(git branch --show-current)}"

bun install
bun run build
bunx wrangler pages deploy dist --project-name=connect --branch="$BRANCH"

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"node": ">=20.11.0"
},
"dependencies": {
"@commaai/qdl": "git+https://github.com/commaai/qdl.js.git#21d7be79fa5178f253d32a0879bd8bdd4fa37e30",
"@commaai/qdl": "git+https://github.com/commaai/qdl.js.git#7177fde779bc6764727130969af1841d71a4c43c",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"react": "^18.3.1",
Expand Down
92 changes: 91 additions & 1 deletion src/app/Flash.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'

import { FlashManager, StepCode, ErrorCode, DeviceType } from '../utils/manager'
import { FlashManager, StepCode, ErrorCode, DeviceType, DUMP_GPT_MODE } from '../utils/manager'
import { useImageManager } from '../utils/image'
import { isLinux, isWindows } from '../utils/platform'
import config from '../config'
Expand Down Expand Up @@ -517,6 +517,91 @@ function LinuxUnbind({ onNext }) {
)
}

// GPT Dump diagnostic mode
function GptDumpMode({ qdlManager, imageManager }) {
const [gptDump, setGptDump] = useState(null)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const [ready, setReady] = useState(false)

useEffect(() => {
if (!imageManager.current) return
fetch(config.loader.url)
.then((res) => res.arrayBuffer())
.then((programmer) => {
qdlManager.current = new FlashManager(programmer, {})
qdlManager.current.initialize(imageManager.current).then(() => setReady(true))
})
}, [imageManager.current])

const handleDump = async () => {
setLoading(true)
const result = await qdlManager.current.dumpGpt()
setGptDump(result)
setLoading(false)
}

const handleCopy = () => {
navigator.clipboard.writeText(gptDump)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

return (
<div className="flex flex-col items-center justify-center h-full gap-6 p-8">
<div className="text-center">
<h1 className="text-3xl font-bold mb-2">GPT Diagnostic Mode</h1>
<p className="text-xl text-gray-600">
Connect your device to dump partition table info
</p>
</div>

{!gptDump ? (
<button
onClick={handleDump}
disabled={loading || !ready}
className={`px-8 py-3 text-xl font-semibold rounded-full transition-colors ${
loading || !ready
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-[#51ff00] hover:bg-[#45e000] active:bg-[#3acc00] text-black'
}`}
>
{loading ? 'Reading...' : !ready ? 'Initializing...' : 'Connect & Dump GPT'}
</button>
) : (
<>
<textarea
readOnly
value={gptDump}
className="w-full max-w-3xl h-96 p-4 font-mono text-sm bg-gray-900 text-gray-100 rounded-lg"
/>
<div className="flex gap-4">
<button
onClick={handleCopy}
className="px-6 py-2 text-lg font-semibold rounded-full bg-blue-600 hover:bg-blue-500 text-white transition-colors"
>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
<button
onClick={() => setGptDump(null)}
className="px-6 py-2 text-lg font-semibold rounded-full bg-gray-300 hover:bg-gray-400 text-black transition-colors"
>
Dump Again
</button>
</div>
<p className="text-gray-500">
Send this to{' '}
<a href="https://discord.comma.ai" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
Discord
</a>
{' '}for debugging
</p>
</>
)}
</div>
)
}

// WebUSB connection screen - shows while waiting for user to select device
function WebUSBConnect({ onConnect }) {
return (
Expand Down Expand Up @@ -722,6 +807,11 @@ export default function Flash() {
// Handle retry on error
const handleRetry = () => window.location.reload()

// Render GPT dump diagnostic mode
if (DUMP_GPT_MODE) {
return <GptDumpMode qdlManager={qdlManager} imageManager={imageManager} />
}

// Render landing page
if (wizardScreen === 'landing' && !error) {
return (
Expand Down
102 changes: 95 additions & 7 deletions src/utils/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { getManifest } from './manifest'
import config from '../config'
import { createSteps, withProgress } from './progress'

// Diagnostic mode - dump GPT partition info
export const DUMP_GPT_MODE = new URLSearchParams(window.location.search).has('dump_gpt')

// Fast mode for development - skips flashing system partition (the slowest)
// Enable with ?fast=1 in URL
const FAST_MODE = new URLSearchParams(window.location.search).has('fast')
Expand Down Expand Up @@ -392,17 +395,17 @@ export class FlashManager {

try {
for await (const image of systemImages) {
const [onDownload, onFlash] = createSteps([1, image.hasAB ? 2 : 1], this.#setProgress.bind(this))
// Flash system to slot A only (large, slow), other A/B partitions to both slots
const flashBothSlots = image.hasAB && image.name !== 'system'
const [onDownload, onFlash] = createSteps([1, flashBothSlots ? 2 : 1], this.#setProgress.bind(this))

this.#setMessage(`Downloading ${image.name}`)
await this.imageManager.downloadImage(image, onDownload)
const blob = await this.imageManager.getImage(image)
onDownload(1.0)

// Flash image to each slot
const slots = image.hasAB ? ['_a', '_b'] : ['']
const slots = flashBothSlots ? ['_a', '_b'] : (image.hasAB ? ['_a'] : [''])
for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) {
// NOTE: userdata image name does not match partition name
const partitionName = `${image.name.startsWith('userdata_') ? 'userdata' : image.name}${slot}`

this.#setMessage(`Flashing ${partitionName}`)
Expand All @@ -424,10 +427,14 @@ export class FlashManager {
this.#setProgress(-1)
this.#setMessage('Finalizing...')

// Set bootable LUN and update active partitions
if (!await this.device.setActiveSlot('a')) {
console.error('[Flash] Failed to update slot')
// Set bootable LUN to slot A (LUN 1)
// GPT images already have correct A/B flags, no need to manipulate them
try {
await this.device.setBootableLun(1)
} catch (err) {
console.error('[Flash] Failed to set bootable LUN', err)
this.#setError(ErrorCode.FINALIZING_FAILED)
return
}

// Reboot the device
Expand Down Expand Up @@ -459,4 +466,85 @@ export class FlashManager {
await this.#finalize()
console.info(`Finalized in ${((performance.now() - start) / 1000).toFixed(2)}s`)
}

/**
* Diagnostic mode: connect and dump GPT from all LUNs
* @returns {Promise<string|null>} Formatted GPT dump or null on error
*/
async dumpGpt() {
this.#setStep(StepCode.CONNECTING)
this.#setProgress(-1)

let usb
try {
usb = new usbClass()
} catch (err) {
console.error('[Flash] Connection error', err)
this.#setError(ErrorCode.LOST_CONNECTION)
return null
}

try {
await this.device.connect(usb)
} catch (err) {
if (err.name === 'NotFoundError') {
console.info('[Flash] No device selected')
this.#setStep(StepCode.READY)
return null
}
console.error('[Flash] Connection error', err)
this.#setError(ErrorCode.LOST_CONNECTION)
return null
}

this.#setConnected(true)
this.#setMessage('Reading GPT...')

try {
const lines = []
lines.push('=== GPT Dump ===')
lines.push(`Time: ${new Date().toISOString()}`)
lines.push('')

// Get storage info
const storageInfo = await this.device.getStorageInfo()
lines.push('Storage Info:')
lines.push(JSON.stringify(storageInfo, null, 2))
lines.push('')

// Dump GPT from each LUN
for (let lun = 0; lun < 6; lun++) {
lines.push(`=== LUN ${lun} ===`)
try {
const gpt = await this.device.getGpt(lun)
const partitions = gpt.getPartitions()

for (const part of partitions) {
const isAB = part.name.endsWith('_a') || part.name.endsWith('_b')
let line = `${part.name}: ${part.attributes}`

if (isAB) {
// Parse A/B flags from attributes
const attrs = BigInt(part.attributes)
// Flags are at bits 48-55 (correct) or 54-61 (buggy)
const flags48 = (attrs >> 48n) & 0xFFFFn
const flags54 = (attrs >> 54n) & 0xFFFFn
line += ` [bit48: 0x${flags48.toString(16)}, bit54: 0x${flags54.toString(16)}]`
}
lines.push(line)
}
} catch (err) {
lines.push(`Error reading LUN ${lun}: ${err.message}`)
}
lines.push('')
}

this.#setStep(StepCode.DONE)
return lines.join('\n')
} catch (err) {
console.error('[Flash] Error dumping GPT', err)
this.#setError(ErrorCode.UNKNOWN)
return null
}
}
}