mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
split firmware flashing from lwp3-bootloader
BLE won't always be the only way to flash firmware
This commit is contained in:
committed by
David Lechner
parent
6d81da0df0
commit
07d0b5ef39
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
import { Action } from 'redux';
|
||||
|
||||
/**
|
||||
* High-level bootloader actions.
|
||||
*/
|
||||
export enum FlashFirmwareActionType {
|
||||
/**
|
||||
* Flash new firmware to the device.
|
||||
*/
|
||||
FlashFirmware = 'flashFirmware.action.flashFirmware',
|
||||
/**
|
||||
* Firmware flash progress.
|
||||
*/
|
||||
Progress = 'flashFirmware.action.progress',
|
||||
}
|
||||
|
||||
/**
|
||||
* Action that flashes firmware to a hub.
|
||||
*/
|
||||
export interface FlashFirmwareFlashAction
|
||||
extends Action<FlashFirmwareActionType.FlashFirmware> {
|
||||
/** The firmware zip file data or undefined to get firmware later. */
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new action to flash firmware to a hub.
|
||||
* @param data The firmware zip file data or undefined to get firmware later.
|
||||
*/
|
||||
export function flashFirmware(data?: ArrayBuffer): FlashFirmwareFlashAction {
|
||||
return { type: FlashFirmwareActionType.FlashFirmware, data };
|
||||
}
|
||||
|
||||
export interface FlashFirmwareProgressAction
|
||||
extends Action<FlashFirmwareActionType.Progress> {
|
||||
/**
|
||||
* The number of bytes that have been flashed so far.
|
||||
*/
|
||||
complete: number;
|
||||
/**
|
||||
* The total number of bytes to be flashed.
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function progress(complete: number, total: number): FlashFirmwareProgressAction {
|
||||
return { type: FlashFirmwareActionType.Progress, complete, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Common type for all high-level bootloader actions.
|
||||
*/
|
||||
export type FlashFirmwareAction =
|
||||
| FlashFirmwareFlashAction
|
||||
| FlashFirmwareProgressAction;
|
||||
@@ -4,9 +4,9 @@
|
||||
import { Dispatch as ReduxDispatch } from 'redux';
|
||||
import { BLEAction, BLEConnectAction, BLEDataAction } from './ble';
|
||||
import { EditorAction } from './editor';
|
||||
import { FlashFirmwareAction } from './flash-firmware';
|
||||
import { HubAction, HubMessageAction } from './hub';
|
||||
import {
|
||||
BootloaderAction,
|
||||
BootloaderConnectionAction,
|
||||
BootloaderDidRequestAction,
|
||||
BootloaderRequestAction,
|
||||
@@ -21,17 +21,17 @@ import { TerminalDataAction } from './terminal';
|
||||
* Common type for all actions.
|
||||
*/
|
||||
export type Action =
|
||||
| BLEAction
|
||||
| BLEConnectAction
|
||||
| BLEDataAction
|
||||
| BLEAction
|
||||
| BootloaderConnectionAction
|
||||
| BootloaderRequestAction
|
||||
| BootloaderDidRequestAction
|
||||
| BootloaderRequestAction
|
||||
| BootloaderResponseAction
|
||||
| BootloaderAction
|
||||
| EditorAction
|
||||
| HubMessageAction
|
||||
| FlashFirmwareAction
|
||||
| HubAction
|
||||
| HubMessageAction
|
||||
| MpyAction
|
||||
| NotificationAction
|
||||
| ServiceWorkerAction
|
||||
|
||||
@@ -463,60 +463,3 @@ export type BootloaderResponseAction =
|
||||
| BootloaderChecksumResponseAction
|
||||
| BootloaderStateResponseAction
|
||||
| BootloaderErrorResponseAction;
|
||||
|
||||
/**
|
||||
* High-level bootloader actions.
|
||||
*/
|
||||
export enum BootloaderActionType {
|
||||
/**
|
||||
* Flash new firmware to the device.
|
||||
*/
|
||||
FlashFirmware = 'bootloader.action.flash',
|
||||
/**
|
||||
* Firmware flash progress.
|
||||
*/
|
||||
FlashProgress = 'bootloader.action.flash.progress',
|
||||
}
|
||||
|
||||
/**
|
||||
* Action that flashes firmware to a hub.
|
||||
*/
|
||||
export interface BootloaderFlashFirmwareAction
|
||||
extends Action<BootloaderActionType.FlashFirmware> {
|
||||
/** The firmware zip file data or undefined to get firmware later. */
|
||||
data?: ArrayBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new action to flash firmware to a hub.
|
||||
* @param data The firmware zip file data or undefined to get firmware later.
|
||||
*/
|
||||
export function flashFirmware(data?: ArrayBuffer): BootloaderFlashFirmwareAction {
|
||||
return { type: BootloaderActionType.FlashFirmware, data };
|
||||
}
|
||||
|
||||
export interface BootloaderFlashProgressAction
|
||||
extends Action<BootloaderActionType.FlashProgress> {
|
||||
/**
|
||||
* The number of bytes that have been flashed so far.
|
||||
*/
|
||||
complete: number;
|
||||
/**
|
||||
* The total number of bytes to be flashed.
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function progress(
|
||||
complete: number,
|
||||
total: number,
|
||||
): BootloaderFlashProgressAction {
|
||||
return { type: BootloaderActionType.FlashProgress, complete, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Common type for all high-level bootloader actions.
|
||||
*/
|
||||
export type BootloaderAction =
|
||||
| BootloaderFlashFirmwareAction
|
||||
| BootloaderFlashProgressAction;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from '../actions';
|
||||
import { flashFirmware } from '../actions/lwp3-bootloader';
|
||||
import { flashFirmware } from '../actions/flash-firmware';
|
||||
import * as notification from '../actions/notification';
|
||||
import { RootState } from '../reducers';
|
||||
import { BootloaderConnectionState } from '../reducers/bootloader';
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
import { Reducer } from 'react';
|
||||
import { combineReducers } from 'redux';
|
||||
import { BootloaderAction, BootloaderActionType } from '../actions/lwp3-bootloader';
|
||||
import { Action } from '../actions';
|
||||
import { FlashFirmwareActionType } from '../actions/flash-firmware';
|
||||
|
||||
const progress: Reducer<number, BootloaderAction> = (state = -1, action) => {
|
||||
const progress: Reducer<number, Action> = (state = -1, action) => {
|
||||
switch (action.type) {
|
||||
case BootloaderActionType.FlashProgress:
|
||||
case FlashFirmwareActionType.Progress:
|
||||
return action.complete / action.total;
|
||||
default:
|
||||
return state;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
import cPlusHubZip from '@pybricks/firmware/build/cplushub.zip';
|
||||
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
|
||||
import JSZip from 'jszip';
|
||||
import { Effect, call, delay, put, race, take, takeEvery } from 'redux-saga/effects';
|
||||
import { Action } from '../actions';
|
||||
import {
|
||||
FlashFirmwareActionType,
|
||||
FlashFirmwareFlashAction,
|
||||
progress,
|
||||
} from '../actions/flash-firmware';
|
||||
import {
|
||||
BootloaderChecksumResponseAction,
|
||||
BootloaderConnectionActionType,
|
||||
BootloaderConnectionDidConnectAction,
|
||||
BootloaderConnectionDidFailToConnectAction,
|
||||
BootloaderDidRequestAction,
|
||||
BootloaderDidRequestType,
|
||||
BootloaderEraseResponseAction,
|
||||
BootloaderErrorResponseAction,
|
||||
BootloaderInfoResponseAction,
|
||||
BootloaderInitResponseAction,
|
||||
BootloaderProgramRequestAction,
|
||||
BootloaderProgramResponseAction,
|
||||
BootloaderResponseAction,
|
||||
BootloaderResponseActionType,
|
||||
checksumRequest,
|
||||
connect,
|
||||
disconnectRequest,
|
||||
eraseRequest,
|
||||
infoRequest,
|
||||
initRequest,
|
||||
programRequest,
|
||||
rebootRequest,
|
||||
} from '../actions/lwp3-bootloader';
|
||||
import {
|
||||
MpyActionType,
|
||||
MpyDidCompileAction,
|
||||
MpyDidFailToCompileAction,
|
||||
compile,
|
||||
} from '../actions/mpy';
|
||||
import * as notification from '../actions/notification';
|
||||
import { HubType, MaxProgramFlashSize } from '../protocols/lwp3-bootloader';
|
||||
import { fmod, sumComplement32 } from '../utils/math';
|
||||
|
||||
const firmwareZipMap = new Map<HubType, string>([
|
||||
[HubType.CPlusHub, cPlusHubZip],
|
||||
[HubType.MoveHub, moveHubZip],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Helper type for return value of wait() function.
|
||||
*/
|
||||
type WaitResponse<T extends BootloaderResponseAction> = [
|
||||
T,
|
||||
BootloaderErrorResponseAction,
|
||||
boolean,
|
||||
];
|
||||
|
||||
/**
|
||||
* Waits for a response action, an error response or timeout, whichever comes
|
||||
* first.
|
||||
* @param type The action type to wait for.
|
||||
* @param timeout The timeout in milliseconds.
|
||||
*/
|
||||
function wait(type: BootloaderResponseActionType, timeout = 500): Effect {
|
||||
return race([take(type), take(BootloaderResponseActionType.Error), delay(timeout)]);
|
||||
}
|
||||
|
||||
interface FirmwareMetadata {
|
||||
'metadata-version': string;
|
||||
'firmware-version': string;
|
||||
'device-id': HubType;
|
||||
'checksum-type': 'sum' | 'crc32';
|
||||
'mpy-abi-version': number;
|
||||
'mpy-cross-options': string[];
|
||||
'user-mpy-offset': number;
|
||||
'max-firmware-size': number;
|
||||
}
|
||||
|
||||
function* firmwareIterator(data: DataView, maxSize: number): Generator<number> {
|
||||
// read each 32-bit word of the firmware
|
||||
for (let i = 0; i < data.byteLength; i += 4) {
|
||||
yield data.getUint32(i, true);
|
||||
}
|
||||
// remaining free space in flash will be 0xff after erase
|
||||
for (let i = data.byteLength; i < maxSize; i += 4) {
|
||||
yield ~0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Pybricks firmware from a .zip file
|
||||
* @param data The zip file raw data
|
||||
*/
|
||||
function* loadFirmware(
|
||||
data: ArrayBuffer,
|
||||
): Generator<unknown, { firmware: Uint8Array; deviceId: HubType }> {
|
||||
const zip = (yield call(() => JSZip.loadAsync(data))) as JSZip;
|
||||
const firmwareBase = (yield call(() =>
|
||||
zip.file('firmware-base.bin').async('uint8array'),
|
||||
)) as Uint8Array;
|
||||
const metadata = JSON.parse(
|
||||
(yield call(() => zip.file('firmware.metadata.json').async('text'))) as string,
|
||||
) as FirmwareMetadata;
|
||||
const main = (yield call(() => zip.file('main.py').async('text'))) as string;
|
||||
|
||||
if (metadata['mpy-abi-version'] !== 5) {
|
||||
throw Error(
|
||||
`Firmware requires mpy-cross ABI version ${metadata['mpy-abi-version']} we have v5`,
|
||||
);
|
||||
}
|
||||
|
||||
yield put(compile(main, metadata['mpy-cross-options']));
|
||||
const [mpy, mpyFail] = (yield race([
|
||||
take(MpyActionType.DidCompile),
|
||||
take(MpyActionType.DidFailToCompile),
|
||||
])) as [MpyDidCompileAction, MpyDidFailToCompileAction];
|
||||
|
||||
if (mpyFail) {
|
||||
throw Error(mpyFail.err);
|
||||
}
|
||||
|
||||
// compute offset for checksum - must be aligned to 4-byte boundary
|
||||
const checksumOffset =
|
||||
metadata['user-mpy-offset'] + 4 + mpy.data.length + fmod(-mpy.data.length, 4);
|
||||
|
||||
const firmware = new Uint8Array(checksumOffset + 4);
|
||||
const firmwareView = new DataView(firmware.buffer);
|
||||
|
||||
if (firmware.length > metadata['max-firmware-size']) {
|
||||
throw Error('firmware + main.mpy is too large');
|
||||
}
|
||||
|
||||
firmware.set(firmwareBase);
|
||||
firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true);
|
||||
firmware.set(mpy.data, metadata['user-mpy-offset'] + 4);
|
||||
|
||||
if (metadata['checksum-type'] !== 'sum') {
|
||||
throw Error(`Unknown checksum type "${metadata['checksum-type']}"`);
|
||||
}
|
||||
|
||||
firmwareView.setUint32(
|
||||
checksumOffset,
|
||||
sumComplement32(firmwareIterator(firmwareView, metadata['max-firmware-size'])),
|
||||
true,
|
||||
);
|
||||
|
||||
return { firmware, deviceId: metadata['device-id'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes firmware to a Powered Up device.
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* flashFirmware(action: FlashFirmwareFlashAction): Generator {
|
||||
let firmware: Uint8Array | undefined = undefined;
|
||||
let deviceId: HubType | undefined = undefined;
|
||||
|
||||
if (action.data !== undefined) {
|
||||
({ firmware, deviceId } = yield* loadFirmware(action.data));
|
||||
}
|
||||
|
||||
yield put(connect());
|
||||
const connectResult = (yield take([
|
||||
BootloaderConnectionActionType.DidConnect,
|
||||
BootloaderConnectionActionType.DidFailToConnect,
|
||||
])) as
|
||||
| BootloaderConnectionDidConnectAction
|
||||
| BootloaderConnectionDidFailToConnectAction;
|
||||
|
||||
if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(infoRequest());
|
||||
const info = (yield wait(BootloaderResponseActionType.Info)) as WaitResponse<
|
||||
BootloaderInfoResponseAction
|
||||
>;
|
||||
if (!info[0]) {
|
||||
throw Error(`failed to get info: ${info}`);
|
||||
}
|
||||
|
||||
if (deviceId !== undefined && info[0].hubType !== deviceId) {
|
||||
throw Error(`Connected to ${info[0].hubType} but firmware is for ${deviceId}`);
|
||||
}
|
||||
|
||||
if (firmware === undefined) {
|
||||
const firmwarePath = firmwareZipMap.get(info[0].hubType);
|
||||
if (firmwarePath === undefined) {
|
||||
yield put(
|
||||
notification.add(
|
||||
'error',
|
||||
"Sorry, we don't have firmware for this hub yet.",
|
||||
),
|
||||
);
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
const response = (yield call(() => fetch(firmwarePath))) as Response;
|
||||
if (!response.ok) {
|
||||
yield put(notification.add('error', 'Failed to fetch firmware.'));
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (yield call(() => response.arrayBuffer())) as ArrayBuffer;
|
||||
({ firmware, deviceId } = yield* loadFirmware(data));
|
||||
|
||||
if (deviceId !== undefined && info[0].hubType !== deviceId) {
|
||||
throw Error(
|
||||
`Connected to ${info[0].hubType} but firmware is for ${deviceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// City hub bootloader is buggy. See note in encodeRequest().
|
||||
if (info[0].hubType === HubType.CityHub && !connectResult.canWriteWithoutResponse) {
|
||||
yield put(
|
||||
notification.add(
|
||||
'error',
|
||||
'City Hub is not compatible with this web browser.',
|
||||
),
|
||||
);
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(eraseRequest());
|
||||
const erase = (yield wait(
|
||||
BootloaderResponseActionType.Erase,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderEraseResponseAction>;
|
||||
if (!erase[0] || erase[0].result) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to erase: ${erase}`);
|
||||
}
|
||||
|
||||
yield put(initRequest(firmware.length));
|
||||
const init = (yield wait(BootloaderResponseActionType.Init)) as WaitResponse<
|
||||
BootloaderInitResponseAction
|
||||
>;
|
||||
if (!init[0] || init[0].result) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to init: ${init}`);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (let offset = 0; offset < firmware.length; offset += MaxProgramFlashSize) {
|
||||
const payload = firmware.slice(offset, offset + MaxProgramFlashSize);
|
||||
const req = (yield put(
|
||||
programRequest(info[0].startAddress + offset, payload.buffer),
|
||||
)) as BootloaderProgramRequestAction;
|
||||
|
||||
// TODO: check for error
|
||||
yield take(
|
||||
(a: Action) =>
|
||||
a.type === BootloaderDidRequestType &&
|
||||
(a as BootloaderDidRequestAction).id === req.id,
|
||||
);
|
||||
|
||||
yield put(progress(offset, firmware.length));
|
||||
|
||||
if (connectResult.canWriteWithoutResponse) {
|
||||
// request checksum every 8K to prevent buffer overrun on the hub
|
||||
// because of sending too much data at once
|
||||
if (++count % 585 === 0) {
|
||||
yield put(checksumRequest());
|
||||
const checksum = (yield wait(
|
||||
BootloaderResponseActionType.Checksum,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderChecksumResponseAction>;
|
||||
if (!checksum[0]) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to get checksum: ${checksum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flash = (yield wait(
|
||||
BootloaderResponseActionType.Program,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderProgramResponseAction>;
|
||||
if (!flash[0]) {
|
||||
throw Error(`failed to get final response: ${flash}`);
|
||||
}
|
||||
if (flash[0].count !== firmware.length) {
|
||||
// TODO: proper error handling
|
||||
throw Error("Didn't flash all bytes");
|
||||
}
|
||||
|
||||
yield put(progress(firmware.length, firmware.length));
|
||||
|
||||
// this will cause the remote device to disconnect and reboot
|
||||
yield put(rebootRequest());
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield takeEvery(FlashFirmwareActionType.FlashFirmware, flashFirmware);
|
||||
}
|
||||
+2
-1
@@ -3,10 +3,11 @@
|
||||
|
||||
import { all } from 'redux-saga/effects';
|
||||
import editor from './editor';
|
||||
import flashFirmware from './flash-firmare';
|
||||
import bootloader from './lwp3-bootloader';
|
||||
import mpy from './mpy';
|
||||
|
||||
/* istanbul ignore next */
|
||||
export default function* (): Generator {
|
||||
yield all([bootloader(), editor(), mpy()]);
|
||||
yield all([bootloader(), editor(), flashFirmware(), mpy()]);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,29 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
import cPlusHubZip from '@pybricks/firmware/build/cplushub.zip';
|
||||
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
|
||||
import JSZip from 'jszip';
|
||||
import { Channel, buffers } from 'redux-saga';
|
||||
import {
|
||||
Effect,
|
||||
actionChannel,
|
||||
call,
|
||||
delay,
|
||||
fork,
|
||||
put,
|
||||
race,
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'redux-saga/effects';
|
||||
import { actionChannel, fork, put, take, takeEvery } from 'redux-saga/effects';
|
||||
import { Action } from '../actions';
|
||||
import {
|
||||
BootloaderActionType,
|
||||
BootloaderChecksumResponseAction,
|
||||
BootloaderConnectionActionType,
|
||||
BootloaderConnectionDidConnectAction,
|
||||
BootloaderConnectionDidFailToConnectAction,
|
||||
BootloaderConnectionDidReceiveAction,
|
||||
BootloaderConnectionDidSendAction,
|
||||
BootloaderDidRequestAction,
|
||||
BootloaderDidRequestType,
|
||||
BootloaderEraseResponseAction,
|
||||
BootloaderErrorResponseAction,
|
||||
BootloaderFlashFirmwareAction,
|
||||
BootloaderInfoResponseAction,
|
||||
BootloaderInitResponseAction,
|
||||
BootloaderProgramRequestAction,
|
||||
BootloaderProgramResponseAction,
|
||||
BootloaderRequestAction,
|
||||
BootloaderRequestActionType,
|
||||
BootloaderResponseAction,
|
||||
BootloaderResponseActionType,
|
||||
checksumRequest,
|
||||
checksumResponse,
|
||||
connect,
|
||||
didError,
|
||||
didRequest,
|
||||
disconnectRequest,
|
||||
eraseRequest,
|
||||
eraseResponse,
|
||||
errorResponse,
|
||||
infoRequest,
|
||||
infoResponse,
|
||||
initRequest,
|
||||
initResponse,
|
||||
programRequest,
|
||||
programResponse,
|
||||
progress,
|
||||
rebootRequest,
|
||||
send,
|
||||
stateResponse,
|
||||
} from '../actions/lwp3-bootloader';
|
||||
import {
|
||||
MpyActionType,
|
||||
MpyDidCompileAction,
|
||||
MpyDidFailToCompileAction,
|
||||
compile,
|
||||
} from '../actions/mpy';
|
||||
import * as notification from '../actions/notification';
|
||||
import {
|
||||
Command,
|
||||
ErrorBytecode,
|
||||
HubType,
|
||||
MaxProgramFlashSize,
|
||||
ProtocolError,
|
||||
createDisconnectRequest,
|
||||
createEraseFlashRequest,
|
||||
@@ -89,12 +43,6 @@ import {
|
||||
parseProgramFlashResponse,
|
||||
} from '../protocols/lwp3-bootloader';
|
||||
import { hex } from '../utils';
|
||||
import { fmod, sumComplement32 } from '../utils/math';
|
||||
|
||||
const firmwareZipMap = new Map<HubType, string>([
|
||||
[HubType.CPlusHub, cPlusHubZip],
|
||||
[HubType.MoveHub, moveHubZip],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Converts a request action into bytecodes and creates a new action to send
|
||||
@@ -203,258 +151,7 @@ function* decodeResponse(action: BootloaderConnectionDidReceiveAction): Generato
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for return value of wait() function.
|
||||
*/
|
||||
type WaitResponse<T extends BootloaderResponseAction> = [
|
||||
T,
|
||||
BootloaderErrorResponseAction,
|
||||
boolean,
|
||||
];
|
||||
|
||||
/**
|
||||
* Waits for a response action, an error response or timeout, whichever comes
|
||||
* first.
|
||||
* @param type The action type to wait for.
|
||||
* @param timeout The timeout in milliseconds.
|
||||
*/
|
||||
function wait(type: BootloaderResponseActionType, timeout = 500): Effect {
|
||||
return race([take(type), take(BootloaderResponseActionType.Error), delay(timeout)]);
|
||||
}
|
||||
|
||||
interface FirmwareMetadata {
|
||||
'metadata-version': string;
|
||||
'firmware-version': string;
|
||||
'device-id': HubType;
|
||||
'checksum-type': 'sum' | 'crc32';
|
||||
'mpy-abi-version': number;
|
||||
'mpy-cross-options': string[];
|
||||
'user-mpy-offset': number;
|
||||
'max-firmware-size': number;
|
||||
}
|
||||
|
||||
function* firmwareIterator(data: DataView, maxSize: number): Generator<number> {
|
||||
// read each 32-bit word of the firmware
|
||||
for (let i = 0; i < data.byteLength; i += 4) {
|
||||
yield data.getUint32(i, true);
|
||||
}
|
||||
// remaining free space in flash will be 0xff after erase
|
||||
for (let i = data.byteLength; i < maxSize; i += 4) {
|
||||
yield ~0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Pybricks firmware from a .zip file
|
||||
* @param data The zip file raw data
|
||||
*/
|
||||
function* loadFirmware(
|
||||
data: ArrayBuffer,
|
||||
): Generator<unknown, { firmware: Uint8Array; deviceId: HubType }> {
|
||||
const zip = (yield call(() => JSZip.loadAsync(data))) as JSZip;
|
||||
const firmwareBase = (yield call(() =>
|
||||
zip.file('firmware-base.bin').async('uint8array'),
|
||||
)) as Uint8Array;
|
||||
const metadata = JSON.parse(
|
||||
(yield call(() => zip.file('firmware.metadata.json').async('text'))) as string,
|
||||
) as FirmwareMetadata;
|
||||
const main = (yield call(() => zip.file('main.py').async('text'))) as string;
|
||||
|
||||
if (metadata['mpy-abi-version'] !== 5) {
|
||||
throw Error(
|
||||
`Firmware requires mpy-cross ABI version ${metadata['mpy-abi-version']} we have v5`,
|
||||
);
|
||||
}
|
||||
|
||||
yield put(compile(main, metadata['mpy-cross-options']));
|
||||
const [mpy, mpyFail] = (yield race([
|
||||
take(MpyActionType.DidCompile),
|
||||
take(MpyActionType.DidFailToCompile),
|
||||
])) as [MpyDidCompileAction, MpyDidFailToCompileAction];
|
||||
|
||||
if (mpyFail) {
|
||||
throw Error(mpyFail.err);
|
||||
}
|
||||
|
||||
// compute offset for checksum - must be aligned to 4-byte boundary
|
||||
const checksumOffset =
|
||||
metadata['user-mpy-offset'] + 4 + mpy.data.length + fmod(-mpy.data.length, 4);
|
||||
|
||||
const firmware = new Uint8Array(checksumOffset + 4);
|
||||
const firmwareView = new DataView(firmware.buffer);
|
||||
|
||||
if (firmware.length > metadata['max-firmware-size']) {
|
||||
throw Error('firmware + main.mpy is too large');
|
||||
}
|
||||
|
||||
firmware.set(firmwareBase);
|
||||
firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true);
|
||||
firmware.set(mpy.data, metadata['user-mpy-offset'] + 4);
|
||||
|
||||
if (metadata['checksum-type'] !== 'sum') {
|
||||
throw Error(`Unknown checksum type "${metadata['checksum-type']}"`);
|
||||
}
|
||||
|
||||
firmwareView.setUint32(
|
||||
checksumOffset,
|
||||
sumComplement32(firmwareIterator(firmwareView, metadata['max-firmware-size'])),
|
||||
true,
|
||||
);
|
||||
|
||||
return { firmware, deviceId: metadata['device-id'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flashes firmware to a Powered Up device.
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* flashFirmware(action: BootloaderFlashFirmwareAction): Generator {
|
||||
let firmware: Uint8Array | undefined = undefined;
|
||||
let deviceId: HubType | undefined = undefined;
|
||||
|
||||
if (action.data !== undefined) {
|
||||
({ firmware, deviceId } = yield* loadFirmware(action.data));
|
||||
}
|
||||
|
||||
yield put(connect());
|
||||
const connectResult = (yield take([
|
||||
BootloaderConnectionActionType.DidConnect,
|
||||
BootloaderConnectionActionType.DidFailToConnect,
|
||||
])) as
|
||||
| BootloaderConnectionDidConnectAction
|
||||
| BootloaderConnectionDidFailToConnectAction;
|
||||
|
||||
if (connectResult.type === BootloaderConnectionActionType.DidFailToConnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(infoRequest());
|
||||
const info = (yield wait(BootloaderResponseActionType.Info)) as WaitResponse<
|
||||
BootloaderInfoResponseAction
|
||||
>;
|
||||
if (!info[0]) {
|
||||
throw Error(`failed to get info: ${info}`);
|
||||
}
|
||||
|
||||
if (deviceId !== undefined && info[0].hubType !== deviceId) {
|
||||
throw Error(`Connected to ${info[0].hubType} but firmware is for ${deviceId}`);
|
||||
}
|
||||
|
||||
if (firmware === undefined) {
|
||||
const firmwarePath = firmwareZipMap.get(info[0].hubType);
|
||||
if (firmwarePath === undefined) {
|
||||
yield put(
|
||||
notification.add(
|
||||
'error',
|
||||
"Sorry, we don't have firmware for this hub yet.",
|
||||
),
|
||||
);
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
const response = (yield call(() => fetch(firmwarePath))) as Response;
|
||||
if (!response.ok) {
|
||||
yield put(notification.add('error', 'Failed to fetch firmware.'));
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (yield call(() => response.arrayBuffer())) as ArrayBuffer;
|
||||
({ firmware, deviceId } = yield* loadFirmware(data));
|
||||
|
||||
if (deviceId !== undefined && info[0].hubType !== deviceId) {
|
||||
throw Error(
|
||||
`Connected to ${info[0].hubType} but firmware is for ${deviceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// City hub bootloader is buggy. See note in encodeRequest().
|
||||
if (info[0].hubType === HubType.CityHub && !connectResult.canWriteWithoutResponse) {
|
||||
yield put(
|
||||
notification.add(
|
||||
'error',
|
||||
'City Hub is not compatible with this web browser.',
|
||||
),
|
||||
);
|
||||
yield put(disconnectRequest());
|
||||
return;
|
||||
}
|
||||
|
||||
yield put(eraseRequest());
|
||||
const erase = (yield wait(
|
||||
BootloaderResponseActionType.Erase,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderEraseResponseAction>;
|
||||
if (!erase[0] || erase[0].result) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to erase: ${erase}`);
|
||||
}
|
||||
|
||||
yield put(initRequest(firmware.length));
|
||||
const init = (yield wait(BootloaderResponseActionType.Init)) as WaitResponse<
|
||||
BootloaderInitResponseAction
|
||||
>;
|
||||
if (!init[0] || init[0].result) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to init: ${init}`);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (let offset = 0; offset < firmware.length; offset += MaxProgramFlashSize) {
|
||||
const payload = firmware.slice(offset, offset + MaxProgramFlashSize);
|
||||
const req = (yield put(
|
||||
programRequest(info[0].startAddress + offset, payload.buffer),
|
||||
)) as BootloaderProgramRequestAction;
|
||||
|
||||
// TODO: check for error
|
||||
yield take(
|
||||
(a: Action) =>
|
||||
a.type === BootloaderDidRequestType &&
|
||||
(a as BootloaderDidRequestAction).id === req.id,
|
||||
);
|
||||
|
||||
yield put(progress(offset, firmware.length));
|
||||
|
||||
if (connectResult.canWriteWithoutResponse) {
|
||||
// request checksum every 8K to prevent buffer overrun on the hub
|
||||
// because of sending too much data at once
|
||||
if (++count % 585 === 0) {
|
||||
yield put(checksumRequest());
|
||||
const checksum = (yield wait(
|
||||
BootloaderResponseActionType.Checksum,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderChecksumResponseAction>;
|
||||
if (!checksum[0]) {
|
||||
// TODO: proper error handling
|
||||
throw Error(`Failed to get checksum: ${checksum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flash = (yield wait(
|
||||
BootloaderResponseActionType.Program,
|
||||
5000,
|
||||
)) as WaitResponse<BootloaderProgramResponseAction>;
|
||||
if (!flash[0]) {
|
||||
throw Error(`failed to get final response: ${flash}`);
|
||||
}
|
||||
if (flash[0].count !== firmware.length) {
|
||||
// TODO: proper error handling
|
||||
throw Error("Didn't flash all bytes");
|
||||
}
|
||||
|
||||
yield put(progress(firmware.length, firmware.length));
|
||||
|
||||
// this will cause the remote device to disconnect and reboot
|
||||
yield put(rebootRequest());
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield fork(encodeRequest);
|
||||
yield takeEvery(BootloaderConnectionActionType.DidReceive, decodeResponse);
|
||||
yield takeEvery(BootloaderActionType.FlashFirmware, flashFirmware);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user