Browse Source

Renamed folder

Toby Chui 5 days ago
parent
commit
d9d9f2e1f0
45 changed files with 3588 additions and 11 deletions
  1. BIN
      3D models/usb-kvm case/lockfile.lck
  2. 27 0
      designs/font_logo.ai
  3. 36 0
      designs/font_logo.svg
  4. 27 0
      designs/logo.ai
  5. 14 0
      designs/logo.svg
  6. BIN
      designs/ref/Screenshot 2023-04-19 at 00-38-33 #6.2 Photo & Design.png
  7. BIN
      designs/ref/Screenshot 2023-04-19 at 00-38-43 #6.2 Photo & Design.png
  8. 1 0
      designs/ref/leaf-line.svg
  9. 18 0
      dezukvmd/.vscode/c_cpp_properties.json
  10. 24 0
      dezukvmd/.vscode/launch.json
  11. 59 0
      dezukvmd/.vscode/settings.json
  12. 38 0
      dezukvmd/api.go
  13. 44 0
      dezukvmd/configure.go
  14. 17 0
      dezukvmd/configure_chip.sh
  15. 33 0
      dezukvmd/debug.sh
  16. 14 0
      dezukvmd/go.mod
  17. 11 0
      dezukvmd/go.sum
  18. 5 0
      dezukvmd/ipkvm.go
  19. 217 0
      dezukvmd/kvmscan.go
  20. 106 0
      dezukvmd/main.go
  21. 81 0
      dezukvmd/mod/remdesaux/handlers.go
  22. 118 0
      dezukvmd/mod/remdesaux/remdesaux.go
  23. 247 0
      dezukvmd/mod/remdeshid/ch9329.go
  24. 64 0
      dezukvmd/mod/remdeshid/handler.go
  25. 319 0
      dezukvmd/mod/remdeshid/keyboard.go
  26. 130 0
      dezukvmd/mod/remdeshid/mouse.go
  27. 176 0
      dezukvmd/mod/remdeshid/remdeshid.go
  28. 65 0
      dezukvmd/mod/remdeshid/typedef.go
  29. 319 0
      dezukvmd/mod/usbcapture/audio_device.go
  30. BIN
      dezukvmd/mod/usbcapture/stream_takeover.jpg
  31. BIN
      dezukvmd/mod/usbcapture/stream_takeover.psd
  32. 53 0
      dezukvmd/mod/usbcapture/typedef.go
  33. 83 0
      dezukvmd/mod/usbcapture/usbcapture.go
  34. 414 0
      dezukvmd/mod/usbcapture/video_device.go
  35. 98 0
      dezukvmd/tools.go
  36. 175 0
      dezukvmd/usbkvm.go
  37. BIN
      dezukvmd/www/img/cursor_overlay.png
  38. BIN
      dezukvmd/www/img/cursor_overlay.psd
  39. 31 0
      dezukvmd/www/index.html
  40. 1 0
      dezukvmd/www/js/jquery-3.7.1.min.js
  41. 386 0
      dezukvmd/www/kvmevt.js
  42. 34 0
      dezukvmd/www/main.css
  43. 72 0
      dezukvmd/www/ui.js
  44. BIN
      usbkvm-app/img/README/image-20251004211519671.png
  45. 31 11
      usbkvm-app/www/local-kvm.js

BIN
3D models/usb-kvm case/lockfile.lck


File diff suppressed because it is too large
+ 27 - 0
designs/font_logo.ai


+ 36 - 0
designs/font_logo.svg

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="圖層_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="1024px" height="256px" viewBox="0 0 1024 256" enable-background="new 0 0 1024 256" xml:space="preserve">
+<path fill="#2EBE7E" d="M231.785,196.207c0,23.753-19.256,43.01-43.01,43.01H54.676c-23.754,0-43.01-19.257-43.01-43.01V59.792
+	c0-23.754,19.256-43.01,43.01-43.01h134.098c23.754,0,43.01,19.255,43.01,43.01V196.207z"/>
+<path fill="#FFFFFF" d="M203.562,54.28v17.088c0,82.257-45.903,119.618-102.529,119.618l-32.102-0.002
+	c-1.386,7.795-2.074,16.296-2.074,25.634H49.769c0-11.644,0.988-22.218,2.954-31.886c-1.969-11.058-2.954-26.063-2.954-45.011
+	c0-47.188,38.253-85.441,85.441-85.441C152.297,54.28,169.385,62.825,203.562,54.28z M135.209,71.368
+	c-37.75,0-68.353,30.604-68.353,68.354c0,3.097,0.027,6.075,0.082,8.937c10.718-16.897,26.414-30.257,46.943-41.987l8.478,14.836
+	c-24.392,13.938-40.573,29.779-48.879,52.391h27.552c51.395,0,84.34-33.945,85.414-99.217c-11.722,1.14-22.615,0.414-36.051-1.602
+	C140.56,71.602,138.634,71.368,135.209,71.368z"/>
+<g>
+	<path fill="#2EBE7E" d="M275.729,71.932h34.453c36.546,0,60.695,17.709,60.695,59.408c0,41.697-24.149,60.533-59.085,60.533
+		h-36.063V71.932z M308.411,168.689c19.319,0,33.004-8.854,33.004-37.35c0-28.497-13.685-36.386-33.004-36.386h-3.864v73.735
+		H308.411z"/>
+	<path fill="#2EBE7E" d="M385.69,145.99c0-29.624,21.412-47.977,43.469-47.977c26.403,0,39.283,19.158,39.283,44.113
+		c0,5.151-0.645,10.143-1.289,12.396h-53.772c2.576,12.558,11.27,17.71,22.861,17.71c6.601,0,12.719-1.932,19.32-5.797l9.499,17.227
+		c-9.499,6.762-22.056,10.465-32.682,10.465C405.975,194.127,385.69,176.418,385.69,145.99z M444.131,135.686
+		c0-9.015-4.025-15.777-14.49-15.777c-7.889,0-14.812,4.991-16.744,15.777H444.131z"/>
+	<path fill="#2EBE7E" d="M480.68,176.579l37.189-53.934h-33.003v-22.378h68.746v15.294l-37.189,53.934h38.477v22.378H480.68V176.579
+		z"/>
+	<path fill="#2EBE7E" d="M567.943,156.938v-56.67H596.6v53.128c0,12.558,3.381,16.261,10.625,16.261
+		c6.441,0,10.465-2.576,15.295-9.338v-60.051h28.658v91.606h-23.346l-2.092-12.557h-0.645c-7.566,9.016-16.1,14.811-28.496,14.811
+		C576.475,194.127,567.943,179.799,567.943,156.938z"/>
+	<path fill="#2EBE7E" d="M676.455,71.932h28.818v47.494h0.482l33.971-47.494h31.555l-36.225,48.137l42.986,71.804h-31.395
+		l-28.336-49.104l-13.039,17.71v31.394h-28.818V71.932z"/>
+	<path fill="#2EBE7E" d="M771.766,71.932h30.43l13.684,54.738c3.543,12.879,5.957,25.115,9.338,38.317h0.805
+		c3.543-13.202,5.957-25.438,9.338-38.317l13.523-54.738h29.301l-35.9,119.941h-34.615L771.766,71.932z"/>
+	<path fill="#2EBE7E" d="M890.582,71.932h31.072l18.998,51.84c2.414,6.923,4.346,14.489,6.6,21.734h0.807
+		c2.414-7.245,4.346-14.812,6.6-21.734l18.354-51.84h31.234v119.941h-26.242V150.82c0-11.914,2.254-29.785,3.863-41.538h-0.805
+		l-9.982,28.657l-16.1,43.791h-15.777l-16.26-43.791l-9.66-28.657h-0.645c1.449,11.753,3.703,29.624,3.703,41.538v41.053h-25.76
+		V71.932z"/>
+</g>
+</svg>

File diff suppressed because it is too large
+ 27 - 0
designs/logo.ai


+ 14 - 0
designs/logo.svg

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="圖層_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="512px" height="512px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
+<path fill="#2EBE7E" d="M509.667,413c0,54.676-44.324,99-99,99H102c-54.676,0-99-44.324-99-99V99C3,44.324,47.324,0,102,0h308.667
+	c54.676,0,99,44.324,99,99V413z"/>
+<path fill="#FFFFFF" d="M444.704,86.312v39.334c0,189.34-105.66,275.334-236.001,275.334l-73.893-0.002
+	c-3.189,17.939-4.773,37.508-4.773,59.002H90.703c0-26.799,2.275-51.139,6.8-73.393c-4.532-25.451-6.8-59.994-6.8-103.607
+	c0-108.617,88.051-196.668,196.667-196.668C326.704,86.312,366.036,105.98,444.704,86.312z M287.37,125.646
+	c-86.892,0-157.333,70.441-157.333,157.334c0,7.127,0.063,13.984,0.188,20.57c24.67-38.893,60.799-69.643,108.055-96.646
+	l19.515,34.15c-56.146,32.083-93.39,68.544-112.511,120.589l63.42,0.002c118.3,0,194.134-78.137,196.607-228.375
+	c-26.982,2.624-52.055,0.95-82.984-3.689C299.687,126.185,295.253,125.646,287.37,125.646z"/>
+</svg>

BIN
designs/ref/Screenshot 2023-04-19 at 00-38-33 #6.2 Photo & Design.png


BIN
designs/ref/Screenshot 2023-04-19 at 00-38-43 #6.2 Photo & Design.png


+ 1 - 0
designs/ref/leaf-line.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.998 3V5C20.998 14.6274 15.6255 19 8.99805 19L5.24077 18.9999C5.0786 19.912 4.99805 20.907 4.99805 22H2.99805C2.99805 20.6373 3.11376 19.3997 3.34381 18.2682C3.1133 16.9741 2.99805 15.2176 2.99805 13C2.99805 7.47715 7.4752 3 12.998 3C14.998 3 16.998 4 20.998 3ZM12.998 5C8.57977 5 4.99805 8.58172 4.99805 13C4.99805 13.3624 5.00125 13.7111 5.00759 14.0459C6.26198 12.0684 8.09902 10.5048 10.5019 9.13176L11.4942 10.8682C8.6393 12.4996 6.74554 14.3535 5.77329 16.9998L8.99805 17C15.0132 17 18.8692 13.0269 18.9949 5.38766C17.6229 5.52113 16.3481 5.436 14.7754 5.20009C13.6243 5.02742 13.3988 5 12.998 5Z"></path></svg>

+ 18 - 0
dezukvmd/.vscode/c_cpp_properties.json

@@ -0,0 +1,18 @@
+{
+  "configurations": [
+    {
+      "name": "windows-gcc-x64",
+      "includePath": [
+        "${workspaceFolder}/**"
+      ],
+      "compilerPath": "C:/TDM-GCC-64/bin/gcc.exe",
+      "cStandard": "${default}",
+      "cppStandard": "${default}",
+      "intelliSenseMode": "windows-gcc-x64",
+      "compilerArgs": [
+        ""
+      ]
+    }
+  ],
+  "version": 4
+}

+ 24 - 0
dezukvmd/.vscode/launch.json

@@ -0,0 +1,24 @@
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "C/C++ Runner: Debug Session",
+      "type": "cppdbg",
+      "request": "launch",
+      "args": [],
+      "stopAtEntry": false,
+      "externalConsole": true,
+      "cwd": "d:/Invention/RedesKVM/RemdesKVM/usbkvm/usbkvm_fw/src/remdesHid",
+      "program": "d:/Invention/RedesKVM/RemdesKVM/usbkvm/usbkvm_fw/src/remdesHid/build/Debug/outDebug",
+      "MIMode": "gdb",
+      "miDebuggerPath": "gdb",
+      "setupCommands": [
+        {
+          "description": "Enable pretty-printing for gdb",
+          "text": "-enable-pretty-printing",
+          "ignoreFailures": true
+        }
+      ]
+    }
+  ]
+}

+ 59 - 0
dezukvmd/.vscode/settings.json

@@ -0,0 +1,59 @@
+{
+  "C_Cpp_Runner.cCompilerPath": "gcc",
+  "C_Cpp_Runner.cppCompilerPath": "g++",
+  "C_Cpp_Runner.debuggerPath": "gdb",
+  "C_Cpp_Runner.cStandard": "",
+  "C_Cpp_Runner.cppStandard": "",
+  "C_Cpp_Runner.msvcBatchPath": "C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Auxiliary/Build/vcvarsall.bat",
+  "C_Cpp_Runner.useMsvc": false,
+  "C_Cpp_Runner.warnings": [
+    "-Wall",
+    "-Wextra",
+    "-Wpedantic",
+    "-Wshadow",
+    "-Wformat=2",
+    "-Wcast-align",
+    "-Wconversion",
+    "-Wsign-conversion",
+    "-Wnull-dereference"
+  ],
+  "C_Cpp_Runner.msvcWarnings": [
+    "/W4",
+    "/permissive-",
+    "/w14242",
+    "/w14287",
+    "/w14296",
+    "/w14311",
+    "/w14826",
+    "/w44062",
+    "/w44242",
+    "/w14905",
+    "/w14906",
+    "/w14263",
+    "/w44265",
+    "/w14928"
+  ],
+  "C_Cpp_Runner.enableWarnings": true,
+  "C_Cpp_Runner.warningsAsError": false,
+  "C_Cpp_Runner.compilerArgs": [],
+  "C_Cpp_Runner.linkerArgs": [],
+  "C_Cpp_Runner.includePaths": [],
+  "C_Cpp_Runner.includeSearch": [
+    "*",
+    "**/*"
+  ],
+  "C_Cpp_Runner.excludeSearch": [
+    "**/build",
+    "**/build/**",
+    "**/.*",
+    "**/.*/**",
+    "**/.vscode",
+    "**/.vscode/**"
+  ],
+  "C_Cpp_Runner.useAddressSanitizer": false,
+  "C_Cpp_Runner.useUndefinedSanitizer": false,
+  "C_Cpp_Runner.useLeakSanitizer": false,
+  "C_Cpp_Runner.showCompilationTime": false,
+  "C_Cpp_Runner.useLinkTimeOptimization": false,
+  "C_Cpp_Runner.msvcSecureNoWarnings": false
+}

+ 38 - 0
dezukvmd/api.go

