diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index 1d2080bb..77eae911 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -55,9 +55,6 @@ const colors = { verifying: '#1ac135', } as const; -/** - * Progress Button component - */ export class ProgressButton extends React.Component { public render() { if (this.props.active) { diff --git a/lib/gui/app/models/flash-state.ts b/lib/gui/app/models/flash-state.ts index 50de3932..2823ca6b 100644 --- a/lib/gui/app/models/flash-state.ts +++ b/lib/gui/app/models/flash-state.ts @@ -68,6 +68,24 @@ export function unsetFlashingFlag(results: { }); } +export function setDevicePaths(devicePaths: string[]) { + store.dispatch({ + type: Actions.SET_DEVICE_PATHS, + data: devicePaths, + }); +} + +export function addFailedDevicePath(devicePath: string) { + const failedDevicePathsSet = new Set( + store.getState().toJS().failedDevicePaths, + ); + failedDevicePathsSet.add(devicePath); + store.dispatch({ + type: Actions.SET_FAILED_DEVICE_PATHS, + data: Array.from(failedDevicePathsSet), + }); +} + /** * @summary Set the flashing state */ @@ -76,7 +94,8 @@ export function setProgressState( ) { // Preserve only one decimal place const PRECISION = 1; - const data = _.assign({}, state, { + const data = { + ...state, percentage: state.percentage !== undefined && _.isFinite(state.percentage) ? Math.floor(state.percentage) @@ -89,7 +108,7 @@ export function setProgressState( return null; }), - }); + }; store.dispatch({ type: Actions.SET_FLASH_STATE, diff --git a/lib/gui/app/models/leds.ts b/lib/gui/app/models/leds.ts index 406c2566..6478233e 100644 --- a/lib/gui/app/models/leds.ts +++ b/lib/gui/app/models/leds.ts @@ -14,22 +14,20 @@ * limitations under the License. */ -import { - AnimationFunction, - blinkWhite, - breatheGreen, - Color, - RGBLed, -} from 'sys-class-rgb-led'; +import { Drive as DrivelistDrive } from 'drivelist'; +import * as _ from 'lodash'; +import { AnimationFunction, Color, RGBLed } from 'sys-class-rgb-led'; +import { isSourceDrive } from '../../../shared/drive-constraints'; import * as settings from './settings'; -import { observe } from './store'; +import { DEFAULT_STATE, observe } from './store'; const leds: Map = new Map(); function setLeds( drivesPaths: Set, colorOrAnimation: Color | AnimationFunction, + frequency?: number, ) { for (const path of drivesPaths) { const led = leds.get(path); @@ -37,28 +35,123 @@ function setLeds( if (Array.isArray(colorOrAnimation)) { led.setStaticColor(colorOrAnimation); } else { - led.setAnimation(colorOrAnimation); + led.setAnimation(colorOrAnimation, frequency); } } } } -export function updateLeds( - availableDrives: string[], - selectedDrives: string[], -) { - const off = new Set(leds.keys()); - const available = new Set(availableDrives); - const selected = new Set(selectedDrives); - for (const s of selected) { - available.delete(s); +const red: Color = [1, 0, 0]; +const green: Color = [0, 1, 0]; +const blue: Color = [0, 0, 1]; +const white: Color = [1, 1, 1]; +const black: Color = [0, 0, 0]; +const purple: Color = [0.5, 0, 0.5]; + +function createAnimationFunction( + intensityFunction: (t: number) => number, + color: Color, +): AnimationFunction { + return (t: number): Color => { + const intensity = intensityFunction(t); + return color.map((v) => v * intensity) as Color; + }; +} + +function blink(t: number) { + return Math.floor(t / 1000) % 2; +} + +function breathe(t: number) { + return (1 + Math.sin(t / 1000)) / 2; +} + +const breatheBlue = createAnimationFunction(breathe, blue); +const blinkGreen = createAnimationFunction(blink, green); +const blinkPurple = createAnimationFunction(blink, purple); + +interface LedsState { + step: 'main' | 'flashing' | 'verifying' | 'finish'; + sourceDrive: string | undefined; + availableDrives: string[]; + selectedDrives: string[]; + failedDrives: string[]; +} + +// Source slot (1st slot): behaves as a target unless it is chosen as source +// No drive: black +// Drive plugged: blue - on +// +// Other slots (2 - 16): +// +// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+ +// | | main screen | flashing | validating | results screen | +// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+ +// | no drive | black | black | black | black | +// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+ +// | drive plugged | black | black | black | black | +// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+ +// | drive selected | white | blink purple, red if failed | blink green, red if failed | green if success, red if failed | +// +----------------+---------------+-----------------------------+----------------------------+---------------------------------+ +export function updateLeds({ + step, + sourceDrive, + availableDrives, + selectedDrives, + failedDrives, +}: LedsState) { + const unplugged = new Set(leds.keys()); + const plugged = new Set(availableDrives); + const selectedOk = new Set(selectedDrives); + const selectedFailed = new Set(failedDrives); + + // Remove selected devices from plugged set + for (const d of selectedOk) { + plugged.delete(d); } - for (const a of available) { - off.delete(a); + + // Remove plugged devices from unplugged set + for (const d of plugged) { + unplugged.delete(d); + } + + // Remove failed devices from selected set + for (const d of selectedFailed) { + selectedOk.delete(d); + } + + // Handle source slot + if (sourceDrive !== undefined) { + if (unplugged.has(sourceDrive)) { + unplugged.delete(sourceDrive); + // TODO + setLeds(new Set([sourceDrive]), breatheBlue, 2); + } else if (plugged.has(sourceDrive)) { + plugged.delete(sourceDrive); + setLeds(new Set([sourceDrive]), blue); + } + } + if (step === 'main') { + setLeds(unplugged, black); + setLeds(plugged, black); + setLeds(selectedOk, white); + setLeds(selectedFailed, white); + } else if (step === 'flashing') { + setLeds(unplugged, black); + setLeds(plugged, black); + setLeds(selectedOk, blinkPurple, 2); + setLeds(selectedFailed, red); + } else if (step === 'verifying') { + setLeds(unplugged, black); + setLeds(plugged, black); + setLeds(selectedOk, blinkGreen, 2); + setLeds(selectedFailed, red); + } else if (step === 'finish') { + setLeds(unplugged, black); + setLeds(plugged, black); + setLeds(selectedOk, green); + setLeds(selectedFailed, red); } - setLeds(off, [0, 0, 0]); - setLeds(available, breatheGreen); - setLeds(selected, blinkWhite); } interface DeviceFromState { @@ -66,6 +159,46 @@ interface DeviceFromState { device: string; } +let ledsState: LedsState | undefined; + +function stateObserver(state: typeof DEFAULT_STATE) { + const s = state.toJS(); + let step: 'main' | 'flashing' | 'verifying' | 'finish'; + if (s.isFlashing) { + step = s.flashState.type; + } else { + step = s.lastAverageFlashingSpeed == null ? 'main' : 'finish'; + } + const availableDrives = s.availableDrives.filter( + (d: DeviceFromState) => d.devicePath, + ); + const sourceDrivePath = availableDrives.filter((d: DrivelistDrive) => + isSourceDrive(d, s.selection.image), + )[0]?.devicePath; + const availableDrivesPaths = availableDrives.map( + (d: DeviceFromState) => d.devicePath, + ); + let selectedDrivesPaths: string[]; + if (step === 'main') { + selectedDrivesPaths = availableDrives + .filter((d: DrivelistDrive) => s.selection.devices.includes(d.device)) + .map((d: DrivelistDrive) => d.devicePath); + } else { + selectedDrivesPaths = s.devicePaths; + } + const newLedsState = { + step, + sourceDrive: sourceDrivePath, + availableDrives: availableDrivesPaths, + selectedDrives: selectedDrivesPaths, + failedDrives: s.failedDevicePaths, + }; + if (!_.isEqual(newLedsState, ledsState)) { + updateLeds(newLedsState); + ledsState = newLedsState; + } +} + export async function init(): Promise { // ledsMapping is something like: // { @@ -78,22 +211,10 @@ export async function init(): Promise { // } const ledsMapping: _.Dictionary<[string, string, string]> = (await settings.get('ledsMapping')) || {}; - for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) { - leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames)); + if (!_.isEmpty(ledsMapping)) { + for (const [drivePath, ledsNames] of Object.entries(ledsMapping)) { + leds.set('/dev/disk/by-path/' + drivePath, new RGBLed(ledsNames)); + } + observe(_.debounce(stateObserver, 1000, { maxWait: 1000 })); } - observe((state) => { - const availableDrives = state - .get('availableDrives') - .toJS() - .filter((d: DeviceFromState) => d.devicePath); - const availableDrivesPaths = availableDrives.map( - (d: DeviceFromState) => d.devicePath, - ); - // like /dev/sda - const selectedDrivesDevices = state.getIn(['selection', 'devices']).toJS(); - const selectedDrivesPaths = availableDrives - .filter((d: DeviceFromState) => selectedDrivesDevices.includes(d.device)) - .map((d: DeviceFromState) => d.devicePath); - updateLeds(availableDrivesPaths, selectedDrivesPaths); - }); } diff --git a/lib/gui/app/models/store.ts b/lib/gui/app/models/store.ts index 06ba71b7..1d8ff2e9 100644 --- a/lib/gui/app/models/store.ts +++ b/lib/gui/app/models/store.ts @@ -55,7 +55,7 @@ const selectImageNoNilFields = ['path', 'extension']; /** * @summary Application default state */ -const DEFAULT_STATE = Immutable.fromJS({ +export const DEFAULT_STATE = Immutable.fromJS({ applicationSessionUuid: '', flashingWorkflowUuid: '', availableDrives: [], @@ -63,6 +63,8 @@ const DEFAULT_STATE = Immutable.fromJS({ devices: Immutable.OrderedSet(), }, isFlashing: false, + devicePaths: [], + failedDevicePaths: [], flashResults: {}, flashState: { active: 0, @@ -78,6 +80,8 @@ const DEFAULT_STATE = Immutable.fromJS({ * @summary Application supported action messages */ export enum Actions { + SET_DEVICE_PATHS, + SET_FAILED_DEVICE_PATHS, SET_AVAILABLE_DRIVES, SET_FLASH_STATE, RESET_FLASH_STATE, @@ -264,6 +268,12 @@ function storeReducer( .set('isFlashing', false) .set('flashState', DEFAULT_STATE.get('flashState')) .set('flashResults', DEFAULT_STATE.get('flashResults')) + .set('devicePaths', DEFAULT_STATE.get('devicePaths')) + .set('failedDevicePaths', DEFAULT_STATE.get('failedDevicePaths')) + .set( + 'lastAverageFlashingSpeed', + DEFAULT_STATE.get('lastAverageFlashingSpeed'), + ) .delete('flashUuid'); } @@ -328,10 +338,6 @@ function storeReducer( return state .set('isFlashing', false) .set('flashResults', Immutable.fromJS(action.data)) - .set( - 'lastAverageFlashingSpeed', - DEFAULT_STATE.get('lastAverageFlashingSpeed'), - ) .set('flashState', DEFAULT_STATE.get('flashState')); } @@ -542,6 +548,14 @@ function storeReducer( return state.set('flashingWorkflowUuid', action.data); } + case Actions.SET_DEVICE_PATHS: { + return state.set('devicePaths', action.data); + } + + case Actions.SET_FAILED_DEVICE_PATHS: { + return state.set('failedDevicePaths', action.data); + } + default: { return state; } diff --git a/lib/gui/app/modules/image-writer.ts b/lib/gui/app/modules/image-writer.ts index bdf87207..358f7b46 100644 --- a/lib/gui/app/modules/image-writer.ts +++ b/lib/gui/app/modules/image-writer.ts @@ -172,7 +172,10 @@ export async function performWrite( validateWriteOnSuccess, }; - ipc.server.on('fail', ({ error }: { error: Error & { code: string } }) => { + ipc.server.on('fail', ({ device, error }) => { + if (device.devicePath) { + flashState.addFailedDevicePath(device.devicePath); + } handleErrorLogging(error, analyticsData); }); @@ -264,6 +267,9 @@ export async function flash( } flashState.setFlashingFlag(); + flashState.setDevicePaths( + drives.map((d) => d.devicePath).filter((p) => p != null) as string[], + ); const analyticsData = { image, diff --git a/lib/gui/app/pages/main/MainPage.tsx b/lib/gui/app/pages/main/MainPage.tsx index 9207c929..77fa8de5 100644 --- a/lib/gui/app/pages/main/MainPage.tsx +++ b/lib/gui/app/pages/main/MainPage.tsx @@ -263,7 +263,12 @@ export class MainPage extends React.Component< private renderSuccess() { return (
- this.setState({ current: 'main' })} /> + { + flashState.resetState(); + this.setState({ current: 'main' }); + }} + />
); diff --git a/lib/shared/drive-constraints.ts b/lib/shared/drive-constraints.ts index 3dde3f2d..a919c900 100644 --- a/lib/shared/drive-constraints.ts +++ b/lib/shared/drive-constraints.ts @@ -44,7 +44,7 @@ export function isSystemDrive(drive: DrivelistDrive): boolean { } export interface Image { - path: string; + path?: string; isSizeEstimated?: boolean; compressedSize?: number; recommendedDriveSize?: number; @@ -59,18 +59,12 @@ export interface Image { * containing the image. */ export function isSourceDrive(drive: DrivelistDrive, image: Image): boolean { - const mountpoints = _.get(drive, ['mountpoints'], []); - const imagePath = _.get(image, ['path']); - - if (!imagePath || _.isEmpty(mountpoints)) { - return false; + for (const mountpoint of drive.mountpoints || []) { + if (image.path !== undefined && pathIsInside(image.path, mountpoint.path)) { + return true; + } } - - return _.some( - _.map(mountpoints, (mountpoint) => { - return pathIsInside(imagePath, mountpoint.path); - }), - ); + return false; } /** diff --git a/scripts/resin b/scripts/resin index 862ad8d5..02c8c7ca 160000 --- a/scripts/resin +++ b/scripts/resin @@ -1 +1 @@ -Subproject commit 862ad8d53e091ba2ffc580c133b36e04084c8a5b +Subproject commit 02c8c7ca1ffdcaf5c8d566c4fb91e869f9223ab8 diff --git a/tests/shared/drive-constraints.spec.ts b/tests/shared/drive-constraints.spec.ts index d39d2b35..e22ca18f 100644 --- a/tests/shared/drive-constraints.spec.ts +++ b/tests/shared/drive-constraints.spec.ts @@ -116,15 +116,6 @@ describe('Shared: DriveConstraints', function () { expect(result).to.be.false; }); - it('should return false if no drive', function () { - // @ts-ignore - const result = constraints.isSourceDrive(undefined, { - path: '/Volumes/Untitled/image.img', - }); - - expect(result).to.be.false; - }); - it('should return false if there are no mount points', function () { const result = constraints.isSourceDrive( { @@ -1134,64 +1125,6 @@ describe('Shared: DriveConstraints', function () { }); }); - describe('given the image is null', () => { - it('should return an empty list', function () { - const result = constraints.getDriveImageCompatibilityStatuses( - this.drive, - // @ts-ignore - null, - ); - - expect(result).to.deep.equal([]); - }); - }); - - describe('given the drive is null', () => { - it('should return an empty list', function () { - const result = constraints.getDriveImageCompatibilityStatuses( - // @ts-ignore - null, - this.image, - ); - - expect(result).to.deep.equal([]); - }); - }); - - describe('given a locked drive and image is null', () => { - it('should return locked drive error', function () { - this.drive.isReadOnly = true; - - const result = constraints.getDriveImageCompatibilityStatuses( - this.drive, - // @ts-ignore - null, - ); - // @ts-ignore - const expectedTuples = [['ERROR', 'locked']]; - - // @ts-ignore - expectStatusTypesAndMessagesToBe(result, expectedTuples); - }); - }); - - describe('given a system drive and image is null', () => { - it('should return system drive warning', function () { - this.drive.isSystem = true; - - const result = constraints.getDriveImageCompatibilityStatuses( - this.drive, - // @ts-ignore - null, - ); - // @ts-ignore - const expectedTuples = [['WARNING', 'system']]; - - // @ts-ignore - expectStatusTypesAndMessagesToBe(result, expectedTuples); - }); - }); - describe('given the drive contains the image and the drive is locked', () => { it('should return the contains-image drive error by precedence', function () { this.drive.isReadOnly = true;