Skip to content

Commit

Permalink
Merge pull request #3158 from balena-io/update-leds-behaviour
Browse files Browse the repository at this point in the history
Update leds behaviour
  • Loading branch information
zvin authored May 20, 2020
2 parents 869d875 + 72c9d61 commit ac51e6a
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 281 deletions.
5 changes: 1 addition & 4 deletions lib/gui/app/components/progress-button/progress-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ const colors = {
verifying: '#1ac135',
} as const;

/**
* Progress Button component
*/
export class ProgressButton extends React.Component<ProgressButtonProps> {
export class ProgressButton extends React.PureComponent<ProgressButtonProps> {
public render() {
if (this.props.active) {
return (
Expand Down
23 changes: 21 additions & 2 deletions lib/gui/app/models/flash-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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)
Expand All @@ -89,7 +108,7 @@ export function setProgressState(

return null;
}),
});
};

store.dispatch({
type: Actions.SET_FLASH_STATE,
Expand Down
201 changes: 161 additions & 40 deletions lib/gui/app/models/leds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,58 +14,191 @@
* 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<string, RGBLed> = new Map();

function setLeds(
drivesPaths: Set<string>,
colorOrAnimation: Color | AnimationFunction,
frequency?: number,
) {
for (const path of drivesPaths) {
const led = leds.get(path);
if (led) {
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);
}

// Remove plugged devices from unplugged set
for (const d of plugged) {
unplugged.delete(d);
}
for (const a of available) {
off.delete(a);

// 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 {
devicePath?: string;
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<void> {
// ledsMapping is something like:
// {
Expand All @@ -78,22 +211,10 @@ export async function init(): Promise<void> {
// }
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);
});
}
24 changes: 19 additions & 5 deletions lib/gui/app/models/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,16 @@ const selectImageNoNilFields = ['path', 'extension'];
/**
* @summary Application default state
*/
const DEFAULT_STATE = Immutable.fromJS({
export const DEFAULT_STATE = Immutable.fromJS({
applicationSessionUuid: '',
flashingWorkflowUuid: '',
availableDrives: [],
selection: {
devices: Immutable.OrderedSet(),
},
isFlashing: false,
devicePaths: [],
failedDevicePaths: [],
flashResults: {},
flashState: {
active: 0,
Expand All @@ -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,
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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'));
}

Expand Down Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion lib/gui/app/modules/image-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion lib/gui/app/modules/progress-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/

import { bytesToClosestUnit } from '../../../shared/units';
// import * as settings from '../models/settings';

export interface FlashState {
active: number;
Expand Down
Loading

0 comments on commit ac51e6a

Please sign in to comment.