mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: convert serial console into the serial plugin
This commit is contained in:
1 parent
708eedb861
commit
791d8656a1
121 files changed
+2216
-1425
No files matched your search
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Converted from core's `src/backend/hosts/serial.ts` and
|
||||
`src/ui/features/serial/` in Termix 2.9.0. Its own port (30011) and nginx
|
||||
blocks are gone; it now serves `/plugin-ws/serial/console` like every other
|
||||
plugin.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Serial
|
||||
|
||||
Serial console access over a device attached to the machine running Termix:
|
||||
the desktop app talks to it through the backend's `serialport` binding, and a
|
||||
browser without the desktop app falls back to the Web Serial API.
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
src/backend/ activate(ctx) / deactivate(), the /console WS route
|
||||
src/frontend/ the rail panel, the tab and their shared transport
|
||||
locales/en.json its strings (English only; the rest are translated)
|
||||
tests/backend/ vitest, node
|
||||
tests/frontend/ vitest, jsdom
|
||||
|
||||
## Native dependency
|
||||
|
||||
`serialport` (and its native `@serialport/bindings-cpp` binding) is this
|
||||
plugin's own npm dependency rather than a host-provided one, but the build
|
||||
never bundles it: see "native dependencies" in
|
||||
packages/plugin-sdk/ARCHITECTURE.md for why, and how Docker, Electron and dev
|
||||
each end up with a working binary anyway.
|
||||
|
||||
## Commands
|
||||
|
||||
npm run build bundle into dist/
|
||||
npm run test run this plugin's tests
|
||||
npm run typecheck
|
||||
|
||||
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
},
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Baud Rate",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Parity",
|
||||
"parityNone": "None",
|
||||
"parityEven": "Even",
|
||||
"parityOdd": "Odd",
|
||||
"connect": "Connect to Serial",
|
||||
"disconnect": "Disconnect",
|
||||
"refreshPorts": "Refresh ports",
|
||||
"connected": "Connected to {{path}} at {{baud}} baud",
|
||||
"disconnected": "Serial port disconnected",
|
||||
"connectionError": "Failed to open serial port",
|
||||
"wsError": "WebSocket error",
|
||||
"errorNoServerUrl": "No server URL configured",
|
||||
"notSupportedTitle": "Serial Not Supported",
|
||||
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
|
||||
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
|
||||
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Reeks",
|
||||
"portLabel": "Hawe",
|
||||
"portPlaceholder": "/dev/ttyUSB0 of COM3",
|
||||
"baudRateLabel": "Baudtempo",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Pariteit",
|
||||
"parityNone": "Geen",
|
||||
"parityEven": "Selfs",
|
||||
"parityOdd": "Vreemd",
|
||||
"connect": "Koppel aan serieel",
|
||||
"disconnect": "Ontkoppel",
|
||||
"refreshPorts": "Verfris poorte",
|
||||
"connected": "Verbind aan {{path}} teen {{baud}} baud",
|
||||
"disconnected": "Seriepoort ontkoppel",
|
||||
"connectionError": "Kon nie die seriepoort oopmaak nie",
|
||||
"wsError": "WebSocket-fout",
|
||||
"errorNoServerUrl": "Geen bediener-URL gekonfigureer nie",
|
||||
"notSupportedTitle": "Serienommer word nie ondersteun nie",
|
||||
"notSupported": "Seriële verbindings vereis 'n blaaier met Web Serial API-ondersteuning (Chrome, Edge of Firefox 151+), of die Termix-lessenaarprogram.",
|
||||
"hideHint": "Jy kan die Seriële oortjie versteek onder Gebruikersprofiel > Voorkoms > Sybalk > Navigasie.",
|
||||
"browserPickerHint": "Klik op Verbind en jou blaaier sal 'n poortkieser oopmaak om die toestel te kies."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Reeks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "منفذ تسلسلي",
|
||||
"portLabel": "المنفذ",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "معدل الباود",
|
||||
"dataBitsLabel": "البيانات",
|
||||
"stopBitsLabel": "التوقف",
|
||||
"parityLabel": "التكافؤ",
|
||||
"parityNone": "بلا",
|
||||
"parityEven": "زوجي",
|
||||
"parityOdd": "فردي",
|
||||
"connect": "اتصال",
|
||||
"disconnect": "قطع الاتصال",
|
||||
"refreshPorts": "تحديث المنافذ",
|
||||
"connected": "متصل بـ {{path}} بسرعة {{baud}} باود",
|
||||
"disconnected": "تم قطع الاتصال بالمنفذ التسلسلي",
|
||||
"connectionError": "فشل في فتح المنفذ التسلسلي",
|
||||
"wsError": "خطأ في WebSocket",
|
||||
"errorNoServerUrl": "لم يتم تكوين عنوان URL للخادم",
|
||||
"notSupportedTitle": "المنفذ التسلسلي غير مدعوم",
|
||||
"notSupported": "تتطلب الاتصالات التسلسلية متصفحًا يدعم Web Serial API (Chrome، أو Edge، أو Firefox 151+)، أو تطبيق Termix لسطح المكتب.",
|
||||
"hideHint": "يمكنك إخفاء تبويب المنفذ التسلسلي من الملف الشخصي > المظهر > الشريط الجانبي > التنقل.",
|
||||
"browserPickerHint": "انقر على Connect وسيفتح متصفحك نافذة اختيار المنفذ لتحديد الجهاز."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "تسلسلي"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Сериен",
|
||||
"portLabel": "Порт",
|
||||
"portPlaceholder": "/dev/ttyUSB0 или COM3",
|
||||
"baudRateLabel": "Скорост на бодове",
|
||||
"dataBitsLabel": "Данни",
|
||||
"stopBitsLabel": "Спри",
|
||||
"parityLabel": "Паритет",
|
||||
"parityNone": "Няма",
|
||||
"parityEven": "Дори",
|
||||
"parityOdd": "Странно",
|
||||
"connect": "Свързване към сериен",
|
||||
"disconnect": "Изключване",
|
||||
"refreshPorts": "Обновяване на портове",
|
||||
"connected": "Свързан към {{path}} при {{baud}} бод",
|
||||
"disconnected": "Серийният порт е прекъснат",
|
||||
"connectionError": "Неуспешно отваряне на серийния порт",
|
||||
"wsError": "Грешка в WebSocket",
|
||||
"errorNoServerUrl": "Няма конфигуриран URL адрес на сървъра",
|
||||
"notSupportedTitle": "Серийният номер не се поддържа",
|
||||
"notSupported": "Серийните връзки изискват браузър с поддръжка на Web Serial API (Chrome, Edge или Firefox 151+) или настолното приложение Termix.",
|
||||
"hideHint": "Можете да скриете раздела „Сериен номер“ в Потребителски профил > Външен вид > Странична лента > Навигация.",
|
||||
"browserPickerHint": "Щракнете върху „Свързване“ и браузърът ви ще отвори инструмент за избор на порт, за да изберете устройството."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Сериен"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "সিরিয়াল",
|
||||
"portLabel": "বন্দর",
|
||||
"portPlaceholder": "/dev/ttyUSB0 অথবা COM3",
|
||||
"baudRateLabel": "বড রেট",
|
||||
"dataBitsLabel": "ডেটা",
|
||||
"stopBitsLabel": "থামুন",
|
||||
"parityLabel": "সমতা",
|
||||
"parityNone": "কোনোটিই না",
|
||||
"parityEven": "এমনকি",
|
||||
"parityOdd": "অদ্ভুত",
|
||||
"connect": "সিরিয়ালের সাথে সংযোগ করুন",
|
||||
"disconnect": "সংযোগ বিচ্ছিন্ন করুন",
|
||||
"refreshPorts": "পোর্টগুলি রিফ্রেশ করুন",
|
||||
"connected": "{{path}} এর সাথে {{baud}} বাউডে সংযুক্ত।",
|
||||
"disconnected": "সিরিয়াল পোর্ট সংযোগ বিচ্ছিন্ন",
|
||||
"connectionError": "সিরিয়াল পোর্ট খুলতে ব্যর্থ হয়েছে",
|
||||
"wsError": "ওয়েব সকেট ত্রুটি",
|
||||
"errorNoServerUrl": "কোন সার্ভার URL কনফিগার করা নেই",
|
||||
"notSupportedTitle": "সিরিয়াল সমর্থিত নয়",
|
||||
"notSupported": "সিরিয়াল সংযোগের জন্য ওয়েব সিরিয়াল এপিআই সমর্থনসহ একটি ব্রাউজার (ক্রোম, এজ, বা ফায়ারফক্স ১৫১+), অথবা টারমিক্স ডেস্কটপ অ্যাপ প্রয়োজন।",
|
||||
"hideHint": "আপনি ইউজার প্রোফাইল > অ্যাপিয়ারেন্স > সাইডবার > নেভিগেশন-এর অধীনে সিরিয়াল ট্যাবটি লুকাতে পারেন।",
|
||||
"browserPickerHint": "কানেক্ট-এ ক্লিক করলে আপনার ব্রাউজারে ডিভাইসটি বেছে নেওয়ার জন্য একটি পোর্ট পিকার খুলবে।"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "সিরিয়াল"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Sèrie",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 o COM3",
|
||||
"baudRateLabel": "velocitat de bauds",
|
||||
"dataBitsLabel": "Dades",
|
||||
"stopBitsLabel": "Atura",
|
||||
"parityLabel": "Paritat",
|
||||
"parityNone": "Cap",
|
||||
"parityEven": "Fins i tot",
|
||||
"parityOdd": "Estrany",
|
||||
"connect": "Connecta't al serial",
|
||||
"disconnect": "Desconnecta",
|
||||
"refreshPorts": "Actualitza els ports",
|
||||
"connected": "Connectat a {{path}} a {{baud}} baud",
|
||||
"disconnected": "Port sèrie desconnectat",
|
||||
"connectionError": "No s'ha pogut obrir el port sèrie",
|
||||
"wsError": "Error de WebSocket",
|
||||
"errorNoServerUrl": "No s'ha configurat cap URL del servidor",
|
||||
"notSupportedTitle": "Número de sèrie no compatible",
|
||||
"notSupported": "Les connexions en sèrie requereixen un navegador compatible amb l'API Web Serial (Chrome, Edge o Firefox 151+) o l'aplicació d'escriptori Termix.",
|
||||
"hideHint": "Podeu amagar la pestanya Sèrie a Perfil d'usuari > Aparença > Barra lateral > Navegació.",
|
||||
"browserPickerHint": "Feu clic a Connecta i el navegador obrirà un selector de ports per triar el dispositiu."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Sèrie"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seriál",
|
||||
"portLabel": "Přístav",
|
||||
"portPlaceholder": "/dev/ttyUSB0 nebo COM3",
|
||||
"baudRateLabel": "Přenosová rychlost",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Zastávka",
|
||||
"parityLabel": "Parita",
|
||||
"parityNone": "Žádný",
|
||||
"parityEven": "Dokonce",
|
||||
"parityOdd": "Zvláštní",
|
||||
"connect": "Připojení k sériovému",
|
||||
"disconnect": "Odpojit",
|
||||
"refreshPorts": "Obnovit porty",
|
||||
"connected": "Připojeno k {{path}} rychlostí {{baud}} baudu",
|
||||
"disconnected": "Sériový port odpojen",
|
||||
"connectionError": "Nepodařilo se otevřít sériový port",
|
||||
"wsError": "Chyba WebSocketu",
|
||||
"errorNoServerUrl": "Není nakonfigurována žádná adresa URL serveru",
|
||||
"notSupportedTitle": "Sériové číslo není podporováno",
|
||||
"notSupported": "Sériová připojení vyžadují prohlížeč s podporou webového sériového API (Chrome, Edge nebo Firefox 151+) nebo desktopovou aplikaci Termix.",
|
||||
"hideHint": "Kartu Sériové číslo můžete skrýt v části Profil uživatele > Vzhled > Postranní panel > Navigace.",
|
||||
"browserPickerHint": "Klikněte na tlačítko Připojit a prohlížeč otevře výběr portu pro výběr zařízení."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seriál"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seriel",
|
||||
"portLabel": "Havn",
|
||||
"portPlaceholder": "/dev/ttyUSB0 eller COM3",
|
||||
"baudRateLabel": "Baudhastighed",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Paritet",
|
||||
"parityNone": "Ingen",
|
||||
"parityEven": "Endog",
|
||||
"parityOdd": "Ulige",
|
||||
"connect": "Tilslut til seriel",
|
||||
"disconnect": "Afbryde",
|
||||
"refreshPorts": "Opdater porte",
|
||||
"connected": "Forbundet til {{path}} ved {{baud}} baud",
|
||||
"disconnected": "Seriel port afbrudt",
|
||||
"connectionError": "Kunne ikke åbne seriel port",
|
||||
"wsError": "WebSocket-fejl",
|
||||
"errorNoServerUrl": "Ingen server-URL konfigureret",
|
||||
"notSupportedTitle": "Seriel understøttes ikke",
|
||||
"notSupported": "Serielle forbindelser kræver en browser med Web Serial API-understøttelse (Chrome, Edge eller Firefox 151+) eller Termix-desktopappen.",
|
||||
"hideHint": "Du kan skjule fanen Seriel under Brugerprofil > Udseende > Sidebjælke > Navigation.",
|
||||
"browserPickerHint": "Klik på Opret forbindelse, og din browser åbner en portvælger, hvor du kan vælge enheden."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seriel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seriell",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 oder COM3",
|
||||
"baudRateLabel": "Baudrate",
|
||||
"dataBitsLabel": "Daten",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Parität",
|
||||
"parityNone": "Keine",
|
||||
"parityEven": "Gerade",
|
||||
"parityOdd": "Ungerade",
|
||||
"connect": "Mit Seriell verbinden",
|
||||
"disconnect": "Trennen",
|
||||
"refreshPorts": "Ports aktualisieren",
|
||||
"connected": "Verbunden mit {{path}} bei {{baud}} Baud",
|
||||
"disconnected": "Serielle Schnittstelle getrennt",
|
||||
"connectionError": "Fehler beim Öffnen der seriellen Schnittstelle",
|
||||
"wsError": "WebSocket-Fehler",
|
||||
"errorNoServerUrl": "Keine Server-URL konfiguriert",
|
||||
"notSupportedTitle": "Seriell nicht unterstützt",
|
||||
"notSupported": "Serielle Verbindungen erfordern einen Browser mit Web Serial API-Unterstützung (Chrome, Edge oder Firefox 151+) oder die Termix-Desktop-App.",
|
||||
"hideHint": "Sie können den Seriell-Tab unter Benutzerprofil > Erscheinungsbild > Seitenleiste > Navigation ausblenden.",
|
||||
"browserPickerHint": "Klicken Sie auf Verbinden, und Ihr Browser öffnet eine Port-Auswahl, um das Gerät auszuwählen."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seriell"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Σειράς",
|
||||
"portLabel": "Λιμάνι",
|
||||
"portPlaceholder": "/dev/ttyUSB0 ή COM3",
|
||||
"baudRateLabel": "Ρυθμός Baud",
|
||||
"dataBitsLabel": "Δεδομένα",
|
||||
"stopBitsLabel": "Στάση",
|
||||
"parityLabel": "Ισοτιμία",
|
||||
"parityNone": "Κανένας",
|
||||
"parityEven": "Ακόμη και",
|
||||
"parityOdd": "Περιττός",
|
||||
"connect": "Σύνδεση σε σειριακή σύνδεση",
|
||||
"disconnect": "Αποσυνδέω",
|
||||
"refreshPorts": "Ανανέωση θυρών",
|
||||
"connected": "Συνδεδεμένο με {{path}} σε {{baud}} baud",
|
||||
"disconnected": "Η σειριακή θύρα αποσυνδέθηκε",
|
||||
"connectionError": "Αποτυχία ανοίγματος σειριακής θύρας",
|
||||
"wsError": "Σφάλμα WebSocket",
|
||||
"errorNoServerUrl": "Δεν έχει ρυθμιστεί διεύθυνση URL διακομιστή",
|
||||
"notSupportedTitle": "Σειριακή Δεν Υποστηρίζεται",
|
||||
"notSupported": "Οι σειριακές συνδέσεις απαιτούν ένα πρόγραμμα περιήγησης με υποστήριξη Web Serial API (Chrome, Edge ή Firefox 151+) ή την εφαρμογή Termix για υπολογιστές.",
|
||||
"hideHint": "Μπορείτε να αποκρύψετε την καρτέλα Σειριακός αριθμός στην περιοχή Προφίλ χρήστη > Εμφάνιση > Πλαϊνή μπάρα > Πλοήγηση.",
|
||||
"browserPickerHint": "Κάντε κλικ στην επιλογή Σύνδεση και το πρόγραμμα περιήγησής σας θα ανοίξει ένα πρόγραμμα επιλογής θυρών για να επιλέξετε τη συσκευή."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Σειράς"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Puerto",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Baudios",
|
||||
"dataBitsLabel": "Datos",
|
||||
"stopBitsLabel": "Parada",
|
||||
"parityLabel": "Paridad",
|
||||
"parityNone": "Ninguna",
|
||||
"parityEven": "Par",
|
||||
"parityOdd": "Impar",
|
||||
"connect": "Conectar a Serial",
|
||||
"disconnect": "Desconectar",
|
||||
"refreshPorts": "Actualizar puertos",
|
||||
"connected": "Conectado a {{path}} a {{baud}} baudios",
|
||||
"disconnected": "Puerto serie desconectado",
|
||||
"connectionError": "Error al abrir el puerto serie",
|
||||
"wsError": "Error WebSocket",
|
||||
"errorNoServerUrl": "No se ha configurado URL de servidor",
|
||||
"notSupportedTitle": "Serial no compatible",
|
||||
"notSupported": "Las conexiones serie requieren un navegador compatible con Web Serial API (Chrome, Edge o Firefox 151+), o la aplicación de escritorio Termix.",
|
||||
"hideHint": "Puedes ocultar la pestaña Serial en Perfil de usuario > Apariencia > Barra lateral > Navegación.",
|
||||
"browserPickerHint": "Haz clic en Conectar y el navegador abrirá un selector de puertos para elegir el dispositivo."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Sarjanumero",
|
||||
"portLabel": "Portti",
|
||||
"portPlaceholder": "/dev/ttyUSB0 tai COM3",
|
||||
"baudRateLabel": "Baud-nopeus",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Pariteetti",
|
||||
"parityNone": "Ei mitään",
|
||||
"parityEven": "Jopa",
|
||||
"parityOdd": "Pariton",
|
||||
"connect": "Yhdistä sarjaporttiin",
|
||||
"disconnect": "Katkaise yhteys",
|
||||
"refreshPorts": "Päivitä portit",
|
||||
"connected": "Yhdistetty {{path}} -laitteeseen {{baud}} baudilla",
|
||||
"disconnected": "Sarjaportti irrotettu",
|
||||
"connectionError": "Sarjaportin avaaminen epäonnistui",
|
||||
"wsError": "WebSocket-virhe",
|
||||
"errorNoServerUrl": "Palvelimen URL-osoitetta ei ole määritetty",
|
||||
"notSupportedTitle": "Sarjanumeroa ei tueta",
|
||||
"notSupported": "Sarjaliikenne vaatii selaimen, jossa on Web Serial API -tuki (Chrome, Edge tai Firefox 151+), tai Termix-työpöytäsovelluksen.",
|
||||
"hideHint": "Voit piilottaa Sarja-välilehden kohdassa Käyttäjäprofiili > Ulkoasu > Sivupalkki > Navigointi.",
|
||||
"browserPickerHint": "Napsauta Yhdistä, niin selaimesi avaa porttivalitsimen laitteen valitsemiseksi."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Sarjanumero"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Série",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Débit en bauds",
|
||||
"dataBitsLabel": "Données",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Parité",
|
||||
"parityNone": "Aucune",
|
||||
"parityEven": "Paire",
|
||||
"parityOdd": "Impaire",
|
||||
"connect": "Connecter",
|
||||
"disconnect": "Déconnecter",
|
||||
"refreshPorts": "Actualiser les ports",
|
||||
"connected": "Connecté à {{path}} à {{baud}} bauds",
|
||||
"disconnected": "Port série déconnecté",
|
||||
"connectionError": "Échec de l'ouverture du port série",
|
||||
"wsError": "Erreur WebSocket",
|
||||
"errorNoServerUrl": "Aucune URL de serveur configurée",
|
||||
"notSupportedTitle": "Port série non pris en charge",
|
||||
"notSupported": "Les connexions série nécessitent un navigateur prenant en charge l'API Web Serial (Chrome, Edge, ou Firefox 151+), ou l'application de bureau Termix.",
|
||||
"hideHint": "Vous pouvez masquer l'onglet Série sous Profil utilisateur > Apparence > Barre latérale > Navigation.",
|
||||
"browserPickerHint": "Cliquez sur Connexion et votre navigateur ouvrira un sélecteur de port pour choisir le périphérique."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Série"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "סִדרָתִי",
|
||||
"portLabel": "נָמָל",
|
||||
"portPlaceholder": "/dev/ttyUSB0 או COM3",
|
||||
"baudRateLabel": "קצב באוד",
|
||||
"dataBitsLabel": "נְתוּנִים",
|
||||
"stopBitsLabel": "לְהַפְסִיק",
|
||||
"parityLabel": "שִׁוּוּי",
|
||||
"parityNone": "אַף לֹא אֶחָד",
|
||||
"parityEven": "אֲפִילוּ",
|
||||
"parityOdd": "מְשׁוּנֶה",
|
||||
"connect": "חיבור סריאלי",
|
||||
"disconnect": "לְנַתֵק",
|
||||
"refreshPorts": "רענון פורטים",
|
||||
"connected": "מחובר ל- {{path}} ב- {{baud}} באוד",
|
||||
"disconnected": "יציאה טורית מנותקת",
|
||||
"connectionError": "נכשלה פתיחת הפורט הטורי",
|
||||
"wsError": "שגיאת WebSocket",
|
||||
"errorNoServerUrl": "לא הוגדרה כתובת URL של שרת",
|
||||
"notSupportedTitle": "סריאלי לא נתמך",
|
||||
"notSupported": "חיבורים טוריים דורשים דפדפן עם תמיכה ב-Web Serial API (Chrome, Edge או Firefox 151+), או את אפליקציית שולחן העבודה Termix.",
|
||||
"hideHint": "ניתן להסתיר את הכרטיסייה 'סדרתי' תחת פרופיל משתמש > מראה > סרגל צד > ניווט.",
|
||||
"browserPickerHint": "לחץ על התחבר והדפדפן שלך יפתח בורר פורטים לבחירת המכשיר."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "סִדרָתִי"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "सीरियल",
|
||||
"portLabel": "पोर्ट",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "बॉड दर",
|
||||
"dataBitsLabel": "डेटा",
|
||||
"stopBitsLabel": "स्टॉप",
|
||||
"parityLabel": "पैरिटी",
|
||||
"parityNone": "कोई नहीं",
|
||||
"parityEven": "सम",
|
||||
"parityOdd": "विषम",
|
||||
"connect": "सीरियल कनेक्ट करें",
|
||||
"disconnect": "डिस्कनेक्ट करें",
|
||||
"refreshPorts": "पोर्ट रीफ़्रेश करें",
|
||||
"connected": "{{path}} से {{baud}} बॉड पर कनेक्टेड",
|
||||
"disconnected": "सीरियल पोर्ट डिस्कनेक्ट हो गया",
|
||||
"connectionError": "सीरियल पोर्ट खोलने में विफल",
|
||||
"wsError": "WebSocket त्रुटि",
|
||||
"errorNoServerUrl": "कोई सर्वर URL कॉन्फ़िगर नहीं है",
|
||||
"notSupportedTitle": "सीरियल समर्थित नहीं",
|
||||
"notSupported": "सीरियल कनेक्शन के लिए Web Serial API समर्थन वाले ब्राउज़र (Chrome, Edge, या Firefox 151+) या Termix डेस्कटॉप ऐप की आवश्यकता है।",
|
||||
"hideHint": "आप सीरियल टैब को User Profile > Appearance > Sidebar > Navigation में छिपा सकते हैं।",
|
||||
"browserPickerHint": "Connect पर क्लिक करें और आपका ब्राउज़र डिवाइस चुनने के लिए एक पोर्ट पिकर खोलेगा।"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "सीरियल"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Sorozatszám",
|
||||
"portLabel": "Kikötő",
|
||||
"portPlaceholder": "/dev/ttyUSB0 vagy COM3",
|
||||
"baudRateLabel": "Baud sebesség",
|
||||
"dataBitsLabel": "Adat",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Paritás",
|
||||
"parityNone": "Egyik sem",
|
||||
"parityEven": "Még",
|
||||
"parityOdd": "Páratlan",
|
||||
"connect": "Csatlakozás soros porthoz",
|
||||
"disconnect": "Leválasztás",
|
||||
"refreshPorts": "Frissítse a portokat",
|
||||
"connected": "{{path}} -hoz csatlakoztatva {{baud}} baud sebességgel",
|
||||
"disconnected": "Soros port leválasztva",
|
||||
"connectionError": "Nem sikerült megnyitni a soros portot",
|
||||
"wsError": "WebSocket hiba",
|
||||
"errorNoServerUrl": "Nincs konfigurálva szerver URL",
|
||||
"notSupportedTitle": "Sorozatszám Nem támogatott",
|
||||
"notSupported": "A soros kapcsolatokhoz Web Serial API-t támogató böngésző (Chrome, Edge vagy Firefox 151+), vagy a Termix asztali alkalmazás szükséges.",
|
||||
"hideHint": "A Sorozat fület elrejtheted a Felhasználói profil > Megjelenés > Oldalsáv > Navigáció alatt.",
|
||||
"browserPickerHint": "Kattintson a Csatlakozás gombra, és a böngészője megnyit egy portválasztót az eszköz kiválasztásához."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Sorozatszám"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Pelabuhan",
|
||||
"portPlaceholder": "/dev/ttyUSB0 atau COM3",
|
||||
"baudRateLabel": "Kecepatan Baud",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Berhenti",
|
||||
"parityLabel": "Keseimbangan",
|
||||
"parityNone": "Tidak ada",
|
||||
"parityEven": "Bahkan",
|
||||
"parityOdd": "Aneh",
|
||||
"connect": "Hubungkan ke Serial",
|
||||
"disconnect": "Memutuskan",
|
||||
"refreshPorts": "Penyegaran port",
|
||||
"connected": "Terhubung ke {{path}} pada kecepatan {{baud}} baud",
|
||||
"disconnected": "Port serial terputus",
|
||||
"connectionError": "Gagal membuka port serial",
|
||||
"wsError": "Kesalahan WebSocket",
|
||||
"errorNoServerUrl": "Tidak ada URL server yang dikonfigurasi.",
|
||||
"notSupportedTitle": "Nomor Seri Tidak Didukung",
|
||||
"notSupported": "Koneksi serial memerlukan browser dengan dukungan Web Serial API (Chrome, Edge, atau Firefox 151+), atau aplikasi desktop Termix.",
|
||||
"hideHint": "Anda dapat menyembunyikan tab Serial di bawah Profil Pengguna > Tampilan > Bilah Samping > Navigasi.",
|
||||
"browserPickerHint": "Klik Sambungkan dan browser Anda akan membuka pemilih port untuk memilih perangkat."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seriale",
|
||||
"portLabel": "Porta",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Baud rate",
|
||||
"dataBitsLabel": "Dati",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Parità",
|
||||
"parityNone": "Nessuna",
|
||||
"parityEven": "Pari",
|
||||
"parityOdd": "Dispari",
|
||||
"connect": "Connetti alla Seriale",
|
||||
"disconnect": "Disconnetti",
|
||||
"refreshPorts": "Aggiorna porte",
|
||||
"connected": "Connesso a {{path}} a {{baud}} baud",
|
||||
"disconnected": "Porta seriale disconnessa",
|
||||
"connectionError": "Impossibile aprire la porta seriale",
|
||||
"wsError": "Errore WebSocket",
|
||||
"errorNoServerUrl": "Nessun URL del server configurato",
|
||||
"notSupportedTitle": "Seriale non supportato",
|
||||
"notSupported": "Le connessioni seriali richiedono un browser con supporto Web Serial API (Chrome, Edge o Firefox 151+), oppure l'app desktop Termix.",
|
||||
"hideHint": "Puoi nascondere la scheda Seriale in Profilo Utente > Aspetto > Barra laterale > Navigazione.",
|
||||
"browserPickerHint": "Fai clic su Connetti e il browser aprirà un selettore di porte per scegliere il dispositivo."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seriale"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "シリアル",
|
||||
"portLabel": "ポート",
|
||||
"portPlaceholder": "/dev/ttyUSB0 または COM3",
|
||||
"baudRateLabel": "ボーレート",
|
||||
"dataBitsLabel": "データ",
|
||||
"stopBitsLabel": "ストップ",
|
||||
"parityLabel": "パリティ",
|
||||
"parityNone": "なし",
|
||||
"parityEven": "偶数",
|
||||
"parityOdd": "奇数",
|
||||
"connect": "シリアルに接続",
|
||||
"disconnect": "切断",
|
||||
"refreshPorts": "ポートを更新",
|
||||
"connected": "{{path}} に {{baud}} ボーで接続しました",
|
||||
"disconnected": "シリアルポートが切断されました",
|
||||
"connectionError": "シリアルポートを開けませんでした",
|
||||
"wsError": "WebSocket エラー",
|
||||
"errorNoServerUrl": "サーバー URL が設定されていません",
|
||||
"notSupportedTitle": "シリアル非対応",
|
||||
"notSupported": "シリアル接続には Web Serial API をサポートするブラウザ (Chrome, Edge, Firefox 151+) または Termix デスクトップアプリが必要です。",
|
||||
"hideHint": "シリアルタブは「ユーザープロファイル > 外観 > サイドバー > ナビゲーション」で非表示にできます。",
|
||||
"browserPickerHint": "接続をクリックすると、ブラウザがポートピッカーを開き、デバイスを選択できます。"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "シリアル"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "시리얼",
|
||||
"portLabel": "포트",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "보레이트",
|
||||
"dataBitsLabel": "데이터",
|
||||
"stopBitsLabel": "스톱",
|
||||
"parityLabel": "패리티",
|
||||
"parityNone": "없음",
|
||||
"parityEven": "짝수",
|
||||
"parityOdd": "홀수",
|
||||
"connect": "시리얼 연결",
|
||||
"disconnect": "연결 해제",
|
||||
"refreshPorts": "포트 새로 고침",
|
||||
"connected": "{{path}}에 {{baud}} 보로 연결됨",
|
||||
"disconnected": "시리얼 포트 연결 해제됨",
|
||||
"connectionError": "시리얼 포트를 열지 못했습니다.",
|
||||
"wsError": "WebSocket 오류",
|
||||
"errorNoServerUrl": "서버 URL이 구성되지 않았습니다.",
|
||||
"notSupportedTitle": "시리얼 미지원",
|
||||
"notSupported": "시리얼 연결에는 Web Serial API를 지원하는 브라우저(Chrome, Edge, Firefox 151+) 또는 Termix 데스크톱 앱이 필요합니다.",
|
||||
"hideHint": "Serial 탭은 사용자 프로필 > 모양 > 사이드바 > 탐색에서 숨길 수 있습니다.",
|
||||
"browserPickerHint": "연결 버튼을 클릭하면 브라우저에서 장치 선택을 위한 포트 선택기가 열립니다."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "시리얼"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serieel",
|
||||
"portLabel": "Haven",
|
||||
"portPlaceholder": "/dev/ttyUSB0 of COM3",
|
||||
"baudRateLabel": "Baudrate",
|
||||
"dataBitsLabel": "Gegevens",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Pariteit",
|
||||
"parityNone": "Geen",
|
||||
"parityEven": "Zelfs",
|
||||
"parityOdd": "Vreemd",
|
||||
"connect": "Verbinden via serieel",
|
||||
"disconnect": "Verbinding verbreken",
|
||||
"refreshPorts": "Vernieuw poorten",
|
||||
"connected": "Verbonden met {{path}} met {{baud}} baud",
|
||||
"disconnected": "Seriële poort losgekoppeld",
|
||||
"connectionError": "Het openen van de seriële poort is mislukt.",
|
||||
"wsError": "WebSocket-fout",
|
||||
"errorNoServerUrl": "Geen server-URL geconfigureerd",
|
||||
"notSupportedTitle": "Serienummer niet ondersteund",
|
||||
"notSupported": "Voor seriële verbindingen is een browser met Web Serial API-ondersteuning vereist (Chrome, Edge of Firefox 151+), of de Termix-desktopapp.",
|
||||
"hideHint": "Je kunt het tabblad 'Serienummer' verbergen via Gebruikersprofiel > Uiterlijk > Zijbalk > Navigatie.",
|
||||
"browserPickerHint": "Klik op Verbinden en uw browser opent een poortkiezer waarmee u het apparaat kunt selecteren."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serieel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seriell",
|
||||
"portLabel": "Havn",
|
||||
"portPlaceholder": "/dev/ttyUSB0 eller COM3",
|
||||
"baudRateLabel": "Baudhastighet",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stoppe",
|
||||
"parityLabel": "Paritet",
|
||||
"parityNone": "Ingen",
|
||||
"parityEven": "Til og med",
|
||||
"parityOdd": "Merkelig",
|
||||
"connect": "Koble til seriell",
|
||||
"disconnect": "Frakople",
|
||||
"refreshPorts": "Oppdater porter",
|
||||
"connected": "Koblet til {{path}} ved {{baud}} baud",
|
||||
"disconnected": "Seriell port frakoblet",
|
||||
"connectionError": "Klarte ikke å åpne serieporten",
|
||||
"wsError": "WebSocket-feil",
|
||||
"errorNoServerUrl": "Ingen server-URL konfigurert",
|
||||
"notSupportedTitle": "Serienummer støttes ikke",
|
||||
"notSupported": "Serielle tilkoblinger krever en nettleser med støtte for Web Serial API (Chrome, Edge eller Firefox 151+), eller Termix-skrivebordsappen.",
|
||||
"hideHint": "Du kan skjule Seriell-fanen under Brukerprofil > Utseende > Sidefelt > Navigasjon.",
|
||||
"browserPickerHint": "Klikk på Koble til, så åpner nettleseren din en portvelger der du kan velge enheten."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seriell"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Port szeregowy",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 lub COM3",
|
||||
"baudRateLabel": "Szybkość transmisji",
|
||||
"dataBitsLabel": "Dane",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Parzystość",
|
||||
"parityNone": "Brak",
|
||||
"parityEven": "Parzysta",
|
||||
"parityOdd": "Nieparzysta",
|
||||
"connect": "Połącz z portem szeregowym",
|
||||
"disconnect": "Odłącz",
|
||||
"refreshPorts": "Odśwież porty",
|
||||
"connected": "Połączono z {{path}} z szybkością {{baud}} bodów",
|
||||
"disconnected": "Port szeregowy rozłączony",
|
||||
"connectionError": "Nie udało się otworzyć portu szeregowego",
|
||||
"wsError": "Błąd WebSocket",
|
||||
"errorNoServerUrl": "Brak skonfigurowanego adresu URL serwera",
|
||||
"notSupportedTitle": "Port szeregowy nieobsługiwany",
|
||||
"notSupported": "Połączenia szeregowe wymagają przeglądarki obsługującej Web Serial API (Chrome, Edge lub Firefox 151+) albo aplikacji desktopowej Termix.",
|
||||
"hideHint": "Kartę Port szeregowy można ukryć w: Profil użytkownika > Wygląd > Pasek boczny > Nawigacja.",
|
||||
"browserPickerHint": "Kliknij Połącz, a przeglądarka otworzy okno wyboru portu, w którym wskażesz urządzenie."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Port szeregowy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Porta",
|
||||
"portPlaceholder": "/dev/ttyUSB0 ou COM3",
|
||||
"baudRateLabel": "Taxa de transmissão (Baud rate)",
|
||||
"dataBitsLabel": "Dados",
|
||||
"stopBitsLabel": "Parar",
|
||||
"parityLabel": "Paridade",
|
||||
"parityNone": "Nenhum",
|
||||
"parityEven": "Até",
|
||||
"parityOdd": "Chance",
|
||||
"connect": "Conectar à porta serial",
|
||||
"disconnect": "Desconectar",
|
||||
"refreshPorts": "Atualizar portas",
|
||||
"connected": "Conectado a {{path}} na taxa de transmissão de {{baud}}",
|
||||
"disconnected": "Porta serial desconectada",
|
||||
"connectionError": "Falha ao abrir a porta serial",
|
||||
"wsError": "Erro no WebSocket",
|
||||
"errorNoServerUrl": "Nenhuma URL de servidor configurada",
|
||||
"notSupportedTitle": "Serial não suportado",
|
||||
"notSupported": "As conexões seriais exigem um navegador com suporte à API Web Serial (Chrome, Edge ou Firefox 151+) ou o aplicativo Termix para desktop.",
|
||||
"hideHint": "Você pode ocultar a guia Serial em Perfil do Usuário > Aparência > Barra lateral > Navegação.",
|
||||
"browserPickerHint": "Clique em Conectar e seu navegador abrirá um seletor de portas para que você escolha o dispositivo."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Porta",
|
||||
"portPlaceholder": "/dev/ttyUSB0 ou COM3",
|
||||
"baudRateLabel": "Taxa Baud",
|
||||
"dataBitsLabel": "Dados",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Paridade",
|
||||
"parityNone": "Nenhuma",
|
||||
"parityEven": "Par",
|
||||
"parityOdd": "Ímpar",
|
||||
"connect": "Ligar à Serial",
|
||||
"disconnect": "Desligar",
|
||||
"refreshPorts": "Atualizar portas",
|
||||
"connected": "Ligado a {{path}} a {{baud}} baud",
|
||||
"disconnected": "Porta série desligada",
|
||||
"connectionError": "Falha ao abrir a porta série",
|
||||
"wsError": "Erro de WebSocket",
|
||||
"errorNoServerUrl": "Nenhum URL de servidor configurado",
|
||||
"notSupportedTitle": "Série não suportada",
|
||||
"notSupported": "As ligações série requerem um navegador com suporte à Web Serial API (Chrome, Edge ou Firefox 151+) ou a aplicação de ambiente de trabalho Termix.",
|
||||
"hideHint": "Pode ocultar o separador Série em Perfil do Utilizador > Aparência > Barra Lateral > Navegação.",
|
||||
"browserPickerHint": "Clique em Ligar e o seu navegador abrirá um seletor de porta para escolher o dispositivo."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serial",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 sau COM3",
|
||||
"baudRateLabel": "Rată de baud",
|
||||
"dataBitsLabel": "Date",
|
||||
"stopBitsLabel": "Stop",
|
||||
"parityLabel": "Paritate",
|
||||
"parityNone": "Nici unul",
|
||||
"parityEven": "Chiar",
|
||||
"parityOdd": "Ciudat",
|
||||
"connect": "Conectare la serial",
|
||||
"disconnect": "Deconecta",
|
||||
"refreshPorts": "Reîmprospătați porturile",
|
||||
"connected": "Conectat la {{path}} la {{baud}} baud",
|
||||
"disconnected": "Portul serial deconectat",
|
||||
"connectionError": "Nu s-a putut deschide portul serial",
|
||||
"wsError": "Eroare WebSocket",
|
||||
"errorNoServerUrl": "Nu este configurată nicio adresă URL a serverului",
|
||||
"notSupportedTitle": "Număr de serie neacceptat",
|
||||
"notSupported": "Conexiunile seriale necesită un browser cu suport Web Serial API (Chrome, Edge sau Firefox 151+) sau aplicația desktop Termix.",
|
||||
"hideHint": "Puteți ascunde fila Serial din Profil utilizator > Aspect > Bara laterală > Navigare.",
|
||||
"browserPickerHint": "Faceți clic pe Conectare și browserul dvs. va deschide un selector de porturi pentru a alege dispozitivul."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Последовательный порт",
|
||||
"portLabel": "Порт",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Скорость передачи",
|
||||
"dataBitsLabel": "Биты данных",
|
||||
"stopBitsLabel": "Стоп-биты",
|
||||
"parityLabel": "Чётность",
|
||||
"parityNone": "Нет",
|
||||
"parityEven": "Чётный",
|
||||
"parityOdd": "Нечётный",
|
||||
"connect": "Подключиться",
|
||||
"disconnect": "Отключиться",
|
||||
"refreshPorts": "Обновить порты",
|
||||
"connected": "Подключено к {{path}} на скорости {{baud}} бод",
|
||||
"disconnected": "Последовательный порт отключен",
|
||||
"connectionError": "Ошибка открытия последовательного порта",
|
||||
"wsError": "Ошибка WebSocket",
|
||||
"errorNoServerUrl": "Не настроен URL сервера",
|
||||
"notSupportedTitle": "Последовательный порт не поддерживается",
|
||||
"notSupported": "Последовательные подключения требуют браузер с поддержкой Web Serial API (Chrome, Edge или Firefox 151+) или десктопное приложение Termix.",
|
||||
"hideHint": "Вы можете скрыть вкладку «Serial» в разделе «Профиль пользователя» > «Оформление» > «Боковая панель» > «Навигация».",
|
||||
"browserPickerHint": "Нажмите «Подключиться», и браузер откроет окно выбора порта для выбора устройства."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Последовательный порт"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Серијски",
|
||||
"portLabel": "Лука",
|
||||
"portPlaceholder": "/dev/ttyUSB0 или COM3",
|
||||
"baudRateLabel": "Брзина преноса података",
|
||||
"dataBitsLabel": "Подаци",
|
||||
"stopBitsLabel": "Заустави",
|
||||
"parityLabel": "Паритет",
|
||||
"parityNone": "Ниједан",
|
||||
"parityEven": "Чак",
|
||||
"parityOdd": "Чудно",
|
||||
"connect": "Повежи се са серијским",
|
||||
"disconnect": "Прекини везу",
|
||||
"refreshPorts": "Освежи портове",
|
||||
"connected": "Повезано са {{path}} брзином {{baud}} бауда",
|
||||
"disconnected": "Серијски порт је искључен",
|
||||
"connectionError": "Отварање серијског порта није успело",
|
||||
"wsError": "Грешка WebSocket-а",
|
||||
"errorNoServerUrl": "Није конфигурисан URL сервера",
|
||||
"notSupportedTitle": "Серијски број није подржан",
|
||||
"notSupported": "Серијске везе захтевају прегледач са подршком за Web Serial API (Chrome, Edge или Firefox 151+) или Termix десктоп апликацију.",
|
||||
"hideHint": "Картицу Серијски број можете сакрити под Кориснички профил > Изглед > Бочна трака > Навигација.",
|
||||
"browserPickerHint": "Кликните на Повежи се и ваш прегледач ће отворити бирач портова за избор уређаја."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Серијски"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Serie",
|
||||
"portLabel": "Hamn",
|
||||
"portPlaceholder": "/dev/ttyUSB0 eller COM3",
|
||||
"baudRateLabel": "Baudhastighet",
|
||||
"dataBitsLabel": "Data",
|
||||
"stopBitsLabel": "Stopp",
|
||||
"parityLabel": "Paritet",
|
||||
"parityNone": "Ingen",
|
||||
"parityEven": "Även",
|
||||
"parityOdd": "Udda",
|
||||
"connect": "Anslut till seriellt",
|
||||
"disconnect": "Koppla från",
|
||||
"refreshPorts": "Uppdatera portar",
|
||||
"connected": "Ansluten till {{path}} vid {{baud}} baud",
|
||||
"disconnected": "Seriell port frånkopplad",
|
||||
"connectionError": "Misslyckades med att öppna serieporten",
|
||||
"wsError": "WebSocket-fel",
|
||||
"errorNoServerUrl": "Ingen server-URL konfigurerad",
|
||||
"notSupportedTitle": "Seriellt stöds inte",
|
||||
"notSupported": "Seriella anslutningar kräver en webbläsare med stöd för Web Serial API (Chrome, Edge eller Firefox 151+), eller Termix-skrivbordsappen.",
|
||||
"hideHint": "Du kan dölja fliken Seriell under Användarprofil > Utseende > Sidofält > Navigering.",
|
||||
"browserPickerHint": "Klicka på Anslut så öppnar din webbläsare en portväljare där du kan välja enheten."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Serie"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "ซีเรียล",
|
||||
"portLabel": "ท่าเรือ",
|
||||
"portPlaceholder": "/dev/ttyUSB0 หรือ COM3",
|
||||
"baudRateLabel": "อัตราบิต",
|
||||
"dataBitsLabel": "ข้อมูล",
|
||||
"stopBitsLabel": "หยุด",
|
||||
"parityLabel": "ความเท่าเทียมกัน",
|
||||
"parityNone": "ไม่มี",
|
||||
"parityEven": "สม่ำเสมอ",
|
||||
"parityOdd": "แปลก",
|
||||
"connect": "เชื่อมต่อกับพอร์ตอนุกรม",
|
||||
"disconnect": "ตัดการเชื่อมต่อ",
|
||||
"refreshPorts": "พอร์ตรีเฟรช",
|
||||
"connected": "เชื่อมต่อกับ {{path}} ที่ความเร็ว {{baud}} บอด",
|
||||
"disconnected": "พอร์ตอนุกรมถูกตัดการเชื่อมต่อ",
|
||||
"connectionError": "ไม่สามารถเปิดพอร์ตอนุกรมได้",
|
||||
"wsError": "ข้อผิดพลาด WebSocket",
|
||||
"errorNoServerUrl": "ไม่ได้กำหนดค่า URL เซิร์ฟเวอร์",
|
||||
"notSupportedTitle": "ไม่รองรับหมายเลขซีเรียล",
|
||||
"notSupported": "การเชื่อมต่อแบบอนุกรมจำเป็นต้องใช้เบราว์เซอร์ที่รองรับ Web Serial API (Chrome, Edge หรือ Firefox เวอร์ชัน 151 ขึ้นไป) หรือแอปพลิเคชัน Termix บนเดสก์ท็อป",
|
||||
"hideHint": "คุณสามารถซ่อนแท็บ Serial ได้ที่ โปรไฟล์ผู้ใช้ > ลักษณะที่ปรากฏ > แถบด้านข้าง > การนำทาง",
|
||||
"browserPickerHint": "คลิก \"เชื่อมต่อ\" แล้วเบราว์เซอร์ของคุณจะเปิดตัวเลือกพอร์ตเพื่อเลือกอุปกรณ์"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "ซีเรียล"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Seri",
|
||||
"portLabel": "Port",
|
||||
"portPlaceholder": "/dev/ttyUSB0 veya COM3",
|
||||
"baudRateLabel": "Baud Hızı",
|
||||
"dataBitsLabel": "Veri",
|
||||
"stopBitsLabel": "Durdurma",
|
||||
"parityLabel": "Eşlik",
|
||||
"parityNone": "Yok",
|
||||
"parityEven": "Çift",
|
||||
"parityOdd": "Tek",
|
||||
"connect": "Seri Bağlantıya Bağlan",
|
||||
"disconnect": "Bağlantıyı Kes",
|
||||
"refreshPorts": "Portları yenile",
|
||||
"connected": "{{path}} ile {{baud}} baud hızında bağlandı.",
|
||||
"disconnected": "Seri port bağlantısı kesildi.",
|
||||
"connectionError": "Seri bağlantı noktası açılamadı",
|
||||
"wsError": "WebSocket hatası",
|
||||
"errorNoServerUrl": "Sunucu URL'si yapılandırılmamış",
|
||||
"notSupportedTitle": "Seri Bağlantı Desteklenmiyor",
|
||||
"notSupported": "Seri bağlantılar, Web Serial API desteği olan bir tarayıcı (Chrome, Edge veya Firefox 151+) ya da Termix masaüstü uygulaması gerektirir.",
|
||||
"hideHint": "Seri sekmesini Kullanıcı Profili > Görünüm > Kenar Çubuğu > Gezinme altından gizleyebilirsiniz.",
|
||||
"browserPickerHint": "Bağlan'a tıklayın, tarayıcınız cihazı seçmek için bir bağlantı noktası seçici açacaktır."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Seri"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Серійний",
|
||||
"portLabel": "Порт",
|
||||
"portPlaceholder": "/dev/ttyUSB0 або COM3",
|
||||
"baudRateLabel": "Швидкість передачі даних",
|
||||
"dataBitsLabel": "Дані",
|
||||
"stopBitsLabel": "СТІЙ",
|
||||
"parityLabel": "Паритет",
|
||||
"parityNone": "Жоден",
|
||||
"parityEven": "Навіть",
|
||||
"parityOdd": "Дивний",
|
||||
"connect": "Підключення до послідовного порту",
|
||||
"disconnect": "Відключитися",
|
||||
"refreshPorts": "Оновити порти",
|
||||
"connected": "Підключено до {{path}} зі швидкістю {{baud}} бод",
|
||||
"disconnected": "Послідовний порт відключено",
|
||||
"connectionError": "Не вдалося відкрити послідовний порт",
|
||||
"wsError": "Помилка WebSocket",
|
||||
"errorNoServerUrl": "URL-адреса сервера не налаштована",
|
||||
"notSupportedTitle": "Серійний номер не підтримується",
|
||||
"notSupported": "Для послідовних з’єднань потрібен браузер із підтримкою Web Serial API (Chrome, Edge або Firefox 151+) або настільний додаток Termix.",
|
||||
"hideHint": "Ви можете приховати вкладку «Серійний номер» у розділі «Профіль користувача» > «Зовнішній вигляд» > «Бічна панель» > «Навігація».",
|
||||
"browserPickerHint": "Натисніть «Підключитися», і ваш браузер відкриє вибір порту для вибору пристрою."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Серійний"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "Cổng nối tiếp",
|
||||
"portLabel": "Cổng",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "Tốc độ Baud",
|
||||
"dataBitsLabel": "Dữ liệu",
|
||||
"stopBitsLabel": "Bit dừng",
|
||||
"parityLabel": "Kiểm tra chẵn lẻ",
|
||||
"parityNone": "Không",
|
||||
"parityEven": "Chẵn",
|
||||
"parityOdd": "Lẻ",
|
||||
"connect": "Kết nối cổng nối tiếp",
|
||||
"disconnect": "Ngắt kết nối",
|
||||
"refreshPorts": "Làm mới cổng",
|
||||
"connected": "Đã kết nối tới {{path}} với tốc độ {{baud}} baud",
|
||||
"disconnected": "Đã ngắt kết nối cổng nối tiếp",
|
||||
"connectionError": "Không thể mở cổng nối tiếp",
|
||||
"wsError": "Lỗi WebSocket",
|
||||
"errorNoServerUrl": "Chưa cấu hình URL máy chủ",
|
||||
"notSupportedTitle": "Không hỗ trợ Serial",
|
||||
"notSupported": "Kết nối nối tiếp yêu cầu trình duyệt hỗ trợ Web Serial API (Chrome, Edge, hoặc Firefox 151+), hoặc ứng dụng Termix desktop.",
|
||||
"hideHint": "Bạn có thể ẩn tab Serial trong Hồ sơ người dùng > Giao diện > Thanh bên > Điều hướng.",
|
||||
"browserPickerHint": "Nhấn Kết nối và trình duyệt sẽ mở bộ chọn cổng để chọn thiết bị."
|
||||
},
|
||||
"nav": {
|
||||
"serial": "Nối tiếp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "串口",
|
||||
"portLabel": "端口",
|
||||
"portPlaceholder": "/dev/ttyUSB0 或 COM3",
|
||||
"baudRateLabel": "波特率",
|
||||
"dataBitsLabel": "数据",
|
||||
"stopBitsLabel": "停止位",
|
||||
"parityLabel": "奇偶校验",
|
||||
"parityNone": "无",
|
||||
"parityEven": "偶校验",
|
||||
"parityOdd": "奇校验",
|
||||
"connect": "连接串口",
|
||||
"disconnect": "断开",
|
||||
"refreshPorts": "刷新端口",
|
||||
"connected": "已连接到 {{path}},波特率 {{baud}}",
|
||||
"disconnected": "串口已断开",
|
||||
"connectionError": "打开串口失败",
|
||||
"wsError": "WebSocket 错误",
|
||||
"errorNoServerUrl": "未配置服务器 URL",
|
||||
"notSupportedTitle": "不支持串口",
|
||||
"notSupported": "串口连接需要支持 Web Serial API 的浏览器(Chrome、Edge 或 Firefox 151+),或者 Termix 桌面应用。",
|
||||
"hideHint": "您可以在用户资料 > 外观 > 侧边栏 > 导航中隐藏串口标签页。",
|
||||
"browserPickerHint": "点击连接,您的浏览器将打开端口选择器以选择设备。"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "串口"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"serial": {
|
||||
"title": "序列",
|
||||
"portLabel": "連接埠",
|
||||
"portPlaceholder": "/dev/ttyUSB0 or COM3",
|
||||
"baudRateLabel": "鮑率",
|
||||
"dataBitsLabel": "資料位元",
|
||||
"stopBitsLabel": "停止位元",
|
||||
"parityLabel": "同位檢查",
|
||||
"parityNone": "無",
|
||||
"parityEven": "偶",
|
||||
"parityOdd": "奇",
|
||||
"connect": "連線至序列埠",
|
||||
"disconnect": "中斷連線",
|
||||
"refreshPorts": "重新整理連接埠",
|
||||
"connected": "已連線至 {{path}},鮑率 {{baud}}",
|
||||
"disconnected": "序列埠已中斷連線",
|
||||
"connectionError": "開啟序列埠失敗",
|
||||
"wsError": "WebSocket 錯誤",
|
||||
"errorNoServerUrl": "未設定伺服器網址",
|
||||
"notSupportedTitle": "不支援序列埠",
|
||||
"notSupported": "序列連線需要支援 Web Serial API 的瀏覽器 (Chrome、Edge 或 Firefox 151+) 或 Termix 桌面應用程式。",
|
||||
"hideHint": "您可以在「使用者設定檔 > 外觀 > 側邊欄 > 導覽」中隱藏序列索引標籤。",
|
||||
"browserPickerHint": "按一下連線,瀏覽器會開啟連接埠選擇器讓您選取裝置。"
|
||||
},
|
||||
"nav": {
|
||||
"serial": "序列"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "serial",
|
||||
"name": "Serial",
|
||||
"version": "1.0.0",
|
||||
"description": "Serial console access over a locally attached device.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Terminal",
|
||||
"icon": "Usb",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["network:serve", "device:serial", "ui:surface"],
|
||||
"nativeDependencies": ["serialport"],
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "serial",
|
||||
"titleKey": "nav.serial",
|
||||
"icon": "Usb",
|
||||
"openFrom": ["rail"]
|
||||
}
|
||||
],
|
||||
"panels": [
|
||||
{
|
||||
"id": "serial",
|
||||
"titleKey": "nav.serial",
|
||||
"icon": "Usb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@termix-plugin/serial",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"serialport": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createSerialSession } from "./session.js";
|
||||
|
||||
export async function activate(ctx: PluginContext): Promise<void> {
|
||||
ctx.ws.route("/console", createSerialSession(ctx));
|
||||
ctx.log.info("Serial console mounted at /plugin-ws/serial/console");
|
||||
}
|
||||
|
||||
export async function deactivate(): Promise<void> {
|
||||
// The WS route and every open socket are torn down by core: see
|
||||
// src/backend/plugins/ws.ts. Nothing here outlives activate().
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { SerialPort } from "serialport";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginWebSocketConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { parseWsMessage } from "./ws-message.js";
|
||||
|
||||
interface SerialConnectData {
|
||||
path: string;
|
||||
baudRate: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 2;
|
||||
parity?: "none" | "even" | "odd";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one /plugin-ws/serial/console connection. Auth already ran (core's
|
||||
* ctx.ws.route authenticates before this is called), so every message here
|
||||
* only has to decide whether to touch the actual hardware.
|
||||
*/
|
||||
export function createSerialSession(ctx: PluginContext) {
|
||||
return async (connection: PluginWebSocketConnection): Promise<void> => {
|
||||
const ws = connection.socket as WebSocket;
|
||||
let port: SerialPort | null = null;
|
||||
|
||||
const send = (msg: object) => {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
}
|
||||
port = null;
|
||||
};
|
||||
|
||||
if (!connection.isDataUnlocked()) {
|
||||
send({ type: "error", data: "Data locked" });
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connection.isDataUnlocked()) {
|
||||
send({ type: "error", data: "Data access expired" });
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
await ctx.capabilities.require("device:serial");
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: errorMessage(err, "Failed to list ports"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "connect": {
|
||||
await ctx.capabilities.require("device:serial");
|
||||
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const opened = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
port = opened;
|
||||
|
||||
opened.open((err) => {
|
||||
if (err) {
|
||||
ctx.log.error(`Serial port open failed for ${cfg.path}`, err);
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
ctx.log.info(
|
||||
`Serial port opened: ${cfg.path} at ${cfg.baudRate} baud`,
|
||||
);
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
opened.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
|
||||
opened.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
|
||||
opened.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: errorMessage(err, "Failed to open serial port"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) send({ type: "error", data: err.message });
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.error("Error handling serial WebSocket message", err);
|
||||
send({
|
||||
type: "error",
|
||||
data:
|
||||
err instanceof Error ? err.message : "Failed to process message",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", cleanup);
|
||||
ws.on("error", cleanup);
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RawData } from "ws";
|
||||
|
||||
// Control messages here are tiny (a path, a baud rate) or raw terminal input,
|
||||
// bounded by what a user can type or paste, so anything larger is abuse.
|
||||
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a WebSocket frame into `{ type, data }`, throwing on anything malformed. */
|
||||
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
|
||||
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Message must be a JSON object");
|
||||
}
|
||||
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new Error("Message type must be a string");
|
||||
}
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useTranslation, useTheme } from "@termix/plugin-sdk/frontend";
|
||||
import type { SerialConfig, SerialHandle } from "./types.js";
|
||||
import { isElectron } from "./electron.js";
|
||||
import { resolveSerialWsUrl } from "./transport.js";
|
||||
|
||||
const XTERM_THEME: Record<"light" | "dark", Record<string, string>> = {
|
||||
dark: {
|
||||
background: "#0b0e14",
|
||||
foreground: "#d4d7de",
|
||||
cursor: "#d4d7de",
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
foreground: "#1a1a1a",
|
||||
cursor: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
|
||||
type WebSerialPort = {
|
||||
open(options: {
|
||||
baudRate: number;
|
||||
dataBits?: number;
|
||||
stopBits?: number;
|
||||
parity?: string;
|
||||
}): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
readable: ReadableStream<Uint8Array> | null;
|
||||
writable: WritableStream<Uint8Array> | null;
|
||||
};
|
||||
|
||||
interface SerialProps {
|
||||
config: SerialConfig;
|
||||
isVisible: boolean;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
||||
{ config, isVisible, instanceId },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const connectedRef = useRef(false);
|
||||
const webSerialReaderRef = useRef<ReadableStreamDefaultReader | null>(null);
|
||||
const webSerialWriterRef = useRef<WritableStreamDefaultWriter | null>(null);
|
||||
const webSerialPortRef = useRef<WebSerialPort | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const useWebSerial = !isElectron();
|
||||
|
||||
const write = useCallback(
|
||||
(text: string) => {
|
||||
terminal?.write(text);
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
terminal.options.theme = XTERM_THEME[appTheme];
|
||||
terminal.options.fontFamily =
|
||||
'"SF Mono", Consolas, "Liberation Mono", monospace';
|
||||
terminal.options.fontSize = 14;
|
||||
}, [terminal, appTheme]);
|
||||
|
||||
// ── WebSocket (Electron) path ──────────────────────────────────────────
|
||||
|
||||
const disconnectWs = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
connectedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const connectWs = useCallback(async () => {
|
||||
disconnectWs();
|
||||
const target = await resolveSerialWsUrl();
|
||||
if (!target) {
|
||||
write(`\r\n\x1b[31m${t("serial.errorNoServerUrl")}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(target.url, target.protocols);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "connect", data: config }));
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
};
|
||||
switch (msg.type) {
|
||||
case "connected":
|
||||
connectedRef.current = true;
|
||||
write(
|
||||
`\x1b[32m${t("serial.connected", { path: config.path, baud: config.baudRate })}\x1b[0m\r\n`,
|
||||
);
|
||||
break;
|
||||
case "data":
|
||||
if (typeof msg.data === "string") write(msg.data);
|
||||
break;
|
||||
case "disconnected":
|
||||
connectedRef.current = false;
|
||||
write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`);
|
||||
break;
|
||||
case "error":
|
||||
write(
|
||||
`\r\n\x1b[31m${t("serial.connectionError")}: ${msg.data as string}\x1b[0m\r\n`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connectedRef.current = false;
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
write(`\r\n\x1b[31m${t("serial.wsError")}\x1b[0m\r\n`);
|
||||
};
|
||||
}, [config, disconnectWs, t, write]);
|
||||
|
||||
// ── Web Serial API path ────────────────────────────────────────────────
|
||||
|
||||
const disconnectWebSerial = useCallback(async () => {
|
||||
try {
|
||||
webSerialReaderRef.current?.cancel();
|
||||
webSerialWriterRef.current?.releaseLock();
|
||||
await webSerialPortRef.current?.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
webSerialReaderRef.current = null;
|
||||
webSerialWriterRef.current = null;
|
||||
webSerialPortRef.current = null;
|
||||
connectedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const connectWebSerial = useCallback(async () => {
|
||||
await disconnectWebSerial();
|
||||
|
||||
if (!("serial" in navigator)) {
|
||||
write(`\r\n\x1b[31m${t("serial.notSupported")}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serial = navigator.serial as {
|
||||
requestPort(): Promise<WebSerialPort>;
|
||||
};
|
||||
const port = await serial.requestPort();
|
||||
await port.open({
|
||||
baudRate: config.baudRate,
|
||||
dataBits: config.dataBits,
|
||||
stopBits: config.stopBits,
|
||||
parity: config.parity === "none" ? "none" : config.parity,
|
||||
});
|
||||
|
||||
webSerialPortRef.current = port;
|
||||
connectedRef.current = true;
|
||||
write(
|
||||
`\x1b[32m${t("serial.connected", { path: config.path || "serial", baud: config.baudRate })}\x1b[0m\r\n`,
|
||||
);
|
||||
|
||||
const reader = port.readable!.getReader();
|
||||
webSerialReaderRef.current = reader;
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
write(decoder.decode(value));
|
||||
}
|
||||
} catch {
|
||||
// port closed
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
connectedRef.current = false;
|
||||
write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`);
|
||||
}
|
||||
})();
|
||||
|
||||
const writer = port.writable!.getWriter();
|
||||
webSerialWriterRef.current = writer;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== "NotFoundError") {
|
||||
write(
|
||||
`\r\n\x1b[31m${t("serial.connectionError")}: ${err.message}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [config, disconnectWebSerial, t, write]);
|
||||
|
||||
// ── Unified connect/disconnect ─────────────────────────────────────────
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (useWebSerial) {
|
||||
connectWebSerial();
|
||||
} else {
|
||||
void connectWs();
|
||||
}
|
||||
}, [useWebSerial, connectWebSerial, connectWs]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (useWebSerial) {
|
||||
disconnectWebSerial();
|
||||
} else {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
}
|
||||
disconnectWs();
|
||||
}
|
||||
}, [useWebSerial, disconnectWebSerial, disconnectWs]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
disconnect();
|
||||
connect();
|
||||
}, [disconnect, connect]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
isConnected: () => connectedRef.current,
|
||||
sendInput: (data: string) => {
|
||||
if (useWebSerial) {
|
||||
const encoder = new TextEncoder();
|
||||
webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {});
|
||||
} else if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Terminal setup ─────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.options.cursorBlink = true;
|
||||
terminal.options.scrollback = 10000;
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (!connectedRef.current) return;
|
||||
if (useWebSerial) {
|
||||
const encoder = new TextEncoder();
|
||||
webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {});
|
||||
} else if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, instanceId]);
|
||||
|
||||
// ── Fit on visibility change ───────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// ── ResizeObserver ─────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !terminal) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [terminal]);
|
||||
|
||||
// ── Not supported (Firefox etc.) ──────────────────────────────────────
|
||||
|
||||
if (useWebSerial && !("serial" in navigator)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<TriangleAlert className="size-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t("serial.notSupportedTitle")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground max-w-xs">
|
||||
{t("serial.notSupported")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full w-full">
|
||||
<div ref={xtermRef} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
|
||||
import { Input, Select2 } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { SerialConfig } from "./types.js";
|
||||
import { isElectron } from "./electron.js";
|
||||
import { resolveSerialWsUrl } from "./transport.js";
|
||||
|
||||
const BAUD_RATES = [
|
||||
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
|
||||
921600,
|
||||
];
|
||||
|
||||
interface SerialPanelProps {
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
}
|
||||
|
||||
// In Chrome/Edge the Web Serial API shows its own native port picker on requestPort(),
|
||||
// so getPorts() only returns previously-granted ports. We never need the user to type
|
||||
// a path — the browser handles selection at connect time.
|
||||
const useWebSerial = !isElectron() && "serial" in navigator;
|
||||
const serialUnsupported = !isElectron() && !("serial" in navigator);
|
||||
|
||||
export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState("");
|
||||
const [baudRate, setBaudRate] = useState(115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
|
||||
const [stopBits, setStopBits] = useState<1 | 2>(1);
|
||||
const [parity, setParity] = useState<"none" | "even" | "odd">("none");
|
||||
// Electron only: ports discovered from the backend
|
||||
const [availablePorts, setAvailablePorts] = useState<string[]>([]);
|
||||
const [loadingPorts, setLoadingPorts] = useState(false);
|
||||
|
||||
const refreshPorts = useCallback(async () => {
|
||||
if (!isElectron()) return;
|
||||
setLoadingPorts(true);
|
||||
const target = await resolveSerialWsUrl();
|
||||
if (!target) {
|
||||
setLoadingPorts(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(target.url, target.protocols);
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as {
|
||||
type: string;
|
||||
data?: { path: string }[];
|
||||
};
|
||||
if (msg.type === "ports_list" && Array.isArray(msg.data)) {
|
||||
const paths = msg.data.map((p) => p.path);
|
||||
setAvailablePorts(paths);
|
||||
setPath((current) => current || (paths[0] ?? current));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
ws.close();
|
||||
setLoadingPorts(false);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setLoadingPorts(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) void refreshPorts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const connect = () => {
|
||||
// For Web Serial, path is irrelevant — the browser picker handles it.
|
||||
// We pass an empty string; Serial.tsx calls requestPort() itself.
|
||||
if (useWebSerial) {
|
||||
onConnect({ path: "", baudRate, dataBits, stopBits, parity });
|
||||
return;
|
||||
}
|
||||
const finalPath = path.trim();
|
||||
if (!finalPath) return;
|
||||
onConnect({ path: finalPath, baudRate, dataBits, stopBits, parity });
|
||||
};
|
||||
|
||||
// ── Not supported (Firefox, Safari, etc.) ─────────────────────────────
|
||||
|
||||
if (serialUnsupported) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="size-3.5 text-destructive shrink-0" />
|
||||
<span className="text-xs font-semibold text-destructive">
|
||||
{t("serial.notSupportedTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.notSupported")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.hideHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Web Serial (Chrome / Edge) ─────────────────────────────────────────
|
||||
|
||||
if (useWebSerial) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.browserPickerHint")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.baudRateLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(Number(e.target.value))}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BAUD_RATES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.dataBitsLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={dataBits}
|
||||
onChange={(e) =>
|
||||
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
|
||||
}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{([5, 6, 7, 8] as const).map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.stopBitsLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.parityLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={parity}
|
||||
onChange={(e) =>
|
||||
setParity(e.target.value as "none" | "even" | "odd")
|
||||
}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("serial.parityNone")}</option>
|
||||
<option value="even">{t("serial.parityEven")}</option>
|
||||
<option value="odd">{t("serial.parityOdd")}</option>
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={connect}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors mt-1"
|
||||
>
|
||||
<Usb className="size-3.5" />
|
||||
{t("serial.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Electron (backend serialport) ─────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.portLabel")}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => void refreshPorts()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("serial.refreshPorts")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingPorts ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{availablePorts.length > 0 && (
|
||||
<Select2
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("serial.portPlaceholder")}</option>
|
||||
{availablePorts.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t("serial.portPlaceholder")}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect();
|
||||
}}
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.baudRateLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(Number(e.target.value))}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BAUD_RATES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.dataBitsLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={dataBits}
|
||||
onChange={(e) =>
|
||||
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
|
||||
}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{([5, 6, 7, 8] as const).map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.stopBitsLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.parityLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={parity}
|
||||
onChange={(e) =>
|
||||
setParity(e.target.value as "none" | "even" | "odd")
|
||||
}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="none">{t("serial.parityNone")}</option>
|
||||
<option value="even">{t("serial.parityEven")}</option>
|
||||
<option value="odd">{t("serial.parityOdd")}</option>
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={connect}
|
||||
disabled={!path.trim()}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mt-1"
|
||||
>
|
||||
<Usb className="size-3.5" />
|
||||
{t("serial.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
type ElectronWindow = Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: { isElectron?: boolean };
|
||||
};
|
||||
|
||||
/** Serial is always local: the device is physically attached to this desktop machine. */
|
||||
export function isElectron(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const win = window as ElectronWindow;
|
||||
return (
|
||||
win.IS_ELECTRON === true ||
|
||||
!!win.electronAPI ||
|
||||
win.electronAPI?.isElectron === true
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Ref } from "react";
|
||||
import { Usb } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
PluginHostRecord,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Serial } from "./Serial.js";
|
||||
import { SerialPanel } from "./SerialPanel.js";
|
||||
import { setSerialWsUrl } from "./transport.js";
|
||||
import type { SerialConfig, SerialHandle } from "./types.js";
|
||||
|
||||
/**
|
||||
* A per-connection synthetic host, never a real DB host. The shell only
|
||||
* opens a genuinely hostless (host: null) tab as a singleton, and a serial
|
||||
* console is not one: a user can have several open at once, each with its
|
||||
* own device and settings. A distinct id per connection is what keeps them
|
||||
* separate tabs.
|
||||
*/
|
||||
function buildPseudoHost(config: SerialConfig): PluginHostRecord {
|
||||
return {
|
||||
id: `serial-${Date.now()}`,
|
||||
name: config.path
|
||||
? `${config.path} (${config.baudRate})`
|
||||
: `Serial (${config.baudRate})`,
|
||||
ip: "",
|
||||
port: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function SerialPanelView({ shell }: PanelProps) {
|
||||
return (
|
||||
<SerialPanel
|
||||
onConnect={(config) => {
|
||||
shell.openTab(buildPseudoHost(config), "serial", {
|
||||
forceNewTab: true,
|
||||
data: { serialConfig: config },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SerialTabView({ tab, isVisible, handleRef }: TabProps) {
|
||||
const config = tab.data?.serialConfig as SerialConfig | undefined;
|
||||
if (!config) return null;
|
||||
return (
|
||||
<Serial
|
||||
ref={handleRef as Ref<SerialHandle>}
|
||||
config={config}
|
||||
isVisible={isVisible}
|
||||
instanceId={tab.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setSerialWsUrl(app.wsUrl);
|
||||
app.onDispose(() => setSerialWsUrl(null));
|
||||
|
||||
app.registerRailItem({
|
||||
id: "serial",
|
||||
icon: Usb,
|
||||
titleKey: "nav.serial",
|
||||
after: "quick-connect",
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
app.registerPanel("serial", SerialPanelView);
|
||||
|
||||
app.registerTab("serial", SerialTabView, {
|
||||
icon: Usb,
|
||||
titleKey: "nav.serial",
|
||||
session: true,
|
||||
// A physical device attached to this machine, not a remote connection
|
||||
// another user could join.
|
||||
shareable: false,
|
||||
// Reconnecting to a COM port automatically after a restart is not a
|
||||
// workflow anyone asked for, and there's no host to restore against
|
||||
// anyway, so a serial tab is a local, ephemeral session like the
|
||||
// desktop app's local terminal rather than something workspaces saves.
|
||||
inLayouts: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* The live app.wsUrl, for components that are defined at module scope rather
|
||||
* than inside activate(). Set on activate, cleared on dispose, the same
|
||||
* pattern the tunnels plugin uses for app.api.
|
||||
*/
|
||||
let wsUrl: TermixApp["wsUrl"] | null = null;
|
||||
|
||||
export function setSerialWsUrl(next: TermixApp["wsUrl"] | null): void {
|
||||
wsUrl = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The console's WebSocket URL and auth subprotocols. No origin option: a
|
||||
* serial device is physically attached to this desktop machine, so it
|
||||
* always dials the embedded local backend rather than a connected remote
|
||||
* sync server (app.wsUrl's default when no origin is given).
|
||||
*/
|
||||
export async function resolveSerialWsUrl() {
|
||||
if (!wsUrl) return null;
|
||||
return wsUrl("/console");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type SerialConfig = {
|
||||
path: string;
|
||||
baudRate: number;
|
||||
dataBits: 5 | 6 | 7 | 8;
|
||||
stopBits: 1 | 2;
|
||||
parity: "none" | "even" | "odd";
|
||||
};
|
||||
|
||||
export interface SerialHandle {
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
sendInput: (data: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
describe("serial activate", () => {
|
||||
it("mounts the console WS route at /console with every declared capability", async () => {
|
||||
const mock: MockPluginContext = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
});
|
||||
|
||||
await activate(mock.ctx);
|
||||
|
||||
expect(mock.wsRoutes).toEqual([{ path: "/console", raw: false }]);
|
||||
});
|
||||
|
||||
it("refuses to mount without network:serve", async () => {
|
||||
const mock: MockPluginContext = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "network:serve"),
|
||||
});
|
||||
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
|
||||
import { createSerialSession } from "../../src/backend/session.js";
|
||||
|
||||
// vi.mock's factory is hoisted above every import, including node:events, so
|
||||
// FakeSerialPort gets its own minimal emitter rather than extending it.
|
||||
const { listMock, openMock, FakeSerialPort } = vi.hoisted(() => {
|
||||
const listMock = vi.fn(async () => [{ path: "/dev/ttyUSB0" }]);
|
||||
const openMock = vi.fn();
|
||||
|
||||
class FakeSerialPort {
|
||||
isOpen = false;
|
||||
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
constructor(public options: Record<string, unknown>) {}
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const set = this.listeners.get(event) ?? new Set();
|
||||
set.add(listener);
|
||||
this.listeners.set(event, set);
|
||||
return this;
|
||||
}
|
||||
private emit(event: string, ...args: unknown[]) {
|
||||
for (const listener of this.listeners.get(event) ?? []) listener(...args);
|
||||
}
|
||||
open(cb: (err?: Error) => void) {
|
||||
openMock(this.options);
|
||||
this.isOpen = true;
|
||||
cb();
|
||||
}
|
||||
write(_data: Buffer, cb: (err?: Error) => void) {
|
||||
cb();
|
||||
}
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.emit("close");
|
||||
}
|
||||
static list = listMock;
|
||||
}
|
||||
|
||||
return { listMock, openMock, FakeSerialPort };
|
||||
});
|
||||
|
||||
vi.mock("serialport", () => ({ SerialPort: FakeSerialPort }));
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState = 1;
|
||||
OPEN = 1;
|
||||
sent: unknown[] = [];
|
||||
send(msg: string) {
|
||||
this.sent.push(JSON.parse(msg));
|
||||
}
|
||||
close(_code?: number, _reason?: string) {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeConnection(
|
||||
socket: FakeSocket,
|
||||
isDataUnlocked: () => boolean = () => true,
|
||||
): PluginWebSocketConnection {
|
||||
return {
|
||||
userId: "user-1",
|
||||
request: {},
|
||||
socket,
|
||||
isDataUnlocked,
|
||||
} as unknown as PluginWebSocketConnection;
|
||||
}
|
||||
|
||||
async function deliver(socket: FakeSocket, message: object) {
|
||||
socket.emit("message", Buffer.from(JSON.stringify(message)));
|
||||
// Let the async message handler's microtasks settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("serial session", () => {
|
||||
let mock: MockPluginContext;
|
||||
|
||||
beforeEach(() => {
|
||||
listMock.mockClear();
|
||||
openMock.mockClear();
|
||||
});
|
||||
|
||||
it("refuses list_ports and connect without device:serial", async () => {
|
||||
mock = createMockCtx({ pluginId: "serial", capabilities: [] });
|
||||
const socket = new FakeSocket();
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket));
|
||||
|
||||
await deliver(socket, { type: "list_ports" });
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
|
||||
|
||||
socket.sent.length = 0;
|
||||
await deliver(socket, {
|
||||
type: "connect",
|
||||
data: { path: "/dev/ttyUSB0", baudRate: 9600 },
|
||||
});
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
|
||||
});
|
||||
|
||||
it("lists ports once granted device:serial", async () => {
|
||||
mock = createMockCtx({
|
||||
pluginId: "serial",
|
||||
capabilities: ["network:serve", "device:serial"],
|
||||
});
|
||||
const socket = new FakeSocket();
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket));
|
||||
|
||||
await deliver(socket, { type: "list_ports" });
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
expect(socket.sent).toEqual([
|
||||
{ type: "ports_list", data: [{ path: "/dev/ttyUSB0" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens, writes to and closes a port through connect/input/disconnect", async () => {
|
||||
mock = createMockCtx({
|
||||
pluginId: "serial",
|
||||
capabilities: ["network:serve", "device:serial"],
|
||||
});
|
||||
const socket = new FakeSocket();
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket));
|
||||
|
||||
await deliver(socket, {
|
||||
type: "connect",
|
||||
data: { path: "/dev/ttyUSB0", baudRate: 115200 },
|
||||
});
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/dev/ttyUSB0", baudRate: 115200 }),
|
||||
);
|
||||
expect(socket.sent).toContainEqual({ type: "connected" });
|
||||
|
||||
socket.sent.length = 0;
|
||||
await deliver(socket, { type: "input", data: "AT\r\n" });
|
||||
// write() succeeds silently; nothing is echoed back until the port emits data.
|
||||
|
||||
socket.sent.length = 0;
|
||||
await deliver(socket, { type: "disconnect" });
|
||||
expect(socket.sent).toContainEqual({ type: "disconnected" });
|
||||
});
|
||||
|
||||
it("rejects a connect with no path or baud rate without touching the port", async () => {
|
||||
mock = createMockCtx({
|
||||
pluginId: "serial",
|
||||
capabilities: ["network:serve", "device:serial"],
|
||||
});
|
||||
const socket = new FakeSocket();
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket));
|
||||
|
||||
await deliver(socket, { type: "connect", data: { path: "" } });
|
||||
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
|
||||
});
|
||||
|
||||
it("closes the socket at connect time when data is locked", async () => {
|
||||
mock = createMockCtx({
|
||||
pluginId: "serial",
|
||||
capabilities: ["network:serve", "device:serial"],
|
||||
});
|
||||
const socket = new FakeSocket();
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket, () => false));
|
||||
|
||||
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
|
||||
});
|
||||
|
||||
it("refuses a message once data locks mid-session", async () => {
|
||||
mock = createMockCtx({
|
||||
pluginId: "serial",
|
||||
capabilities: ["network:serve", "device:serial"],
|
||||
});
|
||||
const socket = new FakeSocket();
|
||||
let unlocked = true;
|
||||
await createSerialSession(mock.ctx)(fakeConnection(socket, () => unlocked));
|
||||
|
||||
unlocked = false;
|
||||
await deliver(socket, { type: "list_ports" });
|
||||
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("activates and registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const tabs = declared(contributes.tabs);
|
||||
const panels = [...declared(contributes.panels), ...tabs];
|
||||
|
||||
expect(rendered.registered.tabs()).toEqual(["serial"]);
|
||||
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
|
||||
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
|
||||
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
|
||||
"serial",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders the connect form in the rail panel", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
rendered.renderPanel("serial", { active: true });
|
||||
// jsdom has no Web Serial API and isn't Electron, so the panel falls
|
||||
// back to its "not supported" message rather than a live form.
|
||||
expect(
|
||||
await screen.findByText(locales.serial.notSupportedTitle),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user