Move serial communication to desktop subproject (#756)

Co-authored-by: Erimel <marioluigivideo@gmail.com>
This commit is contained in:
Uriel
2023-07-30 11:48:42 -03:00
committed by GitHub
parent 414482c139
commit f84efc413d
12 changed files with 355 additions and 346 deletions

2
package-lock.json generated
View File

@@ -17321,8 +17321,8 @@
"react-modal": "3.15.1",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.2.2",
"semver": "^7.5.3",
"rollup-plugin-visualizer": "^5.9.2",
"semver": "^7.5.3",
"solarxr-protocol": "file:../solarxr-protocol",
"tailwind-gradient-mask-image": "^1.0.0",
"tailwindcss": "^3.3.2",

View File

@@ -6,7 +6,6 @@
* User Manual available at https://docs.gradle.org/6.3/userguide/java_library_plugin.html
*/
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
plugins {
kotlin("jvm")
@@ -64,7 +63,6 @@ dependencies {
implementation("org.apache.commons:commons-collections4:4.4")
implementation("com.illposed.osc:javaosc-core:0.8")
implementation("com.fazecast:jSerialComm:2.+")
implementation("org.java-websocket:Java-WebSocket:1.+")
implementation("com.melloware:jintellitype:1.+")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
@@ -79,13 +77,3 @@ dependencies {
tasks.test {
useJUnitPlatform()
}
fun String.runCommand(currentWorkingDir: File = file("./")): String {
val byteOut = ByteArrayOutputStream()
project.exec {
workingDir = currentWorkingDir
commandLine = this@runCommand.split("\\s".toRegex())
standardOutput = byteOut
}
return String(byteOut.toByteArray()).trim()
}

View File

@@ -14,6 +14,7 @@ import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.reset.ResetHandler
import dev.slimevr.serial.ProvisioningHandler
import dev.slimevr.serial.SerialHandler
import dev.slimevr.serial.SerialHandlerStub
import dev.slimevr.setup.TapSetupHandler
import dev.slimevr.status.StatusSystem
import dev.slimevr.tracking.processor.HumanPoseManager
@@ -44,8 +45,9 @@ typealias SteamBridgeProvider = (
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
class VRServer @JvmOverloads constructor(
driverBridgeProvider: SteamBridgeProvider = { _: VRServer, _: Tracker, _: List<Tracker> -> null },
feederBridgeProvider: (VRServer) -> ISteamVRBridge? = { _: VRServer -> null },
driverBridgeProvider: SteamBridgeProvider = { _, _, _ -> null },
feederBridgeProvider: (VRServer) -> ISteamVRBridge? = { _ -> null },
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
// configPath is used by VRWorkout, do not remove!
configPath: String,
) : Thread("VRServer") {
@@ -101,7 +103,7 @@ class VRServer @JvmOverloads constructor(
configManager = ConfigManager(configPath)
configManager.loadConfig()
deviceManager = DeviceManager(this)
serialHandler = SerialHandler()
serialHandler = serialHandlerProvider(this)
provisioningHandler = ProvisioningHandler(this)
resetHandler = ResetHandler()
tapSetupHandler = TapSetupHandler()

View File

@@ -1,11 +1,11 @@
package dev.slimevr.protocol.rpc.serial;
import com.fazecast.jSerialComm.SerialPort;
import com.google.flatbuffers.FlatBufferBuilder;
import dev.slimevr.protocol.GenericConnection;
import dev.slimevr.protocol.ProtocolAPI;
import dev.slimevr.protocol.rpc.RPCHandler;
import dev.slimevr.serial.SerialListener;
import dev.slimevr.serial.SerialPort;
import io.eiren.util.logging.LogManager;
import solarxr_protocol.rpc.*;

View File

@@ -1,8 +1,8 @@
package dev.slimevr.serial;
import com.fazecast.jSerialComm.SerialPort;
import dev.slimevr.VRServer;
import io.eiren.util.logging.LogManager;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Timer;
@@ -107,7 +107,7 @@ public class ProvisioningHandler implements SerialListener {
@Override
public void onSerialConnected(SerialPort port) {
public void onSerialConnected(@NotNull SerialPort port) {
if (!isRunning)
return;
this.tryProvisioning();
@@ -121,7 +121,7 @@ public class ProvisioningHandler implements SerialListener {
}
@Override
public void onSerialLog(String str) {
public void onSerialLog(@NotNull String str) {
if (!isRunning)
return;

View File

@@ -1,299 +0,0 @@
package dev.slimevr.serial;
import com.fazecast.jSerialComm.SerialPort;
import com.fazecast.jSerialComm.SerialPortEvent;
import com.fazecast.jSerialComm.SerialPortMessageListener;
import io.eiren.util.logging.LogManager;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.Equator;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class SerialHandler implements SerialPortMessageListener {
private final List<SerialListener> listeners = new CopyOnWriteArrayList<>();
private final Timer getDevicesTimer = new Timer("GetDevicesTimer");
private SerialPort currentPort = null;
private boolean watchingNewDevices = false;
private SerialPort[] lastKnownPorts = new SerialPort[] {};
public SerialHandler() {
startWatchingNewDevices();
}
public void startWatchingNewDevices() {
if (this.watchingNewDevices)
return;
this.watchingNewDevices = true;
this.getDevicesTimer.scheduleAtFixedRate(new TimerTask() {
public void run() {
try {
detectNewPorts();
} catch (Throwable t) {
LogManager
.severe(
"[SerialHandler] Error while watching for new devices, cancelling the \"getDevicesTimer\".",
t
);
getDevicesTimer.cancel();
}
}
}, 0, 3000);
}
public void stopWatchingNewDevices() {
if (!this.watchingNewDevices)
return;
this.watchingNewDevices = false;
this.getDevicesTimer.cancel();
this.getDevicesTimer.purge();
}
public void onNewDevice(SerialPort port) {
this.listeners.forEach((listener) -> listener.onNewSerialDevice(port));
}
public void addListener(SerialListener channel) {
this.listeners.add(channel);
}
public void removeListener(SerialListener l) {
listeners.removeIf(listener -> l == listener);
}
public synchronized boolean openSerial(String portLocation, boolean auto) {
LogManager.info("[SerialHandler] Trying to open: " + portLocation + ", auto: " + auto);
SerialPort[] ports = SerialPort.getCommPorts();
lastKnownPorts = ports;
SerialPort newPort = null;
for (SerialPort port : ports) {
if (!auto && port.getPortLocation().equals(portLocation)) {
newPort = port;
break;
}
if (auto && isKnownBoard(port.getDescriptivePortName())) {
newPort = port;
break;
}
}
if (newPort == null) {
LogManager
.info(
"[SerialHandler] No serial ports found to connect to ("
+ ports.length
+ ") total ports"
);
return false;
}
if (this.isConnected()) {
if (
!newPort.getPortLocation().equals(currentPort.getPortLocation())
|| !newPort
.getDescriptivePortName()
.equals(currentPort.getDescriptivePortName())
) {
LogManager
.info(
"[SerialHandler] Closing current serial port "
+ currentPort.getDescriptivePortName()
);
currentPort.removeDataListener();
currentPort.closePort();
} else {
LogManager.info("[SerialHandler] Reusing already open port");
this.listeners.forEach((listener) -> listener.onSerialConnected(currentPort));
return true;
}
}
currentPort = newPort;
LogManager
.info(
"[SerialHandler] Trying to connect to new serial port "
+ currentPort.getDescriptivePortName()
);
currentPort.setBaudRate(115200);
currentPort.clearRTS();
currentPort.clearDTR();
if (!currentPort.openPort(1000)) {
LogManager
.warning(
"[SerialHandler] Can't open serial port "
+ currentPort.getDescriptivePortName()
+ ", last error: "
+ currentPort.getLastErrorCode()
);
currentPort = null;
return false;
}
currentPort.addDataListener(this);
this.listeners.forEach((listener) -> listener.onSerialConnected(currentPort));
LogManager
.info("[SerialHandler] Serial port " + newPort.getDescriptivePortName() + " is open");
return true;
}
public void rebootRequest() {
this.writeSerial("REBOOT");
}
public void factoryResetRequest() {
this.writeSerial("FRST");
}
public void infoRequest() {
this.writeSerial("GET INFO");
}
public synchronized void closeSerial() {
try {
if (currentPort != null)
currentPort.closePort();
this.listeners.forEach(SerialListener::onSerialDisconnected);
LogManager
.info(
"[SerialHandler] Port "
+ (currentPort != null ? currentPort.getDescriptivePortName() : "null")
+ " closed okay"
);
currentPort = null;
} catch (Exception e) {
LogManager
.warning(
"[SerialHandler] Error closing port "
+ (currentPort != null ? currentPort.getDescriptivePortName() : "null"),
e
);
}
}
private synchronized void writeSerial(String serialText) {
if (currentPort == null)
return;
OutputStream os = currentPort.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(os);
try {
writer.append(serialText).append("\n");
writer.flush();
this.addLog("-> " + serialText + "\n");
} catch (IOException e) {
addLog("[!] Serial error: " + e.getMessage() + "\n");
LogManager.warning("[SerialHandler] Serial port write error", e);
}
}
public synchronized void setWifi(String ssid, String passwd) {
if (currentPort == null)
return;
OutputStream os = currentPort.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(os);
try {
writer.append("SET WIFI \"").append(ssid).append("\" \"").append(passwd).append("\"\n");
writer.flush();
this.addLog("-> SET WIFI \"" + ssid + "\" \"" + passwd.replaceAll(".", "*") + "\"\n");
} catch (IOException e) {
addLog(e + "\n");
LogManager.warning("[SerialHandler] Serial port write error", e);
}
}
public void addLog(String str) {
LogManager.info("[Serial] " + str);
this.listeners.forEach(listener -> listener.onSerialLog(str));
}
@Override
public int getListeningEvents() {
return SerialPort.LISTENING_EVENT_PORT_DISCONNECTED
| SerialPort.LISTENING_EVENT_DATA_RECEIVED;
}
@Override
public void serialEvent(SerialPortEvent event) {
if (event.getEventType() == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
byte[] newData = event.getReceivedData();
String s = StandardCharsets.UTF_8.decode(ByteBuffer.wrap(newData)).toString();
this.addLog(s);
} else if (event.getEventType() == SerialPort.LISTENING_EVENT_PORT_DISCONNECTED) {
this.closeSerial();
}
}
public synchronized boolean isConnected() {
return this.currentPort != null && this.currentPort.isOpen();
}
@Override
public byte[] getMessageDelimiter() {
return new byte[] { (byte) 0x0A };
}
@Override
public boolean delimiterIndicatesEndOfMessage() {
return true;
}
public Stream<SerialPort> getKnownPorts() {
return Arrays
.stream(SerialPort.getCommPorts())
.filter((port) -> isKnownBoard(port.getDescriptivePortName()));
}
private boolean isKnownBoard(String com) {
String lowerCom = com.toLowerCase();
return lowerCom.contains("ch340")
|| lowerCom.contains("cp21")
|| lowerCom.contains("ch910")
|| (lowerCom.contains("usb")
&& lowerCom.contains("seri"));
}
private void detectNewPorts() {
try {
List<SerialPort> differences = new ArrayList<>(
CollectionUtils
.removeAll(
this.getKnownPorts().collect(Collectors.toList()),
Arrays.asList(lastKnownPorts),
new Equator<>() {
@Override
public boolean equate(SerialPort o1, SerialPort o2) {
return o1.getPortLocation().equals(o2.getPortLocation())
&& o1
.getDescriptivePortName()
.equals(o1.getDescriptivePortName());
}
@Override
public int hash(SerialPort o) {
return 0;
}
}
)
);
lastKnownPorts = SerialPort.getCommPorts();
differences.forEach(this::onNewDevice);
} catch (Throwable e) {
LogManager
.severe("[SerialHandler] Using serial ports is not supported on this platform", e);
throw new RuntimeException("Serial unsupported");
}
}
}

View File

@@ -0,0 +1,62 @@
package dev.slimevr.serial
import java.util.stream.Stream
abstract class SerialHandler {
abstract val isConnected: Boolean
abstract val knownPorts: Stream<out SerialPort>
abstract fun addListener(channel: SerialListener)
abstract fun removeListener(channel: SerialListener)
abstract fun openSerial(portLocation: String?, auto: Boolean): Boolean
abstract fun rebootRequest()
abstract fun factoryResetRequest()
abstract fun infoRequest()
abstract fun closeSerial()
abstract fun setWifi(ssid: String, passwd: String)
companion object {
val supportedSerial: Set<Pair<Int, Int>> = setOf(
// / QinHeng
// CH340
Pair(0x1A86, 0x7522),
Pair(0x1A86, 0x7523),
// CH341
Pair(0x1A86, 0x5523),
// CH9102x
Pair(0x1A86, 0x55D4),
// / Silabs
// CP210x
Pair(0x10C4, 0xEA60),
// / Espressif
// ESP32-C3
Pair(0x303A, 0x1001)
)
fun isKnownBoard(port: SerialPort): Boolean =
supportedSerial.contains(Pair(port.vendorId, port.productId))
}
}
class SerialHandlerStub() : SerialHandler() {
override val isConnected: Boolean = false
override val knownPorts: Stream<out SerialPort> = Stream.empty()
override fun addListener(channel: SerialListener) {}
override fun removeListener(channel: SerialListener) {}
override fun openSerial(portLocation: String?, auto: Boolean): Boolean {
return false
}
override fun rebootRequest() {}
override fun factoryResetRequest() {}
override fun infoRequest() {}
override fun closeSerial() {}
override fun setWifi(ssid: String, passwd: String) {}
}

View File

@@ -1,15 +0,0 @@
package dev.slimevr.serial;
import com.fazecast.jSerialComm.SerialPort;
public interface SerialListener {
void onSerialConnected(SerialPort port);
void onSerialDisconnected();
void onSerialLog(String str);
void onNewSerialDevice(SerialPort port);
}

View File

@@ -0,0 +1,28 @@
package dev.slimevr.serial
import java.util.*
abstract class SerialPort {
abstract val portLocation: String
abstract val descriptivePortName: String
abstract val vendorId: Int?
abstract val productId: Int?
override fun equals(other: Any?): Boolean {
val other: SerialPort = other as? SerialPort ?: return super.equals(other)
return this.portLocation == other.portLocation &&
this.descriptivePortName == other.descriptivePortName &&
this.vendorId == other.vendorId &&
this.productId == other.productId
}
override fun hashCode(): Int = Objects.hash(portLocation, descriptivePortName, vendorId, productId)
}
interface SerialListener {
fun onSerialConnected(port: SerialPort)
fun onSerialDisconnected()
fun onSerialLog(str: String)
fun onNewSerialDevice(port: SerialPort)
}

View File

@@ -6,7 +6,6 @@
* User Manual available at https://docs.gradle.org/6.3/userguide/java_library_plugin.html
*/
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
plugins {
kotlin("jvm")
@@ -58,6 +57,7 @@ dependencies {
implementation("com.google.protobuf:protobuf-java:3.21.12")
implementation("net.java.dev.jna:jna:5.+")
implementation("net.java.dev.jna:jna-platform:5.+")
implementation("com.fazecast:jSerialComm:2.10.2")
}
tasks.shadowJar {
@@ -76,16 +76,6 @@ application {
mainClass.set("dev.slimevr.desktop.Main")
}
fun String.runCommand(currentWorkingDir: File = file("./")): String {
val byteOut = ByteArrayOutputStream()
project.exec {
workingDir = currentWorkingDir
commandLine = this@runCommand.split("\\s".toRegex())
standardOutput = byteOut
}
return String(byteOut.toByteArray()).trim()
}
buildConfig {
useKotlinOutput { topLevelConstants = true }
packageName("dev.slimevr.desktop")

View File

@@ -9,6 +9,7 @@ import dev.slimevr.bridge.ISteamVRBridge
import dev.slimevr.desktop.platform.SteamVRBridge
import dev.slimevr.desktop.platform.linux.UnixSocketBridge
import dev.slimevr.desktop.platform.windows.WindowsNamedPipeBridge
import dev.slimevr.desktop.serial.DesktopSerialHandler
import dev.slimevr.tracking.trackers.Tracker
import io.eiren.util.OperatingSystem
import io.eiren.util.collections.FastList
@@ -116,7 +117,12 @@ fun main(args: Array<String>) {
try {
val configDir = resolveConfig()
LogManager.info("Using config dir: $configDir")
val vrServer = VRServer(::provideSteamVRBridge, ::provideFeederBridge, configDir)
val vrServer = VRServer(
::provideSteamVRBridge,
::provideFeederBridge,
{ _ -> DesktopSerialHandler() },
configDir
)
vrServer.start()
Keybinding(vrServer)
val scanner = thread {

View File

@@ -0,0 +1,247 @@
package dev.slimevr.desktop.serial
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortEvent
import com.fazecast.jSerialComm.SerialPortMessageListener
import dev.slimevr.serial.SerialHandler
import dev.slimevr.serial.SerialListener
import io.eiren.util.logging.LogManager
import java.io.IOException
import java.io.OutputStreamWriter
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList
import java.util.stream.Stream
import kotlin.concurrent.timerTask
import kotlin.streams.asSequence
import kotlin.streams.asStream
import dev.slimevr.serial.SerialPort as SlimeSerialPort
class SerialPortWrapper(val port: SerialPort) : SlimeSerialPort() {
override val portLocation: String
get() = port.portLocation
override val descriptivePortName: String
get() = port.descriptivePortName
override val vendorId: Int
get() = port.vendorID
override val productId: Int
get() = port.productID
}
class DesktopSerialHandler : SerialHandler(), SerialPortMessageListener {
private val listeners: MutableList<SerialListener> = CopyOnWriteArrayList()
private val getDevicesTimer = Timer("GetDevicesTimer")
private var currentPort: SerialPort? = null
private var watchingNewDevices = false
private var lastKnownPorts = setOf<SerialPortWrapper>()
init {
startWatchingNewDevices()
}
fun startWatchingNewDevices() {
if (watchingNewDevices) return
watchingNewDevices = true
getDevicesTimer.scheduleAtFixedRate(
timerTask {
try {
detectNewPorts()
} catch (t: Throwable) {
LogManager.severe(
"[SerialHandler] Error while watching for new devices, cancelling the \"getDevicesTimer\".",
t
)
getDevicesTimer.cancel()
}
},
0,
3000
)
}
fun stopWatchingNewDevices() {
if (!watchingNewDevices) return
watchingNewDevices = false
getDevicesTimer.cancel()
getDevicesTimer.purge()
}
fun onNewDevice(port: SerialPort) {
listeners.forEach { it.onNewSerialDevice(SerialPortWrapper(port)) }
}
override fun addListener(channel: SerialListener) {
listeners.add(channel)
}
override fun removeListener(channel: SerialListener) {
listeners.removeIf { channel === it }
}
@Synchronized
override fun openSerial(portLocation: String?, auto: Boolean): Boolean {
LogManager.info("[SerialHandler] Trying to open: $portLocation, auto: $auto")
val ports = SerialPort.getCommPorts()
lastKnownPorts = ports.map { SerialPortWrapper(it) }.toSet()
val newPort: SerialPort? = ports.find {
(!auto && it.portLocation == portLocation) ||
(auto && isKnownBoard(SerialPortWrapper(it)))
}
if (newPort == null) {
LogManager.info(
"[SerialHandler] No serial ports found to connect to (${ports.size}) total ports"
)
return false
}
if (isConnected) {
if (SerialPortWrapper(newPort) != currentPort?.let { SerialPortWrapper(it) }) {
LogManager.info(
"[SerialHandler] Closing current serial port " +
currentPort!!.descriptivePortName
)
currentPort!!.removeDataListener()
currentPort!!.closePort()
} else {
LogManager.info("[SerialHandler] Reusing already open port")
listeners.forEach { it.onSerialConnected(SerialPortWrapper(currentPort!!)) }
return true
}
}
currentPort = newPort
LogManager.info(
"[SerialHandler] Trying to connect to new serial port " +
currentPort!!.descriptivePortName
)
currentPort?.setBaudRate(115200)
currentPort?.clearRTS()
currentPort?.clearDTR()
if (currentPort?.openPort(1000) == false) {
LogManager.warning(
"[SerialHandler] Can't open serial port ${currentPort?.descriptivePortName}, last error: ${currentPort?.lastErrorCode}"
)
currentPort = null
return false
}
currentPort?.addDataListener(this)
listeners.forEach { it.onSerialConnected(SerialPortWrapper(currentPort!!)) }
LogManager.info("[SerialHandler] Serial port ${newPort.descriptivePortName} is open")
return true
}
override fun rebootRequest() {
writeSerial("REBOOT")
}
override fun factoryResetRequest() {
writeSerial("FRST")
}
override fun infoRequest() {
writeSerial("GET INFO")
}
@Synchronized
override fun closeSerial() {
try {
currentPort?.closePort()
listeners.forEach { it.onSerialDisconnected() }
LogManager.info(
"[SerialHandler] Port ${currentPort?.descriptivePortName} closed okay"
)
currentPort = null
} catch (e: Exception) {
LogManager.warning(
"[SerialHandler] Error closing port ${currentPort?.descriptivePortName}",
e
)
}
}
@Synchronized
private fun writeSerial(serialText: String) {
val os = currentPort?.outputStream ?: return
val writer = OutputStreamWriter(os)
try {
writer.append(serialText).append("\n")
writer.flush()
addLog("-> $serialText\n")
} catch (e: IOException) {
addLog("[!] Serial error: ${e.message}\n")
LogManager.warning("[SerialHandler] Serial port write error", e)
}
}
@Synchronized
override fun setWifi(ssid: String, passwd: String) {
val os = currentPort?.outputStream ?: return
val writer = OutputStreamWriter(os)
try {
writer.append("SET WIFI \"").append(ssid).append("\" \"").append(passwd).append("\"\n")
writer.flush()
addLog("-> SET WIFI \"$ssid\" \"${passwd.replace(".".toRegex(), "*")}\"\n")
} catch (e: IOException) {
addLog("$e\n")
LogManager.warning("[SerialHandler] Serial port write error", e)
}
}
fun addLog(str: String) {
LogManager.info("[Serial] $str")
listeners.forEach { it.onSerialLog(str) }
}
override fun getListeningEvents(): Int {
return (
SerialPort.LISTENING_EVENT_PORT_DISCONNECTED
or SerialPort.LISTENING_EVENT_DATA_RECEIVED
)
}
override fun serialEvent(event: SerialPortEvent) {
when (event.eventType) {
SerialPort.LISTENING_EVENT_DATA_RECEIVED -> {
val newData = event.receivedData
val s = StandardCharsets.UTF_8.decode(ByteBuffer.wrap(newData)).toString()
addLog(s)
}
SerialPort.LISTENING_EVENT_PORT_DISCONNECTED -> {
closeSerial()
}
}
}
@get:Synchronized
override val isConnected: Boolean
get() = currentPort?.isOpen ?: false
override fun getMessageDelimiter(): ByteArray {
return byteArrayOf(0x0A.toByte())
}
override fun delimiterIndicatesEndOfMessage(): Boolean {
return true
}
override val knownPorts: Stream<SerialPortWrapper>
get() = SerialPort.getCommPorts()
.asSequence()
.map { SerialPortWrapper(it) }
.filter { isKnownBoard(it) }
.asStream()
private fun detectNewPorts() {
try {
val differences = knownPorts.asSequence() - lastKnownPorts
lastKnownPorts = SerialPort.getCommPorts().map { SerialPortWrapper(it) }.toSet()
differences.forEach { onNewDevice(it.port) }
} catch (e: Throwable) {
LogManager
.severe("[SerialHandler] Using serial ports is not supported on this platform", e)
throw RuntimeException("Serial unsupported")
}
}
}