@@ -0,0 +1,38 @@
+package main
+
+import "net/http"
+
+func registerAPIRoutes() {
+	// Start the web server
+	http.Handle("/", http.FileServer(webfs))
+	http.HandleFunc("/hid", usbKVM.HIDWebSocketHandler)
+	http.HandleFunc("/audio", usbCaptureDevice.AudioStreamingHandler)
+	http.HandleFunc("/stream", usbCaptureDevice.ServeVideoStream)
+}
+
+// Aux APIs for USB KVM mode
+func registerLocalAuxRoutes() {
+	http.HandleFunc("/aux/switchusbkvm", auxMCU.HandleSwitchUSBToKVM)
+	http.HandleFunc("/aux/switchusbremote", auxMCU.HandleSwitchUSBToRemote)
+	http.HandleFunc("/aux/presspower", auxMCU.HandlePressPowerButton)
+	http.HandleFunc("/aux/releasepower", auxMCU.HandleReleasePowerButton)
+	http.HandleFunc("/aux/pressreset", auxMCU.HandlePressResetButton)
+	http.HandleFunc("/aux/releasereset", auxMCU.HandleReleaseResetButton)
+	http.HandleFunc("/aux/getuuid", auxMCU.HandleGetUUID)
+}
+
+// Dummy Aux APIs for setups that do not have an aux MCU
+func registerDummyLocalAuxRoutes() {
+	dummyHandler := func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotImplemented)
+		w.Write([]byte("Not implemented"))
+	}
+
+	http.HandleFunc("/aux/switchusbkvm", dummyHandler)
+	http.HandleFunc("/aux/switchusbremote", dummyHandler)
+	http.HandleFunc("/aux/presspower", dummyHandler)
+	http.HandleFunc("/aux/releasepower", dummyHandler)
+	http.HandleFunc("/aux/pressreset", dummyHandler)
+	http.HandleFunc("/aux/releasereset", dummyHandler)
+	http.HandleFunc("/aux/getuuid", dummyHandler)
+}

+ 44 - 0
dezukvmd/configure.go

@@ -0,0 +1,44 @@
+package main
+
+import (
+	"log"
+	"time"
+
+	"imuslab.com/dezukvm/dezukvmd/mod/remdeshid"
+)
+
+func SetupHIDCommunication(config *UsbKvmConfig) error {
+	// Initiate the HID controller
+	usbKVM = remdeshid.NewHIDController(&remdeshid.Config{
+		PortName:          config.USBKVMDevicePath,
+		BaudRate:          config.USBKVMBaudrate,
+		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity
+	})
+
+	//Start the HID controller
+	err := usbKVM.Connect()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	time.Sleep(1 * time.Second) // Wait for the controller to initialize
+	log.Println("Updating chip baudrate to 115200...")
+	//Configure the HID controller
+	err = usbKVM.ConfigureChipTo115200()
+	if err != nil {
+		log.Fatalf("Failed to configure chip baudrate: %v", err)
+		return err
+	}
+	time.Sleep(1 * time.Second)
+
+	log.Println("Setting chip USB device properties...")
+	time.Sleep(2 * time.Second) // Wait for the controller to initialize
+	_, err = usbKVM.WriteChipProperties()
+	if err != nil {
+		log.Fatalf("Failed to write chip properties: %v", err)
+		return err
+	}
+
+	log.Println("Configuration command sent. Unplug the device and plug it back in to apply the changes.")
+	return nil
+}

+ 17 - 0
dezukvmd/configure_chip.sh

@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# -----------------------------------------------------------------------------
+# Script to start dezukvmd in config chip mode for CH9329 configuration.
+#
+# This script is intended for use with newly soldered CH9329 chips, which have
+# a default baudrate of 9600. It allows setting the chip's default baudrate to
+# 115200, enabling higher speed for keyboard and mouse virtual machine operation.
+#
+# The configuration port used here is shared with the USB KVM mode, as defined
+# in the config/usbkvm.json file.
+#
+# Author: [email protected]
+#
+# License: GPLv3
+# -----------------------------------------------------------------------------
+sudo ./dezukvmd -mode=cfgchip

+ 33 - 0
dezukvmd/debug.sh

@@ -0,0 +1,33 @@
+#!/bin/bash
+
+echo "This script helps debug audio and USB KVM devices."
+echo "Make sure you have the necessary permissions to access audio and USB devices."
+echo ""
+echo "------------------"
+echo "Checking for required tools..."
+# Check if 'arecord' (ALSA tool) is installed
+if ! command -v arecord &> /dev/null; then
+    echo "Warning: 'arecord' (ALSA audio recorder) is not installed. Please install it for audio debugging."
+else
+    echo "'arecord' is installed."
+fi
+
+# Check if 'v4l2-ctl' (Video4Linux2 control tool) is installed
+if ! command -v v4l2-ctl &> /dev/null; then
+    echo "Warning: 'v4l2-ctl' (Video4Linux2 control tool) is not installed. Please install it for USB video device debugging."
+else
+    echo "'v4l2-ctl' is installed."
+fi
+
+# List all audio devices
+echo "------------------"
+echo "Listing audio devices"
+sudo ./dezukvmd -mode=debug -tool=audio-devices
+
+# List all USB KVM devices
+echo "------------------"
+echo "Listing USB KVM devices"
+sudo ./dezukvmd -mode=debug -tool=list-usbkvm
+echo "------------------"
+echo "The finalized KVM device group is listed below: "
+sudo ./dezukvmd -mode=debug -tool=list-usbkvm-json

+ 14 - 0
dezukvmd/go.mod

@@ -0,0 +1,14 @@
+module imuslab.com/dezukvm/dezukvmd
+
+go 1.23.4
+
+require (
+	github.com/gorilla/websocket v1.5.3
+	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
+	github.com/vladimirvivien/go4vl v0.0.5
+)
+
+require (
+	github.com/pion/opus v0.0.0-20250618074346-646586bb17bf // indirect
+	golang.org/x/sys v0.31.0 // indirect
+)

+ 11 - 0
dezukvmd/go.sum

@@ -0,0 +1,11 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/pion/opus v0.0.0-20250618074346-646586bb17bf h1:aVhn89Yu8oTedA+E/Ge3LHk1FvFBThogBP/a0WkhGeI=
+github.com/pion/opus v0.0.0-20250618074346-646586bb17bf/go.mod h1:MF0ECGlX1vw71XHaPvRqZoeFED6QTwvFL71vbsd29yY=
+github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
+github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
+github.com/vladimirvivien/go4vl v0.0.5 h1:jHuo/CZOAzYGzrSMOc7anOMNDr03uWH5c1B5kQ+Chnc=
+github.com/vladimirvivien/go4vl v0.0.5/go.mod h1:FP+/fG/X1DUdbZl9uN+l33vId1QneVn+W80JMc17OL8=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

+ 5 - 0
dezukvmd/ipkvm.go

@@ -0,0 +1,5 @@
+package main
+
+func init_ipkvm_mode() error {
+	return nil
+}

+ 217 - 0
dezukvmd/kvmscan.go

@@ -0,0 +1,217 @@
+package main
+
+import (
+	"errors"
+	"log"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"imuslab.com/dezukvm/dezukvmd/mod/remdesaux"
+)
+
+/*
+Each of the USB-KVM device has the same set of USB devices
+connected under a single USB hub chip. This function
+will scan the USB device tree to find the connected
+USB devices and match them to the configured device paths.
+
+Commonly found devices are:
+- USB hub (the main hub chip)
+-- USB UART device (HID KVM)
+-- USB CDC ACM device (auxiliary MCU)
+-- USB Video Class device (webcam capture)
+-- USB Audio Class device (audio capture)
+
+The AuxMCU will provide a UUID to uniquely identify
+the USB KVM device subtree.
+*/
+type UsbKvmDevice struct {
+	UUID               string   // 16 bytes UUID obtained from AuxMCU, might change after power cycle
+	USBKVMDevicePath   string   // e.g. /dev/ttyUSB0
+	AuxMCUDevicePath   string   // e.g. /dev/ttyACM0
+	CaptureDevicePaths []string // e.g. /dev/video0, /dev/video1, etc.
+	AlsaDevicePaths    []string // e.g. /dev/snd/pcmC1D0c, etc.
+}
+
+// populateUsbKvmUUID tries to get the UUID from the AuxMCU device
+func populateUsbKvmUUID(dev *UsbKvmDevice) error {
+	if dev.AuxMCUDevicePath == "" {
+		return nil
+	}
+
+	// The standard baudrate for AuxMCU is 115200
+	aux, err := remdesaux.NewAuxOutbandController(dev.AuxMCUDevicePath, 115200)
+	if err != nil {
+		return err
+	}
+	defer aux.Close()
+
+	uuid, err := aux.GetUUID()
+	if err != nil {
+		return err
+	}
+
+	dev.UUID = uuid
+	return nil
+}
+
+func discoverUsbKvmSubtree() ([]*UsbKvmDevice, error) {
+	// Scan all /dev/tty*, /dev/video*, /dev/snd/pcmC* devices
+	getMatchingDevs := func(pattern string) ([]string, error) {
+		files, err := filepath.Glob(pattern)
+		if err != nil {
+			return nil, err
+		}
+		return files, nil
+	}
+
+	// Get all ttyUSB*, ttyACM*
+	ttyDevs1, _ := getMatchingDevs("/dev/ttyUSB*")
+	ttyDevs2, _ := getMatchingDevs("/dev/ttyACM*")
+	ttyDevs := append(ttyDevs1, ttyDevs2...)
+
+	// Get all video*
+	videoDevs, _ := getMatchingDevs("/dev/video*")
+
+	// Get all ALSA PCM devices (USB audio is usually card > 0)
+	alsaDevs, _ := getMatchingDevs("/dev/snd/pcmC*")
+
+	type devInfo struct {
+		path    string
+		sysPath string
+	}
+
+	getSys := func(devs []string) []devInfo {
+		var out []devInfo
+		for _, d := range devs {
+			sys, err := getDeviceFullPath(d)
+			if err == nil {
+				out = append(out, devInfo{d, sys})
+			}
+		}
+		return out
+	}
+
+	ttys := getSys(ttyDevs)
+	videos := getSys(videoDevs)
+	alsas := getSys(alsaDevs)
+
+	// Find common USB root hub prefix
+	hubPattern := regexp.MustCompile(`^\d+-\d+(\.\d+)*$`)
+	getHub := func(sys string) string {
+		parts := strings.Split(sys, "/")
+		for i := range parts {
+			// Look for USB hub pattern (e.g. 1-2, 2-1, etc.)
+			if hubPattern.MatchString(parts[i]) {
+				return strings.Join(parts[:i+1], "/")
+			}
+		}
+		return ""
+	}
+
+	// Map hub -> device info
+	type hubGroup struct {
+		ttys   []string
+		acms   []string
+		videos []string
+		alsas  []string
+	}
+	hubs := make(map[string]*hubGroup)
+
+	for _, t := range ttys {
+		hub := getHub(t.sysPath)
+		if hub != "" {
+			if hubs[hub] == nil {
+				hubs[hub] = &hubGroup{}
+			}
+			if strings.Contains(t.path, "ACM") {
+				hubs[hub].acms = append(hubs[hub].acms, t.path)
+			} else {
+				hubs[hub].ttys = append(hubs[hub].ttys, t.path)
+			}
+		}
+	}
+	for _, v := range videos {
+		hub := getHub(v.sysPath)
+		if hub != "" {
+			if hubs[hub] == nil {
+				hubs[hub] = &hubGroup{}
+			}
+			hubs[hub].videos = append(hubs[hub].videos, v.path)
+		}
+	}
+	for _, alsa := range alsas {
+		hub := getHub(alsa.sysPath)
+		if hub != "" {
+			if hubs[hub] == nil {
+				hubs[hub] = &hubGroup{}
+			}
+			hubs[hub].alsas = append(hubs[hub].alsas, alsa.path)
+		}
+	}
+
+	var result []*UsbKvmDevice
+	for _, g := range hubs {
+		// At least one tty or acm, one video, optionally alsa
+		if (len(g.ttys) > 0 || len(g.acms) > 0) && len(g.videos) > 0 {
+			// Pick the first tty as USBKVMDevicePath, first acm as AuxMCUDevicePath
+			usbKvm := ""
+			auxMcu := ""
+			if len(g.ttys) > 0 {
+				usbKvm = g.ttys[0]
+			}
+			if len(g.acms) > 0 {
+				auxMcu = g.acms[0]
+			}
+			result = append(result, &UsbKvmDevice{
+				USBKVMDevicePath:   usbKvm,
+				AuxMCUDevicePath:   auxMcu,
+				CaptureDevicePaths: g.videos,
+				AlsaDevicePaths:    g.alsas,
+			})
+		}
+	}
+
+	// Populate UUIDs
+	for _, dev := range result {
+		err := populateUsbKvmUUID(dev)
+		if err != nil {
+			log.Printf("Warning: could not get UUID for AuxMCU %s: %v, is this a third party device?", dev.AuxMCUDevicePath, err)
+		}
+	}
+
+	if len(result) == 0 {
+		return nil, errors.New("no USB KVM device found")
+	}
+	return result, nil
+}
+
+func resolveSymlink(path string) (string, error) {
+	resolved, err := filepath.EvalSymlinks(path)
+	if err != nil {
+		return "", err
+	}
+	return resolved, nil
+}
+
+func getDeviceFullPath(devicePath string) (string, error) {
+	resolvedPath, err := resolveSymlink(devicePath)
+	if err != nil {
+		return "", err
+	}
+
+	// Use udevadm to get the device chain
+	out, err := exec.Command("udevadm", "info", "-q", "path", "-n", resolvedPath).Output()
+	if err != nil {
+		return "", err
+	}
+	sysPath := strings.TrimSpace(string(out))
+	if sysPath == "" {
+		return "", errors.New("could not resolve sysfs path")
+	}
+
+	fullPath := "/sys" + sysPath
+	return fullPath, nil
+}

+ 106 - 0
dezukvmd/main.go

@@ -0,0 +1,106 @@
+package main
+
+import (
+	"embed"
+	"flag"
+	"io/fs"
+	"log"
+	"net/http"
+	"os"
+)
+
+const (
+	defaultDevMode   = true
+	configPath       = "./config"
+	usbKvmConfigPath = configPath + "/usbkvm.json"
+)
+
+var (
+	developent = flag.Bool("dev", defaultDevMode, "Enable development mode with local static files")
+	mode       = flag.String("mode", "usbkvm", "Mode of operation: usbkvm, ipkvm or debug")
+	tool       = flag.String("tool", "", "Run debug tool, must be used with -mode=debug")
+)
+
+/* Web Server Static Files */
+//go:embed www
+var embeddedFiles embed.FS
+var webfs http.FileSystem
+
+func init() {
+	// Initiate the web server static files
+	if *developent {
+		webfs = http.Dir("./www")
+	} else {
+		// Embed the ./www folder and trim the prefix
+		subFS, err := fs.Sub(embeddedFiles, "www")
+		if err != nil {
+			log.Fatal(err)
+		}
+		webfs = http.FS(subFS)
+	}
+
+	// Initiate the config folder if not exists
+	err := os.MkdirAll("./config", 0755)
+	if err != nil {
+		log.Fatal("Failed to create config folder:", err)
+	}
+
+}
+
+func main() {
+	flag.Parse()
+
+	switch *mode {
+	case "cfgchip":
+		//Load config file or create default one
+		kvmCfg, err := loadUsbKvmConfig()
+		if err != nil {
+			log.Fatal("Failed to load or create USB KVM config:", err)
+		}
+
+		//Override the baudrate to 9600 for chip configuration
+		kvmCfg.USBKVMBaudrate = 9600
+
+		err = SetupHIDCommunication(kvmCfg)
+		if err != nil {
+			log.Fatal(err)
+		}
+	case "debug":
+		err := handle_debug_tool()
+		if err != nil {
+			log.Fatal(err)
+		}
+	case "ipkvm":
+		//Check runtime dependencies
+		err := run_dependency_precheck()
+		if err != nil {
+			log.Fatal(err)
+		}
+
+		//Start IP-KVM mode
+		err = init_ipkvm_mode()
+		if err != nil {
+			log.Fatal(err)
+		}
+	case "usbkvm":
+		//Check runtime dependencies
+		err := run_dependency_precheck()
+		if err != nil {
+			log.Fatal(err)
+		}
+
+		//Load config file or create default one
+		kvmCfg, err := loadUsbKvmConfig()
+		if err != nil {
+			log.Fatal("Failed to load or create USB KVM config:", err)
+		}
+
+		//Start USB KVM mode
+		err = startUsbKvmMode(kvmCfg)
+		if err != nil {
+			log.Fatal(err)
+		}
+	default:
+		log.Fatalf("Unknown mode: %s. Supported modes are: usbkvm, capture", *mode)
+	}
+}

