/** * Server-side device management */ import type { ServerDeviceConfig } from '../config/schema.js'; import type { DeviceInfo } from '../types/common.js'; import { DeviceState } from '../types/common.js'; import { UdevMonitor } from '../udev/monitor.js'; import { bindDevice, loadModules, matchesPathPattern, unbindDevice } from '../usbip/commands.js'; import { findLocalDevice, findMatchingDevices } from '../usbip/device.js'; import { logger } from '../utils/logger.js'; export type DeviceStateChangeHandler = ( device: DeviceInfo, oldState: DeviceState ) => void | Promise; /** * Server device manager */ export class DeviceManager { // Track devices by their actual path private devices: Map = new Map(); // Track which config entries match which device paths (for tree patterns) private configToDevices: Map> = new Map(); private stateChangeHandlers: DeviceStateChangeHandler[] = []; private monitor: UdevMonitor; private healthCheckInterval: Timer | null = null; constructor(private config: ServerDeviceConfig[]) { this.monitor = new UdevMonitor(config); // Initialize config tracking for (const deviceConfig of config) { this.configToDevices.set(deviceConfig.path, new Set()); } } /** * Start device management */ async start(): Promise { logger.info('Starting device manager...'); // Load kernel modules await loadModules(); // Initial device scan await this.scanDevices(); // Start udev monitoring this.monitor.onEvent(async (event) => { logger.info(`Device ${event.type}: ${event.device.vendorId}:${event.device.productId}`); if (event.type === 'add') { await this.handleDeviceAdded(event.device); } else { await this.handleDeviceRemoved(event.device); } }); this.monitor.start(); // Start periodic health check this.startHealthCheck(); logger.info('Device manager started'); } /** * Stop device management */ async stop(): Promise { logger.info('Stopping device manager...'); this.monitor.stop(); if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } // Unbind all devices for (const device of this.devices.values()) { if (device.state === 'bound') { await unbindDevice(device.busId); } } logger.info('Device manager stopped'); } /** * Get all managed devices */ getDevices(): DeviceInfo[] { return Array.from(this.devices.values()); } /** * Get device by path */ getDevice(path: string): DeviceInfo | undefined { return this.devices.get(path); } /** * Check if client is allowed to access device */ isClientAllowed(path: string, clientId: string): boolean { // Find config that matches this device path const deviceConfig = this.config.find((d) => matchesPathPattern(path, d.path)); if (!deviceConfig) return false; // If no allowed clients specified, allow all if (!deviceConfig.allowedClients || deviceConfig.allowedClients.length === 0) { return true; } return deviceConfig.allowedClients.includes(clientId); } /** * Get devices accessible by client */ getDevicesForClient(clientId: string): DeviceInfo[] { return this.getDevices().filter((device) => this.isClientAllowed(device.path, clientId)); } /** * Register state change handler */ onStateChange(handler: DeviceStateChangeHandler): void { this.stateChangeHandlers.push(handler); } /** * Scan for configured devices */ private async scanDevices(): Promise { logger.info('Scanning for configured devices...'); for (const deviceConfig of this.config) { // Find all devices matching this config (supports wildcards) const matchedDevices = await findMatchingDevices(deviceConfig); // Track which devices are matched by this config const configDeviceSet = this.configToDevices.get(deviceConfig.path) || new Set(); const currentMatches = new Set(); for (const device of matchedDevices) { currentMatches.add(device.path); logger.info(`Found device: ${device.path} at ${device.busId}`); // Get existing device to preserve state const existingDevice = this.devices.get(device.path); if (existingDevice) { // Check if this is actually a different device at the same path const deviceChanged = existingDevice.vendorId !== device.vendorId || existingDevice.productId !== device.productId || existingDevice.serial !== device.serial; if (deviceChanged) { logger.info( `Device replacement detected at ${device.path}: ${existingDevice.vendorId}:${existingDevice.productId} -> ${device.vendorId}:${device.productId}` ); // Treat as new device - unbind old, bind new if (existingDevice.state === DeviceState.BOUND) { await unbindDevice(existingDevice.busId); const oldState = existingDevice.state; existingDevice.state = DeviceState.DISCONNECTED; this.emitStateChange(existingDevice, oldState); } // Replace with new device this.devices.set(device.path, device); await this.bindDeviceIfNeeded(device); } else { // Same device, just update info and ensure it's bound existingDevice.busId = device.busId; this.devices.set(device.path, existingDevice); await this.bindDeviceIfNeeded(existingDevice); } } else { // New device, add it and bind this.devices.set(device.path, device); await this.bindDeviceIfNeeded(device); } } // Update config tracking this.configToDevices.set(deviceConfig.path, currentMatches); } } /** * Handle device added event */ private async handleDeviceAdded(deviceId: { path: string }): Promise { // Check if this device matches any of our configured patterns const matchingConfig = this.config.find((config) => matchesPathPattern(deviceId.path, config.path) ); if (!matchingConfig) { // Device doesn't match any configured patterns return; } const device = await findLocalDevice(deviceId); if (device) { const oldDevice = this.devices.get(device.path); const oldState = oldDevice?.state || DeviceState.DISCONNECTED; this.devices.set(device.path, device); await this.bindDeviceIfNeeded(device); // Update config tracking const configDevices = this.configToDevices.get(matchingConfig.path) || new Set(); configDevices.add(device.path); this.configToDevices.set(matchingConfig.path, configDevices); // Don't emit state change here - bindDeviceIfNeeded will emit if needed // Only emit if device state didn't actually change (shouldn't happen) if (oldDevice && oldState === device.state) { // State didn't change, no emission needed } } } /** * Handle device removed event */ private async handleDeviceRemoved(deviceId: { path: string }): Promise { const device = this.devices.get(deviceId.path); if (device) { const oldState = device.state; // Only emit state change if device was not already disconnected if (oldState !== DeviceState.DISCONNECTED) { logger.info(`Device physically removed: ${deviceId.path}`); device.state = DeviceState.DISCONNECTED; this.devices.set(deviceId.path, device); this.emitStateChange(device, oldState); } } } /** * Bind device and emit state change if needed */ private async bindDeviceIfNeeded(device: DeviceInfo): Promise { const oldState = device.state; const success = await bindDevice(device.busId); if (success) { // Device is bound (either just bound or was already bound) if (device.state !== DeviceState.BOUND) { // State changed from not-bound to bound device.state = DeviceState.BOUND; this.devices.set(device.path, device); this.emitStateChange(device, oldState); } } } /** * Start periodic health check */ private startHealthCheck(): void { this.healthCheckInterval = setInterval(async () => { logger.debug('Running device health check...'); await this.scanDevices(); }, 30000); // Every 30 seconds } /** * Emit state change event */ private emitStateChange(device: DeviceInfo, oldState: DeviceState): void { for (const handler of this.stateChangeHandlers) { try { handler(device, oldState); } catch (error) { logger.error(`Error in state change handler: ${error}`); } } } }