From 483582a8d1d1c54b49f5852f115130f816026bcd Mon Sep 17 00:00:00 2001 From: hstyi Date: Tue, 28 Jan 2025 10:23:05 +0800 Subject: [PATCH] feat: serial comm (#141) --- THIRDPARTY | 6 +- build.gradle.kts | 21 ++- gradle/libs.versions.toml | 2 + .../app/termora/ChannelShellPtyConnector.kt | 8 +- .../kotlin/app/termora/EditHostOptionsPane.kt | 10 ++ src/main/kotlin/app/termora/Host.kt | 55 +++++- src/main/kotlin/app/termora/HostDialog.kt | 44 +++-- .../kotlin/app/termora/HostOptionsPane.kt | 157 +++++++++++++++++- src/main/kotlin/app/termora/HostTree.kt | 2 + src/main/kotlin/app/termora/Icons.kt | 1 + src/main/kotlin/app/termora/Main.kt | 5 + .../kotlin/app/termora/PtyHostTerminalTab.kt | 8 +- .../app/termora/SerialPortPtyConnector.kt | 61 +++++++ .../kotlin/app/termora/SerialTerminalTab.kt | 20 +++ src/main/kotlin/app/termora/Serials.kt | 38 +++++ .../app/termora/actions/OpenHostAction.kt | 13 +- .../ControlSequenceIntroducerProcessor.kt | 6 +- .../app/termora/terminal/PtyConnector.kt | 18 +- .../termora/terminal/PtyConnectorDelegate.kt | 6 +- .../termora/terminal/PtyProcessConnector.kt | 7 +- .../termora/terminal/panel/TerminalPanel.kt | 12 +- .../terminal/panel/TerminalPanelKeyAdapter.kt | 6 +- .../TerminalPanelMouseTrackingAdapter.kt | 4 +- src/main/resources/i18n/messages.properties | 8 + .../resources/i18n/messages_zh_CN.properties | 8 + .../resources/i18n/messages_zh_TW.properties | 8 + src/main/resources/icons/plugin.svg | 4 + src/main/resources/icons/plugin_dark.svg | 4 + 28 files changed, 489 insertions(+), 53 deletions(-) create mode 100644 src/main/kotlin/app/termora/SerialPortPtyConnector.kt create mode 100644 src/main/kotlin/app/termora/SerialTerminalTab.kt create mode 100644 src/main/kotlin/app/termora/Serials.kt create mode 100644 src/main/resources/icons/plugin.svg create mode 100644 src/main/resources/icons/plugin_dark.svg diff --git a/THIRDPARTY b/THIRDPARTY index 7c7562c..1f3741c 100644 --- a/THIRDPARTY +++ b/THIRDPARTY @@ -240,4 +240,8 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE json-20231013 Public Domain. -https://github.com/stleary/JSON-java/blob/master/LICENSE \ No newline at end of file +https://github.com/stleary/JSON-java/blob/master/LICENSE + +jSerialComm 2.11.0 +Apache License 2.0 +https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index dd54496..baad625 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import org.gradle.internal.jvm.Jvm import org.gradle.kotlin.dsl.support.uppercaseFirstChar +import org.gradle.nativeplatform.platform.internal.ArchitectureInternal import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.jetbrains.kotlin.org.apache.commons.io.FileUtils import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils @@ -17,7 +18,7 @@ group = "app.termora" version = "1.0.5" val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() -val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture() +val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture() // macOS 签名信息 val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY @@ -104,6 +105,7 @@ dependencies { implementation(libs.bip39) implementation(libs.colorpicker) implementation(libs.mixpanel) + implementation(libs.jSerialComm) } application { @@ -148,6 +150,8 @@ tasks.register("copy-dependencies") { val jna = libs.jna.asProvider().get() val dylib = dir.get().dir("dylib").asFile val pty4j = libs.pty4j.get() + val jSerialComm = libs.jSerialComm.get() + for (file in dir.get().asFile.listFiles() ?: emptyArray()) { if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) { val targetDir = File(dylib, jna.name) @@ -172,6 +176,21 @@ tasks.register("copy-dependencies") { // @formatter:on // 删除所有二进制类库 exec { commandLine("zip", "-d", file.absolutePath, "resources/*") } + } else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) { + val archName = if (arch.isArm) "aarch64" else "x86_64" + val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName) + FileUtils.forceMkdir(targetDir) + // @formatter:off + exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) } + // @formatter:on + // 删除所有二进制类库 + exec { commandLine("zip", "-d", file.absolutePath, "Android/*") } + exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") } + exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") } + exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") } + exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") } + exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") } + exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2db0493..88033f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ rhino = "1.7.15" delight-rhino-sandbox = "0.0.17" testcontainers = "1.20.4" mixpanel = "1.5.3" +jSerialComm="2.11.0" [libraries] kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -97,6 +98,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" } delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" } +jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt b/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt index ddd11a7..20d6ea8 100644 --- a/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt +++ b/src/main/kotlin/app/termora/ChannelShellPtyConnector.kt @@ -22,10 +22,6 @@ class ChannelShellPtyConnector( output.flush() } - override fun write(buffer: String) { - write(buffer.toByteArray(charset)) - } - override fun resize(rows: Int, cols: Int) { channel.sendWindowChange(cols, rows) } @@ -38,4 +34,8 @@ class ChannelShellPtyConnector( override fun close() { channel.close(true) } + + override fun getCharset(): Charset { + return charset + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/EditHostOptionsPane.kt b/src/main/kotlin/app/termora/EditHostOptionsPane.kt index b24917f..ea83175 100644 --- a/src/main/kotlin/app/termora/EditHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/EditHostOptionsPane.kt @@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() { } jumpHostsOption.filter = { it.id != host.id } + + val serialComm = host.options.serialComm + if (serialComm.port.isNotBlank()) { + serialCommOption.serialPortComboBox.selectedItem = serialComm.port + } + serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate + serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits + serialCommOption.parityComboBox.selectedItem = serialComm.parity + serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits + serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl } override fun getHost(): Host { diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index 0e62658..6edf479 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -13,6 +13,7 @@ enum class Protocol { Folder, SSH, Local, + Serial } @@ -39,6 +40,53 @@ data class Authentication( } } +enum class SerialCommParity { + None, + Even, + Odd, + Mark, + Space +} + +enum class SerialCommFlowControl { + None, + RTS_CTS, + XON_XOFF, +} + +@Serializable +data class SerialComm( + /** + * 串口 + */ + val port: String = StringUtils.EMPTY, + + /** + * 波特率 + */ + val baudRate: Int = 9600, + + /** + * 数据位:5、6、7、8 + */ + val dataBits: Int = 8, + + /** + * 停止位: 1、1.5、2 + */ + val stopBits: String = "1", + + /** + * 校验位 + */ + val parity: SerialCommParity = SerialCommParity.None, + + /** + * 流控 + */ + val flowControl: SerialCommFlowControl = SerialCommFlowControl.None, +) + @Serializable data class Options( @@ -61,7 +109,12 @@ data class Options( /** * SSH 心跳间隔 */ - val heartbeatInterval: Int = 30 + val heartbeatInterval: Int = 30, + + /** + * 串口配置 + */ + val serialComm: SerialComm = SerialComm(), ) { companion object { val Default = Options() diff --git a/src/main/kotlin/app/termora/HostDialog.kt b/src/main/kotlin/app/termora/HostDialog.kt index 3fbded9..a8f552c 100644 --- a/src/main/kotlin/app/termora/HostDialog.kt +++ b/src/main/kotlin/app/termora/HostDialog.kt @@ -67,37 +67,53 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) { private suspend fun testConnection(host: Host) { val owner = this - if (host.protocol != Protocol.SSH) { + if (host.protocol == Protocol.Local) { withContext(Dispatchers.Swing) { OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful")) } return } + try { + if (host.protocol == Protocol.SSH) { + testSSH(host) + } else if (host.protocol == Protocol.Serial) { + testSerial(host) + } + } catch (e: Exception) { + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, ExceptionUtils.getMessage(e), + messageType = JOptionPane.ERROR_MESSAGE + ) + } + return + } + + withContext(Dispatchers.Swing) { + OptionPane.showMessageDialog( + owner, + I18n.getString("termora.new-host.test-connection-successful") + ) + } + + } + + private fun testSSH(host: Host) { var client: SshClient? = null var session: ClientSession? = null try { client = SshClients.openClient(host) client.userInteraction = TerminalUserInteraction(owner) session = SshClients.openSession(host, client) - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, - I18n.getString("termora.new-host.test-connection-successful") - ) - } - } catch (e: Exception) { - withContext(Dispatchers.Swing) { - OptionPane.showMessageDialog( - owner, ExceptionUtils.getRootCauseMessage(e), - messageType = JOptionPane.ERROR_MESSAGE - ) - } } finally { session?.close() client?.close() } + } + private fun testSerial(host: Host) { + Serials.openPort(host).closePort() } override fun doOKAction() { diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index 7f859e9..42b06c3 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -2,11 +2,17 @@ package app.termora import app.termora.keymgr.KeyManager import app.termora.keymgr.KeyManagerDialog +import com.fazecast.jSerialComm.SerialPort import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.ui.FlatTextBorder import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext import org.apache.commons.lang3.StringUtils import java.awt.* import java.awt.event.* @@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() { protected val proxyOption = ProxyOption() protected val terminalOption = TerminalOption() protected val jumpHostsOption = JumpHostsOption() + protected val serialCommOption = SerialCommOption() protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) init { @@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() { addOption(tunnelingOption) addOption(jumpHostsOption) addOption(terminalOption) + addOption(serialCommOption) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) } @@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() { var authentication = Authentication.No var proxy = Proxy.No + if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) { authentication = authentication.copy( type = AuthenticationType.Password, @@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() { ) } + + val serialComm = SerialComm( + port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY, + baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600, + dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8, + stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1", + parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity, + flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl + ) + val options = Options.Default.copy( encoding = terminalOption.charsetComboBox.selectedItem as String, env = terminalOption.environmentTextArea.text, startupCommand = terminalOption.startupCommandTextField.text, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, - jumpHosts = jumpHostsOption.jumpHosts.map { it.id } + jumpHosts = jumpHostsOption.jumpHosts.map { it.id }, + serialComm = serialComm ) return Host( @@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() { if (validateField(generalOption.usernameTextField)) { return false } + } else if (host.protocol == Protocol.Serial) { + if (validateField(serialCommOption.serialPortComboBox) + || validateField(serialCommOption.baudRateComboBox) + ) { + return false + } } if (host.authentication.type == AuthenticationType.Password) { @@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() { * 返回 true 表示有错误 */ private fun validateField(comboBox: JComboBox<*>): Boolean { - if (comboBox.isEnabled && comboBox.selectedItem == null) { + val selectedItem = comboBox.selectedItem + if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) { selectOptionJComponent(comboBox) comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) comboBox.requestFocusInWindow() @@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() { protocolTypeComboBox.addItem(Protocol.SSH) protocolTypeComboBox.addItem(Protocol.Local) + protocolTypeComboBox.addItem(Protocol.Serial) authenticationTypeComboBox.addItem(AuthenticationType.No) authenticationTypeComboBox.addItem(AuthenticationType.Password) @@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() { passwordTextField.isEnabled = true chooseKeyBtn.isEnabled = true - if (protocolTypeComboBox.selectedItem == Protocol.Local) { + if (protocolTypeComboBox.selectedItem == Protocol.Local + || protocolTypeComboBox.selectedItem == Protocol.Serial + ) { hostTextField.isEnabled = false portTextField.isEnabled = false usernameTextField.isEnabled = false @@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() { } } + protected inner class SerialCommOption : JPanel(BorderLayout()), Option { + val serialPortComboBox = OutlineComboBox() + val baudRateComboBox = OutlineComboBox() + val dataBitsComboBox = OutlineComboBox() + val parityComboBox = OutlineComboBox() + val stopBitsComboBox = OutlineComboBox() + val flowControlComboBox = OutlineComboBox() + + + init { + initView() + initEvents() + } + + private fun initView() { + + serialPortComboBox.isEditable = true + + baudRateComboBox.isEditable = true + baudRateComboBox.addItem(9600) + baudRateComboBox.addItem(19200) + baudRateComboBox.addItem(38400) + baudRateComboBox.addItem(57600) + baudRateComboBox.addItem(115200) + + dataBitsComboBox.addItem(5) + dataBitsComboBox.addItem(6) + dataBitsComboBox.addItem(7) + dataBitsComboBox.addItem(8) + dataBitsComboBox.selectedItem = 8 + + parityComboBox.addItem(SerialCommParity.None) + parityComboBox.addItem(SerialCommParity.Even) + parityComboBox.addItem(SerialCommParity.Odd) + parityComboBox.addItem(SerialCommParity.Mark) + parityComboBox.addItem(SerialCommParity.Space) + + stopBitsComboBox.addItem("1") + stopBitsComboBox.addItem("1.5") + stopBitsComboBox.addItem("2") + stopBitsComboBox.selectedItem = "1" + + flowControlComboBox.addItem(SerialCommFlowControl.None) + flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS) + flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF) + + flowControlComboBox.renderer = object : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val text = value?.toString() ?: StringUtils.EMPTY + return super.getListCellRendererComponent( + list, + text.replace('_', '/'), + index, + isSelected, + cellHasFocus + ) + } + } + + add(getCenterComponent(), BorderLayout.CENTER) + } + + private fun initEvents() { + addComponentListener(object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent) { + removeComponentListener(this) + @Suppress("OPT_IN_USAGE") + GlobalScope.launch(Dispatchers.IO) { + for (commPort in SerialPort.getCommPorts()) { + withContext(Dispatchers.Swing) { + serialPortComboBox.addItem(commPort.systemPortName) + } + } + } + } + }) + } + + override fun getIcon(isSelected: Boolean): Icon { + return Icons.plugin + } + + override fun getTitle(): String { + return I18n.getString("termora.new-host.serial") + } + + override fun getJComponent(): JComponent { + return this + } + + private fun getCenterComponent(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow, $formMargin", + "pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref" + ) + + var rows = 1 + val step = 2 + val panel = FormBuilder.create().layout(layout) + .add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows) + .add(serialPortComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows) + .add(baudRateComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows) + .add(dataBitsComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows) + .add(parityComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows) + .add(stopBitsComboBox).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows) + .add(flowControlComboBox).xy(3, rows).apply { rows += step } + .build() + return panel + } + } protected inner class JumpHostsOption : JPanel(BorderLayout()), Option { val jumpHosts = mutableListOf() diff --git a/src/main/kotlin/app/termora/HostTree.kt b/src/main/kotlin/app/termora/HostTree.kt index 49f14bd..8dd37ee 100644 --- a/src/main/kotlin/app/termora/HostTree.kt +++ b/src/main/kotlin/app/termora/HostTree.kt @@ -69,6 +69,8 @@ class HostTree : JTree(), Disposable { icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() } else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) { icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal + } else if (host.protocol == Protocol.Serial) { + icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin } return c } diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index bad8ebc..dde49f2 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -3,6 +3,7 @@ package app.termora object Icons { val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } + val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") } val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") } val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") } diff --git a/src/main/kotlin/app/termora/Main.kt b/src/main/kotlin/app/termora/Main.kt index 9574e7f..0364264 100644 --- a/src/main/kotlin/app/termora/Main.kt +++ b/src/main/kotlin/app/termora/Main.kt @@ -41,4 +41,9 @@ private fun setupNativeLibraries() { if (pty4j.exists()) { System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath) } + + val jSerialComm = FileUtils.getFile(dylib, "jSerialComm") + if (jSerialComm.exists()) { + System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath) + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt index e9f0548..abe323a 100644 --- a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt +++ b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt @@ -53,8 +53,12 @@ abstract class PtyHostTerminalTab( coroutineScope.launch(Dispatchers.IO) { delay(250.milliseconds) withContext(Dispatchers.Swing) { - ptyConnector.write(host.options.startupCommand) - ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))) + val charset = ptyConnector.getCharset() + ptyConnector.write(host.options.startupCommand.toByteArray(charset)) + ptyConnector.write( + terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)) + .toByteArray(charset) + ) } } } diff --git a/src/main/kotlin/app/termora/SerialPortPtyConnector.kt b/src/main/kotlin/app/termora/SerialPortPtyConnector.kt new file mode 100644 index 0000000..cf8397c --- /dev/null +++ b/src/main/kotlin/app/termora/SerialPortPtyConnector.kt @@ -0,0 +1,61 @@ +package app.termora + +import app.termora.terminal.PtyConnector +import com.fazecast.jSerialComm.SerialPort +import com.fazecast.jSerialComm.SerialPortDataListener +import com.fazecast.jSerialComm.SerialPortEvent +import java.nio.charset.Charset +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +class SerialPortPtyConnector( + private val serialPort: SerialPort, + private val charset: Charset = Charsets.UTF_8 +) : PtyConnector, SerialPortDataListener { + + private val queue = LinkedBlockingQueue() + + init { + serialPort.addDataListener(this) + } + + override fun read(buffer: CharArray): Int { + buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0 + return 1 + } + + override fun write(buffer: ByteArray, offset: Int, len: Int) { + serialPort.writeBytes(buffer, len, offset) + } + + override fun resize(rows: Int, cols: Int) { + + } + + override fun waitFor(): Int { + return 0 + } + + override fun close() { + queue.clear() + serialPort.closePort() + } + + override fun getListeningEvents(): Int { + return SerialPort.LISTENING_EVENT_DATA_RECEIVED + } + + override fun serialEvent(event: SerialPortEvent) { + if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) { + val data = event.receivedData + if (data.isEmpty()) return + for (c in String(data, charset).toCharArray()) { + queue.add(c) + } + } + } + + override fun getCharset(): Charset { + return charset + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SerialTerminalTab.kt b/src/main/kotlin/app/termora/SerialTerminalTab.kt new file mode 100644 index 0000000..3b911c3 --- /dev/null +++ b/src/main/kotlin/app/termora/SerialTerminalTab.kt @@ -0,0 +1,20 @@ +package app.termora + +import app.termora.terminal.PtyConnector +import org.apache.commons.io.Charsets +import java.nio.charset.StandardCharsets +import javax.swing.Icon + +class SerialTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) { + override suspend fun openPtyConnector(): PtyConnector { + val serialPort = Serials.openPort(host) + return SerialPortPtyConnector( + serialPort, + Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8) + ) + } + + override fun getIcon(): Icon { + return Icons.plugin + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Serials.kt b/src/main/kotlin/app/termora/Serials.kt new file mode 100644 index 0000000..f89d683 --- /dev/null +++ b/src/main/kotlin/app/termora/Serials.kt @@ -0,0 +1,38 @@ +package app.termora + +import com.fazecast.jSerialComm.SerialPort + +object Serials { + fun openPort(host: Host): SerialPort { + val serialComm = host.options.serialComm + val serialPort = SerialPort.getCommPort(serialComm.port) + serialPort.setBaudRate(serialComm.baudRate) + serialPort.setNumDataBits(serialComm.dataBits) + + when (serialComm.parity) { + SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY) + SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY) + SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY) + SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY) + SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY) + } + + when (serialComm.stopBits) { + "1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT) + "1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS) + "2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS) + } + + when (serialComm.flowControl) { + SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED) + SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED) + SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED) + } + + if (!serialPort.openPort()) { + throw IllegalStateException("Open serial port [${serialComm.port}] failed") + } + + return serialPort + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/actions/OpenHostAction.kt b/src/main/kotlin/app/termora/actions/OpenHostAction.kt index fb0986a..6ce97f1 100644 --- a/src/main/kotlin/app/termora/actions/OpenHostAction.kt +++ b/src/main/kotlin/app/termora/actions/OpenHostAction.kt @@ -1,9 +1,6 @@ package app.termora.actions -import app.termora.LocalTerminalTab -import app.termora.OpenHostActionEvent -import app.termora.Protocol -import app.termora.SSHTerminalTab +import app.termora.* class OpenHostAction : AnAction() { companion object { @@ -18,9 +15,11 @@ class OpenHostAction : AnAction() { val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val windowScope = evt.getData(DataProviders.WindowScope) ?: return - val tab = if (evt.host.protocol == Protocol.SSH) - SSHTerminalTab(windowScope, evt.host) - else LocalTerminalTab(windowScope, evt.host) + val tab = when (evt.host.protocol) { + Protocol.SSH -> SSHTerminalTab(windowScope, evt.host) + Protocol.Serial -> SerialTerminalTab(windowScope, evt.host) + else -> LocalTerminalTab(windowScope, evt.host) + } terminalTabbedManager.addTerminalTab(tab) tab.start() diff --git a/src/main/kotlin/app/termora/terminal/ControlSequenceIntroducerProcessor.kt b/src/main/kotlin/app/termora/terminal/ControlSequenceIntroducerProcessor.kt index 2318342..855c649 100644 --- a/src/main/kotlin/app/termora/terminal/ControlSequenceIntroducerProcessor.kt +++ b/src/main/kotlin/app/termora/terminal/ControlSequenceIntroducerProcessor.kt @@ -485,9 +485,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea val m = args.first() if (m == '6') { val position = terminal.getCursorModel().getPosition() - ptyConnector.write("${ControlCharacters.ESC}[${position.y};${position.x}R") + val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset()) + ptyConnector.write(bytes) } else if (m == '5') { - ptyConnector.write("${ControlCharacters.ESC}[0n") + val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset()) + ptyConnector.write(bytes) } } diff --git a/src/main/kotlin/app/termora/terminal/PtyConnector.kt b/src/main/kotlin/app/termora/terminal/PtyConnector.kt index 0c14b2d..1c4f81f 100644 --- a/src/main/kotlin/app/termora/terminal/PtyConnector.kt +++ b/src/main/kotlin/app/termora/terminal/PtyConnector.kt @@ -1,6 +1,7 @@ package app.termora.terminal import java.nio.ByteBuffer +import java.nio.charset.Charset interface PtyConnector { @@ -15,15 +16,18 @@ interface PtyConnector { */ fun write(buffer: ByteArray, offset: Int, len: Int) + /** + * 写入数组。 + * + * 如果要写入 String 字符串,请通过 [getCharset] 编码。 + */ fun write(buffer: ByteArray) { write(buffer, 0, buffer.size) } - fun write(buffer: String) { - if (buffer.isEmpty()) return - write(buffer.toByteArray()) - } - + /** + * 写入单个 Int + */ fun write(buffer: Int) { write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array()) } @@ -43,4 +47,8 @@ interface PtyConnector { */ fun close() + /** + * 编码 + */ + fun getCharset(): Charset = Charsets.UTF_8 } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/PtyConnectorDelegate.kt b/src/main/kotlin/app/termora/terminal/PtyConnectorDelegate.kt index d20d271..73fd34b 100644 --- a/src/main/kotlin/app/termora/terminal/PtyConnectorDelegate.kt +++ b/src/main/kotlin/app/termora/terminal/PtyConnectorDelegate.kt @@ -1,5 +1,7 @@ package app.termora.terminal +import java.nio.charset.Charset + open class PtyConnectorDelegate( @Volatile var ptyConnector: PtyConnector? = null ) : PtyConnector { @@ -26,5 +28,7 @@ open class PtyConnectorDelegate( ptyConnector = null } - + override fun getCharset(): Charset { + return ptyConnector?.getCharset() ?: super.getCharset() + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/PtyProcessConnector.kt b/src/main/kotlin/app/termora/terminal/PtyProcessConnector.kt index e4a3ea9..035d47f 100644 --- a/src/main/kotlin/app/termora/terminal/PtyProcessConnector.kt +++ b/src/main/kotlin/app/termora/terminal/PtyProcessConnector.kt @@ -20,9 +20,6 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset: output.flush() } - override fun write(buffer: String) { - write(buffer.toByteArray(charset)) - } override fun resize(rows: Int, cols: Int) { process.winSize = WinSize(cols, rows) @@ -38,5 +35,7 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset: process.destroyForcibly() } - + override fun getCharset(): Charset { + return charset + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index 268279d..f7159f9 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -298,7 +298,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect // 输入法提交 if (committedCharacterCount > 0) { - ptyConnector.write(sb.toString()) + ptyConnector.write(sb.toString().toByteArray(ptyConnector.getCharset())) } else { val breakIterator = BreakIterator.getCharacterInstance() val chars = mutableListOf() @@ -404,9 +404,15 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect } if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) { - ptyConnector.write("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~") + val bytes = ptyConnector.getCharset() + .encode("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~") + .array() + ptyConnector.write(bytes) } else { - ptyConnector.write(content) + val bytes = ptyConnector.getCharset() + .encode(content) + .array() + ptyConnector.write(bytes) } terminal.getScrollingModel().scrollToRow( diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelKeyAdapter.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelKeyAdapter.kt index da8e6ec..e0195fa 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelKeyAdapter.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelKeyAdapter.kt @@ -24,7 +24,7 @@ class TerminalPanelKeyAdapter( } terminal.getSelectionModel().clearSelection() - ptyConnector.write("${e.keyChar}") + ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset())) terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) } @@ -47,7 +47,7 @@ class TerminalPanelKeyAdapter( val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e)) if (encode.isNotEmpty()) { - ptyConnector.write(encode) + ptyConnector.write(encode.toByteArray(ptyConnector.getCharset())) } // https://github.com/TermoraDev/termora/issues/52 @@ -64,7 +64,7 @@ class TerminalPanelKeyAdapter( terminal.getSelectionModel().clearSelection() // 如果不为空表示已经发送过了,所以这里为空的时候再发送 if (encode.isEmpty()) { - ptyConnector.write("${e.keyChar}") + ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset())) } terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) } diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseTrackingAdapter.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseTrackingAdapter.kt index 67bcc17..b3332da 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseTrackingAdapter.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanelMouseTrackingAdapter.kt @@ -70,9 +70,9 @@ class TerminalPanelMouseTrackingAdapter( val encode = terminal.getKeyEncoder() .encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN)) if (encode.isBlank()) return - + val bytes = encode.toByteArray(ptyConnector.getCharset()) for (i in 0 until abs(unitsToScroll)) { - ptyConnector.write(encode) + ptyConnector.write(bytes) } } } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 988f376..9f9827c 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -145,6 +145,14 @@ termora.new-host.terminal.heartbeat-interval=Heartbeat Interval termora.new-host.terminal.startup-commands=Startup Command termora.new-host.terminal.env=Environment +termora.new-host.serial=Serial +termora.new-host.serial.port=Port +termora.new-host.serial.baud-rate=Baud rate +termora.new-host.serial.data-bits=Data bits +termora.new-host.serial.parity=Parity +termora.new-host.serial.stop-bits=Stop bits +termora.new-host.serial.flow-control=Flow control + termora.new-host.tunneling=Tunneling termora.new-host.tunneling.table.name=Name termora.new-host.tunneling.table.type=Type diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 04bbfba..8fc611a 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -132,6 +132,14 @@ termora.new-host.terminal.startup-commands=启动命令 termora.new-host.terminal.env=环境 +termora.new-host.serial=串口 +termora.new-host.serial.port=端口 +termora.new-host.serial.baud-rate=波特率 +termora.new-host.serial.data-bits=数据位 +termora.new-host.serial.parity=校验位 +termora.new-host.serial.stop-bits=停止位 +termora.new-host.serial.flow-control=流控 + termora.new-host.test-connection=测试连接 termora.new-host.test-connection-successful=连接成功 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index dde1149..a68657b 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -130,6 +130,14 @@ termora.new-host.terminal.startup-commands=啟動命令 termora.new-host.terminal.heartbeat-interval=心跳間隔 termora.new-host.terminal.env=環境 +termora.new-host.serial=串口 +termora.new-host.serial.port=端口 +termora.new-host.serial.baud-rate=波特率 +termora.new-host.serial.data-bits=資料位 +termora.new-host.serial.parity=校驗位 +termora.new-host.serial.stop-bits=停止位 +termora.new-host.serial.flow-control=流控 + termora.new-host.test-connection=測試連接 termora.new-host.test-connection-successful=連線成功 diff --git a/src/main/resources/icons/plugin.svg b/src/main/resources/icons/plugin.svg new file mode 100644 index 0000000..a6eebf6 --- /dev/null +++ b/src/main/resources/icons/plugin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/plugin_dark.svg b/src/main/resources/icons/plugin_dark.svg new file mode 100644 index 0000000..f731fe3 --- /dev/null +++ b/src/main/resources/icons/plugin_dark.svg @@ -0,0 +1,4 @@ + + + +