project rename

This commit is contained in:
Eugene Pankov
2021-06-29 23:57:04 +02:00
parent c61be3d52b
commit 43cd3318da
609 changed files with 510 additions and 530 deletions

0
tabby-serial/.gitignore vendored Normal file
View File

40
tabby-serial/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "tabby-serial",
"version": "1.0.140",
"description": "Serial connection manager for Tabby",
"keywords": [
"tabby-builtin-plugin"
],
"main": "dist/index.js",
"typings": "typings/index.d.ts",
"scripts": {
"build": "webpack --progress --color",
"watch": "webpack --progress --color --watch"
},
"files": [
"dist"
],
"author": "Eugene Pankov",
"license": "MIT",
"devDependencies": {
"@types/node": "14.14.14",
"ansi-colors": "^4.1.1",
"binstring": "^0.2.1",
"buffer-replace": "^1.0.0",
"cli-spinner": "^0.2.10",
"hexer": "^1.5.0"
},
"peerDependencies": {
"@angular/animations": "^9.1.9",
"@angular/common": "^9.1.11",
"@angular/core": "^9.1.9",
"@angular/forms": "^9.1.11",
"@angular/platform-browser": "^9.1.11",
"@angular/platform-browser-dynamic": "^9.1.11",
"@ng-bootstrap/ng-bootstrap": "^6.2.0",
"rxjs": "^6.6.3",
"tabby-core": "*",
"tabby-settings": "*",
"tabby-terminal": "*"
}
}

278
tabby-serial/src/api.ts Normal file
View File

