/** * udev event monitoring via netlink socket */ import { spawn } from 'node:child_process'; import type { DeviceConfig } from '../config/schema.js'; import type { DeviceId } from '../types/common.js'; import { findMatchingDevices } from '../usbip/device.js'; import { logger } from '../utils/logger.js'; export type DeviceEventType = 'add' | 'remove'; export interface DeviceEvent { type: DeviceEventType; device: DeviceId; } export type DeviceEventHandler = (event: DeviceEvent) => void | Promise; /** * udev event monitor using `udevadm monitor` for real-time events * Falls back to polling if udevadm is not available */ export class UdevMonitor { private handlers: DeviceEventHandler[] = []; private running = false; private pollInterval: Timer | null = null; private lastSeenDevices: Set = new Set(); private udevProcess: any = null; private usePolling = false; constructor(private deviceConfigs: DeviceConfig[]) {} /** * Start monitoring */ async start(): Promise { if (this.running) return; this.running = true; // Try to use udevadm monitor for real-time events try { await this.startUdevMonitor(); logger.info('Started udev monitoring (real-time events via udevadm)'); } catch (error) { logger.warn(`Failed to start udevadm monitor: ${error}, falling back to polling`); this.usePolling = true; this.startPolling(); logger.info('Started udev monitoring (polling fallback every 5 seconds)'); } } /** * Start real udevadm monitor process */ private async startUdevMonitor(): Promise { return new Promise((resolve, reject) => { // Use udevadm monitor to watch for USB device events // --subsystem-match=usb filters to only USB events this.udevProcess = spawn('udevadm', ['monitor', '--subsystem-match=usb', '--property'], { stdio: ['ignore', 'pipe', 'pipe'], }); let started = false; this.udevProcess.stdout.on('data', (data: Buffer) => { if (!started) { started = true; resolve(); } this.handleUdevOutput(data.toString()); }); this.udevProcess.stderr.on('data', (data: Buffer) => { logger.debug(`udevadm stderr: ${data.toString().trim()}`); }); this.udevProcess.on('error', (error: Error) => { if (!started) { reject(error); } else { logger.error(`udevadm process error: ${error.message}`); // Fall back to polling this.usePolling = true; this.startPolling(); } }); this.udevProcess.on('exit', (code: number) => { if (!started) { reject(new Error(`udevadm exited with code ${code}`)); } else { logger.warn(`udevadm process exited with code ${code}`); if (this.running) { // Fall back to polling this.usePolling = true; this.startPolling(); } } }); // Give it a moment to start setTimeout(() => { if (!started) { reject(new Error('udevadm did not start in time')); } }, 1000); }); } /** * Start polling fallback */ private startPolling(): void { if (this.pollInterval) return; this.pollInterval = setInterval(() => { this.checkDevices(); }, 5000); // Check every 5 seconds } /** * Stop monitoring */ stop(): void { if (!this.running) return; this.running = false; if (this.udevProcess) { this.udevProcess.kill(); this.udevProcess = null; } if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } logger.info('Stopped udev monitoring'); } /** * Handle udevadm monitor output */ private handleUdevOutput(output: string): void { const lines = output.split('\n'); let eventType: 'add' | 'remove' | null = null; let devPath: string | null = null; for (const line of lines) { // Parse event type: "KERNEL[12345.678] add /devices/..." const eventMatch = line.match(/^(KERNEL|UDEV)\[\d+\.\d+\]\s+(add|remove)\s+(.+)/); if (eventMatch) { const action = eventMatch[2]; const path = eventMatch[3]; if (action === 'add' || action === 'remove') { eventType = action; devPath = path || null; } } // Parse DEVPATH property for more reliable path const devPathMatch = line.match(/^DEVPATH=(.+)/); if (devPathMatch) { devPath = devPathMatch[1] || null; } } // Process the event if we have both type and path if (eventType && devPath) { // Trigger a device scan to find matches // We can't directly map devpath to our by-path format, so we scan this.checkDevicesForEvent(eventType); } } /** * Check devices in response to a udev event * This is more efficient than full polling as it's triggered by actual events */ private async checkDevicesForEvent(eventType: 'add' | 'remove'): Promise { logger.debug(`Processing udev ${eventType} event...`); await this.checkDevices(); } /** * Register event handler */ onEvent(handler: DeviceEventHandler): void { this.handlers.push(handler); } /** * Check for device changes (polling fallback) * Supports wildcard patterns for hubs */ private async checkDevices(): Promise { logger.debug('Polling for device changes...'); const currentDevices = new Set(); // Check each configured device pattern (supports wildcards) for (const deviceConfig of this.deviceConfigs) { const matchedDevices = await findMatchingDevices(deviceConfig); for (const device of matchedDevices) { currentDevices.add(device.path); } } // Detect additions for (const path of currentDevices) { if (!this.lastSeenDevices.has(path)) { logger.info(`Device added: ${path}`); this.emitEvent({ type: 'add', device: { path } }); } } // Detect removals for (const path of this.lastSeenDevices) { if (!currentDevices.has(path)) { logger.info(`Device removed: ${path}`); this.emitEvent({ type: 'remove', device: { path } }); } } this.lastSeenDevices = currentDevices; } /** * Emit device event */ private emitEvent(event: DeviceEvent): void { for (const handler of this.handlers) { try { handler(event); } catch (error) { logger.error(`Error in device event handler: ${error}`); } } } }