import { injectable, inject } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; import { CommandService } from '@theia/core/lib/common/command'; import { MessageService } from '@theia/core/lib/common/message-service'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; import { RecursiveRequired } from '../../common/types'; import { Port, Board, BoardsService, BoardsPackage, AttachedBoardsChangeEvent, BoardWithPackage, BoardUserField, } from '../../common/protocol'; import { BoardsConfig } from './boards-config'; import { naturalCompare } from '../../common/utils'; import { NotificationCenter } from '../notification-center'; import { StorageWrapper } from '../storage-wrapper'; import { nls } from '@theia/core/lib/common'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @injectable() export class BoardsServiceProvider implements FrontendApplicationContribution { @inject(ILogger) protected logger: ILogger; @inject(MessageService) protected messageService: MessageService; @inject(BoardsService) protected boardsService: BoardsService; @inject(CommandService) protected commandService: CommandService; @inject(NotificationCenter) protected notificationCenter: NotificationCenter; @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; protected readonly onBoardsConfigChangedEmitter = new Emitter<BoardsConfig.Config>(); protected readonly onAvailableBoardsChangedEmitter = new Emitter< AvailableBoard[] >(); protected readonly onAvailablePortsChangedEmitter = new Emitter<Port[]>(); /** * Used for the auto-reconnecting. Sometimes, the attached board gets disconnected after uploading something to it. * It happens with certain boards on Windows. For example, the `MKR1000` boards is selected on post `COM5` on Windows, * perform an upload, the board automatically disconnects and reconnects, but on another port, `COM10`. * We have to listen on such changes and auto-reconnect the same board on another port. * See: https://arduino.slack.com/archives/CJJHJCJSJ/p1568645417013000?thread_ts=1568640504.009400&cid=CJJHJCJSJ */ protected latestValidBoardsConfig: | RecursiveRequired<BoardsConfig.Config> | undefined = undefined; protected latestBoardsConfig: 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[] = []; /** * 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 event is also emitted when the board package for the currently selected board was uninstalled. */ readonly onBoardsConfigChanged = this.onBoardsConfigChangedEmitter.event; readonly onAvailableBoardsChanged = this.onAvailableBoardsChangedEmitter.event; readonly onAvailablePortsChanged = this.onAvailablePortsChangedEmitter.event; private readonly _reconciled = new Deferred<void>(); onStart(): void { this.notificationCenter.onAttachedBoardsDidChange( this.notifyAttachedBoardsChanged.bind(this) ); this.notificationCenter.onPlatformDidInstall( this.notifyPlatformInstalled.bind(this) ); this.notificationCenter.onPlatformDidUninstall( this.notifyPlatformUninstalled.bind(this) ); this.appStateService.reachedState('ready').then(async () => { const [attachedBoards, availablePorts] = await Promise.all([ this.boardsService.getAttachedBoards(), this.boardsService.getAvailablePorts(), this.loadState(), ]); this._attachedBoards = attachedBoards; this._availablePorts = availablePorts; this.onAvailablePortsChangedEmitter.fire(this._availablePorts); await this.reconcileAvailableBoards(); this.tryReconnect(); this._reconciled.resolve(); }); } get reconciled(): Promise<void> { return this._reconciled.promise; } protected notifyAttachedBoardsChanged( event: AttachedBoardsChangeEvent ): void { if (!AttachedBoardsChangeEvent.isEmpty(event)) { this.logger.info('Attached boards and available ports changed:'); this.logger.info(AttachedBoardsChangeEvent.toString(event)); this.logger.info('------------------------------------------'); } this._attachedBoards = event.newState.boards; this._availablePorts = event.newState.ports; this.onAvailablePortsChangedEmitter.fire(this._availablePorts); this.reconcileAvailableBoards().then(() => this.tryReconnect()); } protected notifyPlatformInstalled(event: { item: BoardsPackage }): void { this.logger.info('Boards package installed: ', JSON.stringify(event)); const { selectedBoard } = this.boardsConfig; const { installedVersion, id } = event.item; if (selectedBoard) { const installedBoard = event.item.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, }; return; } // The board name can change after install. // This logic handles it "gracefully" by unselecting the board, so that we can avoid no FQBN is set error. // https://github.com/arduino/arduino-cli/issues/620 // https://github.com/arduino/arduino-pro-ide/issues/374 if ( BoardWithPackage.is(selectedBoard) && selectedBoard.packageId === event.item.id && !installedBoard ) { const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes'); this.messageService .warn( nls.localize( 'arduino/board/couldNotFindPreviouslySelected', "Could not find previously selected board '{0}' in installed platform '{1}'. Please manually reselect the board you want to use. Do you want to reselect it now?", selectedBoard.name, event.item.name ), nls.localize('arduino/board/reselectLater', 'Reselect later'), yes ) .then(async (answer) => { if (answer === yes) { this.commandService.executeCommand( 'arduino-open-boards-dialog', selectedBoard.name ); } }); this.boardsConfig = {}; return; } // Trigger a board re-set. See: https://github.com/arduino/arduino-cli/issues/954 // E.g: install `adafruit:avr`, then select `adafruit:avr:adafruit32u4` board, and finally install the required `arduino:avr` this.boardsConfig = this.boardsConfig; } } protected notifyPlatformUninstalled(event: { item: BoardsPackage }): void { this.logger.info('Boards package uninstalled: ', JSON.stringify(event)); const { selectedBoard } = this.boardsConfig; if (selectedBoard && selectedBoard.fqbn) { const uninstalledBoard = event.item.boards.find( ({ name }) => name === selectedBoard.name ); if (uninstalledBoard && uninstalledBoard.fqbn === selectedBoard.fqbn) { // We should not unset the FQBN, if the selected board is an attached, recognized board. // Attach Uno and install AVR, select Uno. Uninstall the AVR core while Uno is selected. We do not want to discard the FQBN of the Uno board. // Dev note: We cannot assume the `selectedBoard` is a type of `AvailableBoard`. // When the user selects an `AvailableBoard` it works, but between app start/stops, // it is just a FQBN, so we need to find the `selected` board among the `AvailableBoards` const selectedAvailableBoard = AvailableBoard.is(selectedBoard) ? selectedBoard : this._availableBoards.find((availableBoard) => Board.sameAs(availableBoard, selectedBoard) ); if ( selectedAvailableBoard && selectedAvailableBoard.selected && selectedAvailableBoard.state === AvailableBoard.State.recognized ) { return; } this.logger.info( `Board package ${event.item.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, }; } } } protected tryReconnect(): boolean { if (this.latestValidBoardsConfig && !this.canUploadTo(this.boardsConfig)) { 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) ) { this.boardsConfig = this.latestValidBoardsConfig; return true; } } // 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 this.availableBoards.filter( ({ state }) => state !== AvailableBoard.State.incomplete )) { if ( this.latestValidBoardsConfig.selectedBoard.fqbn === board.fqbn && this.latestValidBoardsConfig.selectedBoard.name === board.name && this.latestValidBoardsConfig.selectedPort.protocol === board.port?.protocol ) { this.boardsConfig = { ...this.latestValidBoardsConfig, selectedPort: board.port, }; return true; } } } return false; } set boardsConfig(config: BoardsConfig.Config) { this.setBoardsConfig(config); this.saveState().finally(() => this.reconcileAvailableBoards().finally(() => this.onBoardsConfigChangedEmitter.fire(this._boardsConfig) ) ); } get boardsConfig(): BoardsConfig.Config { return this._boardsConfig; } protected setBoardsConfig(config: BoardsConfig.Config): void { this.logger.debug('Board config changed: ', JSON.stringify(config)); this._boardsConfig = config; this.latestBoardsConfig = this._boardsConfig; if (this.canUploadTo(this._boardsConfig)) { this.latestValidBoardsConfig = this._boardsConfig; } } async searchBoards({ query, cores, }: { query?: string; cores?: string[]; }): Promise<BoardWithPackage[]> { const boards = await this.boardsService.searchBoards({ query }); return boards; } async selectedBoardUserFields(): Promise<BoardUserField[]> { if (!this._boardsConfig.selectedBoard || !this._boardsConfig.selectedPort) { return []; } const fqbn = this._boardsConfig.selectedBoard.fqbn; if (!fqbn) { return []; } const protocol = this._boardsConfig.selectedPort.protocol; return await this.boardsService.getBoardUserFields({ fqbn, protocol }); } /** * `true` if the `config.selectedBoard` is defined; hence can compile against the board. Otherwise, `false`. */ canVerify( config: BoardsConfig.Config | undefined = this.boardsConfig, options: { silent: boolean } = { silent: true } ): config is BoardsConfig.Config & { selectedBoard: Board } { if (!config) { return false; } if (!config.selectedBoard) { if (!options.silent) { this.messageService.warn( nls.localize('arduino/board/noneSelected', 'No boards selected.'), { timeout: 3000, } ); } return false; } return true; } /** * `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, options: { silent: boolean } = { silent: true } ): config is RecursiveRequired<BoardsConfig.Config> { if (!this.canVerify(config, options)) { return false; } const { name } = config.selectedBoard; if (!config.selectedPort) { if (!options.silent) { this.messageService.warn( nls.localize( 'arduino/board/noPortsSelected', "No ports selected for board: '{0}'.", name ), { timeout: 3000, } ); } return false; } if (!config.selectedBoard.fqbn) { if (!options.silent) { this.messageService.warn( nls.localize( 'arduino/board/noFQBN', 'The FQBN is not available for the selected board "{0}". Do you have the corresponding core installed?', name ), { timeout: 3000 } ); } return false; } return true; } get availableBoards(): AvailableBoard[] { return this._availableBoards; } async waitUntilAvailable( what: Board & { port: Port }, timeout?: number ): Promise<void> { const find = (needle: Board & { port: Port }, haystack: AvailableBoard[]) => haystack.find( (board) => Board.equals(needle, board) && Port.sameAs(needle.port, board.port) ); const timeoutTask = !!timeout && timeout > 0 ? new Promise<void>((_, reject) => setTimeout( () => reject(new Error(`Timeout after ${timeout} ms.`)), timeout ) ) : new Promise<void>(() => { /* never */ }); const waitUntilTask = new Promise<void>((resolve) => { let candidate = find(what, this.availableBoards); if (candidate) { resolve(); return; } const disposable = this.onAvailableBoardsChanged((availableBoards) => { candidate = find(what, availableBoards); if (candidate) { disposable.dispose(); resolve(); } }); }); return await Promise.race([waitUntilTask, timeoutTask]); } protected async reconcileAvailableBoards(): Promise<void> { 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.setBoardsConfig({ selectedBoard: this.boardsConfig.selectedBoard, selectedPort: undefined, }); this.onBoardsConfigChangedEmitter.fire(this._boardsConfig); } const boardsConfig = this.boardsConfig; const currentAvailableBoards = this._availableBoards; const availableBoards: AvailableBoard[] = []; const attachedBoards = this._attachedBoards.filter(({ port }) => !!port); const availableBoardPorts = availablePorts.filter((port) => { if (port.protocol === 'serial') { // We always show all serial ports, even if there // is no recognized board connected to it return true; } // All other ports with different protocol are // only shown if there is a recognized board // connected for (const board of attachedBoards) { if (board.port?.address === port.address) { return true; } } return false; }); for (const boardPort of availableBoardPorts) { const board = attachedBoards.find(({ port }) => Port.sameAs(boardPort, port) ); const lastSelectedBoard = await this.getLastSelectedBoardOnPort( boardPort ); let availableBoard = {} as AvailableBoard; if (board) { availableBoard = { ...board, state: AvailableBoard.State.recognized, selected: BoardsConfig.Config.sameAs(boardsConfig, board), port: boardPort, }; } else if (lastSelectedBoard) { // 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 availableBoard = { ...lastSelectedBoard, state: AvailableBoard.State.guessed, selected: BoardsConfig.Config.sameAs(boardsConfig, lastSelectedBoard), port: boardPort, }; } else { availableBoard = { name: nls.localize('arduino/common/unknown', 'Unknown'), port: boardPort, state: AvailableBoard.State.incomplete, }; } availableBoards.push(availableBoard); } if ( boardsConfig.selectedBoard && !availableBoards.some(({ selected }) => selected) ) { // If the selected board has the same port of an unknown board // that is already in availableBoards we might get a duplicate port. // So we remove the one already in the array and add the selected one. const found = availableBoards.findIndex( (board) => board.port?.address === boardsConfig.selectedPort?.address ); if (found >= 0) { availableBoards.splice(found, 1); } availableBoards.push({ ...boardsConfig.selectedBoard, port: boardsConfig.selectedPort, selected: true, state: AvailableBoard.State.incomplete, }); } availableBoards.sort(AvailableBoard.compare); let hasChanged = availableBoards.length !== currentAvailableBoards.length; for (let i = 0; !hasChanged && i < availableBoards.length; i++) { const [left, right] = [availableBoards[i], currentAvailableBoards[i]]; hasChanged = left.fqbn !== right.fqbn || !!AvailableBoard.compare(left, right) || left.selected !== right.selected; } if (hasChanged) { this._availableBoards = availableBoards; this.onAvailableBoardsChangedEmitter.fire(this._availableBoards); } } protected async getLastSelectedBoardOnPort( port: Port ): Promise<Board | undefined> { const key = this.getLastSelectedBoardOnPortKey(port); return this.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.setData(key, selectedBoard); } await Promise.all([ this.setData('latest-valid-boards-config', this.latestValidBoardsConfig), this.setData('latest-boards-config', this.latestBoardsConfig), ]); } 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.address }`; } protected async loadState(): Promise<void> { const storedLatestValidBoardsConfig = await this.getData< RecursiveRequired<BoardsConfig.Config> >('latest-valid-boards-config'); if (storedLatestValidBoardsConfig) { this.latestValidBoardsConfig = storedLatestValidBoardsConfig; if (this.canUploadTo(this.latestValidBoardsConfig)) { this.boardsConfig = this.latestValidBoardsConfig; } } else { // If we could not restore the latest valid config, try to restore something, the board at least. let storedLatestBoardsConfig = await this.getData< BoardsConfig.Config | undefined >('latest-boards-config'); // Try to get from the URL if it was not persisted. if (!storedLatestBoardsConfig) { storedLatestBoardsConfig = BoardsConfig.Config.getConfig( new URL(window.location.href) ); } if (storedLatestBoardsConfig) { this.latestBoardsConfig = storedLatestBoardsConfig; this.boardsConfig = this.latestBoardsConfig; } } } private setData<T>(key: string, value: T): Promise<void> { return this.commandService.executeCommand( StorageWrapper.Commands.SET_DATA.id, key, value ); } private getData<T>(key: string): Promise<T | undefined> { return this.commandService.executeCommand<T>( StorageWrapper.Commands.GET_DATA.id, key ); } } /** * Representation of a ready-to-use board, either the user has configured it or was automatically recognized by the CLI. * An available board was not 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`. We render an available board for the user * when it has the `port` set. */ 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 is(board: any): board is AvailableBoard { return Board.is(board) && 'state' in board; } export function hasPort( board: AvailableBoard ): board is AvailableBoard & { port: Port } { return !!board.port; } // Available boards must be sorted in this order: // 1. Serial with recognized boards // 2. Serial with guessed boards // 3. Serial with incomplete boards // 4. Network with recognized boards // 5. Other protocols with recognized boards export const compare = (left: AvailableBoard, right: AvailableBoard) => { if (left.port?.protocol === 'serial' && right.port?.protocol !== 'serial') { return -1; } else if ( left.port?.protocol !== 'serial' && right.port?.protocol === 'serial' ) { return 1; } else if ( left.port?.protocol === 'network' && right.port?.protocol !== 'network' ) { return -1; } else if ( left.port?.protocol !== 'network' && right.port?.protocol === 'network' ) { return 1; } else if (left.port?.protocol === right.port?.protocol) { // We show all ports, including those that have guessed // or unrecognized boards, so we must sort those too. if (left.state < right.state) { return -1; } else if (left.state > right.state) { return 1; } } return naturalCompare(left.port?.address!, right.port?.address!); }; }