/** * USBIP command wrappers using Bun.$ */ import { $ } from 'bun'; import { logger } from '../utils/logger.js'; /** * Local USB device information with path */ export interface LocalDevice { busId: string; // e.g., "1-1.2" path: string; // USB by-path vendorId: string; productId: string; serial?: string; description: string; deviceClass?: string; // USB device class (e.g., "09" for hubs) } /** * Remote USB device information */ export interface RemoteDevice extends LocalDevice { status: 'available' | 'busy'; } /** * Bound USB device information (from usbip list -l) */ export interface BoundDevice { busId: string; // e.g., "1-1.3" vendorId: string; // e.g., "17ef" productId: string; // e.g., "482f" description: string; // e.g., "Lenovo : unknown product" } /** * Load usbip kernel modules */ export async function loadModules(): Promise { try { await $`modprobe vhci-hcd`.quiet(); await $`modprobe usbip-core`.quiet(); await $`modprobe usbip-host`.quiet(); logger.debug('USBIP kernel modules loaded'); } catch (error) { logger.warn('Failed to load USBIP modules (may already be loaded or need sudo)'); } } /** * USB path components */ export interface UsbPathComponents { pci: string; // PCI address, e.g., "0000:00:14.0" ports: string[]; // Port chain, e.g., ["0", "1", "1.0"] } /** * Parse USB by-path into components * Example: "pci-0000:00:14.0-usb-0:1:1.0" -> { pci: "0000:00:14.0", ports: ["0", "1", "1.0"] } */ export function parseUsbPath(path: string): UsbPathComponents | null { // Remove wildcard suffix if present const cleanPath = path.replace(/:\*\*$/, ''); // Match pattern: platform--pci--usb- // Or legacy format: pci--usb- const match = cleanPath.match(/^(?:platform-[^-]+-)?pci-([^-]+)-usb-(.+)$/); if (!match || !match[1] || !match[2]) { return null; } const pci = match[1]; const portChain = match[2]; // Split port chain by colons AND dots // USB by-path format: "0:1.3.4" means bus 0, port 1, subport 3, subport 4 // Split by both : and . to get individual port components const ports = portChain.split(/[:.]/); return { pci, ports }; } /** * Check if a path is a tree pattern (ends with :** or .**) */ export function isTreePattern(path: string): boolean { const result = path.endsWith(':**') || path.endsWith('.**'); logger.info(`[isTreePattern] path='${path}' ends with wildcard? ${result}`); return result; } /** * Get the prefix for tree matching (removes :** or .** suffix) */ export function getTreePrefix(path: string): string { return path.replace(/[:.]\*\*$/, ''); } /** * Check if a device path is a child of a parent path * Example: isChildOfPath("pci-0000:00:14.0-usb-0:1:1.0", "pci-0000:00:14.0-usb-0:1") -> true */ export function isChildOfPath(devicePath: string, parentPath: string): boolean { const deviceComponents = parseUsbPath(devicePath); const parentComponents = parseUsbPath(parentPath); if (!deviceComponents || !parentComponents) { logger.debug( `[isChildOfPath] Failed to parse paths: device=${!!deviceComponents}, parent=${!!parentComponents}` ); return false; } logger.debug( `[isChildOfPath] Device ports: [${deviceComponents.ports.join(',')}], Parent ports: [${parentComponents.ports.join(',')}]` ); // PCI addresses must match if (deviceComponents.pci !== parentComponents.pci) { logger.debug( `[isChildOfPath] PCI mismatch: ${deviceComponents.pci} !== ${parentComponents.pci}` ); return false; } // Device must have at least as many ports as parent if (deviceComponents.ports.length < parentComponents.ports.length) { logger.debug( `[isChildOfPath] Port length mismatch: ${deviceComponents.ports.length} < ${parentComponents.ports.length}` ); return false; } // All parent ports must match device ports for (let i = 0; i < parentComponents.ports.length; i++) { if (deviceComponents.ports[i] !== parentComponents.ports[i]) { logger.debug( `[isChildOfPath] Port mismatch at index ${i}: '${deviceComponents.ports[i]}' !== '${parentComponents.ports[i]}'` ); return false; } } logger.debug(`[isChildOfPath] MATCH! ${devicePath} is child of ${parentPath}`); return true; } /** * Check if a device path matches a configuration path (exact or tree pattern) */ export function matchesPathPattern(devicePath: string, configPath: string): boolean { if (isTreePattern(configPath)) { // Tree pattern: check if device is child of prefix const prefix = getTreePrefix(configPath); logger.info( `[matchesPathPattern] Tree pattern detected. Prefix: '${prefix}', Device: '${devicePath}'` ); const exactMatch = devicePath === prefix; const childMatch = isChildOfPath(devicePath, prefix); logger.info(`[matchesPathPattern] exactMatch=${exactMatch}, childMatch=${childMatch}`); return exactMatch || childMatch; } // Exact match return devicePath === configPath; } /** * Get USB device path from bus ID using udevadm */ export async function getPathFromBusId(busId: string): Promise { try { // Convert bus ID (1-1.2) to device path const [bus, dev] = busId.split('-'); if (!bus || !dev) return null; const devicePath = `/dev/bus/usb/${bus.padStart(3, '0')}/${dev.padStart(3, '0')}`; // Get ID_PATH from udevadm const result = await $`udevadm info --query=property --name=${devicePath}`.text(); const match = result.match(/ID_PATH=(.+)/); return match?.[1] || null; } catch (error) { logger.debug(`Failed to get path for bus ID ${busId}: ${error}`); return null; } } /** * Get bus ID from USB device path */ export async function getBusIdFromPath(path: string): Promise { try { // Find device by ID_PATH const result = await $`find /dev/bus/usb -type c`.text(); const devices = result.split('\n').filter((line) => line.trim()); for (const devicePath of devices) { if (!devicePath) continue; const info = await $`udevadm info --query=property --name=${devicePath}`.text(); const idPath = info.match(/ID_PATH=(.+)/)?.[1]; const devpath = info.match(/DEVPATH=(.+)/)?.[1]; if (idPath === path && devpath) { // Extract usbip busid from DEVPATH // DEVPATH format: .../usb1/1-1/1-1.3 or .../usb1/1-1 // The busid is the last path component after usb\d+/ const match = devpath.match(/\/usb\d+\/([\d\-./]+)$/); if (match && match[1]) { return match[1].split('/').pop() || null; } } } return null; } catch (error) { logger.debug(`Failed to get bus ID for path ${path}: ${error}`); return null; } } /** * List local USB devices with paths */ export async function listLocalDevices(): Promise { try { const result = await $`lsusb`.text(); const devices: LocalDevice[] = []; for (const line of result.split('\n')) { // Parse lsusb output: Bus 001 Device 002: ID 1234:5678 Device Description const match = line.match(/Bus (\d+) Device (\d+): ID ([0-9a-f]{4}):([0-9a-f]{4}) (.+)/i); if (match && match[1] && match[2] && match[3] && match[4] && match[5]) { const devicePath = `/dev/bus/usb/${match[1].padStart(3, '0')}/${match[2].padStart(3, '0')}`; // Get device info from udevadm let path: string | undefined; let serial: string | undefined; let busId: string | undefined; let deviceClass: string | undefined; try { const info = await $`udevadm info --query=property --name=${devicePath}`.text(); path = info.match(/ID_PATH=(.+)/)?.[1]; serial = info.match(/ID_SERIAL_SHORT=(.+)/)?.[1]; const devpath = info.match(/DEVPATH=(.+)/)?.[1]; // Extract usbip busid from DEVPATH // DEVPATH format: .../usb1/1-1/1-1.3 or .../usb1/1-1 // The busid is everything after the last /usb\d+/ directory if (devpath) { const busIdMatch = devpath.match(/\/usb\d+\/([\d\-./]+)$/); if (busIdMatch && busIdMatch[1]) { // Take the full path after usb\d+/ and extract the last component busId = busIdMatch[1].split('/').pop(); } } // Get device class from sysfs to detect hubs (class 09) if (busId) { try { const classResult = await $`cat /sys/bus/usb/devices/${busId}/bDeviceClass` .nothrow() .quiet(); if (classResult.exitCode === 0) { deviceClass = classResult.stdout.toString().trim(); } } catch { // Device class not available } } } catch { // Device info not available } // Skip if we couldn't get essential info if (!path || !busId) { continue; } devices.push({ busId, path, vendorId: match[3], productId: match[4], serial, description: match[5].trim(), deviceClass, }); } } return devices; } catch (error) { logger.error(`Failed to list local devices: ${error}`); return []; } } /** * List remote devices on USBIP server */ export async function listRemoteDevices(host: string): Promise { try { const result = await $`usbip list -r ${host}`.text(); const devices: RemoteDevice[] = []; // Parse usbip list output const lines = result.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) continue; // Look for device lines like: "1-1: Vendor : Product (1234:5678)" const match = line.match(/^([\d-]+\.?[\d]*): (.+) \(([0-9a-f]{4}):([0-9a-f]{4})\)/i); if (match && match[1] && match[2] && match[3] && match[4]) { const busId = match[1]; const path = (await getPathFromBusId(busId)) || `unknown-${busId}`; devices.push({ busId, path, description: match[2].trim(), vendorId: match[3], productId: match[4], status: 'available', }); } } return devices; } catch (error) { logger.error(`Failed to list remote devices from ${host}: ${error}`); return []; } } /** * Check if a device is bound to usbip-host driver */ export async function isDeviceBound(busId: string): Promise { try { const result = await $`readlink /sys/bus/usb/devices/${busId}/driver`.nothrow().quiet(); if (result.exitCode !== 0) { return false; } const driver = result.stdout.toString().trim(); return driver.includes('usbip-host'); } catch (error) { return false; } } /** * List locally bound devices (devices bound to usbip-host driver) * Note: This checks the actual driver binding in sysfs, not just bindable devices */ export async function listBoundDevices(): Promise { try { // First get all bindable devices const result = await $`usbip list -l`.nothrow().quiet(); if (result.exitCode !== 0) { return []; } const devices: BoundDevice[] = []; const lines = result.stdout.toString().split('\n'); for (const line of lines) { // Parse lines like: " - busid 1-1.3 (17ef:482f)" const match = line.match(/^\s*-\s*busid\s+([\d\-.]+)\s+\(([0-9a-f]{4}):([0-9a-f]{4})\)/i); if (match && match[1] && match[2] && match[3]) { const busId = match[1]; // Check if this device is actually bound to usbip-host driver if (await isDeviceBound(busId)) { devices.push({ busId, vendorId: match[2], productId: match[3], description: '', // Description is on the next line, but we don't need it for binding check }); } } } return devices; } catch (error) { logger.error(`Failed to list bound devices: ${error}`); return []; } } /** * Bind local device for sharing */ export async function bindDevice(busId: string): Promise { try { // First check if already bound by checking the driver in sysfs if (await isDeviceBound(busId)) { logger.info(`Device ${busId} is already bound to usbip-host`); return true; } // Try to bind the device const result = await $`usbip bind -b ${busId}`.nothrow(); if (result.exitCode === 0) { logger.info(`Bound device ${busId} to usbip-host`); return true; } // Check if error is "already bound" which means it's already in the correct state const errorOutput = result.stderr.toString(); if (errorOutput.includes('is already bound to usbip-host')) { logger.info(`Device ${busId} is already bound to usbip-host`); return true; } logger.error(`Failed to bind device ${busId}: ${errorOutput}`); return false; } catch (error) { logger.error(`Failed to bind device ${busId}: ${error}`); return false; } } /** * Unbind local device */ export async function unbindDevice(busId: string): Promise { try { await $`usbip unbind -b ${busId}`.quiet(); logger.info(`Unbound device ${busId}`); return true; } catch (error) { logger.error(`Failed to unbind device ${busId}: ${error}`); return false; } } /** * Attach remote device */ export async function attachDevice(host: string, busId: string): Promise { try { const result = await $`usbip attach -r ${host} -b ${busId}`.nothrow(); if (result.exitCode === 0) { logger.info(`Attached device ${busId} from ${host}`); return true; } const errorOutput = result.stderr.toString().trim(); logger.error(`Failed to attach device ${busId} from ${host}: ${errorOutput}`); return false; } catch (error) { logger.error(`Failed to attach device ${busId} from ${host}: ${error}`); return false; } } /** * Detach device by port */ export async function detachDevice(port: string): Promise { try { await $`usbip detach -p ${port}`.quiet(); logger.info(`Detached device on port ${port}`); return true; } catch (error) { logger.error(`Failed to detach device on port ${port}: ${error}`); return false; } } /** * List attached devices */ export async function listAttachedDevices(): Promise> { try { const result = await $`usbip port`.nothrow().quiet(); const devices: Array<{ port: string; busId: string }> = []; // usbip port returns exit code 0 even when showing errors on stderr // Parse stdout regardless of exit code const output = result.stdout.toString(); const lines = output.split('\n'); let currentPort: string | null = null; for (const line of lines) { // Parse: "Port 00: at High Speed(480Mbps)" const portMatch = line.match(/Port (\d+):/); if (portMatch?.[1]) { currentPort = portMatch[1]; continue; } // Parse busId from line like: "9-1 -> usbip://192.168.0.128:3240/1-1.3.3" const busIdMatch = line.match(/-> usbip:\/\/[^/]+\/([^\s]+)/); if (busIdMatch?.[1] && currentPort) { devices.push({ port: currentPort, busId: busIdMatch[1], }); currentPort = null; // Reset for next device } } return devices; } catch (error) { logger.error(`Failed to list attached devices: ${error}`); return []; } } /** * Check if usbip is installed */ export async function checkUsbipInstalled(): Promise { try { await $`which usbip`.quiet(); return true; } catch { return false; } } /** * Start usbipd daemon * This daemon exposes bound USB devices to remote clients */ export async function startUsbipd(): Promise { try { // Check if usbipd is already running const checkResult = await $`pgrep -x usbipd`.nothrow().quiet(); if (checkResult.exitCode === 0) { logger.debug('usbipd is already running'); return true; } // Start usbipd in daemon mode (IPv4 only for compatibility) const result = await $`usbipd -4 -D`.nothrow(); if (result.exitCode === 0) { logger.info('Started usbipd daemon'); return true; } const errorOutput = result.stderr.toString().trim(); logger.error(`Failed to start usbipd: ${errorOutput}`); return false; } catch (error) { logger.error(`Failed to start usbipd: ${error}`); return false; } } /** * Stop usbipd daemon */ export async function stopUsbipd(): Promise { try { // Find usbipd process const pidResult = await $`pgrep -x usbipd`.nothrow().quiet(); if (pidResult.exitCode !== 0) { logger.debug('usbipd is not running'); return true; } const pid = pidResult.stdout.toString().trim(); if (!pid) { return true; } // Kill the process await $`kill ${pid}`.nothrow(); logger.info('Stopped usbipd daemon'); return true; } catch (error) { logger.error(`Failed to stop usbipd: ${error}`); return false; } }