Keyborg/shared/utils/usbip.ts
2025-01-27 15:53:20 +03:00

284 lines
7.9 KiB
TypeScript

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<CommandOutput, CommandExecutionError> {
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<DeviceDetailed[]> {
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<string>).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<DeviceDetailed[]>,
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<Device[]> {
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<Device[]>,
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<string>;
vendor: Option<string>;
name: Option<string>;
}
interface Device {
busid: string;
usbid: Option<string>;
}
export default UsbipManager;