├── .npmrc ├── .vscode ├── .gitignore ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .gitattributes ├── .gitignore ├── .README ├── screenshot.png ├── file-listing.png ├── output-pane.png ├── download-button.png ├── run-quick-pick.png ├── device-quick-pick.png ├── run-context-menu.png ├── device-context-menu.png ├── device-connect-tree-item.png └── device-context-menu-screenshot.png ├── resources └── icons │ ├── ev3dev-logo.png │ ├── dark │ ├── refresh.svg │ ├── green-circle.svg │ ├── red-circle.svg │ ├── yellow-circle.svg │ └── download.svg │ └── light │ ├── refresh.svg │ ├── green-circle.svg │ ├── red-circle.svg │ ├── yellow-circle.svg │ └── download.svg ├── .travis.yml ├── .vscodeignore ├── tslint.json ├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tsconfig.json ├── LICENSE.txt ├── src ├── debugServer.ts ├── dnssd.ts ├── utils.ts ├── brickd.ts ├── dnssd │ ├── dnssd.ts │ ├── bonjour.ts │ └── avahi.ts ├── device.ts └── extension.ts ├── README.md ├── CHANGELOG.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | optional = false 2 | -------------------------------------------------------------------------------- /.vscode/.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | 4 | # built extension packages 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.README/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/screenshot.png -------------------------------------------------------------------------------- /.README/file-listing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/file-listing.png -------------------------------------------------------------------------------- /.README/output-pane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/output-pane.png -------------------------------------------------------------------------------- /.README/download-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/download-button.png -------------------------------------------------------------------------------- /.README/run-quick-pick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/run-quick-pick.png -------------------------------------------------------------------------------- /.README/device-quick-pick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-quick-pick.png -------------------------------------------------------------------------------- /.README/run-context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/run-context-menu.png -------------------------------------------------------------------------------- /.README/device-context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-context-menu.png -------------------------------------------------------------------------------- /resources/icons/ev3dev-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/resources/icons/ev3dev-logo.png -------------------------------------------------------------------------------- /.README/device-connect-tree-item.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-connect-tree-item.png -------------------------------------------------------------------------------- /.README/device-context-menu-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ev3dev/vscode-ev3dev-browser/HEAD/.README/device-context-menu-screenshot.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | os: 4 | - linux 5 | 6 | language: node_js 7 | node_js: lts/* 8 | 9 | install: 10 | - npm install 11 | 12 | script: 13 | - npm run vscode:prepublish 14 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .README/ 3 | .vscode/** 4 | .vscode-test/** 5 | node_modules/ 6 | out/test/** 7 | src/** 8 | .gitattributes 9 | .gitignore 10 | vsc-extension-quickstart.md 11 | **/tsconfig.json 12 | **/tslint.json 13 | **/*.map 14 | **/*.ts 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "ms-vscode.vscode-typescript-tslint-plugin", 6 | "EditorConfig.EditorConfig" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-string-throw": true, 4 | "no-unused-expression": true, 5 | "no-duplicate-variable": true, 6 | "curly": true, 7 | "class-name": true, 8 | "semicolon": [ 9 | true, 10 | "always" 11 | ], 12 | "triple-equals": true 13 | }, 14 | "defaultSeverity": "warning" 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.{md,py,ts}] 9 | indent_size = 4 10 | indent_style = space 11 | 12 | [*.json] 13 | indent_size = 4 14 | indent_style = tab 15 | 16 | [package*.json] 17 | indent_size = 4 18 | indent_style = space 19 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "editor.formatOnSave": true, 12 | "[typescript]": { 13 | "editor.defaultFormatter": "vscode.typescript-language-features" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "vscode.json-language-features" 17 | }, 18 | "[markdown]": { 19 | "editor.defaultFormatter": "vscode.markdown-language-features" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS and version: [e.g. Windows 10 1809, macOS 10.14] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": [ 6 | "types/*" 7 | ] 8 | }, 9 | "module": "commonjs", 10 | "target": "es6", 11 | "outDir": "out", 12 | "lib": [ 13 | "es6" 14 | ], 15 | "sourceMap": true, 16 | "rootDir": "src", 17 | /* Strict Type-Checking Option */ 18 | "strict": true, /* enable all strict type-checking options */ 19 | /* Additional Checks */ 20 | //"noUnusedLocals": true, /* Report errors on unused locals. */ 21 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 22 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 23 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 24 | "esModuleInterop": true, 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | ".vscode-test" 29 | ] 30 | } -------------------------------------------------------------------------------- /resources/icons/dark/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/light/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [{ 8 | "name": "Run Extension", 9 | "type": "extensionHost", 10 | "request": "launch", 11 | "runtimeExecutable": "${execPath}", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "npm: watch" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "runtimeExecutable": "${execPath}", 25 | "args": [ 26 | "--extensionDevelopmentPath=${workspaceFolder}", 27 | "--extensionTestsPath=${workspaceFolder}/out/test" 28 | ], 29 | "outFiles": [ 30 | "${workspaceFolder}/out/test/**/*.js" 31 | ], 32 | "preLaunchTask": "npm: watch" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2023 David Lechner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /resources/icons/dark/green-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/dark/red-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/dark/yellow-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/light/green-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/light/red-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/light/yellow-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/icons/dark/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 41 | 45 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /resources/icons/light/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 41 | 45 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/debugServer.ts: -------------------------------------------------------------------------------- 1 | import { DebugSession, Event, TerminatedEvent, Thread, ThreadEvent, StoppedEvent, ContinuedEvent, InitializedEvent } from 'vscode-debugadapter'; 2 | import { DebugProtocol } from 'vscode-debugprotocol'; 3 | 4 | /** 5 | * This interface should always match the schema found in the extension manifest. 6 | */ 7 | export interface LaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { 8 | /** An absolute path to the program to debug. */ 9 | program: string; 10 | /** Download files before running. Default is true. */ 11 | download?: boolean; 12 | /** Run in terminal instead of output pane. */ 13 | interactiveTerminal: boolean; 14 | } 15 | 16 | const THREAD_ID = 0; 17 | 18 | export class Ev3devBrowserDebugSession extends DebugSession { 19 | protected initializeRequest(response: DebugProtocol.InitializeResponse, 20 | args: DebugProtocol.InitializeRequestArguments): void { 21 | if (response.body) { 22 | response.body.supportTerminateDebuggee = true; 23 | } 24 | this.sendResponse(response); 25 | } 26 | 27 | protected launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void { 28 | this.sendEvent(new Event('ev3devBrowser.debugger.launch', args)); 29 | this.sendResponse(response); 30 | this.sendEvent(new InitializedEvent()); 31 | } 32 | 33 | protected customRequest(command: string, response: DebugProtocol.Response, args: any): void { 34 | switch (command) { 35 | case 'ev3devBrowser.debugger.thread': 36 | this.sendEvent(new ThreadEvent(args, THREAD_ID)); 37 | this.sendResponse(response); 38 | break; 39 | case 'ev3devBrowser.debugger.terminate': 40 | this.sendEvent(new TerminatedEvent()); 41 | this.sendResponse(response); 42 | break; 43 | } 44 | } 45 | 46 | protected disconnectRequest(response: DebugProtocol.DisconnectResponse, 47 | args: DebugProtocol.DisconnectArguments): void { 48 | this.sendEvent(new Event('ev3devBrowser.debugger.stop', args)); 49 | this.sendResponse(response); 50 | } 51 | 52 | protected threadsRequest(response: DebugProtocol.ThreadsResponse): void { 53 | response.body = { 54 | threads: [ 55 | new Thread(THREAD_ID, 'thread') 56 | ] 57 | }; 58 | this.sendResponse(response); 59 | } 60 | 61 | protected pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments): void { 62 | this.sendEvent(new Event('ev3devBrowser.debugger.interrupt', args)); 63 | this.sendResponse(response); 64 | } 65 | } 66 | 67 | if (require.main === module) { 68 | DebugSession.run(Ev3devBrowserDebugSession); 69 | } 70 | -------------------------------------------------------------------------------- /src/dnssd.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as avahi from './dnssd/avahi'; 3 | import * as dnssd from './dnssd/dnssd'; 4 | import * as bonjour from './dnssd/bonjour'; 5 | 6 | /** 7 | * Common interface used by dnssd implementations. 8 | */ 9 | export interface Client { 10 | /** 11 | * Create a new browser object. 12 | */ 13 | createBrowser(options: BrowseOptions): Promise; 14 | 15 | /** 16 | * Frees resources used by client and destroys any associated browsers, etc. 17 | */ 18 | destroy(): void; 19 | } 20 | 21 | /** 22 | * Options for Dnssd.browse() 23 | */ 24 | export interface BrowseOptions { 25 | /** 26 | * The service type to browse for, e.g. 'http'. 27 | */ 28 | service: string; 29 | 30 | /** 31 | * The protocol transport to search for. Must be 'tcp' or 'udp'. 32 | * Default is 'tcp' if omitted. 33 | */ 34 | transport?: 'tcp' | 'udp'; 35 | 36 | /** 37 | * The IP protocol to search for. Must be 'IPv4' or 'IPv6'. 38 | * Default is 'IPv4' if omitted. 39 | */ 40 | ipv?: 'IPv4' | 'IPv6'; 41 | } 42 | 43 | /** Object for monitoring network service discovery events. */ 44 | export interface Browser { 45 | /** Registers callback for service added events. */ 46 | on(event: 'added', listener: (service: Service) => void): this; 47 | /** Registers callback for service removed events. */ 48 | on(event: 'removed', listener: (service: Service) => void): this; 49 | /** Registers callback for error events. */ 50 | on(event: 'error', listener: (err: Error) => void): this; 51 | /** Starts browsing. */ 52 | start(): Promise; 53 | /** Stops browsing. */ 54 | stop(): Promise; 55 | /** Frees all resources used by browser. */ 56 | destroy(): void; 57 | } 58 | 59 | /** 60 | * Data type for txt record key/value pairs. 61 | */ 62 | export type TxtRecords = { [key: string]: string }; 63 | 64 | export interface Service { 65 | /** 66 | * The name of the service. Suitible for displaying to the user. 67 | */ 68 | readonly name: string; 69 | 70 | /** 71 | * The service type. 72 | */ 73 | readonly service: string; 74 | 75 | /** 76 | * The transport protocol. 77 | */ 78 | readonly transport: 'tcp' | 'udp'; 79 | 80 | /** 81 | * The host name. 82 | */ 83 | readonly host: string; 84 | 85 | /** 86 | * The domain. 87 | */ 88 | readonly domain: string; 89 | 90 | /** 91 | * The network interface index 92 | */ 93 | readonly iface: number; 94 | 95 | /** 96 | * The IP protocol version. 97 | */ 98 | readonly ipv: 'IPv4' | 'IPv6'; 99 | 100 | /** 101 | * The IP address. 102 | */ 103 | readonly address: string; 104 | 105 | /** 106 | * This IP port. 107 | */ 108 | readonly port: number; 109 | 110 | /** 111 | * The txt records as key/value pairs. 112 | */ 113 | readonly txt: TxtRecords; 114 | } 115 | 116 | /** 117 | * Gets in instance of the Bonjour interface. 118 | * 119 | * It will try to use a platform-specific implementation. Or if one is not 120 | * present, it falls back to a pure js implementation. 121 | */ 122 | export async function getInstance(): Promise { 123 | try { 124 | return await avahi.getInstance(); 125 | } 126 | catch (err) { 127 | try { 128 | return dnssd.getInstance(); 129 | } 130 | catch (err) { 131 | // fall back to pure-javascript implementation 132 | return bonjour.getInstance(); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as temp from 'temp'; 3 | import * as fs from 'fs'; 4 | import * as os from 'os'; 5 | 6 | const toastDuration = 5000; 7 | 8 | export function sanitizedDateString(date?: Date): string { 9 | const d = date || new Date(); 10 | const pad = (num: number) => ("00" + num).slice(-2); 11 | 12 | // Months are zero-indexed 13 | return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; 14 | } 15 | 16 | const tempDirs: { [sharedKey: string]: string } = {}; 17 | export function getSharedTempDir(sharedKey: string): Promise { 18 | if (tempDirs[sharedKey]) { 19 | return Promise.resolve(tempDirs[sharedKey]); 20 | } 21 | 22 | return new Promise((resolve, reject) => { 23 | temp.track(); 24 | temp.mkdir(sharedKey, (err, dirPath) => { 25 | if (err) { 26 | reject(err); 27 | } 28 | else { 29 | tempDirs[sharedKey] = dirPath; 30 | resolve(dirPath); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | /** 37 | * Checks a file for Windows line endings. If found, modifies the file to remove 38 | * the Windows line endings. 39 | * @param path The path to the file. 40 | * @returns true if the file was modified, otherwise false 41 | */ 42 | export function normalizeLineEndings(path: string): Promise { 43 | return new Promise((resolve, reject) => { 44 | fs.readFile(path, { encoding: "utf8" }, (err, data) => { 45 | if (err) { 46 | reject(err); 47 | return; 48 | } 49 | 50 | const replace = data.replace("\r\n", "\n"); 51 | 52 | if (replace === data) { 53 | // not changed 54 | resolve(false); 55 | return; 56 | } 57 | 58 | fs.writeFile(path, replace, (err) => { 59 | if (err) { 60 | reject(err); 61 | return; 62 | } 63 | 64 | resolve(true); 65 | }); 66 | }); 67 | }); 68 | } 69 | 70 | export function openAndRead(path: string, offset: number, length: number, position: number): Promise { 71 | return new Promise((resolve, reject) => { 72 | fs.open(path, 'r', (err, fd) => { 73 | if (err) { 74 | reject(err); 75 | return; 76 | } 77 | 78 | const buffer = Buffer.alloc(length); 79 | fs.read(fd, buffer, offset, length, position, (err, bytesRead, buffer) => { 80 | fs.close(fd, err => console.log(err)); 81 | if (err) { 82 | reject(err); 83 | return; 84 | } 85 | resolve(buffer); 86 | }); 87 | }); 88 | }); 89 | } 90 | 91 | export async function verifyFileHeader(filePath: string, expectedHeader: Buffer | number[], offset: number = 0): Promise { 92 | const bufferExpectedHeader = Array.isArray(expectedHeader) ? Buffer.from(expectedHeader) : expectedHeader; 93 | const header = await openAndRead(filePath, 0, bufferExpectedHeader.length, offset); 94 | return header.compare(bufferExpectedHeader) === 0; 95 | } 96 | 97 | export function toastStatusBarMessage(message: string): void { 98 | vscode.window.setStatusBarMessage(message, toastDuration); 99 | } 100 | 101 | /** 102 | * Sets a context that can be use for when clauses in package.json 103 | * 104 | * This may become official vscode API some day. 105 | * https://github.com/Microsoft/vscode/issues/10471 106 | * @param context The context name 107 | */ 108 | export function setContext(context: string, state: boolean): void { 109 | vscode.commands.executeCommand('setContext', context, state); 110 | } 111 | 112 | /** 113 | * Gets the runtime platform suitable for use in settings lookup. 114 | */ 115 | export function getPlatform(): 'windows' | 'osx' | 'linux' | undefined { 116 | let platform: 'windows' | 'osx' | 'linux' | undefined; 117 | switch (os.platform()) { 118 | case 'win32': 119 | platform = 'windows'; 120 | break; 121 | case 'darwin': 122 | platform = 'osx'; 123 | break; 124 | case 'linux': 125 | platform = 'linux'; 126 | break; 127 | } 128 | return platform; 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ev3dev browser for Visual Studio Code 2 | 3 | This extension allows you to browse ev3dev devices from Visual Studio Code, send 4 | files to these devices and remotely run programs. 5 | 6 | Learn more about ev3dev at . 7 | 8 | 9 | ## Requirements 10 | 11 | This extension is only compatible with devices running **ev3dev-stretch**. 12 | It will not work with earlier versions of ev3dev. 13 | 14 | Additional information can be found on the [wiki]. 15 | 16 | [wiki]: https://github.com/ev3dev/vscode-ev3dev-browser/wiki 17 | 18 | 19 | ## Features 20 | 21 | * **Discover devices**: Any connected ev3dev device should be automatically discovered. 22 | No configuration necessary. 23 | 24 | ![Device connect button screenshot](.README/device-connect-tree-item.png) 25 | 26 | ![Device quick-pick screenshot](.README/device-quick-pick.png) 27 | 28 | * **Remotely browse files**: Files for each device are listed just as they are in 29 | Brickman. 30 | 31 | ![File listing screenshot](.README/file-listing.png) 32 | 33 | * **Download files to the device**: The current VS Code project can be sent to an 34 | ev3dev device with a single click. 35 | 36 | ![Download button screenshot](.README/download-button.png) 37 | 38 | * **Remotely run programs**: Click any executable file to run it. 39 | 40 | ![Run quick-pick screenshot](.README/run-quick-pick.png) 41 | 42 | Right-clicking works too. 43 | 44 | ![Run context menu screenshot](.README/run-context-menu.png) 45 | 46 | Error messages will be displayed in the output pane. 47 | 48 | ![Output pane screenshot](.README/output-pane.png) 49 | 50 | * **Build, Download and Run with a single click (or F5)**: Create 51 | a `launch.json` file with an `"ev3devBrowser"` type to use this feature. 52 | 53 | ```json 54 | { 55 | "version": "0.2.0", 56 | "configurations": [ 57 | { 58 | "name": "Download and Run", 59 | "type": "ev3devBrowser", 60 | "request": "launch", 61 | "program": "/home/robot/${workspaceRootFolderName}/hello", 62 | "preLaunchTask": "build" 63 | } 64 | ] 65 | } 66 | ``` 67 | 68 | 69 | * **Start a remote SSH session**: You can start an SSH session in the terminal pane 70 | by right-clicking on a device. 71 | 72 | ![Device context menu screenshot](.README/device-context-menu.png) 73 | 74 | * **Take a screenshot**: You can easily take screenshot by right-clicking 75 | a device. 76 | 77 | ![Device context menu screenshot](.README/device-context-menu-screenshot.png) 78 | 79 | ![Meta screenshot](.README/screenshot.png) 80 | 81 | 82 | ## Extension Settings 83 | 84 | This extension contributes the following settings: 85 | 86 | * `ev3devBrowser.password`: If you changed the password on your ev3dev device, 87 | you will need to set the password here. If you want to manually enter the 88 | password when you connect or use public key authentication, set this to 89 | `null`. 90 | * `ev3devBrowser.env`: If you need to set environment variables for running 91 | remote programs, you can set them here. Each variable is defined as a 92 | key/value pair. 93 | * `ev3devBrowser.interactiveTerminal.env`: This is similar to `ev3devBrowser.env` 94 | but the environment variables are only applied when running a program in 95 | the interactive terminal. 96 | * `ev3devBrowser.download.include`: Use this to specify which files to 97 | included when downloading files to the remote device. Can use glob patterns. 98 | * `ev3devBrowser.download.exclude`: Use this to specify which files to 99 | exclude when downloading files to the remote device. Can use glob patterns. 100 | * `ev3devBrowser.download.directory`: By default files are downloaded to 101 | a folder with the same name as the VS Code project. Use this setting to 102 | save the project files somewhere else. Paths are relative to the `/home/robot` 103 | directory. 104 | * `ev3devBrowser.additionalDevices`: A list of additional devices to show in 105 | the list when connecting to a device. This should only be needed in cases 106 | where there are network problems interfering with device discover. 107 | * `ev3devBrowser.confirmDelete`: Setting to `false` will suppress the 108 | confirmation message when deleting a remote file or directory. 109 | * `ev3devBrowser.connectTimeout`: The connection timeout when connecting to a 110 | device. Longer times may fix "Timeout while waiting for handshake". 111 | 112 | More details and examples on the [wiki](https://github.com/ev3dev/vscode-ev3dev-browser/wiki/Settings). 113 | -------------------------------------------------------------------------------- /src/brickd.ts: -------------------------------------------------------------------------------- 1 | import compareVersions = require('compare-versions'); 2 | import * as events from "events"; 3 | import * as readline from 'readline'; 4 | import * as ssh2 from 'ssh2'; 5 | import Observable from 'zen-observable'; 6 | 7 | const minBrickdVersion = '1.1.0'; 8 | const maxBrickdVersion = '2.0.0'; 9 | 10 | enum BrickdConnectionState { 11 | start, 12 | handshake, 13 | watchPower, 14 | getBatteryVoltage, 15 | getSerialNum, 16 | ok, 17 | bad 18 | } 19 | 20 | /** 21 | * Connection to a brickd server. 22 | */ 23 | export class Brickd extends events.EventEmitter { 24 | private _serialNumber = ''; 25 | 26 | /** 27 | * Gets the serial number of the main board. 28 | */ 29 | public get serialNumber(): string { 30 | return this._serialNumber; 31 | } 32 | 33 | public constructor(readonly channel: ssh2.ClientChannel) { 34 | super(); 35 | const reader = readline.createInterface(channel); 36 | const observable = new Observable(observer => { 37 | reader.on('line', line => { 38 | observer.next(line); 39 | }).on('close', () => { 40 | observer.complete(); 41 | }); 42 | }); 43 | 44 | let state = BrickdConnectionState.start; 45 | observable.forEach(line => { 46 | const [m1, ...m2] = line.split(' '); 47 | 48 | // emit messages 49 | if (m1 === "MSG") { 50 | this.emit('message', m2.join(' ')); 51 | return; 52 | } 53 | 54 | // everything else is handled from state machine 55 | switch (state) { 56 | case BrickdConnectionState.start: 57 | if (m1 === "BRICKD") { 58 | const version = m2[1]; 59 | if (compareVersions(version, minBrickdVersion) < 0) { 60 | state = BrickdConnectionState.bad; 61 | this.emit('error', new Error(`Brickd is too old. Please upgrade to version >= ${minBrickdVersion}`)); 62 | break; 63 | } 64 | if (compareVersions(version, maxBrickdVersion) >= 0) { 65 | state = BrickdConnectionState.bad; 66 | this.emit('error', new Error('Brickd version is too new.')); 67 | break; 68 | } 69 | state = BrickdConnectionState.handshake; 70 | channel.write('YOU ARE A ROBOT\n'); 71 | } 72 | else { 73 | state = BrickdConnectionState.bad; 74 | this.emit('error', new Error('Brickd server did not send expected welcome message.')); 75 | } 76 | break; 77 | case BrickdConnectionState.handshake: 78 | if (m1 === "OK") { 79 | state = BrickdConnectionState.watchPower; 80 | channel.write("WATCH POWER\n"); 81 | } 82 | else if (m1 === "BAD") { 83 | state = BrickdConnectionState.bad; 84 | this.emit('error', new Error("Brickd handshake failed.")); 85 | } 86 | break; 87 | case BrickdConnectionState.watchPower: 88 | if (m1 === "OK") { 89 | state = BrickdConnectionState.getBatteryVoltage; 90 | channel.write("GET system.battery.voltage\n"); 91 | } 92 | else { 93 | state = BrickdConnectionState.bad; 94 | this.emit('error', new Error("Brickd failed to register for power events.")); 95 | } 96 | break; 97 | case BrickdConnectionState.getBatteryVoltage: 98 | if (m1 === "OK") { 99 | this.emit('message', `PROPERTY system.battery.voltage ${m2.join(' ')}`); 100 | state = BrickdConnectionState.getSerialNum; 101 | channel.write("GET system.info.serial\n"); 102 | } 103 | else { 104 | state = BrickdConnectionState.bad; 105 | this.emit('error', new Error("Brickd failed to get battery voltage")); 106 | } 107 | break; 108 | case BrickdConnectionState.getSerialNum: 109 | if (m1 === "OK") { 110 | this._serialNumber = m2.join(' '); 111 | state = BrickdConnectionState.ok; 112 | this.emit('ready'); 113 | } 114 | else { 115 | state = BrickdConnectionState.bad; 116 | this.emit('error', new Error("Brickd failed to get serial number")); 117 | } 118 | break; 119 | } 120 | }); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to the "ev3dev-browser" extension will be documented in this file. 3 | 4 | 5 | 6 | ## v1.2.1 - 2023-03-29 7 | ### Fixed 8 | - Fixed Windows path separator in "program" in `.vscode/launch.json` not converted to UNIX path. 9 | - Fixed running files with `#!` and Windows line endings (CRLF vs. LF). 10 | - Fixed no way to specify port for user-specified IP address. 11 | 12 | ## v1.2.0 - 2020-07-20 13 | ### Changed 14 | - Initial debug configuration has new example to run current file 15 | ### Fixed 16 | - Stop button does not kill all child processes 17 | - Activate extension on command palette command 18 | - Fix multiple network interfaces not updated on Windows when scanning for devices 19 | - Fix race condition when browsing for connected devices 20 | ### Added 21 | - ev3dev remote debugger is now a default debugger for Python files 22 | 23 | ## 1.1.0 - 2020-03-07 24 | ### Added 25 | - New "pause" button on debugger that sends SIGINT to remote process 26 | - New "interactiveTerminal" debugger option to run remote programs in 27 | interactive terminal instead of output pane 28 | - New setting for device connection timeout 29 | ### Fixed 30 | - Fix debugger restart button not working 31 | - Fix numbers not allowed in `ev3devBrowser.env` variable names 32 | ### Changed 33 | - SSH shell no longer requires native executable on Windows 34 | - Device connection timeout increased to 30 seconds 35 | 36 | ## 1.0.4 - 2019-04-26 37 | ### Fixed 38 | - Fix "Timed out while waiting for handshake" error 39 | - Fix not working on Linux without Avahi installed 40 | 41 | ## 1.0.3 - 2019-03-25 42 | ### Changed 43 | - `ev3devBrowser` debugger type no longer uses native executable. 44 | - SSH shell no longer uses native executable on Linux and Mac. 45 | ### Fixed 46 | - Fix debugger hanging when ev3dev Device Browser view is collapsed 47 | 48 | ## 1.0.2 - 2019-03-11 49 | ### Fixed 50 | - Files are not downloaded when using global launch configuration 51 | - No indication when zero files are downloaded 52 | 53 | ## 1.0.1 - 2019-02-02 54 | ### Fixed 55 | - Duplicate listed devices in quick-pick on Windows 56 | - SSH terminal not working 57 | 58 | ## 1.0.0 - 2019-01-31 59 | ### Fixed 60 | - When using "Download and run", only current project is downloaded instead of 61 | entire workspace 62 | ### Changed 63 | - Download progress is shown in notification instead of status bar 64 | - Minimum VS Code version updated to 1.30 65 | - Publisher changed to "ev3dev" 66 | 67 | ## 0.8.1 - 2018-07-14 68 | ### Fixed 69 | - Error when trying to use file paths containing spaces (@WasabiFan) 70 | 71 | ## 0.8.0 - 2017-11-09 72 | ### Fixed 73 | - Current working directory is not the same as when running programs with Brickman 74 | - Context menu shown on root folder in remote file browser 75 | ### Changed 76 | - Upload command remembers selected directory for each workspace 77 | 78 | ## 0.7.0 - 2017-10-24 79 | ### Added 80 | - Multi-root workspace support 81 | - Upload command 82 | ### Fixed 83 | - Backslashes in directory names when downloading (Windows only) 84 | - Cannot run remote files (Windows only) 85 | 86 | ## 0.6.0 - 2017-10-18 87 | ### Added 88 | - Context menu item to connect to a different device 89 | - Context menu item to show file info 90 | ### Changed 91 | - Remote directories can be deleted 92 | - Downloads can be canceled 93 | ### Fixed 94 | - Connection timeout issues with Bluetooth and Wi-Fi 95 | 96 | ## 0.5.0 - 2017-09-14 97 | ### Added 98 | - Battery voltage monitoring 99 | - Refresh command/button 100 | ### Removed 101 | - ev3devBrowser.visible configuration setting (@WasabiFan) 102 | ## Changed 103 | - DNS-SD device discovery uses IPv6 instead of IPv4 104 | 105 | ## 0.4.0 - 2017-09-04 106 | ### Added 107 | - Command to get system info from remote device (@WasabiFan) 108 | - Configuration option and UI for adding devices that are not automatically 109 | discovered 110 | ### Fixed 111 | - Incorrect date stamp in screenshots (@WasabiFan) 112 | - Device still shows connected when the device is unplugged or the network is 113 | disconnected 114 | - Tree view commands listed in command palette 115 | 116 | ## 0.3.1 - 2017-08-26 117 | ### Fixed 118 | - Extra development files published with extension, resulting in large download 119 | 120 | ## 0.3.0 - 2017-08-26 121 | ### Added 122 | - Debugger contribution point to allow download and run by pressing F5 123 | - Device (re)connect/disconnect commands 124 | - Command to capture a screenshot from the remote device (@WasabiFan) 125 | ### Changed 126 | - Connect button is now an item in the tree view 127 | ### Fixed 128 | - Download button shown when no device is connected 129 | - Extra commands listed in command palette 130 | - Device context menu shown when device not connected 131 | - Fix downloading projects with subdirectories 132 | 133 | ## 0.2.0 - 2017-08-15 134 | ### Added 135 | - Optional interactive password prompt 136 | - Delete context menu item to delete remote files 137 | - Connect button to initiate connection to a device 138 | ### Changed 139 | - SSH sessions use internal shared connection instead of depending on 140 | external `ssh` and `plink.exe` programs 141 | - File names are now sorted 142 | - Device discovery improvements 143 | - Improved handling of device disconnection 144 | - Only connect to one device at a time 145 | - Device browser can now be hidden via settings 146 | 147 | ## 0.1.0 - 2017-07-26 148 | - Initial release 149 | -------------------------------------------------------------------------------- /src/dnssd/dnssd.ts: -------------------------------------------------------------------------------- 1 | // This implements the interface from the 'bonjour' npm package using the 2 | // dns-sd command. Not all features are implemented. 3 | 4 | import * as events from 'events'; 5 | 6 | import * as dns from './dnssd-client'; 7 | import * as dnssd from '../dnssd'; 8 | 9 | export function getInstance(): dnssd.Client { 10 | if (!dns.checkDaemonRunning()) { 11 | throw new Error('Could not find mDNSResponder'); 12 | } 13 | return new DnssdClient(); 14 | } 15 | 16 | class DnssdClient implements dnssd.Client { 17 | private destroyOps = new Array<() => void>(); 18 | 19 | // interface method implementation 20 | public createBrowser(options: dnssd.BrowseOptions): Promise { 21 | const browser = new DnssdBrowser(this, options); 22 | return Promise.resolve(browser); 23 | } 24 | 25 | // interface method implementation 26 | public destroy(): void { 27 | this.destroyOps.forEach(op => op()); 28 | this.destroyOps.length = 0; 29 | } 30 | 31 | /** 32 | * Adds an operation to be performed when destroy() is called. 33 | * @param op operation to add 34 | * @return the op argument 35 | */ 36 | pushDestroyOp(op: () => void): () => void { 37 | this.destroyOps.push(op); 38 | return op; 39 | } 40 | 41 | /** 42 | * Removes an operation that was added with pushDestroyOp() 43 | * @param op the operation to remove 44 | */ 45 | popDestroyOp(op: () => void): void { 46 | let i = this.destroyOps.findIndex(v => v === op); 47 | if (i >= 0) { 48 | this.destroyOps.splice(i, 1); 49 | } 50 | } 51 | } 52 | 53 | class DnssdBrowser extends events.EventEmitter implements dnssd.Browser { 54 | private service: dns.Service | undefined; 55 | private destroyOp: () => void; 56 | readonly services: DnssdService[] = new Array(); 57 | 58 | constructor(private dnssd: DnssdClient, private options: dnssd.BrowseOptions) { 59 | super(); 60 | this.destroyOp = this.dnssd.pushDestroyOp(() => this.destroy()); 61 | } 62 | 63 | public async start(): Promise { 64 | const regType = `_${this.options.service}._${this.options.transport || 'tcp'}`; 65 | const domain = ''; // TODO: is this part of options? 66 | 67 | this.service = await dns.Service.browse(0, 0, regType, domain, async (s, f, i, e, n, t, d) => { 68 | if (e) { 69 | this.emit('error', new dns.ServiceError(e, 'Error while browsing.')); 70 | return; 71 | } 72 | if (f & dns.ServiceFlags.Add) { 73 | const resolveService = await s.resolve(0, i, n, t, d, async (s, f, i, e, fn, h, p, txt) => { 74 | if (e) { 75 | this.emit('error', new dns.ServiceError(e, 'Resolving service failed.')); 76 | return; 77 | } 78 | const addrService = await s.getAddrInfo(0, i, dns.ServiceProtocol.IPv6, h, 79 | (s, f, i, e, h, a, ttl) => { 80 | if (e) { 81 | this.emit('error', new dns.ServiceError(e, 'Querying service failed.')); 82 | return; 83 | } 84 | if (this.services.findIndex(v => v.iface === i && v.name === n && v.type === t && v.domain === d.replace(/\.$/, '')) !== -1) { 85 | // ignore duplicates 86 | return; 87 | } 88 | const service = new DnssdService(i, n, t, d, h, a, p, txt); 89 | this.services.push(service); 90 | this.emit('added', service); 91 | }); 92 | await addrService.processResult(); 93 | addrService.destroy(); 94 | }); 95 | await resolveService.processResult(); 96 | resolveService.destroy(); 97 | } 98 | else { 99 | const index = this.services.findIndex(s => s.match(i, n, t, d)); 100 | if (index >= 0) { 101 | const [service] = this.services.splice(index, 1); 102 | this.emit('removed', service); 103 | } 104 | } 105 | }); 106 | 107 | // process received results in the background 108 | (async () => { 109 | while (this.service) { 110 | try { 111 | await this.service.processResult(); 112 | } catch (err) { 113 | this.emit('error', err); 114 | } 115 | } 116 | })(); 117 | } 118 | 119 | public async stop(): Promise { 120 | this.service?.destroy(); 121 | this.service = undefined; 122 | } 123 | 124 | destroy(): void { 125 | this.removeAllListeners(); 126 | this.stop(); 127 | this.dnssd.popDestroyOp(this.destroyOp); 128 | } 129 | } 130 | 131 | class DnssdService extends events.EventEmitter implements dnssd.Service { 132 | public readonly service: string; 133 | public readonly transport: 'tcp' | 'udp'; 134 | public readonly host: string; 135 | public readonly domain: string; 136 | public readonly ipv: 'IPv4' | 'IPv6'; 137 | public readonly txt: dnssd.TxtRecords; 138 | 139 | constructor( 140 | public readonly iface: number, 141 | public readonly name: string, 142 | readonly type: string, 143 | domain: string, 144 | host: string, 145 | public readonly address: string, 146 | public readonly port: number, 147 | txt: string[]) { 148 | super(); 149 | const [service, transport] = type.split('.'); 150 | // remove leading '_' 151 | this.service = service.slice(1); 152 | this.transport = <'tcp' | 'udp'>transport.slice(1); 153 | // strip trailing '.' 154 | this.host = host.replace(/\.$/, ''); 155 | this.domain = domain.replace(/\.$/, ''); 156 | this.ipv = 'IPv6'; 157 | this.txt = DnssdService.parseText(txt); 158 | } 159 | 160 | match(iface: number, name: string, type: string, domain: string): boolean { 161 | return this.iface === iface && this.name === name && this.type === type && this.domain === domain; 162 | } 163 | 164 | private static parseText(txt: string[]): dnssd.TxtRecords { 165 | const result = new Object(); 166 | if (!txt) { 167 | return result; 168 | } 169 | 170 | txt.forEach(v => { 171 | const [key, value] = v.split(/=/); 172 | result[key] = value; 173 | }); 174 | 175 | return result; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/dnssd/bonjour.ts: -------------------------------------------------------------------------------- 1 | 2 | import bonjour from 'bonjour'; 3 | import * as events from 'events'; 4 | import * as os from 'os'; 5 | 6 | import * as dnssd from '../dnssd'; 7 | 8 | export function getInstance(): dnssd.Client { 9 | return new BonjourClient(); 10 | } 11 | 12 | class BonjourClient extends events.EventEmitter implements dnssd.Client { 13 | private readonly bClients: { [ifaceAddress: string]: bonjour.Bonjour } = {}; 14 | private readonly ifaceAddresses = new Array(); 15 | private readonly ifaceTimer = setInterval(() => this.updateInterfaces(), 500); 16 | 17 | forEachClient(func: (bClient: bonjour.Bonjour) => void) { 18 | for (const a in this.bClients) { 19 | func(this.bClients[a]); 20 | } 21 | } 22 | 23 | public createBrowser(opts: dnssd.BrowseOptions): Promise { 24 | const browser = new BonjourBrowser(this, opts); 25 | return Promise.resolve(browser); 26 | } 27 | 28 | public destroy(): void { 29 | clearInterval(this.ifaceTimer); 30 | for (const a in this.bClients) { 31 | this.destroyClient(a); 32 | } 33 | this.removeAllListeners(); 34 | } 35 | 36 | // The bonjour package doesn't seem to be able to handle broadcasting and 37 | // receiving on all interfaces. So, we are monitoring network interfaces 38 | // ourselves and creating a bonjour.Bonjour instance for each network 39 | // interface (actually, each address of each interface, which could be 40 | // more than one). 41 | private updateInterfaces() { 42 | type Address = { iface: number, address: string }; 43 | const newAddresses = new Array
(); 44 | const ifaces = os.networkInterfaces(); 45 | for (let i in ifaces) { 46 | // on Windows, only the local link address has a scopeid that matches 47 | // the index of the network interface. 48 | const localLinkAddr = ifaces[i].find(v => v.address.startsWith('fe80:')); 49 | if (!localLinkAddr) { 50 | continue; 51 | } 52 | const ifaceIndex = (localLinkAddr).scopeid; 53 | 54 | // only supporting IPv6 for now 55 | const addresses = ifaces[i].filter(v => v.internal === false && v.family === 'IPv6').map(v => 56 | `${v.address}%${process.platform === 'win32' ? (v).scopeid : i}`); 57 | newAddresses.push(...addresses.map(v =>
{ iface: ifaceIndex, address: v })); 58 | } 59 | const added = newAddresses.filter(a => this.ifaceAddresses.indexOf(a.address) === -1); 60 | const removed = this.ifaceAddresses.filter(a => newAddresses.findIndex(v => v.address === a) === -1); 61 | if (added.length) { 62 | for (const a of added) { 63 | this.ifaceAddresses.push(a.address); 64 | this.createClient(a.iface, a.address); 65 | } 66 | } 67 | if (removed.length) { 68 | const indexes = removed.map(a => this.ifaceAddresses.indexOf(a)); 69 | indexes.forEach(i => { 70 | const [a] = this.ifaceAddresses.splice(i, 1); 71 | this.destroyClient(a); 72 | }, this); 73 | } 74 | } 75 | 76 | /** 77 | * Asynchronously create an new bonjour.Bonjour client object 78 | * @param ifaceIndex the index of the network interface 79 | * @param ifaceAddress the IP address 80 | */ 81 | private createClient(ifaceIndex: number, ifaceAddress: string): void { 82 | // On Windows, we need the full IP address as part of the multicast socket 83 | // interface or things don't work right. On Linux, we have to strip the 84 | // IP address or things don't work right. 85 | const iface = (os.platform() === 'win32') ? ifaceAddress : ifaceAddress.replace(/.*%/, '::%'); 86 | 87 | // work around bonjour issue where error is not handled 88 | new Promise((resolve, reject) => { 89 | const bClient = bonjour({ 90 | type: 'udp6', 91 | ip: 'ff02::fb', 92 | interface: iface, 93 | }); 94 | (bClient)['iface'] = ifaceIndex; 95 | (bClient)._server.mdns.on('ready', () => resolve(bClient)); 96 | (bClient)._server.mdns.on('error', (err: any) => reject(err)); 97 | }).then(bClient => { 98 | if (this.ifaceAddresses.indexOf(ifaceAddress) < 0) { 99 | // iface was removed while we were waiting for promise 100 | bClient.destroy(); 101 | return; 102 | } 103 | this.bClients[ifaceAddress] = bClient; 104 | this.emit('clientAdded', bClient); 105 | }).catch(err => { 106 | if (err.code === 'EADDRNOTAVAIL') { 107 | // when a new network interface first comes up, we can get this 108 | // error when we try to bind to the socket, so keep trying until 109 | // we are bound or the interface goes away. 110 | setTimeout(() => { 111 | if (this.ifaceAddresses.indexOf(ifaceAddress) >= 0) { 112 | this.createClient(ifaceIndex, ifaceAddress); 113 | } 114 | }, 500); 115 | } 116 | // FIXME: other errors are currently ignored 117 | }); 118 | } 119 | 120 | /** 121 | * Destroys the bonjour.Bonjour client associated with ifaceAddress 122 | * @param ifaceAddress the IP address 123 | */ 124 | private destroyClient(ifaceAddress: string): void { 125 | const bClient = this.bClients[ifaceAddress]; 126 | delete this.bClients[ifaceAddress]; 127 | this.emit('clientRemoved', bClient); 128 | bClient.destroy(); 129 | } 130 | } 131 | 132 | /** Per-client browser object. */ 133 | type ClientBrowser = { 134 | /** Bonjour client associated with specific network interface and address. */ 135 | bClient: bonjour.Bonjour, 136 | /** Bonjour browser for the Bonjour client. */ 137 | browser: bonjour.Browser, 138 | /** Services discovered by the browser. */ 139 | services: BonjourService[], 140 | /** Update timer - undefined if not started. */ 141 | updateInterval?: NodeJS.Timer, 142 | }; 143 | 144 | class BonjourBrowser extends events.EventEmitter implements dnssd.Browser { 145 | private started = false; 146 | private readonly browsers = new Array(); 147 | 148 | constructor(private readonly client: BonjourClient, private readonly opts: dnssd.BrowseOptions) { 149 | super(); 150 | this.addBrowser = this.addBrowser.bind(this); 151 | this.removeBrowser = this.removeBrowser.bind(this); 152 | client.on('clientAdded', this.addBrowser); 153 | client.on('clientRemoved', this.removeBrowser); 154 | client.forEachClient(c => this.addBrowser(c)); 155 | } 156 | 157 | public async start(): Promise { 158 | for (const b of this.browsers) { 159 | this.startClientBrowser(b); 160 | } 161 | this.started = true; 162 | } 163 | 164 | public async stop(): Promise { 165 | for (const b of this.browsers) { 166 | this.stopClientBrowser(b); 167 | } 168 | this.started = false; 169 | } 170 | 171 | public destroy(): void { 172 | this.removeAllListeners(); 173 | this.client.off('clientAdded', this.addBrowser); 174 | this.client.off('clientRemoved', this.removeBrowser); 175 | this.stop(); 176 | } 177 | 178 | private addBrowser(bClient: bonjour.Bonjour) { 179 | const browser = bClient.find({ 180 | type: this.opts.service, 181 | protocol: this.opts.transport, 182 | }); 183 | const services = new Array(); 184 | browser.on('up', s => { 185 | (s)['iface'] = (bClient)['iface']; 186 | for (const b of this.browsers) { 187 | for (const bs of b.services) { 188 | const bss = bs.bService; 189 | if ((s)['iface'] === (bss)['iface'] && s.name === bs.name && s.type === bss.type && s.fqdn === bss.fqdn.replace(/\.$/, '')) { 190 | // ignore duplicates 191 | return; 192 | } 193 | } 194 | } 195 | const service = new BonjourService(s); 196 | services.push(service); 197 | this.emit('added', service, false); 198 | }); 199 | browser.on('down', s => { 200 | const index = services.findIndex(v => v.bService === s); 201 | const [service] = services.splice(index, 1); 202 | this.emit('removed', service, false); 203 | }); 204 | const clientBrowser = { bClient: bClient, browser: browser, services: services }; 205 | this.browsers.push(clientBrowser); 206 | 207 | // If a new client is added after we have already started browsing, we need 208 | // to start that browser as well. 209 | if (this.started) { 210 | this.startClientBrowser(clientBrowser); 211 | } 212 | } 213 | 214 | private removeBrowser(bClient: bonjour.Bonjour): void { 215 | const i = this.browsers.findIndex(v => v.bClient === bClient); 216 | const [removed] = this.browsers.splice(i, 1); 217 | this.stopClientBrowser(removed); 218 | for (const s of removed.services) { 219 | this.emit('removed', s); 220 | } 221 | } 222 | 223 | private startClientBrowser(clientBrowser: ClientBrowser): void { 224 | clientBrowser.browser.start(); 225 | clientBrowser.updateInterval = setInterval(() => { 226 | // poll again every 1 second 227 | clientBrowser.browser.update(); 228 | }, 1000); 229 | } 230 | 231 | private stopClientBrowser(clientBrowser: ClientBrowser): void { 232 | if (clientBrowser.updateInterval) { 233 | clearInterval(clientBrowser.updateInterval); 234 | clientBrowser.browser.stop(); 235 | } 236 | } 237 | } 238 | 239 | class BonjourService implements dnssd.Service { 240 | public readonly name: string; 241 | public readonly service: string; 242 | public readonly transport: 'tcp' | 'udp'; 243 | public readonly iface: number; 244 | public readonly host: string; 245 | public readonly domain: string; 246 | public readonly ipv: 'IPv4' | 'IPv6'; 247 | public readonly address: string; 248 | public readonly port: number; 249 | public readonly txt: dnssd.TxtRecords; 250 | 251 | constructor(public readonly bService: bonjour.Service) { 252 | this.name = bService.name; 253 | this.service = bService.type; 254 | this.transport = <'tcp' | 'udp'>bService.protocol; 255 | this.iface = (bService)['iface']; 256 | this.host = bService.host; 257 | this.domain = (bService).domain; 258 | this.ipv = 'IPv6'; 259 | this.address = (bService).addresses[0]; // FIXME 260 | this.port = bService.port; 261 | this.txt = bService.txt; 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/dnssd/avahi.ts: -------------------------------------------------------------------------------- 1 | // This implements the interface from the 'bonjour' npm package using the 2 | // avahi-browse command. Not all features are implemented. 3 | 4 | import * as dbus from 'dbus-next'; 5 | import * as events from 'events'; 6 | 7 | import * as dnssd from '../dnssd'; 8 | 9 | const PROTO_INET = 0; 10 | const PROTO_INET6 = 1; 11 | const IF_UNSPEC = -1; 12 | 13 | interface Server extends dbus.ClientInterface { 14 | GetVersionString(): Promise; 15 | GetAPIVersion(): Promise; 16 | GetHostName(): Promise; 17 | SetHostName(name: string): Promise; 18 | GetHostNameFqdn(): Promise; 19 | GetDomainName(): Promise; 20 | IsNSSSupportAvailable(): Promise; 21 | GetState(): Promise; 22 | on(event: 'StateChanged', listener: (state: number, error: string) => void): this; 23 | GetLocalServiceCookie(): Promise; 24 | GetAlternativeHostName(name: string): Promise; 25 | GetAlternativeServiceName(name: string): Promise; 26 | GetNetworkInterfaceNameByIndex(index: number): Promise; 27 | GetNetworkInterfaceIndexByName(name: string): Promise; 28 | ResolveHostName(iface: number, protocol: number, name: string, aprotocol: number, flags: number): Promise void>>; 29 | ResolveAddress(iface: number, protocol: number, address: string, flags: number): Promise void>>; 30 | ResolveService(iface: number, protocol: number, name: string, type: string, domain: string, aprotocol: number, flags: number): Promise void>>; 31 | EntryGroupNew(): Promise; 32 | DomainBrowserNew(iface: number, protocol: number, domain: string, btype: number, flags: number): Promise; 33 | ServiceTypeBrowserNew(iface: number, protocol: number, domain: string, flags: number): Promise; 34 | ServiceBrowserNew(iface: number, protocol: number, type: string, domain: string, flags: number): Promise; 35 | ServiceResolverNew(iface: number, protocol: number, name: string, type: string, domain: string, aprotocol: number, flags: number): Promise; 36 | HostNameResolverNew(iface: number, protocol: number, name: string, aprotocol: number, flags: number): Promise; 37 | AddressResolverNew(iface: number, protocol: number, address: string, flags: number): Promise; 38 | RecordBrowserNew(iface: number, protocol: number, name: string, clazz: number, type: number, flags: number): Promise; 39 | } 40 | 41 | interface ServiceBrowser extends dbus.ClientInterface { 42 | Free(): Promise; 43 | // Can't use signal handlers on proxy due to race condition: https://github.com/lathiat/avahi/issues/9 44 | // on(event: 'ItemNew', listener: (iface: number, protocol: number, name: string, type: string, domain: string, flags: number) => void): this; 45 | // on(event: 'ItemRemove', listener: (iface: number, protocol: number, name: string, type: string, domain: string, flags: number) => void): this; 46 | // on(event: 'Failure', listener: (error: string) => void): this; 47 | // on(event: 'AllForNow', listener: () => void): this; 48 | // on(event: 'CacheExhausted', listener: () => void): this; 49 | } 50 | 51 | type ServerObject = { 52 | proxy: dbus.ProxyObject; 53 | iface: Server; 54 | }; 55 | 56 | let cachedServer: ServerObject | undefined; 57 | 58 | async function getServer(): Promise { 59 | if (cachedServer === undefined) { 60 | const bus = dbus.systemBus(); 61 | // dbus-next will queue messages and wait forever for a connection 62 | // so we have to hack in a timeout, otherwise we end up with a deadlock 63 | // on systems without D-Bus. 64 | await new Promise((resolve, reject) => { 65 | const timeout = setTimeout(() => { 66 | reject(Error("Timeout while connecting to D-Bus")); 67 | }, 100); 68 | (bus as any).on('connect', () => { 69 | clearTimeout(timeout); 70 | resolve(); 71 | }); 72 | }); 73 | const proxy = await bus.getProxyObject('org.freedesktop.Avahi', '/'); 74 | const iface = proxy.getInterface('org.freedesktop.Avahi.Server'); 75 | const version = await iface.GetAPIVersion(); 76 | cachedServer = { proxy, iface }; 77 | } 78 | 79 | return cachedServer; 80 | } 81 | 82 | export async function getInstance(): Promise { 83 | const server = await getServer(); 84 | return new AvahiClient(server); 85 | } 86 | 87 | class AvahiClient implements dnssd.Client { 88 | private destroyOps = new Array<() => void>(); 89 | 90 | constructor(public readonly server: ServerObject) { 91 | } 92 | 93 | public createBrowser(options: dnssd.BrowseOptions): Promise { 94 | return new Promise((resolve, reject) => { 95 | const browser = new AvahiBrowser(this, options); 96 | browser.once('ready', () => { 97 | browser.removeAllListeners('error'); 98 | resolve(browser); 99 | }); 100 | browser.once('error', err => { 101 | browser.removeAllListeners('ready'); 102 | reject(err); 103 | }); 104 | }); 105 | } 106 | 107 | // interface method implementation 108 | public destroy(): void { 109 | this.destroyOps.forEach(op => op()); 110 | this.destroyOps.length = 0; 111 | } 112 | 113 | /** 114 | * Adds an operation to be performed when destroy() is called. 115 | * @param op operation to add 116 | * @return the op argument 117 | */ 118 | pushDestroyOp(op: () => void): () => void { 119 | this.destroyOps.push(op); 120 | return op; 121 | } 122 | 123 | /** 124 | * Removes an operation that was added with pushDestroyOp() 125 | * @param op the operation to remove 126 | */ 127 | popDestroyOp(op: () => void): void { 128 | let i = this.destroyOps.findIndex(v => v === op); 129 | if (i >= 0) { 130 | this.destroyOps.splice(i, 1); 131 | } 132 | } 133 | } 134 | 135 | class AvahiBrowser extends events.EventEmitter implements dnssd.Browser { 136 | private browser: ServiceBrowser | undefined; 137 | private readonly services: AvahiService[] = new Array(); 138 | private readonly bus: dbus.MessageBus; 139 | 140 | constructor(private readonly client: AvahiClient, private options: dnssd.BrowseOptions) { 141 | super(); 142 | // Due to race condition: https://github.com/lathiat/avahi/issues/9 143 | // we have to add signal listeners now before creating browser objects 144 | // otherwise we miss signals. 145 | this.bus = client.server.proxy.bus; 146 | (this.bus as any).on('message', (msg: dbus.Message) => { 147 | if (msg.type !== dbus.MessageType.SIGNAL) { 148 | return; 149 | } 150 | if (msg.interface !== 'org.freedesktop.Avahi.ServiceBrowser') { 151 | return; 152 | } 153 | // TODO: should also check msg.path, but we can receive messages 154 | // before ServiceBrowserNew() returns when we don't know the path 155 | // yet. 156 | switch (msg.member) { 157 | case 'ItemNew': { 158 | const [iface, protocol, name, type, domain, flags] = msg.body; 159 | client.server.iface.ResolveService(iface, protocol, name, type, domain, protocol, 0).then( 160 | ([iface, protocol, name, type, domain, host, aprotocol, addr, port, txt, flags]) => { 161 | const service = new AvahiService(iface, protocol, name, type, domain, host, aprotocol, addr, port, txt, flags); 162 | this.services.push(service); 163 | this.emit('added', service); 164 | }); 165 | } 166 | break; 167 | case 'ItemRemove': { 168 | const [iface, protocol, name, type, domain, flags] = msg.body; 169 | const i = this.services.findIndex(s => s.match(iface, protocol, name, type, domain)); 170 | if (i >= 0) { 171 | const [service] = this.services.splice(i, 1); 172 | this.emit('removed', service); 173 | } 174 | } 175 | break; 176 | case 'Failure': { 177 | const [error] = msg.body; 178 | this.emit('error', new Error(error)); 179 | } 180 | break; 181 | } 182 | }); 183 | const addMatchMessage = new dbus.Message({ 184 | destination: 'org.freedesktop.DBus', 185 | path: '/org/freedesktop/DBus', 186 | interface: 'org.freedesktop.DBus', 187 | member: 'AddMatch', 188 | signature: 's', 189 | body: [`type='signal',sender='org.freedesktop.Avahi',interface='org.freedesktop.Avahi.ServiceBrowser'`] 190 | }); 191 | this.bus.call(addMatchMessage).then(() => this.emit('ready')).catch((err) => this.emit('error', err)); 192 | } 193 | 194 | public async start(): Promise { 195 | const proto = this.options.ipv === 'IPv6' ? PROTO_INET6 : PROTO_INET; 196 | const type = `_${this.options.service}._${this.options.transport || 'tcp'}`; 197 | const objPath = await this.client.server.iface.ServiceBrowserNew(IF_UNSPEC, proto, type, '', 0); 198 | const proxy = await this.bus.getProxyObject('org.freedesktop.Avahi', objPath); 199 | this.browser = proxy.getInterface('org.freedesktop.Avahi.ServiceBrowser'); 200 | } 201 | 202 | public async stop(): Promise { 203 | await this.browser?.Free(); 204 | this.browser = undefined; 205 | } 206 | 207 | destroy(): void { 208 | this.removeAllListeners(); 209 | this.stop(); 210 | } 211 | 212 | } 213 | 214 | class AvahiService implements dnssd.Service { 215 | public readonly service: string; 216 | public readonly transport: 'tcp' | 'udp'; 217 | public readonly ipv: 'IPv4' | 'IPv6'; 218 | public readonly txt: dnssd.TxtRecords; 219 | 220 | constructor( 221 | public readonly iface: number, 222 | private readonly protocol: number, 223 | public readonly name: string, 224 | private readonly type: string, 225 | public readonly domain: string, 226 | public readonly host: string, 227 | aprotocol: number, 228 | public readonly address: string, 229 | public readonly port: number, 230 | txt: Buffer[], 231 | flags: number) { 232 | const [service, transport] = type.split('.'); 233 | // remove leading '_' 234 | this.service = service.slice(1); 235 | this.transport = <'tcp' | 'udp'>transport.slice(1); 236 | this.ipv = protocol === PROTO_INET6 ? 'IPv6' : 'IPv4'; 237 | this.txt = AvahiService.parseText(txt); 238 | } 239 | 240 | match(iface: number, protocol: number, name: string, type: string, domain: string): boolean { 241 | return this.iface === iface && this.protocol === protocol && 242 | this.name === name && this.type === type && this.domain === domain; 243 | } 244 | 245 | private static parseText(txt?: Buffer[]): dnssd.TxtRecords { 246 | const result = new Object(); 247 | if (txt) { 248 | txt.forEach(v => { 249 | // dbus-next is supposed to treat array of bytes as buffer but 250 | // it currently treats it as a regular array of numbers. 251 | if (!(v instanceof Buffer)) { 252 | v = Buffer.from(v); 253 | } 254 | const [key, value] = v.toString().split(/=/); 255 | result[key] = value; 256 | }); 257 | } 258 | return result; 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ev3dev-browser", 3 | "displayName": "ev3dev-browser", 4 | "description": "Browse for ev3dev devices", 5 | "icon": "resources/icons/ev3dev-logo.png", 6 | "version": "1.2.1", 7 | "publisher": "ev3dev", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/ev3dev/vscode-ev3dev-browser.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/ev3dev/vscode-ev3dev-browser/issues" 15 | }, 16 | "engines": { 17 | "vscode": "^1.39.0" 18 | }, 19 | "categories": [ 20 | "Other" 21 | ], 22 | "activationEvents": [ 23 | "onView:ev3devBrowser", 24 | "onDebugResolve:ev3devBrowser", 25 | "onCommand:ev3devBrowser.action.pickDevice" 26 | ], 27 | "main": "./out/main.js", 28 | "contributes": { 29 | "configuration": { 30 | "title": "ev3dev browser configuration", 31 | "properties": { 32 | "ev3devBrowser.password": { 33 | "scope": "window", 34 | "type": [ 35 | "string", 36 | "null" 37 | ], 38 | "default": "maker", 39 | "description": "The password for the 'robot' user. Set to \"null\" to prompt for password (or use public key authentication)." 40 | }, 41 | "ev3devBrowser.env": { 42 | "scope": "window", 43 | "type": "object", 44 | "patternProperties": { 45 | "[A-Za-z0-9_]{1,}": { 46 | "type": "string" 47 | } 48 | }, 49 | "additionalProperties": false, 50 | "default": { 51 | "PYTHONUNBUFFERED": "TRUE" 52 | }, 53 | "description": "Addition environment variables to use on remote devices.", 54 | "uniqueItems": true 55 | }, 56 | "ev3devBrowser.interactiveTerminal.env": { 57 | "scope": "window", 58 | "type": "object", 59 | "patternProperties": { 60 | "[A-Za-z0-9_]{1,}": { 61 | "type": "string" 62 | } 63 | }, 64 | "additionalProperties": false, 65 | "default": { 66 | "PYTHONINSPECT": "TRUE", 67 | "MICROPYINSPECT": "TRUE" 68 | }, 69 | "description": "Addition environment variables to use on remote devices only when using the interactive terminal that is started by the debugger.", 70 | "uniqueItems": true 71 | }, 72 | "ev3devBrowser.download.include": { 73 | "scope": "resource", 74 | "type": "string", 75 | "default": "**/*", 76 | "description": "Files to include when sending project to remote devices." 77 | }, 78 | "ev3devBrowser.download.exclude": { 79 | "scope": "resource", 80 | "type": "string", 81 | "default": "**/.*", 82 | "description": "Files to exclude when sending project to remote devices." 83 | }, 84 | "ev3devBrowser.download.directory": { 85 | "scope": "resource", 86 | "type": [ 87 | "string", 88 | "null" 89 | ], 90 | "default": null, 91 | "description": "The directory on the remote device where the files will be saved. The default is to use the name of the vscode project directory." 92 | }, 93 | "ev3devBrowser.confirmDelete": { 94 | "scope": "application", 95 | "type": "boolean", 96 | "default": true, 97 | "description": "Prompt for confirmation before deleting remote files." 98 | }, 99 | "ev3devBrowser.additionalDevices": { 100 | "scope": "machine", 101 | "type": "array", 102 | "items": { 103 | "type": "object", 104 | "properties": { 105 | "name": { 106 | "type": "string", 107 | "pattern": "[a-zA-Z0-9_\\-]{1,}" 108 | }, 109 | "ipAddress": { 110 | "type": "string", 111 | "pattern": "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?" 112 | }, 113 | "username": { 114 | "type": "string", 115 | "pattern": "[a-zA-Z0-9_\\-]{1,}", 116 | "default": "robot" 117 | }, 118 | "homeDirectory": { 119 | "type": "string", 120 | "default": "/home/robot" 121 | } 122 | }, 123 | "required": [ 124 | "name", 125 | "ipAddress" 126 | ] 127 | }, 128 | "uniqueItems": true, 129 | "default": [], 130 | "description": "A list of devices to add to the pick list. This is intended to work around troublesome network connections, such as Bluetooth" 131 | }, 132 | "ev3devBrowser.connectTimeout": { 133 | "scope": "application", 134 | "type": "integer", 135 | "default": 30, 136 | "description": "Device connection timeout in seconds." 137 | } 138 | } 139 | }, 140 | "commands": [ 141 | { 142 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal", 143 | "title": "Open SSH Terminal" 144 | }, 145 | { 146 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot", 147 | "title": "Take Screenshot" 148 | }, 149 | { 150 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo", 151 | "title": "Get system info" 152 | }, 153 | { 154 | "command": "ev3devBrowser.deviceTreeItem.reconnect", 155 | "title": "Reconnect" 156 | }, 157 | { 158 | "command": "ev3devBrowser.deviceTreeItem.connectNew", 159 | "title": "Connect to a different device" 160 | }, 161 | { 162 | "command": "ev3devBrowser.deviceTreeItem.disconnect", 163 | "title": "Disconnect" 164 | }, 165 | { 166 | "command": "ev3devBrowser.fileTreeItem.run", 167 | "title": "Run" 168 | }, 169 | { 170 | "command": "ev3devBrowser.fileTreeItem.runInTerminal", 171 | "title": "Run in interactive terminal" 172 | }, 173 | { 174 | "command": "ev3devBrowser.fileTreeItem.delete", 175 | "title": "Delete" 176 | }, 177 | { 178 | "command": "ev3devBrowser.fileTreeItem.showInfo", 179 | "title": "Show Info" 180 | }, 181 | { 182 | "command": "ev3devBrowser.fileTreeItem.upload", 183 | "title": "Upload" 184 | }, 185 | { 186 | "command": "ev3devBrowser.action.pickDevice", 187 | "title": "Connect to a device", 188 | "category": "ev3dev" 189 | }, 190 | { 191 | "command": "ev3devBrowser.action.download", 192 | "title": "Send workspace to device", 193 | "icon": { 194 | "dark": "resources/icons/dark/download.svg", 195 | "light": "resources/icons/light/download.svg" 196 | }, 197 | "category": "ev3dev" 198 | }, 199 | { 200 | "command": "ev3devBrowser.action.refresh", 201 | "title": "Refresh", 202 | "icon": { 203 | "dark": "resources/icons/dark/refresh.svg", 204 | "light": "resources/icons/light/refresh.svg" 205 | }, 206 | "category": "ev3dev" 207 | } 208 | ], 209 | "debuggers": [ 210 | { 211 | "type": "ev3devBrowser", 212 | "label": "ev3dev", 213 | "program": "./out/debugServer.js", 214 | "runtime": "node", 215 | "languages": [ 216 | "python" 217 | ], 218 | "configurationAttributes": { 219 | "launch": { 220 | "required": [ 221 | "program" 222 | ], 223 | "properties": { 224 | "program": { 225 | "type": "string", 226 | "description": "Absolute path to an executable file on the remote device.", 227 | "default": "/home/robot/myproject/myprogram" 228 | }, 229 | "interactiveTerminal": { 230 | "type": "boolean", 231 | "description": "When true, program will be run in a new interactive terminal, when false the output pane will be used instead.", 232 | "default": false 233 | } 234 | } 235 | } 236 | }, 237 | "configurationSnippets": [ 238 | { 239 | "label": "ev3dev: Download and Run", 240 | "description": "Configuration for downloading and running a program on an ev3dev device.", 241 | "body": { 242 | "name": "Download and Run", 243 | "type": "ev3devBrowser", 244 | "request": "launch", 245 | "program": "^\"/home/robot/\\${workspaceFolderBasename}/${1:myprogram}\"", 246 | "interactiveTerminal": false 247 | } 248 | } 249 | ], 250 | "initialConfigurations": [ 251 | { 252 | "name": "Download and Run current file", 253 | "type": "ev3devBrowser", 254 | "request": "launch", 255 | "program": "/home/robot/${workspaceFolderBasename}/${relativeFile}", 256 | "interactiveTerminal": true 257 | }, 258 | { 259 | "name": "Download and Run my-program", 260 | "type": "ev3devBrowser", 261 | "request": "launch", 262 | "program": "/home/robot/${workspaceFolderBasename}/my-program (replace 'my-program' with the actual path)", 263 | "interactiveTerminal": true 264 | } 265 | ] 266 | } 267 | ], 268 | "menus": { 269 | "commandPalette": [ 270 | { 271 | "command": "ev3devBrowser.action.pickDevice" 272 | }, 273 | { 274 | "command": "ev3devBrowser.action.download", 275 | "when": "ev3devBrowser.context.connected" 276 | }, 277 | { 278 | "command": "ev3devBrowser.action.refresh", 279 | "when": "ev3devBrowser.context.connected" 280 | }, 281 | { 282 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal", 283 | "when": "false" 284 | }, 285 | { 286 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot", 287 | "when": "false" 288 | }, 289 | { 290 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo", 291 | "when": "false" 292 | }, 293 | { 294 | "command": "ev3devBrowser.deviceTreeItem.reconnect", 295 | "when": "false" 296 | }, 297 | { 298 | "command": "ev3devBrowser.deviceTreeItem.connectNew", 299 | "when": "false" 300 | }, 301 | { 302 | "command": "ev3devBrowser.deviceTreeItem.disconnect", 303 | "when": "false" 304 | }, 305 | { 306 | "command": "ev3devBrowser.fileTreeItem.run", 307 | "when": "false" 308 | }, 309 | { 310 | "command": "ev3devBrowser.fileTreeItem.runInTerminal", 311 | "when": "false" 312 | }, 313 | { 314 | "command": "ev3devBrowser.fileTreeItem.delete", 315 | "when": "false" 316 | }, 317 | { 318 | "command": "ev3devBrowser.fileTreeItem.showInfo", 319 | "when": "false" 320 | }, 321 | { 322 | "command": "ev3devBrowser.fileTreeItem.upload", 323 | "when": "false" 324 | } 325 | ], 326 | "view/title": [ 327 | { 328 | "command": "ev3devBrowser.action.refresh", 329 | "group": "navigation", 330 | "when": "view == ev3devBrowser && ev3devBrowser.context.connected" 331 | }, 332 | { 333 | "command": "ev3devBrowser.action.download", 334 | "group": "navigation", 335 | "when": "view == ev3devBrowser && ev3devBrowser.context.connected" 336 | } 337 | ], 338 | "view/item/context": [ 339 | { 340 | "command": "ev3devBrowser.deviceTreeItem.reconnect", 341 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.disconnected", 342 | "group": "group@0" 343 | }, 344 | { 345 | "command": "ev3devBrowser.deviceTreeItem.connectNew", 346 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.disconnected", 347 | "group": "group@1" 348 | }, 349 | { 350 | "command": "ev3devBrowser.deviceTreeItem.disconnect", 351 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected", 352 | "group": "secondary@9" 353 | }, 354 | { 355 | "command": "ev3devBrowser.deviceTreeItem.openSshTerminal", 356 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected", 357 | "group": "primary@1" 358 | }, 359 | { 360 | "command": "ev3devBrowser.deviceTreeItem.captureScreenshot", 361 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected", 362 | "group": "primary@2" 363 | }, 364 | { 365 | "command": "ev3devBrowser.deviceTreeItem.showSysinfo", 366 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.device.connected", 367 | "group": "primary@3" 368 | }, 369 | { 370 | "command": "ev3devBrowser.fileTreeItem.run", 371 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable", 372 | "group": "group@1" 373 | }, 374 | { 375 | "command": "ev3devBrowser.fileTreeItem.runInTerminal", 376 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable", 377 | "group": "group@1" 378 | }, 379 | { 380 | "command": "ev3devBrowser.fileTreeItem.delete", 381 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file", 382 | "group": "group@5" 383 | }, 384 | { 385 | "command": "ev3devBrowser.fileTreeItem.delete", 386 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable", 387 | "group": "group@5" 388 | }, 389 | { 390 | "command": "ev3devBrowser.fileTreeItem.delete", 391 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.folder", 392 | "group": "group@5" 393 | }, 394 | { 395 | "command": "ev3devBrowser.fileTreeItem.upload", 396 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable", 397 | "group": "group@9" 398 | }, 399 | { 400 | "command": "ev3devBrowser.fileTreeItem.upload", 401 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file", 402 | "group": "group@9" 403 | }, 404 | { 405 | "command": "ev3devBrowser.fileTreeItem.showInfo", 406 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file.executable", 407 | "group": "group@10" 408 | }, 409 | { 410 | "command": "ev3devBrowser.fileTreeItem.showInfo", 411 | "when": "view == ev3devBrowser && viewItem == ev3devBrowser.file", 412 | "group": "group@10" 413 | } 414 | ] 415 | }, 416 | "views": { 417 | "explorer": [ 418 | { 419 | "id": "ev3devBrowser", 420 | "name": "ev3dev device browser" 421 | } 422 | ] 423 | } 424 | }, 425 | "scripts": { 426 | "vscode:prepublish": "npm run esbuild-base -- --minify", 427 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node", 428 | "esbuild": "npm run esbuild-base -- --sourcemap", 429 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch", 430 | "compile": "tsc -p ./", 431 | "watch": "tsc -watch -p ./", 432 | "test": "npm run compile && node ./node_modules/vscode/bin/test" 433 | }, 434 | "devDependencies": { 435 | "@types/bonjour": "^3.5.4", 436 | "@types/compare-versions": "^3.0.0", 437 | "@types/mocha": "^2.2.42", 438 | "@types/node": "^10.0.0", 439 | "@types/ssh2": "~0.5.35", 440 | "@types/ssh2-streams": "~0.1.5", 441 | "@types/temp": "^0.8.3", 442 | "@types/vscode": "^1.39.0", 443 | "@types/zen-observable": "^0.5.3", 444 | "esbuild": "^0.17.14", 445 | "tslint": "^5.8.0", 446 | "typescript": "^3.7.4", 447 | "vscode-test": "^1.3.0" 448 | }, 449 | "dependencies": { 450 | "bonjour": "^3.5.0", 451 | "compare-versions": "^3.0.1", 452 | "dbus-next": "~0.8.2", 453 | "ssh2": "~0.5.5", 454 | "ssh2-streams": "~0.1.19", 455 | "temp": "^0.8.3", 456 | "vscode-debugadapter": "^1.37.1", 457 | "zen-observable": "^0.5.2" 458 | } 459 | } -------------------------------------------------------------------------------- /src/device.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as os from 'os'; 3 | import * as readline from 'readline'; 4 | import * as ssh2 from 'ssh2'; 5 | import * as ssh2Streams from 'ssh2-streams'; 6 | import * as vscode from 'vscode'; 7 | import Observable from 'zen-observable'; 8 | 9 | import { Brickd } from './brickd'; 10 | import * as dnssd from './dnssd'; 11 | 12 | /** 13 | * Object that represents a remote ev3dev device. 14 | */ 15 | export class Device extends vscode.Disposable { 16 | private readonly client: ssh2.Client; 17 | private sftp?: ssh2.SFTPWrapper; 18 | private _homeDirectoryAttr?: ssh2Streams.Attributes; 19 | private _isConnecting = false; 20 | private _isConnected = false; 21 | 22 | /** 23 | * The username requested by the device. 24 | * 25 | * This value comes from a mDNS text record. 26 | */ 27 | public readonly username: string; 28 | 29 | private readonly _onWillConnect = new vscode.EventEmitter(); 30 | /** 31 | * Event that fires when a connection is initiated. 32 | * 33 | * This will be followed by either onDidConnect or onDidDisconnect. 34 | */ 35 | public readonly onWillConnect = this._onWillConnect.event; 36 | 37 | private readonly _onDidConnect = new vscode.EventEmitter(); 38 | /** 39 | * Event that fires when a connection has completed successfully. 40 | */ 41 | public readonly onDidConnect = this._onDidConnect.event; 42 | 43 | private readonly _onDidDisconnect = new vscode.EventEmitter(); 44 | /** 45 | * Event that fires when a connection has been closed. 46 | */ 47 | public readonly onDidDisconnect = this._onDidDisconnect.event; 48 | 49 | constructor(private readonly service: dnssd.Service) { 50 | super(() => { 51 | this.disconnect(); 52 | this._onWillConnect.dispose(); 53 | this._onDidConnect.dispose(); 54 | this._onDidDisconnect.dispose(); 55 | this.client.destroy(); 56 | }); 57 | this.username = service.txt['ev3dev.robot.user']; 58 | this.client = new ssh2.Client(); 59 | this.client.on('end', () => { 60 | 61 | }); 62 | this.client.on('close', () => { 63 | this.disconnect(); 64 | }); 65 | this.client.on('keyboard-interactive', async (name, instructions, lang, prompts, finish) => { 66 | const answers = new Array(); 67 | for (const p of prompts) { 68 | const choice = await vscode.window.showInputBox({ 69 | ignoreFocusOut: true, 70 | password: !p.echo, 71 | prompt: p.prompt 72 | }); 73 | // FIXME: how to cancel properly? 74 | answers.push(choice || ''); 75 | } 76 | finish(answers); 77 | }); 78 | } 79 | 80 | /** 81 | * Connect to the device using SSH. 82 | */ 83 | public async connect(): Promise { 84 | this._isConnecting = true; 85 | this._onWillConnect.fire(); 86 | await this.connectClient(); 87 | try { 88 | this.sftp = await this.getSftp(); 89 | this._homeDirectoryAttr = await this.stat(this.homeDirectoryPath); 90 | this._isConnecting = false; 91 | this._isConnected = true; 92 | this._onDidConnect.fire(); 93 | } 94 | catch (err) { 95 | this._isConnecting = false; 96 | this.disconnect(); 97 | throw err; 98 | } 99 | } 100 | 101 | private connectClient(): Promise { 102 | return new Promise((resolve, reject) => { 103 | this.client.once('ready', resolve); 104 | this.client.once('error', reject); 105 | let address = this.service.address; 106 | if (this.service.ipv === 'IPv6' && address.startsWith('fe80::')) { 107 | // this is IPv6 link local address, so we need to add the network 108 | // interface to the end 109 | if (process.platform === 'win32') { 110 | // Windows uses the interface index 111 | address += `%${this.service.iface}`; 112 | } 113 | else { 114 | // everyone else uses the interface name 115 | address += `%${(this.service)['ifaceName']}`; 116 | } 117 | } 118 | const config = vscode.workspace.getConfiguration('ev3devBrowser'); 119 | this.client.connect({ 120 | host: address, 121 | username: this.username, 122 | password: config.get('password'), 123 | tryKeyboard: true, 124 | keepaliveCountMax: 5, 125 | keepaliveInterval: 1000, 126 | readyTimeout: config.get('connectTimeout', 30) * 1000, 127 | }); 128 | }); 129 | } 130 | 131 | private getSftp(): Promise { 132 | return new Promise((resolve, reject) => { 133 | // This can keep the connection busy for a long time. On Bluetooth, 134 | // it is enough for the keepalive timeout to expire. So, we ignore 135 | // the keepalive during this operation. 136 | const timer = setInterval(() => { 137 | (this.client)._resetKA(); 138 | }, 1000); 139 | this.client.sftp((err, sftp) => { 140 | clearInterval(timer); 141 | if (err) { 142 | reject(err); 143 | return; 144 | } 145 | resolve(sftp); 146 | }); 147 | }); 148 | } 149 | 150 | /** 151 | * Disconnect from the device. 152 | */ 153 | public disconnect(): void { 154 | this._isConnected = false; 155 | if (this.sftp) { 156 | this.sftp.end(); 157 | this.sftp = undefined; 158 | } 159 | this.client.end(); 160 | this._onDidDisconnect.fire(); 161 | } 162 | 163 | /** 164 | * Tests if a connection is currently in progress. 165 | */ 166 | public get isConnecting(): boolean { 167 | return this._isConnecting; 168 | } 169 | 170 | /** 171 | * Tests if a device is currently connected. 172 | */ 173 | public get isConnected(): boolean { 174 | return this._isConnected; 175 | } 176 | 177 | /** 178 | * Gets the name of the device. 179 | */ 180 | public get name(): string { 181 | return this.service.name; 182 | } 183 | 184 | /** 185 | * Get the file attributes of the home directory. 186 | */ 187 | public get homeDirectoryAttr(): ssh2Streams.Attributes { 188 | if (!this._homeDirectoryAttr) { 189 | throw new Error('Not connected'); 190 | } 191 | return this._homeDirectoryAttr; 192 | } 193 | 194 | /** 195 | * Gets the home directory path for the device. 196 | */ 197 | public get homeDirectoryPath(): string { 198 | return this.service.txt['ev3dev.robot.home'] || `/home/${this.username}`; 199 | } 200 | 201 | /** 202 | * Sets file permissions. 203 | * @param path The path to a file or directory 204 | * @param mode The file permissions 205 | */ 206 | public chmod(path: string, mode: string | number): Promise { 207 | return new Promise((resolve, reject) => { 208 | if (!this.sftp) { 209 | reject(new Error('Not connected')); 210 | return; 211 | } 212 | this.sftp.chmod(path, mode, err => { 213 | if (err) { 214 | reject(err); 215 | } 216 | else { 217 | resolve(); 218 | } 219 | }); 220 | }); 221 | } 222 | 223 | /** 224 | * Executes a command on the remote device. 225 | * @param command The absolute path of the command. 226 | */ 227 | public exec(command: string, env?: any, pty?: ssh2.PseudoTtyOptions): Promise { 228 | return new Promise((resolve, reject) => { 229 | const options = { 230 | env: env, 231 | pty: pty, 232 | }; 233 | this.client.exec(command, options, (err, channel) => { 234 | if (err) { 235 | reject(err); 236 | return; 237 | } 238 | resolve(channel); 239 | }); 240 | }); 241 | } 242 | 243 | /** 244 | * Create an observable that monitors the stdout and stderr of a command. 245 | * @param command The command to execute. 246 | */ 247 | public async createExecObservable(command: string): Promise<[Observable, Observable]> { 248 | return new Promise<[Observable, Observable]>(async (resolve, reject) => { 249 | try { 250 | const conn = await this.exec(command); 251 | const stdout = new Observable(observer => { 252 | readline.createInterface({ 253 | input: conn.stdout 254 | }).on('line', line => { 255 | observer.next(line); 256 | }).on('close', () => { 257 | observer.complete(); 258 | }); 259 | }); 260 | const stderr = new Observable(observer => { 261 | readline.createInterface({ 262 | input: conn.stderr 263 | }).on('line', line => { 264 | observer.next(line); 265 | }).on('close', () => { 266 | observer.complete(); 267 | }); 268 | }); 269 | resolve([stdout, stderr]); 270 | } 271 | catch (err) { 272 | reject(err); 273 | } 274 | }); 275 | } 276 | 277 | /** 278 | * Starts a new shell on the remote device. 279 | * @param window Optional pty settings or false to not allocate a pty. 280 | */ 281 | public shell(window: false | ssh2.PseudoTtyOptions): Promise { 282 | return new Promise((resolve, reject) => { 283 | const options = { 284 | env: vscode.workspace.getConfiguration('ev3devBrowser').get('env') 285 | }; 286 | this.client.shell(window, options, (err, stream) => { 287 | if (err) { 288 | reject(err); 289 | } 290 | else { 291 | resolve(stream); 292 | } 293 | }); 294 | }); 295 | } 296 | 297 | /** 298 | * Create a directory. 299 | * @param path the path of the directory. 300 | */ 301 | public mkdir(path: string): Promise { 302 | return new Promise((resolve, reject) => { 303 | if (!this.sftp) { 304 | reject(new Error('Not connected')); 305 | return; 306 | } 307 | this.sftp.mkdir(path, err => { 308 | if (err) { 309 | reject(err); 310 | } 311 | else { 312 | resolve(); 313 | } 314 | }); 315 | }); 316 | } 317 | 318 | /** 319 | * Recursively create a directory (equivalent of mkdir -p). 320 | * @param dirPath the path of the directory 321 | */ 322 | public async mkdir_p(dirPath: string): Promise { 323 | if (!path.posix.isAbsolute(dirPath)) { 324 | throw new Error("The supplied file path must be absolute."); 325 | } 326 | 327 | const names = dirPath.split('/'); 328 | 329 | // Leading slash produces empty first element 330 | names.shift(); 331 | 332 | let part = '/'; 333 | while (names.length) { 334 | part = path.posix.join(part, names.shift()); 335 | // Create the directory if it doesn't already exist 336 | try { 337 | const stat = await this.stat(part); 338 | if (!stat.isDirectory()) { 339 | throw new Error(`Cannot create directory: "${part}" exists but isn't a directory`); 340 | } 341 | } 342 | catch (err) { 343 | if (err.code !== ssh2.SFTP_STATUS_CODE.NO_SUCH_FILE) { 344 | throw err; 345 | } 346 | await this.mkdir(part); 347 | } 348 | } 349 | } 350 | 351 | /** 352 | * Copy a remote file to the local host. 353 | * @param remote The remote path. 354 | * @param local The path where the file will be saved. 355 | * @param reportPercentage An optional progress reporting callback 356 | */ 357 | public get(remote: string, local: string, reportPercentage?: (percentage: number) => void): Promise { 358 | return new Promise((resolve, reject) => { 359 | if (!this.sftp) { 360 | reject(new Error('Not connected')); 361 | return; 362 | } 363 | this.sftp.fastGet(remote, local, { 364 | concurrency: 1, 365 | step: (transferred, chunk, total) => { 366 | if (reportPercentage) { 367 | reportPercentage(Math.round(transferred / total * 100)); 368 | } 369 | }, 370 | }, err => { 371 | if (err) { 372 | reject(err); 373 | } 374 | else { 375 | resolve(); 376 | } 377 | }); 378 | }); 379 | } 380 | 381 | /** 382 | * Copy a local file to the remote device. 383 | * @param local The path to a local file. 384 | * @param remote The remote path where the file will be saved. 385 | * @param mode The file permissions 386 | * @param reportPercentage An optional progress reporting callback 387 | */ 388 | public put(local: string, remote: string, mode?: string, reportPercentage?: (percentage: number) => void): Promise { 389 | return new Promise((resolve, reject) => { 390 | if (!this.sftp) { 391 | reject(new Error('Not connected')); 392 | return; 393 | } 394 | this.sftp.fastPut(local, remote, { 395 | concurrency: 1, 396 | step: (transferred, chunk, total) => { 397 | if (reportPercentage) { 398 | reportPercentage(Math.round(transferred / total * 100)); 399 | } 400 | }, 401 | mode: mode 402 | }, (err) => { 403 | if (err) { 404 | reject(err); 405 | } 406 | else { 407 | resolve(); 408 | } 409 | }); 410 | }); 411 | } 412 | 413 | /** 414 | * List the contents of a remote directory. 415 | * @param path The path to a directory. 416 | */ 417 | public ls(path: string): Promise { 418 | return new Promise((resolve, reject) => { 419 | if (!this.sftp) { 420 | reject(new Error('Not connected')); 421 | return; 422 | } 423 | this.sftp.readdir(path, (err, list) => { 424 | if (err) { 425 | reject(err); 426 | } 427 | else { 428 | resolve(list); 429 | } 430 | }); 431 | }); 432 | } 433 | 434 | /** 435 | * Stat a remote file or directory. 436 | * @param path The path to a remote file or directory. 437 | */ 438 | public stat(path: string): Promise { 439 | return new Promise((resolve, reject) => { 440 | if (!this.sftp) { 441 | reject(new Error('Not connected')); 442 | return; 443 | } 444 | this.sftp.stat(path, (err, stats) => { 445 | if (err) { 446 | reject(err); 447 | } 448 | else { 449 | resolve(stats); 450 | } 451 | }); 452 | }); 453 | } 454 | 455 | /** 456 | * Remove a remote file. 457 | * @param path The path to a file or symlink to remove (unlink) 458 | */ 459 | public rm(path: string): Promise { 460 | return new Promise((resolve, reject) => { 461 | if (!this.sftp) { 462 | reject(new Error('Not connected')); 463 | return; 464 | } 465 | this.sftp.unlink(path, err => { 466 | if (err) { 467 | reject(err); 468 | } 469 | else { 470 | resolve(); 471 | } 472 | }); 473 | }); 474 | } 475 | 476 | public async rm_rf(path: string): Promise { 477 | const stat = await this.stat(path); 478 | if (stat.isDirectory()) { 479 | for (const f of await this.ls(path)) { 480 | await this.rm_rf(`${path}/${f.filename}`); 481 | } 482 | await this.rmdir(path); 483 | } 484 | else { 485 | await this.rm(path); 486 | } 487 | } 488 | 489 | public rmdir(path: string): Promise { 490 | return new Promise((resolve, reject) => { 491 | if (!this.sftp) { 492 | reject(new Error('Not connected')); 493 | return; 494 | } 495 | this.sftp.rmdir(path, err => { 496 | if (err) { 497 | reject(err); 498 | } 499 | else { 500 | resolve(); 501 | } 502 | }); 503 | }); 504 | } 505 | 506 | private static dnssdClient: dnssd.Client; 507 | private static async getDnssdClient(): Promise { 508 | if (!Device.dnssdClient) { 509 | Device.dnssdClient = await dnssd.getInstance(); 510 | } 511 | return Device.dnssdClient; 512 | } 513 | 514 | private static additionalDeviceToDnssdService(device: AdditionalDevice): dnssd.Service { 515 | const txt: dnssd.TxtRecords = {}; 516 | txt['ev3dev.robot.user'] = device.username || 'robot'; 517 | txt['ev3dev.robot.home'] = device.homeDirectory || `/home/${txt['ev3dev.robot.user']}`; 518 | 519 | // device.ipAddress is validated, so this is safe 520 | const [address, port] = device.ipAddress.split(':'); 521 | 522 | return { 523 | name: device.name, 524 | address, 525 | ipv: 'IPv4', 526 | port: Number(port) || 22, 527 | service: 'sftp-ssh', 528 | transport: 'tcp', 529 | txt: txt 530 | }; 531 | } 532 | 533 | /** 534 | * Read additional device definitions from the config and convert them to 535 | * ServiceItems 536 | */ 537 | private static getServicesFromConfig(): ServiceItem[] { 538 | const services = new Array(); 539 | const devices = vscode.workspace.getConfiguration('ev3devBrowser').get('additionalDevices', []); 540 | for (const device of devices) { 541 | services.push({ 542 | label: device.name, 543 | service: this.additionalDeviceToDnssdService(device) 544 | }); 545 | } 546 | return services; 547 | } 548 | 549 | /** 550 | * Use a quick-pick to browse discovered devices and select one. 551 | * @returns A new Device or undefined if the user canceled the request 552 | */ 553 | public static async pickDevice(): Promise { 554 | const configItems = this.getServicesFromConfig(); 555 | const manualEntry = { 556 | label: "I don't see my device..." 557 | }; 558 | 559 | const selectedItem = await new Promise(async (resolve, reject) => { 560 | // start browsing for devices 561 | const dnssdClient = await Device.getDnssdClient(); 562 | const browser = await dnssdClient.createBrowser({ 563 | ipv: 'IPv6', 564 | service: 'sftp-ssh' 565 | }); 566 | const items = new Array(); 567 | let cancelSource: vscode.CancellationTokenSource | undefined; 568 | let done = false; 569 | 570 | // if a device is added or removed, cancel the quick-pick 571 | // and then show a new one with the update list 572 | browser.on('added', (service) => { 573 | if (service.txt['ev3dev.robot.home']) { 574 | // this looks like an ev3dev device 575 | const ifaces = os.networkInterfaces(); 576 | for (const ifaceName in ifaces) { 577 | if (ifaces[ifaceName].find(v => (v).scopeid === service.iface)) { 578 | (service)['ifaceName'] = ifaceName; 579 | break; 580 | } 581 | } 582 | const item = new ServiceItem(service); 583 | items.push(item); 584 | cancelSource?.cancel(); 585 | } 586 | }); 587 | browser.on('removed', (service) => { 588 | const index = items.findIndex(si => si.service === service); 589 | if (index > -1) { 590 | items.splice(index, 1); 591 | cancelSource?.cancel(); 592 | } 593 | }); 594 | 595 | // if there is a browser error, cancel the quick-pick and show 596 | // an error message 597 | browser.on('error', err => { 598 | cancelSource?.cancel(); 599 | browser.destroy(); 600 | done = true; 601 | reject(err); 602 | }); 603 | 604 | await browser.start(); 605 | 606 | while (!done) { 607 | cancelSource = new vscode.CancellationTokenSource(); 608 | // using this promise in the quick-pick will cause a progress 609 | // bar to show if there are no items. 610 | const list = new Array(); 611 | if (items) { 612 | list.push(...items); 613 | } 614 | if (configItems) { 615 | list.push(...configItems); 616 | } 617 | list.push(manualEntry); 618 | const selected = await vscode.window.showQuickPick(list, { 619 | ignoreFocusOut: true, 620 | placeHolder: "Searching for devices... Select a device or press ESC to cancel." 621 | }, cancelSource.token); 622 | if (cancelSource.token.isCancellationRequested) { 623 | continue; 624 | } 625 | browser.destroy(); 626 | done = true; 627 | resolve(selected); 628 | } 629 | }); 630 | if (!selectedItem) { 631 | // cancelled 632 | return undefined; 633 | } 634 | 635 | if (selectedItem === manualEntry) { 636 | const name = await vscode.window.showInputBox({ 637 | ignoreFocusOut: true, 638 | prompt: "Enter a name for the device", 639 | placeHolder: 'Example: "ev3dev (Bluetooth)"' 640 | }); 641 | if (!name) { 642 | // cancelled 643 | return undefined; 644 | } 645 | const ipAddress = await vscode.window.showInputBox({ 646 | ignoreFocusOut: true, 647 | prompt: "Enter the IP address of the device", 648 | placeHolder: 'Example: "192.168.137.3"', 649 | validateInput: (v) => { 650 | if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$/.test(v)) { 651 | return 'Not a valid IP address'; 652 | } 653 | return undefined; 654 | } 655 | }); 656 | if (!ipAddress) { 657 | // cancelled 658 | return undefined; 659 | } 660 | 661 | const device = { 662 | name: name, 663 | ipAddress: ipAddress 664 | }; 665 | 666 | const config = vscode.workspace.getConfiguration('ev3devBrowser'); 667 | const existing = config.get('additionalDevices', []); 668 | existing.push(device); 669 | config.update('additionalDevices', existing, vscode.ConfigurationTarget.Global); 670 | 671 | return new Device(this.additionalDeviceToDnssdService(device)); 672 | } 673 | 674 | return new Device(selectedItem.service); 675 | } 676 | 677 | private async forwardOut(srcAddr: string, srcPort: number, destAddr: string, destPort: number): Promise { 678 | return new Promise((resolve, reject) => { 679 | this.client.forwardOut(srcAddr, srcPort, destAddr, destPort, (err, channel) => { 680 | if (err) { 681 | reject(err); 682 | } 683 | else { 684 | resolve(channel); 685 | } 686 | }); 687 | }); 688 | } 689 | 690 | /** 691 | * Gets a new connection to brickd. 692 | * 693 | * @returns A promise of a Brickd object. 694 | */ 695 | public async brickd(): Promise { 696 | const channel = await this.forwardOut('localhost', 0, 'localhost', 31313); 697 | return new Brickd(channel); 698 | } 699 | } 700 | 701 | /** 702 | * Quick pick item used in DeviceManager.pickDevice(). 703 | */ 704 | class ServiceItem implements vscode.QuickPickItem { 705 | public readonly label: string; 706 | public readonly description: string | undefined; 707 | 708 | constructor(public service: dnssd.Service) { 709 | this.label = service.name; 710 | this.description = (service)['ifaceName']; 711 | } 712 | } 713 | 714 | interface AdditionalDevice { 715 | name: string; 716 | ipAddress: string; 717 | username: string; 718 | homeDirectory: string; 719 | } 720 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as net from 'net'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import * as ssh2Streams from 'ssh2-streams'; 6 | import * as temp from 'temp'; 7 | 8 | import * as vscode from 'vscode'; 9 | 10 | import { Ev3devBrowserDebugSession, LaunchRequestArguments } from './debugServer'; 11 | import { Brickd } from './brickd'; 12 | import { Device } from './device'; 13 | import { 14 | getSharedTempDir, 15 | sanitizedDateString, 16 | setContext, 17 | toastStatusBarMessage, 18 | verifyFileHeader, 19 | getPlatform, 20 | normalizeLineEndings, 21 | } from './utils'; 22 | 23 | // fs.constants.S_IXUSR is undefined on win32! 24 | const S_IXUSR = 0o0100; 25 | 26 | let config: WorkspaceConfig; 27 | let output: vscode.OutputChannel; 28 | let resourceDir: string; 29 | let ev3devBrowserProvider: Ev3devBrowserProvider; 30 | 31 | // this method is called when your extension is activated 32 | // your extension is activated the very first time the command is executed 33 | export function activate(context: vscode.ExtensionContext): void { 34 | config = new WorkspaceConfig(context.workspaceState); 35 | output = vscode.window.createOutputChannel('ev3dev'); 36 | resourceDir = context.asAbsolutePath('resources'); 37 | 38 | ev3devBrowserProvider = new Ev3devBrowserProvider(); 39 | const factory = new Ev3devDebugAdapterDescriptorFactory(); 40 | const provider = new Ev3devDebugConfigurationProvider(); 41 | context.subscriptions.push( 42 | output, ev3devBrowserProvider, 43 | vscode.window.registerTreeDataProvider('ev3devBrowser', ev3devBrowserProvider), 44 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.openSshTerminal', d => d.openSshTerminal()), 45 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.captureScreenshot', d => d.captureScreenshot()), 46 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.showSysinfo', d => d.showSysinfo()), 47 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.reconnect', d => d.connect()), 48 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.connectNew', d => pickDevice()), 49 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.disconnect', d => d.disconnect()), 50 | vscode.commands.registerCommand('ev3devBrowser.deviceTreeItem.select', d => d.handleClick()), 51 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.run', f => f.run()), 52 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.runInTerminal', f => f.runInTerminal()), 53 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.delete', f => f.delete()), 54 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.showInfo', f => f.showInfo()), 55 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.upload', f => f.upload()), 56 | vscode.commands.registerCommand('ev3devBrowser.fileTreeItem.select', f => f.handleClick()), 57 | vscode.commands.registerCommand('ev3devBrowser.action.pickDevice', () => pickDevice()), 58 | vscode.commands.registerCommand('ev3devBrowser.action.download', () => downloadAll()), 59 | vscode.commands.registerCommand('ev3devBrowser.action.refresh', () => refresh()), 60 | vscode.debug.onDidReceiveDebugSessionCustomEvent(e => handleCustomDebugEvent(e)), 61 | vscode.debug.registerDebugAdapterDescriptorFactory('ev3devBrowser', factory), 62 | vscode.debug.registerDebugConfigurationProvider('ev3devBrowser', provider), 63 | ); 64 | } 65 | 66 | class Ev3devDebugAdapterDescriptorFactory implements vscode.DebugAdapterDescriptorFactory { 67 | private server?: net.Server; 68 | 69 | createDebugAdapterDescriptor(session: vscode.DebugSession, executable: vscode.DebugAdapterExecutable | undefined): vscode.ProviderResult { 70 | if (!this.server) { 71 | // start listening on a random port 72 | this.server = net.createServer(socket => { 73 | const session = new Ev3devBrowserDebugSession(); 74 | session.setRunAsServer(true); 75 | session.start(socket, socket); 76 | }).listen(0); 77 | } 78 | 79 | // make VS Code connect to debug server 80 | return new vscode.DebugAdapterServer((this.server.address()).port); 81 | } 82 | 83 | dispose() { 84 | this.server?.close(); 85 | } 86 | } 87 | 88 | class Ev3devDebugConfigurationProvider implements vscode.DebugConfigurationProvider { 89 | async resolveDebugConfiguration( 90 | _folder: vscode.WorkspaceFolder | undefined, 91 | debugConfiguration: vscode.DebugConfiguration, 92 | token?: vscode.CancellationToken, 93 | ): Promise { 94 | if (Object.keys(debugConfiguration).length === 0) { 95 | type DebugConfigurationQuickPickItem = vscode.QuickPickItem & { interactiveTerminal: boolean }; 96 | const items: DebugConfigurationQuickPickItem[] = [ 97 | { 98 | label: "Download and run current file", 99 | description: "in interactive terminal", 100 | interactiveTerminal: true, 101 | }, 102 | { 103 | label: "Download and run current file", 104 | description: "in output pane", 105 | interactiveTerminal: false, 106 | }, 107 | ]; 108 | const selected = await vscode.window.showQuickPick(items, { 109 | matchOnDescription: true, 110 | ignoreFocusOut: true, 111 | placeHolder: "Debug configuration" 112 | }, token); 113 | if (selected) { 114 | return { 115 | type: "ev3devBrowser", 116 | name: `${selected.label} ${selected.description}`, 117 | request: "launch", 118 | program: "/home/robot/${workspaceFolderBasename}/${relativeFile}", 119 | interactiveTerminal: selected.interactiveTerminal 120 | }; 121 | } 122 | } 123 | return debugConfiguration; 124 | } 125 | } 126 | 127 | // this method is called when your extension is deactivated 128 | export function deactivate(): void { 129 | // The "temp" module should clean up automatically, but do this just in case. 130 | temp.cleanupSync(); 131 | } 132 | 133 | async function pickDevice(): Promise { 134 | const device = await Device.pickDevice(); 135 | if (!device) { 136 | // user canceled 137 | return; 138 | } 139 | 140 | await vscode.window.withProgress({ 141 | location: vscode.ProgressLocation.Window, 142 | title: "Connecting..." 143 | }, async progress => { 144 | ev3devBrowserProvider.setDevice(device); 145 | try { 146 | await device.connect(); 147 | toastStatusBarMessage(`Connected`); 148 | } 149 | catch (err) { 150 | const troubleshoot = 'Troubleshoot'; 151 | vscode.window.showErrorMessage(`Failed to connect to ${device.name}: ${err.message}`, troubleshoot) 152 | .then((value) => { 153 | if (value === troubleshoot) { 154 | const wiki = vscode.Uri.parse('https://github.com/ev3dev/vscode-ev3dev-browser/wiki/Troubleshooting') 155 | vscode.commands.executeCommand('vscode.open', wiki); 156 | } 157 | }); 158 | } 159 | }); 160 | } 161 | 162 | const activeDebugSessions = new Set(); 163 | let debugTerminal: vscode.Terminal; 164 | let debugRestarting: boolean; 165 | 166 | async function handleCustomDebugEvent(event: vscode.DebugSessionCustomEvent): Promise { 167 | let device: Device | undefined; 168 | switch (event.event) { 169 | case 'ev3devBrowser.debugger.launch': 170 | const args = event.body; 171 | device = await ev3devBrowserProvider.getDevice(); 172 | if (device && !device.isConnected) { 173 | const item = ev3devBrowserProvider.getDeviceTreeItem(); 174 | if (item) { 175 | await item.connect(); 176 | } 177 | } 178 | if (!device || !device.isConnected) { 179 | await event.session.customRequest('ev3devBrowser.debugger.terminate'); 180 | break; 181 | } 182 | 183 | // optionally download before running - workspaceFolder can be undefined 184 | // if the request did not come from a specific project, in which case we 185 | // download all projects 186 | const folder = event.session.workspaceFolder; 187 | if (args.download !== false && !(folder ? await download(folder, device) : await downloadAll())) { 188 | // download() shows error messages, so don't show additional message here. 189 | await event.session.customRequest('ev3devBrowser.debugger.terminate'); 190 | break; 191 | } 192 | 193 | // run the program 194 | try { 195 | // normalize the path to unix path separators since this path will be used on the EV3 196 | const programPath = vscode.Uri.file(args.program).path; 197 | 198 | const dirname = path.posix.dirname(programPath); 199 | if (args.interactiveTerminal) { 200 | const command = `brickrun -r --directory="${dirname}" "${programPath}"`; 201 | const config = vscode.workspace.getConfiguration(`terminal.integrated.env.${getPlatform()}`); 202 | const termEnv = config.get('TERM'); 203 | const env = { 204 | ...vscode.workspace.getConfiguration('ev3devBrowser').get('env'), 205 | ...vscode.workspace.getConfiguration('ev3devBrowser').get('interactiveTerminal.env'), 206 | }; 207 | const ch = await device.exec(command, env, { term: termEnv || process.env['TERM'] || 'xterm-256color' }); 208 | const writeEmitter = new vscode.EventEmitter(); 209 | ch.stdout.on('data', (data: string | Buffer) => writeEmitter.fire(String(data))); 210 | ch.stderr.on('data', (data: string | Buffer) => writeEmitter.fire(String(data))); 211 | if (debugTerminal) { 212 | debugTerminal.dispose(); 213 | } 214 | debugTerminal = vscode.window.createTerminal({ 215 | name: `${path.posix.basename(programPath)} on ${device.name}`, 216 | pty: { 217 | onDidWrite: writeEmitter.event, 218 | open: (dim: vscode.TerminalDimensions | undefined) => { 219 | if (dim !== undefined) { 220 | ch.setWindow(dim.rows, dim.columns, 0, 0); 221 | } 222 | writeEmitter.fire(`Starting: ${command}\r\n`); 223 | writeEmitter.fire('----------\r\n'); 224 | }, 225 | close: () => { 226 | ch.close(); 227 | activeDebugSessions.delete(event.session.id); 228 | }, 229 | handleInput: (data: string) => { 230 | ch.stdin.write(data); 231 | }, 232 | setDimensions: (dim: vscode.TerminalDimensions) => { 233 | ch.setWindow(dim.rows, dim.columns, 0, 0); 234 | }, 235 | }, 236 | }); 237 | ch.on('close', () => { 238 | if (debugRestarting) { 239 | activeDebugSessions.add(event.session.id); 240 | event.session.customRequest('ev3devBrowser.debugger.thread', 'started'); 241 | debugRestarting = false; 242 | } else { 243 | event.session.customRequest('ev3devBrowser.debugger.terminate'); 244 | } 245 | ch.destroy(); 246 | }); 247 | ch.on('exit', (code, signal, coreDump, desc) => { 248 | writeEmitter.fire('----------\r\n'); 249 | if (code === 0) { 250 | writeEmitter.fire('Completed successfully.\r\n'); 251 | } 252 | else if (code) { 253 | writeEmitter.fire(`Exited with error code ${code}.\r\n`); 254 | } 255 | else { 256 | writeEmitter.fire(`Exited with signal ${signal}.\r\n`); 257 | } 258 | activeDebugSessions.delete(event.session.id); 259 | }); 260 | ch.on('error', (err: any) => { 261 | vscode.window.showErrorMessage(`Connection error: ${err || err.message}`); 262 | debugTerminal.dispose(); 263 | ch.destroy(); 264 | }); 265 | debugTerminal.show(); 266 | event.session.customRequest('ev3devBrowser.debugger.thread', 'started'); 267 | } 268 | else { 269 | const command = `brickrun --directory="${dirname}" "${programPath}"`; 270 | output.show(true); 271 | output.clear(); 272 | output.appendLine(`Starting: ${command}`); 273 | const env = vscode.workspace.getConfiguration('ev3devBrowser').get('env'); 274 | const channel = await device.exec(command, env); 275 | channel.on('close', () => { 276 | if (debugRestarting) { 277 | activeDebugSessions.add(event.session.id); 278 | output.clear(); 279 | output.appendLine(`Restarting: ${command}`); 280 | output.appendLine('----------'); 281 | event.session.customRequest('ev3devBrowser.debugger.thread', 'started'); 282 | debugRestarting = false; 283 | } else { 284 | event.session.customRequest('ev3devBrowser.debugger.terminate'); 285 | } 286 | }); 287 | channel.on('exit', (code, signal, coreDump, desc) => { 288 | if (!debugRestarting) { 289 | output.appendLine('----------'); 290 | if (code === 0) { 291 | output.appendLine('Completed successfully.'); 292 | } 293 | else if (code) { 294 | output.appendLine(`Exited with error code ${code}.`); 295 | } 296 | else { 297 | output.appendLine(`Exited with signal ${signal}.`); 298 | } 299 | activeDebugSessions.delete(event.session.id); 300 | } 301 | }); 302 | channel.on('data', (chunk: string | Buffer) => { 303 | output.append(chunk.toString()); 304 | }); 305 | channel.stderr.on('data', (chunk) => { 306 | output.append(chunk.toString()); 307 | }); 308 | output.appendLine('----------'); 309 | event.session.customRequest('ev3devBrowser.debugger.thread', 'started'); 310 | } 311 | activeDebugSessions.add(event.session.id); 312 | } 313 | catch (err) { 314 | await event.session.customRequest('ev3devBrowser.debugger.terminate'); 315 | vscode.window.showErrorMessage(`Failed to run file: ${err.message}`); 316 | } 317 | break; 318 | case 'ev3devBrowser.debugger.stop': 319 | debugRestarting = event.body.restart; 320 | device = ev3devBrowserProvider.getDeviceSync(); 321 | if (activeDebugSessions.has(event.session.id) && device && device.isConnected) { 322 | device.exec('conrun-kill --signal=SIGKILL --group'); 323 | } 324 | // update remote file browser in case program created new files 325 | refresh(); 326 | break; 327 | case 'ev3devBrowser.debugger.interrupt': 328 | device = ev3devBrowserProvider.getDeviceSync(); 329 | if (activeDebugSessions.has(event.session.id) && device && device.isConnected) { 330 | device.exec('conrun-kill --signal=SIGINT'); 331 | } 332 | // update remote file browser in case program created new files 333 | refresh(); 334 | break; 335 | } 336 | } 337 | 338 | /** 339 | * Download all workspace folders to the device. 340 | * 341 | * @return Promise of true on success, otherwise false. 342 | */ 343 | async function downloadAll(): Promise { 344 | let device = await ev3devBrowserProvider.getDevice(); 345 | if (!device) { 346 | // get device will have shown an error message, so we don't need another here 347 | return false; 348 | } 349 | if (!device.isConnected) { 350 | vscode.window.showErrorMessage('Device is not connected.'); 351 | return false; 352 | } 353 | 354 | if (!vscode.workspace.workspaceFolders) { 355 | vscode.window.showErrorMessage('Must have a folder open to send files to device.'); 356 | return false; 357 | } 358 | await vscode.workspace.saveAll(); 359 | 360 | for (const localFolder of vscode.workspace.workspaceFolders) { 361 | if (!await download(localFolder, device)) { 362 | return false; 363 | } 364 | } 365 | 366 | return true; 367 | } 368 | 369 | /** 370 | * Download workspace folder to the device. 371 | * 372 | * @param folder The folder. 373 | * @param device The device. 374 | * @return Promise of true on success, otherwise false. 375 | */ 376 | async function download(folder: vscode.WorkspaceFolder, device: Device): Promise { 377 | const config = vscode.workspace.getConfiguration('ev3devBrowser.download', folder.uri); 378 | 379 | const includeFiles = new vscode.RelativePattern(folder, config.get('include', '')); 380 | const excludeFiles = new vscode.RelativePattern(folder, config.get('exclude', '')); 381 | const projectDir = config.get('directory') || path.basename(folder.uri.fsPath); 382 | const remoteBaseDir = path.posix.join(device.homeDirectoryPath, projectDir); 383 | const deviceName = device.name; 384 | 385 | return vscode.window.withProgress({ 386 | location: vscode.ProgressLocation.Notification, 387 | title: 'Sending', 388 | cancellable: true, 389 | }, async (progress, token) => { 390 | try { 391 | const files = await vscode.workspace.findFiles(includeFiles, excludeFiles); 392 | 393 | // If there are no files matching the given include and exclude patterns, 394 | // let the user know about it. 395 | if (!files.length) { 396 | const msg = 'No files selected for download. Please check the ev3devBrowser.download.include and ev3devBrowser.download.exclude settings.'; 397 | // try to make it easy for the user to fix the problem by offering to 398 | // open the settings editor 399 | const openSettings = 'Open Settings'; 400 | vscode.window.showErrorMessage(msg, openSettings).then(result => { 401 | if (result === openSettings) { 402 | vscode.commands.executeCommand('workbench.action.openSettings2'); 403 | } 404 | }); 405 | 406 | // "cancel" the download 407 | return false; 408 | } 409 | 410 | const increment = 100 / files.length; 411 | let fileIndex = 1; 412 | const reportProgress = (message: string) => progress.report({ message: message }); 413 | 414 | for (const f of files) { 415 | if (token.isCancellationRequested) { 416 | ev3devBrowserProvider.fireDeviceChanged(); 417 | return false; 418 | } 419 | 420 | const relativePath = vscode.workspace.asRelativePath(f, false); 421 | const baseProgressMessage = `(${fileIndex}/${files.length}) ${relativePath}`; 422 | reportProgress(baseProgressMessage); 423 | 424 | const basename = path.basename(f.fsPath); 425 | let relativeDir = path.dirname(relativePath); 426 | if (path === path.win32) { 427 | relativeDir = relativeDir.replace(path.win32.sep, path.posix.sep); 428 | } 429 | const remoteDir = path.posix.join(remoteBaseDir, relativeDir); 430 | const remotePath = path.posix.resolve(remoteDir, basename); 431 | 432 | // File permission handling: 433 | // - If the file starts with a shebang, then assume it should be 434 | // executable. 435 | // - Otherwise use the existing file permissions. On Windows 436 | // we also check for ELF file format to know if a file 437 | // should be executable since Windows doesn't know about 438 | // POSIX file permissions. 439 | let mode: string; 440 | if (await verifyFileHeader(f.fsPath, Buffer.from('#!/'))) { 441 | mode = '755'; 442 | // Bash will fail to execute a file when the shebang line 443 | // contains Windows line endings because it treats \r as 444 | // text rather than a line break. 445 | await normalizeLineEndings(f.fsPath); 446 | } 447 | else { 448 | const stat = fs.statSync(f.fsPath); 449 | if (process.platform === 'win32') { 450 | // fs.stat() on win32 return something like '100666' 451 | // See https://github.com/joyent/libuv/blob/master/src/win/fs.c 452 | // and search for `st_mode` 453 | 454 | // So, we check to see the file uses ELF format, if 455 | // so, make it executable. 456 | if (await verifyFileHeader(f.fsPath, Buffer.from('\x7fELF'))) { 457 | stat.mode |= S_IXUSR; 458 | } 459 | } 460 | mode = stat.mode.toString(8); 461 | } 462 | 463 | // make sure the directory exists 464 | if (!device) { 465 | throw new Error("Lost connection"); 466 | } 467 | await device.mkdir_p(remoteDir); 468 | // then we can copy the file 469 | await device.put(f.fsPath, remotePath, mode, 470 | percentage => reportProgress(`${baseProgressMessage} - ${percentage}%`)); 471 | 472 | fileIndex++; 473 | progress.report({ increment: increment }); 474 | } 475 | // make sure any new files show up in the browser 476 | ev3devBrowserProvider.fireDeviceChanged(); 477 | 478 | vscode.window.showInformationMessage(`Download to ${deviceName} complete`); 479 | } 480 | catch (err) { 481 | vscode.window.showErrorMessage(`Error sending file: ${err.message}`); 482 | return false; 483 | } 484 | 485 | return true; 486 | }); 487 | } 488 | 489 | function refresh(): void { 490 | ev3devBrowserProvider.fireDeviceChanged(); 491 | } 492 | 493 | class Ev3devBrowserProvider extends vscode.Disposable implements vscode.TreeDataProvider { 494 | private _onDidChangeTreeData: vscode.EventEmitter = 495 | new vscode.EventEmitter(); 496 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 497 | private device: DeviceTreeItem | undefined; 498 | private readonly noDeviceTreeItem = new CommandTreeItem('Click here to connect to a device', 'ev3devBrowser.action.pickDevice'); 499 | 500 | constructor() { 501 | super(() => { 502 | this.setDevice(undefined); 503 | }); 504 | } 505 | 506 | public setDevice(device: Device | undefined): void { 507 | if ((this.device && this.device.device) === device) { 508 | return; 509 | } 510 | if (this.device) { 511 | this.device.device.disconnect(); 512 | this.device = undefined; 513 | } 514 | if (device) { 515 | this.device = new DeviceTreeItem(device); 516 | } 517 | this.fireDeviceChanged(); 518 | } 519 | 520 | /** 521 | * Gets the current device. 522 | * 523 | * Will prompt the user to select a device if there is not one already connected 524 | */ 525 | public async getDevice(): Promise { 526 | if (!this.device) { 527 | const connectNow = 'Connect Now'; 528 | const result = await vscode.window.showErrorMessage('No ev3dev device is connected.', connectNow); 529 | if (result === connectNow) { 530 | await pickDevice(); 531 | } 532 | } 533 | return this.device && this.device.device; 534 | } 535 | 536 | /** 537 | * Gets the current device or undefined if no device is connected. 538 | */ 539 | public getDeviceSync(): Device | undefined { 540 | return this.device && this.device.device; 541 | } 542 | 543 | public getDeviceTreeItem(): DeviceTreeItem | undefined { 544 | return this.device; 545 | } 546 | 547 | public getTreeItem(element: DeviceTreeItem | File | CommandTreeItem): vscode.TreeItem { 548 | return element; 549 | } 550 | 551 | public getChildren(element?: DeviceTreeItem | File | CommandTreeItem): vscode.ProviderResult<(DeviceTreeItem | File | CommandTreeItem)[]> { 552 | if (!element) { 553 | return [this.device || this.noDeviceTreeItem]; 554 | } 555 | if (element instanceof DeviceTreeItem) { 556 | // should always have element.rootDirectory - included in if statement just for type checking 557 | if (element.device.isConnected && element.rootDirectory) { 558 | return [element.statusItem, element.rootDirectory]; 559 | } 560 | return []; 561 | } 562 | if (element instanceof DeviceStatusTreeItem) { 563 | return element.children; 564 | } 565 | if (element instanceof File) { 566 | return element.getFiles(); 567 | } 568 | return []; 569 | } 570 | 571 | public fireDeviceChanged(): void { 572 | // not sure why, but if we pass device to fire(), vscode does not call 573 | // back to getTreeItem(), so we are refreshing the entire tree for now 574 | this._onDidChangeTreeData.fire(); 575 | } 576 | 577 | public fireFileChanged(file: File | undefined): void { 578 | this._onDidChangeTreeData.fire(file); 579 | } 580 | 581 | public fireStatusChanged(status: DeviceStatusTreeItem) { 582 | this._onDidChangeTreeData.fire(status); 583 | } 584 | } 585 | 586 | /** 587 | * Possible states for a Device. 588 | * 589 | * These are used for the tree view context value. 590 | */ 591 | enum DeviceState { 592 | Disconnected = 'ev3devBrowser.device.disconnected', 593 | Connecting = 'ev3devBrowser.device.connecting', 594 | Connected = 'ev3devBrowser.device.connected' 595 | } 596 | 597 | class DeviceTreeItem extends vscode.TreeItem { 598 | public rootDirectory: File | undefined; 599 | public statusItem: DeviceStatusTreeItem; 600 | 601 | constructor(public readonly device: Device) { 602 | super(device.name); 603 | this.command = { command: 'ev3devBrowser.deviceTreeItem.select', title: '', arguments: [this] }; 604 | device.onWillConnect(() => this.handleConnectionState(DeviceState.Connecting)); 605 | device.onDidConnect(() => this.handleConnectionState(DeviceState.Connected)); 606 | device.onDidDisconnect(() => this.handleConnectionState(DeviceState.Disconnected)); 607 | if (device.isConnecting) { 608 | this.handleConnectionState(DeviceState.Connecting); 609 | } 610 | else if (device.isConnected) { 611 | this.handleConnectionState(DeviceState.Connected); 612 | } 613 | else { 614 | this.handleConnectionState(DeviceState.Disconnected); 615 | } 616 | this.statusItem = new DeviceStatusTreeItem(device); 617 | } 618 | 619 | private handleConnectionState(state: DeviceState): void { 620 | this.contextValue = state; 621 | setContext('ev3devBrowser.context.connected', false); 622 | this.collapsibleState = vscode.TreeItemCollapsibleState.None; 623 | this.rootDirectory = undefined; 624 | let icon: string | undefined; 625 | 626 | switch (state) { 627 | case DeviceState.Connecting: 628 | icon = 'yellow-circle.svg'; 629 | break; 630 | case DeviceState.Connected: 631 | setContext('ev3devBrowser.context.connected', true); 632 | this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 633 | this.rootDirectory = new File(this.device, undefined, '', { 634 | filename: this.device.homeDirectoryPath, 635 | longname: '', 636 | attrs: this.device.homeDirectoryAttr 637 | }); 638 | this.rootDirectory.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 639 | icon = 'green-circle.svg'; 640 | this.statusItem.connectBrickd(); 641 | break; 642 | case DeviceState.Disconnected: 643 | icon = 'red-circle.svg'; 644 | break; 645 | } 646 | 647 | if (icon) { 648 | this.iconPath = { 649 | dark: path.join(resourceDir, 'icons', 'dark', icon), 650 | light: path.join(resourceDir, 'icons', 'light', icon), 651 | }; 652 | } 653 | else { 654 | this.iconPath = undefined; 655 | } 656 | 657 | ev3devBrowserProvider.fireDeviceChanged(); 658 | } 659 | 660 | public handleClick(): void { 661 | // Attempt to keep he collapsible state correct. If we don't do this, 662 | // strange things happen on a refresh. 663 | switch (this.collapsibleState) { 664 | case vscode.TreeItemCollapsibleState.Collapsed: 665 | this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 666 | break; 667 | case vscode.TreeItemCollapsibleState.Expanded: 668 | this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; 669 | break; 670 | } 671 | } 672 | 673 | public openSshTerminal(): void { 674 | const config = vscode.workspace.getConfiguration(`terminal.integrated.env.${getPlatform()}`); 675 | const termEnv = config.get('TERM'); 676 | this.device.shell({ term: termEnv || process.env['TERM'] || 'xterm-256color' }).then(ch => { 677 | const writeEmitter = new vscode.EventEmitter(); 678 | ch.stdout.on('data', (data: string | Buffer) => writeEmitter.fire(String(data))); 679 | ch.stderr.on('data', (data: string | Buffer) => writeEmitter.fire(String(data))); 680 | const term = vscode.window.createTerminal({ 681 | name: `SSH: ${this.label}`, 682 | pty: { 683 | onDidWrite: writeEmitter.event, 684 | open: (dim: vscode.TerminalDimensions | undefined) => { 685 | if (dim !== undefined) { 686 | ch.setWindow(dim.rows, dim.columns, 0, 0); 687 | } 688 | }, 689 | close: () => { 690 | ch.close(); 691 | }, 692 | handleInput: (data: string) => { 693 | ch.stdin.write(data); 694 | }, 695 | setDimensions: (dim: vscode.TerminalDimensions) => { 696 | ch.setWindow(dim.rows, dim.columns, 0, 0); 697 | }, 698 | }, 699 | }); 700 | ch.on('close', () => { 701 | term.dispose(); 702 | ch.destroy(); 703 | }); 704 | ch.on('error', (err: any) => { 705 | vscode.window.showErrorMessage(`SSH connection error: ${err || err.message}`); 706 | term.dispose(); 707 | ch.destroy(); 708 | }); 709 | term.show(); 710 | }).catch(err => { 711 | vscode.window.showErrorMessage(`Failed to create SSH terminal: ${err || err.message}`); 712 | }); 713 | } 714 | 715 | public async captureScreenshot(): Promise { 716 | vscode.window.withProgress({ 717 | location: vscode.ProgressLocation.Window, 718 | title: "Capturing screenshot..." 719 | }, progress => { 720 | return new Promise(async (resolve, reject) => { 721 | const handleCaptureError = (e: any) => { 722 | vscode.window.showErrorMessage("Error capturing screenshot: " + (e.message || e)); 723 | reject(); 724 | }; 725 | 726 | try { 727 | const screenshotDirectory = await getSharedTempDir('ev3dev-screenshots'); 728 | const screenshotBaseName = `ev3dev-${sanitizedDateString()}.png`; 729 | const screenshotFile = `${screenshotDirectory}/${screenshotBaseName}`; 730 | 731 | const conn = await this.device.exec('fbgrab -'); 732 | const writeStream = fs.createWriteStream(screenshotFile); 733 | 734 | conn.on('error', (e: Error) => { 735 | writeStream.removeAllListeners('finish'); 736 | handleCaptureError(e); 737 | }); 738 | 739 | writeStream.on('open', () => { 740 | conn.stdout.pipe(writeStream); 741 | }); 742 | 743 | writeStream.on('error', (e: Error) => { 744 | vscode.window.showErrorMessage("Error saving screenshot: " + e.message); 745 | reject(); 746 | }); 747 | 748 | writeStream.on('finish', async () => { 749 | const pngHeader = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; 750 | if (await verifyFileHeader(screenshotFile, pngHeader)) { 751 | toastStatusBarMessage("Screenshot captured"); 752 | resolve(); 753 | vscode.commands.executeCommand('vscode.open', vscode.Uri.file(screenshotFile), vscode.ViewColumn.Two); 754 | } 755 | else { 756 | handleCaptureError("The screenshot was not in the correct format. You may need to upgrade to fbcat 0.5.0."); 757 | } 758 | }); 759 | } 760 | catch (e) { 761 | handleCaptureError(e); 762 | } 763 | }); 764 | }); 765 | } 766 | 767 | public async showSysinfo() { 768 | try { 769 | output.clear(); 770 | output.show(); 771 | output.appendLine('========== ev3dev-sysinfo =========='); 772 | await vscode.window.withProgress({ 773 | location: vscode.ProgressLocation.Window, 774 | title: 'Grabbing ev3dev system info...' 775 | }, async progress => { 776 | const [stdout, stderr] = await this.device.createExecObservable('ev3dev-sysinfo'); 777 | await Promise.all([ 778 | stdout.forEach(v => output.appendLine(v)), 779 | stderr.forEach(v => output.appendLine(v)) 780 | ]); 781 | }); 782 | 783 | toastStatusBarMessage('System info retrieved'); 784 | } 785 | catch (err) { 786 | vscode.window.showErrorMessage('An error occurred while getting system info: ' + (err.message || err)); 787 | } 788 | } 789 | 790 | public async connect(): Promise { 791 | try { 792 | await vscode.window.withProgress({ 793 | location: vscode.ProgressLocation.Window, 794 | title: `Connecting to ${this.label}` 795 | }, async progress => { 796 | await this.device.connect(); 797 | }); 798 | toastStatusBarMessage(`Connected to ${this.label}`); 799 | } 800 | catch (err) { 801 | const troubleshoot = 'Troubleshoot'; 802 | vscode.window.showErrorMessage(`Failed to connect to ${this.label}: ${err.message}`, troubleshoot) 803 | .then((value) => { 804 | if (value === troubleshoot) { 805 | const wiki = vscode.Uri.parse('https://github.com/ev3dev/vscode-ev3dev-browser/wiki/Troubleshooting') 806 | vscode.commands.executeCommand('vscode.open', wiki); 807 | } 808 | }); 809 | } 810 | } 811 | 812 | public disconnect(): void { 813 | this.device.disconnect(); 814 | } 815 | } 816 | 817 | /** 818 | * File states are used for the context value of a File. 819 | */ 820 | enum FileState { 821 | None = 'ev3devBrowser.file', 822 | Folder = 'ev3devBrowser.file.folder', 823 | RootFolder = 'ev3devBrowser.file.folder.root', 824 | Executable = 'ev3devBrowser.file.executable' 825 | } 826 | 827 | class File extends vscode.TreeItem { 828 | private fileCache: File[] = new Array(); 829 | readonly path: string; 830 | readonly isExecutable: boolean; 831 | readonly isDirectory: boolean; 832 | 833 | constructor(public device: Device, public parent: File | undefined, directory: string, 834 | private fileInfo: ssh2Streams.FileEntry) { 835 | super(fileInfo.filename); 836 | // work around bad typescript bindings 837 | const stats = (fileInfo.attrs); 838 | this.path = directory + fileInfo.filename; 839 | this.isExecutable = stats.isFile() && !!(stats.mode & S_IXUSR); 840 | this.isDirectory = stats.isDirectory(); 841 | if (this.isDirectory) { 842 | this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; 843 | if (this.parent) { 844 | this.contextValue = FileState.Folder; 845 | } 846 | else { 847 | this.contextValue = FileState.RootFolder; 848 | } 849 | } 850 | else if (this.isExecutable) { 851 | this.contextValue = FileState.Executable; 852 | } 853 | else { 854 | this.contextValue = FileState.None; 855 | } 856 | this.command = { command: 'ev3devBrowser.fileTreeItem.select', title: '', arguments: [this] }; 857 | } 858 | 859 | private createOrUpdate(device: Device, directory: string, fileInfo: any): File { 860 | const path = directory + fileInfo.filename; 861 | const match = this.fileCache.find(f => f.path === path); 862 | if (match) { 863 | match.fileInfo = fileInfo; 864 | return match; 865 | } 866 | const file = new File(device, this, directory, fileInfo); 867 | this.fileCache.push(file); 868 | return file; 869 | } 870 | 871 | private static compare(a: File, b: File): number { 872 | // directories go first 873 | if (a.isDirectory && !b.isDirectory) { 874 | return -1; 875 | } 876 | if (!a.isDirectory && b.isDirectory) { 877 | return 1; 878 | } 879 | 880 | // then sort in ASCII order 881 | return a.path < b.path ? -1 : +(a.path > b.path); 882 | } 883 | 884 | getFiles(): vscode.ProviderResult { 885 | return new Promise((resolve, reject) => { 886 | this.device.ls(this.path).then(list => { 887 | const files = new Array(); 888 | if (list) { 889 | list.forEach(element => { 890 | // skip hidden files 891 | if (element.filename[0] !== '.') { 892 | const file = this.createOrUpdate(this.device, this.path + "/", element); 893 | files.push(file); 894 | } 895 | }, this); 896 | } 897 | // sort directories first, then by ASCII 898 | files.sort(File.compare); 899 | resolve(files); 900 | this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 901 | }, err => { 902 | reject(err); 903 | }); 904 | }); 905 | } 906 | 907 | public handleClick(): void { 908 | // keep track of state so that it is preserved during refresh 909 | if (this.collapsibleState === vscode.TreeItemCollapsibleState.Expanded) { 910 | this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; 911 | // This causes us to refresh the files each time the directory is collapsed 912 | this.fileCache.length = 0; 913 | ev3devBrowserProvider.fireFileChanged(this); 914 | } 915 | 916 | // Show a quick-pick to allow users to run an executable program. 917 | if (this.isExecutable) { 918 | const runItem = { 919 | label: 'Run', 920 | description: this.path 921 | }; 922 | const runInTerminalItem = { 923 | label: 'Run in interactive terminal', 924 | description: this.path 925 | }; 926 | vscode.window.showQuickPick([runItem, runInTerminalItem]).then(value => { 927 | switch (value) { 928 | case runItem: 929 | this.run(); 930 | break; 931 | case runInTerminalItem: 932 | this.runInTerminal(); 933 | break; 934 | } 935 | }); 936 | } 937 | } 938 | 939 | public run(): void { 940 | vscode.debug.startDebugging(undefined, { 941 | type: 'ev3devBrowser', 942 | name: 'Run', 943 | request: 'launch', 944 | program: this.path, 945 | download: false, 946 | interactiveTerminal: false, 947 | }); 948 | } 949 | 950 | public runInTerminal(): void { 951 | vscode.debug.startDebugging(undefined, { 952 | type: 'ev3devBrowser', 953 | name: 'Run in interactive terminal', 954 | request: 'launch', 955 | program: this.path, 956 | download: false, 957 | interactiveTerminal: true, 958 | }); 959 | } 960 | 961 | public delete(): void { 962 | vscode.window.withProgress({ 963 | location: vscode.ProgressLocation.Window, 964 | title: `Deleting '${this.path}'` 965 | }, async progress => { 966 | try { 967 | const config = vscode.workspace.getConfiguration('ev3devBrowser'); 968 | const confirm = config.get('confirmDelete'); 969 | if (confirm) { 970 | const deleteItem = "Delete"; 971 | const dontShowAgainItem = "Don't show this again"; 972 | const result = await vscode.window.showInformationMessage( 973 | `Are you sure you want to delete '${this.path}'? This cannot be undone.`, 974 | deleteItem, dontShowAgainItem); 975 | if (!result) { 976 | return; 977 | } 978 | else if (result === dontShowAgainItem) { 979 | config.update('confirmDelete', false, vscode.ConfigurationTarget.Global); 980 | } 981 | } 982 | await this.device.rm_rf(this.path); 983 | ev3devBrowserProvider.fireFileChanged(this.parent); 984 | toastStatusBarMessage(`Deleted '${this.path}'`); 985 | } 986 | catch (err) { 987 | vscode.window.showErrorMessage(`Error deleting '${this.path}': ${err.message}`); 988 | } 989 | }); 990 | } 991 | 992 | public async showInfo(): Promise { 993 | output.clear(); 994 | output.show(); 995 | output.appendLine('Getting file info...'); 996 | output.appendLine(''); 997 | try { 998 | let [stdout, stderr] = await this.device.createExecObservable(`/bin/ls -lh "${this.path}"`); 999 | await Promise.all([ 1000 | stdout.forEach(line => output.appendLine(line)), 1001 | stderr.forEach(line => output.appendLine(line)) 1002 | ]); 1003 | output.appendLine(''); 1004 | [stdout, stderr] = await this.device.createExecObservable(`/usr/bin/file "${this.path}"`); 1005 | await Promise.all([ 1006 | stdout.forEach(line => output.appendLine(line)), 1007 | stderr.forEach(line => output.appendLine(line)) 1008 | ]); 1009 | } 1010 | catch (err) { 1011 | output.appendLine(`Error: ${err.message}`); 1012 | } 1013 | } 1014 | 1015 | public async upload(): Promise { 1016 | const basename = path.posix.basename(this.path); 1017 | const result = await vscode.window.showSaveDialog({ 1018 | defaultUri: vscode.Uri.file(path.join(config.uploadDir, basename)) 1019 | }); 1020 | 1021 | if (!result) { 1022 | return; 1023 | } 1024 | 1025 | await vscode.window.withProgress({ 1026 | location: vscode.ProgressLocation.Window, 1027 | title: 'Uploading' 1028 | }, async progress => { 1029 | await this.device.get(this.path, result.fsPath, percentage => { 1030 | progress.report({ message: `${this.path} - ${percentage}%` }); 1031 | }); 1032 | }); 1033 | config.uploadDir = path.dirname(result.fsPath); 1034 | } 1035 | } 1036 | 1037 | /** 1038 | * A tree view item that runs a command when clicked. 1039 | */ 1040 | class CommandTreeItem extends vscode.TreeItem { 1041 | constructor(label: string, command: string | undefined) { 1042 | super(label); 1043 | if (command) { 1044 | this.command = { 1045 | command: command, 1046 | title: '' 1047 | }; 1048 | } 1049 | } 1050 | } 1051 | 1052 | class DeviceStatusTreeItem extends CommandTreeItem { 1053 | private readonly defaultBatteryLabel = "Battery: N/A"; 1054 | public children = new Array(); 1055 | private batteryItem = new CommandTreeItem(this.defaultBatteryLabel, undefined); 1056 | private brickd: Brickd | undefined; 1057 | 1058 | public constructor(private device: Device) { 1059 | super("Status", undefined); 1060 | this.children.push(this.batteryItem); 1061 | this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 1062 | } 1063 | 1064 | public async connectBrickd() { 1065 | if (this.brickd) { 1066 | this.brickd.removeAllListeners(); 1067 | this.brickd = undefined; 1068 | this.batteryItem.label = this.defaultBatteryLabel; 1069 | } 1070 | try { 1071 | this.brickd = await this.device.brickd(); 1072 | this.brickd.on('message', message => { 1073 | const [m1, ...m2] = message.split(' '); 1074 | switch (m1) { 1075 | case 'WARN': 1076 | case 'CRITICAL': 1077 | vscode.window.showWarningMessage(`${this.device.name}: ${m2.join(' ')}`); 1078 | break; 1079 | case 'PROPERTY': 1080 | switch (m2[0]) { 1081 | case "system.battery.voltage": 1082 | const voltage = Number(m2[1]) / 1000; 1083 | this.batteryItem.label = `Battery: ${voltage.toFixed(2)}V`; 1084 | ev3devBrowserProvider.fireStatusChanged(this); 1085 | } 1086 | break; 1087 | } 1088 | }); 1089 | this.brickd.on('error', err => { 1090 | vscode.window.showErrorMessage(`${this.device.name}: ${err.message}`); 1091 | }); 1092 | this.brickd.on('ready', () => { 1093 | if (!this.brickd) { 1094 | return; 1095 | } 1096 | // serialNumber is used elsewhere, so tack it on to the device object 1097 | (this.device)['serialNumber'] = this.brickd.serialNumber; 1098 | }); 1099 | } 1100 | catch (err) { 1101 | vscode.window.showWarningMessage('Failed to get brickd connection. No status will be available.'); 1102 | return; 1103 | } 1104 | } 1105 | } 1106 | 1107 | /** 1108 | * Wrapper around vscode.ExtensionContext.workspaceState 1109 | */ 1110 | class WorkspaceConfig { 1111 | constructor(private state: vscode.Memento) { 1112 | } 1113 | 1114 | /** 1115 | * Gets or sets the upload directory for the current workspace. 1116 | */ 1117 | get uploadDir(): string { 1118 | return this.state.get('uploadDir', os.homedir()); 1119 | } 1120 | 1121 | set uploadDir(value: string) { 1122 | this.state.update('uploadDir', value); 1123 | } 1124 | } 1125 | --------------------------------------------------------------------------------