+ 81 - 0
dezukvmd/mod/remdesaux/handlers.go

@@ -0,0 +1,81 @@
+package remdesaux
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+)
+
+// Handler for switching USB to KVM side
+func (c *AuxMcu) HandleSwitchUSBToKVM(w http.ResponseWriter, r *http.Request) {
+	if err := c.SwitchUSBToKVM(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	log.Println("Switched USB mass storage to KVM side")
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for switching USB to Remote side
+func (c *AuxMcu) HandleSwitchUSBToRemote(w http.ResponseWriter, r *http.Request) {
+	if err := c.SwitchUSBToRemote(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	log.Println("Switched USB mass storage to remote side")
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for pressing the power button
+func (c *AuxMcu) HandlePressPowerButton(w http.ResponseWriter, r *http.Request) {
+	if err := c.PressPowerButton(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for releasing the power button
+func (c *AuxMcu) HandleReleasePowerButton(w http.ResponseWriter, r *http.Request) {
+	if err := c.ReleasePowerButton(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for pressing the reset button
+func (c *AuxMcu) HandlePressResetButton(w http.ResponseWriter, r *http.Request) {
+	if err := c.PressResetButton(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for releasing the reset button
+func (c *AuxMcu) HandleReleaseResetButton(w http.ResponseWriter, r *http.Request) {
+	if err := c.ReleaseResetButton(); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// Handler for getting the UUID
+func (c *AuxMcu) HandleGetUUID(w http.ResponseWriter, r *http.Request) {
+	uuid, err := c.GetUUID()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"uuid": uuid})
+}
+
+// Handler for getting the USB mass storage side
+func (c *AuxMcu) HandleGetUSBMassStorageSide(w http.ResponseWriter, r *http.Request) {
+	side := c.GetUSBMassStorageSide()
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{"usb_mass_storage_side": side})
+}

+ 118 - 0
dezukvmd/mod/remdesaux/remdesaux.go

@@ -0,0 +1,118 @@
+package remdesaux
+
+/*
+	RemdesAux - Auxiliary MCU Control for RemdeskVM
+
+	This module provides functions to interact with the auxiliary MCU (CH552G)
+	used in RemdeskVM for managing USB switching and power/reset button simulation.
+*/
+
+import (
+	"bufio"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/tarm/serial"
+)
+
+type USB_mass_storage_side int
+
+const (
+	USB_MASS_STORAGE_KVM USB_mass_storage_side = iota
+	USB_MASS_STORAGE_REMOTE
+)
+
+type AuxMcu struct {
+	usb_mass_storage_side USB_mass_storage_side
+	port                  *serial.Port
+	reader                *bufio.Reader
+	mu                    sync.Mutex
+}
+
+// NewAuxOutbandController initializes a new AuxMcu instance
+func NewAuxOutbandController(portName string, baudRate int) (*AuxMcu, error) {
+	c := &serial.Config{
+		Name:        portName,
+		Baud:        baudRate,
+		ReadTimeout: time.Second * 2,
+	}
+	port, err := serial.OpenPort(c)
+	if err != nil {
+		return nil, err
+	}
+	return &AuxMcu{
+		usb_mass_storage_side: USB_MASS_STORAGE_KVM, //Default to KVM side, defined in MCU firmware
+		port:                  port,
+		reader:                bufio.NewReader(port),
+	}, nil
+}
+
+func (c *AuxMcu) Close() error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if c.port != nil {
+		return c.port.Close()
+	}
+	return nil
+}
+
+// sendCommand writes a single byte command to the serial port
+func (c *AuxMcu) sendCommand(cmd byte) error {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	_, err := c.port.Write([]byte{cmd})
+	return err
+}
+
+// SwitchUSBToKVM switches USB mass storage to KVM side
+func (c *AuxMcu) SwitchUSBToKVM() error {
+	c.usb_mass_storage_side = USB_MASS_STORAGE_KVM
+	return c.sendCommand('m')
+}
+
+// SwitchUSBToRemote switches USB mass storage to remote computer
+func (c *AuxMcu) SwitchUSBToRemote() error {
+	c.usb_mass_storage_side = USB_MASS_STORAGE_REMOTE
+	return c.sendCommand('n')
+}
+
+// PressPowerButton simulates pressing the power button
+func (c *AuxMcu) PressPowerButton() error {
+	return c.sendCommand('p')
+}
+
+// ReleasePowerButton simulates releasing the power button
+func (c *AuxMcu) ReleasePowerButton() error {
+	return c.sendCommand('s')
+}
+
+// PressResetButton simulates pressing the reset button
+func (c *AuxMcu) PressResetButton() error {
+	return c.sendCommand('r')
+}
+
+// ReleaseResetButton simulates releasing the reset button
+func (c *AuxMcu) ReleaseResetButton() error {
+	return c.sendCommand('d')
+}
+
+// GetUUID requests the device UUID and returns it as a string
+func (c *AuxMcu) GetUUID() (string, error) {
+	if err := c.sendCommand('u'); err != nil {
+		return "", err
+	}
+	line, err := c.reader.ReadString('\n')
+	if err != nil {
+		return "", err
+	}
+
+	line = strings.TrimSpace(line)
+	return line, nil
+}
+
+func (c *AuxMcu) GetUSBMassStorageSide() USB_mass_storage_side {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.usb_mass_storage_side
+}

+ 247 - 0
dezukvmd/mod/remdeshid/ch9329.go

@@ -0,0 +1,247 @@
+package remdeshid
+
+import (
+	"errors"
+	"fmt"
+	"time"
+)
+
+func (c *Controller) ConfigureChipTo115200() error {
+	// Send the command to get chip configuration and info
+	currentConfig, err := c.GetChipCurrentConfiguration()
+	if err != nil {
+		fmt.Printf("Error getting current configuration: %v\n", err)
+		return errors.New("failed to get current configuration")
+	}
+
+	// Modify baudrate bytes in the response
+	currentConfig[3] = 0x00 // Baudrate byte 1
+	currentConfig[4] = 0x01 // Baudrate byte 2
+	currentConfig[5] = 0xC2 // Baudrate byte 3
+	currentConfig[6] = 0x00 // Baudrate byte 4
+
+	time.Sleep(1 * time.Second) // Wait for a second before sending the command
+	// Prepare the command to set the new configuration
+	setCmd := append([]byte{0x57, 0xAB, 0x00, 0x09, 0x32}, currentConfig[:50]...)
+	setCmd = append(setCmd, calcChecksum(setCmd[:len(setCmd)-1]))
+
+	err = c.Send(setCmd)
+	if err != nil {
+		fmt.Printf("Error sending configuration command: %v\n", err)
+		return errors.New("failed to send configuration command")
+	}
+
+	// Wait for the reply
+	resp, err := c.WaitForReply(0x09)
+	if err != nil {
+		fmt.Printf("Error waiting for reply: %v\n", err)
+		return errors.New("failed to get reply")
+	}
+
+	fmt.Println()
+	fmt.Print("Reply: ")
+	for _, b := range resp {
+		fmt.Printf("0x%02X ", b)
+	}
+	fmt.Println()
+	fmt.Println("Baudrate updated to 115200 successfully")
+	return nil
+}
+
+func (c *Controller) WriteChipProperties() ([]byte, error) {
+	manufacturerString := []byte{
+		0x57, 0xAB, 0x00, 0x0B,
+		0x09, // Length of payload
+		0x00, // Set manufacturer string
+		0x07, // Length of the USB manufacturer string
+		'i', 'm', 'u', 's', 'l', 'a', 'b',
+		0x00, // Checksum placeholder
+	}
+
+	manufacturerString[14] = calcChecksum(manufacturerString[:14])
+	// Send set manufacturer string
+	err := c.Send(manufacturerString)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send manufacturer string: %v", err)
+	}
+	_, err = c.WaitForReply(0x0B)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get manufacturer string response: %v", err)
+	}
+
+	productString := []byte{
+		0x57, 0xAB, 0x00, 0x0B,
+		0x0B, // Length of the payload
+		0x01, // Set product string
+		0x09, // Length of the USB product string
+		'R', 'e', 'm', 'd', 'e', 's', 'K', 'V', 'M',
+		0x00, // Checksum placeholder
+	}
+
+	productString[16] = calcChecksum(productString[:16])
+	// Send set product string
+	err = c.Send(productString)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send product string: %v", err)
+	}
+	_, err = c.WaitForReply(0x0B)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get product string response: %v", err)
+	}
+
+	return []byte("OK"), nil
+}
+
+// GetChipCurrentConfiguration retrieves the current configuration of the chip.
+// It sends a command to the chip and waits for a reply.
+// Note only the data portion of the response is returned, excluding the header and checksum.
+func (c *Controller) GetChipCurrentConfiguration() ([]byte, error) {
+	//Send the command to get chip configuration and info
+	cmd := []byte{0x57, 0xAB,
+		0x00, 0x08, 0x00,
+		0x00, //placeholder for checksum
+	}
+
+	cmd[5] = calcChecksum(cmd[:5])
+	err := c.Send(cmd)
+	if err != nil {
+		fmt.Printf("Error sending command: %v\n", err)
+		return nil, errors.New("failed to send command")
+	}
+
+	resp, err := c.WaitForReply(0x08)
+	if err != nil {
+		fmt.Printf("Error waiting for reply: %v\n", err)
+		return nil, errors.New("failed to get reply")
+	}
+
+	if len(resp) < 50 {
+		fmt.Println("Invalid response length")
+		return nil, errors.New("invalid response length")
+	}
+
+	fmt.Print("Response: ")
+	for _, b := range resp {
+		fmt.Printf("0x%02X ", b)
+	}
+	fmt.Println()
+
+	return resp, nil
+}
+
+func (c *Controller) ChipSoftReset() error {
+	//Send the command to get chip configuration and info
+	cmd := []byte{0x57, 0xAB,
+		0x00, 0x0F,
+		0x00, //placeholder for checksum
+	}
+
+	cmd[4] = calcChecksum(cmd[:4])
+	err := c.Send(cmd)
+	if err != nil {
+		fmt.Printf("Error sending command: %v\n", err)
+		return errors.New("failed to send command")
+	}
+
+	_, err = c.WaitForReply(0x0F)
+	if err != nil {
+		fmt.Printf("Error waiting for reply: %v\n", err)
+		return errors.New("failed to get reply")
+	}
+
+	fmt.Println("Chip soft reset successfully")
+	return nil
+}
+
+func (c *Controller) IsModifierKeys(keycode int) bool {
+	// Modifier keycodes for JavaScript
+	modifierKeys := []int{16, 17, 18, 91} // Shift, Ctrl, Alt, Meta (Windows/Command key)
+	for _, key := range modifierKeys {
+		if keycode == key {
+			return true
+		}
+	}
+	return false
+}
+
+// ConstructAndSendCmd constructs a HID command based on the provided HIDCommand and sends it.
+func (c *Controller) ConstructAndSendCmd(HIDCommand *HIDCommand) ([]byte, error) {
+	switch HIDCommand.Event {
+	case EventTypeKeyPress:
+		if IsModifierKey(uint8(HIDCommand.Keycode)) {
+			//modifier keys
+			return c.SetModifierKey(uint8(HIDCommand.Keycode), HIDCommand.IsRightModKey)
+		} else if HIDCommand.Keycode == 13 && HIDCommand.IsRightModKey {
+			// Numpad enter
+			return c.SendKeyboardPress(uint8(146))
+		}
+		return c.SendKeyboardPress(uint8(HIDCommand.Keycode))
+	case EventTypeKeyRelease:
+		if IsModifierKey(uint8(HIDCommand.Keycode)) {
+			//modifier keys
+			return c.UnsetModifierKey(uint8(HIDCommand.Keycode), HIDCommand.IsRightModKey)
+		} else if HIDCommand.Keycode == 13 && HIDCommand.IsRightModKey {
+			// Numpad enter
+			return c.SendKeyboardRelease(uint8(146))
+		}
+		return c.SendKeyboardRelease(uint8(HIDCommand.Keycode))
+	case EventTypeMouseMove:
+		//Map mouse button state to HID state
+		leftPressed := (HIDCommand.MouseMoveButtonState & 0x01) != 0
+		middlePressed := (HIDCommand.MouseMoveButtonState & 0x02) != 0
+		rightPressed := (HIDCommand.MouseMoveButtonState & 0x04) != 0
+		if leftPressed {
+			c.hidState.MouseButtons |= 0x01
+		} else {
+			c.hidState.MouseButtons &^= 0x01
+		}
+
+		if middlePressed {
+			c.hidState.MouseButtons |= 0x04
+		} else {
+			c.hidState.MouseButtons &^= 0x04
+		}
+
+		if rightPressed {
+			c.hidState.MouseButtons |= 0x02
+		} else {
+			c.hidState.MouseButtons &^= 0x02
+		}
+
+		// Update mouse position
+		c.lastCursorEventTime = time.Now().UnixMilli()
+		if HIDCommand.MouseAbsX != 0 || HIDCommand.MouseAbsY != 0 {
+			xLSB := byte(HIDCommand.MouseAbsX & 0xFF)        // Extract LSB of X
+			xMSB := byte((HIDCommand.MouseAbsX >> 8) & 0xFF) // Extract MSB of X
+			yLSB := byte(HIDCommand.MouseAbsY & 0xFF)        // Extract LSB of Y
+			yMSB := byte((HIDCommand.MouseAbsY >> 8) & 0xFF) // Extract MSB of Y
+			return c.MouseMoveAbsolute(xLSB, xMSB, yLSB, yMSB)
+		} else if HIDCommand.MouseRelX != 0 || HIDCommand.MouseRelY != 0 {
+			//Todo
+		}
+		return []byte{}, nil
+	case EventTypeMousePress:
+		if HIDCommand.MouseButton < 1 || HIDCommand.MouseButton > 3 {
+			return nil, fmt.Errorf("invalid mouse button: %d", HIDCommand.MouseButton)
+		}
+		button := uint8(HIDCommand.MouseButton)
+		return c.MouseButtonPress(button)
+	case EventTypeMouseRelease:
+		if HIDCommand.MouseButton < 1 || HIDCommand.MouseButton > 3 {
+			return nil, fmt.Errorf("invalid mouse button: %d", HIDCommand.MouseButton)
+		}
+		button := uint8(HIDCommand.MouseButton)
+		return c.MouseButtonRelease(button)
+	case EventTypeMouseScroll:
+		if time.Now().UnixMilli()-c.lastCursorEventTime < MinCusorEventInterval {
+			// Ignore mouse move events that are too close together
+			return []byte{}, nil
+		}
+		c.lastCursorEventTime = time.Now().UnixMilli()
+		return c.MouseScroll(HIDCommand.MouseScroll)
+	case EventTypeHIDReset:
+		return []byte{}, c.ChipSoftReset()
+	default:
+		return nil, fmt.Errorf("unsupported HID command event type: %d", HIDCommand.Event)
+	}
+}

+ 64 - 0
dezukvmd/mod/remdeshid/handler.go

@@ -0,0 +1,64 @@
+package remdeshid
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+
+	"github.com/gorilla/websocket"
+)
+
+// upgrader is used to upgrade HTTP connections to WebSocket connections
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+// HIDWebSocketHandler handles incoming WebSocket connections for HID commands
+func (c *Controller) HIDWebSocketHandler(w http.ResponseWriter, r *http.Request) {
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Println("Failed to upgrade to websocket:", err)
+		return
+	}
+	defer conn.Close()
+	for {
+		_, message, err := conn.ReadMessage()
+		if err != nil {
+			log.Println("Error reading message:", err)
+			break
+		}
+
+		//Try parsing the message as a HIDCommand
+		var hidCmd HIDCommand
+		if err := json.Unmarshal(message, &hidCmd); err != nil {
+			log.Println("Error parsing message:", err)
+			continue
+		}
+
+		bytes, err := c.ConstructAndSendCmd(&hidCmd)
+		if err != nil {
+			errmsg := map[string]string{"error": err.Error()}
+			if err := conn.WriteJSON(errmsg); err != nil {
+				log.Println("Error writing message:", err)
+				continue
+			}
+			log.Println("Error sending command:", err)
+			continue
+		}
+
+		prettyBytes := ""
+		for _, b := range bytes {
+			prettyBytes += fmt.Sprintf("0x%02X ", b)
+		}
+		if err := conn.WriteMessage(websocket.TextMessage, []byte(prettyBytes)); err != nil {
+			log.Println("Error writing message:", err)
+			continue
+		}
+
+	}
+}

+ 319 - 0
dezukvmd/mod/remdeshid/keyboard.go

@@ -0,0 +1,319 @@
+package remdeshid
+
+import "errors"
+
+const (
+	MOD_LCTRL  = 0x01
+	MOD_LSHIFT = 0x02
+	MOD_LALT   = 0x04
+	MOD_LGUI   = 0x08
+	MOD_RCTRL  = 0x10
+	MOD_RSHIFT = 0x20
+	MOD_RALT   = 0x40
+	MOD_RGUI   = 0x80
+	MOD_RENTER = 0x58
+)
+
+// IsModifierKey checks if the given JavaScript keycode corresponds to a modifier key
+func IsModifierKey(keycode uint8) bool {
+	switch keycode {
+	case 16: // Shift
+		return true
+	case 17: // Control
+		return true
+	case 18: // Alt
+		return true
+	case 91: // Meta (Windows/Command key)
+		return true
+	default:
+		return false
+	}
+}
+
+func (c *Controller) SetModifierKey(keycode uint8, isRight bool) ([]byte, error) {
+	// Determine the modifier bit based on HID keycode
+	var modifierBit uint8
+	switch keycode {
+	case 17:
+		if isRight {
+			modifierBit = MOD_RCTRL
+		} else {
+			modifierBit = MOD_LCTRL
+		}
+	case 16:
+		if isRight {
+			modifierBit = MOD_RSHIFT
+		} else {
+			modifierBit = MOD_LSHIFT
+		}
+	case 18:
+		if isRight {
+			modifierBit = MOD_RALT
+		} else {
+			modifierBit = MOD_LALT
+		}
+	case 91:
+		if isRight {
+			modifierBit = MOD_RGUI
+		} else {
+			modifierBit = MOD_LGUI
+		}
+	default:
+		// Not a modifier key
+		return nil, errors.ErrUnsupported
+	}
+
+	c.hidState.Modkey |= modifierBit
+
+	return keyboardSendKeyCombinations(c)
+}
+
+func (c *Controller) UnsetModifierKey(keycode uint8, isRight bool) ([]byte, error) {
+	// Determine the modifier bit based on HID keycode
+	var modifierBit uint8
+	switch keycode {
+	case 17:
+		if isRight {
+			modifierBit = MOD_RCTRL
+		} else {
+			modifierBit = MOD_LCTRL
+		}
+	case 16:
+		if isRight {
+			modifierBit = MOD_RSHIFT
+		} else {
+			modifierBit = MOD_LSHIFT
+		}
+	case 18:
+		if isRight {
+			modifierBit = MOD_RALT
+		} else {
+			modifierBit = MOD_LALT
+		}
+	case 91:
+		if isRight {
+			modifierBit = MOD_RGUI
+		} else {
+			modifierBit = MOD_LGUI
+		}
+	default:
+		// Not a modifier key
+		return nil, errors.ErrUnsupported
+	}
+
+	c.hidState.Modkey &^= modifierBit
+	return keyboardSendKeyCombinations(c)
+}
+
+// SendKeyboardPress sends a keyboard press by JavaScript keycode
+func (c *Controller) SendKeyboardPress(keycode uint8) ([]byte, error) {
+	// Convert JavaScript keycode to HID
+	keycode = javaScriptKeycodeToHIDOpcode(keycode)
+	if keycode == 0x00 {
+		// Not supported
+		return nil, errors.New("Unsupported keycode: " + string(keycode))
+	}
+
+	// Already pressed? Skip
+	for i := 0; i < 6; i++ {
+		if c.hidState.KeyboardButtons[i] == keycode {
+			return nil, nil
+		}
+	}
+
+	// Get the empty slot in the current HID list
+	for i := 0; i < 6; i++ {
+		if c.hidState.KeyboardButtons[i] == 0x00 {
+			c.hidState.KeyboardButtons[i] = keycode
+			return keyboardSendKeyCombinations(c)
+		}
+	}
+
+	// No space left
+	return nil, errors.New("No space left in keyboard state to press key: " + string(keycode))
+}
+
+// SendKeyboardRelease sends a keyboard release by JavaScript keycode
+func (c *Controller) SendKeyboardRelease(keycode uint8) ([]byte, error) {
+	// Convert JavaScript keycode to HID
+	keycode = javaScriptKeycodeToHIDOpcode(keycode)
+	if keycode == 0x00 {
+		// Not supported
+		return nil, errors.New("Unsupported keycode: " + string(keycode))
+	}
+
+	// Find the position where the key is pressed
+	for i := 0; i < 6; i++ {
+		if c.hidState.KeyboardButtons[i] == keycode {
+			c.hidState.KeyboardButtons[i] = 0x00
+			return keyboardSendKeyCombinations(c)
+		}
+	}
+
+	// That key is not pressed
+	return nil, nil
+}
+
+// keyboardSendKeyCombinations simulates sending the current key combinations
+func keyboardSendKeyCombinations(c *Controller) ([]byte, error) {
+	// Prepare the packet
+	packet := []uint8{
+		0x57, 0xAB, 0x00, 0x02, 0x08,
+		c.hidState.Modkey, 0x00,
+		0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+		0x00,
+	}
+
+	// Populate the HID keycodes
+	for i := 0; i < len(c.hidState.KeyboardButtons); i++ {
+		packet[7+i] = c.hidState.KeyboardButtons[i]
+	}
+
+	// Calculate checksum
+	packet[13] = calcChecksum(packet[:13])
+
+	err := c.Send(packet)
+	if err != nil {
+		return nil, errors.New("failed to send mouse move command: " + err.Error())
+	}
+
+	// Wait for a reply from the device
+	return c.WaitForReply(0x02)
+}
+
+// JavaScriptKeycodeToHIDOpcode converts JavaScript keycode into HID keycode
+func javaScriptKeycodeToHIDOpcode(keycode uint8) uint8 {
+	// Letters A-Z
+	if keycode >= 65 && keycode <= 90 {
+		return (keycode - 65) + 0x04 // 'A' is 0x04
+	}
+
+	// Numbers 1-9 (top row, not numpad)
+	if keycode >= 49 && keycode <= 57 {
+		return (keycode - 49) + 0x1E // '1' is 0x1E
+	}
+
+	// F1 to F12
+	if keycode >= 112 && keycode <= 123 {
+		return (keycode - 112) + 0x3A // 'F1' is 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 // Unknown / unsupported
+	}
+}

+ 130 - 0
dezukvmd/mod/remdeshid/mouse.go

@@ -0,0 +1,130 @@
+package remdeshid
+
+import (
+	"bytes"
+	"encoding/binary"
+	"errors"
+)
+
+// calcChecksum calculates the checksum for a given data slice.
+func calcChecksum(data []uint8) uint8 {
+	var sum uint8 = 0
+	for _, value := range data {
+		sum += value
+	}
+	return sum
+}
+
+func (c *Controller) MouseMoveAbsolute(xLSB, xMSB, yLSB, yMSB uint8) ([]byte, error) {
+	packet := []uint8{
+		0x57, 0xAB, 0x00, 0x04, 0x07, 0x02,
+		c.hidState.MouseButtons,
+		xLSB, // X LSB
+		xMSB, // X MSB
+		yLSB, // Y LSB
+		yMSB, // Y MSB
+		0x00, // Scroll
+		0x00, // Checksum placeholder
+	}
+
+	packet[12] = calcChecksum(packet[:12])
+
+	buf := new(bytes.Buffer)
+	if err := binary.Write(buf, binary.LittleEndian, packet); err != nil {
+		return nil, errors.New("failed to write packet to buffer")
+	}
+
+	err := c.Send(buf.Bytes())
+	if err != nil {
+		return nil, errors.New("failed to send mouse move command: " + err.Error())
+	}
+
+	// Wait for a reply from the device
+	return c.WaitForReply(0x04)
+}
+
+func (c *Controller) MouseMoveRelative(dx, dy, wheel uint8) ([]byte, error) {
+	// Ensure 0x80 is not used
+	if dx == 0x80 {
+		dx = 0x81
+	}
+	if dy == 0x80 {
+		dy = 0x81
+	}
+
+	packet := []uint8{
+		0x57, 0xAB, 0x00, 0x05, 0x05, 0x01,
+		c.hidState.MouseButtons,
+		dx,    // Delta X
+		dy,    // Delta Y
+		wheel, // Scroll wheel
+		0x00,  // Checksum placeholder
+	}
+
+	packet[10] = calcChecksum(packet[:10])
+
+	buf := new(bytes.Buffer)
+	if err := binary.Write(buf, binary.LittleEndian, packet); err != nil {
+		return nil, errors.New("failed to write packet to buffer")
+	}
+
+	err := c.Send(buf.Bytes())
+	if err != nil {
+		return nil, errors.New("failed to send mouse move relative command: " + err.Error())
+	}
+
+	return c.WaitForReply(0x05)
+}
+
+// Handle mouse button press events
+func (c *Controller) MouseButtonPress(button uint8) ([]byte, error) {
+	switch button {
+	case 0x01: // Left
+		c.hidState.MouseButtons |= 0x01
+	case 0x02: // Right
+		c.hidState.MouseButtons |= 0x02
+	case 0x03: // Middle
+		c.hidState.MouseButtons |= 0x04
+	default:
+		return nil, errors.New("invalid opcode for mouse button press")
+	}
+
+	// Send updated button state with no movement
+	return c.MouseMoveRelative(0, 0, 0)
+}
+
+// Handle mouse button release events
+func (c *Controller) MouseButtonRelease(button uint8) ([]byte, error) {
+	switch button {
+	case 0x00: // Release all
+		c.hidState.MouseButtons = 0x00
+	case 0x01: // Left
+		c.hidState.MouseButtons &^= 0x01
+	case 0x02: // Right
+		c.hidState.MouseButtons &^= 0x02
+	case 0x03: // Middle
+		c.hidState.MouseButtons &^= 0x04
+	default:
+		return nil, errors.New("invalid opcode for mouse button release")
+	}
+
+	// Send updated button state with no movement
+	return c.MouseMoveRelative(0, 0, 0)
+}
+
+func (c *Controller) MouseScroll(tilt int) ([]byte, error) {
+	if tilt == 0 {
+		// No need to scroll
+		return nil, nil
+	}
+
+	var wheel uint8
+	if tilt < 0 {
+		wheel = uint8(c.Config.ScrollSensitivity)
+	} else {
+		wheel = uint8(0xFF - c.Config.ScrollSensitivity)
+	}
+
+	//fmt.Println(tilt, "-->", wheel)
+	return c.MouseMoveRelative(0, 0, wheel)
+}

+ 176 - 0
dezukvmd/mod/remdeshid/remdeshid.go

@@ -0,0 +1,176 @@
+package remdeshid
+
+import (
+	"fmt"
+	"log"
+	"time"
+
+	"github.com/tarm/serial"
+)
+
+func NewHIDController(config *Config) *Controller {
+	// Initialize the HID state with default values
+	defaultHidState := HIDState{
+		Modkey:          0x00,
+		KeyboardButtons: [6]uint8{0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
+		Leds:            0x00,
+		MouseButtons:    0x00, // No mouse buttons pressed
+		MouseX:          0,
+		MouseY:          0,
+	}
+
+	return &Controller{
+		Config:            config,
+		serialRunning:     false,
+		hidState:          defaultHidState,
+		writeQueue:        make(chan []byte, 32),
+		incomingDataQueue: make(chan []byte, 1024),
+	}
+}
+
+// Connect opens the serial port and starts reading from it
+func (c *Controller) Connect() error {
+	// Open the serial port
+	config := &serial.Config{
+		Name:   c.Config.PortName,
+		Baud:   c.Config.BaudRate,
+		Size:   8,
+		Parity: serial.ParityNone,
+	}
+
+	port, err := serial.OpenPort(config)
+	if err != nil {
+		return err
+	}
+
+	c.serialPort = port
+	//Start reading from the serial port
+	go func() {
+		buf := make([]byte, 1024)
+		for {
+			n, err := port.Read(buf)
+			if err != nil {
+				log.Println(err.Error())
+				return
+			}
+			if n > 0 {
+				c.incomingDataQueue <- buf[:n]
+				//fmt.Print("Received bytes: ")
+				//for i := 0; i < n; i++ {
+				//	fmt.Printf("0x%02X ", buf[i])
+				//}
+			}
+		}
+	}()
+
+	//Create a loop to write to the serial port
+	c.serialRunning = true
+	go func() {
+		for {
+			data := <-c.writeQueue
+			_, err := port.Write(data)
+			if err != nil {
+				log.Println(err.Error())
+				return
+			}
+		}
+	}()
+
+	//Send over an opr queue reset signal
+	err = c.Send([]byte{0xFF})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *Controller) Send(data []byte) error {
+	if !c.serialRunning {
+		return fmt.Errorf("serial port is not running")
+	}
+	select {
+	case c.writeQueue <- data:
+		return nil
+	case <-time.After(30 * time.Millisecond):
+		return fmt.Errorf("timeout waiting to send data")
+	}
+}
+
+func (c *Controller) ClearReadQueue() {
+	// Clear the incoming data queue
+	for len(c.incomingDataQueue) > 0 {
+		<-c.incomingDataQueue
+	}
+}
+
+func (c *Controller) WaitForReply(cmdByte byte) ([]byte, error) {
+	// Wait for a reply from the device
+	succReplyByte := cmdByte | 0x80
+	errorReplyByte := cmdByte | 0xC0
+	timeout := make(chan bool, 1)
+	go func() {
+		// Timeout after 500ms
+		time.Sleep(500 * time.Millisecond)
+		timeout <- true
+	}()
+
+	var reply []byte
+	for {
+		select {
+		case data := <-c.incomingDataQueue:
+			reply = append(reply, data...)
+			// Check if we have received enough bytes for a complete packet
+			if len(reply) >= 7 {
+				// Validate header
+				if reply[0] == 0x57 && reply[1] == 0xAB {
+					// Extract fields
+					//address := reply[2]
+					replyByte := reply[3]
+					dataLength := reply[4]
+					expectedLength := 5 + int(dataLength) + 1 // Header + address + replyByte + dataLength + data + checksum
+
+					// Check if the full packet is received
+					if len(reply) >= expectedLength {
+						data := reply[5 : 5+dataLength]
+						checksum := reply[5+dataLength]
+
+						// Calculate checksum
+						sum := byte(0)
+						for _, b := range reply[:5+dataLength] {
+							sum += b
+						}
+
+						// Validate checksum
+						if sum == checksum {
+							// Check reply byte for success or error
+							switch replyByte {
+							case succReplyByte:
+								return data, nil
+							case errorReplyByte:
+								fmt.Print("Reply: ")
+								for _, b := range reply {
+									fmt.Printf("0x%02X ", b)
+								}
+								return nil, fmt.Errorf("device returned error reply")
+							}
+						} else {
+							return nil, fmt.Errorf("checksum validation failed")
+						}
+					}
+				} else {
+					// Invalid header, discard data
+					reply = nil
+				}
+			}
+		case <-timeout:
+			return nil, fmt.Errorf("timeout waiting for reply")
+		}
+	}
+}
+
+func (c *Controller) Close() {
+	if c.serialPort != nil {
+		c.serialPort.Close()
+	}
+}

+ 65 - 0
dezukvmd/mod/remdeshid/typedef.go

@@ -0,0 +1,65 @@
+package remdeshid
+
+import (
+	"github.com/tarm/serial"
+)
+
+type EventType int
+
+const (
+	EventTypeKeyPress EventType = iota
+	EventTypeKeyRelease
+	EventTypeMouseMove
+	EventTypeMousePress
+	EventTypeMouseRelease
+	EventTypeMouseScroll
+	EventTypeHIDCommand
+	EventTypeHIDReset = 0xFF
+)
+
+const MinCusorEventInterval = 25 // Minimum interval between cursor events in milliseconds
+
+type Config struct {
+	/* Serial port configs */
+	PortName          string
+	BaudRate          int
+	ScrollSensitivity uint8 // Mouse scroll sensitivity, range 0x00 to 0x7E
+}
+
+type HIDState struct {
+	/* Keyboard state */
+	Modkey          uint8    // Modifier key state
+	KeyboardButtons [6]uint8 // Keyboard buttons state
+	Leds            uint8    // LED state
+
+	/* Mouse state */
+	MouseButtons uint8 // Mouse buttons state
+	MouseX       int16 // Mouse X movement
+	MouseY       int16 // Mouse Y movement
+}
+
+// Controller is a struct that represents a HID controller
+type Controller struct {
+	Config *Config
+
+	/* Internal state */
+	serialPort          *serial.Port
+	hidState            HIDState // Current state of the HID device
+	serialRunning       bool
+	writeQueue          chan []byte
+	incomingDataQueue   chan []byte // Queue for incoming data
+	lastCursorEventTime int64
+}
+
+type HIDCommand struct {
+	Event                EventType `json:"event"`
+	Keycode              int       `json:"keycode,omitempty"`
+	IsRightModKey        bool      `json:"is_right_modifier_key,omitempty"`   // true if the key is a right modifier key (Ctrl, Shift, Alt, GUI)
+	MouseAbsX            int       `json:"mouse_x,omitempty"`                 // Absolute mouse position in X direction
+	MouseAbsY            int       `json:"mouse_y,omitempty"`                 // Absolute mouse position in Y direction
+	MouseRelX            int       `json:"mouse_rel_x,omitempty"`             // Relative mouse movement in X direction
+	MouseRelY            int       `json:"mouse_rel_y,omitempty"`             // Relative mouse movement in Y direction
+	MouseMoveButtonState int       `json:"mouse_move_button_state,omitempty"` // Mouse button state during move,
+	MouseButton          int       `json:"mouse_button,omitempty"`            //0x01 for left click, 0x02 for right click, 0x03 for middle clicks
+	MouseScroll          int       `json:"mouse_scroll,omitempty"`            // Positive for scroll up, negative for scroll down, max 127
+}

+ 319 - 0
dezukvmd/mod/usbcapture/audio_device.go

@@ -0,0 +1,319 @@
+package usbcapture
+
+import (
+	"bufio"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+// upgrader is used to upgrade HTTP connections to WebSocket connections
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+// ListCaptureDevices lists all available audio capture devices in the /dev/snd directory.
+func ListCaptureDevices() ([]string, error) {
+	files, err := os.ReadDir("/dev/snd")
+	if err != nil {
+		return nil, fmt.Errorf("failed to read /dev/snd: %w", err)
+	}
+
+	var captureDevs []string
+	for _, file := range files {
+		name := file.Name()
+		if strings.HasPrefix(name, "pcm") && strings.HasSuffix(name, "c") {
+			fullPath := "/dev/snd/" + name
+			captureDevs = append(captureDevs, fullPath)
+		}
+	}
+
+	return captureDevs, nil
+}
+
+// FindHDMICaptureCard searches for an HDMI capture card using the `arecord -l` command.
+func FindHDMICapturePCMPath() (string, error) {
+	out, err := exec.Command("arecord", "-l").Output()
+	if err != nil {
+		return "", fmt.Errorf("arecord -l failed: %w", err)
+	}
+
+	lines := strings.Split(string(out), "\n")
+	for _, line := range lines {
+		lower := strings.ToLower(line)
+		if strings.Contains(lower, "ms2109") || strings.Contains(lower, "ms2130") {
+			// Example line:
+			// card 1: MS2109 [MS2109], device 0: USB Audio [USB Audio]
+			parts := strings.Fields(line)
+			var cardNum, devNum string
+			for i := range parts {
+				if parts[i] == "card" && i+1 < len(parts) {
+					cardNum = parts[i+1][:1] // "1"
+				}
+				if parts[i] == "device" && i+1 < len(parts) {
+					devNum = strings.TrimSuffix(parts[i+1], ":") // "0"
+				}
+			}
+
+			if cardNum != "" && devNum != "" {
+				return fmt.Sprintf("/dev/snd/pcmC%vD%vc", cardNum, devNum), nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("no HDMI capture card found")
+}
+
+// Convert a PCM device name to a hardware device name.
+// Example: "pcmC1D0c" -> "hw:1,0"
+func pcmDeviceToHW(dev string) (string, error) {
+	// Regex to extract card and device numbers
+	re := regexp.MustCompile(`pcmC(\d+)D(\d+)[cp]`)
+	matches := re.FindStringSubmatch(dev)
+	if len(matches) < 3 {
+		return "", fmt.Errorf("invalid device format")
+	}
+	card := matches[1]
+	device := matches[2]
+	return fmt.Sprintf("hw:%s,%s", card, device), nil
+}
+
+func GetDefaultAudioConfig() *AudioConfig {
+	return &AudioConfig{
+		SampleRate:     48000,
+		Channels:       2,
+		BytesPerSample: 2,    // 16-bit
+		FrameSize:      1920, // 1920 samples per frame = 40ms @ 48kHz
+	}
+}
+
+func GetDefaultAudioDevice() string {
+	//Check if the default ALSA device exists
+	if _, err := os.Stat("/dev/snd/pcmC0D0c"); err == nil {
+		return "/dev/snd/pcmC0D0c"
+	}
+
+	//If not, list all capture devices and return the first one
+	devs, err := ListCaptureDevices()
+	if err != nil || len(devs) == 0 {
+		return ""
+	}
+
+	return devs[0]
+}
+
+// AudioStreamingHandler handles incoming WebSocket connections for audio streaming.
+func (i *Instance) AudioStreamingHandler(w http.ResponseWriter, r *http.Request) {
+	// Check if the request contains ?quality=low
+	quality := r.URL.Query().Get("quality")
+	qualityKey := []string{"low", "standard", "high"}
+	selectedQuality := "standard"
+	for _, q := range qualityKey {
+		if quality == q {
+			selectedQuality = q
+			break
+		}
+	}
+
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		log.Println("Failed to upgrade to websocket:", err)
+		return
+	}
+	defer conn.Close()
+
+	if alsa_device_occupied(i.Config.AudioDeviceName) {
+		//Another instance already running
+		log.Println("Audio pipe already running, stopping previous instance")
+		i.audiostopchan <- true
+		retryCounter := 0
+		for alsa_device_occupied(i.Config.AudioDeviceName) {
+			time.Sleep(500 * time.Millisecond) //Wait a bit for the previous instance to stop
+			retryCounter++
+			if retryCounter > 5 {
+				log.Println("Failed to stop previous audio instance")
+				return
+			}
+		}
+	}
+
+	//Get the capture card audio input
+	pcmdev, err := FindHDMICapturePCMPath()
+	if err != nil {
+		log.Println("Failed to find HDMI capture PCM path:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	log.Println("Found HDMI capture PCM path:", pcmdev)
+
+	// Convert PCM device to hardware device name
+	hwdev, err := pcmDeviceToHW(pcmdev)
+	if err != nil {
+		log.Println("Failed to convert PCM device to hardware device:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	log.Println("Using hardware device:", hwdev)
+
+	// Create a buffered reader to read audio data
+	log.Println("Starting audio pipe with arecord...")
+
+	// Start arecord with 48kHz, 16-bit, stereo
+	cmd := exec.Command("arecord",
+		"-f", "S16_LE", // Format: 16-bit little-endian
+		"-r", fmt.Sprint(i.Config.AudioConfig.SampleRate),
+		"-c", fmt.Sprint(i.Config.AudioConfig.Channels),
+		"-D", hwdev, // Use the hardware device
+	)
+
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		log.Println("Failed to get arecord stdout pipe:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	if err := cmd.Start(); err != nil {
+		log.Println("Failed to start arecord:", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	reader := bufio.NewReader(stdout)
+	bufferSize := i.Config.AudioConfig.FrameSize * i.Config.AudioConfig.Channels * i.Config.AudioConfig.BytesPerSample
+	log.Printf("Buffer size: %d bytes (FrameSize: %d, Channels: %d, BytesPerSample: %d)",
+		bufferSize, i.Config.AudioConfig.FrameSize, i.Config.AudioConfig.Channels, i.Config.AudioConfig.BytesPerSample)
+	buf := make([]byte, bufferSize*2)
+
+	// Start a goroutine to handle WebSocket messages
+	log.Println("Listening for WebSocket messages...")
+	go func() {
+		_, msg, err := conn.ReadMessage()
+		if err == nil {
+			if string(msg) == "exit" {
+				log.Println("Received exit command from client")
+				i.audiostopchan <- true // Signal to stop the audio pipe
+				return
+			}
+		}
+	}()
+
+	log.Println("Starting audio capture loop...")
+	i.isAudioStreaming = true
+	for {
+		select {
+		case <-i.audiostopchan:
+			log.Println("Audio pipe stopped")
+			goto DONE
+		default:
+			n, err := reader.Read(buf)
+			if err != nil {
+				log.Println("Read error:", err)
+				if i.audiostopchan != nil {
+					i.audiostopchan <- true // Signal to stop the audio pipe
+				}
+				goto DONE
+			}
+
+			if n == 0 {
+				continue
+			}
+
+			downsampled := buf[:n] // Default to original buffer if no downsampling
+			switch selectedQuality {
+			case "high":
+				// Keep original 48kHz stereo
+
+			case "standard":
+				// Downsample to 24kHz stereo
+				downsampled = downsample48kTo24kStereo(buf[:n]) // Downsample to 24kHz stereo
+				copy(buf, downsampled)                          // Copy downsampled data back into buf
+				n = len(downsampled)                            // Update n to the new length
+			case "low":
+				downsampled = downsample48kTo16kStereo(buf[:n]) // Downsample to 16kHz stereo
+				copy(buf, downsampled)                          // Copy downsampled data back into buf
+				n = len(downsampled)                            // Update n to the new length
+			}
+
+			//Send only the bytes read to WebSocket
+			err = conn.WriteMessage(websocket.BinaryMessage, downsampled[:n])
+			if err != nil {
+				log.Println("WebSocket send error:", err)
+				goto DONE
+			}
+		}
+	}
+
+DONE:
+	i.isAudioStreaming = false
+	cmd.Process.Kill()
+	log.Println("Audio pipe finished")
+}
+
+// Downsample48kTo24kStereo downsamples a 48kHz stereo audio buffer to 24kHz.
+// It assumes the input buffer is in 16-bit stereo format (2 bytes per channel).
+// The output buffer will also be in 16-bit stereo format.
+func downsample48kTo24kStereo(buf []byte) []byte {
+	const frameSize = 4 // 2 bytes per channel × 2 channels
+	if len(buf)%frameSize != 0 {
+		// Trim incomplete frame (rare case)
+		buf = buf[:len(buf)-len(buf)%frameSize]
+	}
+
+	out := make([]byte, 0, len(buf)/2)
+
+	for i := 0; i < len(buf); i += frameSize * 2 {
+		// Copy every other frame (drop 1 in 2)
+		if i+frameSize <= len(buf) {
+			out = append(out, buf[i:i+frameSize]...)
+		}
+	}
+
+	return out
+}
+
+// Downsample48kTo16kStereo downsamples a 48kHz stereo audio buffer to 16kHz.
+// It assumes the input buffer is in 16-bit stereo format (2 bytes per channel).
+// The output buffer will also be in 16-bit stereo format.
+func downsample48kTo16kStereo(buf []byte) []byte {
+	const frameSize = 4 // 2 bytes per channel × 2 channels
+	if len(buf)%frameSize != 0 {
+		// Trim incomplete frame (rare case)
+		buf = buf[:len(buf)-len(buf)%frameSize]
+	}
+
+	out := make([]byte, 0, len(buf)/3)
+
+	for i := 0; i < len(buf); i += frameSize * 3 {
+		// Copy every third frame (drop 2 in 3)
+		if i+frameSize <= len(buf) {
+			out = append(out, buf[i:i+frameSize]...)
+		}
+	}
+
+	return out
+}
+
+func alsa_device_occupied(dev string) bool {
+	f, err := os.OpenFile(dev, os.O_RDONLY|syscall.O_NONBLOCK, 0)
+	if err != nil {
+		//result <- true // Occupied or cannot open
+		return true
+	}
+	f.Close()
+	return false
+}

BIN
dezukvmd/mod/usbcapture/stream_takeover.jpg


BIN
dezukvmd/mod/usbcapture/stream_takeover.psd


+ 53 - 0
dezukvmd/mod/usbcapture/typedef.go

@@ -0,0 +1,53 @@
+package usbcapture
+
+import (
+	"context"
+
+	"github.com/vladimirvivien/go4vl/device"
+	"github.com/vladimirvivien/go4vl/v4l2"
+)
+
+// The capture resolution to open video device
+type CaptureResolution struct {
+	Width  int
+	Height int
+	FPS    int
+}
+
+type AudioConfig struct {
+	SampleRate     int
+	Channels       int
+	FrameSize      int
+	BytesPerSample int
+}
+
+type Config struct {
+	VideoDeviceName string       // The video device name, e.g., /dev/video0
+	AudioDeviceName string       // The audio device name, e.g., /dev/snd
+	AudioConfig     *AudioConfig // The audio configuration
+}
+
+type Instance struct {
+	/* Runtime configuration */
+	Config               *Config
+	SupportedResolutions []FormatInfo //The supported resolutions of the video device
+	Capturing            bool
+
+	/* Internals */
+	/* Video capture device */
+	camera             *device.Device
+	cameraStartContext context.CancelFunc
+	frames_buff        <-chan []byte
+	pixfmt             v4l2.FourCCType
+	width              int
+	height             int
+	streamInfo         string
+
+	/* audio capture device */
+	isAudioStreaming bool      // Whether audio is currently being captured
+	audiostopchan    chan bool // Channel to stop audio capture
+
+	/* Concurrent access */
+	accessCount       int       // The number of current access, in theory each instance should at most have 1 access
+	videoTakeoverChan chan bool // Channel to signal video takeover request
+}

+ 83 - 0
dezukvmd/mod/usbcapture/usbcapture.go

@@ -0,0 +1,83 @@
+package usbcapture
+
+import (
+	"fmt"
+	"os"
+)
+
+// NewInstance creates a new video capture instance
+func NewInstance(config *Config) (*Instance, error) {
+	if config == nil {
+		return nil, fmt.Errorf("config cannot be nil")
+	}
+
+	//Check if the video device exists
+	if _, err := os.Stat(config.VideoDeviceName); os.IsNotExist(err) {
+		return nil, fmt.Errorf("video device %s does not exist", config.VideoDeviceName)
+	} else if err != nil {
+		return nil, fmt.Errorf("failed to check video device: %w", err)
+	}
+
+	//Check if the device file actualy points to a video device
+	isValidDevice, err := checkVideoCaptureDevice(config.VideoDeviceName)
+	if err != nil {
+		return nil, fmt.Errorf("failed to check video device: %w", err)
+	}
+
+	if !isValidDevice {
+		return nil, fmt.Errorf("device %s is not a video capture device", config.VideoDeviceName)
+	}
+
+	//Get the supported resolutions of the video device
+	formatInfo, err := GetV4L2FormatInfo(config.VideoDeviceName)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get video device format info: %w", err)
+	}
+
+	if len(formatInfo) == 0 {
+		return nil, fmt.Errorf("no supported formats found for device %s", config.VideoDeviceName)
+	}
+
+	return &Instance{
+		Config:               config,
+		Capturing:            false,
+		SupportedResolutions: formatInfo,
+
+		// Videos
+		camera:     nil,
+		pixfmt:     0,
+		width:      0,
+		height:     0,
+		streamInfo: "",
+
+		//Audio
+		audiostopchan: make(chan bool, 1),
+
+		// Access control
+		videoTakeoverChan: make(chan bool, 1),
+		accessCount:       0,
+	}, nil
+}
+
+// GetStreamInfo returns the stream information string
+func (i *Instance) GetStreamInfo() string {
+	return i.streamInfo
+}
+
+// IsCapturing checks if the camera is currently capturing video
+func (i *Instance) IsCapturing() bool {
+	return i.Capturing
+}
+
+// IsAudioStreaming checks if the audio is currently being captured
+func (i *Instance) IsAudioStreaming() bool {
+	return i.isAudioStreaming
+}
+
+// Close closes the camera device and releases resources
+func (i *Instance) Close() error {
+	if i.camera != nil {
+		i.StopCapture()
+	}
+	return nil
+}

+ 414 - 0
dezukvmd/mod/usbcapture/video_device.go

@@ -0,0 +1,414 @@
+package usbcapture
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	_ "embed"
+	"errors"
+	"fmt"
+	"log"
+	"mime/multipart"
+	"net/http"
+	"net/textproto"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"syscall"
+
+	"github.com/vladimirvivien/go4vl/device"
+	"github.com/vladimirvivien/go4vl/v4l2"
+)
+
+/*
+	1920 x 1080 60fps = 55Mbps //Edge not support
+	1920 x 1080 30fps = 50Mbps
+	1920 x 1080 25fps = 40Mbps
+	1920 x 1080 20fps = 30Mbps
+	1920 x 1080 10fps = 15Mbps
+
+	1360 x 768 60fps = 28Mbps
+	1360 x 768 30fps = 25Mbps
+	1360 x 768 25fps = 20Mbps
+	1360 x 768 20fps = 18Mbps
+	1360 x 768 10fps = 10Mbps
+*/
+
+// Struct to store the size and fps info
+type FormatInfo struct {
+	Format string
+	Sizes  []SizeInfo
+}
+
+type SizeInfo struct {
+	Width  int
+	Height int
+	FPS    []int
+}
+
+//go:embed stream_takeover.jpg
+var endOfStreamJPG []byte
+
+// start video capture
+func (i *Instance) StartVideoCapture(openWithResolution *CaptureResolution) error {
+	if i.Capturing {
+		return fmt.Errorf("video capture already started")
+	}
+
+	if openWithResolution.FPS == 0 {
+		openWithResolution.FPS = 25 //Default to 25 FPS
+	}
+
+	devName := i.Config.VideoDeviceName
+	if openWithResolution == nil {
+		return fmt.Errorf("resolution not provided")
+	}
+	frameRate := openWithResolution.FPS
+	buffSize := 8 //No. of frames to buffer
+	//Default to MJPEG
+	//Other formats that are commonly supported are YUYV, H264, MJPEG
+	format := "mjpeg"
+
+	//Check if the video device is a capture device
+	isCaptureDev, err := checkVideoCaptureDevice(devName)
+	if err != nil {
+		return fmt.Errorf("failed to check video device: %w", err)
+	}
+	if !isCaptureDev {
+		return fmt.Errorf("device %s is not a video capture device", devName)
+	}
+
+	//Check if the selected FPS is valid in the provided Resolutions
+	resolutionIsSupported, err := deviceSupportResolution(i.Config.VideoDeviceName, openWithResolution)
+	if err != nil {
+		return err
+	}
+	if !resolutionIsSupported {
+		return errors.New("this device do not support the required resolution settings")
+	}
+
+	//Open the video device
+	camera, err := device.Open(devName,
+		device.WithIOType(v4l2.IOTypeMMAP),
+		device.WithPixFormat(v4l2.PixFormat{
+			PixelFormat: getFormatType(format),
+			Width:       uint32(openWithResolution.Width),
+			Height:      uint32(openWithResolution.Height),
+			Field:       v4l2.FieldAny,
+		}),
+		device.WithFPS(uint32(frameRate)),
+		device.WithBufferSize(uint32(buffSize)),
+	)
+
+	if err != nil {
+		return fmt.Errorf("failed to open video device: %w", err)
+	}
+
+	i.camera = camera
+	caps := camera.Capability()
+	log.Printf("device [%s] opened\n", devName)
+	log.Printf("device info: %s", caps.String())
+	// Should get something like this:
+	//2025/03/16 15:45:25 device info: driver: uvcvideo; card: USB Video: USB Video; bus info: usb-0000:00:14.0-2
+
+	// set device format
+	currFmt, err := camera.GetPixFormat()
+	if err != nil {
+		return fmt.Errorf("failed to get current pixel format: %w", err)
+	}
+	log.Printf("Current format: %s", currFmt)
+	//2025/03/16 15:45:25 Current format: Motion-JPEG [1920x1080]; field=any; bytes per line=0; size image=0; colorspace=Default; YCbCr=Default; Quant=Default; XferFunc=Default
+	i.pixfmt = currFmt.PixelFormat
+	i.width = int(currFmt.Width)
+	i.height = int(currFmt.Height)
+
+	i.streamInfo = fmt.Sprintf("%s - %s [%dx%d] %d fps",
+		caps.Card,
+		v4l2.PixelFormats[currFmt.PixelFormat],
+		currFmt.Width, currFmt.Height, frameRate,
+	)
+
+	// start capture
+	ctx, cancel := context.WithCancel(context.TODO())
+	if err := camera.Start(ctx); err != nil {
+		log.Fatalf("stream capture: %s", err)
+	}
+	i.cameraStartContext = cancel
+
+	// video stream
+	i.frames_buff = camera.GetOutput()
+
+	log.Printf("device capture started (buffer size set %d)", camera.BufferCount())
+	i.Capturing = true
+	return nil
+}
+
+// start http service
+func (i *Instance) ServeVideoStream(w http.ResponseWriter, req *http.Request) {
+	//Check if the access count is already 1, if so, kick out the previous access
+	if i.accessCount >= 1 {
+		log.Println("Another client is already connected, kicking out the previous client...")
+		if i.videoTakeoverChan != nil {
+			i.videoTakeoverChan <- true
+		}
+		log.Println("Previous client kicked out, taking over the stream...")
+	}
+	i.accessCount++
+	defer func() { i.accessCount-- }()
+
+	// Set up the multipart response
+	mimeWriter := multipart.NewWriter(w)
+	w.Header().Set("Content-Type", fmt.Sprintf("multipart/x-mixed-replace; boundary=%s", mimeWriter.Boundary()))
+	partHeader := make(textproto.MIMEHeader)
+	partHeader.Add("Content-Type", "image/jpeg")
+
+	var frame []byte
+
+	//Chrome MJPEG decoder cannot decode the first frame from MS2109 capture card for unknown reason
+	//Thus we are discarding the first frame here
+	if i.frames_buff != nil {
+		select {
+		case <-i.frames_buff:
+			// Discard the first frame
+		default:
+			// No frame to discard
+		}
+	}
+
+	// Streaming loop
+	for frame = range i.frames_buff {
+		if len(frame) == 0 {
+			log.Print("skipping empty frame")
+			continue
+		}
+
+		partWriter, err := mimeWriter.CreatePart(partHeader)
+		if err != nil {
+			log.Printf("failed to create multi-part writer: %s", err)
+			return
+		}
+
+		if _, err := partWriter.Write(frame); err != nil {
+			if errors.Is(err, syscall.EPIPE) {
+				//broken pipe, the client browser has exited
+				return
+			}
+			log.Printf("failed to write image: %s", err)
+		}
+
+		select {
+		case <-req.Context().Done():
+			// Client disconnected, exit the loop
+			return
+		case <-i.videoTakeoverChan:
+			// Another client is taking over, exit the loop
+
+			//Send the endofstream.jpg as last frame before exit
+			endFrameHeader := make(textproto.MIMEHeader)
+			endFrameHeader.Add("Content-Type", "image/jpeg")
+			endFrameHeader.Add("Content-Length", fmt.Sprint(len(endOfStreamJPG)))
+			partWriter, err := mimeWriter.CreatePart(endFrameHeader)
+			if err == nil {
+				partWriter.Write(endOfStreamJPG)
+			}
+			log.Println("Video stream taken over by another client, exiting...")
+			return
+		default:
+			// Continue streaming
+		}
+
+	}
+}
+
+// StopCapture stops the video capture and closes the camera device
+func (i *Instance) StopCapture() error {
+	if i.camera != nil {
+		i.cameraStartContext()
+		i.camera.Close()
+		i.camera = nil
+	}
+	i.Capturing = false
+	return nil
+}
+
+// CheckVideoCaptureDevice checks if the given video device is a video capture device
+func checkVideoCaptureDevice(device string) (bool, error) {
+	// Run v4l2-ctl to get device capabilities
+	cmd := exec.Command("v4l2-ctl", "--device", device, "--all")
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return false, fmt.Errorf("failed to execute v4l2-ctl: %w", err)
+	}
+
+	// Convert output to string and check for the "Video Capture" capability
+	outputStr := string(output)
+	if strings.Contains(outputStr, "Video Capture") {
+		return true, nil
+	}
+	return false, nil
+}
+
+// GetDefaultVideoDevice returns the first available video capture device, e.g., /dev/video0
+func GetDefaultVideoDevice() (string, error) {
+	// List all /dev/video* devices and return the first one that is a video capture device
+	for i := 0; i < 10; i++ {
+		device := fmt.Sprintf("/dev/video%d", i)
+		isCapture, err := checkVideoCaptureDevice(device)
+		if err != nil {
+			continue
+		}
+		if isCapture {
+			return device, nil
+		}
+	}
+	return "", fmt.Errorf("no video capture device found")
+}
+
+// deviceSupportResolution checks if the given video device supports the specified resolution and frame rate
+func deviceSupportResolution(devicePath string, resolution *CaptureResolution) (bool, error) {
+	formatInfo, err := GetV4L2FormatInfo(devicePath)
+	if err != nil {
+		return false, err
+	}
+
+	// Yes, this is an O(N^3) operation, but a video decices rarely have supported resolution
+	// more than 20 combinations. The compute time should be fine
+	for _, res := range formatInfo {
+		for _, size := range res.Sizes {
+			//Check if there is a matching resolution
+			if size.Height == resolution.Height && size.Width == resolution.Width {
+				//Matching resolution. Check if the required FPS is supported
+				for _, fps := range size.FPS {
+					if fps == resolution.FPS {
+						return true, nil
+					}
+				}
+			}
+		}
+	}
+
+	return false, nil
+}
+
+// PrintV4L2FormatInfo prints the supported formats, resolutions, and frame rates of the given video device
+func PrintV4L2FormatInfo(devicePath string) {
+	// Check if the device is a video capture device
+	isCapture, err := checkVideoCaptureDevice(devicePath)
+	if err != nil {
+		fmt.Printf("Error checking device: %v\n", err)
+		return
+	}
+	if !isCapture {
+		fmt.Printf("Device %s is not a video capture device\n", devicePath)
+		return
+	}
+
+	// Get format info
+	formats, err := GetV4L2FormatInfo(devicePath)
+	if err != nil {
+		fmt.Printf("Error getting format info: %v\n", err)
+		return
+	}
+
+	// Print format info
+	for _, format := range formats {
+		fmt.Printf("Format: %s\n", format.Format)
+		for _, size := range format.Sizes {
+			fmt.Printf("  Size: %dx%d\n", size.Width, size.Height)
+			fmt.Printf("    FPS: %v\n", size.FPS)
+		}
+	}
+}
+
+// Function to run the v4l2-ctl command and parse the output
+func GetV4L2FormatInfo(devicePath string) ([]FormatInfo, error) {
+	// Run the v4l2-ctl command to list formats
+	cmd := exec.Command("v4l2-ctl", "--list-formats-ext", "-d", devicePath)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	err := cmd.Run()
+	if err != nil {
+		return nil, err
+	}
+
+	// Parse the output
+	var formats []FormatInfo
+	var currentFormat *FormatInfo
+	scanner := bufio.NewScanner(&out)
+
+	formatRegex := regexp.MustCompile(`\[(\d+)\]: '(\S+)'`)
+	sizeRegex := regexp.MustCompile(`Size: Discrete (\d+)x(\d+)`)
+	intervalRegex := regexp.MustCompile(`Interval: Discrete (\d+\.\d+)s \((\d+\.\d+) fps\)`)
+
+	for scanner.Scan() {
+		line := scanner.Text()
+
+		// Match format line
+		if matches := formatRegex.FindStringSubmatch(line); matches != nil {
+			if currentFormat != nil {
+				formats = append(formats, *currentFormat)
+			}
+			// Start a new format entry
+			currentFormat = &FormatInfo{
+				Format: matches[2],
+			}
+		}
+
+		// Match size line
+		if matches := sizeRegex.FindStringSubmatch(line); matches != nil {
+			width, _ := strconv.Atoi(matches[1])
+			height, _ := strconv.Atoi(matches[2])
+
+			// Initialize the size entry
+			sizeInfo := SizeInfo{
+				Width:  width,
+				Height: height,
+			}
+
+			// Match FPS intervals for the current size
+			for scanner.Scan() {
+				line = scanner.Text()
+
+				if fpsMatches := intervalRegex.FindStringSubmatch(line); fpsMatches != nil {
+					fps, _ := strconv.ParseFloat(fpsMatches[2], 32)
+					sizeInfo.FPS = append(sizeInfo.FPS, int(fps))
+				} else {
+					// Stop parsing FPS intervals when no more matches are found
+					break
+				}
+			}
+			// Add the size information to the current format
+			currentFormat.Sizes = append(currentFormat.Sizes, sizeInfo)
+		}
+	}
+
+	// Append the last format if present
+	if currentFormat != nil {
+		formats = append(formats, *currentFormat)
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+
+	return formats, nil
+}
+
+func getFormatType(fmtStr string) v4l2.FourCCType {
+	switch strings.ToLower(fmtStr) {
+	case "jpeg":
+		return v4l2.PixelFmtJPEG
+	case "mpeg":
+		return v4l2.PixelFmtMPEG
+	case "mjpeg":
+		return v4l2.PixelFmtMJPEG
+	case "h264", "h.264":
+		return v4l2.PixelFmtH264
+	case "yuyv":
+		return v4l2.PixelFmtYUYV
+	case "rgb":
+		return v4l2.PixelFmtRGB24
+	}
+	return v4l2.PixelFmtMPEG
+}

+ 98 - 0
dezukvmd/tools.go

@@ -0,0 +1,98 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os/exec"
+
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
+)
+
+func handle_debug_tool() error {
+	switch *tool {
+	case "dependency-precheck":
+		err := run_dependency_precheck()
+		if err != nil {
+			return err
+		}
+	case "list-usbkvm-json":
+		result, err := discoverUsbKvmSubtree()
+		if err != nil {
+			return err
+		}
+		jsonData, err := json.MarshalIndent(result, "", "  ")
+		if err != nil {
+			return err
+		}
+		fmt.Println(string(jsonData))
+	case "audio-devices":
+		err := list_all_audio_devices()
+		if err != nil {
+			return err
+		}
+	case "list-usbkvm":
+		err := list_usb_kvm_devcies()
+		if err != nil {
+			return err
+		}
+	default:
+		return fmt.Errorf("please specify a valid tool with -tool option")
+	}
+	return nil
+}
+
+// run_dependency_precheck checks if required dependencies are available in the system
+func run_dependency_precheck() error {
+	log.Println("Running precheck...")
+	// Dependencies of USB capture card
+	if _, err := exec.LookPath("v4l2-ctl"); err != nil {
+		return fmt.Errorf("v4l2-ctl not found in PATH")
+	}
+	if _, err := exec.LookPath("arecord"); err != nil {
+		return fmt.Errorf("arecord not found in PATH")
+	}
+	log.Println("v4l2-ctl and arecord found in PATH.")
+	return nil
+}
+
+// list_usb_kvm_devcies lists all discovered USB KVM devices and their associated sub-devices
+func list_usb_kvm_devcies() error {
+	result, err := discoverUsbKvmSubtree()
+	if err != nil {
+		return err
+	}
+	for i, dev := range result {
+		log.Printf("USB KVM Device Tree %d:\n", i)
+		log.Printf(" - USB KVM Device: %s\n", dev.USBKVMDevicePath)
+		log.Printf(" - Aux MCU Device: %s\n", dev.AuxMCUDevicePath)
+		for _, cap := range dev.CaptureDevicePaths {
+			log.Printf(" - Capture Device: %s\n", cap)
+		}
+		for _, snd := range dev.AlsaDevicePaths {
+			log.Printf(" - ALSA Device: %s\n", snd)
+		}
+	}
+	return nil
+}
+
+// list_all_audio_devices lists all available audio capture devices
+func list_all_audio_devices() error {
+	log.Println("Starting in List Audio Devices mode...")
+	// Get the audio devices
+	path, err := usbcapture.FindHDMICapturePCMPath()
+	if err != nil {
+		return err
+	}
+	log.Printf("Found HDMI capture PCM path: %s\n", path)
+	// List all audio capture devices
+	captureDevs, err := usbcapture.ListCaptureDevices()
+	if err != nil {
+		return err
+	}
+	log.Println("Available audio capture devices:")
+	for _, dev := range captureDevs {
+		log.Printf(" - %s\n", dev)
+	}
+	return nil
+}

+ 175 - 0
dezukvmd/usbkvm.go

@@ -0,0 +1,175 @@
+package main
+
+/*
+	usbkvm.go
+
+	Handles the USB KVM device connections and auxiliary devices
+	running in USB KVM mode. This mode only support 1 USB KVM device
+	at a time.
+
+	For running multiple USB KVM devices, use the ipkvm mode.
+*/
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"imuslab.com/dezukvm/dezukvmd/mod/remdesaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/remdeshid"
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
+)
+
+type UsbKvmConfig struct {
+	ListeningAddress        string
+	USBKVMDevicePath        string
+	AuxMCUDevicePath        string
+	VideoCaptureDevicePath  string
+	AudioCaptureDevicePath  string
+	CaptureResolutionWidth  int
+	CaptureResolutionHeight int
+	CaptureResolutionFPS    int
+	USBKVMBaudrate          int
+	AuxMCUBaudrate          int
+}
+
+var (
+	/* Internal variables for USB-KVM mode only */
+	usbKVM              *remdeshid.Controller
+	auxMCU              *remdesaux.AuxMcu
+	usbCaptureDevice    *usbcapture.Instance
+	defaultUsbKvmConfig = &UsbKvmConfig{
+		ListeningAddress:        ":9000",
+		USBKVMDevicePath:        "/dev/ttyUSB0",
+		AuxMCUDevicePath:        "/dev/ttyACM0",
+		VideoCaptureDevicePath:  "/dev/video0",
+		AudioCaptureDevicePath:  "/dev/snd/pcmC1D0c",
+		CaptureResolutionWidth:  1920,
+		CaptureResolutionHeight: 1080,
+		CaptureResolutionFPS:    25,
+		USBKVMBaudrate:          115200,
+		AuxMCUBaudrate:          115200,
+	}
+)
+
+func loadUsbKvmConfig() (*UsbKvmConfig, error) {
+	if _, err := os.Stat(usbKvmConfigPath); os.IsNotExist(err) {
+		file, err := os.OpenFile(usbKvmConfigPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
+		if err != nil {
+			return nil, err
+		}
+
+		// Save default config as JSON
+		enc := json.NewEncoder(file)
+		enc.SetIndent("", "  ")
+		if err := enc.Encode(defaultUsbKvmConfig); err != nil {
+			file.Close()
+			return nil, err
+		}
+		file.Close()
+		return defaultUsbKvmConfig, nil
+	}
+
+	// Load config from file
+	file, err := os.Open(usbKvmConfigPath)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg := &UsbKvmConfig{}
+	dec := json.NewDecoder(file)
+	if err := dec.Decode(cfg); err != nil {
+		file.Close()
+		return nil, err
+	}
+	file.Close()
+	return cfg, nil
+}
+
+func startUsbKvmMode(config *UsbKvmConfig) error {
+	log.Println("Starting in USB KVM mode...")
+	// Initiate the HID controller
+	usbKVM = remdeshid.NewHIDController(&remdeshid.Config{
+		PortName:          config.USBKVMDevicePath,
+		BaudRate:          config.USBKVMBaudrate,
+		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity
+	})
+
+	//Start the HID controller
+	err := usbKVM.Connect()
+	if err != nil {
+		return err
+	}
+
+	//Start auxiliary MCU connections
+	auxMCU, err = remdesaux.NewAuxOutbandController(config.AuxMCUDevicePath, config.AuxMCUBaudrate)
+	if err != nil {
+		return err
+	}
+
+	//Try get the UUID from the auxiliary MCU
+	uuid, err := auxMCU.GetUUID()
+	if err != nil {
+		log.Println("Get UUID failed:", err, " - Auxiliary MCU may not be connected.")
+
+		//Register dummy AUX routes if failed to get UUID
+		registerDummyLocalAuxRoutes()
+	} else {
+		log.Println("Auxiliary MCU found with UUID:", uuid)
+
+		//Register the AUX routes if success
+		registerLocalAuxRoutes()
+	}
+
+	// Initiate the video capture device
+	usbCaptureDevice, err = usbcapture.NewInstance(&usbcapture.Config{
+		VideoDeviceName: config.VideoCaptureDevicePath,
+		AudioDeviceName: config.AudioCaptureDevicePath,
+		AudioConfig:     usbcapture.GetDefaultAudioConfig(),
+	})
+
+	if err != nil {
+		log.Println("Video capture device init failed:", err, " - Video capture device may not be connected.")
+		return err
+	}
+
+	//Get device information for debug
+	usbcapture.PrintV4L2FormatInfo(config.VideoCaptureDevicePath)
+
+	//Start the video capture device
+	err = usbCaptureDevice.StartVideoCapture(&usbcapture.CaptureResolution{
+		Width:  config.CaptureResolutionWidth,
+		Height: config.CaptureResolutionHeight,
+		FPS:    config.CaptureResolutionFPS,
+	})
+	if err != nil {
+		return err
+	}
+
+	// Handle program exit to close the HID controller
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		log.Println("Shutting down usbKVM...")
+
+		if auxMCU != nil {
+			auxMCU.Close()
+		}
+		log.Println("Shutting down capture device...")
+		if usbCaptureDevice != nil {
+			usbCaptureDevice.Close()
+		}
+		os.Exit(0)
+	}()
+
+	// Register the rest of the API routes
+	registerAPIRoutes()
+
+	addr := config.ListeningAddress
+	log.Printf("Serving on%s\n", addr)
+	err = http.ListenAndServe(addr, nil)
+	return err
+}

BIN
dezukvmd/www/img/cursor_overlay.png


BIN
dezukvmd/www/img/cursor_overlay.psd


+ 31 - 0
dezukvmd/www/index.html

@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description" content="dezuKVM Management Interface">
+    <meta name="author" content="imuslab">
+    <meta name="csrf-token" content="">
+    <title>Connected | dezuKVM</title>
+    
+    <!-- OpenGraph Metadata -->
+    <meta property="og:title" content="dezuKVM Management Interface">
+    <meta property="og:description" content="A web-based management interface for dezuKVM">
+    <meta property="og:type" content="website">
+    <meta property="og:url" content="https://kvm.aroz.org">
+    <meta property="og:image" content="https://kvm.aroz.org/og.jpg">
+    <script src="js/jquery-3.7.1.min.js"></script>
+    <link rel="stylesheet" href="main.css">
+</head>
+<body>
+    <img id="remoteCapture" src="/stream" oncontextmenu="return false;"></img>
+    <div id="menu">
+        <button id="btnFullScreen" onclick="toggleFullScreen()">Fullscreen</button>
+        <button id="btnCtrlAltDel" onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
+        <button id="btnFullScreen" onclick="switchMassStorageToKvm()">Switch Storage to KVM</button>
+        <button id="btnFullScreen" onclick="switchMassStorageToRemote()">Switch Storage to Remote</button>
+    </div>
+    <script src="ui.js"></script>
+    <script src="kvmevt.js"></script>
+</body>
+</html>

File diff suppressed because it is too large
+ 1 - 0
dezukvmd/www/js/jquery-3.7.1.min.js


+ 386 - 0
dezukvmd/www/kvmevt.js

@@ -0,0 +1,386 @@
+/*
+    kvmevt.js
+
+    Keyboard, Video, Mouse (KVM) over WebSocket client-side event handling.
+    Handles mouse and keyboard events, sending them to the server via WebSocket.
+    Also manages audio streaming from the server.
+*/
+const enableKvmEventDebugPrintout = false; //Set to true to enable debug printout
+const cursorCaptureElementId = "remoteCapture";
+let socket;
+let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+let port = window.location.port ? window.location.port : (protocol === 'wss' ? 443 : 80);
+let socketURL = `${protocol}://${window.location.hostname}:${port}/hid`;
+let mouseMoveAbsolute = true; // Set to true for absolute mouse coordinates, false for relative
+let mouseIsOutside = false; //Mouse is outside capture element
+let audioFrontendStarted = false; //Audio frontend has been started
+
+
+/* Mouse events */
+function handleMouseMove(event) {
+    const hidCommand = {
+        event: 2,
+        mouse_x: event.clientX,
+        mouse_y: event.clientY,
+    };
+
+    const rect = event.target.getBoundingClientRect();
+    const relativeX = event.clientX - rect.left;
+    const relativeY = event.clientY - rect.top;
+    
+    if (relativeX < 0 || relativeY < 0 || relativeX > rect.width || relativeY > rect.height) {
+        mouseIsOutside = true;
+        return; // Mouse is outside the client rect
+    }
+    mouseIsOutside = false;
+    const percentageX = (relativeX / rect.width) * 4096;
+    const percentageY = (relativeY / rect.height) * 4096;
+
+    hidCommand.mouse_x = Math.round(percentageX);
+    hidCommand.mouse_y = Math.round(percentageY);
+
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Mouse move: (${event.clientX}, ${event.clientY})`);
+        console.log(`Mouse move relative: (${relativeX}, ${relativeY})`);
+        console.log(`Mouse move percentage: (${hidCommand.mouse_x}, ${hidCommand.mouse_y})`);
+    }
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+}
+
+
+function handleMousePress(event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+    if (mouseIsOutside) {
+        console.warn("Mouse is outside the capture area, ignoring mouse press.");
+        return;
+    }
+    /* Mouse buttons: 1=left, 2=right, 3=middle */
+    const buttonMap = {
+        0: 1, 
+        1: 3,
+        2: 2
+    }; //Map javascript mouse buttons to HID buttons
+
+    const hidCommand = {
+        event: 3,
+        mouse_button: buttonMap[event.button] || 0
+    };
+
+    // Log the mouse button state
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Mouse down: ${hidCommand.mouse_button}`);
+    }
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+
+    if (!audioFrontendStarted){
+        startAudioWebSocket();
+        audioFrontendStarted = true;
+    }
+}
+
+function handleMouseRelease(event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+    if (mouseIsOutside) {
+        console.warn("Mouse is outside the capture area, ignoring mouse press.");
+        return;
+    }
+    /* Mouse buttons: 1=left, 2=right, 3=middle */
+    const buttonMap = {
+        0: 1, 
+        1: 3,
+        2: 2
+    }; //Map javascript mouse buttons to HID buttons
+    
+    const hidCommand = {
+        event: 4,
+        mouse_button: buttonMap[event.button] || 0
+    };
+
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Mouse release: ${hidCommand.mouse_button}`);
+    }
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+}
+
+function handleMouseScroll(event) {
+    const hidCommand = {
+        event: 5,
+        mouse_scroll: event.deltaY
+    };
+    if (mouseIsOutside) {
+        console.warn("Mouse is outside the capture area, ignoring mouse press.");
+        return;
+    }
+
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Mouse scroll: mouse_scroll=${event.deltaY}`);
+    }
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+}
+
+// Attach mouse event listeners
+let remoteCaptureEle = document.getElementById(cursorCaptureElementId);
+remoteCaptureEle.addEventListener('mousemove', handleMouseMove);
+remoteCaptureEle.addEventListener('mousedown', handleMousePress);
+remoteCaptureEle.addEventListener('mouseup', handleMouseRelease);
+remoteCaptureEle.addEventListener('wheel', handleMouseScroll);
+
+/* Keyboard */
+function isNumpadEvent(event) {
+    return event.location === 3;
+}
+
+function handleKeyDown(event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+    const key = event.key;
+    let hidCommand = {
+        event: 0,
+        keycode: event.keyCode
+    };
+
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Key down: ${key} (code: ${event.keyCode})`);
+    }
+
+    // Check if the key is a modkey on the right side of the keyboard
+    const rightModKeys = ['Control', 'Alt', 'Shift', 'Meta'];
+    if (rightModKeys.includes(key) && event.location === 2) {
+        hidCommand.is_right_modifier_key = true;
+    }else if (key === 'Enter' && isNumpadEvent(event)) {
+        //Special case for Numpad Enter
+        hidCommand.is_right_modifier_key = true;
+    }else{
+        hidCommand.is_right_modifier_key = false;
+    }
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+}
+
+function handleKeyUp(event) {
+    event.preventDefault();
+    event.stopImmediatePropagation();
+    const key = event.key;
+    
+    let hidCommand = {
+        event: 1,
+        keycode: event.keyCode
+    };
+
+    if (enableKvmEventDebugPrintout) {
+        console.log(`Key up: ${key} (code: ${event.keyCode})`);
+    }
+
+    // Check if the key is a modkey on the right side of the keyboard
+    const rightModKeys = ['Control', 'Alt', 'Shift', 'Meta'];
+    if (rightModKeys.includes(key) && event.location === 2) {
+        hidCommand.is_right_modifier_key = true;
+    } else if (key === 'Enter' && isNumpadEvent(event)) {
+        //Special case for Numpad Enter
+        hidCommand.is_right_modifier_key = true;
+    }else{
+        hidCommand.is_right_modifier_key = false;
+    }
+
+
+    if (socket && socket.readyState === WebSocket.OPEN) {
+        socket.send(JSON.stringify(hidCommand));
+    } else {
+        console.error("WebSocket is not open.");
+    }
+}
+
+/* Start and Stop events */
+function startWebSocket(){
+    if (socket){
+        //Already started
+        console.warn("Invalid usage: HID Transport Websocket already started!");
+        return;
+    }
+    const socketUrl = socketURL;
+    socket = new WebSocket(socketUrl);
+
+    socket.addEventListener('open', function(event) {
+        console.log('HID Transport WebSocket is connected.');
+
+        // Send a soft reset command to the server to reset the HID state
+        // that possibly got out of sync from previous session
+        const hidResetCommand = {
+            event: 0xFF
+        };
+        socket.send(JSON.stringify(hidResetCommand));
+    });
+
+    socket.addEventListener('message', function(event) {
+        //Todo: handle control signals from server if needed
+        //console.log('Message from server ', event.data);
+    });
+
+    document.addEventListener('keydown', handleKeyDown);
+    document.addEventListener('keyup', handleKeyUp);
+}
+
+function stopWebSocket(){
+    if (!socket){
+        alert("No ws connection to stop");
+        return;
+    }
+
+    socket.close();
+    console.log('HID Transport WebSocket disconnected.');
+    document.removeEventListener('keydown', handleKeyDown);
+    document.removeEventListener('keyup', handleKeyUp);
+}
+
+/* Audio Streaming Frontend */
+let audioSocket;
+let audioContext;
+let audioQueue = [];
+let audioPlaying = false;
+
+//accept low, standard, high quality audio mode
+function startAudioWebSocket(quality="standard") {
+    if (audioSocket) {
+        console.warn("Audio WebSocket already started");
+        return;
+    }
+    let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
+    let port = window.location.port ? window.location.port : (protocol === 'wss' ? 443 : 80);
+    let audioSocketURL = `${protocol}://${window.location.hostname}:${port}/audio?quality=${quality}`;
+
+    audioSocket = new WebSocket(audioSocketURL);
+    audioSocket.binaryType = 'arraybuffer';
+
+    audioSocket.onopen = function() {
+        console.log("Audio WebSocket connected");
+        if (!audioContext) {
+            audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 24000});
+        }
+    };
+
+
+    const MAX_AUDIO_QUEUE = 8;
+    let PCM_SAMPLE_RATE;
+    if (quality == "high"){
+        PCM_SAMPLE_RATE = 48000; // Use 48kHz for high quality
+    } else if (quality == "low") {
+        PCM_SAMPLE_RATE = 16000; // Use 24kHz for low quality
+    } else {
+        PCM_SAMPLE_RATE = 24000; // Default to 24kHz for standard quality
+    }
+    let scheduledTime = 0;
+    audioSocket.onmessage = function(event) {
+        if (!audioContext) return;
+        let pcm = new Int16Array(event.data);
+        if (pcm.length === 0) {
+            console.warn("Received empty PCM data");
+            return;
+        }
+        if (pcm.length % 2 !== 0) {
+            console.warn("Received PCM data with odd length, dropping last sample");
+            pcm = pcm.slice(0, -1);
+        }
+        // Convert Int16 PCM to Float32 [-1, 1]
+        let floatBuf = new Float32Array(pcm.length);
+        for (let i = 0; i < pcm.length; i++) {
+            floatBuf[i] = pcm[i] / 32768;
+        }
+        // Limit queue size to prevent memory overflow
+        if (audioQueue.length >= MAX_AUDIO_QUEUE) {
+            audioQueue.shift();
+        }
+        audioQueue.push(floatBuf);
+        scheduleAudioPlayback();
+    };
+
+    audioSocket.onclose = function() {
+        console.log("Audio WebSocket closed");
+        audioSocket = null;
+        audioPlaying = false;
+        audioQueue = [];
+        scheduledTime = 0;
+    };
+
+    audioSocket.onerror = function(e) {
+        console.error("Audio WebSocket error", e);
+    };
+
+    function scheduleAudioPlayback() {
+        if (!audioContext || audioQueue.length === 0) return;
+
+        // Use audioContext.currentTime to schedule buffers back-to-back
+        if (scheduledTime < audioContext.currentTime) {
+            scheduledTime = audioContext.currentTime;
+        }
+
+        while (audioQueue.length > 0) {
+            let floatBuf = audioQueue.shift();
+            let frameCount = floatBuf.length / 2;
+            let buffer = audioContext.createBuffer(2, frameCount, PCM_SAMPLE_RATE);
+            for (let ch = 0; ch < 2; ch++) {
+                let channelData = buffer.getChannelData(ch);
+                for (let i = 0; i < frameCount; i++) {
+                    channelData[i] = floatBuf[i * 2 + ch];
+                }
+            }
+            let source = audioContext.createBufferSource();
+            source.buffer = buffer;
+            source.connect(audioContext.destination);
+            source.start(scheduledTime);
+            scheduledTime += buffer.duration;
+        }
+    }
+}
+
+function stopAudioWebSocket() {
+    if (!audioSocket) {
+        console.warn("No audio WebSocket to stop");
+        return;
+    }
+
+    if (audioSocket.readyState === WebSocket.OPEN) {
+        audioSocket.send("exit");
+    }
+    audioSocket.onclose = null; // Prevent onclose from being called again
+    audioSocket.onerror = null; // Prevent onerror from being called again
+    audioSocket.close();
+    audioSocket = null;
+    audioPlaying = false;
+    audioQueue = [];
+    if (audioContext) {
+        audioContext.close();
+        audioContext = null;
+    }
+}
+
+startWebSocket();
+
+window.addEventListener('beforeunload', function() {
+    stopAudioWebSocket();
+});

+ 34 - 0
dezukvmd/www/main.css

@@ -0,0 +1,34 @@
+body {
+    margin: 0;
+    padding: 0;
+    height: 100vh;
+    width: 100vw;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    box-sizing: border-box;
+    overflow: hidden;
+    background-color: black;
+}
+
+#remoteCapture {
+    max-width: 100vw;
+    max-height: 100vh;
+    width: auto;
+    height: auto;
+    display: block;
+    margin: auto;
+    object-fit: contain;
+    cursor: url('img/cursor_overlay.png') 10 10, pointer;
+}
+
+#menu {
+    position: fixed;
+    top: 0px;
+    left: 50%;
+    transform: translateX(-50%);
+    padding: 10px;
+    display: flex;
+    gap: 10px;
+    z-index: 1000;
+}

