import * as PQueue from 'p-queue'; 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 { PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInstallResp, PlatformListReq, PlatformListResp, Platform, PlatformUninstallResp, PlatformUninstallReq } from './cli-protocol/commands/core_pb'; import { CoreClientProvider } from './core-client-provider'; import { BoardListReq, BoardListResp, BoardDetailsReq, BoardDetailsResp } from './cli-protocol/commands/board_pb'; import { ToolOutputServiceServer } from '../common/protocol/tool-output-service'; import { Installable } from '../common/protocol/installable'; @injectable() export class BoardsServiceImpl implements BoardsService { @inject(ILogger) protected logger: ILogger; @inject(ILogger) @named('discovery') protected discoveryLogger: ILogger; @inject(CoreClientProvider) protected readonly coreClientProvider: CoreClientProvider; @inject(ToolOutputServiceServer) protected readonly toolOutputService: ToolOutputServiceServer; protected discoveryInitialized = false; protected discoveryTimer: NodeJS.Timer | undefined; /** * Poor man's serial discovery: * 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 started = new Deferred<void>(); protected client: BoardsServiceClient | undefined; protected readonly queue = new PQueue({ autoStart: true, concurrency: 1 }); @postConstruct() protected async init(): Promise<void> { this.discoveryTimer = setInterval(() => { this.discoveryLogger.trace('Discovering attached boards and available ports...'); 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.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newBoards)} and available ports: ${JSON.stringify(newPorts)}`); if (this.client) { this.client.notifyAttachedBoardsChanged({ oldState: { boards: oldBoards, ports: oldPorts }, newState: { boards: newBoards, ports: newPorts } }); } } const sortedBoards = boards.sort(Board.compare); const sortedPorts = ports.sort(Port.compare); this.discoveryLogger.trace(`Discovery done. Boards: ${JSON.stringify(sortedBoards)}. Ports: ${sortedPorts}`); if (!this.discoveryInitialized) { update([], sortedBoards, [], sortedPorts, 'Initialized attached boards and available ports.'); this.discoveryInitialized = true; this.started.resolve(); } else { Promise.all([ this.getAttachedBoards(), this.getAvailablePorts() ]).then(([{ boards: currentBoards }, { ports: 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.'); return; } // `currentBoards` is already sorted. for (let i = 0; i < sortedBoards.length; i++) { if (Board.compare(sortedBoards[i], currentBoards[i]) !== 0) { update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards.'); return; } } for (let i = 0; i < sortedPorts.length; i++) { if (Port.compare(sortedPorts[i], currentPorts[i]) !== 0) { update(currentBoards, sortedBoards, currentPorts, sortedPorts, 'Updated discovered boards.'); return; } } this.discoveryLogger.trace('No new boards were discovered.'); }); } }); }, 1000); } setClient(client: BoardsServiceClient | undefined): void { this.client = client; } dispose(): void { this.logger.info('>>> Disposing boards service...'); this.queue.pause(); this.queue.clear(); if (this.discoveryTimer !== undefined) { clearInterval(this.discoveryTimer); } this.logger.info('<<< Disposed boards service.'); this.client = undefined; } async getAttachedBoards(): Promise<{ boards: Board[] }> { await this.started.promise; return this.attachedBoards; } async getAvailablePorts(): Promise<{ ports: Port[] }> { await this.started.promise; return this.availablePorts; } private async doGetAttachedBoardsAndAvailablePorts(): Promise<{ boards: Board[], ports: Port[] }> { return this.queue.add(() => { return new Promise<{ boards: Board[], ports: Port[] }>(async resolve => { const coreClient = await this.coreClientProvider.getClient(); const boards: Board[] = []; const ports: Port[] = []; if (!coreClient) { resolve({ boards, ports }); return; } const { client, instance } = coreClient; const req = new BoardListReq(); req.setInstance(instance); const resp = await new Promise<BoardListResp>((resolve, reject) => client.boardList(req, (err, resp) => (!!err ? reject : resolve)(!!err ? err : resp))); const portsList = resp.getPortsList(); // TODO: remove unknown board mocking! // You also have to manually import `DetectedPort`. // const unknownPortList = new DetectedPort(); // unknownPortList.setAddress(platform() === 'win32' ? 'COM3' : platform() === 'darwin' ? '/dev/cu.usbmodem94401' : '/dev/ttyACM0'); // unknownPortList.setProtocol('serial'); // unknownPortList.setProtocolLabel('Serial Port (USB)'); // portsList.push(unknownPortList); for (const portList of portsList) { const protocol = Port.Protocol.toProtocol(portList.getProtocol()); const address = portList.getAddress(); // Available ports can exist with unknown attached boards. // The `BoardListResp` looks like this for a known attached board: // [ // { // "address": "COM10", // "protocol": "serial", // "protocol_label": "Serial Port (USB)", // "boards": [ // { // "name": "Arduino MKR1000", // "FQBN": "arduino:samd:mkr1000" // } // ] // } // ] // And the `BoardListResp` looks like this for an unknown board: // [ // { // "address": "COM9", // "protocol": "serial", // "protocol_label": "Serial Port (USB)", // } // ] ports.push({ protocol, address }); 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}.`); } } } // TODO: remove mock board! // boards.push(...[ // <AttachedSerialBoard>{ name: 'Arduino/Genuino Uno', fqbn: 'arduino:avr:uno', port: '/dev/cu.usbmodem14201' }, // <AttachedSerialBoard>{ name: 'Arduino/Genuino Uno', fqbn: 'arduino:avr:uno', port: '/dev/cu.usbmodem142xx' }, // ]); resolve({ boards, ports }); }) }); } async detail(options: { id: string }): Promise<{ item?: BoardDetails }> { const coreClient = await this.coreClientProvider.getClient(); if (!coreClient) { return {}; } const { client, instance } = coreClient; 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))); const tools = await Promise.all(resp.getRequiredToolsList().map(async t => <Tool>{ name: t.getName(), packager: t.getPackager(), version: t.getVersion() })); return { item: { name: resp.getName(), fqbn: options.id, requiredTools: tools } }; } async search(options: { query?: string }): Promise<{ items: BoardPackage[] }> { const coreClient = await this.coreClientProvider.getClient(); if (!coreClient) { return { items: [] }; } const { client, instance } = coreClient; const installedPlatformsReq = new PlatformListReq(); installedPlatformsReq.setInstance(instance); const installedPlatformsResp = await new Promise<PlatformListResp>((resolve, reject) => client.platformList(installedPlatformsReq, (err, resp) => (!!err ? reject : resolve)(!!err ? err : resp)) ); const installedPlatforms = installedPlatformsResp.getInstalledPlatformList(); const req = new PlatformSearchReq(); req.setSearchArgs(options.query || ""); 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 toPackage = (platform: Platform) => { let installedVersion: string | undefined; const matchingPlatform = installedPlatforms.find(ip => ip.getId() === platform.getId()); if (!!matchingPlatform) { installedVersion = matchingPlatform.getInstalled(); } return { id: platform.getId(), name: platform.getName(), author: platform.getMaintainer(), availableVersions: [platform.getLatest()], description: platform.getBoardsList().map(b => b.getName()).join(", "), installable: true, summary: "Boards included in this package:", installedVersion, boards: platform.getBoardsList().map(b => <Board>{ name: b.getName(), fqbn: b.getFqbn() }), moreInfoLink: platform.getWebsite() } } // We must group the cores by ID, and sort platforms by, first the installed version, then version alphabetical order. // Otherwise we lose the FQBN information. const groupedById: Map<string, Platform[]> = new Map(); for (const platform of resp.getSearchOutputList()) { const id = platform.getId(); if (groupedById.has(id)) { groupedById.get(id)!.push(platform); } else { groupedById.set(id, [platform]); } } const installedAwareVersionComparator = (left: Platform, right: Platform) => { // XXX: we cannot rely on `platform.getInstalled()`, it is always an empty string. const leftInstalled = !!installedPlatforms.find(ip => ip.getId() === left.getId() && ip.getInstalled() === left.getLatest()); const rightInstalled = !!installedPlatforms.find(ip => ip.getId() === right.getId() && ip.getInstalled() === right.getLatest()); if (leftInstalled && !rightInstalled) { return -1; } if (!leftInstalled && rightInstalled) { return 1; } return Installable.Version.COMPARATOR(right.getLatest(), left.getLatest()); // Higher version comes first. } for (const id of groupedById.keys()) { groupedById.get(id)!.sort(installedAwareVersionComparator); } for (const id of groupedById.keys()) { for (const platform of groupedById.get(id)!) { const id = platform.getId(); const pkg = packages.get(id); if (pkg) { pkg.availableVersions.push(platform.getLatest()); pkg.availableVersions.sort(Installable.Version.COMPARATOR); } else { packages.set(id, toPackage(platform)); } } } return { items: [...packages.values()] }; } async install(options: { item: BoardPackage, version?: Installable.Version }): Promise<void> { const pkg = options.item; const version = !!options.version ? options.version : pkg.availableVersions[0]; const coreClient = await this.coreClientProvider.getClient(); if (!coreClient) { return; } const { client, instance } = coreClient; const [platform, boardName] = pkg.id.split(":"); const req = new PlatformInstallReq(); req.setInstance(instance); req.setArchitecture(boardName); req.setPlatformPackage(platform); req.setVersion(version); console.info("Starting board installation", pkg); const resp = client.platformInstall(req); resp.on('data', (r: PlatformInstallResp) => { const prog = r.getProgress(); if (prog && prog.getFile()) { this.toolOutputService.publishNewOutput("board download", `downloading ${prog.getFile()}\n`) } }); await new Promise<void>((resolve, reject) => { resp.on('end', resolve); resp.on('error', reject); }); if (this.client) { this.client.notifyBoardInstalled({ pkg }); } console.info("Board installation done", pkg); } async uninstall(options: { item: BoardPackage }): Promise<void> { const pkg = options.item; const coreClient = await this.coreClientProvider.getClient(); if (!coreClient) { return; } const { client, instance } = coreClient; const [platform, boardName] = pkg.id.split(":"); const req = new PlatformUninstallReq(); req.setInstance(instance); req.setArchitecture(boardName); req.setPlatformPackage(platform); console.info("Starting board uninstallation", pkg); let logged = false; const resp = client.platformUninstall(req); resp.on('data', (_: PlatformUninstallResp) => { if (!logged) { this.toolOutputService.publishNewOutput("board uninstall", `uninstalling ${pkg.id}\n`) logged = true; } }) await new Promise<void>((resolve, reject) => { resp.on('end', resolve); resp.on('error', reject); }); if (this.client) { this.client.notifyBoardUninstalled({ pkg }); } console.info("Board uninstallation done", pkg); } }