UNPKG

@hangtime/grip-connect-cli

Version:
611 lines 23.8 kB
/** * Shared CLI utilities: prompts, connect helper, output formatting, * signal handling, and colored terminal output. */ import input from "@inquirer/input"; import process from "node:process"; import readline from "node:readline"; import select from "@inquirer/select"; import ora from "ora"; import pc from "picocolors"; import { createChartRenderer } from "./chart.js"; import { devices } from "./devices/index.js"; import { INFO_METHODS } from "./info-methods.js"; // --------------------------------------------------------------------------- // Output context // --------------------------------------------------------------------------- /** * Resolves the {@link OutputContext} from the root Commander program options. * * Call this inside every command action to pick up `--json` / `--no-color`. * * @param program - The root Commander program instance. * @returns The resolved output context. */ export function resolveContext(program) { const opts = program.opts(); const unit = opts["unit"] === "lbs" ? "lbs" : opts["unit"] === "n" ? "n" : "kg"; return { json: Boolean(opts["json"]), unit }; } // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- /** * Formats a single force measurement as a human-readable string with color. * When distribution (left/center/right) is present, appends e.g. "Left: 0.00 kg Center: 0.00 kg Right: 0.00 kg". * * @param data - The force measurement to format. * @returns A formatted string such as `"12.34 kg Peak: 15.00 kg Mean: 11.20 kg Left: 0.00 kg Center: 0.00 kg Right: 0.00 kg"`. */ export function formatMeasurement(data) { const current = pc.bold(`${data.current.toFixed(2)} ${data.unit}`); const peak = pc.dim(`Peak: ${data.peak.toFixed(2)} ${data.unit}`); const mean = pc.dim(`Mean: ${data.mean.toFixed(2)} ${data.unit}`); const main = `${current} ${peak} ${mean}`; const dist = data.distribution; const hasDistribution = dist && (dist.left !== undefined || dist.center !== undefined || dist.right !== undefined); if (!hasDistribution) return main; const unit = data.unit; const parts = []; if (dist.left !== undefined) { parts.push(pc.dim(`Left: ${dist.left.current.toFixed(2)} ${dist.left.unit ?? unit}`)); } if (dist.center !== undefined) { parts.push(pc.dim(`Center: ${dist.center.current.toFixed(2)} ${dist.center.unit ?? unit}`)); } if (dist.right !== undefined) { parts.push(pc.dim(`Right: ${dist.right.current.toFixed(2)} ${dist.right.unit ?? unit}`)); } return parts.length > 0 ? `${main} ${parts.join(" ")}` : main; } /** * Writes a JSON line to stdout (newline-delimited JSON). * * @param value - Any JSON-serializable value. */ export function outputJson(value) { console.log(JSON.stringify(value)); } /** * Prints a labelled result value to stdout with colored label. * * @param label - The label text (e.g. `"Battery:"`). * @param value - The value to display, or `undefined` for "Not supported". */ export function printResult(label, value) { console.log(` ${pc.cyan(label.padEnd(18))}${value ?? pc.dim("Not supported")}`); } /** * Prints a section header with a horizontal rule. * * @param title - The header text. */ export function printHeader(title) { console.log(`\n${pc.bold(title)}`); console.log(pc.dim("─".repeat(40))); } /** * Prints a success message in green. * * @param message - The message to display. */ export function printSuccess(message) { console.log(pc.green(`\n${message}\n`)); } /** * Prints an error message in red and throws so the top-level handler * in `index.ts` can exit cleanly. * * @param message - The error message. * @throws {Error} Always throws with the given message. */ export function fail(message) { throw new Error(pc.red(message)); } // --------------------------------------------------------------------------- // Prompts // --------------------------------------------------------------------------- /** * Prompts the user to pick a device from the registry. * * @returns The lowercase device key (e.g. `"progressor"`). */ export async function pickDevice() { return select({ message: "Select a device:", choices: Object.entries(devices).map(([key, def]) => { const disabled = key === "wh-c06"; return { name: disabled ? `${def.name}` : def.name, value: key, ...(disabled && { disabled: true }), }; }), }); } /** * Prompts the user to pick an action from a list. * * @param actions - Available actions for the current device. * @returns The selected {@link Action} object. */ export async function pickAction(actions) { return select({ message: "What do you want to do?", choices: actions.map((action) => { const color = action.nameColor ? pc[action.nameColor] : (s) => s; return { name: `${color(action.name)} ${pc.dim("–")} ${pc.dim(action.description)}`, value: action, }; }), }); } // --------------------------------------------------------------------------- // Device helpers // --------------------------------------------------------------------------- /** * Resolves a device key, prompting interactively if not provided. * * @param deviceKey - Optional device key from the command line. * @returns The resolved lowercase device key. */ export async function resolveDeviceKey(deviceKey) { if (deviceKey) return deviceKey.toLowerCase(); return pickDevice(); } /** * Instantiates a {@link CliDevice} from the registry. * * @param deviceKey - The device key to look up. * @returns An object containing the device instance and its display name. * @throws {Error} If the device key is unknown. */ export function createDevice(deviceKey) { const key = deviceKey.toLowerCase(); const def = devices[key]; if (!def) { fail(`Unknown device: ${deviceKey}\nRun 'grip-connect list' to see supported devices.`); } return { device: new def.class(), name: def.name }; } // --------------------------------------------------------------------------- // Signal handling // --------------------------------------------------------------------------- /** * Registers SIGINT and SIGTERM handlers that gracefully disconnect the * device before exiting. * * @param device - The connected device to disconnect on signal. * @param onCleanup - Optional extra work to run before exit (e.g. print summary). */ export function setupSignalHandlers(device, onCleanup) { const handler = () => { onCleanup?.(); try { device.disconnect(); } catch { /* best-effort */ } process.exit(0); }; process.on("SIGINT", handler); process.on("SIGTERM", handler); } // --------------------------------------------------------------------------- // Connect & run // --------------------------------------------------------------------------- /** * Returns true if the device requires an active stream for tare to work. * Stream devices expose both stream and tare; tare collects samples or * captures current weight during streaming. */ export function isStreamDevice(d) { return typeof d.stream === "function" && typeof d.tare === "function"; } /** * Registers the standard force-measurement notify callback on a device. * * In JSON mode it emits newline-delimited JSON; otherwise it prints a * colored line. Call {@link muteNotify} to silence output between actions. * * @param device - The device to register the callback on. * @param ctx - Output context for JSON / color flags. */ export function setupNotify(device, ctx) { device.notify((data) => { if (ctx.json) { outputJson(data); } else { console.log(formatMeasurement(data)); } }, ctx.unit); } /** * Silences the notify callback so no force data is printed. * * Useful between actions in interactive mode or after a stream completes. * * @param device - The device to mute. */ export function muteNotify(device) { device.notify(() => { /* muted */ }); } /** * Returns a promise that resolves when the user presses Escape (stdin TTY only). * Uses readline keypress events to avoid conflicts with chart rendering. * * @param messageOrOptions - Message string, or options with message and extra key handlers. * @returns A promise that resolves when Escape is pressed, or never when not a TTY. */ export function waitForKeyToStop(messageOrOptions) { if (!process.stdin.isTTY) { return new Promise((_resolve) => { void _resolve; /* never resolve when not a TTY */ }); } const message = typeof messageOrOptions === "string" ? messageOrOptions : messageOrOptions?.message; const extraKeys = typeof messageOrOptions === "object" && messageOrOptions?.extraKeys ? messageOrOptions.extraKeys : undefined; return new Promise((resolve) => { let done = false; const cleanup = () => { if (done) return; done = true; process.stdin.removeListener("keypress", onKeypress); process.stdin.setRawMode?.(false); process.stdin.pause(); resolve(); }; const onKeypress = (_str, key) => { if (key.name === "escape") { cleanup(); return; } if (extraKeys && key.sequence) { const code = key.sequence.charCodeAt(0); if (code in extraKeys) { const cb = extraKeys[code]; if (cb) setImmediate(cb); } } }; if (message) { console.log(pc.dim(message)); } readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.on("keypress", onKeypress); }); } /** * Connects to a device with a spinner, runs a callback, then disconnects. * * Sets up formatted stream output (via {@link setupNotify}) as soon as we're * connected so every device shows human-readable force lines (or NDJSON when * `--json`). Actions that stream may call setupNotify again to refresh unit. * * @param device - The device to connect to. * @param name - The human-readable device name (for spinner text). * @param callback - Async work to perform once connected. * @param ctx - Output context for JSON / color flags. */ export async function connectAndRun(device, name, callback, ctx = { json: false, unit: "kg" }) { // Silence the default activeCallback (which logs true/false to stdout) if (typeof device.active === "function") { device.active(() => { /* silenced */ }); } const spinner = ctx.json ? null : ora(`Connecting to ${pc.bold(name)}...`).start(); // The core's connect() fires onSuccess without awaiting it, so we wrap // everything in a Promise that only resolves when the callback finishes. return new Promise((resolve, reject) => { device .connect(async () => { spinner?.succeed(`Connected to ${pc.bold(name)}`); setupSignalHandlers(device); // Override core default (raw console.log) so all devices get formatted stream output setupNotify(device, ctx); try { await callback(device); } finally { device.disconnect(); if (!ctx.json) { console.log(pc.dim("\nDisconnected.")); } } resolve(); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); spinner?.fail(`Connection failed: ${message}`); reject(new Error(pc.red(`Connection to ${name} failed: ${message}`))); }); }); } // --------------------------------------------------------------------------- // Action builder // --------------------------------------------------------------------------- /** * Builds the full list of actions for a device: shared actions first, * then device-specific actions, then disconnect. * * Shared actions are derived dynamically from the device capabilities * (e.g. `stream`, `battery`, `tare`, `download`). * * @param deviceKey - The device registry key. * @param ctx - Optional output context for dynamic labels (e.g. Unit (kg)). * @returns An ordered array of {@link Action} objects. */ export function buildActions(deviceKey, ctx) { const key = deviceKey.toLowerCase(); const def = devices[key]; if (!def) return []; const device = new def.class(); const shared = []; if (typeof device.stream === "function") { shared.push({ name: "Live Data", description: "Just the raw data visualised in real-time", run: async (d, opts) => { const duration = opts.duration; const indefinite = duration == null || duration === 0; const ctx = opts.ctx ?? { json: false, unit: "kg" }; if (typeof d.stream !== "function") return; const chartEnabled = !ctx.json && process.stdout.isTTY; const chart = createChartRenderer({ disabled: !chartEnabled, color: "cyan", }); if (!ctx.json) { console.log(pc.cyan(indefinite ? "\nLive Data...\n" : `\nLive Data for ${(duration ?? 0) / 1000} seconds...\n`)); } d.notify((data) => { if (ctx.json) { outputJson(data); } else if (chartEnabled) { chart.push(data.current); } else { console.log(formatMeasurement(data)); } }, ctx.unit); if (chartEnabled) chart.start(); if (indefinite) { await d.stream(); await waitForKeyToStop(ctx.json ? undefined : "Press Esc to stop"); const stopFn = d.stop; if (typeof stopFn === "function") await stopFn(); } else { await d.stream(duration); await d.stop?.(); } if (chartEnabled) chart.stop(); muteNotify(d); if (typeof d.download === "function" && !opts.ctx?.json) { const raw = await input({ message: "Download session data? [y/N]:", default: "n", }); if (/^y(es)?$/i.test(raw?.trim() ?? "")) { const format = opts.format ?? (await select({ message: "Export format:", choices: [ { name: "CSV", value: "csv" }, { name: "JSON", value: "json" }, { name: "XML", value: "xml" }, ], })); console.log(pc.cyan(`\nExporting ${format}...\n`)); const filePath = await d.download(format); printSuccess(typeof filePath === "string" ? `Data exported to ${filePath}` : `Data exported as ${format.toUpperCase()}.`); } } }, }); } // Build Settings subactions: Unit, Language, System Info, Calibration, Errors const settingsSubactions = []; // Unit – CLI-level preference (always available) const currentUnit = ctx?.unit ?? "kg"; settingsSubactions.push({ name: `Unit (${currentUnit})`, description: "Set stream output to kilogram, pound, or newton", run: async (_d, opts) => { const unit = await select({ message: "Unit:", choices: [ { name: "Kilogram", value: "kg" }, { name: "Pound", value: "lbs" }, { name: "Newton", value: "n" }, ], }); if (opts.ctx) opts.ctx.unit = unit; if (!opts.ctx?.json) console.log(pc.dim(`Force output: ${unit}`)); }, }); // Language – English as the only option for now settingsSubactions.push({ name: "Language (English)", description: "CLI display language", run: async (_d, opts) => { const lang = await select({ message: "Language:", choices: [{ name: "English", value: "en" }], }); if (opts.ctx?.json) { outputJson({ language: lang }); } else { console.log(pc.dim(`Language: ${lang === "en" ? "English" : lang}`)); } }, }); // System Info – battery, firmware, device ID, etc. const hasAnyInfo = INFO_METHODS.some((m) => typeof device[m.key] === "function"); if (hasAnyInfo) { settingsSubactions.push({ name: "System Info", description: "Battery, firmware, device ID, calibration, etc.", run: async (d, opts) => { const dev = d; const info = {}; for (const entry of INFO_METHODS) { const fn = dev[entry.key]; if (typeof fn === "function") { try { info[entry.key] = (await fn()) ?? undefined; } catch { info[entry.key] = undefined; } } } if (opts.ctx?.json) { outputJson(info); } else { printHeader(`${def.name} System Info`); for (const entry of INFO_METHODS) { if (entry.key in info) { printResult(entry.label, info[entry.key]); } } console.log(pc.dim("─".repeat(40))); } }, }); } // Calibration – from device's calibrationSubactions const calibrationSubs = def.calibrationSubactions; if (calibrationSubs?.length) { settingsSubactions.push({ name: "Calibration", description: "Get curve, set curve, or add calibration points", subactions: calibrationSubs, run: async (d, opts) => { const sub = await pickAction(calibrationSubs); await sub.run(d, opts); }, }); } // Errors – from device's errorSubactions const errorSubs = def.errorSubactions; if (errorSubs?.length) { settingsSubactions.push({ name: "Errors", description: "Get or clear error information", subactions: errorSubs, run: async (d, opts) => { const sub = await pickAction(errorSubs); await sub.run(d, opts); }, }); } if (settingsSubactions.length > 0) { shared.push({ name: "Settings", description: "Unit, language, system info, calibration, errors", run: async (d, opts) => { const sub = await pickAction(settingsSubactions); await sub.run(d, opts); }, }); } if (typeof device.tare === "function") { shared.push({ name: "Tare", description: "Zero offset reset (hardware or software)", run: async (d, opts) => { const duration = opts.duration ?? 5000; if (typeof d.tare !== "function") return; if (isStreamDevice(d)) { // Stream devices need an active stream for tare (data to tare against) const streamSpinner = opts.ctx?.json ? null : ora("Starting stream for tare...").start(); const streamFn = d.stream; if (typeof streamFn === "function") await streamFn(0); await new Promise((r) => setTimeout(r, 1500)); // Wait for data to flow streamSpinner?.succeed("Stream running."); const started = d.tare(duration); if (!started) { const stopFn = d.stop; if (typeof stopFn === "function") await stopFn(); fail("Tare could not be started (already active?)."); } const usesHardwareTare = "usesHardwareTare" in d && d.usesHardwareTare; if (usesHardwareTare) { if (!opts.ctx?.json) ora().succeed("Tare complete (hardware)."); } else { const tareSpinner = opts.ctx?.json ? null : ora(`Tare calibration (${duration / 1000} s). Keep device still...`).start(); await new Promise((r) => setTimeout(r, duration)); tareSpinner?.succeed("Tare calibration complete."); } const stopFn = d.stop; if (typeof stopFn === "function") await stopFn(); } else { const started = d.tare(duration); if (!started) fail("Tare could not be started (already active?)."); const usesHardwareTare = "usesHardwareTare" in d && d.usesHardwareTare; if (usesHardwareTare) { if (!opts.ctx?.json) ora().succeed("Tare complete (hardware)."); } else { const spinner = opts.ctx?.json ? null : ora(`Tare calibration (${duration / 1000} s). Keep device still...`).start(); await new Promise((r) => setTimeout(r, duration)); spinner?.succeed("Tare calibration complete."); } } }, }); } // Device-specific actions const specific = def.actions; // Always-available disconnect (returns to device picker in interactive mode) const disconnect = { name: "Disconnect", description: "Disconnect from current device and pick another", run: async (_d, opts) => { if (!opts.ctx?.json) { printSuccess("Disconnected. You can pick another device."); } }, }; return [...shared, ...specific, disconnect]; } //# sourceMappingURL=utils.js.map