@@ -0,0 +1,278 @@
import hexdump from 'hexer'
import colors from 'ansi-colors'
import binstring from 'binstring'
import stripAnsi from 'strip-ansi'
import bufferReplace from 'buffer-replace'
import { BaseSession } from 'tabby-terminal'
import { SerialPort } from 'serialport'
import { Logger } from 'tabby-core'
import { Subject, Observable, interval } from 'rxjs'
import { debounce } from 'rxjs/operators'
import { ReadLine, createInterface as createReadline, clearLine } from 'readline'
import { PassThrough, Readable, Writable } from 'stream'
export interface LoginScript {
expect: string
send: string
isRegex?: boolean
optional?: boolean
}
export interface SerialConnection {
name: string
port: string
baudrate: number
databits: number
stopbits: number
parity: string
rtscts: boolean
xon: boolean
xoff: boolean
xany: boolean
scripts?: LoginScript[]
color?: string
inputMode?: InputMode
inputNewlines?: NewlineMode
outputMode?: OutputMode
outputNewlines?: NewlineMode
}
export const BAUD_RATES = [
110, 150, 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1500000,
]
export interface SerialPortInfo {
name: string
description?: string
}
export type InputMode = null | 'readline' | 'readline-hex' // eslint-disable-line @typescript-eslint/no-type-alias
export type OutputMode = null | 'hex' // eslint-disable-line @typescript-eslint/no-type-alias
export type NewlineMode = null | 'cr' | 'lf' | 'crlf' // eslint-disable-line @typescript-eslint/no-type-alias
export class SerialSession extends BaseSession {
scripts?: LoginScript[]
serial: SerialPort
logger: Logger
get serviceMessage$ (): Observable<string> { return this.serviceMessage }
private serviceMessage = new Subject<string>()
private inputReadline: ReadLine
private inputPromptVisible = true
private inputReadlineInStream: Readable & Writable
private inputReadlineOutStream: Readable & Writable
constructor (public connection: SerialConnection) {
super()
this.scripts = connection.scripts ?? []
this.inputReadlineInStream = new PassThrough()
this.inputReadlineOutStream = new PassThrough()
this.inputReadline = createReadline({
input: this.inputReadlineInStream,
output: this.inputReadlineOutStream,
terminal: true,
prompt: this.connection.inputMode === 'readline-hex' ? 'hex> ' : '> ',
} as any)
this.inputReadlineOutStream.on('data', data => {
this.emitOutput(Buffer.from(data))
})
this.inputReadline.on('line', line => {
this.onInput(Buffer.from(line + '\n'))
this.resetInputPrompt()
})
this.output$.pipe(debounce(() => interval(500))).subscribe(() => this.onOutputSettled())
}
async start (): Promise<void> {
this.open = true
this.serial.on('readable', () => {
this.onOutput(this.serial.read())
})
this.serial.on('end', () => {
this.logger.info('Shell session ended')
if (this.open) {
this.destroy()
}
})
this.executeUnconditionalScripts()
}
write (data: Buffer): void {
if (this.connection.inputMode?.startsWith('readline')) {
this.inputReadlineInStream.write(data)
} else {
this.onInput(data)
}
}
async destroy (): Promise<void> {
this.serviceMessage.complete()
this.inputReadline.close()
await super.destroy()
}
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-empty-function
resize (_, __) {
this.inputReadlineOutStream.emit('resize')
}
kill (_?: string): void {
this.serial.close()
}
emitServiceMessage (msg: string): void {
this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg))
}
async getChildProcesses (): Promise<any[]> {
return []
}
async gracefullyKillProcess (): Promise<void> {
this.kill('TERM')
}
supportsWorkingDirectory (): boolean {
return false
}
async getWorkingDirectory (): Promise<string|null> {
return null
}
private replaceNewlines (data: Buffer, mode?: NewlineMode): Buffer {
if (!mode) {
return data
}
data = bufferReplace(data, '\r\n', '\n')
data = bufferReplace(data, '\r', '\n')
const replacement = {
strip: '',
cr: '\r',
lf: '\n',
crlf: '\r\n',
}[mode]
return bufferReplace(data, '\n', replacement)
}
private onInput (data: Buffer) {
if (this.connection.inputMode === 'readline-hex') {
const tokens = data.toString().split(/\s/g)
data = Buffer.concat(tokens.filter(t => !!t).map(t => {
if (t.startsWith('0x')) {
t = t.substring(2)
}
return binstring(t, { 'in': 'hex' })
}))
}
data = this.replaceNewlines(data, this.connection.inputNewlines)
if (this.serial) {
this.serial.write(data.toString())
}
}
private onOutputSettled () {
if (this.connection.inputMode?.startsWith('readline') && !this.inputPromptVisible) {
this.resetInputPrompt()
}
}
private resetInputPrompt () {
this.emitOutput(Buffer.from('\r\n'))
this.inputReadline.prompt(true)
this.inputPromptVisible = true
}
private onOutput (data: Buffer) {
const dataString = data.toString()
if (this.connection.inputMode?.startsWith('readline')) {
if (this.inputPromptVisible) {
clearLine(this.inputReadlineOutStream, 0)
this.inputPromptVisible = false
}
}
data = this.replaceNewlines(data, this.connection.outputNewlines)
if (this.connection.outputMode === 'hex') {
this.emitOutput(Buffer.concat([
Buffer.from('\r\n'),
Buffer.from(hexdump(data, {
group: 1,
gutter: 4,
divide: colors.gray(' '),
emptyHuman: colors.gray(''),
}).replace(/\n/g, '\r\n')),
Buffer.from('\r\n\n'),
]))
} else {
this.emitOutput(data)
}
if (this.scripts) {
let found = false
for (const script of this.scripts) {
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (re.test(dataString)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.serial.write(cmd + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
if (found) {
this.executeUnconditionalScripts()
}
}
}
private executeUnconditionalScripts () {
if (this.scripts) {
for (const script of this.scripts) {
if (!script.expect) {
console.log('Executing script:', script.send)
this.serial.write(script.send + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
}
}
export interface SerialConnectionGroup {
name: string
connections: SerialConnection[]
}

View File

@@ -0,0 +1,36 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Injectable, Injector } from '@angular/core'
import { HotkeysService, ToolbarButtonProvider, ToolbarButton } from 'tabby-core'
import { SerialService } from './services/serial.service'
/** @hidden */
@Injectable()
export class ButtonProvider extends ToolbarButtonProvider {
constructor (
private injector: Injector,
hotkeys: HotkeysService,
) {
super()
hotkeys.matchedHotkey.subscribe(async (hotkey: string) => {
if (hotkey === 'serial') {
this.activate()
}
})
}
activate () {
this.injector.get(SerialService).showConnectionSelector()
}
provide (): ToolbarButton[] {
return [{
icon: require('./icons/serial.svg'),
weight: 5,
title: 'Serial connections',
touchBarNSImage: 'NSTouchBarOpenInBrowserTemplate',
click: () => {
this.activate()
},
}]
}
}

30
tabby-serial/src/cli.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core'
import { CLIHandler, CLIEvent, ConfigService } from 'tabby-core'
import { SerialService } from './services/serial.service'
@Injectable()
export class SerialCLIHandler extends CLIHandler {
firstMatchOnly = true
priority = 0
constructor (
private serial: SerialService,
private config: ConfigService,
) {
super()
}
async handle (event: CLIEvent): Promise<boolean> {
const op = event.argv._[0]
if (op === 'connect-serial') {
const connection = this.config.store.serial.connections.find(x => x.name === event.argv.connectionName)
if (connection) {
this.serial.connect(connection)
}
return true
}
return false
}
}

View File

@@ -0,0 +1,200 @@
.modal-body
ul.nav-tabs(ngbNav, #nav='ngbNav')
li(ngbNavItem)
a(ngbNavLink) General
ng-template(ngbNavContent)
.form-group
label Name
input.form-control(
type='text',
autofocus,
[(ngModel)]='connection.name',
)
.row
.col-6
.form-group
label Path
input.form-control(
type='text',
[(ngModel)]='connection.port',
[ngbTypeahead]='portsAutocomplete',
[resultFormatter]='portsFormatter',
)
.col-6
.form-group
label Baud Rate
input.form-control(
type='number',
[(ngModel)]='connection.baudrate',
[ngbTypeahead]='baudratesAutocomplete',
)
.row
.col-6
.form-line
.header
.title Input mode
.d-flex(ngbDropdown)
button.btn.btn-secondary.btn-tab-bar(
ngbDropdownToggle,
) {{getInputModeName(connection.inputMode)}}
div(ngbDropdownMenu)
a.d-flex.flex-column(
*ngFor='let mode of inputModes',
(click)='connection.inputMode = mode.key',
ngbDropdownItem
)
div {{mode.name}}
.text-muted {{mode.description}}
.col-6
.form-line
.header
.title Input newlines
select.form-control(
[(ngModel)]='connection.inputNewlines',
)
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
.row
.col-6
.form-line
.header
.title Output mode
.d-flex(ngbDropdown)
button.btn.btn-secondary.btn-tab-bar(
ngbDropdownToggle,
) {{getOutputModeName(connection.outputMode)}}
div(ngbDropdownMenu)
a.d-flex.flex-column(
*ngFor='let mode of outputModes',
(click)='connection.outputMode = mode.key',
ngbDropdownItem
)
div {{mode.name}}
.text-muted {{mode.description}}
.col-6
.form-line
.header
.title Output newlines
select.form-control(
[(ngModel)]='connection.outputNewlines',
)
option([ngValue]='mode.key', *ngFor='let mode of newlineModes') {{mode.name}}
li(ngbNavItem)
a(ngbNavLink) Advanced
ng-template(ngbNavContent)
.form-line
.header
.title Tab color
input.form-control(
type='text',
autofocus,
[(ngModel)]='connection.color',
placeholder='#000000'
)
.form-line
.header
.title DataBits
input.form-control(
type='number',
placeholder='8',
[(ngModel)]='connection.databits',
)
.form-line
.header
.title StopBits
input.form-control(
type='number',
placeholder='1',
[(ngModel)]='connection.stopbits',
)
.form-line
.header
.title Parity
input.form-control(
type='text',
[(ngModel)]='connection.parity',
placeholder='none'
)
.form-line
.header
.title RTSCTS
toggle([(ngModel)]='connection.rtscts')
.form-line
.header
.title Xon
toggle([(ngModel)]='connection.xon')
.form-line
.header
.title Xoff
toggle([(ngModel)]='connection.xoff')
.form-line
.header
.title Xany
toggle([(ngModel)]='connection.xany')
li(ngbNavItem)
a(ngbNavLink) Login scripts
ng-template(ngbNavContent)
table(*ngIf='connection.scripts.length > 0')
tr
th String to expect
th String to be sent
th.pl-2 Regex
th.pl-2 Optional
th.pl-2 Actions
tr(*ngFor='let script of connection.scripts')
td.pr-2
input.form-control(
type='text',
[(ngModel)]='script.expect'
)
td
input.form-control(
type='text',
[(ngModel)]='script.send'
)
td.pl-2
checkbox(
[(ngModel)]='script.isRegex',
)
td.pl-2
checkbox(
[(ngModel)]='script.optional',
)
td.pl-2
.input-group.flex-nowrap
button.btn.btn-outline-info.ml-0((click)='moveScriptUp(script)')
i.fas.fa-arrow-up
button.btn.btn-outline-info.ml-0((click)='moveScriptDown(script)')
i.fas.fa-arrow-down
button.btn.btn-outline-danger.ml-0((click)='deleteScript(script)')
i.fas.fa-trash
button.btn.btn-outline-info.mt-2((click)='addScript()')
i.fas.fa-plus
span New item
div([ngbNavOutlet]='nav')
.modal-footer
button.btn.btn-outline-primary((click)='save()') Save
button.btn.btn-outline-danger((click)='cancel()') Cancel

View File

@@ -0,0 +1,121 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
import { PlatformService } from 'tabby-core'
import { SerialConnection, LoginScript, SerialPortInfo, BAUD_RATES } from '../api'
import { SerialService } from '../services/serial.service'
/** @hidden */
@Component({
template: require('./editConnectionModal.component.pug'),
})
export class EditConnectionModalComponent {
connection: SerialConnection
foundPorts: SerialPortInfo[]
inputModes = [
{ key: null, name: 'Normal', description: 'Input is sent as you type' },
{ key: 'readline', name: 'Line by line', description: 'Line editor, input is sent after you press Enter' },
{ key: 'readline-hex', name: 'Hexadecimal', description: 'Send bytes by typing in hex values' },
]
outputModes = [
{ key: null, name: 'Normal', description: 'Output is shown as it is received' },
{ key: 'hex', name: 'Hexadecimal', description: 'Output is shown as a hexdump' },
]
newlineModes = [
{ key: null, name: 'Keep' },
{ key: 'strip', name: 'Strip' },
{ key: 'cr', name: 'Force CR' },
{ key: 'lf', name: 'Force LF' },
{ key: 'crlf', name: 'Force CRLF' },
]
constructor (
private modalInstance: NgbActiveModal,
private platform: PlatformService,
private serial: SerialService,
) {
}
getInputModeName (key) {
return this.inputModes.find(x => x.key === key)?.name
}
getOutputModeName (key) {
return this.outputModes.find(x => x.key === key)?.name
}
portsAutocomplete = text$ => text$.pipe(map(() => {
return this.foundPorts.map(x => x.name)
}))
baudratesAutocomplete = text$ => text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((q: string) => BAUD_RATES.filter(x => !q || x.toString().startsWith(q)))
)
portsFormatter = port => {
const p = this.foundPorts.find(x => x.name === port)
if (p?.description) {
return `${port} (${p.description})`
}
return port
}
async ngOnInit () {
this.connection.scripts = this.connection.scripts ?? []
this.foundPorts = await this.serial.listPorts()
}
save () {
this.modalInstance.close(this.connection)
}
cancel () {
this.modalInstance.dismiss()
}
moveScriptUp (script: LoginScript) {
if (!this.connection.scripts) {
this.connection.scripts = []
}
const index = this.connection.scripts.indexOf(script)
if (index > 0) {
this.connection.scripts.splice(index, 1)
this.connection.scripts.splice(index - 1, 0, script)
}
}
moveScriptDown (script: LoginScript) {
if (!this.connection.scripts) {
this.connection.scripts = []
}
const index = this.connection.scripts.indexOf(script)
if (index >= 0 && index < this.connection.scripts.length - 1) {
this.connection.scripts.splice(index, 1)
this.connection.scripts.splice(index + 1, 0, script)
}
}
async deleteScript (script: LoginScript) {
if (this.connection.scripts && (await this.platform.showMessageBox(
{
type: 'warning',
message: 'Delete this script?',
detail: script.expect,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.connection.scripts = this.connection.scripts.filter(x => x !== script)
}
}
addScript () {
if (!this.connection.scripts) {
this.connection.scripts = []
}
this.connection.scripts.push({ expect: '', send: '' })
}
}

View File

@@ -0,0 +1,16 @@
h3 Connections
.list-group.list-group-flush.mt-3.mb-3
.list-group-item.list-group-item-action.d-flex.align-items-center(
*ngFor='let connection of connections',
(click)='editConnection(connection)'
)
.mr-auto
div {{connection.name}}
.text-muted {{connection.port}}
button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteConnection(connection)')
i.fas.fa-trash
button.btn.btn-primary((click)='createConnection()')
i.fas.fa-fw.fa-plus
span.ml-2 Add connection

View File

@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Component } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ConfigService, PlatformService } from 'tabby-core'
import { SerialConnection } from '../api'
import { EditConnectionModalComponent } from './editConnectionModal.component'
/** @hidden */
@Component({
template: require('./serialSettingsTab.component.pug'),
})
export class SerialSettingsTabComponent {
connections: SerialConnection[]
constructor (
public config: ConfigService,
private platform: PlatformService,
private ngbModal: NgbModal,
) {
this.connections = this.config.store.serial.connections
this.refresh()
}
createConnection () {
const connection: SerialConnection = {
name: '',
port: '',
baudrate: 115200,
databits: 8,
parity: 'none',
rtscts: false,
stopbits: 1,
xany: false,
xoff: false,
xon: false,
inputMode: null,
outputMode: null,
inputNewlines: null,
outputNewlines: null,
}
const modal = this.ngbModal.open(EditConnectionModalComponent)
modal.componentInstance.connection = connection
modal.result.then(result => {
this.connections.push(result)
this.config.store.serial.connections = this.connections
this.config.save()
this.refresh()
})
}
editConnection (connection: SerialConnection) {
const modal = this.ngbModal.open(EditConnectionModalComponent, { size: 'lg' })
modal.componentInstance.connection = Object.assign({}, connection)
modal.result.then(result => {
Object.assign(connection, result)
this.config.store.serial.connections = this.connections
this.config.save()
this.refresh()
})
}
async deleteConnection (connection: SerialConnection) {
if ((await this.platform.showMessageBox(
{
type: 'warning',
message: `Delete "${connection.name}"?`,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.connections = this.connections.filter(x => x !== connection)
this.config.store.serial.connections = this.connections
this.config.save()
this.refresh()
}
}
refresh () {
this.connections = this.config.store.serial.connections
}
}

View File

@@ -0,0 +1,16 @@
.tab-toolbar([class.show]='!session || !session.open')
.btn.btn-outline-secondary.reveal-button
i.fas.fa-ellipsis-h
.toolbar
i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
strong {{connection.port}} ({{connection.baudrate}})
.mr-auto
button.btn.btn-secondary.mr-3((click)='changeBaudRate()', *ngIf='session && session.open')
span Change baud rate
button.btn.btn-info((click)='reconnect()', *ngIf='!session || !session.open')
i.fas.fa-reload
span Reconnect

View File

@@ -0,0 +1,7 @@
@import '../../../tabby-ssh/src/components/sshTab.component.scss';
:host {
select {
width: auto;
}
}

View File

@@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import colors from 'ansi-colors'
import { Spinner } from 'cli-spinner'
import { Component, Injector } from '@angular/core'
import { first } from 'rxjs/operators'
import { SelectorService } from 'tabby-core'
import { BaseTerminalTabComponent } from 'tabby-terminal'
import { SerialService } from '../services/serial.service'
import { SerialConnection, SerialSession, BAUD_RATES } from '../api'
/** @hidden */
@Component({
selector: 'serial-tab',
template: `${BaseTerminalTabComponent.template} ${require('./serialTab.component.pug')}`,
styles: [require('./serialTab.component.scss'), ...BaseTerminalTabComponent.styles],
animations: BaseTerminalTabComponent.animations,
})
export class SerialTabComponent extends BaseTerminalTabComponent {
connection?: SerialConnection
session: SerialSession|null = null
serialPort: any
private serialService: SerialService
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor (
injector: Injector,
private selector: SelectorService,
) {
super(injector)
this.serialService = injector.get(SerialService)
}
ngOnInit () {
this.logger = this.log.create('terminalTab')
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
if (!this.hasFocus) {
return
}
switch (hotkey) {
case 'home':
this.sendInput('\x1b[H' )
break
case 'end':
this.sendInput('\x1b[F' )
break
case 'restart-serial-session':
this.reconnect()
break
}
})
this.frontendReady$.pipe(first()).subscribe(() => {
this.initializeSession()
})
super.ngOnInit()
setImmediate(() => {
this.setTitle(this.connection!.name)
})
}
async initializeSession () {
if (!this.connection) {
this.logger.error('No Serial connection info supplied')
return
}
const session = this.serialService.createSession(this.connection)
this.setSession(session)
this.write(`Connecting to `)
const spinner = new Spinner({
text: 'Connecting',
stream: {
write: x => this.write(x),
},
})
spinner.setSpinnerString(6)
spinner.start()
try {
this.serialPort = await this.serialService.connectSession(this.session!)
spinner.stop(true)
session.emitServiceMessage('Port opened')
} catch (e) {
spinner.stop(true)
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
return
}
await this.session!.start()
this.session!.resize(this.size.columns, this.size.rows)
}
protected attachSessionHandlers () {
this.attachSessionHandler(this.session!.serviceMessage$, msg => {
this.write(`\r\n${colors.black.bgWhite(' Serial ')} ${msg}\r\n`)
this.session?.resize(this.size.columns, this.size.rows)
})
this.attachSessionHandler(this.session!.destroyed$, () => {
this.write('Press any key to reconnect\r\n')
this.input$.pipe(first()).subscribe(() => {
if (!this.session?.open) {
this.reconnect()
}
})
})
super.attachSessionHandlers()
}
async getRecoveryToken (): Promise<any> {
return {
type: 'app:serial-tab',
connection: this.connection,
savedState: this.frontend?.saveState(),
}
}
async reconnect (): Promise<void> {
this.session?.destroy()
await this.initializeSession()
this.session?.releaseInitialDataBuffer()
}
async changeBaudRate () {
const rate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
name: x.toString(), result: x,
})))
this.serialPort.update({ baudRate: rate })
this.connection!.baudrate = rate
}
}

View File

@@ -0,0 +1,20 @@
import { ConfigProvider } from 'tabby-core'
/** @hidden */
export class SerialConfigProvider extends ConfigProvider {
defaults = {
serial: {
connections: [],
options: {
},
},
hotkeys: {
serial: [
'Alt-K',
],
'restart-serial-session': [],
},
}
platformDefaults = { }
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
/** @hidden */
@Injectable()
export class SerialHotkeyProvider extends HotkeyProvider {
hotkeys: HotkeyDescription[] = [
{
id: 'serial',
name: 'Show Serial connections',
},
{
id: 'restart-serial-session',
name: 'Restart current serial session',
},
]
async provide (): Promise<HotkeyDescription[]> {
return this.hotkeys
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="svg3749" width="32" height="16" version="1.1" viewBox="0 0 32 16"><g id="g3747" transform="matrix(0.48599086,0,0,0.48599086,0.50191451,-0.299629)" style="fill:none;fill-rule:evenodd"><g id="g3741" transform="translate(-292.02353,-314.25882)" style="fill:#fff"><path style="fill-rule:nonzero;stroke-width:.10270619" id="path3739" d="M 16.007812,0 3.2929688,0.03515625 2.2324219,0.40234375 C 0.91449728,1.1071083 0,2.575555 0,3.9863281 c 0,1.2651363 1.3074352,7.8137089 1.7402344,8.7167969 0.553077,1.153703 1.988134,2.456836 3.234375,2.9375 0.8530743,0.328933 1.4753185,0.348528 10.9804686,0.357422 9.789951,0.0091 10.106534,-0.002 11.087891,-0.369141 1.221173,-0.456851 2.835858,-1.955656 3.333984,-3.09375 0.336145,-0.767943 1.638672,-7.5615083 1.638672,-8.5488279 0,-1.4107731 -0.849384,-3.02258715 -2.234375,-3.58398435 L 28.791016,0 Z m -4.27539,4.890625 c 0.427942,0 0.812664,0.071135 1.152344,0.2128906 0.342354,0.1390812 0.631097,0.3382162 0.86914,0.5976563 0.243393,0.2674641 0.430211,0.5965523 0.558594,0.984375 0.131057,0.3878229 0.197266,0.8262256 0.197266,1.3183593 0,0.4921338 -0.06744,0.9337216 -0.201172,1.3242188 -0.131058,0.387823 -0.316645,0.711263 -0.554688,0.970703 -0.246067,0.270139 -0.536042,0.474922 -0.873047,0.611328 -0.33433,0.136407 -0.71782,0.203125 -1.148437,0.203125 -0.419919,0 -0.801456,-0.0699 -1.146484,-0.208984 C 10.243584,10.765216 9.9516549,10.563618 9.7109375,10.298828 9.4702197,10.034039 9.2834009,9.7093672 9.1523438,9.3242188 9.0239608,8.9390707 8.9609375,8.4987146 8.9609375,8.0039062 c 0,-0.4867846 0.063023,-0.9214925 0.1914063,-1.3066406 C 9.2807263,6.3094427 9.4687769,5.9766597 9.7148438,5.7011719 9.950212,5.4390572 10.242141,5.2386906 10.589844,5.0996094 10.940222,4.9605279 11.320527,4.890625 11.732422,4.890625 Z m 9.933594,0 c 0.427942,0 0.812664,0.071135 1.152343,0.2128906 0.342354,0.1390812 0.631098,0.3382162 0.869141,0.5976563 0.243392,0.2674641 0.430211,0.5965523 0.558594,0.984375 0.131057,0.3878229 0.197265,0.8262256 0.197265,1.3183593 0,0.4921338 -0.06744,0.9337216 -0.201171,1.3242188 -0.131058,0.387823 -0.316645,0.711263 -0.554688,0.970703 -0.246067,0.270139 -0.536042,0.474922 -0.873047,0.611328 -0.33433,0.136407 -0.71782,0.203125 -1.148437,0.203125 -0.419919,0 -0.801456,-0.0699 -1.146485,-0.208984 -0.342353,-0.139081 -0.634282,-0.340679 -0.875,-0.605469 C 19.403813,10.034039 19.216995,9.7093672 19.085938,9.3242188 18.957555,8.9390707 18.894531,8.4987146 18.894531,8.0039062 c 0,-0.4867846 0.06302,-0.9214925 0.191407,-1.3066406 0.128382,-0.3878229 0.316433,-0.7206059 0.5625,-0.9960937 0.235368,-0.2621147 0.527296,-0.4624813 0.875,-0.6015625 0.350377,-0.1390815 0.730683,-0.2089844 1.142578,-0.2089844 z m -16.0839848,0.125 h 2.359375 V 5.625 H 7.1582031 v 4.753906 h 0.7832031 v 0.611328 H 5.5820312 V 10.378906 H 6.3652344 V 5.625 H 5.5820312 Z m 9.9335938,0 H 17.875 V 5.625 h -0.783203 v 4.753906 H 17.875 v 0.611328 h -2.359375 v -0.611328 h 0.783203 V 5.625 h -0.783203 z m 9.933594,0 h 2.359375 V 5.625 h -0.783203 v 4.753906 h 0.783203 v 0.611328 h -2.359375 v -0.611328 h 0.783203 V 5.625 h -0.783203 z m -13.712891,0.5625 c -0.607143,0 -1.083937,0.2102191 -1.43164,0.6328125 -0.3450291,0.4199185 -0.5175786,1.0173231 -0.5175786,1.7929687 0,0.7836694 0.1762443,1.3854905 0.5292966,1.8027344 0.353053,0.4145694 0.826152,0.6210934 1.419922,0.6210934 0.59377,0 1.065638,-0.206524 1.416016,-0.6210934 0.353052,-0.4172439 0.529297,-1.019065 0.529297,-1.8027344 0,-0.7756456 -0.173782,-1.3730502 -0.521485,-1.7929687 C 12.812453,5.7883441 12.338122,5.578125 11.736328,5.578125 Z m 9.933594,0 c -0.607144,0 -1.083938,0.2102191 -1.431641,0.6328125 -0.345028,0.4199185 -0.517578,1.0173231 -0.517578,1.7929687 0,0.7836694 0.176244,1.3854905 0.529297,1.8027344 0.353052,0.4145694 0.826152,0.6210934 1.419922,0.6210934 0.59377,0 1.065638,-0.206524 1.416016,-0.6210934 0.353052,-0.4172439 0.529296,-1.019065 0.529296,-1.8027344 0,-0.7756456 -0.173781,-1.3730502 -0.521484,-1.7929687 C 22.746047,5.7883441 22.271716,5.578125 21.669922,5.578125 Z" transform="matrix(2.0576519,0,0,2.0576519,290.99076,314.87535)"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

50
tabby-serial/src/index.ts Normal file
View File

@@ -0,0 +1,50 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { ToastrModule } from 'ngx-toastr'
import TabbyCoreModule, { ToolbarButtonProvider, ConfigProvider, TabRecoveryProvider, HotkeyProvider, CLIHandler } from 'tabby-core'
import { SettingsTabProvider } from 'tabby-settings'
import TabbyTerminalModule from 'tabby-terminal'
import { EditConnectionModalComponent } from './components/editConnectionModal.component'
import { SerialSettingsTabComponent } from './components/serialSettingsTab.component'
import { SerialTabComponent } from './components/serialTab.component'
import { ButtonProvider } from './buttonProvider'
import { SerialConfigProvider } from './config'
import { SerialSettingsTabProvider } from './settings'
import { RecoveryProvider } from './recoveryProvider'
import { SerialHotkeyProvider } from './hotkeys'
import { SerialCLIHandler } from './cli'
/** @hidden */
@NgModule({
imports: [
NgbModule,
CommonModule,
FormsModule,
ToastrModule,
TabbyCoreModule,
TabbyTerminalModule,
],
providers: [
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: ConfigProvider, useClass: SerialConfigProvider, multi: true },
{ provide: SettingsTabProvider, useClass: SerialSettingsTabProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
{ provide: HotkeyProvider, useClass: SerialHotkeyProvider, multi: true },
{ provide: CLIHandler, useClass: SerialCLIHandler, multi: true },
],
entryComponents: [
EditConnectionModalComponent,
SerialSettingsTabComponent,
SerialTabComponent,
],
declarations: [
EditConnectionModalComponent,
SerialSettingsTabComponent,
SerialTabComponent,
],
})
export default class SerialModule { } // eslint-disable-line @typescript-eslint/no-extraneous-class

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core'
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
import { SerialTabComponent } from './components/serialTab.component'
/** @hidden */
@Injectable()
export class RecoveryProvider extends TabRecoveryProvider {
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
return recoveryToken.type === 'app:serial-tab'
}
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
return {
type: SerialTabComponent,
options: {
connection: recoveryToken.connection,
savedState: recoveryToken.savedState,
},
}
}
}

View File

@@ -0,0 +1,179 @@
import { Injectable, NgZone } from '@angular/core'
import SerialPort from 'serialport'
import { LogService, AppService, SelectorOption, ConfigService, NotificationsService, SelectorService } from 'tabby-core'
import { SettingsTabComponent } from 'tabby-settings'
import { SerialConnection, SerialSession, SerialPortInfo, BAUD_RATES } from '../api'
import { SerialTabComponent } from '../components/serialTab.component'
@Injectable({ providedIn: 'root' })
export class SerialService {
private constructor (
private log: LogService,
private zone: NgZone,
private notifications: NotificationsService,
private app: AppService,
private selector: SelectorService,
private config: ConfigService,
) { }
async listPorts (): Promise<SerialPortInfo[]> {
return (await SerialPort.list()).map(x => ({
name: x.path,
description: x.manufacturer || x.serialNumber ? `${x.manufacturer || ''} ${x.serialNumber || ''}` : undefined,
}))
}
createSession (connection: SerialConnection): SerialSession {
const session = new SerialSession(connection)
session.logger = this.log.create(`serial-${connection.port}`)
return session
}
async connectSession (session: SerialSession): Promise<SerialPort> {
const serial = new SerialPort(session.connection.port, {
autoOpen: false,
baudRate: parseInt(session.connection.baudrate as any),
dataBits: session.connection.databits,
stopBits: session.connection.stopbits,
parity: session.connection.parity,
rtscts: session.connection.rtscts,
xon: session.connection.xon,
xoff: session.connection.xoff,
xany: session.connection.xany,
})
session.serial = serial
let connected = false
await new Promise(async (resolve, reject) => {
serial.on('open', () => {
connected = true
this.zone.run(resolve)
})
serial.on('error', error => {
this.zone.run(() => {
if (connected) {
this.notifications.error(error.toString())
} else {
reject(error)
}
})
})
serial.on('close', () => {
session.emitServiceMessage('Port closed')
session.destroy()
})
try {
serial.open()
} catch (e) {
this.notifications.error(e.message)
reject(e)
}
})
return serial
}
async showConnectionSelector (): Promise<void> {
const options: SelectorOption<void>[] = []
const foundPorts = await this.listPorts()
try {
const lastConnection = JSON.parse(window.localStorage.lastSerialConnection)
if (lastConnection) {
options.push({
name: lastConnection.name,
icon: 'history',
callback: () => this.connect(lastConnection),
})
options.push({
name: 'Clear last connection',
icon: 'eraser',
callback: () => {
window.localStorage.lastSerialConnection = null
},
})
}
} catch { }
for (const port of foundPorts) {
options.push({
name: port.name,
description: port.description,
icon: 'arrow-right',
callback: () => this.connectFoundPort(port),
})
}
for (const connection of this.config.store.serial.connections) {
options.push({
name: connection.name,
description: connection.port,
callback: () => this.connect(connection),
})
}
options.push({
name: 'Manage connections',
icon: 'cog',
callback: () => this.app.openNewTabRaw(SettingsTabComponent, { activeTab: 'serial' }),
})
options.push({
name: 'Quick connect',
freeInputPattern: 'Open device: %s...',
icon: 'arrow-right',
callback: query => this.quickConnect(query),
})
await this.selector.show('Open a serial port', options)
}
async connect (connection: SerialConnection): Promise<SerialTabComponent> {
try {
const tab = this.app.openNewTab(
SerialTabComponent,
{ connection }
) as SerialTabComponent
if (connection.color) {
(this.app.getParentTab(tab) ?? tab).color = connection.color
}
setTimeout(() => {
this.app.activeTab?.emitFocused()
})
return tab
} catch (error) {
this.notifications.error(`Could not connect: ${error}`)
throw error
}
}
quickConnect (query: string): Promise<SerialTabComponent> {
let path = query
let baudrate = 115200
if (query.includes('@')) {
baudrate = parseInt(path.split('@')[1])
path = path.split('@')[0]
}
const connection: SerialConnection = {
name: query,
port: path,
baudrate: baudrate,
databits: 8,
parity: 'none',
rtscts: false,
stopbits: 1,
xany: false,
xoff: false,
xon: false,
}
window.localStorage.lastSerialConnection = JSON.stringify(connection)
return this.connect(connection)
}
async connectFoundPort (port: SerialPortInfo): Promise<SerialTabComponent> {
const rate = await this.selector.show('Baud rate', BAUD_RATES.map(x => ({
name: x.toString(), result: x,
})))
return this.quickConnect(`${port.name}@${rate}`)
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider } from 'tabby-settings'
import { SerialSettingsTabComponent } from './components/serialSettingsTab.component'
/** @hidden */
@Injectable()
export class SerialSettingsTabProvider extends SettingsTabProvider {
id = 'serial'
icon = 'keyboard'
title = 'Serial'
getComponentType (): any {
return SerialSettingsTabComponent
}
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist", "typings"],
"compilerOptions": {
"baseUrl": "src"
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"exclude": ["node_modules", "dist", "typings"],
"include": ["src"],
"compilerOptions": {
"baseUrl": "src",
"emitDeclarationOnly": true,
"declaration": true,
"declarationDir": "./typings",
"paths": {
"tabby-*": ["../../tabby-*"],
"*": ["../../app/node_modules/*"]
}
}
}

View File

@@ -0,0 +1,5 @@
const config = require('../webpack.plugin.config')
module.exports = config({
name: 'serial',
dirname: __dirname,
})

58
tabby-serial/yarn.lock Normal file
View File

@@ -0,0 +1,58 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/node@14.14.14":
version "14.14.14"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.14.tgz#f7fd5f3cc8521301119f63910f0fb965c7d761ae"
integrity sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ==
ansi-color@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/ansi-color/-/ansi-color-0.2.1.tgz#3e75c037475217544ed763a8db5709fa9ae5bf9a"
integrity sha1-PnXAN0dSF1RO12Oo21cJ+prlv5o=
ansi-colors@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
binstring@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/binstring/-/binstring-0.2.1.tgz#8a174d301f6d54efda550dd98bb4cb524eacd75d"
integrity sha1-ihdNMB9tVO/aVQ3Zi7TLUk6s110=
buffer-replace@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-replace/-/buffer-replace-1.0.0.tgz#bc427c40af4c1f06d6933dede57110acba8ade54"
integrity sha1-vEJ8QK9MHwbWkz3t5XEQrLqK3lQ=
cli-spinner@^0.2.10:
version "0.2.10"
resolved "https://registry.yarnpkg.com/cli-spinner/-/cli-spinner-0.2.10.tgz#f7d617a36f5c47a7bc6353c697fc9338ff782a47"
integrity sha512-U0sSQ+JJvSLi1pAYuJykwiA8Dsr15uHEy85iCJ6A+0DjVxivr3d+N2Wjvodeg89uP5K6TswFkKBfAD7B3YSn/Q==
hexer@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/hexer/-/hexer-1.5.0.tgz#b86ce808598e8a9d1892c571f3cedd86fc9f0653"
integrity sha1-uGzoCFmOip0YksVx887dhvyfBlM=
dependencies:
ansi-color "^0.2.1"
minimist "^1.1.0"
process "^0.10.0"
xtend "^4.0.0"
minimist@^1.1.0:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
process@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/process/-/process-0.10.1.tgz#842457cc51cfed72dc775afeeafb8c6034372725"
integrity sha1-hCRXzFHP7XLcd1r+6vuMYDQ3JyU=
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==