diff --git a/src/app/page.tsx b/src/app/page.tsx index bedc09c..29009ef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,7 +10,10 @@ import { Alert, Stack, Flex, + HStack, + Text, } from '@chakra-ui/react'; +import type { DeviceModel } from '@/esp/useEspOperations'; import FileUpload, { FileUploadHandle } from '@/components/FileUpload'; import Steps from '@/components/Steps'; import { useEspOperations } from '@/esp/useEspOperations'; @@ -20,7 +23,8 @@ import { } from '@/remote/firmwareFetcher'; export default function Home() { - const { actions, stepData, isRunning } = useEspOperations(); + const { actions, stepData, isRunning, deviceModel, setDeviceModel } = + useEspOperations(); const [officialFirmwareVersions, setOfficialFirmwareVersions] = useState<{ en: string; ch: string; @@ -32,15 +36,42 @@ export default function Home() { const appPartitionFileInput = useRef(null); useEffect(() => { - getOfficialFirmwareVersions().then((versions) => - setOfficialFirmwareVersions(versions), - ); + let cancelled = false; + setOfficialFirmwareVersions(null); + getOfficialFirmwareVersions(deviceModel).then((versions) => { + if (!cancelled) { + setOfficialFirmwareVersions(versions); + } + }); + return () => { + cancelled = true; + }; + }, [deviceModel]); + + useEffect(() => { getCommunityFirmwareRemoteData().then(setCommunityFirmwareVersions); }, []); return ( + + Device model + + {(['x4', 'x3'] as const).map((model) => ( + + ))} + + + @@ -208,8 +239,8 @@ export default function Home() { Change device language Before starting the process, it is recommended to change the device - language to English. To do this, select “Settings” icon, then click - “OK / Confirm” button and “OK / Confirm” again until English is + language to English. To do this, select “Settings” icon, then click + “OK / Confirm” button and “OK / Confirm” again until English is shown. Otherwise, the language will still be Chinese after flashing and you may not notice changes. @@ -220,13 +251,23 @@ export default function Home() { Device restart instructions - Once you complete a write operation, you will need to restart your - device by pressing and releasing the small “Reset” button near the - bottom right, followed quickly by pressing and holding of the main - power button for about 3 seconds. + +

+ Once you complete a write operation, you will need to restart + your device by pressing and releasing the small “Reset” button + near the bottom right, followed quickly by pressing and holding + of the main power button for about 3 seconds. +

+ {deviceModel === 'x3' && ( +

+ For CrossPoint firmware, disconnect the USB cable and connect + it again instead. +

+ )} +
); -} +} \ No newline at end of file diff --git a/src/esp/EspController.ts b/src/esp/EspController.ts index c472016..611c679 100644 --- a/src/esp/EspController.ts +++ b/src/esp/EspController.ts @@ -41,6 +41,24 @@ const PARTITION_TYPES: Record> = { }, }; +export interface PartitionLayout { + app0Offset: number; + app1Offset: number; + appSize: number; +} + +export const X4_PARTITION_LAYOUT: PartitionLayout = { + app0Offset: 0x10000, + app1Offset: 0x650000, + appSize: 0x640000, +}; + +export const X3_PARTITION_LAYOUT: PartitionLayout = { + app0Offset: 0x10000, + app1Offset: 0x780000, + appSize: 0x770000, +}; + export default class EspController { static async requestDevice() { if (!('serial' in navigator && navigator.serial)) { @@ -54,15 +72,19 @@ export default class EspController { }); } - static async fromRequestedDevice() { + static async fromRequestedDevice( + partitionLayout: PartitionLayout = X4_PARTITION_LAYOUT, + ) { const device = await this.requestDevice(); - return new EspController(device); + return new EspController(device, partitionLayout); } private espLoader; + private layout: PartitionLayout; - constructor(device: SerialPort) { + constructor(device: SerialPort, partitionLayout: PartitionLayout = X4_PARTITION_LAYOUT) { const transport = new Transport(device, false); + this.layout = partitionLayout; this.espLoader = new ESPLoader({ transport, baudrate: 115200, @@ -71,12 +93,16 @@ export default class EspController { }); } + setPartitionLayout(layout: PartitionLayout) { + this.layout = layout; + } + async connect() { await this.espLoader.main(); } async disconnect({ skipReset = false }: { skipReset?: boolean } = {}) { - await this.espLoader.after(skipReset ? 'no_reset' : 'hard_reset'); + await this.espLoader.after(skipReset ? 'no_reset_stub' : 'hard_reset'); await this.espLoader.transport.disconnect(); } @@ -180,8 +206,11 @@ export default class EspController { totalSize: number, ) => void, ) { - const offset = partitionLabel === 'app0' ? 0x10000 : 0x650000; - return this.espLoader.readFlash(offset, 0x640000, onPacketReceived); + const offset = + partitionLabel === 'app0' + ? this.layout.app0Offset + : this.layout.app1Offset; + return this.espLoader.readFlash(offset, this.layout.appSize, onPacketReceived); } async readAppPartitionForIdentification( @@ -206,7 +235,10 @@ export default class EspController { // In testing, most firmwares are identified within the first 25KB read, so reading the entire // partition is unnecessary in the majority of cases. - const baseOffset = partitionLabel === 'app0' ? 0x10000 : 0x650000; + const baseOffset = + partitionLabel === 'app0' + ? this.layout.app0Offset + : this.layout.app1Offset; return this.espLoader.readFlash( baseOffset + offset, @@ -224,8 +256,10 @@ export default class EspController { total: number, ) => void, ) { - if (data.length > 0x640000) { - throw new Error(`Data cannot be larger than 0x640000`); + if (data.length > this.layout.appSize) { + throw new Error( + `Data cannot be larger than 0x${this.layout.appSize.toString(16)}`, + ); } if (data.length < 0xf0000) { throw new Error( @@ -233,7 +267,10 @@ export default class EspController { ); } - const offset = partitionLabel === 'app0' ? 0x10000 : 0x650000; + const offset = + partitionLabel === 'app0' + ? this.layout.app0Offset + : this.layout.app1Offset; await this.writeData(data, offset, reportProgress); } diff --git a/src/esp/useEspOperations.ts b/src/esp/useEspOperations.ts index 57fcba8..9c430cb 100644 --- a/src/esp/useEspOperations.ts +++ b/src/esp/useEspOperations.ts @@ -14,21 +14,88 @@ import { } from '@/utils/firmwareIdentifier'; import OtaPartition, { OtaPartitionDetails } from './OtaPartition'; import useStepRunner from './useStepRunner'; -import EspController from './EspController'; - -const expectedPartitionTable = [ - { type: 'data-nvs', offset: 36864, size: 20480 }, - { type: 'data-ota', offset: 57344, size: 8192 }, - { type: 'app-ota_0', offset: 65536, size: 6553600 }, - { type: 'app-ota_1', offset: 6619136, size: 6553600 }, - { type: 'data-spiffs', offset: 13172736, size: 3538944 }, - { type: 'data-coredump', offset: 16711680, size: 65536 }, +import EspController, { + X3_PARTITION_LAYOUT, + X4_PARTITION_LAYOUT, +} from './EspController'; + +const x4PartitionTable = [ + { type: 'data-nvs', offset: 0x9000, size: 0x5000 }, + { type: 'data-ota', offset: 0xe000, size: 0x2000 }, + { type: 'app-ota_0', offset: 0x10000, size: 0x640000 }, + { type: 'app-ota_1', offset: 0x650000, size: 0x640000 }, + { type: 'data-spiffs', offset: 0xc90000, size: 0x360000 }, + { type: 'data-coredump', offset: 0xff0000, size: 0x10000 }, ]; +const x3PartitionTable = [ + { type: 'data-nvs', offset: 0x9000, size: 0x5000 }, + { type: 'data-ota', offset: 0xe000, size: 0x2000 }, + { type: 'app-ota_0', offset: 0x10000, size: 0x770000 }, + { type: 'app-ota_1', offset: 0x780000, size: 0x770000 }, + { type: 'data-spiffs', offset: 0xef0000, size: 0x100000 }, + { type: 'data-coredump', offset: 0xff0000, size: 0x10000 }, +]; + +type PartitionEntry = { type: string; offset: number; size: number }; + +function matchesPartitionTable( + actual: PartitionEntry[], + expected: PartitionEntry[], +) { + return ( + actual.length === expected.length && + expected.every( + (exp, i) => + actual[i]!.type === exp.type && + actual[i]!.offset === exp.offset && + actual[i]!.size === exp.size, + ) + ); +} + +export type DeviceModel = 'x4' | 'x3'; + export function useEspOperations() { const { stepData, initializeSteps, updateStepData, runStep } = useStepRunner(); const [isRunning, setIsRunning] = useState(false); + const [deviceModel, setDeviceModel] = useState('x4'); + + const resetStepName = 'Reset device'; + const softResetStepName = + 'Disconnect (unplug and replug USB to restart)'; + + const validateAndDetectPartitionLayout = async ( + espController: EspController, + ) => { + const partitionTable = await espController.readPartitionTable(); + + const validTables = + deviceModel === 'x3' + ? [x3PartitionTable, x4PartitionTable] + : [x4PartitionTable]; + + const matched = validTables.find((t) => + matchesPartitionTable(partitionTable, t), + ); + + if (!matched) { + throw new Error( + `Unexpected partition configuration for ${deviceModel.toUpperCase()}. Make sure you've selected the correct device model.\nGot ${JSON.stringify( + partitionTable, + null, + 2, + )}`, + ); + } + + espController.setPartitionLayout( + matchesPartitionTable(partitionTable, x3PartitionTable) + ? X3_PARTITION_LAYOUT + : X4_PARTITION_LAYOUT, + ); + }; const wrapWithRunning = (fn: (...a: Args) => Promise) => @@ -39,7 +106,9 @@ export function useEspOperations() { const flashRemoteFirmware = async ( getFirmware: () => Promise, + { skipReset = false }: { skipReset?: boolean } = {}, ) => { + const stepName = skipReset ? softResetStepName : resetStepName; initializeSteps([ 'Connect to device', 'Validate partition table', @@ -47,35 +116,20 @@ export function useEspOperations() { 'Read otadata partition', 'Flash app partition', 'Flash otadata partition', - 'Reset device', + stepName, ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); - await runStep('Validate partition table', async () => { - const partitionTable = await espController.readPartitionTable(); - if ( - partitionTable.length !== expectedPartitionTable.length || - expectedPartitionTable.some( - (expected, index) => - partitionTable[index]!.type !== expected.type || - partitionTable[index]!.offset !== expected.offset || - partitionTable[index]!.size !== expected.size, - ) - ) { - throw new Error( - `Unexpected partition configuration. You can only use OTA fast flash controls on devices running CrossPoint or official firmware with the default partition table.\nGot ${JSON.stringify( - partitionTable, - null, - 2, - )}`, - ); - } - }); + await runStep('Validate partition table', () => + validateAndDetectPartitionLayout(espController), + ); const firmwareFile = await runStep('Download firmware', getFirmware); @@ -117,15 +171,19 @@ export function useEspOperations() { ); }); - await runStep('Reset device', () => espController.disconnect()); + await runStep(stepName, () => + espController.disconnect({ skipReset }), + ); }; const flashEnglishFirmware = async () => - flashRemoteFirmware(() => getOfficialFirmware('en')); + flashRemoteFirmware(() => getOfficialFirmware('en', deviceModel)); const flashChineseFirmware = async () => - flashRemoteFirmware(() => getOfficialFirmware('ch')); + flashRemoteFirmware(() => getOfficialFirmware('ch', deviceModel)); const flashCrossPointFirmware = async () => - flashRemoteFirmware(() => getCommunityFirmware('CrossPoint')); + flashRemoteFirmware(() => getCommunityFirmware('CrossPoint'), { + skipReset: deviceModel === 'x3', + }); const flashCustomFirmware = async (getFile: () => File | undefined) => { initializeSteps([ @@ -135,7 +193,7 @@ export function useEspOperations() { 'Read otadata partition', 'Flash app partition', 'Flash otadata partition', - 'Reset device', + resetStepName, ]); const fileData = await runStep('Read file', async () => { @@ -147,31 +205,16 @@ export function useEspOperations() { }); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); - await runStep('Validate partition table', async () => { - const partitionTable = await espController.readPartitionTable(); - if ( - partitionTable.length !== expectedPartitionTable.length || - expectedPartitionTable.some( - (expected, index) => - partitionTable[index]!.type !== expected.type || - partitionTable[index]!.offset !== expected.offset || - partitionTable[index]!.size !== expected.size, - ) - ) { - throw new Error( - `Unexpected partition configuration. You can only use OTA fast flash controls on devices running CrossPoint or official firmware with the default partition table.\nGot ${JSON.stringify( - partitionTable, - null, - 2, - )}`, - ); - } - }); + await runStep('Validate partition table', () => + validateAndDetectPartitionLayout(espController), + ); const [otaPartition, backupPartitionLabel] = await runStep( 'Read otadata partition', @@ -211,7 +254,7 @@ export function useEspOperations() { ); }); - await runStep('Reset device', () => espController.disconnect()); + await runStep(resetStepName, () => espController.disconnect()); }; const saveFullFlash = async () => { @@ -222,7 +265,9 @@ export function useEspOperations() { ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); @@ -248,7 +293,7 @@ export function useEspOperations() { 'Read file', 'Connect to device', 'Write flash', - 'Reset device', + resetStepName, ]); const fileData = await runStep('Read file', async () => { @@ -260,7 +305,9 @@ export function useEspOperations() { }); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); @@ -271,7 +318,7 @@ export function useEspOperations() { ), ); - await runStep('Reset device', () => espController.disconnect()); + await runStep(resetStepName, () => espController.disconnect()); }; const readDebugOtadata = async () => { @@ -282,7 +329,9 @@ export function useEspOperations() { ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); @@ -305,16 +354,23 @@ export function useEspOperations() { const readAppPartition = async (partitionLabel: 'app0' | 'app1') => { initializeSteps([ 'Connect to device', + 'Validate partition table', `Read app partition (${partitionLabel})`, 'Disconnect from device', ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); + await runStep('Validate partition table', () => + validateAndDetectPartitionLayout(espController), + ); + const data = await runStep(`Read app partition (${partitionLabel})`, () => espController.readAppPartition(partitionLabel, (_, p, t) => updateStepData(`Read app partition (${partitionLabel})`, { @@ -335,11 +391,13 @@ export function useEspOperations() { 'Connect to device', 'Read otadata partition', 'Flash otadata partition', - 'Reset device', + resetStepName, ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); @@ -368,7 +426,7 @@ export function useEspOperations() { ), ); - await runStep('Reset device', () => espController.disconnect()); + await runStep(resetStepName, () => espController.disconnect()); return otaPartition; }; @@ -378,7 +436,7 @@ export function useEspOperations() { 'Read file', 'Connect to device', 'Write flash', - 'Reset device', + resetStepName, ]); await runStep( @@ -424,7 +482,7 @@ export function useEspOperations() { ); await runStep( - 'Reset device', + resetStepName, () => new Promise((resolve) => { setTimeout(resolve, 500); @@ -439,6 +497,7 @@ export function useEspOperations() { }> => { initializeSteps([ 'Connect to device', + 'Validate partition table', 'Read otadata partition', 'Read app0 partition', 'Read app1 partition', @@ -447,11 +506,17 @@ export function useEspOperations() { ]); const espController = await runStep('Connect to device', async () => { - const c = await EspController.fromRequestedDevice(); + const c = await EspController.fromRequestedDevice( + deviceModel === 'x3' ? X3_PARTITION_LAYOUT : X4_PARTITION_LAYOUT, + ); await c.connect(); return c; }); + await runStep('Validate partition table', () => + validateAndDetectPartitionLayout(espController), + ); + const otaPartition = await runStep('Read otadata partition', () => espController.readOtadataPartition((_, p, t) => updateStepData('Read otadata partition', { @@ -530,6 +595,8 @@ export function useEspOperations() { return { stepData, isRunning, + deviceModel, + setDeviceModel, actions: { flashEnglishFirmware: wrapWithRunning(flashEnglishFirmware), flashChineseFirmware: wrapWithRunning(flashChineseFirmware), diff --git a/src/remote/firmwareFetcher.ts b/src/remote/firmwareFetcher.ts index 7fecb86..687f7b4 100644 --- a/src/remote/firmwareFetcher.ts +++ b/src/remote/firmwareFetcher.ts @@ -2,6 +2,8 @@ import { getCache } from '@vercel/functions'; +type DeviceModel = 'x4' | 'x3'; + interface OfficialFirmwareData { change_log: string; download_url: string; @@ -21,7 +23,7 @@ interface CommunityFirmwareVersions { }; } -const firmwareVersionFallback: OfficialFirmwareVersions = { +const x4FirmwareVersionFallback: OfficialFirmwareVersions = { en: { change_log: '1. Optimize EPUB/TXT \r\n2. Optimize JPG speed \r\n3. Optimize Wi-Fi connection \r\n4. Optimize EPUB covers', @@ -38,23 +40,70 @@ const firmwareVersionFallback: OfficialFirmwareVersions = { }, }; -const chineseFirmwareCheckUrl = +const x3FirmwareVersionFallback: OfficialFirmwareVersions = { + en: { + change_log: + '1. Optimize EPUB\r\n2. Fix a large number of bugs\r\n3. The index needs to be rebuilt manually', + download_url: + 'http://8.216.34.42:5001/api/v1/download/ESP32C3_X3/V5.1.6/V5.1.6-X3-EN-PROD-0304_.bin?choose=1&lang=en', + version: 'V5.1.6', + }, + ch: { + change_log: '', + download_url: + 'https://domestic-upload-file-api.oss-cn-hangzhou.aliyuncs.com/admin_uploads/firmware/202603/26/751e134f-22b1-4a00-bbfa-0942593ef867/V5.2.13-X3-CH-PROD-0326_173844.bin', + version: 'V5.2.13', + }, +}; + +const x4ChineseFirmwareCheckUrl = 'http://47.122.74.33:5000/api/check-update?current_version=V3.0.1&device_type=ESP32C3'; -const englishFirmwareCheckUrl = +const x4EnglishFirmwareCheckUrl = 'http://gotaserver.xteink.com/api/check-update?current_version=V3.0.1&device_type=ESP32C3&device_id=1234'; - -export async function getOfficialFirmwareRemoteData(): Promise { +const x3ChineseFirmwareCheckUrl = + 'https://api-prod.xteink.cn/api/v1/check-update?current_version=V5.1.3&device_type=ESP32C3_X3&device_id=1052463&choose=1&lang=zh'; +const x3EnglishFirmwareCheckUrl = + 'http://8.216.34.42:5001/api/v1/check-update?current_version=V5.1.3&device_type=ESP32C3_X3&device_id=1052463&choose=1&lang=en'; + +export async function getOfficialFirmwareRemoteData( + deviceModel: DeviceModel, +): Promise { const cache = getCache(); - const cacheKey = 'firmware-versions.official.v1'; + const cacheKey = `firmware-versions.official.${deviceModel}.v1`; + const fallback = + deviceModel === 'x3' + ? x3FirmwareVersionFallback + : x4FirmwareVersionFallback; const value = (await cache.get(cacheKey)) as OfficialFirmwareVersions | null; if (value) { return value; } + if (deviceModel === 'x3') { + return Promise.all([ + fetch(x3ChineseFirmwareCheckUrl), + fetch(x3EnglishFirmwareCheckUrl), + ]) + .then(([chRes, enRes]) => Promise.all([chRes.json(), enRes.json()])) + .then(async ([chData, enData]) => { + const data: OfficialFirmwareVersions = { + en: enData.data, + ch: chData.data, + }; + + await cache.set(cacheKey, data, { + ttl: 60 * 60 * 24, // 24 hours + }); + + return data; + }) + .catch(() => fallback); + } + return Promise.all([ - fetch(chineseFirmwareCheckUrl), - fetch(englishFirmwareCheckUrl), + fetch(x4ChineseFirmwareCheckUrl), + fetch(x4EnglishFirmwareCheckUrl), ]) .then(([chRes, enRes]) => Promise.all([chRes.json(), enRes.json()])) .then(async ([chData, enData]) => { @@ -69,11 +118,11 @@ export async function getOfficialFirmwareRemoteData(): Promise firmwareVersionFallback); + .catch(() => fallback); } -export async function getOfficialFirmwareVersions() { - const data = await getOfficialFirmwareRemoteData(); +export async function getOfficialFirmwareVersions(deviceModel: DeviceModel) { + const data = await getOfficialFirmwareRemoteData(deviceModel); return { en: data.en.version, @@ -118,8 +167,11 @@ export async function getCommunityFirmwareRemoteData(): Promise data[region].download_url, ); const response = await fetch(url);