+ 72 - 0
dezukvmd/www/ui.js

@@ -0,0 +1,72 @@
+/*
+    ui.js
+    
+*/
+
+function cjax(object){
+    let csrf_token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
+    $.ajax({
+        url: object.url,
+        type: object.type || 'POST',
+        data: object.data || {},
+        headers: {
+            'X-CSRF-Token': csrf_token
+        },
+        success: object.success,
+        error: object.error
+    });
+}
+
+// Add cjax as a jQuery method
+$.cjax = cjax;
+
+function switchMassStorageToRemote(){
+    $.cjax({
+        url: '/aux/switchusbremote',
+        type: 'GET',
+        success: function(response) {
+            //alert('Mass Storage switched to Remote successfully.');
+        },
+        error: function(xhr, status, error) {
+            alert('Error switching Mass Storage to Remote: ' + error);
+        }
+    });
+}
+
+function switchMassStorageToKvm(){
+    $.cjax({
+        url: '/aux/switchusbkvm',
+        type: 'GET',
+        success: function(response) {
+            //alert('Mass Storage switched to KVM successfully.');
+        },
+        error: function(xhr, status, error) {
+            alert('Error switching Mass Storage to KVM: ' + error);
+        }
+    });
+}
+
+function toggleFullScreen(){
+    let elem = document.documentElement;
+    if (!document.fullscreenElement) {
+        if (elem.requestFullscreen) {
+            elem.requestFullscreen();
+        } else if (elem.mozRequestFullScreen) { // Firefox
+            elem.mozRequestFullScreen();
+        } else if (elem.webkitRequestFullscreen) { // Chrome, Safari, Opera
+            elem.webkitRequestFullscreen();
+        } else if (elem.msRequestFullscreen) { // IE/Edge
+            elem.msRequestFullscreen();
+        }
+    } else {
+        if (document.exitFullscreen) {
+            document.exitFullscreen();
+        } else if (document.mozCancelFullScreen) {
+            document.mozCancelFullScreen();
+        } else if (document.webkitExitFullscreen) {
+            document.webkitExitFullscreen();
+        } else if (document.msExitFullscreen) {
+            document.msExitFullscreen();
+        }
+    }
+}

