import { okAsync, ResultAsync } from "@shared/utils/resultasync.ts"; import { err, getMessageFromError, ok } from "@shared/utils/result.ts"; import { errAsync } from "@shared/utils/index.ts"; import log from "@shared/utils/logger.ts"; import { fromNullableVal, none, type Option, some, } from "@shared/utils/option.ts"; class CommandExecutionError extends Error { code = "CommandExecutionError"; constructor(msg: string) { super(msg); } } class DeviceDoesNotExistError extends Error { code = "DeviceDoesNotExist"; constructor(msg: string) { super(msg); } } class DeviceAlreadyBoundError extends Error { code = "DeviceAlreadyBound"; constructor(msg: string) { super(msg); } } class DeviceNotBound extends Error { code = "DeviceNotBound"; constructor(msg: string) { super(msg); } } class UsbipUknownError extends Error { code = "UsbipUknownError"; constructor(msg: string) { super(msg); } } type UsbipCommonError = DeviceDoesNotExistError | UsbipUknownError; class UsbipManager { private readonly listDeatiledCmd = new Deno.Command("usbip", { args: ["list", "-l"], }); private readonly listParsableCmd = new Deno.Command("usbip", { args: ["list", "-pl"], }); private readonly decoder = new TextDecoder(); private readonly usbidRegex = /[0-9abcdef]{4}:[0-9abcdef]{4}/; private readonly busidRegex = /(?:[0-9]+(?:\.[0-9]+)*-)*[0-9]+(?:\.[0-9]+)*/; private executeCommand( cmd: Deno.Command, ): ResultAsync { const promise = cmd.output(); return ResultAsync.fromPromise( promise, (e) => new CommandExecutionError(getMessageFromError(e)), ) .map(({ stdout, stderr, code }) => new CommandOutput( this.decoder.decode(stdout).trim(), this.decoder.decode(stderr).trim(), code, ) ); } private handleCommonErrors(stderr: string): UsbipCommonError { if ( stderr.includes("device with the specified bus ID does not exist") ) { return new DeviceDoesNotExistError(stderr); } return new UsbipUknownError(stderr); } private parseDetailedList(stdout: string): Option { const devices: DeviceDetailed[] = []; const deviceEntries = stdout.trim().split("\n\n"); for (const deviceEntry of deviceEntries) { const busid = deviceEntry.match(this.busidRegex)?.shift(); if (!busid) { log.error( `Failed to parse busid of a device:\n ${deviceEntry}`, ); continue; } const usbid = fromNullableVal( deviceEntry.match(this.usbidRegex)?.shift(), ); const [_, line2] = deviceEntry.split("\n"); const [vendorVal, nameVal] = line2 ? line2.split(" : ").map((s) => s.trim()) : [undefined, undefined]; const vendor = fromNullableVal(vendorVal); const name = nameVal ? some( nameVal.replace( usbid.isSome() ? usbid.value : this.usbidRegex, "", ).replace("()", "") .trim(), ) : none; [["usbid", usbid], ["vendor", vendor], ["name", name]].filter((v) => (v[1] as Option).isNone() ).map((v) => log.warn(`Failed to parse ${v[0]}:\n ${deviceEntry}`)); devices.push({ busid, usbid, vendor, name, }); } return devices.length > 0 ? some(devices) : none; } public getDevicesDetailed(): ResultAsync< Option, CommandExecutionError | UsbipUknownError > { return this.executeCommand(this.listDeatiledCmd).andThen( ({ stdout, stderr, success }) => { if (success) { if (stderr) { log.warn( `usbip list -l succeeded but encountered an error: ${stderr}`, ); } return ok(this.parseDetailedList(stdout)); } return err(new UsbipUknownError(stderr)); }, ); } private parseParsableList(stdout: string): Option { const devices: Device[] = []; const devicesEntries = stdout.trim().split("\n"); for (const deviceEntry of devicesEntries) { const [busid, usbid] = deviceEntry .slice(0, -1) .split("#") .map((v) => v.split("=")[1].trim() || undefined); if (!busid) { log.error( `Failed to parse busid of a device:\n ${deviceEntry}`, ); continue; } if (!usbid) { log.warn( `Failed to parse usbid of a device:\n ${deviceEntry}`, ); } devices.push({ busid, usbid: fromNullableVal(usbid), }); } return devices.length > 0 ? some(devices) : none; } public getDevices(): ResultAsync< Option, CommandExecutionError | UsbipUknownError > { return this.executeCommand(this.listParsableCmd).andThenAsync( ({ stdout, stderr, success }) => { if (success) { if (stderr) { log.warn( `usbip list -lp succeeded but encountered an error: ${stderr}`, ); } return okAsync(this.parseParsableList(stdout)); } return errAsync(new UsbipUknownError(stderr)); }, ); } public bindDevice( busid: string, ): ResultAsync< string, UsbipCommonError | DeviceAlreadyBoundError | CommandExecutionError > { const cmd = new Deno.Command("usbip", { args: ["bind", "-b", busid] }); return this.executeCommand(cmd).andThen( ({ stderr, success }) => { if (success) { return ok(stderr.trim() || "Device bound successfully"); } if (stderr.includes("is already bound to usbip-host")) { return err(new DeviceAlreadyBoundError(stderr)); } return err(this.handleCommonErrors(stderr)); }, ); } public unbindDevice( busid: string, ): ResultAsync< string, CommandExecutionError | DeviceNotBound | UsbipCommonError > { const cmd = new Deno.Command("usbip", { args: ["unbind", "-b", busid], }); return this.executeCommand(cmd).andThen(({ stderr, success }) => { if (success) { return ok(stderr.trim() || "Device unbound successfully"); } if (stderr.includes("device is not bound to usbip-host driver")) { return err(new DeviceNotBound(stderr)); } return err(this.handleCommonErrors(stderr)); }); } } class CommandOutput { constructor( public readonly stdout: string, public readonly stderr: string, public readonly code: number, ) {} get success(): boolean { return this.code === 0; } } interface DeviceDetailed { busid: string; usbid: Option; vendor: Option; name: Option; } interface Device { busid: string; usbid: Option; } export default UsbipManager;