feat: convert serial console into the serial plugin

This commit is contained in:
LukeGus committed 2026-09-23 23:06:37 -05:00
1 parent 708eedb861
commit 791d8656a1
121 files changed
+2216 -1425

No files matched your search

+8
View File
@@ -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.
+32
View File
@@ -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.
+29
View File
@@ -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": "序列"
}
}
+38
View File
@@ -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"
}
]
}
}
+18
View File
@@ -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": "*"
}
}
+12
View File
@@ -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().
}
+169
View File
@@ -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;
}
+38
View File
@@ -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 };
}
+339
View File
@@ -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>
);
});
+325
View File
@@ -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>
);
}
+16
View File
@@ -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
);
}
+85
View File
@@ -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,
});
}
+23
View File
@@ -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");
}
+15
View File
@@ -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();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);