1
0
mirror of https://github.com/arduino/arduino-ide.git synced 2025-06-05 19:56:34 +00:00

Added support for 3rd party core settings.

Closes .

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
This commit is contained in:
Akos Kitta 2020-03-03 17:03:11 +01:00
parent 5c16f8d6c9
commit 12f2aa35ff
43 changed files with 1905 additions and 895 deletions

21
.vscode/launch.json vendored

@ -83,6 +83,27 @@
"smartStep": true,
"internalConsoleOptions": "openOnSessionStart",
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"protocol": "inspector",
"name": "Run Test [current]",
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
"args": [
"--require",
"reflect-metadata/Reflect",
"--no-timeouts",
"--colors",
"**/${fileBasenameNoExtension}.js"
],
"env": {
"TS_NODE_PROJECT": "${workspaceRoot}/tsconfig.json"
},
"sourceMaps": true,
"smartStep": true,
"internalConsoleOptions": "openOnSessionStart",
"outputCapture": "std"
}
]
}

@ -26,8 +26,8 @@
],
"theiaExtensions": [
{
"backend": "lib/node/backend-module",
"frontend": "lib/browser/frontend-module"
"backend": "lib/node/arduino-debug-backend-module",
"frontend": "lib/browser/arduino-debug-frontend-module"
}
]
}

@ -10,7 +10,6 @@ import { ArduinoDebugSessionManager } from './arduino-debug-session-manager';
import '../../src/browser/style/index.css';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ArduinoVariableResolver).toSelf().inSingletonScope();
bind(VariableContribution).toService(ArduinoVariableResolver);

@ -110,5 +110,8 @@
"additionalProperties": false
}
},
"// TODOs": [
"additionalProperties should be true. See the new telemetry entry"
],
"additionalProperties": false
}

@ -15,8 +15,8 @@
"lint": "tslint -c ./tslint.json --project ./tsconfig.json",
"build": "tsc && ncp ./src/node/cli-protocol/ ./lib/node/cli-protocol/ && yarn lint",
"watch": "tsc -w",
"test": "mocha \"./src/test/**/*.test.ts\"",
"test:watch": "mocha --watch --watch-files src \"./src/test/**/*.test.ts\""
"test": "mocha \"./lib/test/**/*.test.js\"",
"test:watch": "mocha --watch --watch-files lib \"./lib/test/**/*.test.js\""
},
"dependencies": {
"@grpc/grpc-js": "^0.6.18",
@ -79,18 +79,16 @@
"protoc": "1.0.4",
"shelljs": "^0.8.3",
"temp": "^0.9.1",
"ts-node": "^8.6.2",
"uuid": "^3.2.1",
"yargs": "^11.1.0"
},
"mocha": {
"require": [
"ts-node/register",
"reflect-metadata/Reflect"
],
"reporter": "spec",
"colors": true,
"watch-extensions": "ts,tsx",
"watch-extensions": "js",
"timeout": 10000
},
"files": [
@ -101,12 +99,12 @@
],
"theiaExtensions": [
{
"backend": "lib/node/arduino-backend-module",
"frontend": "lib/browser/arduino-frontend-module"
"backend": "lib/node/arduino-ide-backend-module",
"frontend": "lib/browser/arduino-ide-frontend-module"
},
{
"frontend": "lib/browser/menu/browser-arduino-menu-module",
"frontendElectron": "lib/electron-browser/electron-arduino-menu-module"
"frontendElectron": "lib/electron-browser/menu/electron-arduino-menu-module"
}
]
}