BIN
usbkvm-app/img/README/image-20251004211519671.png


+ 31 - 11
usbkvm-app/www/local-kvm.js

@@ -1,8 +1,19 @@
-
 /*
-    USB Serial
+    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.
+*/
 
-    The following section handles USB serial communication using the Web Serial API.
+/*
+    USB Serial Communication
 */
 let serialPort = null;
 let serialReader = null;
@@ -104,18 +115,17 @@ document.getElementById('selectSerialPort').addEventListener('click', function()
 /*
     CH9329 HID bytecode converter
 */
-function calcChecksum(arr) {
-    // Simple checksum: sum of bytes, lowest 8 bits
-    return arr.reduce((sum, b) => (sum + b) & 0xFF, 0);
-}
-
 function resizeTouchscreenToVideo() {
     const video = document.getElementById('video');
     const touchscreen = document.getElementById('touchscreen');
     if (video && touchscreen) {
         const rect = video.getBoundingClientRect();
-        // Assume video stream is always 16:9 (1920x1080)
-        const aspectRatio = 16 / 9;
+        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;
@@ -701,6 +711,15 @@ async function startStream() {
     }
 }
 
+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', () => {
@@ -746,8 +765,9 @@ ensureCameraPermission().then(() => {
     getCameras().then(startStream);
 });
 
+
 navigator.mediaDevices.addEventListener('devicechange', () => {
-    getCameras().then(startStream);
+   getCameras().then(startStream);
 });
 
 

Some files were not shown because too many files changed in this diff