/* DezuKVM - Offline USB KVM Client Author: tobychui Note: This require HTTPS and user interaction to request serial port access. This file is part of DezuKVM. DezuKVM is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. */ /* USB Serial Communication */ let serialPort = null; let serialReader = null; let serialWriter = null; let serialReadBuffer = []; // Update selected port display function updateSelectedPortDisplay(port) { const selectedPortElem = document.getElementById('selectedPort'); if (port && port.getInfo) { const info = port.getInfo(); selectedPortElem.textContent = `VID: ${info.usbVendorId || '-'}, PID: ${info.usbProductId || '-'}`; } else if (port) { selectedPortElem.textContent = 'KVM Connected'; } else { selectedPortElem.textContent = 'KVM Not Connected'; } } // Request a new serial port async function requestSerialPort() { try { // Disconnect previous port if connected if (serialPort) { await disconnectSerialPort(); } serialPort = await navigator.serial.requestPort(); await serialPort.open({ baudRate: 115200 }); serialReader = serialPort.readable.getReader(); serialWriter = serialPort.writable.getWriter(); updateSelectedPortDisplay(serialPort); // Change button to indicate connected state document.getElementById('selectSerialPort').classList.add('is-negative'); document.querySelector('#selectSerialPort span').className = 'ts-icon is-link-slash-icon'; // Start reading loop for incoming data readSerialLoop(); } catch (e) { updateSelectedPortDisplay(null); alert('Failed to open serial port'); } } // Disconnect serial port async function disconnectSerialPort() { try { if (serialReader) { await serialReader.cancel(); serialReader.releaseLock(); serialReader = null; } if (serialWriter) { serialWriter.releaseLock(); serialWriter = null; } if (serialPort) { await serialPort.close(); serialPort = null; } } catch (e) {} updateSelectedPortDisplay(null); } // Read loop for incoming serial data, dispatches 'data' events on parent async function readSerialLoop() { while (serialPort && serialReader) { try { const { value, done } = await serialReader.read(); if (done) break; if (value) { // Append to buffer //serialReadBuffer.push(...value); //console.log('Received data:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(' ')); } } catch (e) { break; } } } // Send data over serial async function sendSerial(data) { if (!serialWriter) throw new Error('Serial port not open'); await serialWriter.write(data); } // Button event to select serial port document.getElementById('selectSerialPort').addEventListener('click', function(){ if (serialPort) { disconnectSerialPort(); document.getElementById('selectSerialPort').classList.remove('is-negative'); document.querySelector('#selectSerialPort span').className = 'ts-icon is-keyboard-icon'; } else { requestSerialPort(); } }); /* CH9329 HID bytecode converter */ function resizeTouchscreenToVideo() { const video = document.getElementById('video'); const touchscreen = document.getElementById('touchscreen'); if (video && touchscreen) { const rect = video.getBoundingClientRect(); const resolution = getResolutionFromCurrentStream(); // Dynamically get video resolution and aspect ratio let aspectRatio = 16 / 9; // default if (resolution && resolution.width && resolution.height) { aspectRatio = resolution.width / resolution.height; } let displayWidth = rect.width; let displayHeight = rect.height; let offsetX = 0; let offsetY = 0; // Calculate the actual displayed video area (may be letterboxed/pillarboxed) if (rect.width / rect.height > aspectRatio) { // Pillarbox: black bars left/right displayHeight = rect.height; displayWidth = rect.height * aspectRatio; offsetX = rect.left + (rect.width - displayWidth) / 2; offsetY = rect.top; } else { // Letterbox: black bars top/bottom displayWidth = rect.width; displayHeight = rect.width / aspectRatio; offsetX = rect.left; offsetY = rect.top + (rect.height - displayHeight) / 2; } touchscreen.style.position = 'absolute'; touchscreen.style.left = offsetX + 'px'; touchscreen.style.top = offsetY + 'px'; touchscreen.style.width = displayWidth + 'px'; touchscreen.style.height = displayHeight + 'px'; touchscreen.width = displayWidth; touchscreen.height = displayHeight; } } // Call on load and on resize window.addEventListener('resize', resizeTouchscreenToVideo); window.addEventListener('DOMContentLoaded', resizeTouchscreenToVideo); setTimeout(resizeTouchscreenToVideo, 1000); // Also after 1s to ensure video is loaded class HIDController { constructor() { this.hidState = { MouseButtons: 0x00, Modkey: 0x00, KeyboardButtons: [0, 0, 0, 0, 0, 0] }; this.Config = { ScrollSensitivity: 1 }; } // Calculates checksum for a given array of bytes calcChecksum(arr) { return arr.reduce((sum, b) => (sum + b) & 0xFF, 0); } // Soft reset the CH9329 chip async softReset() { if (!serialPort || !serialPort.readable || !serialPort.writable) { throw new Error('Serial port not open'); } const packet = [ 0x57, 0xAB, 0x00, 0x0F, 0x00 // checksum placeholder ]; packet[4] = this.calcChecksum(packet.slice(0, 4)); await this.sendPacketAndWait(packet, 0x0F); } // Sends a packet over serial and waits for a reply with a specific command code async sendPacketAndWait(packet, replyCmd) { const timeout = 300; // 300ms timeout const succReplyByte = replyCmd | 0x80; const errorReplyByte = replyCmd | 0xC0; // Succ example for cmd 0x04: 57 AB 00 84 01 00 87 // Header is 57 AB 00, we can skip that // then the 0x84 is the replyCmd | 0x80 (or if error, 0xC4) // 0x01 is the data length (1 byte) // 0x00 is the data (success) // 0x87 is the checksum serialReadBuffer = []; await sendSerial(new Uint8Array(packet)); const startTime = Date.now(); /* while (true) { // Check for timeout if (Date.now() - startTime > timeout) { //Timeout, ignore this reply return Promise.reject(new Error('timeout waiting for reply')); } // Check if we have enough data for a reply if (serialReadBuffer.length >= 5) { // Look for the start of a packet for (let i = 0; i <= serialReadBuffer.length - 5; i++) { if (serialReadBuffer[i] === 0x57 && serialReadBuffer[i + 1] === 0xAB) { //Discard bytes before the packet if (i > 0) { serialReadBuffer.splice(0, i); } // Now we have 57 AB at the start, check if we have the full packet const len = serialReadBuffer[3]; const fullPacketLength = 4 + len + 1; if (serialReadBuffer.length >= fullPacketLength) { const packet = serialReadBuffer.slice(0, fullPacketLength); serialReadBuffer = serialReadBuffer.slice(fullPacketLength); const checksum = this.calcChecksum(packet.slice(0, fullPacketLength - 1)); if (checksum !== packet[fullPacketLength - 1]) { // Invalid checksum, discard packet continue; } if (packet[4] === replyCmd) { return Promise.resolve(); } } } } } }*/ // Seems the speed required to get a reply is too high for the browser // so reply check is not implemented for now await new Promise(resolve => setTimeout(resolve, 30)); return Promise.resolve(); } // Mouse move absolute async MouseMoveAbsolute(xLSB, xMSB, yLSB, yMSB) { if (!serialPort || !serialPort.readable || !serialPort.writable) { return; } const packet = [ 0x57, 0xAB, 0x00, 0x04, 0x07, 0x02, this.hidState.MouseButtons, xLSB, xMSB, yLSB, yMSB, 0x00, // Scroll 0x00 // Checksum placeholder ]; packet[12] = this.calcChecksum(packet.slice(0, 12)); await this.sendPacketAndWait(packet, 0x04); } // Mouse move relative async MouseMoveRelative(dx, dy, wheel) { if (!serialPort || !serialPort.readable || !serialPort.writable) { return; } // Ensure 0x80 is not used if (dx === 0x80) dx = 0x81; if (dy === 0x80) dy = 0x81; const packet = [ 0x57, 0xAB, 0x00, 0x05, 0x05, 0x01, this.hidState.MouseButtons, dx, dy, wheel, 0x00 // Checksum placeholder ]; packet[10] = this.calcChecksum(packet.slice(0, 10)); await this.sendPacketAndWait(packet, 0x05); } // Mouse button press async MouseButtonPress(button) { switch (button) { case 0x01: // Left this.hidState.MouseButtons |= 0x01; break; case 0x02: // Right this.hidState.MouseButtons |= 0x02; break; case 0x03: // Middle this.hidState.MouseButtons |= 0x04; break; default: throw new Error("invalid opcode for mouse button press"); } await this.MouseMoveRelative(0, 0, 0); } // Mouse button release async MouseButtonRelease(button) { switch (button) { case 0x00: // Release all this.hidState.MouseButtons = 0x00; break; case 0x01: // Left this.hidState.MouseButtons &= ~0x01; break; case 0x02: // Right this.hidState.MouseButtons &= ~0x02; break; case 0x03: // Middle this.hidState.MouseButtons &= ~0x04; break; default: throw new Error("invalid opcode for mouse button release"); } await this.MouseMoveRelative(0, 0, 0); } // Mouse scroll async MouseScroll(tilt) { if (tilt === 0) return; let wheel; if (tilt < 0) { wheel = this.Config.ScrollSensitivity; } else { wheel = 0xFF - this.Config.ScrollSensitivity; } await this.MouseMoveRelative(0, 0, wheel); } // --- Keyboard Emulation --- // Set modifier key (Ctrl, Shift, Alt, GUI) async SetModifierKey(keycode, isRight) { const MOD_LCTRL = 0x01, MOD_LSHIFT = 0x02, MOD_LALT = 0x04, MOD_LGUI = 0x08; const MOD_RCTRL = 0x10, MOD_RSHIFT = 0x20, MOD_RALT = 0x40, MOD_RGUI = 0x80; let modifierBit = 0; switch (keycode) { case 17: modifierBit = isRight ? MOD_RCTRL : MOD_LCTRL; break; case 16: modifierBit = isRight ? MOD_RSHIFT : MOD_LSHIFT; break; case 18: modifierBit = isRight ? MOD_RALT : MOD_LALT; break; case 91: modifierBit = isRight ? MOD_RGUI : MOD_LGUI; break; default: throw new Error("Not a modifier key"); } this.hidState.Modkey |= modifierBit; await this.keyboardSendKeyCombinations(); } // Unset modifier key (Ctrl, Shift, Alt, GUI) async UnsetModifierKey(keycode, isRight) { const MOD_LCTRL = 0x01, MOD_LSHIFT = 0x02, MOD_LALT = 0x04, MOD_LGUI = 0x08; const MOD_RCTRL = 0x10, MOD_RSHIFT = 0x20, MOD_RALT = 0x40, MOD_RGUI = 0x80; let modifierBit = 0; switch (keycode) { case 17: modifierBit = isRight ? MOD_RCTRL : MOD_LCTRL; break; case 16: modifierBit = isRight ? MOD_RSHIFT : MOD_LSHIFT; break; case 18: modifierBit = isRight ? MOD_RALT : MOD_LALT; break; case 91: modifierBit = isRight ? MOD_RGUI : MOD_LGUI; break; default: throw new Error("Not a modifier key"); } this.hidState.Modkey &= ~modifierBit; await this.keyboardSendKeyCombinations(); } // Send a keyboard press by JavaScript keycode async SendKeyboardPress(keycode) { const hid = this.javaScriptKeycodeToHIDOpcode(keycode); if (hid === 0x00) throw new Error("Unsupported keycode: " + keycode); // Already pressed? for (let i = 0; i < 6; i++) { if (this.hidState.KeyboardButtons[i] === hid) return; } // Find empty slot for (let i = 0; i < 6; i++) { if (this.hidState.KeyboardButtons[i] === 0x00) { this.hidState.KeyboardButtons[i] = hid; await this.keyboardSendKeyCombinations(); return; } } throw new Error("No space left in keyboard state to press key: " + keycode); } // Send a keyboard release by JavaScript keycode async SendKeyboardRelease(keycode) { const hid = this.javaScriptKeycodeToHIDOpcode(keycode); if (hid === 0x00) throw new Error("Unsupported keycode: " + keycode); for (let i = 0; i < 6; i++) { if (this.hidState.KeyboardButtons[i] === hid) { this.hidState.KeyboardButtons[i] = 0x00; await this.keyboardSendKeyCombinations(); return; } } // Not pressed, do nothing } // Send the current key combinations (modifiers + up to 6 keys) async keyboardSendKeyCombinations() { const packet = [ 0x57, 0xAB, 0x00, 0x02, 0x08, this.hidState.Modkey, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ]; for (let i = 0; i < 6; i++) { packet[7 + i] = this.hidState.KeyboardButtons[i] || 0x00; } packet[13] = this.calcChecksum(packet.slice(0, 13)); await this.sendPacketAndWait(packet, 0x02); } // Convert JavaScript keycode to HID keycode javaScriptKeycodeToHIDOpcode(keycode) { // Letters A-Z if (keycode >= 65 && keycode <= 90) return (keycode - 65) + 0x04; // Numbers 1-9 (top row, not numpad) if (keycode >= 49 && keycode <= 57) return (keycode - 49) + 0x1E; // F1 to F12 if (keycode >= 112 && keycode <= 123) return (keycode - 112) + 0x3A; switch (keycode) { case 8: return 0x2A; // Backspace case 9: return 0x2B; // Tab case 13: return 0x28; // Enter case 16: return 0xE1; // Left shift case 17: return 0xE0; // Left Ctrl case 18: return 0xE6; // Left Alt case 19: return 0x48; // Pause case 20: return 0x39; // Caps Lock case 27: return 0x29; // Escape case 32: return 0x2C; // Spacebar case 33: return 0x4B; // Page Up case 34: return 0x4E; // Page Down case 35: return 0x4D; // End case 36: return 0x4A; // Home case 37: return 0x50; // Left Arrow case 38: return 0x52; // Up Arrow case 39: return 0x4F; // Right Arrow case 40: return 0x51; // Down Arrow case 44: return 0x46; // Print Screen or F13 (Firefox) case 45: return 0x49; // Insert case 46: return 0x4C; // Delete case 48: return 0x27; // 0 (not Numpads) case 59: return 0x33; // ';' case 61: return 0x2E; // '=' case 91: return 0xE3; // Left GUI (Windows) case 92: return 0xE7; // Right GUI case 93: return 0x65; // Menu key case 96: return 0x62; // 0 (Numpads) case 97: return 0x59; // 1 (Numpads) case 98: return 0x5A; // 2 (Numpads) case 99: return 0x5B; // 3 (Numpads) case 100: return 0x5C; // 4 (Numpads) case 101: return 0x5D; // 5 (Numpads) case 102: return 0x5E; // 6 (Numpads) case 103: return 0x5F; // 7 (Numpads) case 104: return 0x60; // 8 (Numpads) case 105: return 0x61; // 9 (Numpads) case 106: return 0x55; // * (Numpads) case 107: return 0x57; // + (Numpads) case 109: return 0x56; // - (Numpads) case 110: return 0x63; // dot (Numpads) case 111: return 0x54; // divide (Numpads) case 144: return 0x53; // Num Lock case 145: return 0x47; // Scroll Lock case 146: return 0x58; // Numpad enter case 173: return 0x2D; // - case 186: return 0x33; // ';' case 187: return 0x2E; // '=' case 188: return 0x36; // ',' case 189: return 0x2D; // '-' case 190: return 0x37; // '.' case 191: return 0x38; // '/' case 192: return 0x35; // '`' case 219: return 0x2F; // '[' case 220: return 0x31; // backslash case 221: return 0x30; // ']' case 222: return 0x34; // '\'' default: return 0x00; } } } // Instantiate HID controller const controller = new HIDController(); const videoOverlayElement = document.getElementById('touchscreen'); let isMouseDown = false; let lastX = 0; let lastY = 0; // Mouse down videoOverlayElement.addEventListener('mousedown', async (e) => { isMouseDown = true; lastX = e.clientX; lastY = e.clientY; if (e.button === 0) { await controller.MouseButtonPress(0x01); // Left } else if (e.button === 2) { await controller.MouseButtonPress(0x02); // Right } else if (e.button === 1) { await controller.MouseButtonPress(0x03); // Middle } }); // Mouse up videoOverlayElement.addEventListener('mouseup', async (e) => { isMouseDown = false; if (e.button === 0) { await controller.MouseButtonRelease(0x01); // Left } else if (e.button === 2) { await controller.MouseButtonRelease(0x02); // Right } else if (e.button === 1) { await controller.MouseButtonRelease(0x03); // Middle } }); // Mouse move (absolute positioning) videoOverlayElement.addEventListener('mousemove', async (e) => { const rect = videoOverlayElement.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const width = rect.width; const height = rect.height; const offsetX = x / width; const offsetY = y / height; //console.log('Offset ratio:', { offsetX, offsetY }); const absX = Math.round(offsetX * 4095); const absY = Math.round(offsetY * 4095); await controller.MouseMoveAbsolute(absX & 0xFF, (absX >> 8) & 0xFF, absY & 0xFF, (absY >> 8) & 0xFF); }); // Context menu disable (for right click) videoOverlayElement.addEventListener('contextmenu', (e) => { e.preventDefault(); }); // Mouse wheel (scroll) videoOverlayElement.addEventListener('wheel', async (e) => { e.preventDefault(); let tilt = e.deltaY > 0 ? 1 : -1; await controller.MouseScroll(tilt); }); // Keyboard events for HID emulation window.addEventListener('keydown', async (e) => { // Ignore repeated events //if (e.repeat) return; try { // Modifier keys if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') { await controller.SetModifierKey(e.keyCode, e.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT); } else { await controller.SendKeyboardPress(e.keyCode); } e.preventDefault(); } catch (err) { // Ignore unsupported keys } }); window.addEventListener('keyup', async (e) => { try { if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') { await controller.UnsetModifierKey(e.keyCode, e.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT); } else { await controller.SendKeyboardRelease(e.keyCode); } e.preventDefault(); } catch (err) { // Ignore unsupported keys } }); document.getElementById('resetHIDBtn').addEventListener('click', async () => { try { await controller.softReset(); alert('HID soft reset sent.'); } catch (e) { alert('Failed to reset HID: ' + e.message); } }); /* Audio Capture */ const audioSelect = document.getElementById('audioSource'); const refreshAudioBtn = document.getElementById('refreshAudioSources'); let currentAudioStream = null; // List audio input devices async function listAudioSources() { const devices = await navigator.mediaDevices.enumerateDevices(); audioSelect.innerHTML = ''; devices .filter(device => device.kind === 'audioinput') .forEach(device => { const option = document.createElement('option'); option.value = device.deviceId; option.text = device.label || `Microphone ${audioSelect.length + 1}`; audioSelect.appendChild(option); }); } // Start streaming selected audio source async function startAudioStream() { if (currentAudioStream) { currentAudioStream.getTracks().forEach(track => track.stop()); } const deviceId = audioSelect.value; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: deviceId ? { exact: deviceId } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false, sampleRate: 48000, channelCount: 2 } }); currentAudioStream = stream; // Create audio element if not exists let audioElem = document.getElementById('audioStream'); if (!audioElem) { audioElem = document.createElement('audio'); audioElem.id = 'audioStream'; audioElem.autoplay = true; audioElem.controls = true; audioElem.style.position = 'fixed'; audioElem.style.bottom = '10px'; audioElem.style.left = '10px'; audioElem.style.zIndex = 1001; document.body.appendChild(audioElem); audioElem.style.display = 'none'; } audioElem.srcObject = stream; } catch (err) { alert('Error accessing audio device: ' + err.message); } } // Event listeners refreshAudioBtn.addEventListener('click', listAudioSources); audioSelect.addEventListener('change', startAudioStream); // Initial population listAudioSources().then(startAudioStream); /* Video Captures The following section handles HDMI capture via connected webcams. */ async function ensureCameraPermission() { try { // Request permission to access the camera to get device labels await navigator.mediaDevices.getUserMedia({ video: true }); } catch (e) { alert('Unable to access camera'); } } async function getCameras() { const devices = await navigator.mediaDevices.enumerateDevices(); const videoSelect = document.getElementById('videoSource'); videoSelect.innerHTML = ''; devices.forEach(device => { if (device.kind === 'videoinput') { const option = document.createElement('option'); option.value = device.deviceId; option.text = device.label || `Camera ${videoSelect.length + 1}`; videoSelect.appendChild(option); } }); } async function startStream() { const videoSelect = document.getElementById('videoSource'); const deviceId = videoSelect.value; if (window.currentStream) { window.currentStream.getTracks().forEach(track => track.stop()); } const constraints = { video: { deviceId: { exact: deviceId }, width: { ideal: 1920 }, height: { ideal: 1080 } } }; try { const stream = await navigator.mediaDevices.getUserMedia(constraints); document.getElementById('video').srcObject = stream; window.currentStream = stream; // Resize touchscreen overlay after a short delay to ensure video is loaded setTimeout(resizeTouchscreenToVideo, 500); } catch (e) { alert('Unable to access camera'); } } function getResolutionFromCurrentStream() { if (window.currentStream) { const track = window.currentStream.getVideoTracks()[0]; const settings = track.getSettings(); return { width: settings.width, height: settings.height }; } return null; } document.getElementById('videoSource').addEventListener('change', startStream); document.getElementById('fullscreenBtn').addEventListener('click', () => { if ( document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement ) { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } document.querySelector('#fullscreenBtn span').className = 'ts-icon is-maximize-icon'; } else { if (document.body.requestFullscreen) { document.body.requestFullscreen(); } else if (document.body.webkitRequestFullscreen) { document.body.webkitRequestFullscreen(); } else if (document.body.mozRequestFullScreen) { document.body.mozRequestFullScreen(); } else if (document.body.msRequestFullscreen) { document.body.msRequestFullscreen(); } document.querySelector('#fullscreenBtn span').className = 'ts-icon is-minimize-icon'; } }); document.getElementById('refreshCameras').addEventListener('click', async () => { await getCameras(); await startStream(); }); // Ensure permission, then populate cameras and start stream ensureCameraPermission().then(() => { getCameras().then(startStream); }); navigator.mediaDevices.addEventListener('devicechange', () => { getCameras().then(startStream); });