@ -2,7 +2,7 @@ import { injectable, inject } from 'inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import { ArduinoDaemonClient } from '../common/protocol/arduino-daemon';
import { ArduinoDaemonClient } from '../common/protocol';
@injectable()
export class ArduinoDaemonClientImpl implements ArduinoDaemonClient {

@ -5,9 +5,8 @@ import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
import { MessageService } from '@theia/core/lib/common/message-service';
import { CommandContribution, CommandRegistry, Command, CommandHandler } from '@theia/core/lib/common/command';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { BoardsService } from '../common/protocol/boards-service';
import { BoardsService, BoardsServiceClient, CoreService, Sketch, SketchesService, ToolOutputServiceClient } from '../common/protocol';
import { ArduinoCommands } from './arduino-commands';
import { CoreService } from '../common/protocol/core-service';
import { BoardsServiceClientImpl } from './boards/boards-service-client-impl';
import { WorkspaceRootUriAwareCommandHandler, WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
import { SelectionService, MenuContribution, MenuModelRegistry, MAIN_MENU_BAR, MenuPath } from '@theia/core';
@ -19,8 +18,6 @@ import {
} from '@theia/core/lib/browser';
import { OpenFileDialogProps, FileDialogService } from '@theia/filesystem/lib/browser/file-dialog';
import { FileSystem, FileStat } from '@theia/filesystem/lib/common';
import { Sketch, SketchesService } from '../common/protocol/sketches-service';
import { ToolOutputServiceClient } from '../common/protocol/tool-output-service';
import { CommonCommands, CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution';
import { FileSystemCommands } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution';
import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution';
@ -45,6 +42,8 @@ import { ColorContribution } from '@theia/core/lib/browser/color-application-con
import { ColorRegistry } from '@theia/core/lib/browser/color-registry';
import { ArduinoDaemon } from '../common/protocol/arduino-daemon';
import { ConfigService } from '../common/protocol/config-service';
import { BoardsConfigStore } from './boards/boards-config-store';
import { MainMenuManager } from './menu/main-menu-manager';
export namespace ArduinoMenus {
export const SKETCH = [...MAIN_MENU_BAR, '3_sketch'];
@ -75,7 +74,11 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
protected readonly toolOutputServiceClient: ToolOutputServiceClient;
@inject(BoardsServiceClientImpl)
protected readonly boardsServiceClient: BoardsServiceClientImpl;
protected readonly boardsServiceClientImpl: BoardsServiceClientImpl;
// Unused but do not remove it. It's required by DI, otherwise `init` method is not called.
@inject(BoardsServiceClient)
protected readonly boardsServiceClient: BoardsServiceClient;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
@ -143,6 +146,12 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(BoardsConfigStore)
protected readonly boardsConfigStore: BoardsConfigStore;
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
protected application: FrontendApplication;
protected wsSketchCount: number = 0; // TODO: this does not belong here, does it?
@ -154,15 +163,10 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
text: BoardsConfig.Config.toString(config)
});
}
this.boardsServiceClient.onBoardsConfigChanged(updateStatusBar);
updateStatusBar(this.boardsServiceClient.boardsConfig);
this.boardsServiceClientImpl.onBoardsConfigChanged(updateStatusBar);
updateStatusBar(this.boardsServiceClientImpl.boardsConfig);
this.registerSketchesInMenu(this.menuRegistry);
Promise.all([
this.boardsService.getAttachedBoards(),
this.boardsService.getAvailablePorts()
]).then(([{ boards }, { ports }]) => this.boardsServiceClient.tryReconnect(boards, ports));
}
onStart(app: FrontendApplication): void {
@ -210,8 +214,7 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
render: () => <BoardsToolBarItem
key='boardsToolbarItem'
commands={this.commandRegistry}
boardsServiceClient={this.boardsServiceClient}
boardService={this.boardsService} />,
boardsServiceClient={this.boardsServiceClientImpl} />,
isVisible: widget => ArduinoToolbar.is(widget) && widget.side === 'left',
priority: 2
});
@ -276,10 +279,7 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
});
registry.registerCommand(ArduinoCommands.TOGGLE_COMPILE_FOR_DEBUG, {
execute: () => {
this.editorMode.toggleCompileForDebug();
this.editorMode.menuContentChanged.fire();
},
execute: () => this.editorMode.toggleCompileForDebug(),
isToggled: () => this.editorMode.compileForDebug
});
@ -345,7 +345,7 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
execute: async () => {
const boardsConfig = await this.boardsConfigDialog.open();
if (boardsConfig) {
this.boardsServiceClient.boardsConfig = boardsConfig;
this.boardsServiceClientImpl.boardsConfig = boardsConfig;
}
}
});
@ -377,18 +377,18 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
}
try {
const { boardsConfig } = this.boardsServiceClient;
const { boardsConfig } = this.boardsServiceClientImpl;
if (!boardsConfig || !boardsConfig.selectedBoard) {
throw new Error('No boards selected. Please select a board.');
}
if (!boardsConfig.selectedBoard.fqbn) {
throw new Error(`No core is installed for ${boardsConfig.selectedBoard.name}. Please install the board.`);
throw new Error(`No core is installed for the '${boardsConfig.selectedBoard.name}' board. Please install the core.`);
}
// Reveal the Output view asynchronously (don't await it)
const fqbn = await this.boardsConfigStore.appendConfigToFqbn(boardsConfig.selectedBoard.fqbn);
this.outputContribution.openView({ reveal: true });
await this.coreService.compile({
uri: uri.toString(),
board: boardsConfig.selectedBoard,
sketchUri: uri.toString(),
fqbn,
optimizeForDebug: this.editorMode.compileForDebug
});
} catch (e) {
@ -413,7 +413,7 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
}
try {
const { boardsConfig } = this.boardsServiceClient;
const { boardsConfig } = this.boardsServiceClientImpl;
if (!boardsConfig || !boardsConfig.selectedBoard) {
throw new Error('No boards selected. Please select a board.');
}
@ -421,11 +421,14 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut
if (!selectedPort) {
throw new Error('No ports selected. Please select a port.');
}
// Reveal the Output view asynchronously (don't await it)
if (!boardsConfig.selectedBoard.fqbn) {
throw new Error(`No core is installed for the '${boardsConfig.selectedBoard.name}' board. Please install the core.`);
}
this.outputContribution.openView({ reveal: true });
const fqbn = await this.boardsConfigStore.appendConfigToFqbn(boardsConfig.selectedBoard.fqbn);
await this.coreService.upload({
uri: uri.toString(),
board: boardsConfig.selectedBoard,
sketchUri: uri.toString(),
fqbn,
port: selectedPort.address,
optimizeForDebug: this.editorMode.compileForDebug
});

@ -75,6 +75,9 @@ import { ArduinoFrontendConnectionStatusService, ArduinoApplicationConnectionSta
import { FrontendConnectionStatusService, ApplicationConnectionStatusContribution } from '@theia/core/lib/browser/connection-status-service';
import { ConfigServiceClientImpl } from './config-service-client-impl';
import { CoreServiceClientImpl } from './core-service-client-impl';
import { BoardsDetailsMenuUpdater } from './boards/boards-details-menu-updater';
import { BoardsConfigStore } from './boards/boards-config-store';
import { ILogger } from '@theia/core';
const ElementQueries = require('css-element-queries/src/ElementQueries');
@ -144,12 +147,28 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un
// Boards service client to receive and delegate notifications from the backend.
bind(BoardsServiceClientImpl).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(BoardsServiceClientImpl);
bind(BoardsServiceClient).toDynamicValue(context => {
bind(BoardsServiceClient).toDynamicValue(async context => {
const client = context.container.get(BoardsServiceClientImpl);
const service = context.container.get<BoardsService>(BoardsService);
const [attachedBoards, availablePorts] = await Promise.all([
service.getAttachedBoards(),
service.getAvailablePorts()
]);
client.init({ attachedBoards, availablePorts });
WebSocketConnectionProvider.createProxy(context.container, BoardsServicePath, client);
return client;
}).inSingletonScope();
// To be able to track, and update the menu based on the core settings (aka. board details) of the currently selected board.
bind(FrontendApplicationContribution).to(BoardsDetailsMenuUpdater).inSingletonScope();
bind(BoardsConfigStore).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(BoardsConfigStore);
// Logger for the Arduino daemon
bind(ILogger).toDynamicValue(ctx => {
const parentLogger = ctx.container.get<ILogger>(ILogger);
return parentLogger.child('store');
}).inSingletonScope().whenTargetNamed('store');
// Boards auto-installer
bind(BoardsAutoInstaller).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(BoardsAutoInstaller);

@ -35,9 +35,9 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution {
protected ensureCoreExists(config: BoardsConfig.Config): void {
const { selectedBoard } = config;
if (selectedBoard) {
this.boardsService.search({}).then(({ items }) => {
const candidates = items
.filter(item => item.boards.some(board => Board.sameAs(board, selectedBoard)))
this.boardsService.search({}).then(packages => {
const candidates = packages
.filter(pkg => pkg.boards.some(board => Board.sameAs(board, selectedBoard)))
.filter(({ installable, installedVersion }) => installable && !installedVersion);
for (const candidate of candidates) {
// tslint:disable-next-line:max-line-length

@ -0,0 +1,43 @@
import { inject, injectable } from 'inversify';
import { QuickOpenItem, QuickOpenModel } from '@theia/core/lib/common/quick-open-model';
import { QuickOpenService, QuickOpenOptions } from '@theia/core/lib/browser/quick-open/quick-open-service';
import { BoardsService, BoardsServiceClient } from '../../common/protocol';
@injectable()
export class BoardsConfigQuickOpenService {
@inject(QuickOpenService)
protected readonly quickOpenService: QuickOpenService;
@inject(BoardsService)
protected readonly boardsService: BoardsService;
@inject(BoardsServiceClient)
protected readonly boardsServiceClient: BoardsServiceClient;
async selectBoard(): Promise<void> {
}
protected open(items: QuickOpenItem | QuickOpenItem[], placeholder: string): void {
this.quickOpenService.open(this.getModel(Array.isArray(items) ? items : [items]), this.getOptions(placeholder));
}
protected getOptions(placeholder: string, fuzzyMatchLabel: boolean = true, onClose: (canceled: boolean) => void = () => { }): QuickOpenOptions {
return QuickOpenOptions.resolve({
placeholder,
fuzzyMatchLabel,
fuzzySort: false,
onClose
});
}
protected getModel(items: QuickOpenItem | QuickOpenItem[]): QuickOpenModel {
return {
onType(_: string, acceptor: (items: QuickOpenItem[]) => void): void {
acceptor(Array.isArray(items) ? items : [items]);
}
};
}
}

@ -0,0 +1,161 @@
import { injectable, inject, named } from 'inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { MaybePromise } from '@theia/core/lib/common/types';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { deepClone, notEmpty } from '@theia/core/lib/common/objects';
import { FrontendApplicationContribution, LocalStorageService } from '@theia/core/lib/browser';
import { BoardsService, ConfigOption, Installable, BoardDetails } from '../../common/protocol';
import { BoardsServiceClientImpl } from './boards-service-client-impl';
@injectable()
export class BoardsConfigStore implements FrontendApplicationContribution {
@inject(ILogger)
@named('store')
protected readonly logger: ILogger;
@inject(BoardsService)
protected readonly boardsService: BoardsService;
@inject(BoardsServiceClientImpl)
protected readonly boardsServiceClient: BoardsServiceClientImpl;
@inject(LocalStorageService)
protected readonly storageService: LocalStorageService;
protected readonly onChangedEmitter = new Emitter<void>();
onStart(): void {
this.boardsServiceClient.onBoardsPackageInstalled(async ({ pkg }) => {
const { installedVersion: version } = pkg;
if (!version) {
return;
}
let shouldFireChanged = false;
for (const fqbn of pkg.boards.map(({ fqbn }) => fqbn).filter(notEmpty).filter(fqbn => !!fqbn)) {
const key = this.getStorageKey(fqbn, version);
let data = await this.storageService.getData<ConfigOption[] | undefined>(key);
if (!data || !data.length) {
const details = await this.getBoardDetailsSafe(fqbn);
if (details) {
data = details.configOptions;
if (data.length) {
await this.storageService.setData(key, data);
shouldFireChanged = true;
}
}
}
}
if (shouldFireChanged) {
this.fireChanged();
}
});
}
get onChanged(): Event<void> {
return this.onChangedEmitter.event;
}
async appendConfigToFqbn(
fqbn: string,
boardsPackageVersion: MaybePromise<Installable.Version | undefined> = this.getBoardsPackageVersion(fqbn)): Promise<string> {
const configOptions = await this.getConfig(fqbn, boardsPackageVersion);
return ConfigOption.decorate(fqbn, configOptions);
}
async getConfig(
fqbn: string,
boardsPackageVersion: MaybePromise<Installable.Version | undefined> = this.getBoardsPackageVersion(fqbn)): Promise<ConfigOption[]> {
const version = await boardsPackageVersion;
if (!version) {
return [];
}
const key = this.getStorageKey(fqbn, version);
let configOptions = await this.storageService.getData<ConfigOption[] | undefined>(key, undefined);
if (configOptions) {
return configOptions;
}
const details = await this.getBoardDetailsSafe(fqbn);
if (!details) {
return [];
}
configOptions = details.configOptions;
await this.storageService.setData(key, configOptions);
return configOptions;
}
async setSelected(
{ fqbn, option, selectedValue }: { fqbn: string, option: string, selectedValue: string },
boardsPackageVersion: MaybePromise<Installable.Version | undefined> = this.getBoardsPackageVersion(fqbn)): Promise<boolean> {
const configOptions = deepClone(await this.getConfig(fqbn, boardsPackageVersion));
const configOption = configOptions.find(c => c.option === option);
if (!configOption) {
return false;
}
let updated = false;
for (const value of configOption.values) {
if (value.value === selectedValue) {
(value as any).selected = true;
updated = true;
} else {
(value as any).selected = false;
}
}
if (!updated) {
return false;
}
const version = await boardsPackageVersion;
if (!version) {
return false;
}
await this.setConfig({ fqbn, configOptions, version });
this.fireChanged();
return true;
}
protected async setConfig(
{ fqbn, configOptions, version }: { fqbn: string, configOptions: ConfigOption[], version: Installable.Version }): Promise<void> {
const key = this.getStorageKey(fqbn, version);
return this.storageService.setData(key, configOptions);
}
protected getStorageKey(fqbn: string, version: Installable.Version): string {
return `.arduinoProIDE-configOptions-${version}-${fqbn}`;
}
protected async getBoardDetailsSafe(fqbn: string): Promise<BoardDetails | undefined> {
try {
const details = this.boardsService.getBoardDetails({ fqbn });
return details;
} catch (err) {
if (err instanceof Error && err.message.includes('loading board data') && err.message.includes('is not installed')) {
this.logger.warn(`The boards package is not installed for board with FQBN: ${fqbn}`);
} else {
this.logger.error(`An unexpected error occurred while retrieving the board details for ${fqbn}.`, err);
}
return undefined;
}
}
protected fireChanged(): void {
this.onChangedEmitter.fire();
}
protected async getBoardsPackageVersion(fqbn: string): Promise<Installable.Version | undefined> {
if (!fqbn) {
return undefined;
}
const boardsPackage = await this.boardsService.getContainerBoardPackage({ fqbn });
if (!boardsPackage) {
return undefined;
}
return boardsPackage.installedVersion;
}
}

@ -1,6 +1,6 @@
import * as React from 'react';
import { DisposableCollection } from '@theia/core';
import { BoardsService, Board, Port, AttachedSerialBoard, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service';
import { BoardsService, Board, Port, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service';
import { BoardsServiceClientImpl } from './boards-service-client-impl';
import { CoreServiceClientImpl } from '../core-service-client-impl';
import { ArduinoDaemonClientImpl } from '../arduino-daemon-client-impl';
@ -36,11 +36,11 @@ export abstract class Item<T> extends React.Component<{
selected: boolean,
onClick: (item: T) => void,
missing?: boolean,
detail?: string
details?: string
}> {
render(): React.ReactNode {
const { selected, label, missing, detail } = this.props;
const { selected, label, missing, details } = this.props;
const classNames = ['item'];
if (selected) {
classNames.push('selected');
@ -48,11 +48,11 @@ export abstract class Item<T> extends React.Component<{
if (missing === true) {
classNames.push('missing')
}
return <div onClick={this.onClick} className={classNames.join(' ')} title={`${label}${!detail ? '' : detail}`}>
return <div onClick={this.onClick} className={classNames.join(' ')} title={`${label}${!details ? '' : details}`}>
<div className='label'>
{label}
</div>
{!detail ? '' : <div className='detail'>{detail}</div>}
{!details ? '' : <div className='details'>{details}</div>}
{!selected ? '' : <div className='selected-icon'><i className='fa fa-check' /></div>}
</div>;
}
@ -82,13 +82,15 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
componentDidMount() {
this.updateBoards();
this.props.boardsService.getAvailablePorts().then(({ ports }) => this.updatePorts(ports));
this.props.boardsService.getAvailablePorts().then(ports => this.updatePorts(ports));
const { boardsServiceClient, coreServiceClient, daemonClient } = this.props;
this.toDispose.pushAll([
boardsServiceClient.onBoardsChanged(event => this.updatePorts(event.newState.ports, AttachedBoardsChangeEvent.diff(event).detached.ports)),
boardsServiceClient.onAttachedBoardsChanged(event => this.updatePorts(event.newState.ports, AttachedBoardsChangeEvent.diff(event).detached.ports)),
boardsServiceClient.onBoardsConfigChanged(({ selectedBoard, selectedPort }) => {
this.setState({ selectedBoard, selectedPort }, () => this.fireConfigChanged());
}),
boardsServiceClient.onBoardsPackageInstalled(() => this.updateBoards(this.state.query)),
boardsServiceClient.onBoardsPackageUninstalled(() => this.updateBoards(this.state.query)),
coreServiceClient.onIndexUpdated(() => this.updateBoards(this.state.query)),
daemonClient.onDaemonStarted(() => this.updateBoards(this.state.query)),
daemonClient.onDaemonStopped(() => this.setState({ searchResults: [] }))
@ -110,11 +112,11 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
: eventOrQuery.target.value.toLowerCase()
).trim();
this.setState({ query });
this.queryBoards({ query }).then(({ searchResults }) => this.setState({ searchResults }));
this.queryBoards({ query }).then(searchResults => this.setState({ searchResults }));
}
protected updatePorts = (ports: Port[] = [], removedPorts: Port[] = []) => {
this.queryPorts(Promise.resolve({ ports })).then(({ knownPorts }) => {
this.queryPorts(Promise.resolve(ports)).then(({ knownPorts }) => {
let { selectedPort } = this.state;
// If the currently selected port is not available anymore, unset the selected port.
if (removedPorts.some(port => Port.equals(port, selectedPort))) {
@ -124,35 +126,17 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
});
}
protected queryBoards = (options: { query?: string } = {}): Promise<{ searchResults: Array<Board & { packageName: string }> }> => {
const { boardsService } = this.props;
const query = (options.query || '').toLocaleLowerCase();
return new Promise<{ searchResults: Array<Board & { packageName: string }> }>(resolve => {
boardsService.search(options)
.then(({ items }) => items
.map(item => item.boards.map(board => ({ ...board, packageName: item.name })))
.reduce((acc, curr) => acc.concat(curr), [])
.filter(board => board.name.toLocaleLowerCase().indexOf(query) !== -1)
.sort(Board.compare))
.then(searchResults => resolve({ searchResults }));
});
protected queryBoards = (options: { query?: string } = {}): Promise<Array<Board & { packageName: string }>> => {
return this.props.boardsService.searchBoards(options);
}
protected get attachedBoards(): Promise<{ boards: Board[] }> {
return this.props.boardsService.getAttachedBoards();
}
protected get availablePorts(): Promise<{ ports: Port[] }> {
protected get availablePorts(): Promise<Port[]> {
return this.props.boardsService.getAvailablePorts();
}
protected queryPorts = (availablePorts: Promise<{ ports: Port[] }> = this.availablePorts) => {
return new Promise<{ knownPorts: Port[] }>(resolve => {
availablePorts
.then(({ ports }) => ports
.sort(Port.compare))
.then(knownPorts => resolve({ knownPorts }));
});
protected queryPorts = async (availablePorts: Promise<Port[]> = this.availablePorts) => {
const ports = await availablePorts;
return { knownPorts: ports.sort(Port.compare) };
}
protected toggleFilterPorts = () => {
@ -194,41 +178,20 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
protected renderBoards(): React.ReactNode {
const { selectedBoard, searchResults } = this.state;
// Board names are not unique. We show the corresponding core name as a detail.
// https://github.com/arduino/arduino-cli/pull/294#issuecomment-513764948
const distinctBoardNames = new Map<string, number>();
for (const { name } of searchResults) {
const counter = distinctBoardNames.get(name) || 0;
distinctBoardNames.set(name, counter + 1);
}
// Due to the non-unique board names, we have to check the package name as well.
const selected = (board: Board & { packageName: string }) => {
if (!!selectedBoard) {
if (Board.equals(board, selectedBoard)) {
if ('packageName' in selectedBoard) {
return board.packageName === (selectedBoard as any).packageName;
}
return true;
}
}
return false;
}
return <React.Fragment>
<div className='search'>
<input type='search' className='theia-input' placeholder='SEARCH BOARD' onChange={this.updateBoards} ref={this.focusNodeSet} />
<i className='fa fa-search'></i>
</div>
<div className='boards list'>
{this.state.searchResults.map(board => <Item<Board & { packageName: string }>
{Board.decorateBoards(selectedBoard, searchResults).map(board => <Item<Board & { packageName: string }>
key={`${board.name}-${board.packageName}`}
item={board}
label={board.name}
detail={(distinctBoardNames.get(board.name) || 0) > 1 ? ` - ${board.packageName}` : undefined}
selected={selected(board)}
details={board.details}
selected={board.selected}
onClick={this.selectBoard}
missing={!Board.installed(board)}
missing={board.missing}
/>)}
</div>
</React.Fragment>;
@ -276,9 +239,9 @@ export namespace BoardsConfig {
export namespace Config {
export function sameAs(config: Config, other: Config | AttachedSerialBoard): boolean {
export function sameAs(config: Config, other: Config | Board): boolean {
const { selectedBoard, selectedPort } = config;
if (AttachedSerialBoard.is(other)) {
if (Board.is(other)) {
return !!selectedBoard
&& Board.equals(other, selectedBoard)
&& Port.sameAs(selectedPort, other.port);

@ -0,0 +1,90 @@
import { inject, injectable } from 'inversify';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { MenuModelRegistry, MenuNode } from '@theia/core/lib/common/menu';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { BoardsServiceClientImpl } from './boards-service-client-impl';
import { Board, ConfigOption } from '../../common/protocol';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { ArduinoMenus } from '../arduino-frontend-contribution';
import { BoardsConfigStore } from './boards-config-store';
import { MainMenuManager } from '../menu/main-menu-manager';
@injectable()
export class BoardsDetailsMenuUpdater implements FrontendApplicationContribution {
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
protected readonly menuRegistry: MenuModelRegistry;
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
@inject(BoardsConfigStore)
protected readonly boardsConfigStore: BoardsConfigStore;
@inject(BoardsServiceClientImpl)
protected readonly boardsServiceClient: BoardsServiceClientImpl;
protected readonly toDisposeOnBoardChange = new DisposableCollection();
onStart(): void {
this.boardsConfigStore.onChanged(() => this.updateMenuActions(this.boardsServiceClient.boardsConfig.selectedBoard));
this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) => this.updateMenuActions(selectedBoard));
this.updateMenuActions(this.boardsServiceClient.boardsConfig.selectedBoard);
}
protected async updateMenuActions(selectedBoard: Board | undefined): Promise<void> {
if (selectedBoard) {
this.toDisposeOnBoardChange.dispose();
this.mainMenuManager.update();
const { fqbn } = selectedBoard;
if (fqbn) {
const configOptions = await this.boardsConfigStore.getConfig(fqbn);
const boardsConfigMenuPath = [...ArduinoMenus.TOOLS, 'z_boardsConfig']; // `z_` is for ordering.
for (const { label, option, values } of configOptions.sort(ConfigOption.LABEL_COMPARATOR)) {
const menuPath = [...boardsConfigMenuPath, `${option}`];
const commands = new Map<string, Disposable>()
for (const value of values) {
const id = `${fqbn}-${option}--${value.value}`;
const command = { id, label: value.label };
const selectedValue = value.value;
const handler = {
execute: () => this.boardsConfigStore.setSelected({ fqbn, option, selectedValue }),
isToggled: () => value.selected
};
commands.set(id, this.commandRegistry.registerCommand(command, handler));
}
this.menuRegistry.registerSubmenu(menuPath, label);
this.toDisposeOnBoardChange.pushAll([
...commands.values(),
Disposable.create(() => this.unregisterSubmenu(menuPath)), // We cannot dispose submenu entries: https://github.com/eclipse-theia/theia/issues/7299
...Array.from(commands.keys()).map((commandId, index) => {
this.menuRegistry.registerMenuAction(menuPath, { commandId, order: String(index) })
return Disposable.create(() => this.menuRegistry.unregisterMenuAction(commandId))
})
]);
}
this.mainMenuManager.update();
}
}
}
protected unregisterSubmenu(menuPath: string[]): void {
if (menuPath.length < 2) {
throw new Error(`Expected at least two item as a menu-path. Got ${JSON.stringify(menuPath)} instead.`);
}
const toRemove = menuPath[menuPath.length - 1];
const parentMenuPath = menuPath.slice(0, menuPath.length - 1);
// This is unsafe. Calling `getMenu` with a non-existing menu-path will result in a new menu creation.
// https://github.com/eclipse-theia/theia/issues/7300
const parent = this.menuRegistry.getMenu(parentMenuPath);
const index = parent.children.findIndex(({ id }) => id === toRemove);
if (index === -1) {
throw new Error(`Could not find menu with menu-path: ${JSON.stringify(menuPath)}.`);
}
(parent.children as Array<MenuNode>).splice(index, 1);
}
}

@ -1,17 +1,17 @@
import { inject, injectable } from 'inversify';
import { BoardPackage, BoardsService } from '../../common/protocol/boards-service';
import { BoardsPackage, BoardsService } from '../../common/protocol/boards-service';
import { ListWidget } from '../components/component-list/list-widget';
import { ListItemRenderer } from '../components/component-list/list-item-renderer';
@injectable()
export class BoardsListWidget extends ListWidget<BoardPackage> {
export class BoardsListWidget extends ListWidget<BoardsPackage> {
static WIDGET_ID = 'boards-list-widget';
static WIDGET_LABEL = 'Boards Manager';
constructor(
@inject(BoardsService) protected service: BoardsService,
@inject(ListItemRenderer) protected itemRenderer: ListItemRenderer<BoardPackage>) {
@inject(ListItemRenderer) protected itemRenderer: ListItemRenderer<BoardsPackage>) {
super({
id: BoardsListWidget.WIDGET_ID,
@ -19,7 +19,7 @@ export class BoardsListWidget extends ListWidget<BoardPackage> {
iconClass: 'fa fa-microchip',
searchable: service,
installable: service,
itemLabel: (item: BoardPackage) => item.name,
itemLabel: (item: BoardsPackage) => item.name,
itemRenderer
});
}

@ -1,11 +1,11 @@
import { injectable, inject } from 'inversify';
import { injectable, inject, optional } from 'inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { ILogger } from '@theia/core/lib/common/logger';
import { MessageService } from '@theia/core/lib/common/message-service';
import { LocalStorageService } from '@theia/core/lib/browser/storage-service';
import { StorageService } from '@theia/core/lib/browser/storage-service';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { RecursiveRequired } from '../../common/types';
import { BoardsServiceClient, AttachedBoardsChangeEvent, BoardInstalledEvent, AttachedSerialBoard, Board, Port, BoardUninstalledEvent } from '../../common/protocol/boards-service';
import { BoardsServiceClient, AttachedBoardsChangeEvent, BoardInstalledEvent, Board, Port, BoardUninstalledEvent } from '../../common/protocol';
import { BoardsConfig } from './boards-config';
@injectable()
@ -14,16 +14,18 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
@inject(ILogger)
protected logger: ILogger;
@optional()
@inject(MessageService)
protected messageService: MessageService;
@inject(LocalStorageService)
protected storageService: LocalStorageService;
@inject(StorageService)
protected storageService: StorageService;
protected readonly onBoardInstalledEmitter = new Emitter<BoardInstalledEvent>();
protected readonly onBoardUninstalledEmitter = new Emitter<BoardUninstalledEvent>();
protected readonly onBoardsPackageInstalledEmitter = new Emitter<BoardInstalledEvent>();
protected readonly onBoardsPackageUninstalledEmitter = new Emitter<BoardUninstalledEvent>();
protected readonly onAttachedBoardsChangedEmitter = new Emitter<AttachedBoardsChangeEvent>();
protected readonly onSelectedBoardsConfigChangedEmitter = new Emitter<BoardsConfig.Config>();
protected readonly onBoardsConfigChangedEmitter = new Emitter<BoardsConfig.Config>();
protected readonly onAvailableBoardsChangedEmitter = new Emitter<AvailableBoard[]>();
/**
* Used for the auto-reconnecting. Sometimes, the attached board gets disconnected after uploading something to it.
@ -34,35 +36,51 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
*/
protected latestValidBoardsConfig: RecursiveRequired<BoardsConfig.Config> | undefined = undefined;
protected _boardsConfig: BoardsConfig.Config = {};
protected _attachedBoards: Board[] = []; // This does not contain the `Unknown` boards. They're visible from the available ports only.
protected _availablePorts: Port[] = [];
protected _availableBoards: AvailableBoard[] = [];
readonly onBoardsChanged = this.onAttachedBoardsChangedEmitter.event;
readonly onBoardInstalled = this.onBoardInstalledEmitter.event;
readonly onBoardUninstalled = this.onBoardUninstalledEmitter.event;
readonly onBoardsConfigChanged = this.onSelectedBoardsConfigChangedEmitter.event;
/**
* Event when the state of the attached/detached boards has changed. For instance, the user have detached a physical board.
*/
readonly onAttachedBoardsChanged = this.onAttachedBoardsChangedEmitter.event;
readonly onBoardsPackageInstalled = this.onBoardsPackageInstalledEmitter.event;
readonly onBoardsPackageUninstalled = this.onBoardsPackageUninstalledEmitter.event;
/**
* Unlike `onAttachedBoardsChanged` this even fires when the user modifies the selected board in the IDE.\
* This even also fires, when the boards package was not available for the currently selected board,
* and the user installs the board package. Note: installing a board package will set the `fqbn` of the
* currently selected board.\
* This even also emitted when the board package for the currently selected board was uninstalled.
*/
readonly onBoardsConfigChanged = this.onBoardsConfigChangedEmitter.event;
readonly onAvailableBoardsChanged = this.onAvailableBoardsChangedEmitter.event;
async onStart(): Promise<void> {
return this.loadState();
}
notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void {
this.logger.info('Attached boards and available ports changed: ', JSON.stringify(event));
const { detached, attached } = AttachedBoardsChangeEvent.diff(event);
const { selectedPort, selectedBoard } = this.boardsConfig;
this.onAttachedBoardsChangedEmitter.fire(event);
// Dynamically unset the port if is not available anymore. A port can be "detached" when removing a board.
if (detached.ports.some(port => Port.equals(selectedPort, port))) {
this.boardsConfig = {
selectedBoard,
selectedPort: undefined
};
}
// Try to reconnect.
this.tryReconnect(attached.boards, attached.ports);
/**
* When the FE connects to the BE, the BE stets the known boards and ports.\
* This is a DI workaround for not being able to inject the service into the client.
*/
init({ attachedBoards, availablePorts }: { attachedBoards: Board[], availablePorts: Port[] }): void {
this._attachedBoards = attachedBoards;
this._availablePorts = availablePorts;
this.reconcileAvailableBoards().then(() => this.tryReconnect());
}
async tryReconnect(attachedBoards: Board[], availablePorts: Port[]): Promise<boolean> {
notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void {
this.logger.info('Attached boards and available ports changed: ', JSON.stringify(event));
this._attachedBoards = event.newState.boards;
this.onAttachedBoardsChangedEmitter.fire(event);
this._availablePorts = event.newState.ports;
this.reconcileAvailableBoards().then(() => this.tryReconnect());
}
protected async tryReconnect(): Promise<boolean> {
if (this.latestValidBoardsConfig && !this.canUploadTo(this.boardsConfig)) {
for (const board of attachedBoards.filter(AttachedSerialBoard.is)) {
for (const board of this.availableBoards.filter(({ state }) => state !== AvailableBoard.State.incomplete)) {
if (this.latestValidBoardsConfig.selectedBoard.fqbn === board.fqbn
&& this.latestValidBoardsConfig.selectedBoard.name === board.name
&& Port.sameAs(this.latestValidBoardsConfig.selectedPort, board.port)) {
@ -73,13 +91,13 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
}
// If we could not find an exact match, we compare the board FQBN-name pairs and ignore the port, as it might have changed.
// See documentation on `latestValidBoardsConfig`.
for (const board of attachedBoards.filter(AttachedSerialBoard.is)) {
for (const board of this.availableBoards.filter(({ state }) => state !== AvailableBoard.State.incomplete)) {
if (this.latestValidBoardsConfig.selectedBoard.fqbn === board.fqbn
&& this.latestValidBoardsConfig.selectedBoard.name === board.name) {
this.boardsConfig = {
...this.latestValidBoardsConfig,
selectedPort: availablePorts.find(port => Port.sameAs(port, board.port))
selectedPort: board.port
};
return true;
}
@ -90,21 +108,52 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
notifyBoardInstalled(event: BoardInstalledEvent): void {
this.logger.info('Board installed: ', JSON.stringify(event));
this.onBoardInstalledEmitter.fire(event);
this.onBoardsPackageInstalledEmitter.fire(event);
const { selectedBoard } = this.boardsConfig;
const { installedVersion, id } = event.pkg;
if (selectedBoard) {
const installedBoard = event.pkg.boards.find(({ name }) => name === selectedBoard.name);
if (installedBoard && (!selectedBoard.fqbn || selectedBoard.fqbn === installedBoard.fqbn)) {
this.logger.info(`Board package ${id}[${installedVersion}] was installed. Updating the FQBN of the currently selected ${selectedBoard.name} board. [FQBN: ${installedBoard.fqbn}]`);
this.boardsConfig = {
...this.boardsConfig,
selectedBoard: installedBoard
};
}
}
}
notifyBoardUninstalled(event: BoardUninstalledEvent): void {
this.logger.info('Board uninstalled: ', JSON.stringify(event));
this.onBoardUninstalledEmitter.fire(event);
this.onBoardsPackageUninstalledEmitter.fire(event);
const { selectedBoard } = this.boardsConfig;
if (selectedBoard && selectedBoard.fqbn) {
const uninstalledBoard = event.pkg.boards.find(({ name }) => name === selectedBoard.name);
if (uninstalledBoard && uninstalledBoard.fqbn === selectedBoard.fqbn) {
this.logger.info(`Board package ${event.pkg.id} was uninstalled. Discarding the FQBN of the currently selected ${selectedBoard.name} board.`);
const selectedBoardWithoutFqbn = {
name: selectedBoard.name
// No FQBN
};
this.boardsConfig = {
...this.boardsConfig,
selectedBoard: selectedBoardWithoutFqbn
};
}
}
}
set boardsConfig(config: BoardsConfig.Config) {
this.doSetBoardsConfig(config);
this.saveState().finally(() => this.reconcileAvailableBoards().finally(() => this.onBoardsConfigChangedEmitter.fire(this._boardsConfig)));
}
protected doSetBoardsConfig(config: BoardsConfig.Config): void {
this.logger.info('Board config changed: ', JSON.stringify(config));
this._boardsConfig = config;
if (this.canUploadTo(this._boardsConfig)) {
this.latestValidBoardsConfig = this._boardsConfig;
}
this.saveState().then(() => this.onSelectedBoardsConfigChangedEmitter.fire(this._boardsConfig));
}
get boardsConfig(): BoardsConfig.Config {
@ -123,7 +172,7 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
}
if (!config.selectedBoard) {
if (!options.silent) {
if (!options.silent && this.messageService) {
this.messageService.warn('No boards selected.', { timeout: 3000 });
}
return false;
@ -133,7 +182,7 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
}
/**
* `true` if the `canVerify` and the `config.selectedPort` is also set with FQBN, hence can upload to board. Otherwise, `false`.
* `true` if `canVerify`, the board has an FQBN and the `config.selectedPort` is also set, hence can upload to board. Otherwise, `false`.
*/
canUploadTo(
config: BoardsConfig.Config | undefined = this.boardsConfig,
@ -145,14 +194,14 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
const { name } = config.selectedBoard;
if (!config.selectedPort) {
if (!options.silent) {
if (!options.silent && this.messageService) {
this.messageService.warn(`No ports selected for board: '${name}'.`, { timeout: 3000 });
}
return false;
}
if (!config.selectedBoard.fqbn) {
if (!options.silent) {
if (!options.silent && this.messageService) {
this.messageService.warn(`The FQBN is not available for the selected board ${name}. Do you have the corresponding core installed?`, { timeout: 3000 });
}
return false;
@ -161,8 +210,93 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
return true;
}
protected saveState(): Promise<void> {
return this.storageService.setData('latest-valid-boards-config', this.latestValidBoardsConfig);
get availableBoards(): AvailableBoard[] {
return this._availableBoards;
}
protected async reconcileAvailableBoards(): Promise<void> {
const attachedBoards = this._attachedBoards;
const availablePorts = this._availablePorts;
// Unset the port on the user's config, if it is not available anymore.
if (this.boardsConfig.selectedPort && !availablePorts.some(port => Port.sameAs(port, this.boardsConfig.selectedPort))) {
this.doSetBoardsConfig({ selectedBoard: this.boardsConfig.selectedBoard, selectedPort: undefined });
this.onBoardsConfigChangedEmitter.fire(this._boardsConfig);
}
const boardsConfig = this.boardsConfig;
const currentAvailableBoards = this._availableBoards;
const availableBoards: AvailableBoard[] = [];
const availableBoardPorts = availablePorts.filter(Port.isBoardPort);
const attachedSerialBoards = attachedBoards.filter(({ port }) => !!port);
for (const boardPort of availableBoardPorts) {
let state = AvailableBoard.State.incomplete; // Initial pessimism.
let board = attachedSerialBoards.find(({ port }) => Port.sameAs(boardPort, port));
if (board) {
state = AvailableBoard.State.recognized;
} else {
// If the selected board is not recognized because it is a 3rd party board: https://github.com/arduino/arduino-cli/issues/623
// We still want to show it without the red X in the boards toolbar: https://github.com/arduino/arduino-pro-ide/issues/198#issuecomment-599355836
const lastSelectedBoard = await this.getLastSelectedBoardOnPort(boardPort);
if (lastSelectedBoard) {
board = {
...lastSelectedBoard,
port: boardPort
};
state = AvailableBoard.State.guessed;
}
}
if (!board) {
availableBoards.push({ name: 'Unknown', port: boardPort, state });
} else {
const selected = BoardsConfig.Config.sameAs(boardsConfig, board);
availableBoards.push({ ...board, state, selected, port: boardPort });
}
}
if (boardsConfig.selectedBoard && !availableBoards.some(({ selected }) => selected)) {
availableBoards.push({
...boardsConfig.selectedBoard,
port: boardsConfig.selectedPort,
selected: true,
state: AvailableBoard.State.incomplete
});
}
const sortedAvailableBoards = availableBoards.sort(AvailableBoard.COMPARATOR);
let hasChanged = sortedAvailableBoards.length !== currentAvailableBoards.length;
for (let i = 0; !hasChanged && i < sortedAvailableBoards.length; i++) {
hasChanged = AvailableBoard.COMPARATOR(sortedAvailableBoards[i], currentAvailableBoards[i]) !== 0;
}
if (hasChanged) {
this._availableBoards = sortedAvailableBoards;
this.onAvailableBoardsChangedEmitter.fire(this._availableBoards);
}
}
protected async getLastSelectedBoardOnPort(port: Port | string | undefined): Promise<Board | undefined> {
if (!port) {
return undefined;
}
const key = this.getLastSelectedBoardOnPortKey(port);
return this.storageService.getData<Board>(key);
}
protected async saveState(): Promise<void> {
// We save the port with the selected board name/FQBN, to be able to guess a better board name.
// Required when the attached board belongs to a 3rd party boards package, and neither the name, nor
// the FQBN can be retrieved with a `board list` command.
// https://github.com/arduino/arduino-cli/issues/623
const { selectedBoard, selectedPort } = this.boardsConfig;
if (selectedBoard && selectedPort) {
const key = this.getLastSelectedBoardOnPortKey(selectedPort);
await this.storageService.setData(key, selectedBoard);
}
await this.storageService.setData('latest-valid-boards-config', this.latestValidBoardsConfig);
}
protected getLastSelectedBoardOnPortKey(port: Port | string): string {
// TODO: we lose the port's `protocol` info (`serial`, `network`, etc.) here if the `port` is a `string`.
return `last-selected-board-on-port:${typeof port === 'string' ? port : Port.toString(port)}`;
}
protected async loadState(): Promise<void> {
@ -176,3 +310,63 @@ export class BoardsServiceClientImpl implements BoardsServiceClient, FrontendApp
}
}
/**
* Representation of a ready-to-use board, configured by the user. Not all of the available boards are
* necessarily recognized by the CLI (e.g.: it is a 3rd party board) or correctly configured but ready for `verify`.
* If it has the selected board and a associated port, it can be used for `upload`.
*/
export interface AvailableBoard extends Board {
readonly state: AvailableBoard.State;
readonly selected?: boolean;
readonly port?: Port;
}
export namespace AvailableBoard {
export enum State {
/**
* Retrieved from the CLI via the `board list` command.
*/
'recognized',
/**
* Guessed the name/FQBN of the board from the available board ports (3rd party).
*/
'guessed',
/**
* We do not know anything about this board, probably a 3rd party. The user has not selected a board for this port yet.
*/
'incomplete'
}
export function isWithPort(board: AvailableBoard): board is AvailableBoard & { port: Port } {
return !!board.port;
}
export const COMPARATOR = (left: AvailableBoard, right: AvailableBoard) => {
let result = left.name.localeCompare(right.name);
if (result !== 0) {
return result;
}
if (left.fqbn && right.fqbn) {
result = left.name.localeCompare(right.name);
if (result !== 0) {
return result;
}
}
if (left.port && right.port) {
result = Port.compare(left.port, right.port);
if (result !== 0) {
return result;
}
}
if (!!left.selected && !right.selected) {
return -1;
}
if (!!right.selected && !left.selected) {
return 1;
}
return left.state - right.state;
}
}

@ -1,10 +1,11 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { CommandRegistry, DisposableCollection } from '@theia/core';
import { BoardsService, Board, AttachedSerialBoard, Port } from '../../common/protocol/boards-service';
import { ArduinoCommands } from '../arduino-commands';
import { BoardsServiceClientImpl } from './boards-service-client-impl';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { Port } from '../../common/protocol';
import { BoardsConfig } from './boards-config';
import { ArduinoCommands } from '../arduino-commands';
import { BoardsServiceClientImpl, AvailableBoard } from './boards-service-client-impl';
export interface BoardsDropDownListCoords {
readonly top: number;
@ -16,14 +17,9 @@ export interface BoardsDropDownListCoords {
export namespace BoardsDropDown {
export interface Props {
readonly coords: BoardsDropDownListCoords | 'hidden';
readonly items: Item[];
readonly items: Array<AvailableBoard & { onClick: () => void, port: Port }>;
readonly openBoardsConfig: () => void;
}
export interface Item {
readonly label: string;
readonly selected: boolean;
readonly onClick: () => void;
}
}
export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
@ -51,48 +47,30 @@ export class BoardsDropDown extends React.Component<BoardsDropDown.Props> {
if (coords === 'hidden') {
return '';
}
items.push({
label: 'Select Other Board & Port',
selected: false,
onClick: () => this.props.openBoardsConfig()
})
return <div className='arduino-boards-dropdown-list'
style={{
position: 'absolute',
...coords
}}>
{items.map(this.renderItem)}
{this.renderItem({
label: 'Select Other Board & Port',
onClick: () => this.props.openBoardsConfig()
})}
{items.map(({ name, port, selected, onClick }) => ({ label: `${name} at ${Port.toString(port)}`, selected, onClick })).map(this.renderItem)}
</div>
}
protected renderItem(item: BoardsDropDown.Item): React.ReactNode {
const { label, selected, onClick } = item;
protected renderItem({ label, selected, onClick }: { label: string, selected?: boolean, onClick: () => void }): React.ReactNode {
return <div key={label} className={`arduino-boards-dropdown-item ${selected ? 'selected' : ''}`} onClick={onClick}>
<div>
{label}
</div>
{selected ? <span className='fa fa-check'/> : ''}
{selected ? <span className='fa fa-check' /> : ''}
</div>
}
}
export namespace BoardsToolBarItem {
export interface Props {
readonly boardService: BoardsService;
readonly boardsServiceClient: BoardsServiceClientImpl;
readonly commands: CommandRegistry;
}
export interface State {
boardsConfig: BoardsConfig.Config;
attachedBoards: Board[];
availablePorts: Port[];
coords: BoardsDropDownListCoords | 'hidden';
}
}
export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props, BoardsToolBarItem.State> {
static TOOLBAR_ID: 'boards-toolbar';
@ -102,10 +80,9 @@ export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props,
constructor(props: BoardsToolBarItem.Props) {
super(props);
const { availableBoards } = props.boardsServiceClient;
this.state = {
boardsConfig: this.props.boardsServiceClient.boardsConfig,
attachedBoards: [],
availablePorts: [],
availableBoards,
coords: 'hidden'
};
@ -115,17 +92,7 @@ export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props,
}
componentDidMount() {
const { boardsServiceClient: client, boardService } = this.props;
this.toDispose.pushAll([
client.onBoardsConfigChanged(boardsConfig => this.setState({ boardsConfig })),
client.onBoardsChanged(({ newState }) => this.setState({ attachedBoards: newState.boards, availablePorts: newState.ports }))
]);
Promise.all([
boardService.getAttachedBoards(),
boardService.getAvailablePorts()
]).then(([{boards: attachedBoards}, { ports: availablePorts }]) => {
this.setState({ attachedBoards, availablePorts })
});
this.props.boardsServiceClient.onAvailableBoardsChanged(availableBoards => this.setState({ availableBoards }));
}
componentWillUnmount(): void {
@ -146,7 +113,7 @@ export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props,
}
});
} else {
this.setState({ coords: 'hidden'});
this.setState({ coords: 'hidden' });
}
}
event.stopPropagation();
@ -154,41 +121,52 @@ export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props,
};
render(): React.ReactNode {
const { boardsConfig, coords, attachedBoards, availablePorts } = this.state;
const { coords, availableBoards } = this.state;
const boardsConfig = this.props.boardsServiceClient.boardsConfig;
const title = BoardsConfig.Config.toString(boardsConfig, { default: 'no board selected' });
const configuredBoard = attachedBoards
.filter(AttachedSerialBoard.is)
.filter(board => availablePorts.some(port => Port.sameAs(port, board.port)))
.filter(board => BoardsConfig.Config.sameAs(boardsConfig, board)).shift();
const items = attachedBoards.filter(AttachedSerialBoard.is).map(board => ({
label: `${board.name} at ${board.port}`,
selected: configuredBoard === board,
onClick: () => {
this.props.boardsServiceClient.boardsConfig = {
selectedBoard: board,
selectedPort: availablePorts.find(port => Port.sameAs(port, board.port))
}
const decorator = (() => {
const selectedBoard = availableBoards.find(({ selected }) => selected);
if (!selectedBoard || !selectedBoard.port) {
return 'fa fa-times notAttached'
}
}));
if (selectedBoard.state === AvailableBoard.State.guessed) {
return 'fa fa-exclamation-triangle guessed'
}
return ''
})();
return <React.Fragment>
<div className='arduino-boards-toolbar-item-container'>
<div className='arduino-boards-toolbar-item' title={title}>
<div className='inner-container' onClick={this.show}>
<span className={!configuredBoard ? 'fa fa-times notAttached' : ''}/>
<span className={decorator} />
<div className='label noWrapInfo'>
<div className='noWrapInfo noselect'>
{title}
</div>
</div>
<span className='fa fa-caret-down caret'/>
<span className='fa fa-caret-down caret' />
</div>
</div>
</div>
<BoardsDropDown
coords={coords}
items={items}
items={availableBoards.filter(AvailableBoard.isWithPort).map(board => ({
...board,
onClick: () => {
if (board.state === AvailableBoard.State.incomplete) {
this.props.boardsServiceClient.boardsConfig = {
selectedPort: board.port
};
this.openDialog();
} else {
this.props.boardsServiceClient.boardsConfig = {
selectedBoard: board,
selectedPort: board.port
}
}
}
}))}
openBoardsConfig={this.openDialog}>
</BoardsDropDown>
</React.Fragment>;
@ -200,3 +178,16 @@ export class BoardsToolBarItem extends React.Component<BoardsToolBarItem.Props,
};
}
export namespace BoardsToolBarItem {
export interface Props {
readonly boardsServiceClient: BoardsServiceClientImpl;
readonly commands: CommandRegistry;
}
export interface State {
availableBoards: AvailableBoard[];
coords: BoardsDropDownListCoords | 'hidden';
}
}

@ -2,11 +2,11 @@ import { injectable } from 'inversify';
import { MenuModelRegistry } from '@theia/core';
import { BoardsListWidget } from './boards-list-widget';
import { ArduinoMenus } from '../arduino-frontend-contribution';
import { BoardPackage } from '../../common/protocol/boards-service';
import { BoardsPackage } from '../../common/protocol/boards-service';
import { ListWidgetFrontendContribution } from '../components/component-list/list-widget-frontend-contribution';
@injectable()
export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<BoardPackage> {
export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<BoardsPackage> {
static readonly OPEN_MANAGER = `${BoardsListWidget.WIDGET_ID}:toggle`;

@ -72,12 +72,7 @@ export class FilterableListContainer<T extends ArduinoComponent> extends React.C
protected search(query: string): void {
const { searchable } = this.props;
searchable.search({ query: query.trim() }).then(result => {
const { items } = result;
this.setState({
items: this.sort(items)
});
});
searchable.search({ query: query.trim() }).then(items => this.setState({ items: this.sort(items) }));
}
protected sort(items: T[]): T[] {
@ -91,7 +86,7 @@ export class FilterableListContainer<T extends ArduinoComponent> extends React.C
dialog.open();
try {
await installable.install({ item, version });
const { items } = await searchable.search({ query: this.state.filterText });
const items = await searchable.search({ query: this.state.filterText });
this.setState({ items: this.sort(items) });
} finally {
dialog.close();
@ -113,7 +108,7 @@ export class FilterableListContainer<T extends ArduinoComponent> extends React.C
dialog.open();
try {
await installable.uninstall({ item });
const { items } = await searchable.search({ query: this.state.filterText });
const items = await searchable.search({ query: this.state.filterText });
this.setState({ items: this.sort(items) });
} finally {
dialog.close();

@ -1,16 +1,17 @@
import { injectable } from 'inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { injectable, inject } from 'inversify';
import { ApplicationShell, FrontendApplicationContribution, FrontendApplication, Widget } from '@theia/core/lib/browser';
import { OutputWidget } from '@theia/output/lib/browser/output-widget';
import { EditorWidget } from '@theia/editor/lib/browser';
import { ArduinoShellLayoutRestorer } from './shell/arduino-shell-layout-restorer';
import { OutputWidget } from '@theia/output/lib/browser/output-widget';
import { MainMenuManager } from './menu/main-menu-manager';
import { BoardsListWidget } from './boards/boards-list-widget';
import { LibraryListWidget } from './library/library-list-widget';
import { ArduinoShellLayoutRestorer } from './shell/arduino-shell-layout-restorer';
@injectable()
export class EditorMode implements FrontendApplicationContribution {
readonly menuContentChanged = new Emitter<void>();
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
protected app: FrontendApplication;
@ -62,6 +63,7 @@ export class EditorMode implements FrontendApplicationContribution {
const oldState = this.compileForDebug;
const newState = !oldState;
window.localStorage.setItem(EditorMode.COMPILE_FOR_DEBUG_KEY, String(newState));
this.mainMenuManager.update();
}
}

@ -2,7 +2,6 @@ import { injectable, inject, postConstruct } from 'inversify';
import { BaseLanguageClientContribution } from '@theia/languages/lib/browser';
import { BoardsServiceClientImpl } from '../boards/boards-service-client-impl';
import { BoardsConfig } from '../boards/boards-config';
import { Board, BoardPackage } from '../../common/protocol/boards-service';
@injectable()
export class ArduinoLanguageClientContribution extends BaseLanguageClientContribution {
@ -26,18 +25,6 @@ export class ArduinoLanguageClientContribution extends BaseLanguageClientContrib
@postConstruct()
protected init() {
this.boardsServiceClient.onBoardsConfigChanged(this.selectBoard.bind(this));
const restartIfAffected = (pkg: BoardPackage) => {
if (!this.boardConfig) {
this.restart();
return;
}
const { selectedBoard } = this.boardConfig;
if (selectedBoard && pkg.boards.some(board => Board.sameAs(board, selectedBoard))) {
this.restart();
}
}
this.boardsServiceClient.onBoardInstalled(({ pkg }) => restartIfAffected(pkg));
this.boardsServiceClient.onBoardUninstalled(({ pkg }) => restartIfAffected(pkg));
}
selectBoard(config: BoardsConfig.Config): void {

@ -0,0 +1,22 @@
import { injectable } from 'inversify';
import { BrowserMainMenuFactory, MenuBarWidget } from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { MainMenuManager } from './main-menu-manager';
@injectable()
export class ArduinoBrowserMainMenuFactory extends BrowserMainMenuFactory implements MainMenuManager {
protected menuBar: MenuBarWidget | undefined;
createMenuBar(): MenuBarWidget {
this.menuBar = super.createMenuBar();
return this.menuBar;
}
update() {
if (this.menuBar) {
this.menuBar.clearMenus();
this.fillMenuBar(this.menuBar);
}
}
}

@ -1,10 +1,14 @@
import { BrowserMenuBarContribution } from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { ArduinoMenuContribution } from './arduino-menu-contribution';
import { ContainerModule, interfaces } from 'inversify';
import '../../../src/browser/style/browser-menu.css'
import { ContainerModule } from 'inversify';
import { BrowserMenuBarContribution, BrowserMainMenuFactory } from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { MainMenuManager } from './main-menu-manager';
import { ArduinoMenuContribution } from './arduino-menu-contribution';
import { ArduinoBrowserMainMenuFactory } from './arduino-browser-main-menu-factory';
export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind) => {
unbind(BrowserMenuBarContribution);
bind(BrowserMenuBarContribution).to(ArduinoMenuContribution).inSingletonScope();
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ArduinoBrowserMainMenuFactory).toSelf().inSingletonScope();
bind(MainMenuManager).toService(ArduinoBrowserMainMenuFactory);
rebind(BrowserMainMenuFactory).toService(ArduinoBrowserMainMenuFactory);
rebind(BrowserMenuBarContribution).to(ArduinoMenuContribution).inSingletonScope();
});

@ -0,0 +1,8 @@
export const MainMenuManager = Symbol('MainMenuManager');
export interface MainMenuManager {
/**
* Call this method if you have changed the content of the main menu (updated a toggle flag, removed/added new groups or menu items)
* and you want to re-render it from scratch. Works for electron too.
*/
update(): void;
}

@ -4,7 +4,7 @@ import { MessageService } from '@theia/core/lib/common/message-service';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { MonitorService, MonitorConfig, MonitorError, Status, MonitorReadEvent } from '../../common/protocol/monitor-service';
import { BoardsServiceClientImpl } from '../boards/boards-service-client-impl';
import { Port, Board, BoardsService, AttachedSerialBoard, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service';
import { Port, Board, BoardsService, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service';
import { MonitorServiceClientImpl } from './monitor-service-client-impl';
import { BoardsConfig } from '../boards/boards-config';
import { MonitorModel } from './monitor-model';
@ -110,12 +110,12 @@ export class MonitorConnection {
}
});
this.boardsServiceClient.onBoardsConfigChanged(this.handleBoardConfigChange.bind(this));
this.boardsServiceClient.onBoardsChanged(event => {
this.boardsServiceClient.onAttachedBoardsChanged(event => {
if (this.autoConnect && this.connected) {
const { boardsConfig } = this.boardsServiceClient;
if (this.boardsServiceClient.canUploadTo(boardsConfig, { silent: false })) {
const { attached } = AttachedBoardsChangeEvent.diff(event);
if (attached.boards.some(board => AttachedSerialBoard.is(board) && BoardsConfig.Config.sameAs(boardsConfig, board))) {
if (attached.boards.some(board => !!board.port && BoardsConfig.Config.sameAs(boardsConfig, board))) {
const { selectedBoard: board, selectedPort: port } = boardsConfig;
const { baudRate } = this.monitorModel;
this.disconnect()
@ -225,7 +225,7 @@ export class MonitorConnection {
if (this.boardsServiceClient.canUploadTo(boardsConfig, { silent: false })) {
// Instead of calling `getAttachedBoards` and filtering for `AttachedSerialBoard` we have to check the available ports.
// The connected board might be unknown. See: https://github.com/arduino/arduino-pro-ide/issues/127#issuecomment-563251881
this.boardsService.getAvailablePorts().then(({ ports }) => {
this.boardsService.getAvailablePorts().then(ports => {
if (ports.some(port => Port.equals(port, boardsConfig.selectedPort))) {
new Promise<void>(resolve => {
// First, disconnect if connected.

@ -178,7 +178,7 @@ export class MonitorWidget extends ReactWidget {
this.monitorModel.lineEnding = option.value;
}
protected readonly onChangeBaudRate = async (option: SelectOption<MonitorConfig.BaudRate>) => {
protected readonly onChangeBaudRate = (option: SelectOption<MonitorConfig.BaudRate>) => {
this.monitorModel.baudRate = option.value;
}

@ -97,7 +97,7 @@ div#select-board-dialog .selectBoardContainer .body .list .item.selected i {
margin-left: auto;
}
#select-board-dialog .selectBoardContainer .body .list .item .detail {
#select-board-dialog .selectBoardContainer .body .list .item .details {
font-size: var(--theia-ui-font-size1);
opacity: var(--theia-mod-disabled-opacity);
width: 155px; /* used heuristics for the calculation */
@ -169,6 +169,13 @@ button.theia-button.main {
margin: 0 5px;
}
.arduino-boards-toolbar-item-container .arduino-boards-toolbar-item .inner-container .guessed {
width: 10px;
height: 10px;
color: var(--theia-warningBackground);
margin: 0 5px;
}
.arduino-boards-toolbar-item-container {
display: flex;
align-items: center;

@ -46,7 +46,7 @@
See above: `.filterable-list-container .items-container > div:nth-child(odd|event)`.
We have to increase `z-index` of the scroll-bar thumb. Otherwise, the thumb is not visible.
https://github.com/arduino/arduino-pro-ide/issues/82 */
.arduino-list-widget .ps__rail-y > .ps__thumb-y {
.arduino-list-widget .filterable-list-container .items-container .ps__rail-y {
z-index: 1;
}

@ -2,7 +2,6 @@ import { isWindows, isOSX } from '@theia/core/lib/common/os';
import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory';
import { Searchable } from './searchable';
import { Installable } from './installable';
import { Detailable } from './detailable';
import { ArduinoComponent } from './arduino-component';
const naturalCompare: (left: string, right: string) => number = require('string-natural-compare').caseInsensitive;
@ -22,21 +21,24 @@ export namespace AttachedBoardsChangeEvent {
ports: Port[]
}
}> {
const diff = <T>(left: T[], right: T[]) => {
return left.filter(item => right.indexOf(item) === -1);
// In `lefts` AND not in `rights`.
const diff = <T>(lefts: T[], rights: T[], sameAs: (left: T, right: T) => boolean) => {
return lefts.filter(left => rights.findIndex(right => sameAs(left, right)) === -1);
}
const { boards: newBoards } = event.newState;
const { boards: oldBoards } = event.oldState;
const { ports: newPorts } = event.newState;
const { ports: oldPorts } = event.oldState;
const boardSameAs = (left: Board, right: Board) => Board.sameAs(left, right);
const portSameAs = (left: Port, right: Port) => Port.sameAs(left, right);
return {
detached: {
boards: diff(oldBoards, newBoards),
ports: diff(oldPorts, newPorts)
boards: diff(oldBoards, newBoards, boardSameAs),
ports: diff(oldPorts, newPorts, portSameAs)
},
attached: {
boards: diff(newBoards, oldBoards),
ports: diff(newPorts, oldPorts)
boards: diff(newBoards, oldBoards, boardSameAs),
ports: diff(newPorts, oldPorts, portSameAs)
}
};
}
@ -44,11 +46,11 @@ export namespace AttachedBoardsChangeEvent {
}
export interface BoardInstalledEvent {
readonly pkg: Readonly<BoardPackage>;
readonly pkg: Readonly<BoardsPackage>;
}
export interface BoardUninstalledEvent {
readonly pkg: Readonly<BoardPackage>;
readonly pkg: Readonly<BoardsPackage>;
}
export const BoardsServiceClient = Symbol('BoardsServiceClient');
@ -60,9 +62,13 @@ export interface BoardsServiceClient {
export const BoardsServicePath = '/services/boards-service';
export const BoardsService = Symbol('BoardsService');
export interface BoardsService extends Installable<BoardPackage>, Searchable<BoardPackage>, Detailable<BoardDetails>, JsonRpcServer<BoardsServiceClient> {
getAttachedBoards(): Promise<{ boards: Board[] }>;
getAvailablePorts(): Promise<{ ports: Port[] }>;
export interface BoardsService extends Installable<BoardsPackage>, Searchable<BoardsPackage>, JsonRpcServer<BoardsServiceClient> {
getAttachedBoards(): Promise<Board[]>;
getAvailablePorts(): Promise<Port[]>;
getBoardDetails(options: { fqbn: string }): Promise<BoardDetails>;
getBoardPackage(options: { id: string }): Promise<BoardsPackage | undefined>;
getContainerBoardPackage(options: { fqbn: string }): Promise<BoardsPackage | undefined>;
searchBoards(options: { query?: string }): Promise<Array<Board & { packageName: string }>>;
}
export interface Port {
@ -160,38 +166,114 @@ export namespace Port {
return false;
}
export function sameAs(left: Port | undefined, right: string | undefined) {
export function sameAs(left: Port | undefined, right: Port | string | undefined) {
if (left && right) {
if (left.protocol !== 'serial') {
console.log(`Unexpected protocol for port: ${JSON.stringify(left)}. Ignoring protocol, comparing addresses with ${right}.`);
console.log(`Unexpected protocol for 'left' port: ${JSON.stringify(left)}. Ignoring 'protocol', comparing 'addresses' with ${JSON.stringify(right)}.`);
}
return left.address === right;
if (typeof right === 'string') {
return left.address === right;
}
if (right.protocol !== 'serial') {
console.log(`Unexpected protocol for 'right' port: ${JSON.stringify(right)}. Ignoring 'protocol', comparing 'addresses' with ${JSON.stringify(left)}.`);
}
return left.address === right.address;
}
return false;
}
}
export interface BoardPackage extends ArduinoComponent {
id: string;
boards: Board[];
export interface BoardsPackage extends ArduinoComponent {
readonly id: string;
readonly boards: Board[];
}
export interface Board {
name: string
fqbn?: string
readonly name: string;
readonly fqbn?: string;
readonly port?: Port;
}
export interface BoardDetails extends Board {
fqbn: string;
requiredTools: Tool[];
export interface BoardDetails {
readonly fqbn: string;
readonly requiredTools: Tool[];
readonly configOptions: ConfigOption[];
}
export interface Tool {
readonly packager: string;
readonly name: string;
readonly version: string;
readonly version: Installable.Version;
}
export interface ConfigOption {
readonly option: string;
readonly label: string;
readonly values: ConfigValue[];
}
export namespace ConfigOption {
/**
* Appends the configuration options to the `fqbn` argument.
* Throws an error if the `fqbn` does not have the `segment(':'segment)*` format.
* The provided output format is always segment(':'segment)*(':'option'='value(','option'='value)*)?
* Validation can be disabled with the `{ validation: false }` option.
*/
export function decorate(fqbn: string, configOptions: ConfigOption[], { validate } = { validate: true }): string {
if (validate) {
if (!isValidFqbn(fqbn)) {
throw new ConfigOptionError(`${fqbn} is not a valid FQBN.`);
}
if (isValidFqbnWithOptions(fqbn)) {
throw new ConfigOptionError(`${fqbn} is already decorated with the configuration options.`);
}
}
if (!configOptions.length) {
return fqbn;
}
const toValue = (values: ConfigValue[]) => {
const selectedValue = values.find(({ selected }) => selected);
if (!selectedValue) {
console.warn(`None of the config values was selected. Values were: ${JSON.stringify(values)}`);
return undefined;
}
return selectedValue.value;
};
const options = configOptions
.map(({ option, values }) => [option, toValue(values)])
.filter(([, value]) => !!value)
.map(([option, value]) => `${option}=${value}`)
.join(',');
return `${fqbn}:${options}`;
}
export function isValidFqbn(fqbn: string): boolean {
return /^\w+(:\w+)*$/.test(fqbn);
}
export function isValidFqbnWithOptions(fqbn: string): boolean {
return /^\w+(:\w+)*(:\w+=\w+(,\w+=\w+)*)$/.test(fqbn);
}
export class ConfigOptionError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, ConfigOptionError.prototype);
}
}
export const LABEL_COMPARATOR = (left: ConfigOption, right: ConfigOption) => left.label.toLocaleLowerCase().localeCompare(right.label.toLocaleLowerCase());
}
export interface ConfigValue {
readonly label: string;
readonly value: string;
readonly selected: boolean;
}
export namespace Board {
@ -232,25 +314,36 @@ export namespace Board {
return `${board.name}${fqbn}`;
}
}
export function decorateBoards(
selectedBoard: Board | undefined,
searchResults: Array<Board & { packageName: string }>): Array<Board & { selected: boolean, missing: boolean, packageName: string, details?: string }> {
// Board names are not unique. We show the corresponding core name as a detail.
// https://github.com/arduino/arduino-cli/pull/294#issuecomment-513764948
const distinctBoardNames = new Map<string, number>();
for (const { name } of searchResults) {
const counter = distinctBoardNames.get(name) || 0;
distinctBoardNames.set(name, counter + 1);
}
export interface AttachedSerialBoard extends Board {
port: string;
}
export namespace AttachedSerialBoard {
export function is(b: Board | any): b is AttachedSerialBoard {
return !!b && 'port' in b;
// Due to the non-unique board names, we have to check the package name as well.
const selected = (board: Board & { packageName: string }) => {
if (!!selectedBoard) {
if (Board.equals(board, selectedBoard)) {
if ('packageName' in selectedBoard) {
return board.packageName === (selectedBoard as any).packageName;
}
return true;
}
}
return false;
}
return searchResults.map(board => ({
...board,
details: (distinctBoardNames.get(board.name) || 0) > 1 ? ` - ${board.packageName}` : undefined,
selected: selected(board),
missing: !installed(board)
}));
}
}
export interface AttachedNetworkBoard extends Board {
address: string;
port: string;
}
export namespace AttachedNetworkBoard {
export function is(b: Board): b is AttachedNetworkBoard {
return 'address' in b && 'port' in b;
}
}

@ -1,5 +1,4 @@
import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory';
import { Board } from './boards-service';
export const CoreServiceClient = Symbol('CoreServiceClient');
export interface CoreServiceClient {
@ -15,20 +14,18 @@ export interface CoreService extends JsonRpcServer<CoreServiceClient> {
export namespace CoreService {
export namespace Upload {
export namespace Compile {
export interface Options {
readonly uri: string;
readonly board: Board;
readonly port: string;
readonly sketchUri: string;
readonly fqbn: string;
readonly optimizeForDebug: boolean;
}
}
export namespace Compile {
export interface Options {
readonly uri: string;
readonly board: Board;
readonly optimizeForDebug: boolean;
export namespace Upload {
export interface Options extends Compile.Options {
readonly port: string;
}
}
}

@ -1,10 +0,0 @@
export interface Detailable<T> {
detail(options: Detailable.Options): Promise<{ item?: T }>;
}
export namespace Detailable {
export interface Options {
readonly id: string;
}
}

@ -1,5 +1,5 @@
export interface Searchable<T> {
search(options: Searchable.Options): Promise<{ items: T[] }>;
search(options: Searchable.Options): Promise<T[]>;
}
export namespace Searchable {
export interface Options {
@ -8,4 +8,4 @@ export namespace Searchable {
*/
readonly query?: string;
}
}
}

@ -1,30 +0,0 @@
import * as electron from 'electron';
import { injectable, inject, postConstruct } from 'inversify';
import { isOSX } from '@theia/core/lib/common/os';
import { ElectronMenuContribution } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution';
import { EditorMode } from '../browser/editor-mode';
@injectable()
export class ElectronArduinoMenuContribution extends ElectronMenuContribution {
@inject(EditorMode)
protected readonly editorMode: EditorMode;
@postConstruct()
protected init(): void {
this.editorMode.menuContentChanged.event(() => {
const createdMenuBar = this.factory.createMenuBar();
if (isOSX) {
electron.remote.Menu.setApplicationMenu(createdMenuBar);
} else {
electron.remote.getCurrentWindow().setMenu(createdMenuBar);
}
});
}
protected hideTopPanel(): void {
// NOOP
// We reuse the `div` for the Arduino toolbar.
}
}

@ -0,0 +1,17 @@
import { injectable } from 'inversify';
import { ElectronMenuContribution } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution';
import { MainMenuManager } from '../../browser/menu/main-menu-manager';
@injectable()
export class ElectronArduinoMenuContribution extends ElectronMenuContribution implements MainMenuManager {
protected hideTopPanel(): void {
// NOOP
// We reuse the `div` for the Arduino toolbar.
}
update(): void {
(this as any).setMenu();
}
}

@ -1,8 +1,10 @@
import { ContainerModule } from 'inversify';
import { ElectronMenuContribution } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution'
import { ElectronArduinoMenuContribution } from './electron-arduino-menu-contribution';
import { MainMenuManager } from '../../browser/menu/main-menu-manager';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ElectronArduinoMenuContribution).toSelf().inSingletonScope();
bind(MainMenuManager).toService(ElectronArduinoMenuContribution);
rebind(ElectronMenuContribution).to(ElectronArduinoMenuContribution);
});

@ -85,7 +85,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ConnectionContainerModule).toConstantValue(sketchesServiceConnectionModule);
// Boards service
const boardsServiceConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
const boardsServiceConnectionModule = ConnectionContainerModule.create(async ({ bind, bindBackendService }) => {
bind(BoardsServiceImpl).toSelf().inSingletonScope();
bind(BoardsService).toService(BoardsServiceImpl);
bindBackendService<BoardsService, BoardsServiceClient>(BoardsServicePath, BoardsService, (service, client) => {

@ -1,10 +1,7 @@
import { injectable, inject, postConstruct, named } from 'inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { Deferred } from '@theia/core/lib/common/promise-util';
import {
BoardsService, AttachedSerialBoard, BoardPackage, Board, AttachedNetworkBoard, BoardsServiceClient,
Port, BoardDetails, Tool
} from '../common/protocol/boards-service';
import { BoardsService, BoardsPackage, Board, BoardsServiceClient, Port, BoardDetails, Tool, ConfigOption, ConfigValue } from '../common/protocol';
import {
PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInstallResp, PlatformListReq,
PlatformListResp, Platform, PlatformUninstallResp, PlatformUninstallReq
@ -37,8 +34,8 @@ export class BoardsServiceImpl implements BoardsService {
* Stores the state of the currently discovered and attached boards.
* This state is updated via periodical polls. If there diff, a change event will be sent out to the frontend.
*/
protected attachedBoards: { boards: Board[] } = { boards: [] };
protected availablePorts: { ports: Port[] } = { ports: [] };
protected attachedBoards: Board[] = [];
protected availablePorts: Port[] = [];
protected started = new Deferred<void>();
protected client: BoardsServiceClient | undefined;
@ -49,8 +46,8 @@ export class BoardsServiceImpl implements BoardsService {
this.doGetAttachedBoardsAndAvailablePorts()
.then(({ boards, ports }) => {
const update = (oldBoards: Board[], newBoards: Board[], oldPorts: Port[], newPorts: Port[], message: string) => {
this.attachedBoards = { boards: newBoards };
this.availablePorts = { ports: newPorts };
this.attachedBoards = newBoards;
this.availablePorts = newPorts;
this.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newBoards)} and available ports: ${JSON.stringify(newPorts)}`);
if (this.client) {
this.client.notifyAttachedBoardsChanged({
@ -76,7 +73,7 @@ export class BoardsServiceImpl implements BoardsService {
Promise.all([
this.getAttachedBoards(),
this.getAvailablePorts()
]).then(([{ boards: currentBoards }, { ports: currentPorts }]) => {
]).then(([currentBoards, currentPorts]) => {
this.discoveryLogger.trace(`Updating discovered boards... ${JSON.stringify(currentBoards)}`);
if (currentBoards.length !== sortedBoards.length || currentPorts.length !== sortedPorts.length) {
update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards and available ports.');
@ -118,12 +115,12 @@ export class BoardsServiceImpl implements BoardsService {
this.client = undefined;
}
async getAttachedBoards(): Promise<{ boards: Board[] }> {
async getAttachedBoards(): Promise<Board[]> {
await this.started.promise;
return this.attachedBoards;
}
async getAvailablePorts(): Promise<{ ports: Port[] }> {
async getAvailablePorts(): Promise<Port[]> {
await this.started.promise;
return this.availablePorts;
}
@ -192,23 +189,8 @@ export class BoardsServiceImpl implements BoardsService {
for (const board of portList.getBoardsList()) {
const name = board.getName() || 'unknown';
const fqbn = board.getFqbn();
const port = address;
if (protocol === 'serial') {
boards.push(<AttachedSerialBoard>{
name,
fqbn,
port
});
} else if (protocol === 'network') { // We assume, it is a `network` board.
boards.push(<AttachedNetworkBoard>{
name,
fqbn,
address,
port
});
} else {
console.warn(`Unknown protocol for port: ${address}.`);
}
const port = { address, protocol };
boards.push({ name, fqbn, port });
}
}
// TODO: remove mock board!
@ -219,37 +201,79 @@ export class BoardsServiceImpl implements BoardsService {
return { boards, ports };
}
async detail(options: { id: string }): Promise<{ item?: BoardDetails }> {
async getBoardDetails(options: { fqbn: string }): Promise<BoardDetails> {
const coreClient = await this.coreClientProvider.client();
if (!coreClient) {
return {};
throw new Error(`Cannot acquire core client provider.`);
}
const { client, instance } = coreClient;
const { fqbn } = options;
const req = new BoardDetailsReq();
req.setInstance(instance);
req.setFqbn(options.id);
const resp = await new Promise<BoardDetailsResp>((resolve, reject) => client.boardDetails(req, (err, resp) => (!!err ? reject : resolve)(!!err ? err : resp)));
req.setFqbn(fqbn);
const resp = await new Promise<BoardDetailsResp>((resolve, reject) => client.boardDetails(req, (err, resp) => {
if (err) {
reject(err);
return;
}
resolve(resp);
}));
const tools = await Promise.all(resp.getRequiredToolsList().map(async t => <Tool>{
const requiredTools = resp.getRequiredToolsList().map(t => <Tool>{
name: t.getName(),
packager: t.getPackager(),
version: t.getVersion()
}));
});
const configOptions = resp.getConfigOptionsList().map(c => <ConfigOption>{
label: c.getOptionLabel(),
option: c.getOption(),
values: c.getValuesList().map(v => <ConfigValue>{
value: v.getValue(),
label: v.getValueLabel(),
selected: v.getSelected()
})
});
return {
item: {
name: resp.getName(),
fqbn: options.id,
requiredTools: tools
}
fqbn,
requiredTools,
configOptions
};
}
async search(options: { query?: string }): Promise<{ items: BoardPackage[] }> {
async getBoardPackage(options: { id: string }): Promise<BoardsPackage | undefined> {
const { id: expectedId } = options;
if (!expectedId) {
return undefined;
}
const packages = await this.search({ query: expectedId });
return packages.find(({ id }) => id === expectedId);
}
async getContainerBoardPackage(options: { fqbn: string }): Promise<BoardsPackage | undefined> {
const { fqbn: expectedFqbn } = options;
if (!expectedFqbn) {
return undefined;
}
const packages = await this.search({});
return packages.find(({ boards }) => boards.some(({ fqbn }) => fqbn === expectedFqbn));
}
async searchBoards(options: { query?: string }): Promise<Array<Board & { packageName: string }>> {
const query = (options.query || '').toLocaleLowerCase();
const results = await this.search(options);
return results.map(item => item.boards.map(board => ({ ...board, packageName: item.name })))
.reduce((acc, curr) => acc.concat(curr), [])
.filter(board => board.name.toLocaleLowerCase().indexOf(query) !== -1)
.sort(Board.compare);
}
async search(options: { query?: string }): Promise<BoardsPackage[]> {
const coreClient = await this.coreClientProvider.client();
if (!coreClient) {
return { items: [] };
return [];
}
const { client, instance } = coreClient;
@ -265,7 +289,7 @@ export class BoardsServiceImpl implements BoardsService {
req.setAllVersions(true);
req.setInstance(instance);
const resp = await new Promise<PlatformSearchResp>((resolve, reject) => client.platformSearch(req, (err, resp) => (!!err ? reject : resolve)(!!err ? err : resp)));
const packages = new Map<string, BoardPackage>();
const packages = new Map<string, BoardsPackage>();
const toPackage = (platform: Platform) => {
let installedVersion: string | undefined;
const matchingPlatform = installedPlatforms.find(ip => ip.getId() === platform.getId());
@ -307,7 +331,7 @@ export class BoardsServiceImpl implements BoardsService {
if (!leftInstalled && rightInstalled) {
return 1;
}
return Installable.Version.COMPARATOR(right.getLatest(), left.getLatest()); // Higher version comes first.
return Installable.Version.COMPARATOR(left.getLatest(), right.getLatest()); // Higher version comes first.
}
for (const id of groupedById.keys()) {
groupedById.get(id)!.sort(installedAwareVersionComparator);
@ -326,10 +350,10 @@ export class BoardsServiceImpl implements BoardsService {
}
}
return { items: [...packages.values()] };
return [...packages.values()];
}
async install(options: { item: BoardPackage, version?: Installable.Version }): Promise<void> {
async install(options: { item: BoardsPackage, version?: Installable.Version }): Promise<void> {
const pkg = options.item;
const version = !!options.version ? options.version : pkg.availableVersions[0];
const coreClient = await this.coreClientProvider.client();
@ -338,11 +362,11 @@ export class BoardsServiceImpl implements BoardsService {
}
const { client, instance } = coreClient;
const [platform, boardName] = pkg.id.split(":");
const [platform, architecture] = pkg.id.split(":");
const req = new PlatformInstallReq();
req.setInstance(instance);
req.setArchitecture(boardName);
req.setArchitecture(architecture);
req.setPlatformPackage(platform);
req.setVersion(version);
@ -359,12 +383,14 @@ export class BoardsServiceImpl implements BoardsService {
resp.on('error', reject);
});
if (this.client) {
this.client.notifyBoardInstalled({ pkg });
const packages = await this.search({});
const updatedPackage = packages.find(({ id }) => id === pkg.id) || pkg;
this.client.notifyBoardInstalled({ pkg: updatedPackage });
}
console.info("Board installation done", pkg);
}
async uninstall(options: { item: BoardPackage }): Promise<void> {
async uninstall(options: { item: BoardsPackage }): Promise<void> {
const pkg = options.item;
const coreClient = await this.coreClientProvider.client();
if (!coreClient) {
@ -372,11 +398,11 @@ export class BoardsServiceImpl implements BoardsService {
}
const { client, instance } = coreClient;
const [platform, boardName] = pkg.id.split(":");
const [platform, architecture] = pkg.id.split(":");
const req = new PlatformUninstallReq();
req.setInstance(instance);
req.setArchitecture(boardName);
req.setArchitecture(architecture);
req.setPlatformPackage(platform);
console.info("Starting board uninstallation", pkg);
@ -393,6 +419,7 @@ export class BoardsServiceImpl implements BoardsService {
resp.on('error', reject);
});
if (this.client) {
// Here, unlike at `install` we send out the argument `pkg`. Otherwise, we would not know about the board FQBN.
this.client.notifyBoardUninstalled({ pkg });
}
console.info("Board uninstallation done", pkg);

@ -36,10 +36,10 @@ export class CoreServiceImpl implements CoreService {
async compile(options: CoreService.Compile.Options): Promise<void> {
console.log('compile', options);
const { uri } = options;
const sketchFilePath = await this.fileSystem.getFsPath(options.uri);
const { sketchUri, fqbn } = options;
const sketchFilePath = await this.fileSystem.getFsPath(sketchUri);
if (!sketchFilePath) {
throw new Error(`Cannot resolve filesystem path for URI: ${uri}.`);
throw new Error(`Cannot resolve filesystem path for URI: ${sketchUri}.`);
}
const sketchpath = path.dirname(sketchFilePath);
@ -49,18 +49,14 @@ export class CoreServiceImpl implements CoreService {
}
const { client, instance } = coreClient;
const currentBoard = options.board;
if (!currentBoard) {
throw new Error("no board selected");
}
if (!currentBoard.fqbn) {
throw new Error(`selected board (${currentBoard.name}) has no FQBN`);
if (!fqbn) {
throw new Error('The selected board has no FQBN.');
}
const compilerReq = new CompileReq();
compilerReq.setInstance(instance);
compilerReq.setSketchpath(sketchpath);
compilerReq.setFqbn(currentBoard.fqbn!);
compilerReq.setFqbn(fqbn);
compilerReq.setOptimizefordebug(options.optimizeForDebug);
compilerReq.setPreprocess(false);
compilerReq.setVerbose(true);
@ -84,23 +80,15 @@ export class CoreServiceImpl implements CoreService {
}
async upload(options: CoreService.Upload.Options): Promise<void> {
await this.compile({ uri: options.uri, board: options.board, optimizeForDebug: options.optimizeForDebug });
await this.compile(options);
console.log('upload', options);
const { uri } = options;
const sketchFilePath = await this.fileSystem.getFsPath(options.uri);
const { sketchUri, fqbn } = options;
const sketchFilePath = await this.fileSystem.getFsPath(sketchUri);
if (!sketchFilePath) {
throw new Error(`Cannot resolve filesystem path for URI: ${uri}.`);
throw new Error(`Cannot resolve filesystem path for URI: ${sketchUri}.`);
}
const sketchpath = path.dirname(sketchFilePath);
const currentBoard = options.board;
if (!currentBoard) {
throw new Error("no board selected");
}
if (!currentBoard.fqbn) {
throw new Error(`selected board (${currentBoard.name}) has no FQBN`);
}
const coreClient = await this.coreClientProvider.client();
if (!coreClient) {
@ -108,10 +96,14 @@ export class CoreServiceImpl implements CoreService {
}
const { client, instance } = coreClient;
if (!fqbn) {
throw new Error('The selected board has no FQBN.');
}
const req = new UploadReq();
req.setInstance(instance);
req.setSketchPath(sketchpath);
req.setFqbn(currentBoard.fqbn);
req.setFqbn(fqbn);
req.setPort(options.port);
const result = client.upload(req);

@ -25,10 +25,10 @@ export class LibraryServiceImpl implements LibraryService {
@inject(ToolOutputServiceServer)
protected readonly toolOutputService: ToolOutputServiceServer;
async search(options: { query?: string }): Promise<{ items: Library[] }> {
async search(options: { query?: string }): Promise<Library[]> {
const coreClient = await this.coreClientProvider.client();
if (!coreClient) {
return { items: [] };
return [];
}
const { client, instance } = coreClient;
@ -68,7 +68,7 @@ export class LibraryServiceImpl implements LibraryService {
}, item.getLatest()!, availableVersions)
})
return { items };
return items;
}
async install(options: { item: Library, version?: Installable.Version }): Promise<void> {

@ -0,0 +1,310 @@
import { expect } from 'chai';
import { Container, injectable } from 'inversify';
import { Event } from '@theia/core/lib/common/event';
import { ILogger } from '@theia/core/lib/common/logger';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
import { MaybePromise } from '@theia/core/lib/common/types';
import { StorageService } from '@theia/core/lib/browser/storage-service';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { BoardsService, Board, Port, BoardsPackage, BoardDetails, BoardsServiceClient } from '../../common/protocol';
import { BoardsServiceClientImpl, AvailableBoard } from '../../browser/boards/boards-service-client-impl';
import { BoardsConfig } from '../../browser/boards/boards-config';
// tslint:disable: no-unused-expression
describe('boards-service-client-impl', () => {
describe('onAvailableBoardsChanged', () => {
const ESP8266: Port = { protocol: 'serial', address: '/dev/cu.SLAB_USBtoUART' };
const UNO: Board = { name: 'Arduino Uno', fqbn: 'arduino:avr:uno', port: { protocol: 'serial', address: '/dev/cu.usbmodem14501' } };
const MKR1000: Board = { name: 'Arduino MKR1000', fqbn: 'arduino:samd:mkr1000', port: { protocol: 'serial', address: '/dev/cu.usbmodem14601' } };
const NANO: Board = { name: 'Arduino Nano', fqbn: 'arduino:avr:nano' };
const recognized = AvailableBoard.State.recognized;
const guessed = AvailableBoard.State.guessed;
const incomplete = AvailableBoard.State.incomplete;
let server: MockBoardsService;
let client: BoardsServiceClientImpl;
// let storage: MockStorageService;
beforeEach(() => {
const container = init();
server = container.get(MockBoardsService);
client = container.get(BoardsServiceClientImpl);
// storage = container.get(MockStorageService);
server.setClient(client);
});
it('should have no available boards by default', () => {
expect(client.availableBoards).to.have.length(0);
});
it('should be notified when a board is attached', async () => {
await attach(MKR1000);
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(recognized);
expect(!!availableBoards()[0].selected).to.be.false;
});
it('should be notified when a unknown board is attached', async () => {
await attach(ESP8266);
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(incomplete);
});
it('should be notified when a board is detached', async () => {
await attach(MKR1000, UNO, ESP8266);
expect(availableBoards()).to.have.length(3);
await detach(MKR1000);
expect(availableBoards()).to.have.length(2);
});
it('should be notified when an unknown board is detached', async () => {
await attach(MKR1000, UNO, ESP8266);
expect(availableBoards()).to.have.length(3);
await detach(ESP8266);
expect(availableBoards()).to.have.length(2);
});
it('should recognize boards config as an available board', async () => {
await configureBoards({ selectedBoard: NANO });
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(incomplete);
expect(availableBoards()[0].selected).to.be.true;
});
it('should discard the boards config port when corresponding board is detached', async () => {
await attach(MKR1000);
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(recognized);
expect(availableBoards()[0].selected).to.be.false;
await configureBoards({ selectedBoard: MKR1000, selectedPort: server.portFor(MKR1000) });
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(recognized);
expect(availableBoards()[0].selected).to.be.true;
await detach(MKR1000);
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(incomplete);
expect(availableBoards()[0].selected).to.be.true;
});
it("should consider selected unknown boards as 'guessed'", async () => {
await attach(ESP8266);
await configureBoards({ selectedBoard: { name: 'guessed' }, selectedPort: ESP8266 });
expect(availableBoards()).to.have.length(1);
expect(availableBoards()[0].state).to.be.equal(guessed);
expect(availableBoards()[0].name).to.be.equal('guessed');
expect(availableBoards()[0].fqbn).to.be.undefined;
expect(client.canVerify(client.boardsConfig)).to.be.true;
});
it('should not reconnect last valid selected if port is gone', async () => {
await attach(ESP8266, UNO);
await configureBoards({ selectedBoard: { name: 'NodeMCU 0.9 (ESP-12 Module)', fqbn: 'esp8266:esp8266:nodemcu' }, selectedPort: ESP8266 });
await detach(ESP8266);
expect(availableBoards()).to.have.length(2);
const selected = availableBoards().find(({ selected }) => selected);
expect(selected).to.be.not.undefined;
expect(selected!.port).to.be.undefined;
expect(selected!.name).to.be.equal('NodeMCU 0.9 (ESP-12 Module)');
});
function availableBoards(): AvailableBoard[] {
return client.availableBoards.slice();
}
async function configureBoards(config: BoardsConfig.Config): Promise<void> {
return awaitAll(() => { client.boardsConfig = config; }, client.onAvailableBoardsChanged);
}
async function detach(...toDetach: Array<Board | Port>): Promise<void> {
return awaitAll(() => server.detach(...toDetach), client.onAttachedBoardsChanged, client.onAvailableBoardsChanged);
}
async function attach(...toAttach: Array<Board | Port>): Promise<void> {
return awaitAll(() => server.attach(...toAttach), client.onAttachedBoardsChanged, client.onAvailableBoardsChanged);
}
async function awaitAll(exec: () => MaybePromise<void>, ...waitFor: Event<any>[]): Promise<void> {
return new Promise<void>(async resolve => {
const toDispose = new DisposableCollection();
const promises = waitFor.map(event => {
const deferred = new Deferred<void>();
toDispose.push(event(() => deferred.resolve()));
return deferred.promise;
});
await exec();
await Promise.all(promises);
toDispose.dispose();
resolve();
});
}
});
});
function init(): Container {
const container = new Container({ defaultScope: 'Singleton' });
container.bind(MockBoardsService).toSelf();
container.bind(MockLogger).toSelf();
container.bind(ILogger).toService(MockLogger);
container.bind(MockStorageService).toSelf();
container.bind(StorageService).toService(MockStorageService);
container.bind(BoardsServiceClientImpl).toSelf();
return container;
}
@injectable()
export class MockBoardsService implements BoardsService {
private client: BoardsServiceClient | undefined;
boards: Board[] = [];
ports: Port[] = [];
attach(...toAttach: Array<Board | Port>): void {
const oldState = { boards: this.boards.slice(), ports: this.ports.slice() };
for (const what of toAttach) {
if (Board.is(what)) {
if (what.port) {
this.ports.push(what.port);
}
this.boards.push(what);
} else {
this.ports.push(what);
}
}
const newState = { boards: this.boards, ports: this.ports };
if (this.client) {
this.client.notifyAttachedBoardsChanged({ oldState, newState });
}
}
detach(...toRemove: Array<Board | Port>): void {
const oldState = { boards: this.boards.slice(), ports: this.ports.slice() };
for (const what of toRemove) {
if (Board.is(what)) {
const index = this.boards.indexOf(what);
if (index === -1) {
throw new Error(`${what} board is not attached. Boards were: ${JSON.stringify(oldState.boards)}`);
}
this.boards.splice(index, 1);
if (what.port) {
const portIndex = this.ports.findIndex(port => Port.sameAs(what.port, port));
if (portIndex === -1) {
throw new Error(`${what} port is not available. Ports were: ${JSON.stringify(oldState.ports)}`);
}
this.ports.splice(portIndex, 1);
}
} else {
const index = this.ports.indexOf(what);
if (index === -1) {
throw new Error(`${what} port is not available. Ports were: ${JSON.stringify(oldState.ports)}`);
}
this.ports.splice(index, 1);
}
}
const newState = { boards: this.boards, ports: this.ports };
if (this.client) {
this.client.notifyAttachedBoardsChanged({ oldState, newState });
}
}
reset(): void {
this.setState({ boards: [], ports: [], silent: true });
}
setState({ boards, ports, silent }: { boards: Board[], ports: Port[], silent?: boolean }): void {
const oldState = { boards: this.boards, ports: this.ports };
const newState = { boards, ports };
if (this.client && !silent) {
this.client.notifyAttachedBoardsChanged({ oldState, newState });
}
}
portFor(board: Board): Port {
if (!board.port) {
throw new Error(`${JSON.stringify(board)} does not have a port.`);
}
const port = this.ports.find(port => Port.sameAs(port, board.port));
if (!port) {
throw new Error(`Could not find port for board: ${JSON.stringify(board)}. Ports were: ${JSON.stringify(this.ports)}.`);
}
return port;
}
// BoardsService API
async getAttachedBoards(): Promise<Board[]> {
return this.boards;
}
async getAvailablePorts(): Promise<Port[]> {
throw this.ports;
}
async getBoardDetails(): Promise<BoardDetails> {
throw new Error('Method not implemented.');
}
getBoardPackage(): Promise<BoardsPackage> {
throw new Error('Method not implemented.');
}
getContainerBoardPackage(): Promise<BoardsPackage> {
throw new Error('Method not implemented.');
}
searchBoards(): Promise<Array<Board & { packageName: string; }>> {
throw new Error('Method not implemented.');
}
install(): Promise<void> {
throw new Error('Method not implemented.');
}
uninstall(): Promise<void> {
throw new Error('Method not implemented.');
}
search(): Promise<BoardsPackage[]> {
throw new Error('Method not implemented.');
}
dispose(): void {
this.reset();
this.client = undefined;
}
setClient(client: BoardsServiceClient | undefined): void {
this.client = client;
}
}
@injectable()
class MockStorageService implements StorageService {
private store: Map<string, any> = new Map();
reset(): void {
this.store.clear();
}
async setData<T>(key: string, data: T): Promise<void> {
this.store.set(key, data);
}
async getData<T>(key: string): Promise<T | undefined>;
async getData<T>(key: string, defaultValue?: T): Promise<T | undefined> {
const data = this.store.get(key);
return data ? data : defaultValue;
}
}

@ -0,0 +1,116 @@
import { expect } from 'chai';
import { ConfigOption, AttachedBoardsChangeEvent } from '../../common/protocol';
import { fail } from 'assert';
describe('boards-service', () => {
describe('AttachedBoardsChangeEvent', () => {
it('should detect one attached port', () => {
const event = <AttachedBoardsChangeEvent & any>{
oldState: {
boards: [
{ name: 'Arduino MKR1000', fqbn: 'arduino:samd:mkr1000', port: '/dev/cu.usbmodem14601' },
{ name: 'Arduino Uno', fqbn: 'arduino:avr:uno', port: '/dev/cu.usbmodem14501' }
],
ports: [
{ protocol: 'serial', address: '/dev/cu.usbmodem14501' },
{ protocol: 'serial', address: '/dev/cu.usbmodem14601' },
{ protocol: 'serial', address: '/dev/cu.Bluetooth-Incoming-Port' },
{ protocol: 'serial', address: '/dev/cu.MALS' },
{ protocol: 'serial', address: '/dev/cu.SOC' }
]
},
newState: {
boards: [
{ name: 'Arduino MKR1000', fqbn: 'arduino:samd:mkr1000', 'port': '/dev/cu.usbmodem1460' },
{ name: 'Arduino Uno', fqbn: 'arduino:avr:uno', 'port': '/dev/cu.usbmodem14501' }
],
ports: [
{ protocol: 'serial', address: '/dev/cu.SLAB_USBtoUART' },
{ protocol: 'serial', address: '/dev/cu.usbmodem14501' },
{ protocol: 'serial', address: '/dev/cu.usbmodem14601' },
{ protocol: 'serial', address: '/dev/cu.Bluetooth-Incoming-Port' },
{ protocol: 'serial', address: '/dev/cu.MALS' },
{ protocol: 'serial', address: '/dev/cu.SOC' }
]
}
};
const diff = AttachedBoardsChangeEvent.diff(event);
expect(diff.attached.boards).to.be.empty; // tslint:disable-line:no-unused-expression
expect(diff.detached.boards).to.be.empty; // tslint:disable-line:no-unused-expression
expect(diff.detached.ports).to.be.empty; // tslint:disable-line:no-unused-expression
expect(diff.attached.ports.length).to.be.equal(1);
expect(diff.attached.ports[0].address).to.be.equal('/dev/cu.SLAB_USBtoUART');
});
});
describe('ConfigOption', () => {
([
['', false],
['foo', true],
['foo:bar', true],
['foo:bar:baz', true],
['foo:', false],
[':foo', false],
[':foo:', false],
['foo:bar:', false]
] as Array<[string, boolean]>).forEach(([fqbn, expectation]) => {
it(`"${fqbn}" should ${expectation ? '' : 'not '}be a valid FQBN`, () => {
expect(ConfigOption.isValidFqbn(fqbn)).to.be.equal(expectation);
});
});
([
['', false],
['foo:bar:option1', false],
['foo:bar:option1=', false],
['foo:bar:baz:option1=value1', true],
['foo:bar:baz:option1=value1,option2=value2', true],
['foo:bar:baz:option1=value1,option2=value2,', false],
['foo:bar:baz,option1=value1,option2=value2', false],
['foo:bar:baz:option1=value1,option2=value2,options3', false],
['foo:bar:baz:option1=value1,option2=value2, options3=value3', false],
] as Array<[string, boolean]>).forEach(([fqbn, expectation]) => {
it(`"${fqbn}" should ${expectation ? '' : 'not '}be a valid FQBN with options`, () => {
expect(ConfigOption.isValidFqbnWithOptions(fqbn)).to.be.equal(expectation);
});
});
([
[
'foo:bar:baz',
JSON.parse('[{"label":"CPU Frequency","option":"xtal","values":[{"value":"80","label":"80 MHz","selected":true},{"value":"160","label":"160 MHz","selected":false}]},{"label":"VTables","option":"vt","values":[{"value":"flash","label":"Flash","selected":true},{"value":"heap","label":"Heap","selected":false},{"value":"iram","label":"IRAM","selected":false}]},{"label":"Exceptions","option":"exception","values":[{"value":"legacy","label":"Legacy (new can return nullptr)","selected":true},{"value":"disabled","label":"Disabled (new can abort)","selected":false},{"value":"enabled","label":"Enabled","selected":false}]},{"label":"SSL Support","option":"ssl","values":[{"value":"all","label":"All SSL ciphers (most compatible)","selected":true},{"value":"basic","label":"Basic SSL ciphers (lower ROM use)","selected":false}]},{"label":"Flash Size","option":"eesz","values":[{"value":"4M2M","label":"4MB (FS:2MB OTA:~1019KB)","selected":true},{"value":"4M3M","label":"4MB (FS:3MB OTA:~512KB)","selected":false},{"value":"4M1M","label":"4MB (FS:1MB OTA:~1019KB)","selected":false},{"value":"4M","label":"4MB (FS:none OTA:~1019KB)","selected":false}]},{"label":"lwIP Variant","option":"ip","values":[{"value":"lm2f","label":"v2 Lower Memory","selected":true},{"value":"hb2f","label":"v2 Higher Bandwidth","selected":false},{"value":"lm2n","label":"v2 Lower Memory (no features)","selected":false},{"value":"hb2n","label":"v2 Higher Bandwidth (no features)","selected":false},{"value":"lm6f","label":"v2 IPv6 Lower Memory","selected":false},{"value":"hb6f","label":"v2 IPv6 Higher Bandwidth","selected":false},{"value":"hb1","label":"v1.4 Higher Bandwidth","selected":false},{"value":"src","label":"v1.4 Compile from source","selected":false}]},{"label":"Debug port","option":"dbg","values":[{"value":"Disabled","label":"Disabled","selected":true},{"value":"Serial","label":"Serial","selected":false},{"value":"Serial1","label":"Serial1","selected":false}]},{"label":"Debug Level","option":"lvl","values":[{"value":"None____","label":"None","selected":true},{"value":"SSL","label":"SSL","selected":false},{"value":"TLS_MEM","label":"TLS_MEM","selected":false},{"value":"HTTP_CLIENT","label":"HTTP_CLIENT","selected":false},{"value":"HTTP_SERVER","label":"HTTP_SERVER","selected":false},{"value":"SSLTLS_MEM","label":"SSL+TLS_MEM","selected":false},{"value":"SSLHTTP_CLIENT","label":"SSL+HTTP_CLIENT","selected":false},{"value":"SSLHTTP_SERVER","label":"SSL+HTTP_SERVER","selected":false},{"value":"TLS_MEMHTTP_CLIENT","label":"TLS_MEM+HTTP_CLIENT","selected":false},{"value":"TLS_MEMHTTP_SERVER","label":"TLS_MEM+HTTP_SERVER","selected":false},{"value":"HTTP_CLIENTHTTP_SERVER","label":"HTTP_CLIENT+HTTP_SERVER","selected":false},{"value":"SSLTLS_MEMHTTP_CLIENT","label":"SSL+TLS_MEM+HTTP_CLIENT","selected":false},{"value":"SSLTLS_MEMHTTP_SERVER","label":"SSL+TLS_MEM+HTTP_SERVER","selected":false},{"value":"SSLHTTP_CLIENTHTTP_SERVER","label":"SSL+HTTP_CLIENT+HTTP_SERVER","selected":false},{"value":"TLS_MEMHTTP_CLIENTHTTP_SERVER","label":"TLS_MEM+HTTP_CLIENT+HTTP_SERVER","selected":false},{"value":"SSLTLS_MEMHTTP_CLIENTHTTP_SERVER","label":"SSL+TLS_MEM+HTTP_CLIENT+HTTP_SERVER","selected":false},{"value":"CORE","label":"CORE","selected":false},{"value":"WIFI","label":"WIFI","selected":false},{"value":"HTTP_UPDATE","label":"HTTP_UPDATE","selected":false},{"value":"UPDATER","label":"UPDATER","selected":false},{"value":"OTA","label":"OTA","selected":false},{"value":"OOM","label":"OOM","selected":false},{"value":"MDNS","label":"MDNS","selected":false},{"value":"COREWIFIHTTP_UPDATEUPDATEROTAOOMMDNS","label":"CORE+WIFI+HTTP_UPDATE+UPDATER+OTA+OOM+MDNS","selected":false},{"value":"SSLTLS_MEMHTTP_CLIENTHTTP_SERVERCOREWIFIHTTP_UPDATEUPDATEROTAOOMMDNS","label":"SSL+TLS_MEM+HTTP_CLIENT+HTTP_SERVER+CORE+WIFI+HTTP_UPDATE+UPDATER+OTA+OOM+MDNS","selected":false},{"value":"NoAssert-NDEBUG","label":"NoAssert-NDEBUG","selected":false}]},{"label":"Erase Flash","option":"wipe","values":[{"value":"none","label":"Only Sketch","selected":true},{"value":"sdk","label":"Sketch + WiFi Settings","selected":false},{"value":"all","label":"All Flash Contents","selected":false}]},{"label":"Upload Speed","option":"baud","values":[{"value":"115200","label":"115200","selected":true},{"value":"57600","label":"57600","selected":false},{"value":"230400","label":"230400","selected":false},{"value":"460800","label":"460800","selected":false},{"value":"921600","label":"921600","selected":false},{"value":"3000000","label":"3000000","selected":false}]}]'),
'foo:bar:baz:xtal=80,vt=flash,exception=legacy,ssl=all,eesz=4M2M,ip=lm2f,dbg=Disabled,lvl=None____,wipe=none,baud=115200'
],
[
'foo:bar:baz',
JSON.parse('[]'),
'foo:bar:baz'
],
[
'foo:bar:baz:xtal=80',
{},
undefined
]
] as Array<[string, Array<ConfigOption>, string | undefined]>).forEach(([fqbn, configOptions, expectation]) => {
it(`should ${expectation ? `append` : 'throw an error when appending'}config options to ${fqbn}`, () => {
if (!expectation) {
try {
ConfigOption.decorate(fqbn, configOptions);
fail(`Expected a failure when decorating ${fqbn} with config options.`);
} catch (e) {
expect(e).to.be.instanceOf(ConfigOption.ConfigOptionError);
}
} else {
expect(ConfigOption.decorate(fqbn, configOptions)).to.be.equal(expectation);
}
});
});
});
});

@ -1,4 +0,0 @@
Known issues:
- arduino-cli does not get stopped reliably upon app shutdown
- startup time is not as fast we'd like to have it
- in Electron on OSX, the application menu is incomplete on app startup (see https://github.com/theia-ide/theia/issues/5100)

914
yarn.lock

File diff suppressed because it is too large Load Diff