mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-05-13 12:55:51 +02:00
tdeck fix
This commit is contained in:
+157
-24
@@ -324,6 +324,115 @@ def pick_mt_json(build_dir: str) -> str | None:
|
||||
return fw[0] if fw else paths[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path 3 — generic nRF52 (UF2 bootloader, no *.mt.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pick_firmware_uf2(names: set[str]) -> str | None:
|
||||
"""Return the best candidate firmware *.uf2 (exclude nuke.uf2)."""
|
||||
cands = sorted(
|
||||
n for n in names
|
||||
if n.lower().endswith(".uf2") and n.lower() != "nuke.uf2"
|
||||
)
|
||||
# Prefer names starting with "firmware"
|
||||
fw = [n for n in cands if n.lower().startswith("firmware")]
|
||||
return fw[0] if fw else (cands[0] if cands else None)
|
||||
|
||||
|
||||
def emit_generic_nrf52(names: set[str]) -> dict | None:
|
||||
"""
|
||||
Emit a UF2-based manifest for nRF52 builds.
|
||||
update: firmware *.uf2 with role "uf2"
|
||||
factory (optional): nuke.uf2 (role "nuke") + firmware *.uf2, eraseFlash true
|
||||
"""
|
||||
fw_uf2 = _pick_firmware_uf2(names)
|
||||
if not fw_uf2:
|
||||
return None
|
||||
|
||||
doc: dict = {
|
||||
"update": {
|
||||
"images": [{"file": fw_uf2, "offset": 0, "role": "uf2"}],
|
||||
"eraseFlash": False,
|
||||
}
|
||||
}
|
||||
|
||||
if "nuke.uf2" in names:
|
||||
doc["factory"] = {
|
||||
"images": [
|
||||
{"file": "nuke.uf2", "offset": 0, "role": "nuke"},
|
||||
{"file": fw_uf2, "offset": 0, "role": "uf2"},
|
||||
],
|
||||
"eraseFlash": True,
|
||||
}
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path 4 — generic ESP32 (standard PlatformIO bins, no *.mt.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Standard PlatformIO ESP32 flash offsets
|
||||
_ESP32_OFFSETS: dict[str, int] = {
|
||||
"bootloader.bin": 0x1000,
|
||||
"partitions.bin": 0x8000,
|
||||
"boot_app0.bin": 0xE000,
|
||||
}
|
||||
_ESP32_APP_OFFSET = 0x10000
|
||||
|
||||
|
||||
def _pick_esp32_app_bin(names: set[str]) -> str | None:
|
||||
"""Return firmware app bin (versioned or exact), excluding factory images."""
|
||||
if "firmware.bin" in names:
|
||||
return "firmware.bin"
|
||||
cands = sorted(
|
||||
n for n in names
|
||||
if re.match(r"^firmware-.+\.bin$", n, re.I) and not re.search(r"\.factory\.bin$", n, re.I)
|
||||
)
|
||||
return cands[0] if cands else None
|
||||
|
||||
|
||||
def emit_generic_esp32(names: set[str]) -> dict | None:
|
||||
"""
|
||||
Emit a standard ESP32 manifest from PlatformIO bin output.
|
||||
update: bootloader + partitions + app (+ boot_app0 if present)
|
||||
factory (optional): firmware-*.factory.bin @ 0x0, eraseFlash true
|
||||
"""
|
||||
bootloader = "bootloader.bin" if "bootloader.bin" in names else None
|
||||
partitions = "partitions.bin" if "partitions.bin" in names else None
|
||||
app_bin = _pick_esp32_app_bin(names)
|
||||
|
||||
if not (bootloader and partitions and app_bin):
|
||||
# Not enough for a canonical ESP32 layout
|
||||
return None
|
||||
|
||||
update_images: list[dict] = [
|
||||
{"file": "bootloader.bin", "offset": _ESP32_OFFSETS["bootloader.bin"]},
|
||||
{"file": "partitions.bin", "offset": _ESP32_OFFSETS["partitions.bin"]},
|
||||
]
|
||||
if "boot_app0.bin" in names:
|
||||
update_images.append({"file": "boot_app0.bin", "offset": _ESP32_OFFSETS["boot_app0.bin"]})
|
||||
update_images.append({"file": app_bin, "offset": _ESP32_APP_OFFSET})
|
||||
update_images.sort(key=lambda im: im["offset"])
|
||||
|
||||
doc: dict = {
|
||||
"update": {"images": update_images, "eraseFlash": False}
|
||||
}
|
||||
|
||||
factory_bin = pick_factory_bin(names)
|
||||
if factory_bin:
|
||||
doc["factory"] = {
|
||||
"images": [{"file": factory_bin, "offset": 0}],
|
||||
"eraseFlash": True,
|
||||
}
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(
|
||||
@@ -346,6 +455,7 @@ def main() -> int:
|
||||
|
||||
out_path = os.path.join(build_dir, "flash-manifest.json")
|
||||
|
||||
# Path 1: existing manifest — merge targetFamily when absent
|
||||
if os.path.isfile(out_path):
|
||||
with open(out_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
@@ -366,35 +476,58 @@ def main() -> int:
|
||||
print(f"Keeping existing {out_path} (targetFamily already set)")
|
||||
return 0
|
||||
|
||||
mt_path = pick_mt_json(build_dir)
|
||||
if not mt_path:
|
||||
print("No *.mt.json; not emitting flash-manifest.json")
|
||||
return 0
|
||||
|
||||
with open(mt_path, encoding="utf-8") as f:
|
||||
mt = json.load(f)
|
||||
|
||||
names = list_basenames(build_dir)
|
||||
manifest_update = emit_meshtastic_update(build_dir, mt)
|
||||
if not manifest_update:
|
||||
print("Could not synthesize flash-manifest.json (update) from mt.json")
|
||||
|
||||
# Path 2: Meshtastic *.mt.json
|
||||
mt_path = pick_mt_json(build_dir)
|
||||
if mt_path:
|
||||
with open(mt_path, encoding="utf-8") as f:
|
||||
mt = json.load(f)
|
||||
|
||||
manifest_update = emit_meshtastic_update(build_dir, mt)
|
||||
if not manifest_update:
|
||||
print("Could not synthesize flash-manifest.json (update) from mt.json")
|
||||
return 0
|
||||
|
||||
doc: dict = {"update": manifest_update}
|
||||
factory = pick_factory_bin(names)
|
||||
if factory:
|
||||
manifest_full = emit_meshtastic_full(build_dir, mt, factory)
|
||||
if manifest_full:
|
||||
doc["factory"] = manifest_full
|
||||
else:
|
||||
print("Could not build factory section; omitting factory key", file=sys.stderr)
|
||||
else:
|
||||
print("No firmware-*.factory.bin in BUILD_DIR; omitting factory section")
|
||||
|
||||
merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board)
|
||||
_write_manifest(out_path, merged_doc)
|
||||
print(f"Wrote {out_path} (update + {'factory' if 'factory' in merged_doc else 'no factory'})")
|
||||
return 0
|
||||
|
||||
doc: dict = {"update": manifest_update}
|
||||
factory = pick_factory_bin(names)
|
||||
if factory:
|
||||
manifest_full = emit_meshtastic_full(build_dir, mt, factory)
|
||||
if manifest_full:
|
||||
doc["factory"] = manifest_full
|
||||
else:
|
||||
print("Could not build factory section; omitting factory key", file=sys.stderr)
|
||||
else:
|
||||
print("No firmware-*.factory.bin in BUILD_DIR; omitting factory section")
|
||||
# Path 3: generic nRF52 (UF2 output)
|
||||
if pio_family == "nrf52":
|
||||
doc = emit_generic_nrf52(names)
|
||||
if doc:
|
||||
merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board)
|
||||
_write_manifest(out_path, merged_doc)
|
||||
print(f"Wrote {out_path} (nRF52 UF2, update + {'factory' if 'factory' in merged_doc else 'no factory'})")
|
||||
return 0
|
||||
print("nRF52 target but no *.uf2 found in BUILD_DIR; skipping manifest", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board)
|
||||
_write_manifest(out_path, merged_doc)
|
||||
print(f"Wrote {out_path} (update + {'factory' if 'factory' in merged_doc else 'no factory'})")
|
||||
# Path 4: generic ESP32 (standard PlatformIO bin output)
|
||||
if pio_family == "esp32":
|
||||
doc = emit_generic_esp32(names)
|
||||
if doc:
|
||||
merged_doc = _merge_target_family_meta(doc, pio_family, pio_platform, pio_board)
|
||||
_write_manifest(out_path, merged_doc)
|
||||
print(f"Wrote {out_path} (ESP32 bins, update + {'factory' if 'factory' in merged_doc else 'no factory'})")
|
||||
return 0
|
||||
print("ESP32 target but could not detect bootloader+partitions+firmware in BUILD_DIR; skipping manifest", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
print(f"No manifest source found for family={pio_family!r}; skipping flash-manifest.json")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
runEspFlash,
|
||||
type FlashPhase,
|
||||
} from "../lib/espFlashRun"
|
||||
import { buildNrfPlan, isNrfFlashSupported, runNrfFlash } from "../lib/nrfFlashRun"
|
||||
import { resolveFlashTargetFamily } from "../lib/flashTargetFamily"
|
||||
import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz"
|
||||
import { extractTarGz } from "../lib/untarGz"
|
||||
@@ -38,9 +39,6 @@ const PHASE_LABEL: Record<FlashPhase, string> = {
|
||||
}
|
||||
|
||||
function unsupportedFlashMessage(family: FlashTargetFamily): string | null {
|
||||
if (family === "nrf52") {
|
||||
return "This bundle targets nRF52. In-browser flashing here uses esptool (ESP32). Use adafruit-nrfutil / nrfutil with the ZIP from Download bundle, or your board’s UF2/DFU workflow."
|
||||
}
|
||||
if (family === "rp2040") {
|
||||
return "This bundle targets RP2040. Use UF2 drag-and-drop or picotool with artifacts from Download bundle — Web Serial esptool here is for ESP32-class boards only."
|
||||
}
|
||||
@@ -146,6 +144,12 @@ export default function EspFlasher({
|
||||
toast.error("Web Serial is not supported in this browser")
|
||||
return
|
||||
}
|
||||
if (resolvedFamily === "nrf52" && !isNrfFlashSupported()) {
|
||||
toast.error("File System Access API not available", {
|
||||
description: "Use Chrome or Edge for nRF52 UF2 flashing.",
|
||||
})
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setFlashProgress({ kind: "indeterminate", label: "Select a serial port…" })
|
||||
let finishedOk = false
|
||||
@@ -157,31 +161,50 @@ export default function EspFlasher({
|
||||
|
||||
setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" })
|
||||
const files = await prepareBundle()
|
||||
const plan = buildFlashParts(files, {
|
||||
factoryInstall: eraseFlashForFactory,
|
||||
resetDeviceStorage: false,
|
||||
})
|
||||
if (!plan) {
|
||||
toast.error("Could not detect flash layout from bundle")
|
||||
return
|
||||
|
||||
if (resolvedFamily === "nrf52") {
|
||||
const manifest = manifestFromMap(files)
|
||||
const plan = buildNrfPlan(files, manifest, eraseFlashForFactory)
|
||||
if (!plan) {
|
||||
toast.error("No UF2 found in bundle")
|
||||
return
|
||||
}
|
||||
await runNrfFlash({
|
||||
plan,
|
||||
onPhase: label => {
|
||||
setFlashProgress({ kind: "indeterminate", label })
|
||||
},
|
||||
onWriteProgress: p => {
|
||||
setFlashProgress({ kind: "determinate", label: "Writing firmware…", pct: p.pct })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const plan = buildFlashParts(files, {
|
||||
factoryInstall: eraseFlashForFactory,
|
||||
resetDeviceStorage: false,
|
||||
})
|
||||
if (!plan) {
|
||||
toast.error("Could not detect flash layout from bundle")
|
||||
return
|
||||
}
|
||||
await runEspFlash({
|
||||
port,
|
||||
parts: plan.parts,
|
||||
baud: ESP_FLASH_WEB_BAUD,
|
||||
eraseAll: plan.eraseAll,
|
||||
onPhase: phase => {
|
||||
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
|
||||
},
|
||||
onWriteProgress: p => {
|
||||
setFlashProgress({
|
||||
kind: "determinate",
|
||||
label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`,
|
||||
pct: p.overallPct,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runEspFlash({
|
||||
port,
|
||||
parts: plan.parts,
|
||||
baud: ESP_FLASH_WEB_BAUD,
|
||||
eraseAll: plan.eraseAll,
|
||||
onPhase: phase => {
|
||||
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
|
||||
},
|
||||
onWriteProgress: p => {
|
||||
setFlashProgress({
|
||||
kind: "determinate",
|
||||
label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`,
|
||||
pct: p.overallPct,
|
||||
})
|
||||
},
|
||||
})
|
||||
finishedOk = true
|
||||
setFlashProgress({ kind: "complete" })
|
||||
toast.success("Flash complete")
|
||||
@@ -189,6 +212,10 @@ export default function EspFlasher({
|
||||
if (isSerialUserCancelledError(e)) {
|
||||
return
|
||||
}
|
||||
// User dismissed the directory picker (nRF52 UF2 flow)
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
return
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error("Flash failed", { description: msg })
|
||||
} finally {
|
||||
@@ -200,7 +227,7 @@ export default function EspFlasher({
|
||||
setFlashProgress(null)
|
||||
}
|
||||
}
|
||||
}, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason])
|
||||
}, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason, resolvedFamily])
|
||||
|
||||
const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl])
|
||||
const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { findInTar } from './untarGz'
|
||||
import type { FlashManifest } from './untarGz'
|
||||
|
||||
export type NrfFlashPlan = {
|
||||
/** Written first when chip erase is requested; causes device to erase all flash and reboot. */
|
||||
nukeFile?: Uint8Array
|
||||
/** The application UF2 to write after optional nuke. */
|
||||
firmwareFile: Uint8Array
|
||||
firmwareName: string
|
||||
}
|
||||
|
||||
export type NrfWriteProgressPayload = {
|
||||
phase: 'nuke' | 'firmware'
|
||||
written: number
|
||||
total: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
function sleepMs(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function pickDirectory(): Promise<FileSystemDirectoryHandle> {
|
||||
// showDirectoryPicker is present in Chromium but typed as unknown in some TS DOM versions.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const picker = (window as any).showDirectoryPicker as (() => Promise<FileSystemDirectoryHandle>) | undefined
|
||||
if (!picker) throw new Error('File System Access API is not available in this browser. Use Chrome or Edge.')
|
||||
return picker.call(window)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an nRF52 flash plan from the bundle tarball + parsed manifest.
|
||||
* Reads the `update` section for a normal flash, or `factory` when factoryInstall is true.
|
||||
* Images with role "uf2" are the firmware; role "nuke" is the erase-all UF2.
|
||||
*/
|
||||
export function buildNrfPlan(
|
||||
files: Map<string, Uint8Array>,
|
||||
manifest: FlashManifest | null,
|
||||
factoryInstall: boolean
|
||||
): NrfFlashPlan | null {
|
||||
const section = factoryInstall ? (manifest?.factory ?? manifest?.update) : manifest?.update
|
||||
if (!section?.images?.length) return null
|
||||
|
||||
let firmwareFile: Uint8Array | undefined
|
||||
let firmwareName: string | undefined
|
||||
let nukeFile: Uint8Array | undefined
|
||||
|
||||
for (const img of section.images) {
|
||||
const role = img.role?.toLowerCase()
|
||||
if (role === 'nuke') {
|
||||
nukeFile = findInTar(files, img.file)
|
||||
} else if (role === 'uf2' || img.file.toLowerCase().endsWith('.uf2')) {
|
||||
if (!firmwareFile) {
|
||||
firmwareFile = findInTar(files, img.file)
|
||||
firmwareName = img.file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!firmwareFile || !firmwareName) return null
|
||||
return { firmwareFile, firmwareName, nukeFile }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single Uint8Array to a file in a directory handle, replacing any existing file.
|
||||
*/
|
||||
async function writeToDir(dirHandle: FileSystemDirectoryHandle, filename: string, data: Uint8Array): Promise<void> {
|
||||
const fileHandle = await dirHandle.getFileHandle(filename, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
try {
|
||||
// Slice to a plain ArrayBuffer to satisfy FileSystemWriteChunkType
|
||||
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer
|
||||
await writable.write(buf)
|
||||
} finally {
|
||||
await writable.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash an nRF52 device via UF2 using the File System Access API.
|
||||
*
|
||||
* Flow (normal):
|
||||
* 1. showDirectoryPicker — user selects the UF2 drive
|
||||
* 2. Write firmware.uf2 → device reboots
|
||||
*
|
||||
* Flow (chip erase / factory):
|
||||
* 1. showDirectoryPicker — user selects the UF2 drive
|
||||
* 2. Write nuke.uf2 → device erases all flash and re-enters bootloader (~3 s)
|
||||
* 3. showDirectoryPicker again — user selects the re-enumerated UF2 drive
|
||||
* 4. Write firmware.uf2 → device reboots
|
||||
*/
|
||||
export async function runNrfFlash(options: {
|
||||
plan: NrfFlashPlan
|
||||
onPhase: (label: string) => void
|
||||
onWriteProgress: (p: NrfWriteProgressPayload) => void
|
||||
}): Promise<void> {
|
||||
const { plan, onPhase, onWriteProgress } = options
|
||||
|
||||
if (plan.nukeFile) {
|
||||
onPhase('Select the UF2 drive to erase…')
|
||||
const nukeDir = await pickDirectory()
|
||||
|
||||
onPhase('Erasing device flash…')
|
||||
onWriteProgress({ phase: 'nuke', written: 0, total: plan.nukeFile.byteLength, pct: 0 })
|
||||
await writeToDir(nukeDir, 'nuke.uf2', plan.nukeFile)
|
||||
onWriteProgress({ phase: 'nuke', written: plan.nukeFile.byteLength, total: plan.nukeFile.byteLength, pct: 100 })
|
||||
|
||||
// Device erases flash and re-enters UF2 bootloader; drive disappears then reappears.
|
||||
onPhase('Waiting for device to re-enumerate…')
|
||||
await sleepMs(3500)
|
||||
|
||||
onPhase('Select the UF2 drive again…')
|
||||
const fwDir = await pickDirectory()
|
||||
|
||||
onPhase('Writing firmware…')
|
||||
onWriteProgress({ phase: 'firmware', written: 0, total: plan.firmwareFile.byteLength, pct: 0 })
|
||||
await writeToDir(fwDir, plan.firmwareName, plan.firmwareFile)
|
||||
onWriteProgress({ phase: 'firmware', written: plan.firmwareFile.byteLength, total: plan.firmwareFile.byteLength, pct: 100 })
|
||||
} else {
|
||||
onPhase('Select the UF2 drive that appeared…')
|
||||
const dir = await pickDirectory()
|
||||
|
||||
onPhase('Writing firmware…')
|
||||
onWriteProgress({ phase: 'firmware', written: 0, total: plan.firmwareFile.byteLength, pct: 0 })
|
||||
await writeToDir(dir, plan.firmwareName, plan.firmwareFile)
|
||||
onWriteProgress({ phase: 'firmware', written: plan.firmwareFile.byteLength, total: plan.firmwareFile.byteLength, pct: 100 })
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the File System Access API directory picker is available (Chromium). */
|
||||
export function isNrfFlashSupported(): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return typeof (window as any).showDirectoryPicker === 'function'
|
||||
}
|
||||
Reference in New Issue
Block a user