diff --git a/.gitignore b/.gitignore index 9579b61..b8c8ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ package-lock.json /.idea .kiro/coverage/ coverage/ +nul diff --git a/CLAUDE.md b/CLAUDE.md index dde76a8..750e952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,8 @@ A TypeScript API library (`@ghosttypes/ff-api`) for controlling FlashForge 3D pr ## Build & Test Commands - **Build:** `pnpm build` (runs `tsc`, outputs to `dist/`) -- **Test all:** `pnpm test` (Jest with ts-jest) -- **Test single file:** `pnpm exec jest path/to/file.test.ts` +- **Test all:** `pnpm test` (Vitest) +- **Test single file:** `pnpm exec vitest run path/to/file.test.ts` - **Test watch:** `pnpm test:watch` - **Test coverage:** `pnpm test:coverage` diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..6f41461 --- /dev/null +++ b/biome.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "recommended": true + }, + "correctness": { + "recommended": true, + "noUnusedVariables": "error", + "noUnusedImports": "error" + }, + "complexity": { + "recommended": true, + "noForEach": "off", + "useLiteralKeys": "off", + "noStaticOnlyClass": "off" + }, + "style": { + "recommended": true, + "noParameterAssign": "off", + "useConst": "error", + "useTemplate": "warn", + "noNonNullAssertion": "warn" + }, + "suspicious": { + "recommended": true, + "noArrayIndexKey": "warn", + "noExplicitAny": "warn", + "noEmptyBlockStatements": "warn", + "noImplicitAnyLet": "warn" + }, + "performance": { + "recommended": true + }, + "security": { + "recommended": true + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false + } + }, + "json": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + } + }, + "overrides": [ + { + "includes": ["*.test.ts", "**/*.test.ts"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] +} diff --git a/docs/specs/printer-discovery.md b/docs/specs/printer-discovery.md new file mode 100644 index 0000000..d181765 --- /dev/null +++ b/docs/specs/printer-discovery.md @@ -0,0 +1,1345 @@ +# Printer Discovery Implementation Specification + +**Status:** Proposed +**Version:** 1.0.0 +**Date:** 2025-02-07 +**Printer Models:** AD5X, 5M, 5M Pro, Adventurer 4, Adventurer 3 +**API Version:** HTTP API (Port 8898) + TCP API (Port 8899) + +## Table of Contents + +1. [Overview](#overview) +2. [Requirements](#requirements) +3. [Protocol Details](#protocol-details) +4. [Type Definitions](#type-definitions) +5. [API Design](#api-design) +6. [Implementation Details](#implementation-details) +7. [Error Handling](#error-handling) +8. [Testing Strategy](#testing-strategy) +9. [Usage Examples](#usage-examples) +10. [Migration Guide](#migration-guide) + +--- + +## Overview + +This specification defines the implementation of a universal printer discovery system for FlashForge printers. The system supports: + +- **All modern FlashForge models** (AD5X, 5M, 5M Pro, Adventurer 4, Adventurer 3) +- **Dual-format response parsing** (modern 276-byte and legacy 140-byte protocols) +- **Multi-port discovery** (multicast ports 8899, 19000 and broadcast port 48899) +- **Automatic model detection** from response metadata +- **Complete metadata extraction** (serial numbers, ports, status codes, etc.) + +### Current State + +**Existing Implementation:** `src/api/PrinterDiscovery.ts` +- ❌ Expects 196-byte responses (incorrect) +- ❌ Only supports broadcast port 48899 +- ❌ Single-format parser (can't handle Adventurer 3/4) +- ❌ Limited metadata extraction (name + serial only) +- ⚠️ Partially works for AD5X/5M by luck + +### Goals + +**After Implementation:** +- ✅ Support all FlashForge printer models +- ✅ Parse both modern (276-byte) and legacy (140-byte) response formats +- ✅ Use multicast and broadcast discovery for maximum compatibility +- ✅ Extract all available metadata from discovery responses +- ✅ Automatic printer model identification +- ✅ Backward compatible with existing API + +--- + +## Requirements + +### Functional Requirements + +#### FR1: Universal Discovery +The API MUST discover all FlashForge printer models on the local network: +- AD5X series +- Adventurer 5M / 5M Pro +- Adventurer 4 series +- Adventurer 3 series +- Finder series (if compatible) + +#### FR2: Multi-Format Response Parsing +The API MUST parse both response formats: +- Modern protocol: 276-byte responses (AD5X/5M) +- Legacy protocol: 140-byte responses (Adventurer 3/4) + +#### FR3: Multi-Port Discovery +The API MUST send discovery requests to multiple ports: +- Multicast port 8899 (Adventurer 3 primary) +- Multicast port 19000 (AD5X/5M/Adventurer 4) +- Broadcast port 48899 (all models fallback) + +#### FR4: Automatic Model Detection +The API MUST automatically identify printer model from response metadata: +- From `productType` field (modern protocol) +- From printer name string (legacy protocol) + +#### FR5: Complete Metadata Extraction +The API MUST extract all available fields: +- Printer name +- IP address +- Serial number (when available) +- Command port (8899) +- Event port / HTTP API port (8898 when available) +- Vendor ID / Product ID +- Status code +- Firmware version (from subsequent HTTP API call) + +### Non-Functional Requirements + +#### NFR1: Backward Compatibility +New implementation MUST maintain existing API surface where possible. + +#### NFR2: Performance +Discovery MUST complete within 10 seconds by default. + +#### NFR3: Reliability +Discovery MUST handle: +- Malformed responses gracefully +- Network errors gracefully +- Multiple printers on same network +- Printers that respond on multiple ports + +#### NFR4: Type Safety +All methods MUST use TypeScript strict types and enums. + +--- + +## Protocol Details + +### Discovery Protocol Overview + +All FlashForge printers use a **proprietary UDP multicast/broadcast protocol** (NOT mDNS/Bonjour/SSDP). + +**Common Characteristics:** +- **Multicast Group:** `225.0.0.9` +- **Discovery Request:** ANY UDP packet (empty packet works) +- **Endian:** Big Endian +- **TTL:** 2 (can cross 1 router hop) + +**Response Format Detection:** +- Modern printers → 276-byte responses +- Legacy printers → 140-byte responses + +### Port Matrix + +| Printer Series | Multicast Port | Broadcast Port | Response Size | +|---------------|-----------------|----------------|---------------| +| **AD5X** | 19000 | 48899 | 276 bytes | +| **5M / 5M Pro** | 19000 | 48899 | 276 bytes | +| **Adventurer 4** | 19000 | 48899 | 140 bytes | +| **Adventurer 3** | 8899 (primary) | 48899 | 140 bytes | + +### Modern Protocol - 276 Byte Response + +**Used by:** AD5X, 5M, 5M Pro + +``` +Offset Size Type Field Description +------- ------- ------- ------- ----------------------------- +0x00 132 char printer_name Null-terminated UTF-8 string +0x84 2 uint16 command_port Command port (8899) +0x86 2 uint16 vendor_id USB Vendor ID (0x2B71) +0x88 2 uint16 product_id USB Product ID (0x0024/0x0026) +0x8A 2 uint16 reserved Always 0 +0x8C 2 uint16 product_type Product identifier (0x5A02) +0x8E 2 uint16 event_port HTTP API port (8898) +0x90 2 uint16 status_code Printer status +0x92 130 char serial_number Null-terminated string +------- +Total: 276 bytes (0x114) +``` + +**Status Codes:** +- `0` = Ready / Idle +- `1` = Busy / Printing +- `2` = Error State +- `3` = Unknown + +**Product Types:** +- `0x5A02` = Adventurer 5M series +- May include other values for future models + +### Legacy Protocol - 140 Byte Response + +**Used by:** Adventurer 3, Adventurer 4 + +``` +Offset Size Type Field Description +------- ------- ------- ------- ----------------------------- +0x00 128 char printer_name Null-terminated UTF-8 string +0x80 4 padding Zero padding +0x84 2 uint16 command_port Command port (8899) +0x86 2 uint16 vendor_id USB Vendor ID +0x88 2 uint16 product_id USB Product ID +0x8A 2 uint16 status_code Printer status +------- +Total: 140 bytes (0x8C) +``` + +**Missing Fields:** Serial number, product type, event port not included. + +### Discovery Request Packets + +Two approaches work: + +**Option 1: Empty Packet (Simplest)** +```typescript +const emptyPacket = Buffer.alloc(0); +socket.send(emptyPacket, 0, 0, port, address); +``` + +**Option 2: Legacy Magic Packet (FlashPrint Compatible)** +```typescript +const magicPacket = Buffer.from([ + 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, // "www.usr" + 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, // Control bytes + 0x00, 0x00, 0x00, 0x00 // Padding +]); +``` + +**Recommendation:** Use empty packet for simplicity, unless compatibility with very old firmware is required. + +--- + +## Type Definitions + +### Printer Model Enum + +```typescript +/** + * FlashForge printer model families + */ +export enum PrinterModel { + /** AD5X multi-material printer */ + AD5X = 'AD5X', + + /** Adventurer 5M */ + Adventurer5M = 'Adventurer5M', + + /** Adventurer 5M Pro */ + Adventurer5MPro = 'Adventurer5MPro', + + /** Adventurer 4 series */ + Adventurer4 = 'Adventurer4', + + /** Adventurer 3 series */ + Adventurer3 = 'Adventurer3', + + /** Unknown model */ + Unknown = 'Unknown' +} +``` + +### Protocol Format Enum + +```typescript +/** + * Discovery response protocol format + */ +export enum DiscoveryProtocol { + /** Modern 276-byte response (AD5X/5M) */ + Modern = 'modern', + + /** Legacy 140-byte response (Adventurer 3/4) */ + Legacy = 'legacy' +} +``` + +### Discovered Printer Interface + +```typescript +/** + * Represents a discovered FlashForge printer + */ +export interface DiscoveredPrinter { + /** Printer model identification */ + model: PrinterModel; + + /** Protocol format used in discovery response */ + protocolFormat: DiscoveryProtocol; + + /** Printer name from configuration */ + name: string; + + /** IP address */ + ipAddress: string; + + /** Command/control port (typically 8899) */ + commandPort: number; + + /** Serial number (modern protocol only) */ + serialNumber?: string; + + /** HTTP API event port (modern protocol only, typically 8898) */ + eventPort?: number; + + /** Vendor ID */ + vendorId?: number; + + /** Product ID */ + productId?: number; + + /** Product type (modern protocol only) */ + productType?: number; + + /** Current printer status */ + statusCode?: number; + + /** Human-readable status description */ + status?: PrinterStatus; +} + +/** + * Printer status from discovery + */ +export enum PrinterStatus { + /** Ready / Idle */ + Ready = 0, + + /** Busy / Printing */ + Busy = 1, + + /** Error State */ + Error = 2, + + /** Unknown */ + Unknown = 3 +} +``` + +### Discovery Options Interface + +```typescript +/** + * Options for printer discovery + */ +export interface DiscoveryOptions { + /** Discovery timeout in milliseconds (default: 10000) */ + timeout?: number; + + /** Idle timeout in milliseconds (default: 1500) */ + idleTimeout?: number; + + /** Maximum number of retries (default: 3) */ + maxRetries?: number; + + /** Whether to use multicast discovery (default: true) */ + useMulticast?: boolean; + + /** Whether to use broadcast discovery (default: true) */ + useBroadcast?: boolean; + + /** Specific ports to scan (default: all known ports) */ + ports?: number[]; +} +``` + +### Parsed Response Interfaces + +```typescript +/** + * Modern protocol parsed response + */ +interface ModernDiscoveryResponse { + printerName: string; + commandPort: number; + vendorId: number; + productId: number; + productType: number; + eventPort: number; + statusCode: number; + serialNumber: string; +} + +/** + * Legacy protocol parsed response + */ +interface LegacyDiscoveryResponse { + printerName: string; + commandPort: number; + vendorId: number; + productId: number; + statusCode: number; +} +``` + +--- + +## API Design + +### Public API - PrinterDiscovery Class + +**File:** `src/api/PrinterDiscovery.ts` (REPLACE EXISTING) + +```typescript +import { EventEmitter } from 'events'; +import dgram from 'dgram'; +import os from 'os'; + +/** + * Discover FlashForge printers on the local network via UDP multicast/broadcast. + * + * Supports all FlashForge models: + * - AD5X series (276-byte responses) + * - Adventurer 5M / 5M Pro (276-byte responses) + * - Adventurer 4 series (140-byte responses) + * - Adventurer 3 series (140-byte responses) + * + * @example + * ```typescript + * const discovery = new PrinterDiscovery(); + * const printers = await discovery.discover(); + * + * printers.forEach(printer => { + * console.log(`Found ${printer.model} at ${printer.ipAddress}`); + * console.log(` Serial: ${printer.serialNumber || 'N/A'}`); + * console.log(` HTTP API: ${printer.eventPort || 8898}`); + * }); + * ``` + */ +export class PrinterDiscovery extends EventEmitter { + /** + * Discover FlashForge printers on the local network. + * + * @param options Discovery options + * @returns Promise Array of discovered printers + * + * @example + * ```typescript + * const discovery = new PrinterDiscovery(); + * + * // Basic discovery (10 second timeout) + * const printers = await discovery.discover(); + * + * // Custom timeout + * const printers = await discovery.discover({ timeout: 5000 }); + * + * // Specific ports only + * const printers = await discovery.discover({ ports: [19000] }); + * ``` + */ + public async discover(options?: DiscoveryOptions): Promise; + + /** + * Start continuous discovery monitoring. + * + * Emits 'discovered' event for each new printer found. + * + * @param options Discovery options + * @returns EventEmitter that emits 'discovered' events + * + * @example + * ```typescript + * const discovery = new PrinterDiscovery(); + * const monitor = discovery.monitor({ timeout: 60000 }); + * + * monitor.on('discovered', (printer) => { + * console.log(`Found: ${printer.name} at ${printer.ipAddress}`); + * }); + * + * monitor.on('complete', (printers) => { + * console.log(`Discovery complete: ${printers.length} printers`); + * }); + * + * monitor.on('error', (error) => { + * console.error('Discovery error:', error); + * }); + * ``` + */ + public monitor(options?: DiscoveryOptions): EventEmitter; + + /** + * Stop active discovery monitoring. + */ + public stop(): void; +} +``` + +### Internal API - Response Parsers + +```typescript +/** + * Parse discovery response based on packet size + * @private + */ +function parseDiscoveryResponse( + buffer: Buffer, + remoteInfo: dgram.RemoteInfo +): DiscoveredPrinter | null { + const size = buffer.length; + + if (size >= 276) { + return parseModernProtocol(buffer, remoteInfo); + } else if (size >= 140) { + return parseLegacyProtocol(buffer, remoteInfo); + } else { + console.warn(`Invalid discovery response size: ${size} from ${remoteInfo.address}`); + return null; + } +} + +/** + * Parse modern 276-byte protocol (AD5X/5M) + * @private + */ +function parseModernProtocol( + buffer: Buffer, + remoteInfo: dgram.RemoteInfo +): DiscoveredPrinter { + // Extract fields (Big Endian) + const printerName = buffer.subarray(0, 132).toString('utf8').split('\0')[0]; + const commandPort = buffer.readUInt16BE(0x84); + const vendorId = buffer.readUInt16BE(0x86); + const productId = buffer.readUInt16BE(0x88); + const productType = buffer.readUInt16BE(0x8C); + const eventPort = buffer.readUInt16BE(0x8E); + const statusCode = buffer.readUInt16BE(0x90); + const serialNumber = buffer.subarray(0x92, 0x92 + 130).toString('utf8').split('\0')[0]; + + // Detect model + const model = detectModernModel(printerName, productType); + + return { + model, + protocolFormat: DiscoveryProtocol.Modern, + name: printerName, + ipAddress: remoteInfo.address, + commandPort, + serialNumber, + eventPort, + vendorId, + productId, + productType, + statusCode, + status: mapStatusCode(statusCode) + }; +} + +/** + * Parse legacy 140-byte protocol (Adventurer 3/4) + * @private + */ +function parseLegacyProtocol( + buffer: Buffer, + remoteInfo: dgram.RemoteInfo +): DiscoveredPrinter { + // Extract fields (Big Endian) + const printerName = buffer.subarray(0, 128).toString('utf8').split('\0')[0]; + const commandPort = buffer.readUInt16BE(0x84); + const vendorId = buffer.readUInt16BE(0x86); + const productId = buffer.readUInt16BE(0x88); + const statusCode = buffer.readUInt16BE(0x8A); + + // Detect model + const model = detectLegacyModel(printerName); + + return { + model, + protocolFormat: DiscoveryProtocol.Legacy, + name: printerName, + ipAddress: remoteInfo.address, + commandPort, + vendorId, + productId, + statusCode, + status: mapStatusCode(statusCode) + }; +} + +/** + * Detect printer model from modern protocol response + * @private + */ +function detectModernModel( + printerName: string, + productType: number +): PrinterModel { + if (printerName === 'AD5X') return PrinterModel.AD5X; + + if (productType === 0x5A02) { + // Adventurer 5M series + if (printerName.includes('Pro')) { + return PrinterModel.Adventurer5MPro; + } + return PrinterModel.Adventurer5M; + } + + return PrinterModel.Unknown; +} + +/** + * Detect printer model from legacy protocol response + * @private + */ +function detectLegacyModel(printerName: string): PrinterModel { + if (printerName.includes('Adventurer 4')) { + return PrinterModel.Adventurer4; + } + if (printerName.includes('Adventurer 3')) { + return PrinterModel.Adventurer3; + } + + return PrinterModel.Unknown; +} + +/** + * Map status code to enum + * @private + */ +function mapStatusCode(code: number): PrinterStatus { + switch (code) { + case 0: return PrinterStatus.Ready; + case 1: return PrinterStatus.Busy; + case 2: return PrinterStatus.Error; + default: return PrinterStatus.Unknown; + } +} +``` + +--- + +## Implementation Details + +### 1. Discovery Class Structure + +**File:** `src/api/PrinterDiscovery.ts` (COMPLETE REWRITE) + +```typescript +import { EventEmitter } from 'events'; +import dgram from 'dgram'; +import os from 'os'; +import { + PrinterModel, + DiscoveryProtocol, + DiscoveredPrinter, + DiscoveryOptions, + PrinterStatus +} from '../models/PrinterDiscovery'; + +/** + * Default discovery options + */ +const DEFAULT_OPTIONS: Required = { + timeout: 10000, + idleTimeout: 1500, + maxRetries: 3, + useMulticast: true, + useBroadcast: true, + ports: [8899, 19000, 48899] +}; + +/** + * Discover FlashForge printers on the local network. + */ +export class PrinterDiscovery extends EventEmitter { + private socket?: dgram.Socket; + private active = false; + + /** + * Discover printers once (one-shot discovery). + */ + public async discover(options?: DiscoveryOptions): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const printers = new Map(); + const lastResponseTimes = new Map(); + + return new Promise((resolve, reject) => { + const socket = dgram.createSocket('udp4'); + this.socket = socket; + + const timeoutTimer = setTimeout(() => { + this.cleanup(); + resolve(Array.from(printers.values())); + }, opts.timeout); + + const idleTimer = setInterval(() => { + const now = Date.now(); + const idleTime = opts.idleTimeout; + + for (const [key, time] of lastResponseTimes) { + if (now - time > idleTime) { + lastResponseTimes.delete(key); + } + } + + if (lastResponseTimes.size === 0) { + clearInterval(idleTimer); + clearTimeout(timeoutTimer); + this.cleanup(); + resolve(Array.from(printers.values())); + } + }, 1000); + + socket.on('message', (msg: Buffer, rinfo: dgram.RemoteInfo) => { + try { + const printer = parseDiscoveryResponse(msg, rinfo); + if (printer) { + const key = `${rinfo.address}:${printer.commandPort}`; + if (!printers.has(key)) { + printers.set(key, printer); + this.emit('discovered', printer); + } + lastResponseTimes.set(key, Date.now()); + } + } catch (error) { + console.error('Error parsing discovery response:', error); + } + }); + + socket.on('error', (err) => { + console.error('Discovery socket error:', err); + this.cleanup(); + reject(err); + }); + + // Start discovery + this.startDiscovery(socket, opts); + }); + } + + /** + * Start continuous monitoring. + */ + public monitor(options?: DiscoveryOptions): EventEmitter { + // Implementation for continuous monitoring + // Returns this emitter for 'discovered' events + return this; + } + + /** + * Stop active discovery. + */ + public stop(): void { + this.active = false; + this.cleanup(); + } + + /** + * Send discovery packets to all configured ports. + * @private + */ + private startDiscovery(socket: dgram.Socket, options: Required): void { + const ports = options.ports || DEFAULT_OPTIONS.ports; + + // Bind to random port + socket.bind(0, () => { + // Send discovery requests + if (options.useMulticast) { + // Multicast discovery + ports.forEach(port => { + if ([8899, 19000].includes(port)) { + this.sendMulticastDiscovery(socket, port); + } + }); + } + + if (options.useBroadcast) { + // Broadcast discovery on port 48899 + if (ports.includes(48899)) { + this.sendBroadcastDiscovery(socket); + } + } + }); + } + + /** + * Send multicast discovery packet. + * @private + */ + private sendMulticastDiscovery(socket: dgram.Socket, port: number): void { + const multicastGroup = '225.0.0.9'; + const emptyPacket = Buffer.alloc(0); + + try { + socket.addMembership(multicastGroup); + socket.send(emptyPacket, 0, 0, port, multicastGroup); + } catch (error) { + console.error(`Multicast discovery failed on port ${port}:`, error); + } + } + + /** + * Send broadcast discovery packet. + * @private + */ + private sendBroadcastDiscovery(socket: dgram.Socket): void { + const broadcastAddress = '255.255.255.255'; + const port = 48899; + const emptyPacket = Buffer.alloc(0); + + // Get all broadcast addresses + const interfaces = os.networkInterfaces(); + const broadcastAddresses = this.getBroadcastAddresses(interfaces); + + broadcastAddresses.forEach(address => { + try { + socket.setBroadcast(true); + socket.send(emptyPacket, 0, 0, port, address); + } catch (error) { + console.error(`Broadcast discovery failed to ${address}:`, error); + } + }); + } + + /** + * Extract broadcast addresses from network interfaces. + * @private + */ + private getBroadcastAddresses(interfaces: os.NetworkInterfaceInfo[]): string[] { + const addresses: string[] = []; + + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + // Skip internal and loopback + if (iface.internal || iface.family !== 'IPv4') continue; + + if (iface.broadcast) { + addresses.push(iface.broadcast); + } + } + } + + return addresses; + } + + /** + * Clean up resources. + * @private + */ + private cleanup(): void { + if (this.socket) { + try { + this.socket.close(); + } catch (e) { + // Socket already closed + } + this.socket = undefined; + } + this.active = false; + } +} +``` + +--- + +## Error Handling + +### Error Types + +```typescript +/** + * Discovery-specific errors + */ +export class DiscoveryError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = 'DiscoveryError'; + } +} + +/** + * Socket creation error + */ +export class SocketCreationError extends DiscoveryError { + constructor(message: string) { + super(message, 'SOCKET_CREATION_FAILED'); + this.name = 'SocketCreationError'; + } +} + +/** + * Discovery timeout error + */ +export class DiscoveryTimeoutError extends DiscoveryError { + constructor(timeout: number) { + super(`Discovery timed out after ${timeout}ms`, 'DISCOVERY_TIMEOUT'); + this.name = 'DiscoveryTimeoutError'; + } +} + +/** + * Invalid response error + */ +export class InvalidResponseError extends DiscoveryError { + constructor(size: number, address: string) { + super(`Invalid response size: ${size} bytes from ${address}`, 'INVALID_RESPONSE'); + this.name = 'InvalidResponseError'; + } +} +``` + +### Error Handling Strategy + +1. **Socket Errors**: Log and emit 'error' event, continue discovery +2. **Malformed Responses**: Log warning, skip response +3. **Timeout**: Return any printers found so far +4. **No Printers Found**: Return empty array (not an error) + +--- + +## Testing Strategy + +### Unit Tests + +**File:** `src/api/__tests__/PrinterDiscovery.test.ts` + +```typescript +import { PrinterDiscovery } from '../PrinterDiscovery'; +import { PrinterModel, DiscoveryProtocol, PrinterStatus } from '../../models/PrinterDiscovery'; + +describe('PrinterDiscovery', () => { + describe('parseModernProtocol', () => { + it('should parse 276-byte modern response', () => { + const buffer = createModernResponseBuffer({ + printerName: 'FlashForge Adventurer 5M Pro', + serialNumber: 'SNAD5M12345678', + statusCode: 0 + }); + + const result = parseModernProtocol(buffer, { address: '192.168.1.100' }); + + expect(result.model).toBe(PrinterModel.Adventurer5MPro); + expect(result.protocolFormat).toBe(DiscoveryProtocol.Modern); + expect(result.name).toBe('FlashForge Adventurer 5M Pro'); + expect(result.serialNumber).toBe('SNAD5M12345678'); + expect(result.commandPort).toBe(8899); + expect(result.eventPort).toBe(8898); + expect(result.statusCode).toBe(0); + expect(result.status).toBe(PrinterStatus.Ready); + }); + + it('should detect AD5X model', () => { + const buffer = createModernResponseBuffer({ + printerName: 'AD5X', + serialNumber: 'SNADVA5X00000', + productType: 0x5A02 + }); + + const result = parseModernProtocol(buffer, { address: '192.168.1.100' }); + + expect(result.model).toBe(PrinterModel.AD5X); + }); + }); + + describe('parseLegacyProtocol', () => { + it('should parse 140-byte legacy response', () => { + const buffer = createLegacyResponseBuffer({ + printerName: 'FlashForge Adventurer 4', + statusCode: 0 + }); + + const result = parseLegacyProtocol(buffer, { address: '192.168.1.100' }); + + expect(result.model).toBe(PrinterModel.Adventurer4); + expect(result.protocolFormat).toBe(DiscoveryProtocol.Legacy); + expect(result.name).toBe('FlashForge Adventurer 4'); + expect(result.commandPort).toBe(8899); + expect(result.serialNumber).toBeUndefined(); + expect(result.statusCode).toBe(0); + }); + + it('should detect Adventurer 3 model', () => { + const buffer = createLegacyResponseBuffer({ + printerName: 'FlashForge Adventurer 3' + }); + + const result = parseLegacyProtocol(buffer, { address: '192.168.1.100' }); + + expect(result.model).toBe(PrinterModel.Adventurer3); + }); + }); + + describe('response format detection', () => { + it('should detect modern protocol from size >= 276', () => { + const modernBuffer = createModernResponseBuffer({ printerName: 'Test' }); + const legacyBuffer = createLegacyResponseBuffer({ printerName: 'Test' }); + + expect(modernBuffer.length).toBeGreaterThanOrEqual(276); + expect(legacyBuffer.length).toBeGreaterThanOrEqual(140); + }); + }); +}); +``` + +### Integration Tests + +**File:** `src/api/__tests__/PrinterDiscovery.integration.test.ts` + +```typescript +describe('PrinterDiscovery Integration Tests', () => { + let discovery: PrinterDiscovery; + + beforeEach(() => { + discovery = new PrinterDiscovery(); + }); + + afterEach(() => { + discovery.stop(); + }); + + it('should discover printers on local network (requires physical printer)', async () => { + // This test requires a real printer on the network + // Skip in CI/CD environments + if (process.env.CI) { + return; + } + + const printers = await discovery.discover({ timeout: 5000 }); + + console.log(`Found ${printers.length} printers`); + + printers.forEach(printer => { + console.log(` - ${printer.model}: ${printer.name} at ${printer.ipAddress}:${printer.commandPort}`); + if (printer.serialNumber) { + console.log(` Serial: ${printer.serialNumber}`); + } + }); + + expect(printers.length).toBeGreaterThan(0); + }, 10000); +}); +``` + +--- + +## Usage Examples + +### Example 1: Basic Discovery + +```typescript +import { PrinterDiscovery } from '@ghosttypes/ff-api'; + +const discovery = new PrinterDiscovery(); + +// Discover all printers (10 second timeout) +const printers = await discovery.discover(); + +console.log(`Found ${printers.length} printers:`); +printers.forEach(printer => { + console.log(`\n${printer.model}`); + console.log(` Name: ${printer.name}`); + console.log(` IP: ${printer.ipAddress}`); + console.log(` Serial: ${printer.serialNumber || 'N/A'}`); + console.log(` HTTP API Port: ${printer.eventPort || 8898}`); + console.log(` Status: ${printer.status}`); +}); +``` + +### Example 2: Model-Specific Discovery + +```typescript +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover(); + +// Filter by model +const ad5xPrinters = printers.filter(p => p.model === PrinterModel.AD5X); +const adventurer5mPrinters = printers.filter(p => + p.model === PrinterModel.Adventurer5M || + p.model === PrinterModel.Adventurer5MPro +); + +console.log(`AD5X printers: ${ad5xPrinters.length}`); +console.log(`Adventurer 5M printers: ${adventurer5mPrinters.length}`); +``` + +### Example 3: Continuous Monitoring + +```typescript +import { PrinterDiscovery } from '@ghosttypes/ff-api'; + +const discovery = new PrinterDiscovery(); +const monitor = discovery.monitor({ timeout: 60000 }); // 1 minute + +monitor.on('discovered', (printer) => { + console.log(`✓ Discovered: ${printer.model} - ${printer.name}`); + console.log(` IP: ${printer.ipAddress}:${printer.commandPort}`); + + if (printer.model === PrinterModel.AD5X && printer.serialNumber) { + console.log(` Serial: ${printer.serialNumber}`); + } +}); + +monitor.on('complete', (printers) => { + console.log(`\nDiscovery complete! Total printers: ${printers.length}`); +}); + +monitor.on('error', (error) => { + console.error('Discovery error:', error); +}); + +// Stop after 1 minute +setTimeout(() => { + discovery.stop(); + console.log('Monitoring stopped'); +}, 60000); +``` + +### Example 4: Custom Timeout + +```typescript +// Quick 3-second scan +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover({ + timeout: 3000, + ports: [19000] // Only scan multicast port 19000 +}); + +console.log(`Quick scan found ${printers.length} printers`); +``` + +### Example 5: Filter by Availability + +```typescript +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover(); + +// Only ready printers +const availablePrinters = printers.filter(p => + p.status === PrinterStatus.Ready +); + +console.log(`Available printers: ${availablePrinters.length}`); +availablePrinters.forEach(printer => { + console.log(` - ${printer.name} at ${printer.ipAddress}`); +}); +``` + +### Example 6: Auto-Connect to FiveMClient + +```typescript +import { PrinterDiscovery } from '@ghosttypes/ff-api'; +import { FiveMClient } from '@ghosttypes/ff-api'; + +async function discoverAndConnect() { + const discovery = new PrinterDiscovery(); + const printers = await discovery.discover(); + + for (const printer of printers) { + // Try to connect to AD5X/5M printers + if (printer.serialNumber && printer.eventPort) { + try { + const client = new FiveMClient( + printer.ipAddress, + printer.serialNumber, + '0000' // Will need to get check code from printer display + ); + + const detail = await client.info.getDetailResponse(); + console.log(`Connected to ${printer.name}`); + console.log(` Firmware: ${detail.machineInfo.firmwareVersion}`); + + return client; + } catch (error) { + console.log(`Failed to connect to ${printer.name}: ${error}`); + // Try next printer + } + } + } + + throw new Error('No compatible printer found'); +} +``` + +--- + +## Migration Guide + +### From Old Implementation + +**Old API:** +```typescript +import { FlashForgePrinterDiscovery, FlashForgePrinter } from '@ghosttypes/ff-api'; + +const discovery = new FlashForgePrinterDiscovery(); +const printers = await discovery.discoverPrintersAsync(); + +printers.forEach((printer: FlashForgePrinter) => { + console.log(printer.name); + console.log(printer.serialNumber); + console.log(printer.ipAddress); + console.log(printer.isAD5X); +}); +``` + +**New API:** +```typescript +import { PrinterDiscovery } from '@ghosttypes/ff-api'; + +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover(); + +printers.forEach(printer => { + console.log(printer.name); + console.log(printer.serialNumber || 'N/A'); + console.log(printer.ipAddress); + console.log(printer.model); // More precise than isAD5X +}); +``` + +### Breaking Changes + +| Old | New | Notes | +|-----|-----|-------| +| `FlashForgePrinterDiscovery` | `PrinterDiscovery` | Class renamed | +| `FlashForgePrinter` | `DiscoveredPrinter` | Interface renamed | +| `isAD5X?: boolean` | `model: PrinterModel` | More precise model detection | +| `discoverPrintersAsync()` | `discover()` | Method simplified | +| N/A | `protocolFormat` | New field | +| N/A | `commandPort` | New field | +| N/A | `eventPort` | New field | +| N/A | `statusCode` | New field | + +### Backward Compatibility Layer + +**File:** `src/api/PrinterDiscovery.legacy.ts` (NEW) + +```typescript +/** + * @deprecated Use PrinterDiscovery instead + */ +export class FlashForgePrinterDiscovery extends PrinterDiscovery { + /** + * @deprecated Use discover() instead + */ + public async discoverPrintersAsync( + timeoutMs = 10000, + idleTimeoutMs = 1500, + maxRetries = 3 + ): Promise { + const printers = await this.discover({ + timeout: timeoutMs, + idleTimeout: idleTimeoutMs, + maxRetries + }); + + // Convert to old format + return printers.map(toLegacyPrinter); + } +} + +/** + * @deprecated Use DiscoveredPrinter instead + */ +export class FlashForgePrinter { + public name: string = ''; + public serialNumber: string = ''; + public ipAddress: string = ''; + public isAD5X?: boolean; +} + +function toLegacyPrinter(printer: DiscoveredPrinter): FlashForgePrinter { + const legacy = new FlashForgePrinter(); + legacy.name = printer.name; + legacy.serialNumber = printer.serialNumber || ''; + legacy.ipAddress = printer.ipAddress; + legacy.isAD5X = printer.model === PrinterModel.AD5X; + return legacy; +} +``` + +--- + +## Appendix + +### A. Discovery Flow Diagram + +``` +Client Printer(s) + | ^ + |-------- UDP empty packet ---->| (multicast 225.0.0.9:19000) + | | (or broadcast 255.255.255.255:48899) + | | + | | Process received packet + | | Prepare response + | | + |<------- 276-byte or 140-byte response + | + | Parse response + | Detect format by size + | Extract metadata + | Identify model + v +Return DiscoveredPrinter[] +``` + +### B. Response Format Decision Tree + +``` +Receive UDP Packet + | + v + Check packet size + | + v + size >= 276? ────Yes──> Parse Modern Protocol + | (AD5X/5M/5M Pro) + No + | + v + size >= 140? ────Yes──> Parse Legacy Protocol + | (Adventurer 3/4) + No + | + v + Invalid response (log warning, discard) +``` + +### C. Port Priority + +**Recommended port order for discovery:** +1. **19000** (multicast) - Modern printers (AD5X/5M/Adv4) +2. **8899** (multicast) - Legacy Adventurer 3 +3. **48899** (broadcast) - All models fallback + +**Default configuration scans all three ports.** + +### D. Network Configuration + +**Multicast Setup:** +```typescript +socket.addMembership('225.0.0.9'); +socket.send(Buffer.alloc(0), 0, 0, 19000, '225.0.0.9'); +``` + +**Broadcast Setup:** +```typescript +socket.setBroadcast(true); +socket.send(Buffer.alloc(0), 0, 0, 48899, '255.255.255.255'); +``` + +### E. Product Type Values + +| Product Type | Model | +|--------------|-------| +| `0x5A02` | Adventurer 5M / 5M Pro | +| Unknown | AD5X | + +### F. Vendor ID / Product ID Values + +| VID | PID | Description | +|-----|-----|-------------| +| `0x2B71` | `0x0024` | Adventurer 5M | +| `0x2B71` | `0x0026` | Adventurer 5M Pro (sometimes) | + +### G. Related Documentation + +- **HTTP API:** `docs/http-api.md` +- **Legacy TCP API:** `docs/legacy-api.md` +- **AD5X API:** `docs/ad5x/ad5x-api.md` + +### H. Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0.0 | 2025-02-07 | Claude Code | Initial specification | + +--- + +**End of Specification** diff --git a/jest.setup.js b/jest.setup.js deleted file mode 100644 index fd1ae72..0000000 --- a/jest.setup.js +++ /dev/null @@ -1,8 +0,0 @@ -// jest.setup.js -// Suppress console logs during tests -global.console = { - ...console, - log: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; diff --git a/package.json b/package.json index c638439..f00695c 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "scripts": { "build": "tsc", "prepare": "pnpm build", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "docs:check": "go run scripts/check-fileoverview.go" + "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage", + "docs:check": "go run scripts/check-fileoverview.go", + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check --write .", + "ci": "biome ci ." }, "keywords": [ "flashforge", @@ -23,13 +27,13 @@ "author": "GhostTypes", "license": "ISC", "devDependencies": { + "@biomejs/biome": "2.3.14", "@types/axios": "^0.9.36", - "@types/jest": "^29.5.11", "@types/node": "^22.14.0", - "jest": "^29.7.0", - "ts-jest": "^29.1.1", + "@vitest/coverage-v8": "^4.0.18", "tsx": "^4.20.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.0.18" }, "dependencies": { "axios": "^1.8.4", @@ -41,33 +45,5 @@ "repository": { "type": "git", "url": "git+https://github.com/GhostTypes/ff-5mp-api-ts.git" - }, - "jest": { - "preset": "ts-jest", - "testEnvironment": "node", - "testMatch": [ - "**/*.test.ts" - ], - "transform": { - "^.+\\.ts$": [ - "ts-jest", - { - "diagnostics": false - } - ] - }, - "collectCoverageFrom": [ - "src/**/*.ts", - "!src/**/*.test.ts", - "!src/firmware-test.ts" - ], - "coverageThreshold": { - "global": { - "branches": 40, - "functions": 20, - "lines": 30, - "statements": 30 - } - } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb82965..89e11a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,68 +15,30 @@ importers: specifier: ^4.0.0 version: 4.0.5 devDependencies: + '@biomejs/biome': + specifier: 2.3.14 + version: 2.3.14 '@types/axios': specifier: ^0.9.36 version: 0.9.36 - '@types/jest': - specifier: ^29.5.11 - version: 29.5.14 '@types/node': specifier: ^22.14.0 version: 22.19.9 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.9) - ts-jest: - specifier: ^29.1.1 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.9))(typescript@5.9.3) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0)) tsx: specifier: ^4.20.3 version: 4.21.0 typescript: specifier: ^5.8.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.9)(tsx@4.21.0) packages: - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -85,124 +47,71 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-import-meta@7.10.4': - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4': - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-numeric-separator@7.10.4': - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} - '@babel/plugin-syntax-optional-catch-binding@7.8.3': - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/biome@2.3.14': + resolution: {integrity: sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA==} + engines: {node: '>=14.21.3'} + hasBin: true - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/cli-darwin-arm64@2.3.14': + resolution: {integrity: sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/cli-darwin-x64@2.3.14': + resolution: {integrity: sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/cli-linux-arm64-musl@2.3.14': + resolution: {integrity: sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@biomejs/cli-linux-arm64@2.3.14': + resolution: {integrity: sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} + '@biomejs/cli-linux-x64-musl@2.3.14': + resolution: {integrity: sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} + '@biomejs/cli-linux-x64@2.3.14': + resolution: {integrity: sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} + '@biomejs/cli-win32-arm64@2.3.14': + resolution: {integrity: sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@biomejs/cli-win32-x64@2.3.14': + resolution: {integrity: sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} @@ -360,169 +269,203 @@ packages: cpu: [x64] os: [win32] - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} - - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jest/core@29.7.0': - resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] - '@jest/fake-timers@29.7.0': - resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] - '@types/axios@0.9.36': - resolution: {integrity: sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==} + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@types/axios@0.9.36': + resolution: {integrity: sha512-NLOpedx9o+rxo/X5ChbdiX6mS1atE4WHmEEIcR9NLenRVa5HoVjAvjafwU3FPTqnZEstpoqCaW7fagqSoTDNeg==} - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -530,181 +473,26 @@ packages: axios@1.13.4: resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} - babel-jest@29.7.0: - resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.8.0 - - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} - engines: {node: '>=8'} - - babel-plugin-jest-hoist@29.6.3: - resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 - - babel-preset-jest@29.6.3: - resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@babel/core': ^7.0.0 - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} - - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.1: - resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -713,6 +501,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -726,44 +517,21 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -778,9 +546,6 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -789,49 +554,21 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -851,267 +588,35 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-circus@29.7.0: - resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-cli@29.7.0: - resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - jest-config@29.7.0: - resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-each@29.7.0: - resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true - - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1120,139 +625,37 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} - - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true semver@7.7.4: @@ -1260,383 +663,181 @@ packages: engines: {node: '>=10'} hasBin: true - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map-support@0.5.13: - resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} - engines: {node: '>=8'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} - engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/transform': ^29.0.0 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} hasBin: true - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' - - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} - - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - write-file-atomic@4.0.2: - resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} - engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true snapshots: - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + '@babel/types@7.29.0': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@bcoe/v8-coverage@1.0.2': {} - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@biomejs/biome@2.3.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.14 + '@biomejs/cli-darwin-x64': 2.3.14 + '@biomejs/cli-linux-arm64': 2.3.14 + '@biomejs/cli-linux-arm64-musl': 2.3.14 + '@biomejs/cli-linux-x64': 2.3.14 + '@biomejs/cli-linux-x64-musl': 2.3.14 + '@biomejs/cli-win32-arm64': 2.3.14 + '@biomejs/cli-win32-x64': 2.3.14 + + '@biomejs/cli-darwin-arm64@2.3.14': + optional: true - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@biomejs/cli-darwin-x64@2.3.14': + optional: true - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@biomejs/cli-linux-arm64-musl@2.3.14': + optional: true - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@biomejs/cli-linux-arm64@2.3.14': + optional: true - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@biomejs/cli-linux-x64-musl@2.3.14': + optional: true - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + '@biomejs/cli-linux-x64@2.3.14': + optional: true - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/cli-win32-arm64@2.3.14': + optional: true - '@bcoe/v8-coverage@0.2.3': {} + '@biomejs/cli-win32-x64@2.3.14': + optional: true '@esbuild/aix-ppc64@0.27.3': optional: true @@ -1716,188 +917,6 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@istanbuljs/load-nyc-config@1.1.0': - dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} - - '@jest/console@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - - '@jest/core@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.9) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - - '@jest/environment@29.7.0': - dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - jest-mock: 29.7.0 - - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - - '@jest/expect@29.7.0': - dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/fake-timers@29.7.0': - dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.19.9 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - '@jest/globals@29.7.0': - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color - - '@jest/reporters@29.7.0': - dependencies: - '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 22.19.9 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 - istanbul-reports: 3.2.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - jest-worker: 29.7.0 - slash: 3.0.0 - string-length: 4.0.2 - strip-ansi: 6.0.1 - v8-to-istanbul: 9.3.0 - transitivePeerDependencies: - - supports-color - - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.10 - - '@jest/source-map@29.6.3': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 - - '@jest/test-result@29.7.0': - dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 - - '@jest/test-sequencer@29.7.0': - dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 - - '@jest/transform@29.7.0': - dependencies: - '@babel/core': 7.29.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 - convert-source-map: 2.0.0 - fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - micromatch: 4.0.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color - - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.9 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1907,290 +926,194 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@sinclair/typebox@0.27.10': {} - - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 - - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@types/axios@0.9.36': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 + '@rollup/rollup-android-arm64@4.57.1': + optional: true - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + '@rollup/rollup-darwin-x64@4.57.1': + optional: true - '@types/graceful-fs@4.1.9': - dependencies: - '@types/node': 22.19.9 + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true - '@types/istanbul-lib-coverage@2.0.6': {} + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true - '@types/jest@29.5.14': - dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true - '@types/node@22.19.9': - dependencies: - undici-types: 6.21.0 + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true - '@types/stack-utils@2.0.3': {} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true - '@types/yargs-parser@21.0.3': {} + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true - ansi-regex@5.0.1: {} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true - ansi-styles@5.2.0: {} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true - asynckit@0.4.0: {} + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true - axios@1.13.4: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true - babel-jest@29.7.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true - babel-plugin-istanbul@6.1.1: - dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@istanbuljs/load-nyc-config': 1.1.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 - test-exclude: 6.0.0 - transitivePeerDependencies: - - supports-color + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true - babel-plugin-jest-hoist@29.6.3: - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@29.6.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true - balanced-match@1.0.2: {} + '@standard-schema/spec@1.1.0': {} - baseline-browser-mapping@2.9.19: {} + '@types/axios@0.9.36': {} - brace-expansion@1.1.12: + '@types/chai@5.2.3': dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 + '@types/deep-eql@4.0.2': {} - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + '@types/estree@1.0.8': {} - bs-logger@0.2.6: + '@types/node@22.19.9': dependencies: - fast-json-stable-stringify: 2.1.0 + undici-types: 6.21.0 - bser@2.1.1: + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0))': dependencies: - node-int64: 0.4.0 - - buffer-from@1.1.2: {} + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.9)(tsx@4.21.0) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0) - call-bind-apply-helpers@1.0.2: + '@vitest/pretty-format@4.0.18': dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - callsites@3.1.0: {} - - camelcase@5.3.1: {} - - camelcase@6.3.0: {} + tinyrainbow: 3.0.3 - caniuse-lite@1.0.30001769: {} - - chalk@4.1.2: + '@vitest/runner@4.0.18': dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - char-regex@1.0.2: {} - - ci-info@3.9.0: {} + '@vitest/utils': 4.0.18 + pathe: 2.0.3 - cjs-module-lexer@1.4.3: {} - - cliui@8.0.1: + '@vitest/snapshot@4.0.18': dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - co@4.6.0: {} + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 - collect-v8-coverage@1.0.3: {} + '@vitest/spy@4.0.18': {} - color-convert@2.0.1: + '@vitest/utils@4.0.18': dependencies: - color-name: 1.1.4 + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 - color-name@1.1.4: {} + assertion-error@2.0.1: {} - combined-stream@1.0.8: + ast-v8-to-istanbul@0.3.11: dependencies: - delayed-stream: 1.0.0 - - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 - create-jest@29.7.0(@types/node@22.19.9): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.9) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node + asynckit@0.4.0: {} - cross-spawn@7.0.6: + axios@1.13.4: dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug - debug@4.4.3: + call-bind-apply-helpers@1.0.2: dependencies: - ms: 2.1.3 + es-errors: 1.3.0 + function-bind: 1.1.2 - dedent@1.7.1: {} + chai@6.2.2: {} - deepmerge@4.3.1: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 delayed-stream@1.0.0: {} - detect-newline@3.1.0: {} - - diff-sequences@29.6.3: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.286: {} - - emittery@0.13.1: {} - - emoji-regex@8.0.0: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2231,48 +1154,15 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 - escalade@3.2.0: {} - - escape-string-regexp@2.0.0: {} - - esprima@4.0.1: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit@0.1.2: {} - - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - - fast-json-stable-stringify@2.1.0: {} - - fb-watchman@2.0.2: + estree-walker@3.0.3: dependencies: - bser: 2.1.1 + '@types/estree': 1.0.8 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 + expect-type@1.3.0: {} - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 follow-redirects@1.15.11: {} @@ -2284,17 +1174,11 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2308,41 +1192,17 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} - get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - gopd@1.2.0: {} - graceful-fs@4.2.11: {} - - handlebars@4.7.8: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2357,638 +1217,118 @@ snapshots: html-escaper@2.0.2: {} - human-signals@2.1.0: {} - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-arrayish@0.2.1: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-fn@2.1.0: {} - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@4.0.1: - dependencies: - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jest-changed-files@29.7.0: - dependencies: - execa: 5.1.1 - jest-util: 29.7.0 - p-limit: 3.1.0 - - jest-circus@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.1 - is-generator-fn: 2.1.0 - jest-each: 29.7.0 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - p-limit: 3.1.0 - pretty-format: 29.7.0 - pure-rand: 6.1.0 - slash: 3.0.0 - stack-utils: 2.0.6 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-cli@29.7.0(@types/node@22.19.9): - dependencies: - '@jest/core': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.9) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.19.9) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-config@29.7.0(@types/node@22.19.9): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.19.9 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-docblock@29.7.0: - dependencies: - detect-newline: 3.1.0 - - jest-each@29.7.0: - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - jest-get-type: 29.6.3 - jest-util: 29.7.0 - pretty-format: 29.7.0 - - jest-environment-node@29.7.0: - dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - jest-mock: 29.7.0 - jest-util: 29.7.0 - - jest-get-type@29.6.3: {} - - jest-haste-map@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 22.19.9 - anymatch: 3.1.3 - fb-watchman: 2.0.2 - graceful-fs: 4.2.11 - jest-regex-util: 29.6.3 - jest-util: 29.7.0 - jest-worker: 29.7.0 - micromatch: 4.0.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 - - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - jest-util: 29.7.0 - - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 - - jest-regex-util@29.6.3: {} - - jest-resolve-dependencies@29.7.0: - dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color - - jest-resolve@29.7.0: - dependencies: - chalk: 4.1.2 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - resolve: 1.22.11 - resolve.exports: 2.0.3 - slash: 3.0.0 - - jest-runner@29.7.0: - dependencies: - '@jest/console': 29.7.0 - '@jest/environment': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - chalk: 4.1.2 - emittery: 0.13.1 - graceful-fs: 4.2.11 - jest-docblock: 29.7.0 - jest-environment-node: 29.7.0 - jest-haste-map: 29.7.0 - jest-leak-detector: 29.7.0 - jest-message-util: 29.7.0 - jest-resolve: 29.7.0 - jest-runtime: 29.7.0 - jest-util: 29.7.0 - jest-watcher: 29.7.0 - jest-worker: 29.7.0 - p-limit: 3.1.0 - source-map-support: 0.5.13 - transitivePeerDependencies: - - supports-color + js-tokens@10.0.0: {} - jest-runtime@29.7.0: + magic-string@0.30.21: dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 - '@jest/source-map': 29.6.3 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 - strip-bom: 4.0.0 - transitivePeerDependencies: - - supports-color + '@jridgewell/sourcemap-codec': 1.5.5 - jest-snapshot@29.7.0: + magicast@0.5.2: dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - chalk: 4.1.2 - expect: 29.7.0 - graceful-fs: 4.2.11 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - natural-compare: 1.4.0 - pretty-format: 29.7.0 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.1 - - jest-validate@29.7.0: - dependencies: - '@jest/types': 29.6.3 - camelcase: 6.3.0 - chalk: 4.1.2 - jest-get-type: 29.6.3 - leven: 3.1.0 - pretty-format: 29.7.0 - - jest-watcher@29.7.0: - dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.9 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 - - jest-worker@29.7.0: - dependencies: - '@types/node': 22.19.9 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jest@29.7.0(@types/node@22.19.9): - dependencies: - '@jest/core': 29.7.0 - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.19.9) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - js-tokens@4.0.0: {} - - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - jsesc@3.1.0: {} - - json-parse-even-better-errors@2.3.1: {} - - json5@2.2.3: {} - - kleur@3.0.3: {} - - leven@3.1.0: {} - - lines-and-columns@1.2.4: {} - - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - - lodash.memoize@4.1.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 + source-map-js: 1.2.1 make-dir@4.0.0: dependencies: semver: 7.7.4 - make-error@1.3.6: {} - - makeerror@1.0.12: - dependencies: - tmpl: 1.0.5 - math-intrinsics@1.1.0: {} - merge-stream@2.0.0: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - mime-db@1.52.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mimic-fn@2.1.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - natural-compare@1.4.0: {} - - neo-async@2.6.2: {} - - node-int64@0.4.0: {} - - node-releases@2.0.27: {} - - normalize-path@3.0.0: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} + nanoid@3.3.11: {} - path-key@3.1.1: {} + obug@2.1.1: {} - path-parse@1.0.7: {} + pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@4.0.3: {} - pirates@4.0.7: {} - - pkg-dir@4.2.0: - dependencies: - find-up: 4.1.0 - - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 - - prompts@2.4.2: + postcss@8.5.6: dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 proxy-from-env@1.1.0: {} - pure-rand@6.1.0: {} - - react-is@18.3.1: {} - - require-directory@2.1.1: {} - - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 - - resolve-from@5.0.0: {} - resolve-pkg-maps@1.0.0: {} - resolve.exports@2.0.3: {} - - resolve@1.22.11: + rollup@4.57.1: dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - semver@6.3.1: {} + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 semver@7.7.4: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} - - sisteransi@1.0.5: {} - - slash@3.0.0: {} - - source-map-support@0.5.13: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} + siginfo@2.0.0: {} - sprintf-js@1.0.3: {} + source-map-js@1.2.1: {} - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@4.0.0: {} + stackback@0.0.2: {} - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} + std-env@3.10.0: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} + tinybench@2.9.0: {} - test-exclude@6.0.0: - dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.2 - - tmpl@1.0.5: {} + tinyexec@1.0.2: {} - to-regex-range@5.0.1: + tinyglobby@0.2.15: dependencies: - is-number: 7.0.0 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.9))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@22.19.9) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 29.7.0 + tinyrainbow@3.0.3: {} tsx@4.21.0: dependencies: @@ -2997,68 +1337,61 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - type-detect@4.0.8: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} - typescript@5.9.3: {} - uglify-js@3.19.3: - optional: true - undici-types@6.21.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - - walker@1.0.8: - dependencies: - makeerror: 1.0.12 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wordwrap@1.0.0: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - write-file-atomic@4.0.2: - dependencies: - imurmurhash: 0.1.4 - signal-exit: 3.0.7 - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: + vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0): dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.9 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.9)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.9)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/src/FiveMClient.ts b/src/FiveMClient.ts index e58c009..684f957 100644 --- a/src/FiveMClient.ts +++ b/src/FiveMClient.ts @@ -3,17 +3,16 @@ */ // src/FiveMClient.ts import axios from 'axios'; -import { FFPrinterDetail, FFMachineInfo, MachineState, Temperature } from './models/ff-models'; -import { Control } from './api/controls/Control'; -import { JobControl } from './api/controls/JobControl'; -import { Info } from './api/controls/Info'; +import { Control, type GenericResponse } from './api/controls/Control'; import { Files } from './api/controls/Files'; +import { Info } from './api/controls/Info'; +import { JobControl } from './api/controls/JobControl'; import { TempControl } from './api/controls/TempControl'; -import { FlashForgeClient } from './tcpapi/FlashForgeClient'; -import { Endpoints } from './api/server/Endpoints'; -import {MachineInfo} from "./models/MachineInfo"; -import { GenericResponse } from './api/controls/Control'; import { NetworkUtils } from './api/network/NetworkUtils'; +import { Endpoints } from './api/server/Endpoints'; +import type { FFMachineInfo } from './models/ff-models'; +import { MachineInfo } from './models/MachineInfo'; +import { FlashForgeClient } from './tcpapi/FlashForgeClient'; /** * Represents a client for interacting with a FlashForge 3D printer. @@ -21,258 +20,258 @@ import { NetworkUtils } from './api/network/NetworkUtils'; * retrieving information, and handling file operations. */ export class FiveMClient { - /** Port used for HTTP communication with the printer. */ - private readonly PORT = 8898; - - /** Instance for general printer control operations. */ - public control: Control; - /** Instance for managing print jobs. */ - public jobControl: JobControl; - /** Instance for retrieving printer information. */ - public info: Info; - /** Instance for managing files on the printer. */ - public files: Files; - /** Instance for controlling printer temperatures. */ - public tempControl: TempControl; - /** Instance for lower-level TCP communication with the printer. */ - public tcpClient: FlashForgeClient; - - public serialNumber: string; - public checkCode: string; - /** HTTP client for making requests to the printer's API. */ - public httpClient: ReturnType; - - /** Flag indicating if the HTTP client is currently busy with a request. */ - private httpClientBusy = false; - - public printerName: string = ''; - public isPro: boolean = false; - public isAD5X: boolean = false; - public firmwareVersion: string = ''; - public firmVer: string = ''; - - public ipAddress: string; - public macAddress: string = ''; - - public flashCloudCode: string = ''; - public polarCloudCode: string = ''; - - public lifetimePrintTime: string = ''; - public lifetimeFilamentMeters: string = ''; - - // Control states - /** State of the LED light control. */ - public ledControl: boolean = false; - /** State of the filtration system control. */ - public filtrationControl: boolean = false; - /** Raw product info containing all control states */ - public productInfo: Product | null = null; - - /** - * Creates an instance of FiveMClient. - * @param ipAddress The IP address of the printer. - * @param serialNumber The serial number of the printer. - * @param checkCode The check code for the printer. - */ - constructor(ipAddress: string, serialNumber: string, checkCode: string) { - this.ipAddress = ipAddress; - this.serialNumber = serialNumber; - this.checkCode = checkCode; - - this.httpClient = axios.create({ - timeout: 5000, - headers: { - 'Accept': '*/*' - } - }); - - // FlashForgeClient is used internally for some "lower-level" stuff like sending direct g/m-code - // That isn't available over the new API - this.tcpClient = new FlashForgeClient(ipAddress); - - this.control = new Control(this); - this.jobControl = new JobControl(this); - this.info = new Info(this); - this.files = new Files(this); - this.tempControl = new TempControl(this); + /** Port used for HTTP communication with the printer. */ + private readonly PORT = 8898; + + /** Instance for general printer control operations. */ + public control: Control; + /** Instance for managing print jobs. */ + public jobControl: JobControl; + /** Instance for retrieving printer information. */ + public info: Info; + /** Instance for managing files on the printer. */ + public files: Files; + /** Instance for controlling printer temperatures. */ + public tempControl: TempControl; + /** Instance for lower-level TCP communication with the printer. */ + public tcpClient: FlashForgeClient; + + public serialNumber: string; + public checkCode: string; + /** HTTP client for making requests to the printer's API. */ + public httpClient: ReturnType; + + /** Flag indicating if the HTTP client is currently busy with a request. */ + private httpClientBusy = false; + + public printerName: string = ''; + public isPro: boolean = false; + public isAD5X: boolean = false; + public firmwareVersion: string = ''; + public firmVer: string = ''; + + public ipAddress: string; + public macAddress: string = ''; + + public flashCloudCode: string = ''; + public polarCloudCode: string = ''; + + public lifetimePrintTime: string = ''; + public lifetimeFilamentMeters: string = ''; + + // Control states + /** State of the LED light control. */ + public ledControl: boolean = false; + /** State of the filtration system control. */ + public filtrationControl: boolean = false; + /** Raw product info containing all control states */ + public productInfo: Product | null = null; + + /** + * Creates an instance of FiveMClient. + * @param ipAddress The IP address of the printer. + * @param serialNumber The serial number of the printer. + * @param checkCode The check code for the printer. + */ + constructor(ipAddress: string, serialNumber: string, checkCode: string) { + this.ipAddress = ipAddress; + this.serialNumber = serialNumber; + this.checkCode = checkCode; + + this.httpClient = axios.create({ + timeout: 5000, + headers: { + Accept: '*/*', + }, + }); + + // FlashForgeClient is used internally for some "lower-level" stuff like sending direct g/m-code + // That isn't available over the new API + this.tcpClient = new FlashForgeClient(ipAddress); + + this.control = new Control(this); + this.jobControl = new JobControl(this); + this.info = new Info(this); + this.files = new Files(this); + this.tempControl = new TempControl(this); + } + + /** + * Initializes the FiveMClient and verifies the connection to the printer. + * @returns A Promise that resolves to true if initialization is successful, false otherwise. + */ + public async initialize(): Promise { + const connected = await this.verifyConnection(); + if (connected) { + //console.log("Connected to printer successfully"); + return true; } - - /** - * Initializes the FiveMClient and verifies the connection to the printer. - * @returns A Promise that resolves to true if initialization is successful, false otherwise. - */ - public async initialize(): Promise { - const connected = await this.verifyConnection(); - if (connected) { - //console.log("Connected to printer successfully"); - return true; - } - console.log("Failed to connect to printer"); - return false; - } - - /** - * Checks if the HTTP client is currently busy. - * @returns A Promise that resolves to true if the HTTP client is busy, false otherwise. - */ - public async isHttpClientBusy(): Promise { - return this.httpClientBusy; - } - - /** - * Releases the HTTP client, allowing it to be used for new requests. - */ - public releaseHttpClient(): void { - this.httpClientBusy = false; + console.log('Failed to connect to printer'); + return false; + } + + /** + * Checks if the HTTP client is currently busy. + * @returns A Promise that resolves to true if the HTTP client is busy, false otherwise. + */ + public async isHttpClientBusy(): Promise { + return this.httpClientBusy; + } + + /** + * Releases the HTTP client, allowing it to be used for new requests. + */ + public releaseHttpClient(): void { + this.httpClientBusy = false; + } + + /** + * Initializes the control interface with the printer. + * This involves sending a product command and initializing TCP control. + * @returns A Promise that resolves to true if control initialization is successful, false otherwise. + */ + public async initControl(): Promise { + //console.log("InitControl()"); + if (await this.sendProductCommand()) { + return await this.tcpClient.initControl(); } - - /** - * Initializes the control interface with the printer. - * This involves sending a product command and initializing TCP control. - * @returns A Promise that resolves to true if control initialization is successful, false otherwise. - */ - public async initControl(): Promise { - //console.log("InitControl()"); - if (await this.sendProductCommand()) { - return await this.tcpClient.initControl(); - } - console.log("New API control failed!"); + console.log('New API control failed!'); + return false; + } + + /** + * Disposes of the FiveMClient instance, stopping keep-alive messages and cleaning up resources. + */ + public async dispose(): Promise { + await this.tcpClient.dispose(); + } + + /** + * Caches machine details from the provided FFMachineInfo object. + * @param info The FFMachineInfo object containing printer details. + * @returns True if caching is successful, false otherwise. + */ + public cacheDetails(info: FFMachineInfo | null): boolean { + if (!info) return false; + + // console.log(JSON.stringify(info, null, 2)); // Useful for debugging + this.printerName = info.Name || ''; + this.isPro = info.IsPro; // Use the value from MachineInfo + this.isAD5X = info.IsAD5X; // Cache the AD5X status + this.firmwareVersion = info.FirmwareVersion || ''; + this.firmVer = info.FirmwareVersion ? info.FirmwareVersion.split('-')[0] : ''; + this.macAddress = info.MacAddress || ''; + this.flashCloudCode = info.FlashCloudRegisterCode || ''; + this.polarCloudCode = info.PolarCloudRegisterCode || ''; + this.lifetimePrintTime = info.FormattedTotalRunTime || ''; + this.lifetimeFilamentMeters = + info.CumulativeFilament !== undefined ? `${info.CumulativeFilament.toFixed(2)}m` : '0.00m'; + + return true; + } + + /** + * Constructs the full API endpoint URL. + * @param endpoint The specific API endpoint path. + * @returns The full URL for the API endpoint. + */ + public getEndpoint(endpoint: string): string { + return `http://${this.ipAddress}:${this.PORT}${endpoint}`; + } + + /** + * Verifies the connection to the printer by retrieving machine details and TCP information. + * @returns A Promise that resolves to true if the connection is verified, false otherwise. + */ + public async verifyConnection(): Promise { + try { + const response = await this.info.getDetailResponse(); + if (!response || !NetworkUtils.isOk(response)) { + console.log('Failed to get valid response from printer API'); return false; - } - - /** - * Disposes of the FiveMClient instance, stopping keep-alive messages and cleaning up resources. - */ - public async dispose(): Promise { - await this.tcpClient.dispose(); - } - - /** - * Caches machine details from the provided FFMachineInfo object. - * @param info The FFMachineInfo object containing printer details. - * @returns True if caching is successful, false otherwise. - */ - public cacheDetails(info: FFMachineInfo | null): boolean { - if (!info) return false; - - // console.log(JSON.stringify(info, null, 2)); // Useful for debugging - this.printerName = info.Name || ''; - this.isPro = info.IsPro; // Use the value from MachineInfo - this.isAD5X = info.IsAD5X; // Cache the AD5X status - this.firmwareVersion = info.FirmwareVersion || ''; - this.firmVer = info.FirmwareVersion ? info.FirmwareVersion.split('-')[0] : ''; - this.macAddress = info.MacAddress || ''; - this.flashCloudCode = info.FlashCloudRegisterCode || ''; - this.polarCloudCode = info.PolarCloudRegisterCode || ''; - this.lifetimePrintTime = info.FormattedTotalRunTime || ''; - this.lifetimeFilamentMeters = info.CumulativeFilament !== undefined ? - `${info.CumulativeFilament.toFixed(2)}m` : '0.00m'; - - return true; - } + } - /** - * Constructs the full API endpoint URL. - * @param endpoint The specific API endpoint path. - * @returns The full URL for the API endpoint. - */ - public getEndpoint(endpoint: string): string { - return `http://${this.ipAddress}:${this.PORT}${endpoint}`; - } - - /** - * Verifies the connection to the printer by retrieving machine details and TCP information. - * @returns A Promise that resolves to true if the connection is verified, false otherwise. - */ - public async verifyConnection(): Promise { - - try { - const response = await this.info.getDetailResponse(); - if (!response || !NetworkUtils.isOk(response)) { - console.log("Failed to get valid response from printer API"); - return false; - } - - // Make sure we get a valid detail response - const machineInfo = new MachineInfo().fromDetail(response.detail); - if (!machineInfo) { return false; } - - // Check for Pro model with the machine TypeName (can't be changed by user) - // We now rely on MachineInfo.fromDetail to set IsPro and IsAD5X based on detail.name - // So, the TCP check for "Pro" might be redundant or could be a fallback. - // For now, let's keep it but prioritize what's in machineInfo. - const tcpInfo = await this.tcpClient.getPrinterInfo(); - if (tcpInfo) { - // If machineInfo hasn't already set isPro, we can use TCP info as a fallback. - // However, machineInfo.IsPro (derived from detail.name) should be more reliable. - // This line effectively gets overridden by cacheDetails if machineInfo.IsPro is set. - if (tcpInfo.TypeName.includes("Pro") && !machineInfo.IsPro && !machineInfo.IsAD5X) { - // Only set this if not already determined by machineInfo, and it's not an AD5X - this.isPro = true; - } - } else { - console.error("Unable to get PrinterInfo from TcpAPI, some details might be incomplete"); - } - // we should probably return false if tcpInfo is null here, like we do for machineInfo, - // but for now, we'll let cacheDetails be the primary source of truth for these flags. - - return this.cacheDetails(machineInfo); - } catch (error: unknown) { - const err = error as Error; - console.log(`Error in verifyConnection: ${err.message}`); - console.log(err.stack); - return false; + // Make sure we get a valid detail response + const machineInfo = new MachineInfo().fromDetail(response.detail); + if (!machineInfo) { + return false; + } + + // Check for Pro model with the machine TypeName (can't be changed by user) + // We now rely on MachineInfo.fromDetail to set IsPro and IsAD5X based on detail.name + // So, the TCP check for "Pro" might be redundant or could be a fallback. + // For now, let's keep it but prioritize what's in machineInfo. + const tcpInfo = await this.tcpClient.getPrinterInfo(); + if (tcpInfo) { + // If machineInfo hasn't already set isPro, we can use TCP info as a fallback. + // However, machineInfo.IsPro (derived from detail.name) should be more reliable. + // This line effectively gets overridden by cacheDetails if machineInfo.IsPro is set. + if (tcpInfo.TypeName.includes('Pro') && !machineInfo.IsPro && !machineInfo.IsAD5X) { + // Only set this if not already determined by machineInfo, and it's not an AD5X + this.isPro = true; } + } else { + console.error('Unable to get PrinterInfo from TcpAPI, some details might be incomplete'); + } + // we should probably return false if tcpInfo is null here, like we do for machineInfo, + // but for now, we'll let cacheDetails be the primary source of truth for these flags. + + return this.cacheDetails(machineInfo); + } catch (error: unknown) { + const err = error as Error; + console.log(`Error in verifyConnection: ${err.message}`); + console.log(err.stack); + return false; } - - /** - * Sends a product command to the printer to retrieve control states. - * This method sets the `httpClientBusy` flag while the request is in progress. - * @returns A Promise that resolves to true if the product command is sent successfully and valid data is received, false otherwise. - * @throws Error if there is an HTTP error or an error parsing the response. - */ - public async sendProductCommand(): Promise { - //console.log("SendProductCommand()"); - this.httpClientBusy = true; - - const payload = { - serialNumber: this.serialNumber, - checkCode: this.checkCode - }; - - try { - const response = await this.httpClient.post( - this.getEndpoint(Endpoints.Product), - payload - ); - - if (response.status !== 200) return false; - - try { - const productResponse = response.data as ProductResponse; - if (productResponse && NetworkUtils.isOk(productResponse)) { - // Parse & set control states - const product = productResponse.product; - this.productInfo = product; // Store raw product data - this.ledControl = product.lightCtrlState !== 0; - this.filtrationControl = !(product.internalFanCtrlState === 0 || product.externalFanCtrlState === 0); - //console.log("LedControl: " + this.ledControl); - //console.log("FiltrationControl: " + this.filtrationControl); - return true; - } - } catch (error) { - console.error(`SendProductCommand error: ${(error as Error).message}`); - throw error; - } - } catch (error) { - console.error(`SendProductCommand HTTP error: ${(error as Error).message}`); - throw error; - } finally { - this.httpClientBusy = false; + } + + /** + * Sends a product command to the printer to retrieve control states. + * This method sets the `httpClientBusy` flag while the request is in progress. + * @returns A Promise that resolves to true if the product command is sent successfully and valid data is received, false otherwise. + * @throws Error if there is an HTTP error or an error parsing the response. + */ + public async sendProductCommand(): Promise { + //console.log("SendProductCommand()"); + this.httpClientBusy = true; + + const payload = { + serialNumber: this.serialNumber, + checkCode: this.checkCode, + }; + + try { + const response = await this.httpClient.post(this.getEndpoint(Endpoints.Product), payload); + + if (response.status !== 200) return false; + + try { + const productResponse = response.data as ProductResponse; + if (productResponse && NetworkUtils.isOk(productResponse)) { + // Parse & set control states + const product = productResponse.product; + this.productInfo = product; // Store raw product data + this.ledControl = product.lightCtrlState !== 0; + this.filtrationControl = !( + product.internalFanCtrlState === 0 || product.externalFanCtrlState === 0 + ); + //console.log("LedControl: " + this.ledControl); + //console.log("FiltrationControl: " + this.filtrationControl); + return true; } - - return false; + } catch (error) { + console.error(`SendProductCommand error: ${(error as Error).message}`); + throw error; + } + } catch (error) { + console.error(`SendProductCommand HTTP error: ${(error as Error).message}`); + throw error; + } finally { + this.httpClientBusy = false; } + + return false; + } } /** @@ -284,8 +283,8 @@ export class FiveMClient { * @see GenericResponse */ interface ProductResponse extends GenericResponse { - /** Contains various control state flags from the printer. See {@link Product}. */ - product: Product; + /** Contains various control state flags from the printer. See {@link Product}. */ + product: Product; } /** @@ -296,16 +295,16 @@ interface ProductResponse extends GenericResponse { * while other numbers (typically 1) mean on/available or a specific mode. */ export interface Product { - /** State of the chamber temperature control. */ - chamberTempCtrlState: number; - /** State of the external fan control. */ - externalFanCtrlState: number; - /** State of the internal fan control. */ - internalFanCtrlState: number; - /** State of the light control. */ - lightCtrlState: number; - /** State of the nozzle temperature control. */ - nozzleTempCtrlState: number; - /** State of the platform (bed) temperature control. */ - platformTempCtrlState: number; + /** State of the chamber temperature control. */ + chamberTempCtrlState: number; + /** State of the external fan control. */ + externalFanCtrlState: number; + /** State of the internal fan control. */ + internalFanCtrlState: number; + /** State of the light control. */ + lightCtrlState: number; + /** State of the nozzle temperature control. */ + nozzleTempCtrlState: number; + /** State of the platform (bed) temperature control. */ + platformTempCtrlState: number; } diff --git a/src/api/PrinterDiscovery.ts b/src/api/PrinterDiscovery.ts index 44acf6d..abbe381 100644 --- a/src/api/PrinterDiscovery.ts +++ b/src/api/PrinterDiscovery.ts @@ -5,34 +5,34 @@ * printer name, serial number, and IP address from fixed buffer offsets. */ // src/api/PrinterDiscovery.ts -import * as dgram from 'dgram'; -import { networkInterfaces } from 'os'; +import * as dgram from 'node:dgram'; +import { networkInterfaces } from 'node:os'; /** * Represents a discovered FlashForge 3D printer. * Stores information such as name, serial number, and IP address. */ export class FlashForgePrinter { - /** The name of the printer. */ - public name: string = ''; - /** The serial number of the printer. */ - public serialNumber: string = ''; - /** The IP address of the printer. */ - public ipAddress: string = ''; - /** Optional flag indicating if the discovered printer is an AD5X model, based on its name. */ - public isAD5X?: boolean; - - /** - * Returns a string representation of the FlashForgePrinter object. - * @returns A string containing the printer's name, serial number, IP address, and AD5X status if applicable. - */ - public toString(): string { - let str = `Name: ${this.name}, Serial: ${this.serialNumber}, IP: ${this.ipAddress}`; - if (this.isAD5X) { - str += ", Model: AD5X"; - } - return str; + /** The name of the printer. */ + public name: string = ''; + /** The serial number of the printer. */ + public serialNumber: string = ''; + /** The IP address of the printer. */ + public ipAddress: string = ''; + /** Optional flag indicating if the discovered printer is an AD5X model, based on its name. */ + public isAD5X?: boolean; + + /** + * Returns a string representation of the FlashForgePrinter object. + * @returns A string containing the printer's name, serial number, IP address, and AD5X status if applicable. + */ + public toString(): string { + let str = `Name: ${this.name}, Serial: ${this.serialNumber}, IP: ${this.ipAddress}`; + if (this.isAD5X) { + str += ', Model: AD5X'; } + return str; + } } /** @@ -40,284 +40,286 @@ export class FlashForgePrinter { * Uses UDP broadcast messages to find printers and parses their responses. */ export class FlashForgePrinterDiscovery { - /** The UDP port used for sending discovery messages to FlashForge printers. */ - private static readonly DISCOVERY_PORT = 48899; - - // Instance property for easy access to the discovery port - /** The UDP port used for sending discovery messages. */ - private readonly discoveryPort = FlashForgePrinterDiscovery.DISCOVERY_PORT; - - /** - * Discovers FlashForge printers on the network asynchronously. - * It sends UDP broadcast messages and listens for responses from printers. - * The discovery process involves sending a specific UDP packet to the `DISCOVERY_PORT`. - * Printers respond with a packet containing their details, which is then parsed. - * Retries are implemented in case of no initial response. - * - * @param timeoutMs The total time (in milliseconds) to wait for printer responses. Defaults to 10000ms. - * @param idleTimeoutMs The time (in milliseconds) to wait for additional responses after the last received one. Defaults to 1500ms. - * @param maxRetries The maximum number of discovery attempts. Defaults to 3. - * @returns A Promise that resolves to an array of `FlashForgePrinter` objects found on the network. - */ - public async discoverPrintersAsync(timeoutMs: number = 10000, idleTimeoutMs: number = 1500, maxRetries: number = 3): Promise { - const printers: FlashForgePrinter[] = []; - const broadcastAddresses = this.getBroadcastAddresses(); - let attempt = 0; - - while (attempt < maxRetries) { - attempt++; - - const udpClient = dgram.createSocket({ type: 'udp4', reuseAddr: true }); - - try { - // Set up socket - await new Promise((resolve) => { - // Bind to port 18007 to receive responses - udpClient.bind(18007, () => { - udpClient.setBroadcast(true); - resolve(); - }); - }); - - // Send discovery message to all broadcast addresses - // The discovery UDP packet is a 20-byte message. - // It starts with "www.usr" followed by specific bytes. - // This packet structure is based on observations from FlashPrint software. - // Bytes: - // 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, (www.usr") - // 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, - // 0x00, 0x00, 0x00, 0x00 - const discoveryMessage = Buffer.from([ - 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, - 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 - ]); - for (const broadcastAddress of broadcastAddresses) { - try { - udpClient.send(discoveryMessage, this.discoveryPort, broadcastAddress); - } catch (ex) { - console.log(`Failed to send to ${broadcastAddress}: ${(ex as Error).message}`); - } - } - - try { - await this.receivePrinterResponses(udpClient, printers, timeoutMs, idleTimeoutMs); - } catch (ex) { - console.log(`ReceivePrinterResponses error: ${(ex as Error).message}`); - } - } finally { - udpClient.close(); - } - - if (printers.length > 0) { - break; // Printers found, exit the retry loop - } - - if (attempt >= maxRetries) continue; - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait before retrying - } - - - return printers; - } - - /** - * Receives and processes printer responses from the UDP socket. - * Listens for messages on the socket and parses them using `parsePrinterResponse`. - * Manages timeouts for the overall discovery process and for idle periods between responses. - * - * @param udpClient The dgram.Socket instance to listen on. - * @param printers An array to store the discovered `FlashForgePrinter` objects. - * @param totalTimeoutMs The total duration (in milliseconds) to listen for responses. - * @param idleTimeoutMs The maximum idle time (in milliseconds) to wait for a new response before stopping. - * @returns A Promise that resolves when the listening period is over or an error occurs. - * @private - */ - private async receivePrinterResponses( - udpClient: dgram.Socket, - printers: FlashForgePrinter[], - totalTimeoutMs: number, - idleTimeoutMs: number - ): Promise { - return new Promise((resolve, reject) => { - let totalTimeoutHandle: NodeJS.Timeout | null = null; - let idleTimeoutHandle: NodeJS.Timeout | null = null; - - const cleanupAndResolve = () => { - if (totalTimeoutHandle) clearTimeout(totalTimeoutHandle); - if (idleTimeoutHandle) clearTimeout(idleTimeoutHandle); - udpClient.removeAllListeners('message'); - udpClient.removeAllListeners('error'); - resolve(); - }; - - // Set total timeout - totalTimeoutHandle = setTimeout(() => { - cleanupAndResolve(); - }, totalTimeoutMs); - - const resetIdleTimeout = () => { - if (idleTimeoutHandle) clearTimeout(idleTimeoutHandle); - idleTimeoutHandle = setTimeout(() => { - cleanupAndResolve(); - }, idleTimeoutMs); - }; - - // Handle incoming messages - udpClient.on('message', (buffer, rinfo) => { - resetIdleTimeout(); - - const printer = this.parsePrinterResponse(buffer, rinfo.address); - if (printer) { - printers.push(printer); - } - }); - - // Handle errors - udpClient.on('error', (err) => { - console.log(`Socket error: ${err.message}`); - reject(err); - cleanupAndResolve(); - }); - - // Start the idle timeout - resetIdleTimeout(); + /** The UDP port used for sending discovery messages to FlashForge printers. */ + private static readonly DISCOVERY_PORT = 48899; + + // Instance property for easy access to the discovery port + /** The UDP port used for sending discovery messages. */ + private readonly discoveryPort = FlashForgePrinterDiscovery.DISCOVERY_PORT; + + /** + * Discovers FlashForge printers on the network asynchronously. + * It sends UDP broadcast messages and listens for responses from printers. + * The discovery process involves sending a specific UDP packet to the `DISCOVERY_PORT`. + * Printers respond with a packet containing their details, which is then parsed. + * Retries are implemented in case of no initial response. + * + * @param timeoutMs The total time (in milliseconds) to wait for printer responses. Defaults to 10000ms. + * @param idleTimeoutMs The time (in milliseconds) to wait for additional responses after the last received one. Defaults to 1500ms. + * @param maxRetries The maximum number of discovery attempts. Defaults to 3. + * @returns A Promise that resolves to an array of `FlashForgePrinter` objects found on the network. + */ + public async discoverPrintersAsync( + timeoutMs: number = 10000, + idleTimeoutMs: number = 1500, + maxRetries: number = 3 + ): Promise { + const printers: FlashForgePrinter[] = []; + const broadcastAddresses = this.getBroadcastAddresses(); + let attempt = 0; + + while (attempt < maxRetries) { + attempt++; + + const udpClient = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + + try { + // Set up socket + await new Promise((resolve) => { + // Bind to port 18007 to receive responses + udpClient.bind(18007, () => { + udpClient.setBroadcast(true); + resolve(); + }); }); - } - /** - * Parses the UDP response received from a FlashForge printer. - * The response is a buffer containing printer information at specific offsets. - * - Printer Name: ASCII string at offset 0x00 (32 bytes). - * - Serial Number: ASCII string at offset 0x92 (32 bytes). - * - * @param response The Buffer containing the printer's response. - * @param ipAddress The IP address from which the response was received. - * @returns A `FlashForgePrinter` object if parsing is successful, otherwise null. - * @private - */ - private parsePrinterResponse(response: Buffer, ipAddress: string): FlashForgePrinter | null { - // Expected response length is at least 0xC4 (196 bytes) to contain name and serial. - if (!response || response.length < 0xC4) { - console.log("Invalid response, discarded."); - return null; + // Send discovery message to all broadcast addresses + // The discovery UDP packet is a 20-byte message. + // It starts with "www.usr" followed by specific bytes. + // This packet structure is based on observations from FlashPrint software. + // Bytes: + // 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, (www.usr") + // 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, + // 0x00, 0x00, 0x00, 0x00 + const discoveryMessage = Buffer.from([ + 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + for (const broadcastAddress of broadcastAddresses) { + try { + udpClient.send(discoveryMessage, this.discoveryPort, broadcastAddress); + } catch (ex) { + console.log(`Failed to send to ${broadcastAddress}: ${(ex as Error).message}`); + } } - // Printer name is at offset 0x00, padded with null characters. - const name = response.toString('ascii', 0, 32).replace(/\0+$/, ''); - // Serial number is at offset 0x92, padded with null characters. - const serialNumber = response.toString('ascii', 0x92, 0x92 + 32).replace(/\0+$/, ''); - - const printer = new FlashForgePrinter(); - printer.name = name; - printer.serialNumber = serialNumber; - printer.ipAddress = ipAddress; - if (name === "AD5X") { - printer.isAD5X = true; + try { + await this.receivePrinterResponses(udpClient, printers, timeoutMs, idleTimeoutMs); + } catch (ex) { + console.log(`ReceivePrinterResponses error: ${(ex as Error).message}`); } + } finally { + udpClient.close(); + } + + if (printers.length > 0) { + break; // Printers found, exit the retry loop + } - return printer; + if (attempt >= maxRetries) continue; + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait before retrying } - /** - * Retrieves a list of broadcast addresses for all active IPv4 network interfaces. - * This is used to send the discovery UDP packet to all devices on the local network(s). - * - * @returns An array of string representations of broadcast addresses. - * @private - */ - private getBroadcastAddresses(): string[] { - const broadcastAddresses: string[] = []; - const interfaces = networkInterfaces(); - - for (const [name, netInterface] of Object.entries(interfaces)) { - if (!netInterface) continue; - - for (const iface of netInterface) { - // Skip non-IPv4 and internal/loopback interfaces - if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) { - continue; - } - - // Calculate broadcast address based on IP and netmask - const broadcastAddress = this.calculateBroadcastAddress(iface.address, iface.netmask); - if (broadcastAddress) { - broadcastAddresses.push(broadcastAddress); - } - } + return printers; + } + + /** + * Receives and processes printer responses from the UDP socket. + * Listens for messages on the socket and parses them using `parsePrinterResponse`. + * Manages timeouts for the overall discovery process and for idle periods between responses. + * + * @param udpClient The dgram.Socket instance to listen on. + * @param printers An array to store the discovered `FlashForgePrinter` objects. + * @param totalTimeoutMs The total duration (in milliseconds) to listen for responses. + * @param idleTimeoutMs The maximum idle time (in milliseconds) to wait for a new response before stopping. + * @returns A Promise that resolves when the listening period is over or an error occurs. + * @private + */ + private async receivePrinterResponses( + udpClient: dgram.Socket, + printers: FlashForgePrinter[], + totalTimeoutMs: number, + idleTimeoutMs: number + ): Promise { + return new Promise((resolve, reject) => { + let totalTimeoutHandle: NodeJS.Timeout | null = null; + let idleTimeoutHandle: NodeJS.Timeout | null = null; + + const cleanupAndResolve = () => { + if (totalTimeoutHandle) clearTimeout(totalTimeoutHandle); + if (idleTimeoutHandle) clearTimeout(idleTimeoutHandle); + udpClient.removeAllListeners('message'); + udpClient.removeAllListeners('error'); + resolve(); + }; + + // Set total timeout + totalTimeoutHandle = setTimeout(() => { + cleanupAndResolve(); + }, totalTimeoutMs); + + const resetIdleTimeout = () => { + if (idleTimeoutHandle) clearTimeout(idleTimeoutHandle); + idleTimeoutHandle = setTimeout(() => { + cleanupAndResolve(); + }, idleTimeoutMs); + }; + + // Handle incoming messages + udpClient.on('message', (buffer, rinfo) => { + resetIdleTimeout(); + + const printer = this.parsePrinterResponse(buffer, rinfo.address); + if (printer) { + printers.push(printer); } + }); + + // Handle errors + udpClient.on('error', (err) => { + console.log(`Socket error: ${err.message}`); + reject(err); + cleanupAndResolve(); + }); + + // Start the idle timeout + resetIdleTimeout(); + }); + } + + /** + * Parses the UDP response received from a FlashForge printer. + * The response is a buffer containing printer information at specific offsets. + * - Printer Name: ASCII string at offset 0x00 (32 bytes). + * - Serial Number: ASCII string at offset 0x92 (32 bytes). + * + * @param response The Buffer containing the printer's response. + * @param ipAddress The IP address from which the response was received. + * @returns A `FlashForgePrinter` object if parsing is successful, otherwise null. + * @private + */ + private parsePrinterResponse(response: Buffer, ipAddress: string): FlashForgePrinter | null { + // Expected response length is at least 0xC4 (196 bytes) to contain name and serial. + if (!response || response.length < 0xc4) { + console.log('Invalid response, discarded.'); + return null; + } - return broadcastAddresses; + // Printer name is at offset 0x00, padded with null characters. + const name = response.toString('ascii', 0, 32).replace(/\0+$/, ''); + // Serial number is at offset 0x92, padded with null characters. + const serialNumber = response.toString('ascii', 0x92, 0x92 + 32).replace(/\0+$/, ''); + + const printer = new FlashForgePrinter(); + printer.name = name; + printer.serialNumber = serialNumber; + printer.ipAddress = ipAddress; + if (name === 'AD5X') { + printer.isAD5X = true; } - /** - * Calculates the broadcast address for a given IP address and subnet mask. - * The broadcast address is calculated as `IP | (~SUBNET_MASK)`. - * - * @param ipAddress The IPv4 address string (e.g., "192.168.1.10"). - * @param subnetMask The IPv4 subnet mask string (e.g., "255.255.255.0"). - * @returns The calculated broadcast address string, or null if input is invalid. - * @private - */ - private calculateBroadcastAddress(ipAddress: string, subnetMask: string): string | null { - try { - // Convert IP and subnet to arrays of numbers - const ip = ipAddress.split('.').map(Number); - const mask = subnetMask.split('.').map(Number); - - if (ip.length !== 4 || mask.length !== 4) { - return null; - } - - // Calculate broadcast address: IP | (~MASK) - const broadcast = ip.map((octet, index) => octet | (~mask[index] & 255)); - return broadcast.join('.'); - } catch (error) { - console.log(`Error calculating broadcast address: ${(error as Error).message}`); - return null; + return printer; + } + + /** + * Retrieves a list of broadcast addresses for all active IPv4 network interfaces. + * This is used to send the discovery UDP packet to all devices on the local network(s). + * + * @returns An array of string representations of broadcast addresses. + * @private + */ + private getBroadcastAddresses(): string[] { + const broadcastAddresses: string[] = []; + const interfaces = networkInterfaces(); + + for (const [_name, netInterface] of Object.entries(interfaces)) { + if (!netInterface) continue; + + for (const iface of netInterface) { + // Skip non-IPv4 and internal/loopback interfaces + if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) { + continue; } + + // Calculate broadcast address based on IP and netmask + const broadcastAddress = this.calculateBroadcastAddress(iface.address, iface.netmask); + if (broadcastAddress) { + broadcastAddresses.push(broadcastAddress); + } + } } - /** - * Prints detailed debugging information about a received UDP response. - * This includes a hex dump and an ASCII dump of the response buffer. - * Useful for inspecting the raw data received from printers. - * - * @param response The Buffer containing the response data. - * @param ipAddress The IP address from which the response was received. - */ - public printDebugInfo(response: Buffer, ipAddress: string): void { - console.log(`Received response from ${ipAddress}:`); - console.log(`Response length: ${response.length} bytes`); - - // Hex dump - console.log("Hex dump:"); - for (let i = 0; i < response.length; i += 16) { - let line = `${i.toString(16).padStart(4, '0')} `; - - // Hex values - for (let j = 0; j < 16; j++) { - if (i + j < response.length) { - line += `${response[i + j].toString(16).padStart(2, '0')} `; - } else { - line += " "; - } - - if (j === 7) line += " "; - } - - // ASCII representation - line += " "; - for (let j = 0; j < 16 && i + j < response.length; j++) { - const c = response[i + j]; - line += (c >= 32 && c <= 126) ? String.fromCharCode(c) : '.'; - } - - console.log(line); + return broadcastAddresses; + } + + /** + * Calculates the broadcast address for a given IP address and subnet mask. + * The broadcast address is calculated as `IP | (~SUBNET_MASK)`. + * + * @param ipAddress The IPv4 address string (e.g., "192.168.1.10"). + * @param subnetMask The IPv4 subnet mask string (e.g., "255.255.255.0"). + * @returns The calculated broadcast address string, or null if input is invalid. + * @private + */ + private calculateBroadcastAddress(ipAddress: string, subnetMask: string): string | null { + try { + // Convert IP and subnet to arrays of numbers + const ip = ipAddress.split('.').map(Number); + const mask = subnetMask.split('.').map(Number); + + if (ip.length !== 4 || mask.length !== 4) { + return null; + } + + // Calculate broadcast address: IP | (~MASK) + const broadcast = ip.map((octet, index) => octet | (~mask[index] & 255)); + return broadcast.join('.'); + } catch (error) { + console.log(`Error calculating broadcast address: ${(error as Error).message}`); + return null; + } + } + + /** + * Prints detailed debugging information about a received UDP response. + * This includes a hex dump and an ASCII dump of the response buffer. + * Useful for inspecting the raw data received from printers. + * + * @param response The Buffer containing the response data. + * @param ipAddress The IP address from which the response was received. + */ + public printDebugInfo(response: Buffer, ipAddress: string): void { + console.log(`Received response from ${ipAddress}:`); + console.log(`Response length: ${response.length} bytes`); + + // Hex dump + console.log('Hex dump:'); + for (let i = 0; i < response.length; i += 16) { + let line = `${i.toString(16).padStart(4, '0')} `; + + // Hex values + for (let j = 0; j < 16; j++) { + if (i + j < response.length) { + line += `${response[i + j].toString(16).padStart(2, '0')} `; + } else { + line += ' '; } - // ASCII dump - console.log("ASCII dump:"); - console.log(response.toString('ascii')); + if (j === 7) line += ' '; + } + + // ASCII representation + line += ' '; + for (let j = 0; j < 16 && i + j < response.length; j++) { + const c = response[i + j]; + line += c >= 32 && c <= 126 ? String.fromCharCode(c) : '.'; + } + + console.log(line); } -} \ No newline at end of file + + // ASCII dump + console.log('ASCII dump:'); + console.log(response.toString('ascii')); + } +} diff --git a/src/api/controls/Control.test.ts b/src/api/controls/Control.test.ts index cd6aea7..a2b5ece 100644 --- a/src/api/controls/Control.test.ts +++ b/src/api/controls/Control.test.ts @@ -2,43 +2,57 @@ * @fileoverview Unit tests for Control module. * Tests HTTP API control operations including homing, filtration, camera, fans, LEDs, and filament operations using mocked clients. */ + import axios from 'axios'; -import { Control } from './Control'; -import { FiveMClient } from '../../FiveMClient'; -import { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; import { Commands } from '../server/Commands'; import { Endpoints } from '../server/Endpoints'; -import { Info } from './Info'; +import { Control } from './Control'; +import type { Info } from './Info'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +vi.mock('axios'); +const mockedAxios = axios as typeof axios & { + post: ReturnType; +}; -jest.mock('../../tcpapi/FlashForgeClient'); +vi.mock('../../tcpapi/FlashForgeClient'); describe('Control', () => { let mockFiveMClient: FiveMClient; - let mockTcpClient: jest.Mocked; - let mockInfo: jest.Mocked; + let mockTcpClient: FlashForgeClient & { + homeAxes: ReturnType; + rapidHome: ReturnType; + turnRunoutSensorOn: ReturnType; + turnRunoutSensorOff: ReturnType; + prepareFilamentLoad: ReturnType; + loadFilament: ReturnType; + finishFilamentLoad: ReturnType; + }; + let mockInfo: Info & { + get: ReturnType; + }; let control: Control; beforeEach(() => { mockedAxios.post.mockReset(); mockTcpClient = { - homeAxes: jest.fn().mockResolvedValue(true), - rapidHome: jest.fn().mockResolvedValue(true), - turnRunoutSensorOn: jest.fn().mockResolvedValue(true), - turnRunoutSensorOff: jest.fn().mockResolvedValue(true), - prepareFilamentLoad: jest.fn().mockResolvedValue(true), - loadFilament: jest.fn().mockResolvedValue(true), - finishFilamentLoad: jest.fn().mockResolvedValue(true) + homeAxes: vi.fn().mockResolvedValue(true), + rapidHome: vi.fn().mockResolvedValue(true), + turnRunoutSensorOn: vi.fn().mockResolvedValue(true), + turnRunoutSensorOff: vi.fn().mockResolvedValue(true), + prepareFilamentLoad: vi.fn().mockResolvedValue(true), + loadFilament: vi.fn().mockResolvedValue(true), + finishFilamentLoad: vi.fn().mockResolvedValue(true), } as any; mockInfo = { - get: jest.fn().mockResolvedValue({ + get: vi.fn().mockResolvedValue({ Status: 'ready', - CurrentPrintLayer: 5 - }) + CurrentPrintLayer: 5, + }), } as any; mockFiveMClient = { @@ -49,9 +63,9 @@ describe('Control', () => { filtrationControl: true, ledControl: true, isPro: true, - isHttpClientBusy: jest.fn().mockResolvedValue(undefined), - releaseHttpClient: jest.fn(), - getEndpoint: (endpoint: string) => `http://printer:8898${endpoint}` + isHttpClientBusy: vi.fn().mockResolvedValue(undefined), + releaseHttpClient: vi.fn(), + getEndpoint: (endpoint: string) => `http://printer:8898${endpoint}`, } as any; control = new Control(mockFiveMClient); @@ -87,7 +101,7 @@ describe('Control', () => { beforeEach(() => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); }); @@ -102,9 +116,9 @@ describe('Control', () => { cmd: Commands.CirculationControlCmd, args: { internal: 'close', - external: 'open' - } - } + external: 'open', + }, + }, }), expect.any(Object) ); @@ -121,9 +135,9 @@ describe('Control', () => { cmd: Commands.CirculationControlCmd, args: { internal: 'open', - external: 'close' - } - } + external: 'close', + }, + }, }), expect.any(Object) ); @@ -140,9 +154,9 @@ describe('Control', () => { cmd: Commands.CirculationControlCmd, args: { internal: 'close', - external: 'close' - } - } + external: 'close', + }, + }, }), expect.any(Object) ); @@ -162,7 +176,7 @@ describe('Control', () => { beforeEach(() => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); }); @@ -175,8 +189,8 @@ describe('Control', () => { expect.objectContaining({ payload: { cmd: Commands.CameraControlCmd, - args: { action: 'open' } - } + args: { action: 'open' }, + }, }), expect.any(Object) ); @@ -191,8 +205,8 @@ describe('Control', () => { expect.objectContaining({ payload: { cmd: Commands.CameraControlCmd, - args: { action: 'close' } - } + args: { action: 'close' }, + }, }), expect.any(Object) ); @@ -214,11 +228,11 @@ describe('Control', () => { beforeEach(() => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); mockInfo.get.mockResolvedValue({ Status: 'printing', - CurrentPrintLayer: 10 + CurrentPrintLayer: 10, }); }); @@ -232,9 +246,9 @@ describe('Control', () => { payload: { cmd: Commands.PrinterControlCmd, args: expect.objectContaining({ - speed: 150 - }) - } + speed: 150, + }), + }, }), expect.any(Object) ); @@ -250,9 +264,9 @@ describe('Control', () => { payload: { cmd: Commands.PrinterControlCmd, args: expect.objectContaining({ - zAxisCompensation: 0.2 - }) - } + zAxisCompensation: 0.2, + }), + }, }), expect.any(Object) ); @@ -263,11 +277,11 @@ describe('Control', () => { beforeEach(() => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); mockInfo.get.mockResolvedValue({ Status: 'printing', - CurrentPrintLayer: 10 + CurrentPrintLayer: 10, }); }); @@ -281,9 +295,9 @@ describe('Control', () => { payload: { cmd: Commands.PrinterControlCmd, args: expect.objectContaining({ - chamberFan: 75 - }) - } + chamberFan: 75, + }), + }, }), expect.any(Object) ); @@ -299,9 +313,9 @@ describe('Control', () => { payload: { cmd: Commands.PrinterControlCmd, args: expect.objectContaining({ - coolingFan: 80 - }) - } + coolingFan: 80, + }), + }, }), expect.any(Object) ); @@ -310,7 +324,7 @@ describe('Control', () => { it('should set fan speeds to 0 for initial layers', async () => { mockInfo.get.mockResolvedValue({ Status: 'printing', - CurrentPrintLayer: 1 + CurrentPrintLayer: 1, }); await control.setChamberFanSpeed(100); @@ -322,9 +336,9 @@ describe('Control', () => { cmd: Commands.PrinterControlCmd, args: expect.objectContaining({ chamberFan: 0, - coolingFan: 0 - }) - } + coolingFan: 0, + }), + }, }), expect.any(Object) ); @@ -335,7 +349,7 @@ describe('Control', () => { beforeEach(() => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); }); @@ -348,8 +362,8 @@ describe('Control', () => { expect.objectContaining({ payload: { cmd: Commands.LightControlCmd, - args: { status: 'open' } - } + args: { status: 'open' }, + }, }), expect.any(Object) ); @@ -364,8 +378,8 @@ describe('Control', () => { expect.objectContaining({ payload: { cmd: Commands.LightControlCmd, - args: { status: 'close' } - } + args: { status: 'close' }, + }, }), expect.any(Object) ); @@ -427,7 +441,7 @@ describe('Control', () => { it('should send control command successfully', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await control.sendControlCommand('test_cmd', { test: 'value' }); @@ -441,13 +455,13 @@ describe('Control', () => { checkCode: 'CC123456', payload: { cmd: 'test_cmd', - args: { test: 'value' } - } + args: { test: 'value' }, + }, }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); expect(mockFiveMClient.releaseHttpClient).toHaveBeenCalled(); @@ -456,7 +470,7 @@ describe('Control', () => { it('should return false for non-OK response', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 1, message: 'Error' } + data: { code: 1, message: 'Error' }, }); const result = await control.sendControlCommand('test_cmd', {}); @@ -485,7 +499,7 @@ describe('Control', () => { it('should send job control command', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await control.sendJobControlCmd('pause'); @@ -498,9 +512,9 @@ describe('Control', () => { cmd: Commands.JobControlCmd, args: { jobID: '', - action: 'pause' - } - } + action: 'pause', + }, + }, }), expect.any(Object) ); diff --git a/src/api/controls/Control.ts b/src/api/controls/Control.ts index 962c38c..875316f 100644 --- a/src/api/controls/Control.ts +++ b/src/api/controls/Control.ts @@ -3,12 +3,23 @@ * Provides methods for controlling printer hardware including axes, filtration, camera, fans, LEDs, and filament operations via the HTTP control endpoint. */ // src/api/controls/Control.ts -import { FiveMClient } from '../../FiveMClient'; + +import axios from 'axios'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; +import type { Filament } from '../filament/Filament'; +import { NetworkUtils } from '../network/NetworkUtils'; import { Commands } from '../server/Commands'; -import { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; import { Endpoints } from '../server/Endpoints'; -import { NetworkUtils } from '../network/NetworkUtils'; -import axios from 'axios'; + +/** + * Represents printer status information. + * Used to check the current state of the printer. + */ +interface PrinterStatusInfo { + /** The current status of the printer (e.g., "printing", "idle"). */ + Status: string; +} /** * Provides methods for controlling various aspects of the FlashForge 3D printer. @@ -16,345 +27,346 @@ import axios from 'axios'; * fans, LEDs, and filament operations. */ export class Control { - private client: FiveMClient; - private tcpClient: FlashForgeClient; - - /** - * Creates an instance of the Control class. - * @param client The FiveMClient instance used for communication with the printer. - */ - constructor(client: FiveMClient) { - this.client = client; - this.tcpClient = client.tcpClient; - } - - /** - * Homes the X, Y, and Z axes of the printer. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async homeAxes(): Promise { - return await this.tcpClient.homeAxes(); - } - - /** - * Performs a rapid homing of the X, Y, and Z axes. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async homeAxesRapid(): Promise { - return await this.tcpClient.rapidHome(); - } - - /** - * Turns on the external filtration system. - * Requires the printer to have filtration control. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setExternalFiltrationOn(): Promise { - if (this.client.filtrationControl) { - return await this.sendFiltrationCommand(new FiltrationArgs(false, true)); - } - console.log("SetExternalFiltrationOn() error, filtration not equipped."); - return false; - } - - /** - * Turns on the internal filtration system. - * Requires the printer to have filtration control. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setInternalFiltrationOn(): Promise { - if (this.client.filtrationControl) { - return await this.sendFiltrationCommand(new FiltrationArgs(true, false)); - } - console.log("SetInternalFiltrationOn() error, filtration not equipped."); - return false; - } - - /** - * Turns off both internal and external filtration systems. - * Requires the printer to have filtration control. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setFiltrationOff(): Promise { - if (this.client.filtrationControl) { - return await this.sendFiltrationCommand(new FiltrationArgs(false, false)); - } - console.log("SetFiltrationOff() error, filtration not equipped."); - return false; - } - - /** - * Turns on the printer's camera. - * Only applicable for Pro models. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async turnCameraOn(): Promise { - if (!this.client.isPro) return false; - return await this.sendCameraCommand(true); - } - - /** - * Turns off the printer's camera. - * Only applicable for Pro models. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async turnCameraOff(): Promise { - if (!this.client.isPro) return false; - return await this.sendCameraCommand(false); - } - - /** - * Sets the print speed override. - * @param speed The desired print speed percentage (e.g., 100 for normal speed). - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setSpeedOverride(speed: number): Promise { - return await this.sendPrinterControlCmd({ printSpeed: speed }); - } - - /** - * Sets the Z-axis offset override. - * @param offset The Z-axis offset value. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setZAxisOverride(offset: number): Promise { - return await this.sendPrinterControlCmd({ zOffset: offset }); - } - - /** - * Sets the chamber fan speed. - * @param speed The desired chamber fan speed percentage. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setChamberFanSpeed(speed: number): Promise { - return await this.sendPrinterControlCmd({ chamberFanSpeed: speed }); - } - - /** - * Sets the cooling fan speed. - * @param speed The desired cooling fan speed percentage. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setCoolingFanSpeed(speed: number): Promise { - return await this.sendPrinterControlCmd({ coolingFanSpeed: speed }); - } - - /** - * Turns on the printer's LED lights. - * Requires the printer to have LED control. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setLedOn(): Promise { - if (this.client.ledControl) { - return await this.sendControlCommand(Commands.LightControlCmd, { status: "open" }); - } - console.log("SetLedOn() error, LEDs not equipped."); - return false; - } - - /** - * Turns off the printer's LED lights. - * Requires the printer to have LED control. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setLedOff(): Promise { - if (this.client.ledControl) { - return await this.sendControlCommand(Commands.LightControlCmd, { status: "close" }); - } - console.log("SetLedOff() error, LEDs not equipped."); - return false; - } - - /** - * Turns on the filament runout sensor. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async turnRunoutSensorOn(): Promise { - return await this.tcpClient.turnRunoutSensorOn(); - } - - /** - * Turns off the filament runout sensor. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async turnRunoutSensorOff(): Promise { - return await this.tcpClient.turnRunoutSensorOff(); + private client: FiveMClient; + private tcpClient: FlashForgeClient; + + /** + * Creates an instance of the Control class. + * @param client The FiveMClient instance used for communication with the printer. + */ + constructor(client: FiveMClient) { + this.client = client; + this.tcpClient = client.tcpClient; + } + + /** + * Homes the X, Y, and Z axes of the printer. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async homeAxes(): Promise { + return await this.tcpClient.homeAxes(); + } + + /** + * Performs a rapid homing of the X, Y, and Z axes. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async homeAxesRapid(): Promise { + return await this.tcpClient.rapidHome(); + } + + /** + * Turns on the external filtration system. + * Requires the printer to have filtration control. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setExternalFiltrationOn(): Promise { + if (this.client.filtrationControl) { + return await this.sendFiltrationCommand(new FiltrationArgs(false, true)); } - - // Filament load/unload/change - - /** - * Prepares the printer for filament loading. - * @param filament Information about the filament being loaded (type, temperature, etc.). - * The exact structure of this parameter depends on the `FlashForgeClient` implementation. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async prepareFilamentLoad(filament: any): Promise { - return await this.tcpClient.prepareFilamentLoad(filament); + console.log('SetExternalFiltrationOn() error, filtration not equipped.'); + return false; + } + + /** + * Turns on the internal filtration system. + * Requires the printer to have filtration control. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setInternalFiltrationOn(): Promise { + if (this.client.filtrationControl) { + return await this.sendFiltrationCommand(new FiltrationArgs(true, false)); } - - /** - * Initiates the filament loading process. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async loadFilament(): Promise { - return await this.tcpClient.loadFilament(); + console.log('SetInternalFiltrationOn() error, filtration not equipped.'); + return false; + } + + /** + * Turns off both internal and external filtration systems. + * Requires the printer to have filtration control. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setFiltrationOff(): Promise { + if (this.client.filtrationControl) { + return await this.sendFiltrationCommand(new FiltrationArgs(false, false)); } - - /** - * Finalizes the filament loading process. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async finishFilamentLoad(): Promise { - return await this.tcpClient.finishFilamentLoad(); + console.log('SetFiltrationOff() error, filtration not equipped.'); + return false; + } + + /** + * Turns on the printer's camera. + * Only applicable for Pro models. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async turnCameraOn(): Promise { + if (!this.client.isPro) return false; + return await this.sendCameraCommand(true); + } + + /** + * Turns off the printer's camera. + * Only applicable for Pro models. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async turnCameraOff(): Promise { + if (!this.client.isPro) return false; + return await this.sendCameraCommand(false); + } + + /** + * Sets the print speed override. + * @param speed The desired print speed percentage (e.g., 100 for normal speed). + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setSpeedOverride(speed: number): Promise { + return await this.sendPrinterControlCmd({ printSpeed: speed }); + } + + /** + * Sets the Z-axis offset override. + * @param offset The Z-axis offset value. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setZAxisOverride(offset: number): Promise { + return await this.sendPrinterControlCmd({ zOffset: offset }); + } + + /** + * Sets the chamber fan speed. + * @param speed The desired chamber fan speed percentage. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setChamberFanSpeed(speed: number): Promise { + return await this.sendPrinterControlCmd({ chamberFanSpeed: speed }); + } + + /** + * Sets the cooling fan speed. + * @param speed The desired cooling fan speed percentage. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setCoolingFanSpeed(speed: number): Promise { + return await this.sendPrinterControlCmd({ coolingFanSpeed: speed }); + } + + /** + * Turns on the printer's LED lights. + * Requires the printer to have LED control. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setLedOn(): Promise { + if (this.client.ledControl) { + return await this.sendControlCommand(Commands.LightControlCmd, { status: 'open' }); } - - // Internal methods for sending commands - - /** - * Sends a generic control command to the printer via HTTP POST. - * This method is used internally by other specific control methods. - * It ensures that the HTTP client is not busy before sending the command and releases it afterward. - * - * @param command The specific command string (from `Commands` enum) to send. - * @param args The arguments or payload specific to the command. - * @returns A Promise that resolves to true if the command is acknowledged with a success code, false otherwise or if an error occurs. - */ - public async sendControlCommand(command: string, args: any): Promise { - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - payload: { - cmd: command, - args: args - } - }; - - console.log("SendControlCommand:\n" + JSON.stringify(payload)); - - try { - await this.client.isHttpClientBusy(); - const response = await axios.post( - this.client.getEndpoint(Endpoints.Control), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - const data = response.data; - console.log(`Command reply: ${JSON.stringify(data)}`); - - const result = data as GenericResponse; - return this.isResponseOk(result); - } catch (e) { - return false; - } finally { - this.client.releaseHttpClient(); - } + console.log('SetLedOn() error, LEDs not equipped.'); + return false; + } + + /** + * Turns off the printer's LED lights. + * Requires the printer to have LED control. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setLedOff(): Promise { + if (this.client.ledControl) { + return await this.sendControlCommand(Commands.LightControlCmd, { status: 'close' }); } - - /** - * Sends a command to control various printer settings during a print. - * This includes Z-axis offset, print speed, chamber fan speed, and cooling fan speed. - * It prevents fan activation during the initial layers of a print. - * Throws an error if no print job is active. - * - * @param options An object containing the control parameters. - * @param options.zOffset The Z-axis compensation offset. Defaults to 0. - * @param options.printSpeed The print speed percentage. Defaults to 100. - * @param options.chamberFanSpeed The chamber fan speed percentage. Defaults to 100. - * @param options.coolingFanSpeed The cooling fan speed percentage. Defaults to 100. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - * @throws Error if called when the printer is not actively printing. - * @private - */ - private async sendPrinterControlCmd({ - zOffset = 0, - printSpeed = 100, - chamberFanSpeed = 100, - coolingFanSpeed = 100 - }: { - zOffset?: number; - printSpeed?: number; - chamberFanSpeed?: number; - coolingFanSpeed?: number; - }): Promise { - const info = await this.client.info.get(); - - // @ts-ignore - if (info.CurrentPrintLayer < 2) { - // Don't accidentally turn on the fans in the initial layers - chamberFanSpeed = 0; - coolingFanSpeed = 0; - } - - if (!this.isPrinting(info)) { - throw new Error("Attempted to send printerCtl_cmd with no active job"); - } - - const payload = { - zAxisCompensation: zOffset, - speed: printSpeed, - chamberFan: chamberFanSpeed, - coolingFan: coolingFanSpeed, - coolingLeftFan: 0 // This is unused - }; - - return await this.sendControlCommand(Commands.PrinterControlCmd, payload); + console.log('SetLedOff() error, LEDs not equipped.'); + return false; + } + + /** + * Turns on the filament runout sensor. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async turnRunoutSensorOn(): Promise { + return await this.tcpClient.turnRunoutSensorOn(); + } + + /** + * Turns off the filament runout sensor. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async turnRunoutSensorOff(): Promise { + return await this.tcpClient.turnRunoutSensorOff(); + } + + // Filament load/unload/change + + /** + * Prepares the printer for filament loading. + * @param filament Information about the filament being loaded (type, temperature, etc.). + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async prepareFilamentLoad(filament: Filament): Promise { + return await this.tcpClient.prepareFilamentLoad(filament); + } + + /** + * Initiates the filament loading process. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async loadFilament(): Promise { + return await this.tcpClient.loadFilament(); + } + + /** + * Finalizes the filament loading process. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async finishFilamentLoad(): Promise { + return await this.tcpClient.finishFilamentLoad(); + } + + // Internal methods for sending commands + + /** + * Sends a generic control command to the printer via HTTP POST. + * This method is used internally by other specific control methods. + * It ensures that the HTTP client is not busy before sending the command and releases it afterward. + * + * @param command The specific command string (from `Commands` enum) to send. + * @param args The arguments or payload specific to the command. + * @returns A Promise that resolves to true if the command is acknowledged with a success code, false otherwise or if an error occurs. + */ + public async sendControlCommand( + command: string, + args: object | boolean | number | string | undefined + ): Promise { + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + payload: { + cmd: command, + args: args, + }, + }; + + console.log(`SendControlCommand:\n${JSON.stringify(payload)}`); + + try { + await this.client.isHttpClientBusy(); + const response = await axios.post(this.client.getEndpoint(Endpoints.Control), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = response.data; + console.log(`Command reply: ${JSON.stringify(data)}`); + + const result = data as GenericResponse; + return this.isResponseOk(result); + } catch (_e) { + return false; + } finally { + this.client.releaseHttpClient(); } - - public async sendJobControlCmd(command: string): Promise { - const payload = { - jobID: "", // jobID seems to be optional or not strictly enforced by the printer for these actions. - action: command - }; - - return await this.sendControlCommand(Commands.JobControlCmd, payload); - } - - /** - * Sends a command to control the printer's filtration system. - * @param args The filtration arguments specifying internal and external fan states. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - * @private - */ - private async sendFiltrationCommand(args: FiltrationArgs): Promise { - return await this.sendControlCommand(Commands.CirculationControlCmd, args); + } + + /** + * Sends a command to control various printer settings during a print. + * This includes Z-axis offset, print speed, chamber fan speed, and cooling fan speed. + * It prevents fan activation during the initial layers of a print. + * Throws an error if no print job is active. + * + * @param options An object containing the control parameters. + * @param options.zOffset The Z-axis compensation offset. Defaults to 0. + * @param options.printSpeed The print speed percentage. Defaults to 100. + * @param options.chamberFanSpeed The chamber fan speed percentage. Defaults to 100. + * @param options.coolingFanSpeed The cooling fan speed percentage. Defaults to 100. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + * @throws Error if called when the printer is not actively printing. + * @private + */ + private async sendPrinterControlCmd({ + zOffset = 0, + printSpeed = 100, + chamberFanSpeed = 100, + coolingFanSpeed = 100, + }: { + zOffset?: number; + printSpeed?: number; + chamberFanSpeed?: number; + coolingFanSpeed?: number; + }): Promise { + const info = await this.client.info.get(); + + if (!info) { + throw new Error('Unable to retrieve printer information'); } - /** - * Sends a command to control the printer's camera. - * @param enabled True to turn the camera on ("open"), false to turn it off ("close"). - * @returns A Promise that resolves to true if the command is successful, false otherwise. - * @private - */ - private async sendCameraCommand(enabled: boolean): Promise { - const payload = { action: enabled ? "open" : "close" }; - return await this.sendControlCommand(Commands.CameraControlCmd, payload); + if (info.CurrentPrintLayer < 2) { + // Don't accidentally turn on the fans in the initial layers + chamberFanSpeed = 0; + coolingFanSpeed = 0; } - /** - * Checks if the printer is currently printing based on its status information. - * @param info The printer information object. - * @returns True if the printer status is "printing", false otherwise. - * @private - */ - private isPrinting(info: any): boolean { - return info.Status === "printing"; + if (!this.isPrinting(info)) { + throw new Error('Attempted to send printerCtl_cmd with no active job'); } - /** - * Checks if a generic API response indicates success. - * @param response The generic response object. - * @returns True if the response code indicates success, false otherwise. - * @private - */ - private isResponseOk(response: GenericResponse): boolean { - return NetworkUtils.isOk(response); - } + const payload = { + zAxisCompensation: zOffset, + speed: printSpeed, + chamberFan: chamberFanSpeed, + coolingFan: coolingFanSpeed, + coolingLeftFan: 0, // This is unused + }; + + return await this.sendControlCommand(Commands.PrinterControlCmd, payload); + } + + public async sendJobControlCmd(command: string): Promise { + const payload = { + jobID: '', // jobID seems to be optional or not strictly enforced by the printer for these actions. + action: command, + }; + + return await this.sendControlCommand(Commands.JobControlCmd, payload); + } + + /** + * Sends a command to control the printer's filtration system. + * @param args The filtration arguments specifying internal and external fan states. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + * @private + */ + private async sendFiltrationCommand(args: FiltrationArgs): Promise { + return await this.sendControlCommand(Commands.CirculationControlCmd, args); + } + + /** + * Sends a command to control the printer's camera. + * @param enabled True to turn the camera on ("open"), false to turn it off ("close"). + * @returns A Promise that resolves to true if the command is successful, false otherwise. + * @private + */ + private async sendCameraCommand(enabled: boolean): Promise { + const payload = { action: enabled ? 'open' : 'close' }; + return await this.sendControlCommand(Commands.CameraControlCmd, payload); + } + + /** + * Checks if the printer is currently printing based on its status information. + * @param info The printer information object. + * @returns True if the printer status is "printing", false otherwise. + * @private + */ + private isPrinting(info: PrinterStatusInfo): boolean { + return info.Status === 'printing'; + } + + /** + * Checks if a generic API response indicates success. + * @param response The generic response object. + * @returns True if the response code indicates success, false otherwise. + * @private + */ + private isResponseOk(response: GenericResponse): boolean { + return NetworkUtils.isOk(response); + } } /** @@ -362,20 +374,20 @@ export class Control { * Specifies the desired state (on/off) for internal and external fans. */ export class FiltrationArgs { - /** State of the internal fan ("open" or "close"). */ - internal: string; - /** State of the external fan ("open" or "close"). */ - external: string; - - /** - * Creates an instance of FiltrationArgs. - * @param i True to set the internal fan to "open", false for "close". - * @param e True to set the external fan to "open", false for "close". - */ - constructor(i: boolean, e: boolean) { - this.internal = i ? "open" : "close"; - this.external = e ? "open" : "close"; - } + /** State of the internal fan ("open" or "close"). */ + internal: string; + /** State of the external fan ("open" or "close"). */ + external: string; + + /** + * Creates an instance of FiltrationArgs. + * @param i True to set the internal fan to "open", false for "close". + * @param e True to set the external fan to "open", false for "close". + */ + constructor(i: boolean, e: boolean) { + this.internal = i ? 'open' : 'close'; + this.external = e ? 'open' : 'close'; + } } /** @@ -383,8 +395,8 @@ export class FiltrationArgs { * Typically used to indicate the success or failure of a command. */ export interface GenericResponse { - /** The response code. A code of 0 or 200 usually indicates success. */ - code: number; - /** A message accompanying the response code, often empty or "ok" for success. */ - message: string; -} \ No newline at end of file + /** The response code. A code of 0 or 200 usually indicates success. */ + code: number; + /** A message accompanying the response code, often empty or "ok" for success. */ + message: string; +} diff --git a/src/api/controls/Files.test.ts b/src/api/controls/Files.test.ts index 5c30414..f75efa3 100644 --- a/src/api/controls/Files.test.ts +++ b/src/api/controls/Files.test.ts @@ -2,155 +2,191 @@ * @fileoverview Unit tests for Files module. * Tests file listing and thumbnail retrieval with AD5X and legacy printer format support using mocked HTTP responses. */ + import axios from 'axios'; -import { FiveMClient } from '../../FiveMClient'; -import { Files } from './Files'; -import { Endpoints } from '../server/Endpoints'; -import { FFGcodeFileEntry, FFGcodeToolData } from '../../models/ff-models'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FFGcodeFileEntry } from '../../models/ff-models'; import { NetworkUtils } from '../network/NetworkUtils'; +import { Files } from './Files'; // Mock FiveMClient and its dependencies as needed -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +vi.mock('axios'); +const mockedAxios = axios as typeof axios & { + post: ReturnType; +}; // Mock NetworkUtils.isOk -jest.mock('../network/NetworkUtils', () => ({ - NetworkUtils: { - isOk: jest.fn(), - }, +vi.mock('../network/NetworkUtils', () => ({ + NetworkUtils: { + isOk: vi.fn(), + }, })); -const mockedNetworkUtils = NetworkUtils as jest.Mocked; - +const mockedNetworkUtils = NetworkUtils as typeof NetworkUtils & { + isOk: ReturnType; +}; describe('Files Control', () => { - let mockFiveMClient: FiveMClient; - let filesControl: Files; + let mockFiveMClient: FiveMClient; + let filesControl: Files; + + beforeEach(() => { + // Reset mocks before each test + mockedAxios.post.mockReset(); + mockedNetworkUtils.isOk.mockReset(); + + // Setup default mock behavior + mockedNetworkUtils.isOk.mockImplementation((response: any) => response && response.code === 0); + + // A very basic mock for FiveMClient, only providing what Files control needs + mockFiveMClient = { + getEndpoint: (endpoint: string) => `http://fakeprinter:8898${endpoint}`, + serialNumber: 'testSN', + checkCode: 'testCC', + } as FiveMClient; // Cast to FiveMClient, add more properties if Files uses them + + filesControl = new Files(mockFiveMClient); + }); + + describe('getRecentFileList', () => { + const ad5xGcodeListResponse: FFGcodeFileEntry[] = [ + { + gcodeFileName: 'FISH_PLA.3mf', + gcodeToolCnt: 4, + gcodeToolDatas: [ + { + filamentWeight: 3.28, + materialColor: '#FFFF00', + materialName: 'PLA', + slotId: 0, + toolId: 0, + }, + { + filamentWeight: 0.51, + materialColor: '#FFFF00', + materialName: 'PLA', + slotId: 0, + toolId: 1, + }, + { + filamentWeight: 18.1, + materialColor: '#FF0000', + materialName: 'PLA', + slotId: 0, + toolId: 2, + }, + { + filamentWeight: 6.15, + materialColor: '#FF8040', + materialName: 'PLA', + slotId: 0, + toolId: 3, + }, + ], + printingTime: 29958, + totalFilamentWeight: 28.04, + useMatlStation: true, + }, + { + gcodeFileName: 'FlashForge-TestModel-01.3mf', + gcodeToolCnt: 2, + gcodeToolDatas: [ + { + filamentWeight: 3.46, + materialColor: '#FFFFFF', + materialName: 'PLA', + slotId: 0, + toolId: 0, + }, + { + filamentWeight: 0.26, + materialColor: '#0000FF', + materialName: 'PLA', + slotId: 0, + toolId: 1, + }, + ], + printingTime: 849, + totalFilamentWeight: 3.73, + useMatlStation: true, + }, + ]; + + const olderPrinterGcodeListResponse: string[] = ['test_file1.gcode', 'another_print.gcode']; + + it('should correctly parse AD5X-style detailed G-code list', async () => { + mockedAxios.post.mockResolvedValue({ + status: 200, + data: { code: 0, message: 'Success', gcodeList: ad5xGcodeListResponse }, + }); + mockedNetworkUtils.isOk.mockReturnValue(true); + + const result = await filesControl.getRecentFileList(); + + expect(result).toHaveLength(2); + expect(result[0].gcodeFileName).toBe('FISH_PLA.3mf'); + expect(result[0].gcodeToolCnt).toBe(4); + expect(result[0].gcodeToolDatas).toHaveLength(4); + expect(result[0].gcodeToolDatas?.[2].materialColor).toBe('#FF0000'); + expect(result[0].totalFilamentWeight).toBe(28.04); + expect(result[0].useMatlStation).toBe(true); + expect(result[1].gcodeFileName).toBe('FlashForge-TestModel-01.3mf'); + }); - beforeEach(() => { - // Reset mocks before each test - mockedAxios.post.mockReset(); - mockedNetworkUtils.isOk.mockReset(); + it('should correctly parse older printer string-array G-code list', async () => { + mockedAxios.post.mockResolvedValue({ + status: 200, + data: { code: 0, message: 'Success', gcodeList: olderPrinterGcodeListResponse }, + }); + mockedNetworkUtils.isOk.mockReturnValue(true); - // Setup default mock behavior - mockedNetworkUtils.isOk.mockImplementation((response: any) => response && response.code === 0); + const result = await filesControl.getRecentFileList(); + expect(result).toHaveLength(2); + expect(result[0].gcodeFileName).toBe('test_file1.gcode'); + expect(result[0].printingTime).toBe(0); // Defaulted + expect(result[0].gcodeToolDatas).toBeUndefined(); + expect(result[1].gcodeFileName).toBe('another_print.gcode'); + }); - // A very basic mock for FiveMClient, only providing what Files control needs - mockFiveMClient = { - getEndpoint: (endpoint: string) => `http://fakeprinter:8898${endpoint}`, - serialNumber: 'testSN', - checkCode: 'testCC', - } as FiveMClient; // Cast to FiveMClient, add more properties if Files uses them + it('should return an empty array for an empty G-code list', async () => { + mockedAxios.post.mockResolvedValue({ + status: 200, + data: { code: 0, message: 'Success', gcodeList: [] }, + }); + mockedNetworkUtils.isOk.mockReturnValue(true); - filesControl = new Files(mockFiveMClient); + const result = await filesControl.getRecentFileList(); + expect(result).toHaveLength(0); }); - describe('getRecentFileList', () => { - const ad5xGcodeListResponse: FFGcodeFileEntry[] = [ - { - "gcodeFileName": "FISH_PLA.3mf", - "gcodeToolCnt": 4, - "gcodeToolDatas": [ - { "filamentWeight": 3.28, "materialColor": "#FFFF00", "materialName": "PLA", "slotId": 0, "toolId": 0 }, - { "filamentWeight": 0.51, "materialColor": "#FFFF00", "materialName": "PLA", "slotId": 0, "toolId": 1 }, - { "filamentWeight": 18.10, "materialColor": "#FF0000", "materialName": "PLA", "slotId": 0, "toolId": 2 }, - { "filamentWeight": 6.15, "materialColor": "#FF8040", "materialName": "PLA", "slotId": 0, "toolId": 3 } - ], - "printingTime": 29958, - "totalFilamentWeight": 28.04, - "useMatlStation": true - }, - { - "gcodeFileName": "FlashForge-TestModel-01.3mf", - "gcodeToolCnt": 2, - "gcodeToolDatas": [ - { "filamentWeight": 3.46, "materialColor": "#FFFFFF", "materialName": "PLA", "slotId": 0, "toolId": 0 }, - { "filamentWeight": 0.26, "materialColor": "#0000FF", "materialName": "PLA", "slotId": 0, "toolId": 1 } - ], - "printingTime": 849, - "totalFilamentWeight": 3.73, - "useMatlStation": true - } - ]; - - const olderPrinterGcodeListResponse: string[] = [ - "test_file1.gcode", - "another_print.gcode" - ]; - - it('should correctly parse AD5X-style detailed G-code list', async () => { - mockedAxios.post.mockResolvedValue({ - status: 200, - data: { code: 0, message: 'Success', gcodeList: ad5xGcodeListResponse } - }); - mockedNetworkUtils.isOk.mockReturnValue(true); - - const result = await filesControl.getRecentFileList(); - - expect(result).toHaveLength(2); - expect(result[0].gcodeFileName).toBe("FISH_PLA.3mf"); - expect(result[0].gcodeToolCnt).toBe(4); - expect(result[0].gcodeToolDatas).toHaveLength(4); - expect(result[0].gcodeToolDatas?.[2].materialColor).toBe("#FF0000"); - expect(result[0].totalFilamentWeight).toBe(28.04); - expect(result[0].useMatlStation).toBe(true); - expect(result[1].gcodeFileName).toBe("FlashForge-TestModel-01.3mf"); - }); - - it('should correctly parse older printer string-array G-code list', async () => { - mockedAxios.post.mockResolvedValue({ - status: 200, - data: { code: 0, message: 'Success', gcodeList: olderPrinterGcodeListResponse } - }); - mockedNetworkUtils.isOk.mockReturnValue(true); - - const result = await filesControl.getRecentFileList(); - - expect(result).toHaveLength(2); - expect(result[0].gcodeFileName).toBe("test_file1.gcode"); - expect(result[0].printingTime).toBe(0); // Defaulted - expect(result[0].gcodeToolDatas).toBeUndefined(); - expect(result[1].gcodeFileName).toBe("another_print.gcode"); - }); - - it('should return an empty array for an empty G-code list', async () => { - mockedAxios.post.mockResolvedValue({ - status: 200, - data: { code: 0, message: 'Success', gcodeList: [] } - }); - mockedNetworkUtils.isOk.mockReturnValue(true); - - const result = await filesControl.getRecentFileList(); - expect(result).toHaveLength(0); - }); - - it('should return an empty array if API response is not OK', async () => { - mockedAxios.post.mockResolvedValue({ - status: 200, - data: { code: 1, message: 'Error from printer', gcodeList: [] } - }); - mockedNetworkUtils.isOk.mockReturnValue(false); // Simulate NetworkUtils.isOk returning false - - const result = await filesControl.getRecentFileList(); - expect(result).toHaveLength(0); - }); - - it('should return an empty array if HTTP status is not 200', async () => { - mockedAxios.post.mockResolvedValue({ - status: 500, - data: {} // Data doesn't matter here - }); - // NetworkUtils.isOk won't even be called if status is not 200 - - const result = await filesControl.getRecentFileList(); - expect(result).toHaveLength(0); - }); - - it('should return an empty array on axios POST error', async () => { - mockedAxios.post.mockRejectedValue(new Error('Network Error')); - - const result = await filesControl.getRecentFileList(); - expect(result).toHaveLength(0); - }); + it('should return an empty array if API response is not OK', async () => { + mockedAxios.post.mockResolvedValue({ + status: 200, + data: { code: 1, message: 'Error from printer', gcodeList: [] }, + }); + mockedNetworkUtils.isOk.mockReturnValue(false); // Simulate NetworkUtils.isOk returning false + + const result = await filesControl.getRecentFileList(); + expect(result).toHaveLength(0); + }); + + it('should return an empty array if HTTP status is not 200', async () => { + mockedAxios.post.mockResolvedValue({ + status: 500, + data: {}, // Data doesn't matter here + }); + // NetworkUtils.isOk won't even be called if status is not 200 + + const result = await filesControl.getRecentFileList(); + expect(result).toHaveLength(0); + }); + + it('should return an empty array on axios POST error', async () => { + mockedAxios.post.mockRejectedValue(new Error('Network Error')); + + const result = await filesControl.getRecentFileList(); + expect(result).toHaveLength(0); }); + }); }); diff --git a/src/api/controls/Files.ts b/src/api/controls/Files.ts index 7c60166..1dd5e09 100644 --- a/src/api/controls/Files.ts +++ b/src/api/controls/Files.ts @@ -3,159 +3,150 @@ * Handles file operations including listing local and recent print files, and retrieving G-code thumbnails via HTTP endpoints. */ // src/api/controls/Files.ts -import { FiveMClient } from '../../FiveMClient'; -import { FFGcodeFileEntry } from '../../models/ff-models'; // Import the new model -import { Endpoints } from '../server/Endpoints'; + import axios from 'axios'; -import { GenericResponse } from './Control'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FFGcodeFileEntry } from '../../models/ff-models'; // Import the new model import { NetworkUtils } from '../network/NetworkUtils'; +import { Endpoints } from '../server/Endpoints'; +import type { GenericResponse } from './Control'; /** * Provides methods for managing files on the FlashForge 3D printer. * This includes listing local and recent files, and retrieving G-code thumbnails. */ export class Files { - private client: FiveMClient; - - /** - * Creates an instance of the Files class. - * @param printerClient The FiveMClient instance used for communication with the printer. - */ - constructor(printerClient: FiveMClient) { - this.client = printerClient; - } - - /** - * Retrieves a list of all G-code files stored locally on the printer via TCP. - * @returns A Promise that resolves to an array of file names (strings). - */ - public async getLocalFileList(): Promise { - return await this.client.tcpClient.getFileListAsync(); - } - - /** - * Retrieves a list of the 10 most recently printed files from the printer's API. - * For AD5X and newer printers, returns detailed file entries with material info. - * For older printers, returns basic file entries with normalized data. - * @returns A Promise that resolves to an array of `FFGcodeFileEntry` objects. - * Returns an empty array if the request fails or an error occurs. - */ - public async getRecentFileList(): Promise { - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode - }; - - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.GCodeList), - payload, - { headers: { 'Content-Type': 'application/json' } } - ); - - if (response.status !== 200) return []; - - const result = response.data as GCodeListResponse; - - if (!NetworkUtils.isOk(result)) { - console.log(`Error retrieving file list: ${result.message || 'Unknown error'}`); - return []; - } - - // AD5X and newer printers provide detailed info in gcodeListDetail - if (result.gcodeListDetail && result.gcodeListDetail.length > 0) { - return result.gcodeListDetail; - } - - // Fallback for older printers using gcodeList - if (result.gcodeList?.length > 0) { - const firstItem = result.gcodeList[0]; - - if (typeof firstItem === 'string') { - // Convert string array to FFGcodeFileEntry objects - return (result.gcodeList as string[]).map(fileName => ({ - gcodeFileName: fileName, - printingTime: 0 - })); - } else { - // Already FFGcodeFileEntry objects - return result.gcodeList as FFGcodeFileEntry[]; - } - } - - return []; - } catch (error: unknown) { - const err = error as Error; - console.log(`GetRecentFileList error: ${err.message}\n${err.stack}`); - return []; + private client: FiveMClient; + + /** + * Creates an instance of the Files class. + * @param printerClient The FiveMClient instance used for communication with the printer. + */ + constructor(printerClient: FiveMClient) { + this.client = printerClient; + } + + /** + * Retrieves a list of all G-code files stored locally on the printer via TCP. + * @returns A Promise that resolves to an array of file names (strings). + */ + public async getLocalFileList(): Promise { + return await this.client.tcpClient.getFileListAsync(); + } + + /** + * Retrieves a list of the 10 most recently printed files from the printer's API. + * For AD5X and newer printers, returns detailed file entries with material info. + * For older printers, returns basic file entries with normalized data. + * @returns A Promise that resolves to an array of `FFGcodeFileEntry` objects. + * Returns an empty array if the request fails or an error occurs. + */ + public async getRecentFileList(): Promise { + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + }; + + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.GCodeList), payload, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.status !== 200) return []; + + const result = response.data as GCodeListResponse; + + if (!NetworkUtils.isOk(result)) { + console.log(`Error retrieving file list: ${result.message || 'Unknown error'}`); + return []; + } + + // AD5X and newer printers provide detailed info in gcodeListDetail + if (result.gcodeListDetail && result.gcodeListDetail.length > 0) { + return result.gcodeListDetail; + } + + // Fallback for older printers using gcodeList + if (result.gcodeList?.length > 0) { + const firstItem = result.gcodeList[0]; + + if (typeof firstItem === 'string') { + // Convert string array to FFGcodeFileEntry objects + return (result.gcodeList as string[]).map((fileName) => ({ + gcodeFileName: fileName, + printingTime: 0, + })); + } else { + // Already FFGcodeFileEntry objects + return result.gcodeList as FFGcodeFileEntry[]; } - } + } - /** - * Retrieves the thumbnail image for a specified G-code file. - * The image data is returned as a Buffer. - * - * @param fileName The name of the G-code file (e.g., "my_print.gcode") for which to retrieve the thumbnail. - * @returns A Promise that resolves to a Buffer containing the thumbnail image data (in base64 format, then converted to Buffer), - * or null if the request fails, the file has no thumbnail, or an error occurs. - */ - public async getGCodeThumbnail(fileName: string): Promise { - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - fileName - }; - - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.GCodeThumb), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - if (response.status !== 200) return null; - - const result = response.data as ThumbnailResponse; - if (NetworkUtils.isOk(result)) { - return Buffer.from(result.imageData, 'base64'); - } - - console.log(`Error retrieving thumbnail: ${result.message}`); - return null; - } catch (error: unknown) { - const err = error as Error; - console.log(`GetGcodeThumbnail error: ${err.message}\n${err.stack}`); - return null; - } + return []; + } catch (error: unknown) { + const err = error as Error; + console.log(`GetRecentFileList error: ${err.message}\n${err.stack}`); + return []; } + } + + /** + * Retrieves the thumbnail image for a specified G-code file. + * The image data is returned as a Buffer. + * + * @param fileName The name of the G-code file (e.g., "my_print.gcode") for which to retrieve the thumbnail. + * @returns A Promise that resolves to a Buffer containing the thumbnail image data (in base64 format, then converted to Buffer), + * or null if the request fails, the file has no thumbnail, or an error occurs. + */ + public async getGCodeThumbnail(fileName: string): Promise { + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileName, + }; + + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.GCodeThumb), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status !== 200) return null; + + const result = response.data as ThumbnailResponse; + if (NetworkUtils.isOk(result)) { + return Buffer.from(result.imageData, 'base64'); + } + + console.log(`Error retrieving thumbnail: ${result.message}`); + return null; + } catch (error: unknown) { + const err = error as Error; + console.log(`GetGcodeThumbnail error: ${err.message}\n${err.stack}`); + return null; + } + } } // Updated GCodeListResponse to reflect that gcodeList can be string[] or FFGcodeFileEntry[] interface GCodeListResponse extends GenericResponse { - gcodeList: string[] | FFGcodeFileEntry[]; - gcodeListDetail?: FFGcodeFileEntry[]; // AD5X and newer printers provide detailed info here + gcodeList: string[] | FFGcodeFileEntry[]; + gcodeListDetail?: FFGcodeFileEntry[]; // AD5X and newer printers provide detailed info here } -interface ThumbnailResponse extends GenericResponse { - imageData: string; -} /** * Represents the response structure for a G-code file list request. * @interface GCodeListResponse * @extends GenericResponse */ - /** * Represents the response structure for a G-code thumbnail request. * @interface ThumbnailResponse * @extends GenericResponse */ interface ThumbnailResponse extends GenericResponse { - /** The thumbnail image data encoded as a base64 string. */ - imageData: string; -} \ No newline at end of file + /** The thumbnail image data encoded as a base64 string. */ + imageData: string; +} diff --git a/src/api/controls/Info.test.ts b/src/api/controls/Info.test.ts index 7ee3e1a..5bea44c 100644 --- a/src/api/controls/Info.test.ts +++ b/src/api/controls/Info.test.ts @@ -2,14 +2,18 @@ * @fileoverview Unit tests for Info module. * Tests printer information retrieval, status checking, and machine state transformation using mocked HTTP responses. */ + import axios from 'axios'; -import { Info } from './Info'; -import { FiveMClient } from '../../FiveMClient'; -import { MachineState, FFPrinterDetail } from '../../models/ff-models'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FiveMClient } from '../../FiveMClient'; +import { type FFPrinterDetail, MachineState } from '../../models/ff-models'; import { Endpoints } from '../server/Endpoints'; +import { Info } from './Info'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +vi.mock('axios'); +const mockedAxios = axios as typeof axios & { + post: ReturnType; +}; describe('Info', () => { let mockClient: FiveMClient; @@ -21,7 +25,7 @@ describe('Info', () => { mockClient = { getEndpoint: (endpoint: string) => `http://printer:8898${endpoint}`, serialNumber: 'SN123456', - checkCode: 'CC123456' + checkCode: 'CC123456', } as FiveMClient; info = new Info(mockClient); @@ -35,13 +39,13 @@ describe('Info', () => { detail: { name: 'FlashForge 5M Pro', firmwareVersion: '1.0.0', - status: 'ready' - } as FFPrinterDetail + status: 'ready', + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.getDetailResponse(); @@ -53,12 +57,12 @@ describe('Info', () => { `http://printer:8898${Endpoints.Detail}`, { serialNumber: 'SN123456', - checkCode: 'CC123456' + checkCode: 'CC123456', }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); }); @@ -66,7 +70,7 @@ describe('Info', () => { it('should return null for non-200 status', async () => { mockedAxios.post.mockResolvedValue({ status: 500, - data: {} + data: {}, }); const result = await info.getDetailResponse(); @@ -93,13 +97,13 @@ describe('Info', () => { firmwareVersion: '1.0.0', status: 'ready', platTemp: 60, - rightTemp: 210 - } as FFPrinterDetail + rightTemp: 210, + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.get(); @@ -125,13 +129,13 @@ describe('Info', () => { message: 'Success', detail: { name: 'FlashForge 5M Pro', - status: 'printing' - } as FFPrinterDetail + status: 'printing', + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.isPrinting(); @@ -145,13 +149,13 @@ describe('Info', () => { message: 'Success', detail: { name: 'FlashForge 5M Pro', - status: 'ready' - } as FFPrinterDetail + status: 'ready', + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.isPrinting(); @@ -175,13 +179,13 @@ describe('Info', () => { message: 'Success', detail: { name: 'FlashForge 5M Pro', - status: 'ready' - } as FFPrinterDetail + status: 'ready', + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.getStatus(); @@ -205,13 +209,13 @@ describe('Info', () => { message: 'Success', detail: { name: 'FlashForge 5M Pro', - status: 'ready' - } as FFPrinterDetail + status: 'ready', + } as FFPrinterDetail, }; mockedAxios.post.mockResolvedValue({ status: 200, - data: mockDetailResponse + data: mockDetailResponse, }); const result = await info.getMachineState(); diff --git a/src/api/controls/Info.ts b/src/api/controls/Info.ts index 50aaeb6..63142ff 100644 --- a/src/api/controls/Info.ts +++ b/src/api/controls/Info.ts @@ -3,109 +3,106 @@ * Fetches printer status, machine state, and detailed information from the detail endpoint, transforming raw responses into structured machine info. */ // src/api/controls/Info.ts -import { FiveMClient } from '../../FiveMClient'; -import { FFPrinterDetail, FFMachineInfo, MachineState } from '../../models/ff-models'; -import { Endpoints } from '../server/Endpoints'; + import axios from 'axios'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FFMachineInfo, FFPrinterDetail, MachineState } from '../../models/ff-models'; import { MachineInfo } from '../../models/MachineInfo'; -import { GenericResponse } from './Control'; +import { Endpoints } from '../server/Endpoints'; +import type { GenericResponse } from './Control'; /** * Provides methods for retrieving various information and status details from the FlashForge 3D printer. * This includes general machine information, printing status, and raw detail responses. */ export class Info { - private client: FiveMClient; - - /** - * Creates an instance of the Info class. - * @param printerClient The FiveMClient instance used for communication with the printer. - */ - constructor(printerClient: FiveMClient) { - this.client = printerClient; - } + private client: FiveMClient; - /** - * Retrieves comprehensive machine information, processed into the `FFMachineInfo` model. - * This method fetches detailed data from the printer and transforms it. - * @returns A Promise that resolves to an `FFMachineInfo` object, or null if an error occurs or no data is returned. - */ - public async get(): Promise { - const detail = await this.getDetailResponse(); - return detail ? new MachineInfo().fromDetail(detail.detail) : null; - } + /** + * Creates an instance of the Info class. + * @param printerClient The FiveMClient instance used for communication with the printer. + */ + constructor(printerClient: FiveMClient) { + this.client = printerClient; + } - /** - * Checks if the printer is currently in the "printing" state. - * @returns A Promise that resolves to true if the printer is printing, false otherwise or if status cannot be determined. - */ - public async isPrinting(): Promise { - const info = await this.get(); - return info?.Status === "printing" || false; - } + /** + * Retrieves comprehensive machine information, processed into the `FFMachineInfo` model. + * This method fetches detailed data from the printer and transforms it. + * @returns A Promise that resolves to an `FFMachineInfo` object, or null if an error occurs or no data is returned. + */ + public async get(): Promise { + const detail = await this.getDetailResponse(); + return detail ? new MachineInfo().fromDetail(detail.detail) : null; + } - /** - * Retrieves the raw status string of the printer (e.g., "ready", "printing", "error"). - * @returns A Promise that resolves to the status string, or null if it cannot be determined. - */ - public async getStatus(): Promise { - const info = await this.get(); - return info?.Status ?? null; - } + /** + * Checks if the printer is currently in the "printing" state. + * @returns A Promise that resolves to true if the printer is printing, false otherwise or if status cannot be determined. + */ + public async isPrinting(): Promise { + const info = await this.get(); + return info?.Status === 'printing' || false; + } - /** - * Retrieves the machine state as a `MachineState` enum value. - * @returns A Promise that resolves to a `MachineState` enum value, or null if it cannot be determined. - */ - public async getMachineState(): Promise { - const info = await this.get(); - return info?.MachineState ?? null; - } + /** + * Retrieves the raw status string of the printer (e.g., "ready", "printing", "error"). + * @returns A Promise that resolves to the status string, or null if it cannot be determined. + */ + public async getStatus(): Promise { + const info = await this.get(); + return info?.Status ?? null; + } - /** - * Retrieves the raw detailed response from the printer's detail endpoint. - * This contains a wealth of information about the printer's current state. - * - * @returns A Promise that resolves to a `DetailResponse` object containing the raw printer details, - * or null if the request fails or an error occurs. - */ - public async getDetailResponse(): Promise { - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode - }; + /** + * Retrieves the machine state as a `MachineState` enum value. + * @returns A Promise that resolves to a `MachineState` enum value, or null if it cannot be determined. + */ + public async getMachineState(): Promise { + const info = await this.get(); + return info?.MachineState ?? null; + } - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.Detail), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); + /** + * Retrieves the raw detailed response from the printer's detail endpoint. + * This contains a wealth of information about the printer's current state. + * + * @returns A Promise that resolves to a `DetailResponse` object containing the raw printer details, + * or null if the request fails or an error occurs. + */ + public async getDetailResponse(): Promise { + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + }; - if (response.status !== 200) { - console.log("Non-200 status from detail endpoint:", response.status); - return null; - } + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.Detail), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.status !== 200) { + console.log('Non-200 status from detail endpoint:', response.status); + return null; + } - return response.data as DetailResponse; - } catch (error: unknown) { - const err = error as Error; - console.log(`GetDetailResponse Request error: ${err.message}`); - if ('cause' in err) { - console.log(`GetDetailResponse Inner exception: ${(err as any).cause}`); - } - return null; - } + return response.data as DetailResponse; + } catch (error: unknown) { + const err = error as Error; + console.log(`GetDetailResponse Request error: ${err.message}`); + if ('cause' in err) { + const errorWithCause = err as Error & { cause?: unknown }; + console.log(`GetDetailResponse Inner exception: ${errorWithCause.cause}`); + } + return null; } + } } export interface DetailResponse extends GenericResponse { - detail: FFPrinterDetail; + detail: FFPrinterDetail; } /** @@ -114,6 +111,6 @@ export interface DetailResponse extends GenericResponse { * @extends GenericResponse */ export interface DetailResponse extends GenericResponse { - /** The detailed printer information object (`FFPrinterDetail`). */ - detail: FFPrinterDetail; -} \ No newline at end of file + /** The detailed printer information object (`FFPrinterDetail`). */ + detail: FFPrinterDetail; +} diff --git a/src/api/controls/JobControl.test.ts b/src/api/controls/JobControl.test.ts index f578f81..5b9ffde 100644 --- a/src/api/controls/JobControl.test.ts +++ b/src/api/controls/JobControl.test.ts @@ -2,29 +2,36 @@ * @fileoverview Unit tests for JobControl module. * Tests print job operations, file uploads with firmware-specific handling, and AD5X multi-color job validation using mocked HTTP clients. */ + import axios from 'axios'; -import { JobControl } from './JobControl'; -import { FiveMClient } from '../../FiveMClient'; -import { Control } from './Control'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FiveMClient } from '../../FiveMClient'; +import type { AD5XMaterialMapping } from '../../models/ff-models'; import { Endpoints } from '../server/Endpoints'; -import { AD5XMaterialMapping } from '../../models/ff-models'; +import type { Control } from './Control'; +import { JobControl } from './JobControl'; -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; +vi.mock('axios'); +const mockedAxios = axios as typeof axios & { + post: ReturnType; +}; -jest.mock('./Control'); +vi.mock('./Control'); describe('JobControl', () => { let mockFiveMClient: FiveMClient; - let mockControl: jest.Mocked; + let mockControl: Control & { + sendJobControlCmd: ReturnType; + sendControlCommand: ReturnType; + }; let jobControl: JobControl; beforeEach(() => { mockedAxios.post.mockReset(); mockControl = { - sendJobControlCmd: jest.fn().mockResolvedValue(true), - sendControlCommand: jest.fn().mockResolvedValue(true) + sendJobControlCmd: vi.fn().mockResolvedValue(true), + sendControlCommand: vi.fn().mockResolvedValue(true), } as any; mockFiveMClient = { @@ -33,7 +40,7 @@ describe('JobControl', () => { checkCode: 'CC123456', firmVer: '3.1.3', isAD5X: false, - getEndpoint: (endpoint: string) => `http://printer:8898${endpoint}` + getEndpoint: (endpoint: string) => `http://printer:8898${endpoint}`, } as FiveMClient; jobControl = new JobControl(mockFiveMClient); @@ -95,10 +102,9 @@ describe('JobControl', () => { const result = await jobControl.clearPlatform(); expect(result).toBe(true); - expect(mockControl.sendControlCommand).toHaveBeenCalledWith( - 'stateCtrl_cmd', - { action: 'setClearPlatform' } - ); + expect(mockControl.sendControlCommand).toHaveBeenCalledWith('stateCtrl_cmd', { + action: 'setClearPlatform', + }); }); it('should return false when control command fails', async () => { @@ -116,7 +122,7 @@ describe('JobControl', () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await jobControl.printLocalFile('test.gcode', true); @@ -132,12 +138,12 @@ describe('JobControl', () => { flowCalibration: false, useMatlStation: false, gcodeToolCnt: 0, - materialMappings: [] + materialMappings: [], }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); }); @@ -147,7 +153,7 @@ describe('JobControl', () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await jobControl.printLocalFile('test.gcode', false); @@ -159,12 +165,12 @@ describe('JobControl', () => { serialNumber: 'SN123456', checkCode: 'CC123456', fileName: 'test.gcode', - levelingBeforePrint: false + levelingBeforePrint: false, }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); }); @@ -172,7 +178,7 @@ describe('JobControl', () => { it('should return false for non-200 status', async () => { mockedAxios.post.mockResolvedValue({ status: 500, - data: {} + data: {}, }); const result = await jobControl.printLocalFile('test.gcode', true); @@ -183,7 +189,7 @@ describe('JobControl', () => { it('should return false for non-OK response', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 1, message: 'Error' } + data: { code: 1, message: 'Error' }, }); const result = await jobControl.printLocalFile('test.gcode', true); @@ -194,8 +200,7 @@ describe('JobControl', () => { it('should throw error on network failure', async () => { mockedAxios.post.mockRejectedValue(new Error('Network error')); - await expect(jobControl.printLocalFile('test.gcode', true)) - .rejects.toThrow('Network error'); + await expect(jobControl.printLocalFile('test.gcode', true)).rejects.toThrow('Network error'); }); }); @@ -207,12 +212,12 @@ describe('JobControl', () => { it('should start single color job on AD5X printer', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await jobControl.startAD5XSingleColorJob({ fileName: 'test.3mf', - levelingBeforePrint: true + levelingBeforePrint: true, }); expect(result).toBe(true); @@ -228,12 +233,12 @@ describe('JobControl', () => { timeLapseVideo: false, useMatlStation: false, gcodeToolCnt: 0, - materialMappings: [] + materialMappings: [], }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); }); @@ -243,7 +248,7 @@ describe('JobControl', () => { const result = await jobControl.startAD5XSingleColorJob({ fileName: 'test.3mf', - levelingBeforePrint: true + levelingBeforePrint: true, }); expect(result).toBe(false); @@ -253,7 +258,7 @@ describe('JobControl', () => { it('should return false for empty file name', async () => { const result = await jobControl.startAD5XSingleColorJob({ fileName: '', - levelingBeforePrint: true + levelingBeforePrint: true, }); expect(result).toBe(false); @@ -272,27 +277,27 @@ describe('JobControl', () => { slotId: 1, materialName: 'PLA', toolMaterialColor: '#FF0000', - slotMaterialColor: '#FF0000' + slotMaterialColor: '#FF0000', }, { toolId: 1, slotId: 2, materialName: 'PLA', toolMaterialColor: '#00FF00', - slotMaterialColor: '#00FF00' - } + slotMaterialColor: '#00FF00', + }, ]; it('should start multi-color job on AD5X printer', async () => { mockedAxios.post.mockResolvedValue({ status: 200, - data: { code: 0, message: 'Success' } + data: { code: 0, message: 'Success' }, }); const result = await jobControl.startAD5XMultiColorJob({ fileName: 'multicolor.3mf', levelingBeforePrint: true, - materialMappings: validMaterialMappings + materialMappings: validMaterialMappings, }); expect(result).toBe(true); @@ -308,12 +313,12 @@ describe('JobControl', () => { timeLapseVideo: false, useMatlStation: true, gcodeToolCnt: 2, - materialMappings: validMaterialMappings + materialMappings: validMaterialMappings, }, { headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, } ); }); @@ -324,7 +329,7 @@ describe('JobControl', () => { const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: validMaterialMappings + materialMappings: validMaterialMappings, }); expect(result).toBe(false); @@ -335,7 +340,7 @@ describe('JobControl', () => { const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: [] + materialMappings: [], }); expect(result).toBe(false); @@ -349,14 +354,14 @@ describe('JobControl', () => { slotId: 1, materialName: 'PLA', toolMaterialColor: '#FF0000', - slotMaterialColor: '#FF0000' - } + slotMaterialColor: '#FF0000', + }, ]; const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: invalidMappings + materialMappings: invalidMappings, }); expect(result).toBe(false); @@ -369,14 +374,14 @@ describe('JobControl', () => { slotId: 5, // Invalid: must be 1-4 materialName: 'PLA', toolMaterialColor: '#FF0000', - slotMaterialColor: '#FF0000' - } + slotMaterialColor: '#FF0000', + }, ]; const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: invalidMappings + materialMappings: invalidMappings, }); expect(result).toBe(false); @@ -389,14 +394,14 @@ describe('JobControl', () => { slotId: 1, materialName: 'PLA', toolMaterialColor: 'red', // Invalid: must be #RRGGBB - slotMaterialColor: '#FF0000' - } + slotMaterialColor: '#FF0000', + }, ]; const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: invalidMappings + materialMappings: invalidMappings, }); expect(result).toBe(false); @@ -404,17 +409,47 @@ describe('JobControl', () => { it('should return false for too many material mappings', async () => { const tooManyMappings: AD5XMaterialMapping[] = [ - { toolId: 0, slotId: 1, materialName: 'PLA', toolMaterialColor: '#FF0000', slotMaterialColor: '#FF0000' }, - { toolId: 1, slotId: 2, materialName: 'PLA', toolMaterialColor: '#00FF00', slotMaterialColor: '#00FF00' }, - { toolId: 2, slotId: 3, materialName: 'PLA', toolMaterialColor: '#0000FF', slotMaterialColor: '#0000FF' }, - { toolId: 3, slotId: 4, materialName: 'PLA', toolMaterialColor: '#FFFF00', slotMaterialColor: '#FFFF00' }, - { toolId: 4, slotId: 1, materialName: 'PLA', toolMaterialColor: '#FF00FF', slotMaterialColor: '#FF00FF' } // 5th mapping - too many + { + toolId: 0, + slotId: 1, + materialName: 'PLA', + toolMaterialColor: '#FF0000', + slotMaterialColor: '#FF0000', + }, + { + toolId: 1, + slotId: 2, + materialName: 'PLA', + toolMaterialColor: '#00FF00', + slotMaterialColor: '#00FF00', + }, + { + toolId: 2, + slotId: 3, + materialName: 'PLA', + toolMaterialColor: '#0000FF', + slotMaterialColor: '#0000FF', + }, + { + toolId: 3, + slotId: 4, + materialName: 'PLA', + toolMaterialColor: '#FFFF00', + slotMaterialColor: '#FFFF00', + }, + { + toolId: 4, + slotId: 1, + materialName: 'PLA', + toolMaterialColor: '#FF00FF', + slotMaterialColor: '#FF00FF', + }, // 5th mapping - too many ]; const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: tooManyMappings + materialMappings: tooManyMappings, }); expect(result).toBe(false); @@ -427,14 +462,14 @@ describe('JobControl', () => { slotId: 1, materialName: '', // Empty toolMaterialColor: '#FF0000', - slotMaterialColor: '#FF0000' - } + slotMaterialColor: '#FF0000', + }, ]; const result = await jobControl.startAD5XMultiColorJob({ fileName: 'test.3mf', levelingBeforePrint: true, - materialMappings: invalidMappings + materialMappings: invalidMappings, }); expect(result).toBe(false); diff --git a/src/api/controls/JobControl.ts b/src/api/controls/JobControl.ts index 9a32731..cdb7776 100644 --- a/src/api/controls/JobControl.ts +++ b/src/api/controls/JobControl.ts @@ -3,15 +3,21 @@ * Manages print job operations including pause/resume/cancel, file uploads with firmware-specific handling, and AD5X multi-color printing with material station support. */ // src/api/controls/JobControl.ts -import { FiveMClient } from '../../FiveMClient'; -import {Control, GenericResponse} from './Control'; -import { Endpoints } from '../server/Endpoints'; -import { AD5XLocalJobParams, AD5XMaterialMapping, AD5XSingleColorJobParams, AD5XUploadParams } from '../../models/ff-models'; -import * as fs from 'fs'; -import * as path from 'path'; + +import * as fs from 'node:fs'; +import * as path from 'node:path'; import axios from 'axios'; -import { NetworkUtils } from '../network/NetworkUtils'; import FormData from 'form-data'; +import type { FiveMClient } from '../../FiveMClient'; +import type { + AD5XLocalJobParams, + AD5XMaterialMapping, + AD5XSingleColorJobParams, + AD5XUploadParams, +} from '../../models/ff-models'; +import { NetworkUtils } from '../network/NetworkUtils'; +import { Endpoints } from '../server/Endpoints'; +import type { Control, GenericResponse } from './Control'; /** * Provides methods for managing print jobs on the FlashForge 3D printer. @@ -19,560 +25,576 @@ import FormData from 'form-data'; * and starting prints from local files. */ export class JobControl { - private client: FiveMClient; - private control: Control; - - /** - * Creates an instance of the JobControl class. - * @param printerClient The FiveMClient instance used for communication with the printer. - */ - constructor(printerClient: FiveMClient) { - this.client = printerClient; - this.control = printerClient.control; + private client: FiveMClient; + private control: Control; + + /** + * Creates an instance of the JobControl class. + * @param printerClient The FiveMClient instance used for communication with the printer. + */ + constructor(printerClient: FiveMClient) { + this.client = printerClient; + this.control = printerClient.control; + } + + // Basic controls + /** + * Pauses the current print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async pausePrintJob(): Promise { + return await this.control.sendJobControlCmd('pause'); + } + + /** + * Resumes a paused print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async resumePrintJob(): Promise { + return await this.control.sendJobControlCmd('continue'); + } + + /** + * Cancels the current print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async cancelPrintJob(): Promise { + return await this.control.sendJobControlCmd('cancel'); + } + + /** + * Checks if the printer's firmware version is 3.1.3 or newer. + * This is used to determine which API payload format to use for certain commands. + * @returns True if the firmware is new (>= 3.1.3), false otherwise or if version cannot be determined. + * @private + */ + private isNewFirmwareVersion(): boolean { + try { + const currentVersion = this.client.firmVer.split('.'); + const minVersion = [3, 1, 3]; + + for (let i = 0; i < 3; i++) { + const current = parseInt(currentVersion[i] || '0', 10); + if (current > minVersion[i]) return true; + if (current < minVersion[i]) return false; + } + + return true; // Equal versions + } catch { + return false; } - - // Basic controls - /** - * Pauses the current print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async pausePrintJob(): Promise { - return await this.control.sendJobControlCmd("pause"); + } + + /** + * Sends a command to clear the printer's build platform. + * (Note: The exact behavior of "setClearPlatform" might need further clarification from printer documentation, + * it's assumed here it's a command to potentially move the print head out of the way or a similar action.) + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async clearPlatform(): Promise { + const args = { + action: 'setClearPlatform', + }; + + return await this.control.sendControlCommand('stateCtrl_cmd', args); + } + + /** + * Uploads a G-code or 3MF file to the printer and optionally starts printing. + * It handles different API requirements based on the printer's firmware version. + * + * @param filePath The local path to the G-code or 3MF file to upload. + * @param startPrint If true, the printer will start printing the file immediately after upload. + * @param levelBeforePrint If true, the printer will perform bed leveling before starting the print. + * @returns A Promise that resolves to true if the file upload (and optional print start) is successful, false otherwise. + */ + public async uploadFile( + filePath: string, + startPrint: boolean, + levelBeforePrint: boolean + ): Promise { + if (!fs.existsSync(filePath)) { + console.error(`UploadFile error: File not found at ${filePath}`); + return false; } - /** - * Resumes a paused print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async resumePrintJob(): Promise { - return await this.control.sendJobControlCmd("continue"); + const stats = fs.statSync(filePath); + const fileSize = stats.size; + const fileName = path.basename(filePath); + + console.log( + `Starting upload for ${fileName}, Size: ${fileSize}, Start: ${startPrint}, Level: ${levelBeforePrint}` + ); + + try { + // Create FormData with the file content + const form = new FormData(); + form.append('gcodeFile', fs.createReadStream(filePath), { + filename: fileName, + contentType: 'application/octet-stream', // Ensure correct MIME type + }); + + // Prepare the custom HTTP headers with metadata + const customHeaders: Record = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileSize: fileSize.toString(), + printNow: startPrint.toString().toLowerCase(), + levelingBeforePrint: levelBeforePrint.toString().toLowerCase(), + Expect: '100-continue', + }; + + // Add additional headers for new firmware + if (this.isNewFirmwareVersion()) { + console.log('Using new firmware headers for upload.'); + customHeaders['flowCalibration'] = 'false'; + customHeaders['useMatlStation'] = 'false'; + customHeaders['gcodeToolCnt'] = '0'; + // Base64 encode "[]" which is "W10=" + customHeaders['materialMappings'] = 'W10='; + } else { + console.log('Using old firmware headers for upload.'); + } + + // Get necessary headers from FormData + const formHeaders = form.getHeaders(); + + // Combine custom headers and FormData headers + const requestHeaders = { + ...customHeaders, + 'Content-Type': formHeaders['content-type'], + }; + + console.log('Upload Request Headers:', requestHeaders); + + // Configure Axios request + const config = { + headers: requestHeaders, + }; + + // Make the POST request + const response = await axios.post( + this.client.getEndpoint(Endpoints.UploadFile), + form, + config + ); + + console.log(`Upload Response Status: ${response.status}`); + console.log('Upload Response Data:', response.data); // Log the response body + + if (response.status !== 200) { + console.error(`Upload failed: Printer responded with status ${response.status}`); + return false; + } + + // Assuming response.data is already parsed JSON by axios + const result = response.data as GenericResponse; + if (NetworkUtils.isOk(result)) { + console.log('Upload successful according to printer response.'); + return true; + } else { + console.error( + `Upload failed: Printer response code=${result.code}, message=${result.message}` + ); + return false; + } + } catch (error) { + const err = error as Error & { + response?: { status: number; data: GenericResponse }; + request?: unknown; + }; + console.error(`UploadFile error: ${err.message}`); + if (err.response) { + console.error(`Error Status: ${err.response.status}`); + console.error('Error Response Data:', err.response.data); + } else if (err.request) { + console.error('Error Request:', err.request); + } else { + console.error('Error', err.message); + } + console.error(err.stack); + return false; } - - /** - * Cancels the current print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async cancelPrintJob(): Promise { - return await this.control.sendJobControlCmd("cancel"); + } + + /** + * Uploads a G-code or 3MF file to AD5X printer with material station support. + * Handles material mappings, flow calibration, and other AD5X-specific features. + * Material mappings are base64-encoded in HTTP headers according to AD5X API requirements. + * + * @param params AD5X upload parameters including file path, print options, and material mappings + * @returns A Promise that resolves to true if the file upload is successful, false otherwise + */ + public async uploadFileAD5X(params: AD5XUploadParams): Promise { + // Validate that this is an AD5X printer + if (!this.validateAD5XPrinter()) { + return false; } - /** - * Checks if the printer's firmware version is 3.1.3 or newer. - * This is used to determine which API payload format to use for certain commands. - * @returns True if the firmware is new (>= 3.1.3), false otherwise or if version cannot be determined. - * @private - */ - private isNewFirmwareVersion(): boolean { - try { - const currentVersion = this.client.firmVer.split('.'); - const minVersion = [3, 1, 3]; - - for (let i = 0; i < 3; i++) { - const current = parseInt(currentVersion[i] || '0', 10); - if (current > minVersion[i]) return true; - if (current < minVersion[i]) return false; - } - - return true; // Equal versions - } catch { - return false; - } + // Validate material mappings + if (!this.validateMaterialMappings(params.materialMappings)) { + return false; } - /** - * Sends a command to clear the printer's build platform. - * (Note: The exact behavior of "setClearPlatform" might need further clarification from printer documentation, - * it's assumed here it's a command to potentially move the print head out of the way or a similar action.) - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async clearPlatform(): Promise { - const args = { - action: "setClearPlatform" - }; - - return await this.control.sendControlCommand("stateCtrl_cmd", args); + // Validate file exists + if (!fs.existsSync(params.filePath)) { + console.error(`UploadFileAD5X error: File not found at ${params.filePath}`); + return false; } - /** - * Uploads a G-code or 3MF file to the printer and optionally starts printing. - * It handles different API requirements based on the printer's firmware version. - * - * @param filePath The local path to the G-code or 3MF file to upload. - * @param startPrint If true, the printer will start printing the file immediately after upload. - * @param levelBeforePrint If true, the printer will perform bed leveling before starting the print. - * @returns A Promise that resolves to true if the file upload (and optional print start) is successful, false otherwise. - */ - public async uploadFile(filePath: string, startPrint: boolean, levelBeforePrint: boolean): Promise { - if (!fs.existsSync(filePath)) { - console.error(`UploadFile error: File not found at ${filePath}`); - return false; - } - - const stats = fs.statSync(filePath); - const fileSize = stats.size; - const fileName = path.basename(filePath); - - console.log(`Starting upload for ${fileName}, Size: ${fileSize}, Start: ${startPrint}, Level: ${levelBeforePrint}`); - - try { - // Create FormData with the file content - const form = new FormData(); - form.append('gcodeFile', fs.createReadStream(filePath), { - filename: fileName, - contentType: 'application/octet-stream' // Ensure correct MIME type - }); - - // Prepare the custom HTTP headers with metadata - const customHeaders: Record = { - 'serialNumber': this.client.serialNumber, - 'checkCode': this.client.checkCode, - 'fileSize': fileSize.toString(), - 'printNow': startPrint.toString().toLowerCase(), - 'levelingBeforePrint': levelBeforePrint.toString().toLowerCase(), - 'Expect': '100-continue' - }; - - // Add additional headers for new firmware - if (this.isNewFirmwareVersion()) { - console.log("Using new firmware headers for upload."); - customHeaders['flowCalibration'] = 'false'; - customHeaders['useMatlStation'] = 'false'; - customHeaders['gcodeToolCnt'] = '0'; - // Base64 encode "[]" which is "W10=" - customHeaders['materialMappings'] = 'W10='; - } else { - console.log("Using old firmware headers for upload."); - } - - // Get necessary headers from FormData - const formHeaders = form.getHeaders(); - - // Combine custom headers and FormData headers - const requestHeaders = { - ...customHeaders, - 'Content-Type': formHeaders['content-type'], - }; - - console.log("Upload Request Headers:", requestHeaders); - - // Configure Axios request - // @ts-ignore - const config: AxiosRequestConfig = { - headers: requestHeaders, - }; - - // Make the POST request - const response = await axios.post( - this.client.getEndpoint(Endpoints.UploadFile), - form, - config - ); - - console.log(`Upload Response Status: ${response.status}`); - console.log("Upload Response Data:", response.data); // Log the response body - - if (response.status !== 200) { - console.error(`Upload failed: Printer responded with status ${response.status}`); - return false; - } - - // Assuming response.data is already parsed JSON by axios - const result = response.data as any; - if (NetworkUtils.isOk(result)) { - console.log("Upload successful according to printer response."); - return true; - } else { - console.error(`Upload failed: Printer response code=${result.code}, message=${result.message}`); - return false; - } - - } catch (e: any) { - console.error(`UploadFile error: ${e.message}`); - if (e.response) { - console.error(`Error Status: ${e.response.status}`); - console.error("Error Response Data:", e.response.data); - } else if (e.request) { - console.error("Error Request:", e.request); - } else { - console.error('Error', e.message); - } - console.error(e.stack); - return false; - } + const stats = fs.statSync(params.filePath); + const fileSize = stats.size; + const fileName = path.basename(params.filePath); + + console.log( + `Starting AD5X upload for ${fileName}, Size: ${fileSize}, Start: ${params.startPrint}, Level: ${params.levelingBeforePrint}, Tools: ${params.materialMappings.length}` + ); + + try { + // Create FormData with the file content + const form = new FormData(); + form.append('gcodeFile', fs.createReadStream(params.filePath), { + filename: fileName, + contentType: 'application/octet-stream', + }); + + // Encode material mappings to base64 + const materialMappingsBase64 = this.encodeMaterialMappingsToBase64(params.materialMappings); + + // Prepare AD5X-specific HTTP headers + const customHeaders: Record = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileSize: fileSize.toString(), + printNow: params.startPrint.toString().toLowerCase(), + levelingBeforePrint: params.levelingBeforePrint.toString().toLowerCase(), + flowCalibration: params.flowCalibration.toString().toLowerCase(), + firstLayerInspection: params.firstLayerInspection.toString().toLowerCase(), + timeLapseVideo: params.timeLapseVideo.toString().toLowerCase(), + useMatlStation: 'true', // Always true for AD5X uploads with material mappings + gcodeToolCnt: params.materialMappings.length.toString(), + materialMappings: materialMappingsBase64, + Expect: '100-continue', + }; + + // Get necessary headers from FormData + const formHeaders = form.getHeaders(); + + // Combine custom headers and FormData headers + const requestHeaders = { + ...customHeaders, + 'Content-Type': formHeaders['content-type'], + }; + + console.log('AD5X Upload Request Headers:', requestHeaders); + + // Configure Axios request + const config = { + headers: requestHeaders, + }; + + // Make the POST request + const response = await axios.post( + this.client.getEndpoint(Endpoints.UploadFile), + form, + config + ); + + console.log(`AD5X Upload Response Status: ${response.status}`); + console.log('AD5X Upload Response Data:', response.data); + + if (response.status !== 200) { + console.error(`AD5X Upload failed: Printer responded with status ${response.status}`); + return false; + } + + // Assuming response.data is already parsed JSON by axios + const result = response.data as GenericResponse; + if (NetworkUtils.isOk(result)) { + console.log('AD5X Upload successful according to printer response.'); + return true; + } else { + console.error( + `AD5X Upload failed: Printer response code=${result.code}, message=${result.message}` + ); + return false; + } + } catch (error) { + const err = error as Error & { + response?: { status: number; data: GenericResponse }; + request?: unknown; + }; + console.error(`UploadFileAD5X error: ${err.message}`); + if (err.response) { + console.error(`Error Status: ${err.response.status}`); + console.error('Error Response Data:', err.response.data); + } else if (err.request) { + console.error('Error Request:', err.request); + } else { + console.error('Error', err.message); + } + console.error(err.stack); + return false; } + } + + /** + * Starts printing a file that is already stored locally on the printer. + * It handles different API payload formats based on the printer's firmware version. + * + * @param fileName The name of the file on the printer (e.g., "my_model.gcode") to print. + * @param levelingBeforePrint If true, the printer will perform bed leveling before starting the print. + * @returns A Promise that resolves to true if the print command is successfully sent and acknowledged, false otherwise. + * @throws Error if there's an issue sending the command (e.g., network error). + */ + public async printLocalFile(fileName: string, levelingBeforePrint: boolean): Promise { + let payload: Record; + + if (this.isNewFirmwareVersion()) { + // New format for firmware >= 3.1.3 + payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileName, + levelingBeforePrint, + flowCalibration: false, + useMatlStation: false, + gcodeToolCnt: 0, + materialMappings: [], // Empty array for materialMappings + }; + } else { + // Old format for firmware < 3.1.3 + payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileName, + levelingBeforePrint, + }; + } + + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.GCodePrint), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); - /** - * Uploads a G-code or 3MF file to AD5X printer with material station support. - * Handles material mappings, flow calibration, and other AD5X-specific features. - * Material mappings are base64-encoded in HTTP headers according to AD5X API requirements. - * - * @param params AD5X upload parameters including file path, print options, and material mappings - * @returns A Promise that resolves to true if the file upload is successful, false otherwise - */ - public async uploadFileAD5X(params: AD5XUploadParams): Promise { - // Validate that this is an AD5X printer - if (!this.validateAD5XPrinter()) { - return false; - } - - // Validate material mappings - if (!this.validateMaterialMappings(params.materialMappings)) { - return false; - } - - // Validate file exists - if (!fs.existsSync(params.filePath)) { - console.error(`UploadFileAD5X error: File not found at ${params.filePath}`); - return false; - } - - const stats = fs.statSync(params.filePath); - const fileSize = stats.size; - const fileName = path.basename(params.filePath); - - console.log(`Starting AD5X upload for ${fileName}, Size: ${fileSize}, Start: ${params.startPrint}, Level: ${params.levelingBeforePrint}, Tools: ${params.materialMappings.length}`); - - try { - // Create FormData with the file content - const form = new FormData(); - form.append('gcodeFile', fs.createReadStream(params.filePath), { - filename: fileName, - contentType: 'application/octet-stream' - }); - - // Encode material mappings to base64 - const materialMappingsBase64 = this.encodeMaterialMappingsToBase64(params.materialMappings); - - // Prepare AD5X-specific HTTP headers - const customHeaders: Record = { - 'serialNumber': this.client.serialNumber, - 'checkCode': this.client.checkCode, - 'fileSize': fileSize.toString(), - 'printNow': params.startPrint.toString().toLowerCase(), - 'levelingBeforePrint': params.levelingBeforePrint.toString().toLowerCase(), - 'flowCalibration': params.flowCalibration.toString().toLowerCase(), - 'firstLayerInspection': params.firstLayerInspection.toString().toLowerCase(), - 'timeLapseVideo': params.timeLapseVideo.toString().toLowerCase(), - 'useMatlStation': 'true', // Always true for AD5X uploads with material mappings - 'gcodeToolCnt': params.materialMappings.length.toString(), - 'materialMappings': materialMappingsBase64, - 'Expect': '100-continue' - }; - - // Get necessary headers from FormData - const formHeaders = form.getHeaders(); - - // Combine custom headers and FormData headers - const requestHeaders = { - ...customHeaders, - 'Content-Type': formHeaders['content-type'] - }; - - console.log("AD5X Upload Request Headers:", requestHeaders); - - // Configure Axios request - // @ts-ignore - const config: AxiosRequestConfig = { - headers: requestHeaders - }; - - // Make the POST request - const response = await axios.post( - this.client.getEndpoint(Endpoints.UploadFile), - form, - config - ); - - console.log(`AD5X Upload Response Status: ${response.status}`); - console.log("AD5X Upload Response Data:", response.data); - - if (response.status !== 200) { - console.error(`AD5X Upload failed: Printer responded with status ${response.status}`); - return false; - } - - // Assuming response.data is already parsed JSON by axios - const result = response.data as any; - if (NetworkUtils.isOk(result)) { - console.log("AD5X Upload successful according to printer response."); - return true; - } else { - console.error(`AD5X Upload failed: Printer response code=${result.code}, message=${result.message}`); - return false; - } - - } catch (e: any) { - console.error(`UploadFileAD5X error: ${e.message}`); - if (e.response) { - console.error(`Error Status: ${e.response.status}`); - console.error("Error Response Data:", e.response.data); - } else if (e.request) { - console.error("Error Request:", e.request); - } else { - console.error('Error', e.message); - } - console.error(e.stack); - return false; - } + if (response.status !== 200) return false; + + const result = response.data as GenericResponse; + return NetworkUtils.isOk(result); + } catch (error) { + console.error(`PrintLocalFile error: ${(error as Error).message}`); + throw error; + } + } + + /** + * Starts a multi-color local print job on AD5X printers with material mappings. + * This method automatically configures the material station settings and validates + * all parameters before sending the print command. + * + * @param params Job parameters including file name, leveling option, and material mappings + * @returns Promise resolving to true if successful, false if validation fails or printer rejects + * @throws Error if there's a network issue sending the command + */ + public async startAD5XMultiColorJob(params: AD5XLocalJobParams): Promise { + // Validate that this is an AD5X printer + if (!this.validateAD5XPrinter()) { + return false; } - /** - * Starts printing a file that is already stored locally on the printer. - * It handles different API payload formats based on the printer's firmware version. - * - * @param fileName The name of the file on the printer (e.g., "my_model.gcode") to print. - * @param levelingBeforePrint If true, the printer will perform bed leveling before starting the print. - * @returns A Promise that resolves to true if the print command is successfully sent and acknowledged, false otherwise. - * @throws Error if there's an issue sending the command (e.g., network error). - */ - public async printLocalFile(fileName: string, levelingBeforePrint: boolean): Promise { - let payload: any; - - if (this.isNewFirmwareVersion()) { - // New format for firmware >= 3.1.3 - payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - fileName, - levelingBeforePrint, - flowCalibration: false, - useMatlStation: false, - gcodeToolCnt: 0, - materialMappings: [] // Empty array for materialMappings - }; - } else { - // Old format for firmware < 3.1.3 - payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - fileName, - levelingBeforePrint - }; - } - - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.GCodePrint), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - if (response.status !== 200) return false; - - const result = response.data as GenericResponse; - return NetworkUtils.isOk(result); - } catch (error) { - console.error(`PrintLocalFile error: ${(error as Error).message}`); - throw error; - } + // Validate material mappings + if (!this.validateMaterialMappings(params.materialMappings)) { + return false; } - /** - * Starts a multi-color local print job on AD5X printers with material mappings. - * This method automatically configures the material station settings and validates - * all parameters before sending the print command. - * - * @param params Job parameters including file name, leveling option, and material mappings - * @returns Promise resolving to true if successful, false if validation fails or printer rejects - * @throws Error if there's a network issue sending the command - */ - public async startAD5XMultiColorJob(params: AD5XLocalJobParams): Promise { - // Validate that this is an AD5X printer - if (!this.validateAD5XPrinter()) { - return false; - } - - // Validate material mappings - if (!this.validateMaterialMappings(params.materialMappings)) { - return false; - } - - // Validate file name - if (!params.fileName || params.fileName.trim() === '') { - console.error('AD5X Multi-Color Job error: fileName cannot be empty'); - return false; - } - - // Create payload with AD5X-specific parameters - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - fileName: params.fileName, - levelingBeforePrint: params.levelingBeforePrint, - firstLayerInspection: false, - flowCalibration: false, - timeLapseVideo: false, - useMatlStation: true, // Automatically set to true for multi-color jobs - gcodeToolCnt: params.materialMappings.length, // Set based on material mappings count - materialMappings: params.materialMappings - }; - - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.GCodePrint), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - if (response.status !== 200) return false; - - const result = response.data as GenericResponse; - return NetworkUtils.isOk(result); - } catch (error) { - console.error(`AD5X Multi-Color Job error: ${(error as Error).message}`); - throw error; - } + // Validate file name + if (!params.fileName || params.fileName.trim() === '') { + console.error('AD5X Multi-Color Job error: fileName cannot be empty'); + return false; } - /** - * Starts a single-color local print job on AD5X printers. - * This method automatically configures the printer for single-color printing - * without using the material station. - * - * @param params Job parameters including file name and leveling option - * @returns Promise resolving to true if successful, false if validation fails or printer rejects - * @throws Error if there's a network issue sending the command - */ - public async startAD5XSingleColorJob(params: AD5XSingleColorJobParams): Promise { - // Validate that this is an AD5X printer - if (!this.validateAD5XPrinter()) { - return false; - } - - // Validate file name - if (!params.fileName || params.fileName.trim() === '') { - console.error('AD5X Single-Color Job error: fileName cannot be empty'); - return false; - } - - // Create payload with AD5X-specific parameters for single-color printing - const payload = { - serialNumber: this.client.serialNumber, - checkCode: this.client.checkCode, - fileName: params.fileName, - levelingBeforePrint: params.levelingBeforePrint, - firstLayerInspection: false, - flowCalibration: false, - timeLapseVideo: false, - useMatlStation: false, // Set to false for single-color jobs - gcodeToolCnt: 0, // Set to 0 for single-color jobs - materialMappings: [] // Empty array for single-color jobs - }; - - try { - const response = await axios.post( - this.client.getEndpoint(Endpoints.GCodePrint), - payload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - if (response.status !== 200) return false; - - const result = response.data as GenericResponse; - return NetworkUtils.isOk(result); - } catch (error) { - console.error(`AD5X Single-Color Job error: ${(error as Error).message}`); - throw error; - } + // Create payload with AD5X-specific parameters + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileName: params.fileName, + levelingBeforePrint: params.levelingBeforePrint, + firstLayerInspection: false, + flowCalibration: false, + timeLapseVideo: false, + useMatlStation: true, // Automatically set to true for multi-color jobs + gcodeToolCnt: params.materialMappings.length, // Set based on material mappings count + materialMappings: params.materialMappings, + }; + + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.GCodePrint), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status !== 200) return false; + + const result = response.data as GenericResponse; + return NetworkUtils.isOk(result); + } catch (error) { + console.error(`AD5X Multi-Color Job error: ${(error as Error).message}`); + throw error; + } + } + + /** + * Starts a single-color local print job on AD5X printers. + * This method automatically configures the printer for single-color printing + * without using the material station. + * + * @param params Job parameters including file name and leveling option + * @returns Promise resolving to true if successful, false if validation fails or printer rejects + * @throws Error if there's a network issue sending the command + */ + public async startAD5XSingleColorJob(params: AD5XSingleColorJobParams): Promise { + // Validate that this is an AD5X printer + if (!this.validateAD5XPrinter()) { + return false; } - /** - * Validates that the current printer is an AD5X model. - * @returns True if the printer is AD5X, false otherwise - * @private - */ - private validateAD5XPrinter(): boolean { - if (!this.client.isAD5X) { - console.error('AD5X Job error: This method can only be used with AD5X printers'); - return false; - } - return true; + // Validate file name + if (!params.fileName || params.fileName.trim() === '') { + console.error('AD5X Single-Color Job error: fileName cannot be empty'); + return false; } - /** - * Encodes material mappings array to base64 string for HTTP headers. - * Converts AD5XMaterialMapping array to JSON and then to base64 encoding. - * @param materialMappings Array of material mappings to encode - * @returns Base64-encoded JSON string - * @throws Error if encoding fails - * @private - */ - private encodeMaterialMappingsToBase64(materialMappings: AD5XMaterialMapping[]): string { - try { - const jsonString = JSON.stringify(materialMappings); - return Buffer.from(jsonString, 'utf8').toString('base64'); - } catch (error) { - console.error('Failed to encode material mappings to base64:', error); - throw new Error('Failed to encode material mappings for upload'); - } + // Create payload with AD5X-specific parameters for single-color printing + const payload = { + serialNumber: this.client.serialNumber, + checkCode: this.client.checkCode, + fileName: params.fileName, + levelingBeforePrint: params.levelingBeforePrint, + firstLayerInspection: false, + flowCalibration: false, + timeLapseVideo: false, + useMatlStation: false, // Set to false for single-color jobs + gcodeToolCnt: 0, // Set to 0 for single-color jobs + materialMappings: [], // Empty array for single-color jobs + }; + + try { + const response = await axios.post(this.client.getEndpoint(Endpoints.GCodePrint), payload, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.status !== 200) return false; + + const result = response.data as GenericResponse; + return NetworkUtils.isOk(result); + } catch (error) { + console.error(`AD5X Single-Color Job error: ${(error as Error).message}`); + throw error; + } + } + + /** + * Validates that the current printer is an AD5X model. + * @returns True if the printer is AD5X, false otherwise + * @private + */ + private validateAD5XPrinter(): boolean { + if (!this.client.isAD5X) { + console.error('AD5X Job error: This method can only be used with AD5X printers'); + return false; + } + return true; + } + + /** + * Encodes material mappings array to base64 string for HTTP headers. + * Converts AD5XMaterialMapping array to JSON and then to base64 encoding. + * @param materialMappings Array of material mappings to encode + * @returns Base64-encoded JSON string + * @throws Error if encoding fails + * @private + */ + private encodeMaterialMappingsToBase64(materialMappings: AD5XMaterialMapping[]): string { + try { + const jsonString = JSON.stringify(materialMappings); + return Buffer.from(jsonString, 'utf8').toString('base64'); + } catch (error) { + console.error('Failed to encode material mappings to base64:', error); + throw new Error('Failed to encode material mappings for upload'); + } + } + + /** + * Validates material mappings for AD5X multi-color jobs. + * Checks toolId range (0-3), slotId range (1-4), and color format (#RRGGBB). + * @param materialMappings Array of material mappings to validate + * @returns True if all mappings are valid, false otherwise + * @private + */ + private validateMaterialMappings(materialMappings: AD5XMaterialMapping[]): boolean { + if (!materialMappings || materialMappings.length === 0) { + console.error( + 'Material mappings validation error: materialMappings array cannot be empty for multi-color jobs' + ); + return false; } - /** - * Validates material mappings for AD5X multi-color jobs. - * Checks toolId range (0-3), slotId range (1-4), and color format (#RRGGBB). - * @param materialMappings Array of material mappings to validate - * @returns True if all mappings are valid, false otherwise - * @private - */ - private validateMaterialMappings(materialMappings: AD5XMaterialMapping[]): boolean { - if (!materialMappings || materialMappings.length === 0) { - console.error('Material mappings validation error: materialMappings array cannot be empty for multi-color jobs'); - return false; - } - - if (materialMappings.length > 4) { - console.error('Material mappings validation error: Maximum 4 material mappings allowed'); - return false; - } - - const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; - - for (let i = 0; i < materialMappings.length; i++) { - const mapping = materialMappings[i]; - - // Validate toolId (0-3) - if (mapping.toolId < 0 || mapping.toolId > 3) { - console.error(`Material mappings validation error: toolId must be between 0-3, got ${mapping.toolId} at index ${i}`); - return false; - } - - // Validate slotId (1-4) - if (mapping.slotId < 1 || mapping.slotId > 4) { - console.error(`Material mappings validation error: slotId must be between 1-4, got ${mapping.slotId} at index ${i}`); - return false; - } - - // Validate materialName is not empty - if (!mapping.materialName || mapping.materialName.trim() === '') { - console.error(`Material mappings validation error: materialName cannot be empty at index ${i}`); - return false; - } - - // Validate toolMaterialColor format - if (!hexColorRegex.test(mapping.toolMaterialColor)) { - console.error(`Material mappings validation error: toolMaterialColor must be in #RRGGBB format, got ${mapping.toolMaterialColor} at index ${i}`); - return false; - } - - // Validate slotMaterialColor format - if (!hexColorRegex.test(mapping.slotMaterialColor)) { - console.error(`Material mappings validation error: slotMaterialColor must be in #RRGGBB format, got ${mapping.slotMaterialColor} at index ${i}`); - return false; - } - } + if (materialMappings.length > 4) { + console.error('Material mappings validation error: Maximum 4 material mappings allowed'); + return false; + } - return true; + const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; + + for (let i = 0; i < materialMappings.length; i++) { + const mapping = materialMappings[i]; + + // Validate toolId (0-3) + if (mapping.toolId < 0 || mapping.toolId > 3) { + console.error( + `Material mappings validation error: toolId must be between 0-3, got ${mapping.toolId} at index ${i}` + ); + return false; + } + + // Validate slotId (1-4) + if (mapping.slotId < 1 || mapping.slotId > 4) { + console.error( + `Material mappings validation error: slotId must be between 1-4, got ${mapping.slotId} at index ${i}` + ); + return false; + } + + // Validate materialName is not empty + if (!mapping.materialName || mapping.materialName.trim() === '') { + console.error( + `Material mappings validation error: materialName cannot be empty at index ${i}` + ); + return false; + } + + // Validate toolMaterialColor format + if (!hexColorRegex.test(mapping.toolMaterialColor)) { + console.error( + `Material mappings validation error: toolMaterialColor must be in #RRGGBB format, got ${mapping.toolMaterialColor} at index ${i}` + ); + return false; + } + + // Validate slotMaterialColor format + if (!hexColorRegex.test(mapping.slotMaterialColor)) { + console.error( + `Material mappings validation error: slotMaterialColor must be in #RRGGBB format, got ${mapping.slotMaterialColor} at index ${i}` + ); + return false; + } } -} \ No newline at end of file + + return true; + } +} diff --git a/src/api/controls/TempControl.test.ts b/src/api/controls/TempControl.test.ts index 096bdde..32e9839 100644 --- a/src/api/controls/TempControl.test.ts +++ b/src/api/controls/TempControl.test.ts @@ -2,38 +2,47 @@ * @fileoverview Unit tests for TempControl module. * Tests temperature control operations including setting/canceling extruder and bed temperatures via mocked TCP client. */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FiveMClient } from '../../FiveMClient'; +import type { GCodeController } from '../../tcpapi/client/GCodeController'; +import type { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; import { TempControl } from './TempControl'; -import { FiveMClient } from '../../FiveMClient'; -import { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; -import { GCodeController } from '../../tcpapi/client/GCodeController'; // Mock the FlashForgeClient -jest.mock('../../tcpapi/FlashForgeClient'); +vi.mock('../../tcpapi/FlashForgeClient'); describe('TempControl', () => { let mockFiveMClient: FiveMClient; - let mockTcpClient: jest.Mocked; - let mockGCodeController: jest.Mocked; + let mockTcpClient: FlashForgeClient & { + setExtruderTemp: ReturnType; + setBedTemp: ReturnType; + cancelExtruderTemp: ReturnType; + cancelBedTemp: ReturnType; + gCode: ReturnType; + }; + let mockGCodeController: GCodeController & { + waitForBedTemp: ReturnType; + }; let tempControl: TempControl; beforeEach(() => { // Create mock GCodeController mockGCodeController = { - waitForBedTemp: jest.fn().mockResolvedValue(undefined) + waitForBedTemp: vi.fn().mockResolvedValue(undefined), } as any; // Create mock TCP client mockTcpClient = { - setExtruderTemp: jest.fn().mockResolvedValue(true), - setBedTemp: jest.fn().mockResolvedValue(true), - cancelExtruderTemp: jest.fn().mockResolvedValue(true), - cancelBedTemp: jest.fn().mockResolvedValue(true), - gCode: jest.fn().mockReturnValue(mockGCodeController) + setExtruderTemp: vi.fn().mockResolvedValue(true), + setBedTemp: vi.fn().mockResolvedValue(true), + cancelExtruderTemp: vi.fn().mockResolvedValue(true), + cancelBedTemp: vi.fn().mockResolvedValue(true), + gCode: vi.fn().mockReturnValue(mockGCodeController), } as any; // Create mock FiveMClient mockFiveMClient = { - tcpClient: mockTcpClient + tcpClient: mockTcpClient, } as FiveMClient; tempControl = new TempControl(mockFiveMClient); diff --git a/src/api/controls/TempControl.ts b/src/api/controls/TempControl.ts index 1d94d4e..9558e7c 100644 --- a/src/api/controls/TempControl.ts +++ b/src/api/controls/TempControl.ts @@ -3,89 +3,87 @@ * Provides methods for setting and canceling extruder and bed temperatures via TCP G-code commands, with cooldown waiting functionality. */ // src/api/controls/TempControl.ts -import { FiveMClient } from '../../FiveMClient'; -import { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; +import type { FiveMClient } from '../../FiveMClient'; +import type { FlashForgeClient } from '../../tcpapi/FlashForgeClient'; /** * Provides methods for controlling the temperatures of various components of the FlashForge 3D printer, * such as the extruder and the print bed. It relies on the TCP client for direct G-code/M-code commands. */ export class TempControl { - private printerClient: FiveMClient; - private tcpClient: FlashForgeClient; + private tcpClient: FlashForgeClient; - /** - * Creates an instance of the TempControl class. - * @param printerClient The FiveMClient instance used for communication with the printer. - */ - constructor(printerClient: FiveMClient) { - this.printerClient = printerClient; - this.tcpClient = printerClient.tcpClient; - } + /** + * Creates an instance of the TempControl class. + * @param printerClient The FiveMClient instance used for communication with the printer. + */ + constructor(printerClient: FiveMClient) { + this.tcpClient = printerClient.tcpClient; + } - /** - * Sets the target temperature for the printer's extruder. - * @param temp The target temperature in Celsius. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setExtruderTemp(temp: number): Promise { - return await this.tcpClient.setExtruderTemp(temp); - } + /** + * Sets the target temperature for the printer's extruder. + * @param temp The target temperature in Celsius. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setExtruderTemp(temp: number): Promise { + return await this.tcpClient.setExtruderTemp(temp); + } - /** - * Sets the target temperature for the printer's print bed. - * @param temp The target temperature in Celsius. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setBedTemp(temp: number): Promise { - return await this.tcpClient.setBedTemp(temp); - } + /** + * Sets the target temperature for the printer's print bed. + * @param temp The target temperature in Celsius. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setBedTemp(temp: number): Promise { + return await this.tcpClient.setBedTemp(temp); + } - /** - * Cancels any ongoing extruder heating and sets its target temperature to 0. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async cancelExtruderTemp(): Promise { - return await this.tcpClient.cancelExtruderTemp(); - } + /** + * Cancels any ongoing extruder heating and sets its target temperature to 0. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async cancelExtruderTemp(): Promise { + return await this.tcpClient.cancelExtruderTemp(); + } - /** - * Cancels any ongoing print bed heating and sets its target temperature to 0. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async cancelBedTemp(): Promise { - return await this.tcpClient.cancelBedTemp(); - } + /** + * Cancels any ongoing print bed heating and sets its target temperature to 0. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async cancelBedTemp(): Promise { + return await this.tcpClient.cancelBedTemp(); + } - /** - * Waits for the print bed (platform) to cool down to or below a specified temperature. - * This is typically used after a print finishes to ensure the part can be safely removed. - * @param temp The target temperature in Celsius to wait for the bed to reach. - * @returns A Promise that resolves when the bed temperature is at or below the specified temperature. - */ - public async waitForPartCool(temp: number): Promise { - await this.tcpClient.gCode().waitForBedTemp(temp, true); - } + /** + * Waits for the print bed (platform) to cool down to or below a specified temperature. + * This is typically used after a print finishes to ensure the part can be safely removed. + * @param temp The target temperature in Celsius to wait for the bed to reach. + * @returns A Promise that resolves when the bed temperature is at or below the specified temperature. + */ + public async waitForPartCool(temp: number): Promise { + await this.tcpClient.gCode().waitForBedTemp(temp, true); + } - /* - * TODO: This method is commented out as it needs verification. - * It's intended to send a temperature control command via the HTTP API, - * which might be an alternative or a supplement to the TCP-based commands. - * - * private async sendTempControlCommand( - * bedTemp: number, - * rightExtruder: number, - * leftExtruder: number, - * chamberTemp: number - * ): Promise { - * const payload = { - * platformTemp: bedTemp, - * rightTemp: rightExtruder, - * leftTemp: leftExtruder, - * chamberTemp: chamberTemp - * }; - * - * return await this.printerClient.control.sendControlCommand(Commands.TempControlCmd, payload); - * } - */ -} \ No newline at end of file + /* + * TODO: This method is commented out as it needs verification. + * It's intended to send a temperature control command via the HTTP API, + * which might be an alternative or a supplement to the TCP-based commands. + * + * private async sendTempControlCommand( + * bedTemp: number, + * rightExtruder: number, + * leftExtruder: number, + * chamberTemp: number + * ): Promise { + * const payload = { + * platformTemp: bedTemp, + * rightTemp: rightExtruder, + * leftTemp: leftExtruder, + * chamberTemp: chamberTemp + * }; + * + * return await this.printerClient.control.sendControlCommand(Commands.TempControlCmd, payload); + * } + */ +} diff --git a/src/api/filament/Filament.test.ts b/src/api/filament/Filament.test.ts index 4e1069c..7a07b85 100644 --- a/src/api/filament/Filament.test.ts +++ b/src/api/filament/Filament.test.ts @@ -3,6 +3,7 @@ * * Verifies filament type creation with custom and default loading temperatures. */ +import { describe, expect, it } from 'vitest'; import { Filament } from './Filament'; describe('Filament', () => { diff --git a/src/api/filament/Filament.ts b/src/api/filament/Filament.ts index 6ebc14e..8ce68d9 100644 --- a/src/api/filament/Filament.ts +++ b/src/api/filament/Filament.ts @@ -12,18 +12,18 @@ * like loading or preheating. */ export class Filament { - /** The recommended loading temperature for this filament in Celsius. */ - public readonly loadTemp: number; - /** The name of the filament type (e.g., "PLA", "ABS", "PETG"). */ - public readonly name: string; + /** The recommended loading temperature for this filament in Celsius. */ + public readonly loadTemp: number; + /** The name of the filament type (e.g., "PLA", "ABS", "PETG"). */ + public readonly name: string; - /** - * Creates an instance of the Filament class. - * @param name The name of the filament type. - * @param loadTemp The recommended loading temperature for the filament in Celsius. Defaults to 220°C. - */ - constructor(name: string, loadTemp: number = 220) { - this.name = name; - this.loadTemp = loadTemp; - } -} \ No newline at end of file + /** + * Creates an instance of the Filament class. + * @param name The name of the filament type. + * @param loadTemp The recommended loading temperature for the filament in Celsius. Defaults to 220°C. + */ + constructor(name: string, loadTemp: number = 220) { + this.name = name; + this.loadTemp = loadTemp; + } +} diff --git a/src/api/misc/ScientificNotationFloatConverter.test.ts b/src/api/misc/ScientificNotationFloatConverter.test.ts index 4fc7e72..3a0d8f8 100644 --- a/src/api/misc/ScientificNotationFloatConverter.test.ts +++ b/src/api/misc/ScientificNotationFloatConverter.test.ts @@ -4,6 +4,7 @@ * Verifies correct formatting behavior for small numbers, large numbers, * and standard decimal numbers within normal range. */ +import { describe, expect, it } from 'vitest'; import { formatScientificNotation } from './ScientificNotationFloatConverter'; describe('formatScientificNotation', () => { diff --git a/src/api/misc/ScientificNotationFloatConverter.ts b/src/api/misc/ScientificNotationFloatConverter.ts index 4af946f..6f4642d 100644 --- a/src/api/misc/ScientificNotationFloatConverter.ts +++ b/src/api/misc/ScientificNotationFloatConverter.ts @@ -19,8 +19,8 @@ * formatScientificNotation(12.34) // "12.34" */ export function formatScientificNotation(value: number): string { - if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) { - return value.toExponential(); - } - return value.toString(); -} \ No newline at end of file + if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) { + return value.toExponential(); + } + return value.toString(); +} diff --git a/src/api/misc/Temperature.test.ts b/src/api/misc/Temperature.test.ts index 4a66720..6573dfd 100644 --- a/src/api/misc/Temperature.test.ts +++ b/src/api/misc/Temperature.test.ts @@ -4,6 +4,7 @@ * Verifies temperature value storage, retrieval, and string conversion * for positive, negative, zero, and decimal values. */ +import { describe, expect, it } from 'vitest'; import { Temperature } from './Temperature'; describe('Temperature', () => { diff --git a/src/api/misc/Temperature.ts b/src/api/misc/Temperature.ts index e487ebf..0978b08 100644 --- a/src/api/misc/Temperature.ts +++ b/src/api/misc/Temperature.ts @@ -15,30 +15,30 @@ * (e.g., `{ current: number, set: number }`) is used elsewhere in the models. */ export class Temperature { - /** The underlying temperature value, typically in Celsius. */ - private readonly _value: number; + /** The underlying temperature value, typically in Celsius. */ + private readonly _value: number; - /** - * Creates an instance of the Temperature class. - * @param value The numeric temperature value. - */ - constructor(value: number) { - this._value = value; - } + /** + * Creates an instance of the Temperature class. + * @param value The numeric temperature value. + */ + constructor(value: number) { + this._value = value; + } - /** - * Gets the numeric temperature value. - * @returns The temperature value. - */ - public getValue(): number { - return this._value; - } + /** + * Gets the numeric temperature value. + * @returns The temperature value. + */ + public getValue(): number { + return this._value; + } - /** - * Gets the string representation of the temperature value. - * @returns The temperature value as a string. - */ - public toString(): string { - return this._value.toString(); - } -} \ No newline at end of file + /** + * Gets the string representation of the temperature value. + * @returns The temperature value as a string. + */ + public toString(): string { + return this._value.toString(); + } +} diff --git a/src/api/network/FNetCode.ts b/src/api/network/FNetCode.ts index e3a083d..c31639c 100644 --- a/src/api/network/FNetCode.ts +++ b/src/api/network/FNetCode.ts @@ -9,8 +9,8 @@ * to indicate the success or failure of a requested operation. */ export enum FNetCode { - /** Indicates that the network operation was successful (Code: 0). */ - Ok = 0, - /** Indicates that an error occurred during the network operation (Code: 1). */ - Error = 1 -} \ No newline at end of file + /** Indicates that the network operation was successful (Code: 0). */ + Ok = 0, + /** Indicates that an error occurred during the network operation (Code: 1). */ + Error = 1, +} diff --git a/src/api/network/NetworkUtils.test.ts b/src/api/network/NetworkUtils.test.ts index 0d4f44a..f54477d 100644 --- a/src/api/network/NetworkUtils.test.ts +++ b/src/api/network/NetworkUtils.test.ts @@ -3,16 +3,17 @@ * * Verifies response validation logic for successful and failed API responses. */ -import { NetworkUtils } from './NetworkUtils'; +import { describe, expect, it } from 'vitest'; +import type { GenericResponse } from '../controls/Control'; import { FNetCode } from './FNetCode'; -import { GenericResponse } from '../controls/Control'; +import { NetworkUtils } from './NetworkUtils'; describe('NetworkUtils', () => { describe('isOk', () => { it('should return true for a successful response', () => { const response: GenericResponse = { code: FNetCode.Ok, - message: 'Success' + message: 'Success', }; expect(NetworkUtils.isOk(response)).toBe(true); @@ -21,7 +22,7 @@ describe('NetworkUtils', () => { it('should return false if code is not Ok', () => { const response: GenericResponse = { code: 1, - message: 'Success' + message: 'Success', }; expect(NetworkUtils.isOk(response)).toBe(false); @@ -30,7 +31,7 @@ describe('NetworkUtils', () => { it('should return false if message is not "Success"', () => { const response: GenericResponse = { code: FNetCode.Ok, - message: 'Failed' + message: 'Failed', }; expect(NetworkUtils.isOk(response)).toBe(false); @@ -39,7 +40,7 @@ describe('NetworkUtils', () => { it('should return false if both code and message are incorrect', () => { const response: GenericResponse = { code: 1, - message: 'Error' + message: 'Error', }; expect(NetworkUtils.isOk(response)).toBe(false); @@ -48,7 +49,7 @@ describe('NetworkUtils', () => { it('should return false for error responses', () => { const response: GenericResponse = { code: -1, - message: 'Network error' + message: 'Network error', }; expect(NetworkUtils.isOk(response)).toBe(false); diff --git a/src/api/network/NetworkUtils.ts b/src/api/network/NetworkUtils.ts index 27e5d62..4d9cfe3 100644 --- a/src/api/network/NetworkUtils.ts +++ b/src/api/network/NetworkUtils.ts @@ -5,7 +5,7 @@ * GenericResponse objects indicate successful operations. */ // src/api/network/NetworkUtils.ts -import { GenericResponse } from '../controls/Control'; +import type { GenericResponse } from '../controls/Control'; import { FNetCode } from './FNetCode'; /** @@ -13,15 +13,15 @@ import { FNetCode } from './FNetCode'; * particularly for interpreting API responses from the printer. */ export class NetworkUtils { - /** - * Checks if a generic API response indicates success. - * A response is considered "OK" if its code is `FNetCode.Ok` (0) - * and its message is "Success". - * - * @param response The `GenericResponse` object received from the API. - * @returns True if the response signifies success, false otherwise. - */ - public static isOk(response: GenericResponse): boolean { - return response.code === FNetCode.Ok && response.message === 'Success'; - } -} \ No newline at end of file + /** + * Checks if a generic API response indicates success. + * A response is considered "OK" if its code is `FNetCode.Ok` (0) + * and its message is "Success". + * + * @param response The `GenericResponse` object received from the API. + * @returns True if the response signifies success, false otherwise. + */ + public static isOk(response: GenericResponse): boolean { + return response.code === FNetCode.Ok && response.message === 'Success'; + } +} diff --git a/src/api/server/Commands.ts b/src/api/server/Commands.ts index 79b77a8..5fa6b9e 100644 --- a/src/api/server/Commands.ts +++ b/src/api/server/Commands.ts @@ -11,16 +11,16 @@ * to instruct the printer to perform certain actions. */ export class Commands { - /** Command for controlling the printer's LED lights (e.g., turning them on or off). */ - static readonly LightControlCmd = "lightControl_cmd"; - /** Command for general printer control actions (e.g., setting speed, Z-offset, fan speeds during a print). */ - static readonly PrinterControlCmd = "printerCtl_cmd"; - /** Command for managing print jobs (e.g., pause, resume, cancel). */ - static readonly JobControlCmd = "jobCtl_cmd"; - /** Command for controlling the printer's air circulation or filtration system. */ - static readonly CirculationControlCmd = "circulateCtl_cmd"; - /** Command for controlling the printer's camera stream (e.g., starting or stopping the stream). */ - static readonly CameraControlCmd = "streamCtrl_cmd"; - /** Command for controlling the printer's temperatures (e.g., setting extruder or bed temperature via HTTP, if supported). */ - static readonly TempControlCmd = "temperatureCtl_cmd"; -} \ No newline at end of file + /** Command for controlling the printer's LED lights (e.g., turning them on or off). */ + static readonly LightControlCmd = 'lightControl_cmd'; + /** Command for general printer control actions (e.g., setting speed, Z-offset, fan speeds during a print). */ + static readonly PrinterControlCmd = 'printerCtl_cmd'; + /** Command for managing print jobs (e.g., pause, resume, cancel). */ + static readonly JobControlCmd = 'jobCtl_cmd'; + /** Command for controlling the printer's air circulation or filtration system. */ + static readonly CirculationControlCmd = 'circulateCtl_cmd'; + /** Command for controlling the printer's camera stream (e.g., starting or stopping the stream). */ + static readonly CameraControlCmd = 'streamCtrl_cmd'; + /** Command for controlling the printer's temperatures (e.g., setting extruder or bed temperature via HTTP, if supported). */ + static readonly TempControlCmd = 'temperatureCtl_cmd'; +} diff --git a/src/api/server/Endpoints.ts b/src/api/server/Endpoints.ts index ed5a84a..ef32c85 100644 --- a/src/api/server/Endpoints.ts +++ b/src/api/server/Endpoints.ts @@ -11,18 +11,18 @@ * for various API requests. */ export class Endpoints { - /** Endpoint for sending control commands to the printer (e.g., light control, job control, temperature control). */ - static readonly Control = "/control"; - /** Endpoint for retrieving detailed information and status about the printer. */ - static readonly Detail = "/detail"; - /** Endpoint for fetching a list of G-code files, typically recently printed ones. */ - static readonly GCodeList = "/gcodeList"; - /** Endpoint for initiating a print job from a G-code file stored on the printer. */ - static readonly GCodePrint = "/printGcode"; - /** Endpoint for retrieving thumbnail images associated with G-code files. */ - static readonly GCodeThumb = "/gcodeThumb"; - /** Endpoint for retrieving product information, including serial number and check code for authentication. */ - static readonly Product = "/product"; - /** Endpoint for uploading G-code files to the printer. */ - static readonly UploadFile = "/uploadGcode"; -} \ No newline at end of file + /** Endpoint for sending control commands to the printer (e.g., light control, job control, temperature control). */ + static readonly Control = '/control'; + /** Endpoint for retrieving detailed information and status about the printer. */ + static readonly Detail = '/detail'; + /** Endpoint for fetching a list of G-code files, typically recently printed ones. */ + static readonly GCodeList = '/gcodeList'; + /** Endpoint for initiating a print job from a G-code file stored on the printer. */ + static readonly GCodePrint = '/printGcode'; + /** Endpoint for retrieving thumbnail images associated with G-code files. */ + static readonly GCodeThumb = '/gcodeThumb'; + /** Endpoint for retrieving product information, including serial number and check code for authentication. */ + static readonly Product = '/product'; + /** Endpoint for uploading G-code files to the printer. */ + static readonly UploadFile = '/uploadGcode'; +} diff --git a/src/firmware-test.ts b/src/firmware-test.ts index 5b87758..2807047 100644 --- a/src/firmware-test.ts +++ b/src/firmware-test.ts @@ -5,59 +5,58 @@ import { FiveMClient } from './index'; async function testFirmwareVersion() { - // Printer connection details - const ipAddress = '192.168.0.145'; - const serialNumber = 'SNMQRE9400951'; - const checkCode = '0e35a229'; - - console.log('=== FlashForge Firmware Version Test ==='); - console.log(`Connecting to printer at ${ipAddress}...`); - - try { - // Create and initialize the client - const client = new FiveMClient(ipAddress, serialNumber, checkCode); - - const connected = await client.initialize(); - if (!connected) { - console.error('Failed to connect to the printer. Check your connection details.'); - return; - } - - console.log('Connected successfully!'); - - // Test HTTP API - console.log('\n--- HTTP API Results ---'); - const info = await client.info.get(); - if (info) { - console.log(`HTTP API Firmware Version: ${info.FirmwareVersion}`); - console.log(`HTTP API Printer Name: ${info.Name}`); - } else { - console.error('Failed to retrieve printer information via HTTP API.'); - } - - // Test Legacy TCP API - console.log('\n--- Legacy TCP API Results ---'); - const tcpInfo = await client.tcpClient.getPrinterInfo(); - if (tcpInfo) { - console.log(`TCP API Firmware Version: ${tcpInfo.FirmwareVersion}`); - console.log(`TCP API Machine Name: ${tcpInfo.Name}`); - console.log(`TCP API Machine Type: ${tcpInfo.TypeName}`); - } else { - console.error('Failed to retrieve printer information via TCP API.'); - } - - // Clean up - console.log('\nCleaning up connection...'); - await client.dispose(); - console.log('Connection closed.'); - - } catch (error) { - console.error('Error:', error); - } finally { - // Force exit to ensure the process terminates - process.exit(0); + // Printer connection details + const ipAddress = '192.168.0.145'; + const serialNumber = 'SNMQRE9400951'; + const checkCode = '0e35a229'; + + console.log('=== FlashForge Firmware Version Test ==='); + console.log(`Connecting to printer at ${ipAddress}...`); + + try { + // Create and initialize the client + const client = new FiveMClient(ipAddress, serialNumber, checkCode); + + const connected = await client.initialize(); + if (!connected) { + console.error('Failed to connect to the printer. Check your connection details.'); + return; } + + console.log('Connected successfully!'); + + // Test HTTP API + console.log('\n--- HTTP API Results ---'); + const info = await client.info.get(); + if (info) { + console.log(`HTTP API Firmware Version: ${info.FirmwareVersion}`); + console.log(`HTTP API Printer Name: ${info.Name}`); + } else { + console.error('Failed to retrieve printer information via HTTP API.'); + } + + // Test Legacy TCP API + console.log('\n--- Legacy TCP API Results ---'); + const tcpInfo = await client.tcpClient.getPrinterInfo(); + if (tcpInfo) { + console.log(`TCP API Firmware Version: ${tcpInfo.FirmwareVersion}`); + console.log(`TCP API Machine Name: ${tcpInfo.Name}`); + console.log(`TCP API Machine Type: ${tcpInfo.TypeName}`); + } else { + console.error('Failed to retrieve printer information via TCP API.'); + } + + // Clean up + console.log('\nCleaning up connection...'); + await client.dispose(); + console.log('Connection closed.'); + } catch (error) { + console.error('Error:', error); + } finally { + // Force exit to ensure the process terminates + process.exit(0); + } } // Run the test -testFirmwareVersion(); \ No newline at end of file +testFirmwareVersion(); diff --git a/src/index.ts b/src/index.ts index 0e8f912..e085037 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,63 +3,56 @@ */ // src/index.ts // Main client -export { FiveMClient, Product } from './FiveMClient'; // API Controls export { Control, FiltrationArgs, GenericResponse } from './api/controls/Control'; -export { JobControl } from './api/controls/JobControl'; -export { Info, DetailResponse } from './api/controls/Info'; export { Files } from './api/controls/Files'; +export { DetailResponse, Info } from './api/controls/Info'; +export { JobControl } from './api/controls/JobControl'; export { TempControl } from './api/controls/TempControl'; - -// Models -export { - FFPrinterDetail, - FFMachineInfo, - Temperature as TemperatureInterface, - MachineState, - FFGcodeFileEntry, - FFGcodeToolData, - AD5XMaterialMapping, - AD5XLocalJobParams, - AD5XSingleColorJobParams, - AD5XUploadParams, - MatlStationInfo, - SlotInfo -} from './models/ff-models'; - // Filament export { Filament } from './api/filament/Filament'; - +// Misc +export { formatScientificNotation } from './api/misc/ScientificNotationFloatConverter'; // Network Utilities export { FNetCode } from './api/network/FNetCode'; export { NetworkUtils } from './api/network/NetworkUtils'; +export { FlashForgePrinter, FlashForgePrinterDiscovery } from './api/PrinterDiscovery'; // Server constants export { Commands } from './api/server/Commands'; export { Endpoints } from './api/server/Endpoints'; - +export { FiveMClient, Product } from './FiveMClient'; +// Models +export { + AD5XLocalJobParams, + AD5XMaterialMapping, + AD5XSingleColorJobParams, + AD5XUploadParams, + FFGcodeFileEntry, + FFGcodeToolData, + FFMachineInfo, + FFPrinterDetail, + MachineState, + MatlStationInfo, + SlotInfo, + Temperature as TemperatureInterface, +} from './models/ff-models'; +export { GCodeController } from './tcpapi/client/GCodeController'; +export { GCodes } from './tcpapi/client/GCodes'; // TCP API export { FlashForgeClient } from './tcpapi/FlashForgeClient'; export { FlashForgeTcpClient } from './tcpapi/FlashForgeTcpClient'; -export { GCodeController } from './tcpapi/client/GCodeController'; -export { GCodes } from './tcpapi/client/GCodes'; - // Replays export { - EndstopStatus, - Status, - Endstop, - MachineStatus, - MoveMode + Endstop, + EndstopStatus, + MachineStatus, + MoveMode, + Status, } from './tcpapi/replays/EndstopStatus'; export { LocationInfo } from './tcpapi/replays/LocationInfo'; export { PrinterInfo } from './tcpapi/replays/PrinterInfo'; export { PrintStatus } from './tcpapi/replays/PrintStatus'; -export { TempInfo, TempData } from './tcpapi/replays/TempInfo'; +export { TempData, TempInfo } from './tcpapi/replays/TempInfo'; export { ThumbnailInfo } from './tcpapi/replays/ThumbnailInfo'; - -// Misc -export { formatScientificNotation } from './api/misc/ScientificNotationFloatConverter'; - -export { FlashForgePrinter, FlashForgePrinterDiscovery } from './api/PrinterDiscovery'; \ No newline at end of file diff --git a/src/models/MachineInfo.test.ts b/src/models/MachineInfo.test.ts index 3ac899b..66a8a45 100644 --- a/src/models/MachineInfo.test.ts +++ b/src/models/MachineInfo.test.ts @@ -1,113 +1,118 @@ /** * @fileoverview Unit tests for MachineInfo transformation logic. */ +import { describe, expect, it } from 'vitest'; +import { + type FFPrinterDetail, + type IndepMatlInfo, + MachineState, + type MatlStationInfo, +} from './ff-models'; import { MachineInfo } from './MachineInfo'; -import { FFPrinterDetail, MachineState, SlotInfo, MatlStationInfo, IndepMatlInfo } from './ff-models'; const AD5X_PRINTER_DETAIL_JSON: FFPrinterDetail = { - "autoShutdown": "close", - "autoShutdownTime": 30, - "cameraStreamUrl": "", - "chamberFanSpeed": 0, - "chamberTargetTemp": 0, - "chamberTemp": 0, - "clearFanStatus": "open", // This field was in the example but not in FFPrinterDetail, assuming it's not standard or a typo. Will omit. - "coolingFanLeftSpeed": 0, - "coolingFanSpeed": 0, - "cumulativeFilament": 0.0, - "cumulativePrintTime": 0, - "currentPrintSpeed": 0, - "doorStatus": "close", - "errorCode": "", - "estimatedLeftLen": 0, // For AD5X with material station, these might behave differently or be less relevant - "estimatedLeftWeight": 0.0, - "estimatedRightLen": 0, // For AD5X, this might represent the active extruder from station - "estimatedRightWeight": 0.0, - "estimatedTime": 0.0, - "externalFanStatus": "close", - "fillAmount": 0, - "firmwareVersion": "1.1.3-1.0.8", - "flashRegisterCode": "", - "hasLeftFilament": false, // Could be true if direct extruder also used - "hasMatlStation": true, - "hasRightFilament": false, // Could be true if direct extruder also used - "indepMatlInfo": { - "materialColor": "", - "materialName": "?", - "stateAction": 0, - "stateStep": 0 + autoShutdown: 'close', + autoShutdownTime: 30, + cameraStreamUrl: '', + chamberFanSpeed: 0, + chamberTargetTemp: 0, + chamberTemp: 0, + clearFanStatus: 'open', // This field was in the example but not in FFPrinterDetail, assuming it's not standard or a typo. Will omit. + coolingFanLeftSpeed: 0, + coolingFanSpeed: 0, + cumulativeFilament: 0.0, + cumulativePrintTime: 0, + currentPrintSpeed: 0, + doorStatus: 'close', + errorCode: '', + estimatedLeftLen: 0, // For AD5X with material station, these might behave differently or be less relevant + estimatedLeftWeight: 0.0, + estimatedRightLen: 0, // For AD5X, this might represent the active extruder from station + estimatedRightWeight: 0.0, + estimatedTime: 0.0, + externalFanStatus: 'close', + fillAmount: 0, + firmwareVersion: '1.1.3-1.0.8', + flashRegisterCode: '', + hasLeftFilament: false, // Could be true if direct extruder also used + hasMatlStation: true, + hasRightFilament: false, // Could be true if direct extruder also used + indepMatlInfo: { + materialColor: '', + materialName: '?', + stateAction: 0, + stateStep: 0, }, - "internalFanStatus": "close", - "ipAddr": "192.168.0.204", - "leftFilamentType": "", // Might be populated by indepMatlInfo or active station slot - "leftTargetTemp": 0, - "leftTemp": 0, - "lightStatus": "open", - "location": "Group A", - "macAddr": "88:A9:A7:9D:2A:70", - "matlStationInfo": { - "currentLoadSlot": 0, - "currentSlot": 0, - "slotCnt": 4, - "slotInfos": [ + internalFanStatus: 'close', + ipAddr: '192.168.0.204', + leftFilamentType: '', // Might be populated by indepMatlInfo or active station slot + leftTargetTemp: 0, + leftTemp: 0, + lightStatus: 'open', + location: 'Group A', + macAddr: '88:A9:A7:9D:2A:70', + matlStationInfo: { + currentLoadSlot: 0, + currentSlot: 0, + slotCnt: 4, + slotInfos: [ { - "hasFilament": true, - "materialColor": "#FFFFFF", - "materialName": "PLA", - "slotId": 1 + hasFilament: true, + materialColor: '#FFFFFF', + materialName: 'PLA', + slotId: 1, }, { - "hasFilament": true, - "materialColor": "#2750E0", - "materialName": "PLA", - "slotId": 2 + hasFilament: true, + materialColor: '#2750E0', + materialName: 'PLA', + slotId: 2, }, { - "hasFilament": true, - "materialColor": "#FEF043", - "materialName": "PLA", - "slotId": 3 + hasFilament: true, + materialColor: '#FEF043', + materialName: 'PLA', + slotId: 3, }, { - "hasFilament": true, - "materialColor": "#F95D73", - "materialName": "PLA", - "slotId": 4 - } + hasFilament: true, + materialColor: '#F95D73', + materialName: 'PLA', + slotId: 4, + }, ], - "stateAction": 0, - "stateStep": 0 + stateAction: 0, + stateStep: 0, }, - "measure": "220X220X220", - "name": "AD5X", - "nozzleCnt": 1, - "nozzleModel": "0.4mm", - "nozzleStyle": 0, - "pid": 38, - "platTargetTemp": 0.0, - "platTemp": 27.75, - "polarRegisterCode": "" + measure: '220X220X220', + name: 'AD5X', + nozzleCnt: 1, + nozzleModel: '0.4mm', + nozzleStyle: 0, + pid: 38, + platTargetTemp: 0.0, + platTemp: 27.75, + polarRegisterCode: '', // "status", "printDuration", "printFileName" etc. are missing but MachineInfo.fromDetail handles defaults }; // Basic mock for a non-AD5X printer (e.g., 5M) const GENERIC_PRINTER_DETAIL_JSON: FFPrinterDetail = { - "name": "FlashForge 5M", - "firmwareVersion": "1.0.0", - "ipAddr": "192.168.1.100", - "macAddr": "AA:BB:CC:DD:EE:FF", - "coolingFanSpeed": 100, - "platTemp": 60.5, - "platTargetTemp": 60.0, - "rightTemp": 210.3, - "rightTargetTemp": 210.0, - "status": "ready", - "cumulativePrintTime": 1200, // 20 hours in minutes - "cumulativeFilament": 500.75, // meters - // No AD5X specific fields + name: 'FlashForge 5M', + firmwareVersion: '1.0.0', + ipAddr: '192.168.1.100', + macAddr: 'AA:BB:CC:DD:EE:FF', + coolingFanSpeed: 100, + platTemp: 60.5, + platTargetTemp: 60.0, + rightTemp: 210.3, + rightTargetTemp: 210.0, + status: 'ready', + cumulativePrintTime: 1200, // 20 hours in minutes + cumulativeFilament: 500.75, // meters + // No AD5X specific fields }; - describe('MachineInfo', () => { describe('fromDetail', () => { const machineInfoConverter = new MachineInfo(); @@ -118,10 +123,10 @@ describe('MachineInfo', () => { expect(result).not.toBeNull(); if (!result) return; // Type guard - expect(result.Name).toBe("AD5X"); + expect(result.Name).toBe('AD5X'); expect(result.IsAD5X).toBe(true); expect(result.IsPro).toBe(false); // As per our logic Name=AD5X implies IsPro=false - expect(result.FirmwareVersion).toBe("1.1.3-1.0.8"); + expect(result.FirmwareVersion).toBe('1.1.3-1.0.8'); expect(result.HasMatlStation).toBe(true); expect(result.CoolingFanLeftSpeed).toBe(0); @@ -134,20 +139,20 @@ describe('MachineInfo', () => { expect(matlStation.currentSlot).toBe(0); expect(matlStation.slotCnt).toBe(4); expect(matlStation.slotInfos).toHaveLength(4); - expect(matlStation.slotInfos[0].materialName).toBe("PLA"); + expect(matlStation.slotInfos[0].materialName).toBe('PLA'); expect(matlStation.slotInfos[0].slotId).toBe(1); - expect(matlStation.slotInfos[1].materialColor).toBe("#2750E0"); + expect(matlStation.slotInfos[1].materialColor).toBe('#2750E0'); expect(matlStation.slotInfos[1].slotId).toBe(2); // Check IndepMatlInfo expect(result.IndepMatlInfo).toBeDefined(); const indepMatl = result.IndepMatlInfo as IndepMatlInfo; // Type assertion - expect(indepMatl.materialName).toBe("?"); + expect(indepMatl.materialName).toBe('?'); expect(indepMatl.stateAction).toBe(0); // Check some standard fields too - expect(result.IpAddress).toBe("192.168.0.204"); - expect(result.MacAddress).toBe("88:A9:A7:9D:2A:70"); + expect(result.IpAddress).toBe('192.168.0.204'); + expect(result.MacAddress).toBe('88:A9:A7:9D:2A:70'); expect(result.PrintBed.current).toBe(27.75); expect(result.Extruder.current).toBe(0); // Assuming rightTemp is for the active extruder expect(result.MachineState).toBe(MachineState.Unknown); // status was not in AD5X JSON, so defaults to Unknown @@ -159,10 +164,10 @@ describe('MachineInfo', () => { expect(result).not.toBeNull(); if (!result) return; // Type guard - expect(result.Name).toBe("FlashForge 5M"); + expect(result.Name).toBe('FlashForge 5M'); expect(result.IsAD5X).toBe(false); expect(result.IsPro).toBe(false); // "FlashForge 5M" does not contain "Pro" - expect(result.FirmwareVersion).toBe("1.0.0"); + expect(result.FirmwareVersion).toBe('1.0.0'); expect(result.HasMatlStation).toBeUndefined(); expect(result.MatlStationInfo).toBeUndefined(); @@ -170,25 +175,25 @@ describe('MachineInfo', () => { expect(result.CoolingFanLeftSpeed).toBeUndefined(); expect(result.CoolingFanSpeed).toBe(100); - expect(result.IpAddress).toBe("192.168.1.100"); + expect(result.IpAddress).toBe('192.168.1.100'); expect(result.PrintBed.current).toBe(60.5); expect(result.Extruder.current).toBe(210.3); expect(result.MachineState).toBe(MachineState.Ready); - expect(result.FormattedTotalRunTime).toBe("20h:0m"); // 1200 minutes + expect(result.FormattedTotalRunTime).toBe('20h:0m'); // 1200 minutes }); it('should correctly identify a non-AD5X Pro model', () => { - const proPrinterDetail: FFPrinterDetail = { - ...GENERIC_PRINTER_DETAIL_JSON, - name: "FlashForge 5M Pro", - }; - const result = machineInfoConverter.fromDetail(proPrinterDetail); - expect(result).not.toBeNull(); - if (!result) return; - - expect(result.Name).toBe("FlashForge 5M Pro"); - expect(result.IsAD5X).toBe(false); - expect(result.IsPro).toBe(true); + const proPrinterDetail: FFPrinterDetail = { + ...GENERIC_PRINTER_DETAIL_JSON, + name: 'FlashForge 5M Pro', + }; + const result = machineInfoConverter.fromDetail(proPrinterDetail); + expect(result).not.toBeNull(); + if (!result) return; + + expect(result.Name).toBe('FlashForge 5M Pro'); + expect(result.IsAD5X).toBe(false); + expect(result.IsPro).toBe(true); }); it('should return null if detail is null', () => { @@ -198,20 +203,20 @@ describe('MachineInfo', () => { // Test for default values if some fields are missing in FFPrinterDetail it('should handle missing optional fields gracefully with defaults', () => { - const minimalDetail: FFPrinterDetail = { name: "Minimal" }; - const result = machineInfoConverter.fromDetail(minimalDetail); - - expect(result).not.toBeNull(); - if (!result) return; - - expect(result.Name).toBe("Minimal"); - expect(result.IsAD5X).toBe(false); - expect(result.IsPro).toBe(false); - expect(result.FirmwareVersion).toBe(""); // Defaults to empty string - expect(result.CoolingFanSpeed).toBe(0); // Defaults to 0 - expect(result.PrintBed.current).toBe(0); - expect(result.Extruder.set).toBe(0); - expect(result.MachineState).toBe(MachineState.Unknown); // status is empty + const minimalDetail: FFPrinterDetail = { name: 'Minimal' }; + const result = machineInfoConverter.fromDetail(minimalDetail); + + expect(result).not.toBeNull(); + if (!result) return; + + expect(result.Name).toBe('Minimal'); + expect(result.IsAD5X).toBe(false); + expect(result.IsPro).toBe(false); + expect(result.FirmwareVersion).toBe(''); // Defaults to empty string + expect(result.CoolingFanSpeed).toBe(0); // Defaults to 0 + expect(result.PrintBed.current).toBe(0); + expect(result.Extruder.set).toBe(0); + expect(result.MachineState).toBe(MachineState.Unknown); // status is empty }); }); }); diff --git a/src/models/MachineInfo.ts b/src/models/MachineInfo.ts index 49e200c..be7b965 100644 --- a/src/models/MachineInfo.ts +++ b/src/models/MachineInfo.ts @@ -1,7 +1,7 @@ /** * @fileoverview Transforms raw printer detail data from the API into structured machine info. */ -import {FFMachineInfo, FFPrinterDetail, MachineState, MatlStationInfo, IndepMatlInfo} from './ff-models'; +import { type FFMachineInfo, type FFPrinterDetail, MachineState } from './ff-models'; /** * Transforms printer detail data from the API response format into a structured `FFMachineInfo` object. @@ -9,190 +9,200 @@ import {FFMachineInfo, FFPrinterDetail, MachineState, MatlStationInfo, IndepMatl * and capabilities based on the raw data received from the printer. */ export class MachineInfo { - /** - * Converts printer details from the API response format (`FFPrinterDetail`) - * to our internal `FFMachineInfo` model. - * - * This method performs several transformations: - * - Calculates print ETA and completion time. - * - Formats total run time and current print duration. - * - Converts status strings (like "open", "close") to boolean values for states like auto-shutdown, door status, fan status, and light status. - * - Calculates estimated filament length and weight used for the current job based on progress. - * - Maps raw status strings to the `MachineState` enum. - * - Formats disk space to two decimal places. - * - * @param detail The `FFPrinterDetail` object received from the printer's API. If null, the method returns null. - * @returns An `FFMachineInfo` object containing structured and formatted printer information, - * or null if the input `detail` is null or an error occurs during processing. - */ - public fromDetail(detail: FFPrinterDetail | null): FFMachineInfo | null { - if (!detail) return null; - - try { - const printEta = this.formatTimeFromSeconds(detail.estimatedTime || 0); - const completionTime = new Date(Date.now() + (detail.estimatedTime || 0) * 1000); - const formattedRunTime = this.formatTimeFromSeconds(detail.printDuration || 0); - - const totalMinutes = detail.cumulativePrintTime || 0; - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - const formattedTotalRunTime = `${hours}h:${minutes}m`; - - const autoShutdown = (detail.autoShutdown || '') === "open"; - const doorOpen = (detail.doorStatus || '') === "open"; - const externalFanOn = (detail.externalFanStatus || '') === "open"; - const internalFanOn = (detail.internalFanStatus || '') === "open"; - const lightsOn = (detail.lightStatus || '') === "open"; - - const totalJobFilamentMeters = (detail.estimatedRightLen || 0) / 1000.0; - const estLength = totalJobFilamentMeters * (detail.printProgress || 0); - const estWeight = (detail.estimatedRightWeight || 0) * (detail.printProgress || 0); - - return { - // Auto-shutdown settings - AutoShutdown: autoShutdown, - AutoShutdownTime: detail.autoShutdownTime || 0, - - // Camera - CameraStreamUrl: detail.cameraStreamUrl || '', - - // Fan speeds - ChamberFanSpeed: detail.chamberFanSpeed || 0, - CoolingFanSpeed: detail.coolingFanSpeed || 0, - CoolingFanLeftSpeed: detail.coolingFanLeftSpeed, // Keep as undefined if not present - - // Cumulative stats - CumulativeFilament: detail.cumulativeFilament || 0, - CumulativePrintTime: detail.cumulativePrintTime || 0, - - // Current print speed - CurrentPrintSpeed: detail.currentPrintSpeed || 0, - - // Disk space - FreeDiskSpace: (detail.remainingDiskSpace || 0).toFixed(2), - - // Door and error status - DoorOpen: doorOpen, - ErrorCode: detail.errorCode || '', - - // Current print estimates - EstLength: estLength, - EstWeight: estWeight, - EstimatedTime: detail.estimatedTime || 0, - - // Fans & LED status - ExternalFanOn: externalFanOn, - InternalFanOn: internalFanOn, - LightsOn: lightsOn, - - // Network - IpAddress: detail.ipAddr || '', - MacAddress: detail.macAddr || '', - - // Print settings - FillAmount: detail.fillAmount || 0, - FirmwareVersion: detail.firmwareVersion || '', - Name: detail.name || '', - IsPro: (detail.name || '').includes("Pro") && detail.name !== "AD5X", // AD5X is special - IsAD5X: detail.name === "AD5X", - NozzleSize: detail.nozzleModel || '', - - // Material Station Info - HasMatlStation: detail.hasMatlStation, - MatlStationInfo: detail.matlStationInfo, // Assign directly - IndepMatlInfo: detail.indepMatlInfo, // Assign directly - - // Temperatures - PrintBed: { - current: detail.platTemp || 0, - set: detail.platTargetTemp || 0 - }, - Extruder: { - current: detail.rightTemp || 0, - set: detail.rightTargetTemp || 0 - }, - - // Current print stats - PrintDuration: detail.printDuration || 0, - PrintFileName: detail.printFileName || '', - PrintFileThumbUrl: detail.printFileThumbUrl || '', - CurrentPrintLayer: detail.printLayer || 0, - PrintProgress: detail.printProgress || 0, - PrintProgressInt: Math.floor((detail.printProgress || 0) * 100), - PrintSpeedAdjust: detail.printSpeedAdjust || 0, - FilamentType: detail.rightFilamentType || '', - - // Machine state - MachineState: this.getMachineState(detail.status || ''), - Status: detail.status || '', - TotalPrintLayers: detail.targetPrintLayer || 0, - Tvoc: detail.tvoc || 0, - ZAxisCompensation: detail.zAxisCompensation || 0, - - // Cloud codes - FlashCloudRegisterCode: detail.flashRegisterCode || '', - PolarCloudRegisterCode: detail.polarRegisterCode || '', - - // Extras - PrintEta: printEta, - CompletionTime: completionTime, - FormattedRunTime: formattedRunTime, - FormattedTotalRunTime: formattedTotalRunTime, - }; - } catch (error: unknown) { - console.error("Error in MachineInfo.fromDetail:", (error as Error).message); - console.error("Detail object causing error:", JSON.stringify(detail, null, 2)); // Log detail on error - return null; - } + /** + * Converts printer details from the API response format (`FFPrinterDetail`) + * to our internal `FFMachineInfo` model. + * + * This method performs several transformations: + * - Calculates print ETA and completion time. + * - Formats total run time and current print duration. + * - Converts status strings (like "open", "close") to boolean values for states like auto-shutdown, door status, fan status, and light status. + * - Calculates estimated filament length and weight used for the current job based on progress. + * - Maps raw status strings to the `MachineState` enum. + * - Formats disk space to two decimal places. + * + * @param detail The `FFPrinterDetail` object received from the printer's API. If null, the method returns null. + * @returns An `FFMachineInfo` object containing structured and formatted printer information, + * or null if the input `detail` is null or an error occurs during processing. + */ + public fromDetail(detail: FFPrinterDetail | null): FFMachineInfo | null { + if (!detail) return null; + + try { + const printEta = this.formatTimeFromSeconds(detail.estimatedTime || 0); + const completionTime = new Date(Date.now() + (detail.estimatedTime || 0) * 1000); + const formattedRunTime = this.formatTimeFromSeconds(detail.printDuration || 0); + + const totalMinutes = detail.cumulativePrintTime || 0; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const formattedTotalRunTime = `${hours}h:${minutes}m`; + + const autoShutdown = (detail.autoShutdown || '') === 'open'; + const doorOpen = (detail.doorStatus || '') === 'open'; + const externalFanOn = (detail.externalFanStatus || '') === 'open'; + const internalFanOn = (detail.internalFanStatus || '') === 'open'; + const lightsOn = (detail.lightStatus || '') === 'open'; + + const totalJobFilamentMeters = (detail.estimatedRightLen || 0) / 1000.0; + const estLength = totalJobFilamentMeters * (detail.printProgress || 0); + const estWeight = (detail.estimatedRightWeight || 0) * (detail.printProgress || 0); + + return { + // Auto-shutdown settings + AutoShutdown: autoShutdown, + AutoShutdownTime: detail.autoShutdownTime || 0, + + // Camera + CameraStreamUrl: detail.cameraStreamUrl || '', + + // Fan speeds + ChamberFanSpeed: detail.chamberFanSpeed || 0, + CoolingFanSpeed: detail.coolingFanSpeed || 0, + CoolingFanLeftSpeed: detail.coolingFanLeftSpeed, // Keep as undefined if not present + + // Cumulative stats + CumulativeFilament: detail.cumulativeFilament || 0, + CumulativePrintTime: detail.cumulativePrintTime || 0, + + // Current print speed + CurrentPrintSpeed: detail.currentPrintSpeed || 0, + + // Disk space + FreeDiskSpace: (detail.remainingDiskSpace || 0).toFixed(2), + + // Door and error status + DoorOpen: doorOpen, + ErrorCode: detail.errorCode || '', + + // Current print estimates + EstLength: estLength, + EstWeight: estWeight, + EstimatedTime: detail.estimatedTime || 0, + + // Fans & LED status + ExternalFanOn: externalFanOn, + InternalFanOn: internalFanOn, + LightsOn: lightsOn, + + // Network + IpAddress: detail.ipAddr || '', + MacAddress: detail.macAddr || '', + + // Print settings + FillAmount: detail.fillAmount || 0, + FirmwareVersion: detail.firmwareVersion || '', + Name: detail.name || '', + IsPro: (detail.name || '').includes('Pro') && detail.name !== 'AD5X', // AD5X is special + IsAD5X: detail.name === 'AD5X', + NozzleSize: detail.nozzleModel || '', + + // Material Station Info + HasMatlStation: detail.hasMatlStation, + MatlStationInfo: detail.matlStationInfo, // Assign directly + IndepMatlInfo: detail.indepMatlInfo, // Assign directly + + // Temperatures + PrintBed: { + current: detail.platTemp || 0, + set: detail.platTargetTemp || 0, + }, + Extruder: { + current: detail.rightTemp || 0, + set: detail.rightTargetTemp || 0, + }, + + // Current print stats + PrintDuration: detail.printDuration || 0, + PrintFileName: detail.printFileName || '', + PrintFileThumbUrl: detail.printFileThumbUrl || '', + CurrentPrintLayer: detail.printLayer || 0, + PrintProgress: detail.printProgress || 0, + PrintProgressInt: Math.floor((detail.printProgress || 0) * 100), + PrintSpeedAdjust: detail.printSpeedAdjust || 0, + FilamentType: detail.rightFilamentType || '', + + // Machine state + MachineState: this.getMachineState(detail.status || ''), + Status: detail.status || '', + TotalPrintLayers: detail.targetPrintLayer || 0, + Tvoc: detail.tvoc || 0, + ZAxisCompensation: detail.zAxisCompensation || 0, + + // Cloud codes + FlashCloudRegisterCode: detail.flashRegisterCode || '', + PolarCloudRegisterCode: detail.polarRegisterCode || '', + + // Extras + PrintEta: printEta, + CompletionTime: completionTime, + FormattedRunTime: formattedRunTime, + FormattedTotalRunTime: formattedTotalRunTime, + }; + } catch (error: unknown) { + console.error('Error in MachineInfo.fromDetail:', (error as Error).message); + console.error('Detail object causing error:', JSON.stringify(detail, null, 2)); // Log detail on error + return null; } - - /** - * Formats a duration given in seconds into a "HH:MM" string format. - * - * @param seconds The total number of seconds to format. - * @returns A string representing the formatted time (e.g., "02:30" for 9000 seconds). - * Returns "00:00" if the input is invalid or an error occurs. - * @private - */ - private formatTimeFromSeconds(seconds: number): string { - try { - const validSeconds = typeof seconds === 'number' ? seconds : 0; - const hours = Math.floor(validSeconds / 3600); - const minutes = Math.floor((validSeconds % 3600) / 60); - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; - } catch (error) { - console.error("Error formatting time:", error); - return "00:00"; - } + } + + /** + * Formats a duration given in seconds into a "HH:MM" string format. + * + * @param seconds The total number of seconds to format. + * @returns A string representing the formatted time (e.g., "02:30" for 9000 seconds). + * Returns "00:00" if the input is invalid or an error occurs. + * @private + */ + private formatTimeFromSeconds(seconds: number): string { + try { + const validSeconds = typeof seconds === 'number' ? seconds : 0; + const hours = Math.floor(validSeconds / 3600); + const minutes = Math.floor((validSeconds % 3600) / 60); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } catch (error) { + console.error('Error formatting time:', error); + return '00:00'; } - - /** - * Maps a raw status string from the printer API to a `MachineState` enum value. - * Handles various known status strings and defaults to `MachineState.Unknown` for unrecognized statuses, - * logging a warning in such cases. - * - * @param status The raw status string (e.g., "ready", "printing", "error"). Case-insensitive. - * @returns The corresponding `MachineState` enum value. - * @private - */ - private getMachineState(status: string): MachineState { - const validStatus = typeof status === 'string' ? status.toLowerCase() : ''; - switch (validStatus) { - case "ready": return MachineState.Ready; - case "busy": return MachineState.Busy; - case "calibrate_doing": return MachineState.Calibrating; - case "error": return MachineState.Error; - case "heating": return MachineState.Heating; - case "printing": return MachineState.Printing; - case "pausing": return MachineState.Pausing; - case "paused": return MachineState.Paused; - case "cancel": return MachineState.Cancelled; - case "completed": return MachineState.Completed; - default: - if (validStatus) { - console.warn(`Unknown machine status received: '${status}'`); - } - return MachineState.Unknown; + } + + /** + * Maps a raw status string from the printer API to a `MachineState` enum value. + * Handles various known status strings and defaults to `MachineState.Unknown` for unrecognized statuses, + * logging a warning in such cases. + * + * @param status The raw status string (e.g., "ready", "printing", "error"). Case-insensitive. + * @returns The corresponding `MachineState` enum value. + * @private + */ + private getMachineState(status: string): MachineState { + const validStatus = typeof status === 'string' ? status.toLowerCase() : ''; + switch (validStatus) { + case 'ready': + return MachineState.Ready; + case 'busy': + return MachineState.Busy; + case 'calibrate_doing': + return MachineState.Calibrating; + case 'error': + return MachineState.Error; + case 'heating': + return MachineState.Heating; + case 'printing': + return MachineState.Printing; + case 'pausing': + return MachineState.Pausing; + case 'paused': + return MachineState.Paused; + case 'cancel': + return MachineState.Cancelled; + case 'completed': + return MachineState.Completed; + default: + if (validStatus) { + console.warn(`Unknown machine status received: '${status}'`); } + return MachineState.Unknown; } -} \ No newline at end of file + } +} diff --git a/src/models/ff-models.ts b/src/models/ff-models.ts index a1ba268..d0549be 100644 --- a/src/models/ff-models.ts +++ b/src/models/ff-models.ts @@ -9,154 +9,154 @@ * All properties are optional as their presence can vary based on printer model, firmware, or current state. */ export interface FFPrinterDetail { - /** Status of the auto-shutdown feature (e.g., "open" for enabled, "close" for disabled). */ - autoShutdown?: string; - /** Configured time for auto-shutdown, often in minutes. */ - autoShutdownTime?: number; - /** URL for accessing the printer's camera stream, if available. */ - cameraStreamUrl?: string; - /** Current speed of the chamber fan, if applicable. */ - chamberFanSpeed?: number; - /** Target temperature for the chamber, if applicable. */ - chamberTargetTemp?: number; - /** Current temperature of the chamber, if applicable. */ - chamberTemp?: number; - /** Current speed of the part cooling fan (right fan for dual setups, or main fan). */ - coolingFanSpeed?: number; - /** Current speed of the left part cooling fan (for dual setups like AD5X). */ - coolingFanLeftSpeed?: number; - /** Total filament extruded by the printer over its lifetime, typically in millimeters or meters. */ - cumulativeFilament?: number; - /** Total print time accumulated by the printer over its lifetime, often in minutes. */ - cumulativePrintTime?: number; - /** Current printing speed, possibly as a percentage of the base speed. */ - currentPrintSpeed?: number; - /** Status of the printer's door (e.g., "open", "close"), if equipped with a sensor. */ - doorStatus?: string; - /** Current error code reported by the printer, if any. */ - errorCode?: string; - /** Estimated length of filament remaining for the left extruder for the current print job. */ - estimatedLeftLen?: number; - /** Estimated weight of filament remaining for the left extruder for the current print job. */ - estimatedLeftWeight?: number; - /** Estimated length of filament remaining for the right extruder (or single extruder) for the current print job. */ - estimatedRightLen?: number; - /** Estimated weight of filament remaining for the right extruder (or single extruder) for the current print job. */ - estimatedRightWeight?: number; - /** Estimated time remaining for the current print job, often in seconds. */ - estimatedTime?: number; - /** Status of the external fan (e.g., "open" for on, "close" for off). */ - externalFanStatus?: string; - /** Fill amount or density for the current print job. */ - fillAmount?: number; - /** Firmware version of the printer. */ - firmwareVersion?: string; - /** Registration code for FlashCloud services. */ - flashRegisterCode?: string; - /** Indicates if the printer has a material station (e.g., for AD5X). */ - hasMatlStation?: boolean; - /** Detailed information about the material station, if present. */ - matlStationInfo?: MatlStationInfo; - /** Information about independent material loading (e.g., for AD5X single extruder with material station). */ - indepMatlInfo?: IndepMatlInfo; - /** Indicates if filament is present in the left extruder/path. */ - hasLeftFilament?: boolean; - /** Indicates if filament is present in the right extruder/path. */ - hasRightFilament?: boolean; - /** Status of the internal fan (e.g., "open" for on, "close" for off). */ - internalFanStatus?: string; - /** IP address of the printer on the local network. */ - ipAddr?: string; - /** Type of filament loaded in the left extruder (e.g., "PLA", "ABS"). */ - leftFilamentType?: string; - /** Target temperature for the left extruder. */ - leftTargetTemp?: number; - /** Current temperature of the left extruder. */ - leftTemp?: number; - /** Status of the printer's LED lights (e.g., "open" for on, "close" for off). */ - lightStatus?: string; - /** Physical location of the printer, if set. */ - location?: string; - /** MAC address of the printer's network interface. */ - macAddr?: string; - /** Measurement unit system (e.g., "metric"). */ - measure?: string; - /** Name of the printer, as configured by the user. */ - name?: string; - /** Number of nozzles the printer has. */ - nozzleCnt?: number; - /** Model or size of the nozzle (e.g., "0.4mm"). */ - nozzleModel?: string; - /** Style or type of the nozzle. */ - nozzleStyle?: number; - /** Process ID, possibly related to the current print job. */ - pid?: number; - /** Target temperature for the print bed (platform). */ - platTargetTemp?: number; - /** Current temperature of the print bed (platform). */ - platTemp?: number; - /** Registration code for Polar Cloud services. */ - polarRegisterCode?: string; - /** Duration of the current print job so far, often in seconds. */ - printDuration?: number; - /** Name of the file currently being printed. */ - printFileName?: string; - /** URL for the thumbnail image of the currently printing file. */ - printFileThumbUrl?: string; - /** Current layer number being printed. */ - printLayer?: number; - /** Progress of the current print job, typically as a decimal (0.0 to 1.0) or percentage. */ - printProgress?: number; - /** Adjustment factor for the print speed, often as a percentage. */ - printSpeedAdjust?: number; - /** Remaining disk space on the printer's internal storage, if applicable. */ - remainingDiskSpace?: number; - /** Type of filament loaded in the right extruder (or single extruder). */ - rightFilamentType?: string; - /** Target temperature for the right extruder (or single extruder). */ - rightTargetTemp?: number; - /** Current temperature of the right extruder (or single extruder). */ - rightTemp?: number; - /** Current operational status of the printer (e.g., "ready", "printing", "error"). */ - status?: string; - /** Total number of layers for the current print job. */ - targetPrintLayer?: number; - /** Total Volatile Organic Compounds (TVOC) level, if measured by the printer. */ - tvoc?: number; - /** Current Z-axis compensation value. */ - zAxisCompensation?: number; + /** Status of the auto-shutdown feature (e.g., "open" for enabled, "close" for disabled). */ + autoShutdown?: string; + /** Configured time for auto-shutdown, often in minutes. */ + autoShutdownTime?: number; + /** URL for accessing the printer's camera stream, if available. */ + cameraStreamUrl?: string; + /** Current speed of the chamber fan, if applicable. */ + chamberFanSpeed?: number; + /** Target temperature for the chamber, if applicable. */ + chamberTargetTemp?: number; + /** Current temperature of the chamber, if applicable. */ + chamberTemp?: number; + /** Current speed of the part cooling fan (right fan for dual setups, or main fan). */ + coolingFanSpeed?: number; + /** Current speed of the left part cooling fan (for dual setups like AD5X). */ + coolingFanLeftSpeed?: number; + /** Total filament extruded by the printer over its lifetime, typically in millimeters or meters. */ + cumulativeFilament?: number; + /** Total print time accumulated by the printer over its lifetime, often in minutes. */ + cumulativePrintTime?: number; + /** Current printing speed, possibly as a percentage of the base speed. */ + currentPrintSpeed?: number; + /** Status of the printer's door (e.g., "open", "close"), if equipped with a sensor. */ + doorStatus?: string; + /** Current error code reported by the printer, if any. */ + errorCode?: string; + /** Estimated length of filament remaining for the left extruder for the current print job. */ + estimatedLeftLen?: number; + /** Estimated weight of filament remaining for the left extruder for the current print job. */ + estimatedLeftWeight?: number; + /** Estimated length of filament remaining for the right extruder (or single extruder) for the current print job. */ + estimatedRightLen?: number; + /** Estimated weight of filament remaining for the right extruder (or single extruder) for the current print job. */ + estimatedRightWeight?: number; + /** Estimated time remaining for the current print job, often in seconds. */ + estimatedTime?: number; + /** Status of the external fan (e.g., "open" for on, "close" for off). */ + externalFanStatus?: string; + /** Fill amount or density for the current print job. */ + fillAmount?: number; + /** Firmware version of the printer. */ + firmwareVersion?: string; + /** Registration code for FlashCloud services. */ + flashRegisterCode?: string; + /** Indicates if the printer has a material station (e.g., for AD5X). */ + hasMatlStation?: boolean; + /** Detailed information about the material station, if present. */ + matlStationInfo?: MatlStationInfo; + /** Information about independent material loading (e.g., for AD5X single extruder with material station). */ + indepMatlInfo?: IndepMatlInfo; + /** Indicates if filament is present in the left extruder/path. */ + hasLeftFilament?: boolean; + /** Indicates if filament is present in the right extruder/path. */ + hasRightFilament?: boolean; + /** Status of the internal fan (e.g., "open" for on, "close" for off). */ + internalFanStatus?: string; + /** IP address of the printer on the local network. */ + ipAddr?: string; + /** Type of filament loaded in the left extruder (e.g., "PLA", "ABS"). */ + leftFilamentType?: string; + /** Target temperature for the left extruder. */ + leftTargetTemp?: number; + /** Current temperature of the left extruder. */ + leftTemp?: number; + /** Status of the printer's LED lights (e.g., "open" for on, "close" for off). */ + lightStatus?: string; + /** Physical location of the printer, if set. */ + location?: string; + /** MAC address of the printer's network interface. */ + macAddr?: string; + /** Measurement unit system (e.g., "metric"). */ + measure?: string; + /** Name of the printer, as configured by the user. */ + name?: string; + /** Number of nozzles the printer has. */ + nozzleCnt?: number; + /** Model or size of the nozzle (e.g., "0.4mm"). */ + nozzleModel?: string; + /** Style or type of the nozzle. */ + nozzleStyle?: number; + /** Process ID, possibly related to the current print job. */ + pid?: number; + /** Target temperature for the print bed (platform). */ + platTargetTemp?: number; + /** Current temperature of the print bed (platform). */ + platTemp?: number; + /** Registration code for Polar Cloud services. */ + polarRegisterCode?: string; + /** Duration of the current print job so far, often in seconds. */ + printDuration?: number; + /** Name of the file currently being printed. */ + printFileName?: string; + /** URL for the thumbnail image of the currently printing file. */ + printFileThumbUrl?: string; + /** Current layer number being printed. */ + printLayer?: number; + /** Progress of the current print job, typically as a decimal (0.0 to 1.0) or percentage. */ + printProgress?: number; + /** Adjustment factor for the print speed, often as a percentage. */ + printSpeedAdjust?: number; + /** Remaining disk space on the printer's internal storage, if applicable. */ + remainingDiskSpace?: number; + /** Type of filament loaded in the right extruder (or single extruder). */ + rightFilamentType?: string; + /** Target temperature for the right extruder (or single extruder). */ + rightTargetTemp?: number; + /** Current temperature of the right extruder (or single extruder). */ + rightTemp?: number; + /** Current operational status of the printer (e.g., "ready", "printing", "error"). */ + status?: string; + /** Total number of layers for the current print job. */ + targetPrintLayer?: number; + /** Total Volatile Organic Compounds (TVOC) level, if measured by the printer. */ + tvoc?: number; + /** Current Z-axis compensation value. */ + zAxisCompensation?: number; } /** * Information about a single slot in the material station. */ export interface SlotInfo { - /** Indicates if filament is present in this slot. */ - hasFilament: boolean; - /** Color of the material in this slot (e.g., "#FFFFFF"). */ - materialColor: string; - /** Name of the material in this slot (e.g., "PLA"). */ - materialName: string; - /** Identifier for this slot. */ - slotId: number; + /** Indicates if filament is present in this slot. */ + hasFilament: boolean; + /** Color of the material in this slot (e.g., "#FFFFFF"). */ + materialColor: string; + /** Name of the material in this slot (e.g., "PLA"). */ + materialName: string; + /** Identifier for this slot. */ + slotId: number; } /** * Detailed information about the material station. */ export interface MatlStationInfo { - /** Currently loading slot ID (0 if none). */ - currentLoadSlot: number; - /** Currently active/printing slot ID (0 if none). */ - currentSlot: number; - /** Total number of slots in the station. */ - slotCnt: number; - /** Array of information for each slot. */ - slotInfos: SlotInfo[]; - /** Current action state of the material station. */ - stateAction: number; - /** Current step within the state action. */ - stateStep: number; + /** Currently loading slot ID (0 if none). */ + currentLoadSlot: number; + /** Currently active/printing slot ID (0 if none). */ + currentSlot: number; + /** Total number of slots in the station. */ + slotCnt: number; + /** Array of information for each slot. */ + slotInfos: SlotInfo[]; + /** Current action state of the material station. */ + stateAction: number; + /** Current step within the state action. */ + stateStep: number; } /** @@ -164,14 +164,14 @@ export interface MatlStationInfo { * often used when a single extruder printer has a material station. */ export interface IndepMatlInfo { - /** Color of the material. */ - materialColor: string; - /** Name of the material (can be "?" if unknown). */ - materialName: string; - /** Current action state. */ - stateAction: number; - /** Current step within the state action. */ - stateStep: number; + /** Color of the material. */ + materialColor: string; + /** Name of the material (can be "?" if unknown). */ + materialName: string; + /** Current action state. */ + stateAction: number; + /** Current step within the state action. */ + stateStep: number; } /** @@ -180,160 +180,160 @@ export interface IndepMatlInfo { * It uses clearer property names and boolean types for states. */ export interface FFMachineInfo { - /** Indicates if auto-shutdown is enabled. */ - AutoShutdown: boolean; - /** Configured time for auto-shutdown in minutes. */ - AutoShutdownTime: number; + /** Indicates if auto-shutdown is enabled. */ + AutoShutdown: boolean; + /** Configured time for auto-shutdown in minutes. */ + AutoShutdownTime: number; - /** URL for the printer's camera stream. */ - CameraStreamUrl: string; + /** URL for the printer's camera stream. */ + CameraStreamUrl: string; - /** Current speed of the chamber fan. */ - ChamberFanSpeed: number; - /** Current speed of the part cooling fan (right or main). */ - CoolingFanSpeed: number; - /** Current speed of the left part cooling fan (if applicable). */ - CoolingFanLeftSpeed?: number; + /** Current speed of the chamber fan. */ + ChamberFanSpeed: number; + /** Current speed of the part cooling fan (right or main). */ + CoolingFanSpeed: number; + /** Current speed of the left part cooling fan (if applicable). */ + CoolingFanLeftSpeed?: number; - /** Total filament extruded over the printer's lifetime (unit depends on source, e.g., mm or m). */ - CumulativeFilament: number; - /** Total print time accumulated over the printer's lifetime (often in minutes). */ - CumulativePrintTime: number; + /** Total filament extruded over the printer's lifetime (unit depends on source, e.g., mm or m). */ + CumulativeFilament: number; + /** Total print time accumulated over the printer's lifetime (often in minutes). */ + CumulativePrintTime: number; - /** Current printing speed (interpretation depends on source, could be percentage or absolute). */ - CurrentPrintSpeed: number; + /** Current printing speed (interpretation depends on source, could be percentage or absolute). */ + CurrentPrintSpeed: number; - /** Free disk space on the printer's internal storage, formatted as a string (e.g., "123.45MB"). */ - FreeDiskSpace: string; + /** Free disk space on the printer's internal storage, formatted as a string (e.g., "123.45MB"). */ + FreeDiskSpace: string; - /** Indicates if the printer's door is open. */ - DoorOpen: boolean; - /** Current error code, if any. */ - ErrorCode: string; + /** Indicates if the printer's door is open. */ + DoorOpen: boolean; + /** Current error code, if any. */ + ErrorCode: string; - /** Estimated filament length used for the current print job so far (typically in meters). */ - EstLength: number; - /** Estimated filament weight used for the current print job so far (typically in grams). */ - EstWeight: number; - /** Estimated time remaining for the current print job (often in seconds). */ - EstimatedTime: number; + /** Estimated filament length used for the current print job so far (typically in meters). */ + EstLength: number; + /** Estimated filament weight used for the current print job so far (typically in grams). */ + EstWeight: number; + /** Estimated time remaining for the current print job (often in seconds). */ + EstimatedTime: number; - /** Indicates if the external fan is on. */ - ExternalFanOn: boolean; - /** Indicates if the internal fan is on. */ - InternalFanOn: boolean; - /** Indicates if the printer's LED lights are on. */ - LightsOn: boolean; + /** Indicates if the external fan is on. */ + ExternalFanOn: boolean; + /** Indicates if the internal fan is on. */ + InternalFanOn: boolean; + /** Indicates if the printer's LED lights are on. */ + LightsOn: boolean; - /** IP address of the printer. */ - IpAddress: string; - /** MAC address of the printer. */ - MacAddress: string; + /** IP address of the printer. */ + IpAddress: string; + /** MAC address of the printer. */ + MacAddress: string; - /** Fill amount or density for the current print job. */ - FillAmount: number; - /** Firmware version of the printer. */ - FirmwareVersion: string; - /** User-configured name of the printer. */ - Name: string; - /** Indicates if the printer model is a "Pro" version. */ - IsPro: boolean; - /** Indicates if the printer is an AD5X model. */ - IsAD5X: boolean; - /** Nozzle size (e.g., "0.4mm"). */ - NozzleSize: string; + /** Fill amount or density for the current print job. */ + FillAmount: number; + /** Firmware version of the printer. */ + FirmwareVersion: string; + /** User-configured name of the printer. */ + Name: string; + /** Indicates if the printer model is a "Pro" version. */ + IsPro: boolean; + /** Indicates if the printer is an AD5X model. */ + IsAD5X: boolean; + /** Nozzle size (e.g., "0.4mm"). */ + NozzleSize: string; - /** Current and target temperatures for the print bed. See {@link Temperature}. */ - PrintBed: Temperature; - /** Current and target temperatures for the extruder. See {@link Temperature}. */ - Extruder: Temperature; + /** Current and target temperatures for the print bed. See {@link Temperature}. */ + PrintBed: Temperature; + /** Current and target temperatures for the extruder. See {@link Temperature}. */ + Extruder: Temperature; - /** Duration of the current print job so far (often in seconds). */ - PrintDuration: number; - /** Name of the file currently being printed. */ - PrintFileName: string; - /** URL for the thumbnail of the file currently being printed. */ - PrintFileThumbUrl: string; - /** Current layer number being printed. */ - CurrentPrintLayer: number; - /** Progress of the current print job (0.0 to 1.0). */ - PrintProgress: number; - /** Integer representation of print progress (0 to 100). */ - PrintProgressInt: number; - /** Print speed adjustment factor (often a percentage). */ - PrintSpeedAdjust: number; - /** Type of filament currently loaded/printing (e.g., "PLA"). */ - FilamentType: string; + /** Duration of the current print job so far (often in seconds). */ + PrintDuration: number; + /** Name of the file currently being printed. */ + PrintFileName: string; + /** URL for the thumbnail of the file currently being printed. */ + PrintFileThumbUrl: string; + /** Current layer number being printed. */ + CurrentPrintLayer: number; + /** Progress of the current print job (0.0 to 1.0). */ + PrintProgress: number; + /** Integer representation of print progress (0 to 100). */ + PrintProgressInt: number; + /** Print speed adjustment factor (often a percentage). */ + PrintSpeedAdjust: number; + /** Type of filament currently loaded/printing (e.g., "PLA"). */ + FilamentType: string; - /** Current state of the machine. See {@link MachineState}. */ - MachineState: MachineState; - /** Raw status string from the printer. */ - Status: string; - /** Total number of layers for the current print job. */ - TotalPrintLayers: number; - /** TVOC (Total Volatile Organic Compounds) level, if available. */ - Tvoc: number; - /** Current Z-axis compensation value. */ - ZAxisCompensation: number; + /** Current state of the machine. See {@link MachineState}. */ + MachineState: MachineState; + /** Raw status string from the printer. */ + Status: string; + /** Total number of layers for the current print job. */ + TotalPrintLayers: number; + /** TVOC (Total Volatile Organic Compounds) level, if available. */ + Tvoc: number; + /** Current Z-axis compensation value. */ + ZAxisCompensation: number; - /** Registration code for FlashCloud services. */ - FlashCloudRegisterCode: string; - /** Registration code for Polar Cloud services. */ - PolarCloudRegisterCode: string; + /** Registration code for FlashCloud services. */ + FlashCloudRegisterCode: string; + /** Registration code for Polar Cloud services. */ + PolarCloudRegisterCode: string; - /** Estimated time of arrival for the current print, formatted as a string (e.g., "HH:MM"). */ - PrintEta: string; - /** Calculated completion time of the current print as a Date object. */ - CompletionTime: Date; - /** Formatted string of the current print job's duration (e.g., "HH:MM"). */ - FormattedRunTime: string; - /** Formatted string of the printer's total accumulated run time (e.g., "Xh:Ym"). */ - FormattedTotalRunTime: string; + /** Estimated time of arrival for the current print, formatted as a string (e.g., "HH:MM"). */ + PrintEta: string; + /** Calculated completion time of the current print as a Date object. */ + CompletionTime: Date; + /** Formatted string of the current print job's duration (e.g., "HH:MM"). */ + FormattedRunTime: string; + /** Formatted string of the printer's total accumulated run time (e.g., "Xh:Ym"). */ + FormattedTotalRunTime: string; - /** Indicates if the printer has a material station. */ - HasMatlStation?: boolean; - /** Detailed information about the material station, if present. */ - MatlStationInfo?: MatlStationInfo; // Using the raw type directly for now - /** Information about independent material loading. */ - IndepMatlInfo?: IndepMatlInfo; // Using the raw type directly for now + /** Indicates if the printer has a material station. */ + HasMatlStation?: boolean; + /** Detailed information about the material station, if present. */ + MatlStationInfo?: MatlStationInfo; // Using the raw type directly for now + /** Information about independent material loading. */ + IndepMatlInfo?: IndepMatlInfo; // Using the raw type directly for now } /** * Represents a pair of current and target temperatures for a component like an extruder or print bed. */ export interface Temperature { - /** The current temperature in Celsius. */ - current: number; - /** The target (set) temperature in Celsius. */ - set: number; + /** The current temperature in Celsius. */ + current: number; + /** The target (set) temperature in Celsius. */ + set: number; } /** * Enumerates the possible operational states of the FlashForge 3D printer. */ export enum MachineState { - /** Printer is ready for a new command or job. */ - Ready, - /** Printer is busy with an operation (general busy state). */ - Busy, - /** Printer is currently performing a calibration routine. */ - Calibrating, - /** Printer has encountered an error. Check `ErrorCode` in `FFMachineInfo`. */ - Error, - /** Printer is heating a component (extruder or bed). */ - Heating, - /** Printer is actively printing. */ - Printing, - /** Printer is in the process of pausing a print job. */ - Pausing, - /** Printer's print job is currently paused. */ - Paused, - /** Printer's print job has been cancelled. */ - Cancelled, - /** Printer has successfully completed a print job. */ - Completed, - /** Printer state is unknown or cannot be determined. */ - Unknown + /** Printer is ready for a new command or job. */ + Ready, + /** Printer is busy with an operation (general busy state). */ + Busy, + /** Printer is currently performing a calibration routine. */ + Calibrating, + /** Printer has encountered an error. Check `ErrorCode` in `FFMachineInfo`. */ + Error, + /** Printer is heating a component (extruder or bed). */ + Heating, + /** Printer is actively printing. */ + Printing, + /** Printer is in the process of pausing a print job. */ + Pausing, + /** Printer's print job is currently paused. */ + Paused, + /** Printer's print job has been cancelled. */ + Cancelled, + /** Printer has successfully completed a print job. */ + Completed, + /** Printer state is unknown or cannot be determined. */ + Unknown, } // --- Interfaces for Gcode List Entries (AD5X and similar) --- @@ -343,16 +343,16 @@ export enum MachineState { * typically part of a multi-material print. */ export interface FFGcodeToolData { - /** Calculated filament weight for this tool/material in the print. */ - filamentWeight: number; - /** Material color hex string (e.g., "#FFFF00"). */ - materialColor: string; - /** Name of the material (e.g., "PLA"). */ - materialName: string; - /** Slot ID from the material station, if applicable (0 if not or direct). */ - slotId: number; - /** Tool ID or extruder number. */ - toolId: number; + /** Calculated filament weight for this tool/material in the print. */ + filamentWeight: number; + /** Material color hex string (e.g., "#FFFF00"). */ + materialColor: string; + /** Name of the material (e.g., "PLA"). */ + materialName: string; + /** Slot ID from the material station, if applicable (0 if not or direct). */ + slotId: number; + /** Tool ID or extruder number. */ + toolId: number; } /** @@ -360,20 +360,20 @@ export interface FFGcodeToolData { * especially for printers like AD5X that provide detailed material info. */ export interface FFGcodeFileEntry { - /** The name of the G-code file (e.g., "FISH_PLA.3mf"). */ - gcodeFileName: string; - /** Number of tools/materials used in this G-code file. */ - gcodeToolCnt?: number; - /** Array of detailed information for each tool/material. */ - gcodeToolDatas?: FFGcodeToolData[]; - /** Estimated printing time in seconds. */ - printingTime: number; // Assuming this is seconds, as is common - /** Total estimated filament weight for the print. */ - totalFilamentWeight?: number; - /** Indicates if the G-code file is intended for use with a material station. */ - useMatlStation?: boolean; - // Potentially other fields might exist for non-AD5X printers in a simpler format - // For now, focusing on AD5X structure. + /** The name of the G-code file (e.g., "FISH_PLA.3mf"). */ + gcodeFileName: string; + /** Number of tools/materials used in this G-code file. */ + gcodeToolCnt?: number; + /** Array of detailed information for each tool/material. */ + gcodeToolDatas?: FFGcodeToolData[]; + /** Estimated printing time in seconds. */ + printingTime: number; // Assuming this is seconds, as is common + /** Total estimated filament weight for the print. */ + totalFilamentWeight?: number; + /** Indicates if the G-code file is intended for use with a material station. */ + useMatlStation?: boolean; + // Potentially other fields might exist for non-AD5X printers in a simpler format + // For now, focusing on AD5X structure. } // --- AD5X Local Job Start Interfaces --- @@ -383,16 +383,16 @@ export interface FFGcodeFileEntry { * Maps a tool (extruder) to a specific material station slot. */ export interface AD5XMaterialMapping { - /** Tool ID (0-based: 0, 1, 2, 3) */ - toolId: number; - /** Slot ID (1-based: 1, 2, 3, 4) */ - slotId: number; - /** Name of the material (e.g., "PLA", "SILK") */ - materialName: string; - /** Hex color code for the tool material (e.g., "#FFFFFF") */ - toolMaterialColor: string; - /** Hex color code for the slot material (e.g., "#46328E") */ - slotMaterialColor: string; + /** Tool ID (0-based: 0, 1, 2, 3) */ + toolId: number; + /** Slot ID (1-based: 1, 2, 3, 4) */ + slotId: number; + /** Name of the material (e.g., "PLA", "SILK") */ + materialName: string; + /** Hex color code for the tool material (e.g., "#FFFFFF") */ + toolMaterialColor: string; + /** Hex color code for the slot material (e.g., "#46328E") */ + slotMaterialColor: string; } /** @@ -400,12 +400,12 @@ export interface AD5XMaterialMapping { * Used for multi-color prints that utilize the material station. */ export interface AD5XLocalJobParams { - /** Name of the file on the printer to start */ - fileName: string; - /** Whether to perform bed leveling before printing */ - levelingBeforePrint: boolean; - /** Array of material mappings (1-4 items) */ - materialMappings: AD5XMaterialMapping[]; + /** Name of the file on the printer to start */ + fileName: string; + /** Whether to perform bed leveling before printing */ + levelingBeforePrint: boolean; + /** Array of material mappings (1-4 items) */ + materialMappings: AD5XMaterialMapping[]; } /** @@ -413,10 +413,10 @@ export interface AD5XLocalJobParams { * Used for single-color prints that do not require the material station. */ export interface AD5XSingleColorJobParams { - /** Name of the file on the printer to start */ - fileName: string; - /** Whether to perform bed leveling before printing */ - levelingBeforePrint: boolean; + /** Name of the file on the printer to start */ + fileName: string; + /** Whether to perform bed leveling before printing */ + levelingBeforePrint: boolean; } /** @@ -425,18 +425,18 @@ export interface AD5XSingleColorJobParams { * flow calibration, and first layer inspection. */ export interface AD5XUploadParams { - /** Local file path to upload */ - filePath: string; - /** Whether to start printing immediately after upload */ - startPrint: boolean; - /** Whether to perform bed leveling before printing */ - levelingBeforePrint: boolean; - /** Whether to enable flow calibration */ - flowCalibration: boolean; - /** Whether to enable first layer inspection */ - firstLayerInspection: boolean; - /** Whether to enable time lapse video recording */ - timeLapseVideo: boolean; - /** Array of material mappings for the material station (1-4 items) */ - materialMappings: AD5XMaterialMapping[]; -} \ No newline at end of file + /** Local file path to upload */ + filePath: string; + /** Whether to start printing immediately after upload */ + startPrint: boolean; + /** Whether to perform bed leveling before printing */ + levelingBeforePrint: boolean; + /** Whether to enable flow calibration */ + flowCalibration: boolean; + /** Whether to enable first layer inspection */ + firstLayerInspection: boolean; + /** Whether to enable time lapse video recording */ + timeLapseVideo: boolean; + /** Array of material mappings for the material station (1-4 items) */ + materialMappings: AD5XMaterialMapping[]; +} diff --git a/src/tcpapi/FlashForgeClient.ts b/src/tcpapi/FlashForgeClient.ts index d6bb36b..cf18485 100644 --- a/src/tcpapi/FlashForgeClient.ts +++ b/src/tcpapi/FlashForgeClient.ts @@ -3,411 +3,416 @@ * workflows (LED, job management, homing, temperature, filament) via G-code commands. */ // src/tcpapi/FlashForgeClient.ts -import { FlashForgeTcpClient } from './FlashForgeTcpClient'; -import { GCodes } from './client/GCodes'; + +import type { Filament } from '../api/filament/Filament'; import { GCodeController } from './client/GCodeController'; -import { PrinterInfo } from './replays/PrinterInfo'; -import { TempInfo } from './replays/TempInfo'; +import { GCodes } from './client/GCodes'; +import { FlashForgeTcpClient } from './FlashForgeTcpClient'; import { EndstopStatus } from './replays/EndstopStatus'; -import { PrintStatus } from './replays/PrintStatus'; import { LocationInfo } from './replays/LocationInfo'; +import { PrinterInfo } from './replays/PrinterInfo'; +import { PrintStatus } from './replays/PrintStatus'; +import { TempInfo } from './replays/TempInfo'; import { ThumbnailInfo } from './replays/ThumbnailInfo'; -import { Filament } from '../api/filament/Filament'; -import path from "node:path"; export class FlashForgeClient extends FlashForgeTcpClient { - /** Controller for sending specific G-code commands. */ - private control: GCodeController; - /** Flag indicating if the connected printer is a 5M Pro model, which may have specific features. */ - private is5mPro: boolean = false; - - /** - * Creates an instance of FlashForgeClient. - * @param hostname The IP address or hostname of the FlashForge printer. - */ - constructor(hostname: string) { - super(hostname); - this.control = new GCodeController(this); - } - - /** - * Gets the IP address or hostname of the connected printer. - * @returns The printer's hostname or IP address. - */ - public getIp(): string { - return this.hostname; - } - - /** - * Gets the GCodeController instance associated with this client, - * providing access to specific G-code command methods. - * @returns The `GCodeController` instance. - */ - public gCode(): GCodeController { - return this.control; - } - - /** - * Initializes the control connection with the printer. - * This typically involves sending a login command, retrieving printer info, - * and starting a keep-alive mechanism. Retries on failure. - * @returns A Promise that resolves to true if control is successfully initialized, false otherwise. - */ - public async initControl(): Promise { - console.log("(Legacy API) InitControl()"); - let tries = 0; - while (tries <= 3) { - const result = await this.sendRawCmd(GCodes.CmdLogin); - if (result && !result.includes("Control failed.") && result.includes("ok")) { - await sleep(100); - const info = await this.getPrinterInfo(); - if (!info) { - console.log("(Legacy API) Failed to get printer info, aborting."); - return false; - } - console.log("(Legacy API) connected to: " + info.TypeName); - console.log("(Legacy API) Firmware version: " + info.FirmwareVersion); - if (info.TypeName.includes("5M") && info.TypeName.includes("Pro")) { - this.is5mPro = true; - } - this.startKeepAlive(); - return true; - } - tries++; - // ensures no errors from previous connections that were improperly closed - await this.sendRawCmd(GCodes.CmdLogout); - await sleep(500 * tries); - } - return false; - } - - /** - * Turns the printer's LED lights on. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async ledOn(): Promise { return await this.control.ledOn(); } - - /** - * Turns the printer's LED lights off. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async ledOff(): Promise { return await this.control.ledOff(); } - - /** - * Pauses the current print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async pauseJob(): Promise { return await this.control.pauseJob(); } - - /** - * Resumes a paused print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async resumeJob(): Promise { return await this.control.resumeJob(); } - - /** - * Stops the current print job. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async stopJob(): Promise { return await this.control.stopJob(); } - - /** - * Starts a print job from a file stored on the printer. - * @param name The name of the file to print (typically without path). - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async startJob(name: string): Promise { return await this.control.startJob(name); } - - /** - * Homes all axes (X, Y, Z) of the printer. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async homeAxes(): Promise { return await this.control.home(); } - - /** - * Performs a rapid homing of all axes. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async rapidHome(): Promise { return await this.control.rapidHome(); } - - /** - * Turns on the filament runout sensor. - * This functionality is only available on specific printer models (e.g., 5M Pro). - * @returns A Promise that resolves to true if the command is successful and applicable, false otherwise. - */ - public async turnRunoutSensorOn(): Promise { - if (this.is5mPro) { - return await this.sendCmdOk(GCodes.CmdRunoutSensorOn); + /** Controller for sending specific G-code commands. */ + private control: GCodeController; + /** Flag indicating if the connected printer is a 5M Pro model, which may have specific features. */ + private is5mPro: boolean = false; + + /** + * Creates an instance of FlashForgeClient. + * @param hostname The IP address or hostname of the FlashForge printer. + */ + constructor(hostname: string) { + super(hostname); + this.control = new GCodeController(this); + } + + /** + * Gets the IP address or hostname of the connected printer. + * @returns The printer's hostname or IP address. + */ + public getIp(): string { + return this.hostname; + } + + /** + * Gets the GCodeController instance associated with this client, + * providing access to specific G-code command methods. + * @returns The `GCodeController` instance. + */ + public gCode(): GCodeController { + return this.control; + } + + /** + * Initializes the control connection with the printer. + * This typically involves sending a login command, retrieving printer info, + * and starting a keep-alive mechanism. Retries on failure. + * @returns A Promise that resolves to true if control is successfully initialized, false otherwise. + */ + public async initControl(): Promise { + console.log('(Legacy API) InitControl()'); + let tries = 0; + while (tries <= 3) { + const result = await this.sendRawCmd(GCodes.CmdLogin); + if (result && !result.includes('Control failed.') && result.includes('ok')) { + await sleep(100); + const info = await this.getPrinterInfo(); + if (!info) { + console.log('(Legacy API) Failed to get printer info, aborting.'); + return false; } - console.log("Filament runout sensor not equipped on this printer."); - return false; - } - - /** - * Turns off the filament runout sensor. - * This functionality is only available on specific printer models (e.g., 5M Pro). - * @returns A Promise that resolves to true if the command is successful and applicable, false otherwise. - */ - public async turnRunoutSensorOff(): Promise { - if (this.is5mPro) { - return await this.sendCmdOk(GCodes.CmdRunoutSensorOff); - } - console.log("Filament runout sensor not equipped on this printer."); - return false; - } - - /** - * Sets the target temperature for the extruder. - * @param temp The target temperature in Celsius. - * @param waitFor If true, the method will wait until the target temperature is reached. Defaults to false. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setExtruderTemp(temp: number, waitFor: boolean = false): Promise { - return await this.control.setExtruderTemp(temp, waitFor); - } - - /** - * Cancels extruder heating and sets its target temperature to 0. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async cancelExtruderTemp(): Promise { - return await this.control.cancelExtruderTemp(); - } - - /** - * Sets the target temperature for the print bed. - * @param temp The target temperature in Celsius. - * @param waitFor If true, the method will wait until the target temperature is reached. Defaults to false. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async setBedTemp(temp: number, waitFor: boolean = false): Promise { - return await this.control.setBedTemp(temp, waitFor); - } - - /** - * Cancels print bed heating and sets its target temperature to 0. - * @param waitForCool If true, waits for the bed to cool down after canceling. Defaults to false. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async cancelBedTemp(waitForCool: boolean = false): Promise { - return await this.control.cancelBedTemp(waitForCool); - } - - /** - * Commands the extruder to extrude a specific length of filament. - * Uses G1 E[length] F[feedrate] command. - * @param length The length of filament to extrude in millimeters. - * @param feedrate The feedrate for extrusion in mm/min. Defaults to 450. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async extrude(length: number, feedrate: number = 450): Promise { - return await this.sendCmdOk(`~G1 E${length} F${feedrate}`); - } - - /** - * Moves the extruder to a specified X, Y position. - * Uses G1 X[x] Y[y] F[feedrate] command. - * @param x The target X coordinate. - * @param y The target Y coordinate. - * @param feedrate The feedrate for the movement in mm/min. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async moveExtruder(x: number, y: number, feedrate: number): Promise { - return await this.sendCmdOk(`~G1 X${x} Y${y} F${feedrate}`); - } - - /** - * Moves the extruder to a specified X, Y, Z position. - * Uses G1 X[x] Y[y] Z[z] F[feedrate] command. - * @param x The target X coordinate. - * @param y The target Y coordinate. - * @param z The target Z coordinate. - * @param feedrate The feedrate for the movement in mm/min. - * @returns A Promise that resolves to true if the command is successful, false otherwise. - */ - public async move(x: number, y: number, z: number, feedrate: number): Promise { - return await this.sendCmdOk(`~G1 X${x} Y${y} Z${z} F${feedrate}`); - } - - /** - * Prepares the printer for filament loading. - * This involves canceling current extruder temperature, setting absolute mode, homing axes, - * moving the extruder to a safe position, heating the extruder to the filament's load temperature, - * and then purging some filament. - * @param filament The `Filament` object containing details like load temperature. - * @returns A Promise that resolves to true if all preparation steps are successful, false otherwise. - */ - public async prepareFilamentLoad(filament: Filament): Promise { - if (!await this.cancelExtruderTemp()) return false; - if (!await this.sendCmdOk("~G90")) return false; // absolute mode ok - if (!await this.homeAxes()) return false; - // todo should probably adjust this feedrate for older printers.. - if (!await this.moveExtruder(0, 0, 9000)) return false; - if (!await this.setExtruderTemp(filament.loadTemp, true)) return false; // heat extruder (and wait for it) - return await this.extrude(300); // purge old filament - } - - /** - * Primes the nozzle by extruding a small amount of filament. - * Checks if the nozzle is hot enough before attempting to extrude. - * @returns A Promise that resolves to true if priming is successful, false otherwise. - * @private - */ - private async primeNozzle(): Promise { - if (await this.canExtrude()) return await this.extrude(125); - console.log("PrimeNozzle() failed, nozzle is not hot enough."); - return false; - } - - /** - * Loads filament by extruding a specified amount. - * Checks if the nozzle is hot enough before attempting to extrude. - * @returns A Promise that resolves to true if loading is successful, false otherwise. - */ - public async loadFilament(): Promise { - if (await this.canExtrude()) return await this.extrude(250); - console.log("LoadFilament() failed, nozzle is not hot enough."); - return false; - } - - /** - * Checks if the nozzle is hot enough to allow extrusion. - * @returns A Promise that resolves to true if the nozzle temperature is at or above 210°C, false otherwise. - * @private - */ - private async canExtrude(): Promise { - const nozzleTemp = await this.getNozzleTemp(); - // todo this might need adjustment? - return nozzleTemp >= 210; - } - - /** - * Finishes the filament loading process. - * This involves canceling extruder heating, waiting for a short period, and then homing the axes. - * @returns A Promise that resolves to true if finishing steps are successful, false otherwise. - */ - public async finishFilamentLoad(): Promise { - if (!await this.cancelExtruderTemp()) return false; - await sleep(5000); - return await this.homeAxes(); - } - - /** - * Sends a G-code/M-code command to the printer and checks for an "ok" response. - * Expects the printer's reply to include "Received." and "ok" to be considered successful. - * @param cmd The command string to send (e.g., "~M115"). - * @returns A Promise that resolves to true if the command is acknowledged with "ok", false otherwise or on error. - */ - public async sendCmdOk(cmd: string): Promise { - try { - const reply = await this.sendCommandAsync(cmd); - if (reply && reply.includes("Received.") && reply.includes("ok")) return true; - } catch (ex) { - console.log(`SendCmdOk exception sending cmd: ${cmd} : ${ex}`); - return false; + console.log(`(Legacy API) connected to: ${info.TypeName}`); + console.log(`(Legacy API) Firmware version: ${info.FirmwareVersion}`); + if (info.TypeName.includes('5M') && info.TypeName.includes('Pro')) { + this.is5mPro = true; } - return false; - } - - /** - * Sends a raw command string to the printer and returns the raw response. - * Handles a special case for "M661" (list files), which is processed differently. - * @param cmd The raw command string to send. - * @returns A Promise that resolves to the printer's raw string response, or an empty string on failure. - * For "M661", it returns a newline-separated list of files. - */ - public async sendRawCmd(cmd: string): Promise { - if (!cmd.includes("M661")) return await this.sendCommandAsync(cmd) || ''; - const list = await this.getFileListAsync(); - return list.join("\n"); - } - - /** - * Retrieves general printer information (model, firmware, etc.). - * Sends `GCodes.CmdInfoStatus` and parses the response into a `PrinterInfo` object. - * @returns A Promise that resolves to a `PrinterInfo` object, or null if retrieval fails. - */ - public async getPrinterInfo(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdInfoStatus); - return response ? new PrinterInfo().fromReplay(response) : null; - } - - /** - * Retrieves current temperature information (extruder, bed). - * Sends `GCodes.CmdTemp` and parses the response into a `TempInfo` object. - * @returns A Promise that resolves to a `TempInfo` object, or null if retrieval fails. - */ - public async getTempInfo(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdTemp); - return response ? new TempInfo().fromReplay(response) : null; - } - - /** - * Retrieves the status of the printer's endstops. - * Sends `GCodes.CmdEndstopInfo` and parses the response into an `EndstopStatus` object. - * @returns A Promise that resolves to an `EndstopStatus` object, or null if retrieval fails. - */ - public async getEndstopInfo(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdEndstopInfo); - return response ? new EndstopStatus().fromReplay(response) : null; + this.startKeepAlive(); + return true; + } + tries++; + // ensures no errors from previous connections that were improperly closed + await this.sendRawCmd(GCodes.CmdLogout); + await sleep(500 * tries); } - - /** - * Retrieves the current print job status. - * Sends `GCodes.CmdPrintStatus` and parses the response into a `PrintStatus` object. - * @returns A Promise that resolves to a `PrintStatus` object, or null if retrieval fails. - */ - public async getPrintStatus(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdPrintStatus); - return response ? new PrintStatus().fromReplay(response) : null; + return false; + } + + /** + * Turns the printer's LED lights on. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async ledOn(): Promise { + return await this.control.ledOn(); + } + + /** + * Turns the printer's LED lights off. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async ledOff(): Promise { + return await this.control.ledOff(); + } + + /** + * Pauses the current print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async pauseJob(): Promise { + return await this.control.pauseJob(); + } + + /** + * Resumes a paused print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async resumeJob(): Promise { + return await this.control.resumeJob(); + } + + /** + * Stops the current print job. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async stopJob(): Promise { + return await this.control.stopJob(); + } + + /** + * Starts a print job from a file stored on the printer. + * @param name The name of the file to print (typically without path). + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async startJob(name: string): Promise { + return await this.control.startJob(name); + } + + /** + * Homes all axes (X, Y, Z) of the printer. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async homeAxes(): Promise { + return await this.control.home(); + } + + /** + * Performs a rapid homing of all axes. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async rapidHome(): Promise { + return await this.control.rapidHome(); + } + + /** + * Turns on the filament runout sensor. + * This functionality is only available on specific printer models (e.g., 5M Pro). + * @returns A Promise that resolves to true if the command is successful and applicable, false otherwise. + */ + public async turnRunoutSensorOn(): Promise { + if (this.is5mPro) { + return await this.sendCmdOk(GCodes.CmdRunoutSensorOn); } - - /** - * Retrieves the current XYZ coordinates of the print head. - * Sends `GCodes.CmdInfoXyzab` and parses the response into a `LocationInfo` object. - * @returns A Promise that resolves to a `LocationInfo` object, or null if retrieval fails. - */ - public async getLocationInfo(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdInfoXyzab); - return response ? new LocationInfo().fromReplay(response) : null; + console.log('Filament runout sensor not equipped on this printer.'); + return false; + } + + /** + * Turns off the filament runout sensor. + * This functionality is only available on specific printer models (e.g., 5M Pro). + * @returns A Promise that resolves to true if the command is successful and applicable, false otherwise. + */ + public async turnRunoutSensorOff(): Promise { + if (this.is5mPro) { + return await this.sendCmdOk(GCodes.CmdRunoutSensorOff); } - - /** - * Retrieves the current temperature of the nozzle (extruder). - * @returns A Promise that resolves to the current nozzle temperature in Celsius, or 0 if unavailable. - * @private - */ - private async getNozzleTemp(): Promise { - const temps = await this.getTempInfo(); - return temps?.getExtruderTemp()?.getCurrent() ?? 0; + console.log('Filament runout sensor not equipped on this printer.'); + return false; + } + + /** + * Sets the target temperature for the extruder. + * @param temp The target temperature in Celsius. + * @param waitFor If true, the method will wait until the target temperature is reached. Defaults to false. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setExtruderTemp(temp: number, waitFor: boolean = false): Promise { + return await this.control.setExtruderTemp(temp, waitFor); + } + + /** + * Cancels extruder heating and sets its target temperature to 0. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async cancelExtruderTemp(): Promise { + return await this.control.cancelExtruderTemp(); + } + + /** + * Sets the target temperature for the print bed. + * @param temp The target temperature in Celsius. + * @param waitFor If true, the method will wait until the target temperature is reached. Defaults to false. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async setBedTemp(temp: number, waitFor: boolean = false): Promise { + return await this.control.setBedTemp(temp, waitFor); + } + + /** + * Cancels print bed heating and sets its target temperature to 0. + * @param waitForCool If true, waits for the bed to cool down after canceling. Defaults to false. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async cancelBedTemp(waitForCool: boolean = false): Promise { + return await this.control.cancelBedTemp(waitForCool); + } + + /** + * Commands the extruder to extrude a specific length of filament. + * Uses G1 E[length] F[feedrate] command. + * @param length The length of filament to extrude in millimeters. + * @param feedrate The feedrate for extrusion in mm/min. Defaults to 450. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async extrude(length: number, feedrate: number = 450): Promise { + return await this.sendCmdOk(`~G1 E${length} F${feedrate}`); + } + + /** + * Moves the extruder to a specified X, Y position. + * Uses G1 X[x] Y[y] F[feedrate] command. + * @param x The target X coordinate. + * @param y The target Y coordinate. + * @param feedrate The feedrate for the movement in mm/min. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async moveExtruder(x: number, y: number, feedrate: number): Promise { + return await this.sendCmdOk(`~G1 X${x} Y${y} F${feedrate}`); + } + + /** + * Moves the extruder to a specified X, Y, Z position. + * Uses G1 X[x] Y[y] Z[z] F[feedrate] command. + * @param x The target X coordinate. + * @param y The target Y coordinate. + * @param z The target Z coordinate. + * @param feedrate The feedrate for the movement in mm/min. + * @returns A Promise that resolves to true if the command is successful, false otherwise. + */ + public async move(x: number, y: number, z: number, feedrate: number): Promise { + return await this.sendCmdOk(`~G1 X${x} Y${y} Z${z} F${feedrate}`); + } + + /** + * Prepares the printer for filament loading. + * This involves canceling current extruder temperature, setting absolute mode, homing axes, + * moving the extruder to a safe position, heating the extruder to the filament's load temperature, + * and then purging some filament. + * @param filament The `Filament` object containing details like load temperature. + * @returns A Promise that resolves to true if all preparation steps are successful, false otherwise. + */ + public async prepareFilamentLoad(filament: Filament): Promise { + if (!(await this.cancelExtruderTemp())) return false; + if (!(await this.sendCmdOk('~G90'))) return false; // absolute mode ok + if (!(await this.homeAxes())) return false; + // todo should probably adjust this feedrate for older printers.. + if (!(await this.moveExtruder(0, 0, 9000))) return false; + if (!(await this.setExtruderTemp(filament.loadTemp, true))) return false; // heat extruder (and wait for it) + return await this.extrude(300); // purge old filament + } + + /** + * Loads filament by extruding a specified amount. + * Checks if the nozzle is hot enough before attempting to extrude. + * @returns A Promise that resolves to true if loading is successful, false otherwise. + */ + public async loadFilament(): Promise { + if (await this.canExtrude()) return await this.extrude(250); + console.log('LoadFilament() failed, nozzle is not hot enough.'); + return false; + } + + /** + * Checks if the nozzle is hot enough to allow extrusion. + * @returns A Promise that resolves to true if the nozzle temperature is at or above 210°C, false otherwise. + * @private + */ + private async canExtrude(): Promise { + const nozzleTemp = await this.getNozzleTemp(); + // todo this might need adjustment? + return nozzleTemp >= 210; + } + + /** + * Finishes the filament loading process. + * This involves canceling extruder heating, waiting for a short period, and then homing the axes. + * @returns A Promise that resolves to true if finishing steps are successful, false otherwise. + */ + public async finishFilamentLoad(): Promise { + if (!(await this.cancelExtruderTemp())) return false; + await sleep(5000); + return await this.homeAxes(); + } + + /** + * Sends a G-code/M-code command to the printer and checks for an "ok" response. + * Expects the printer's reply to include "Received." and "ok" to be considered successful. + * @param cmd The command string to send (e.g., "~M115"). + * @returns A Promise that resolves to true if the command is acknowledged with "ok", false otherwise or on error. + */ + public async sendCmdOk(cmd: string): Promise { + try { + const reply = await this.sendCommandAsync(cmd); + if (reply?.includes('Received.') && reply.includes('ok')) return true; + } catch (ex) { + console.log(`SendCmdOk exception sending cmd: ${cmd} : ${ex}`); + return false; } - - /** - * Retrieves the thumbnail image for a specified G-code file stored on the printer. - * The command requires the file path to be prefixed with `/data/`. - * @param fileName The name of the file (e.g., "my_print.gcode") for which to retrieve the thumbnail. - * The `/data/` prefix will be added if not present. - * @returns A Promise that resolves to a `ThumbnailInfo` object containing thumbnail data, - * or null if retrieval fails or the file has no thumbnail. - */ - public async getThumbnail(fileName: string): Promise { - // Ensure the filename has the required /data/ prefix - const filePath = fileName.startsWith('/data/') ? fileName : `/data/${fileName}`; - //console.log(`Getting thumbnail for: ${filePath}`); - - try { - const response = await this.sendCommandAsync(`${GCodes.CmdGetThumbnail} ${filePath}`); - if (!response) { - console.log(`Failed to get thumbnail for ${fileName} - null response`); - return null; - } - - return new ThumbnailInfo().fromReplay(response, fileName); - } catch (error) { - console.log(`Failed to get thumbnail for ${fileName}: ${error instanceof Error ? error.message : String(error)}`); - return null; - } + return false; + } + + /** + * Sends a raw command string to the printer and returns the raw response. + * Handles a special case for "M661" (list files), which is processed differently. + * @param cmd The raw command string to send. + * @returns A Promise that resolves to the printer's raw string response, or an empty string on failure. + * For "M661", it returns a newline-separated list of files. + */ + public async sendRawCmd(cmd: string): Promise { + if (!cmd.includes('M661')) return (await this.sendCommandAsync(cmd)) || ''; + const list = await this.getFileListAsync(); + return list.join('\n'); + } + + /** + * Retrieves general printer information (model, firmware, etc.). + * Sends `GCodes.CmdInfoStatus` and parses the response into a `PrinterInfo` object. + * @returns A Promise that resolves to a `PrinterInfo` object, or null if retrieval fails. + */ + public async getPrinterInfo(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdInfoStatus); + return response ? new PrinterInfo().fromReplay(response) : null; + } + + /** + * Retrieves current temperature information (extruder, bed). + * Sends `GCodes.CmdTemp` and parses the response into a `TempInfo` object. + * @returns A Promise that resolves to a `TempInfo` object, or null if retrieval fails. + */ + public async getTempInfo(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdTemp); + return response ? new TempInfo().fromReplay(response) : null; + } + + /** + * Retrieves the status of the printer's endstops. + * Sends `GCodes.CmdEndstopInfo` and parses the response into an `EndstopStatus` object. + * @returns A Promise that resolves to an `EndstopStatus` object, or null if retrieval fails. + */ + public async getEndstopInfo(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdEndstopInfo); + return response ? new EndstopStatus().fromReplay(response) : null; + } + + /** + * Retrieves the current print job status. + * Sends `GCodes.CmdPrintStatus` and parses the response into a `PrintStatus` object. + * @returns A Promise that resolves to a `PrintStatus` object, or null if retrieval fails. + */ + public async getPrintStatus(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdPrintStatus); + return response ? new PrintStatus().fromReplay(response) : null; + } + + /** + * Retrieves the current XYZ coordinates of the print head. + * Sends `GCodes.CmdInfoXyzab` and parses the response into a `LocationInfo` object. + * @returns A Promise that resolves to a `LocationInfo` object, or null if retrieval fails. + */ + public async getLocationInfo(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdInfoXyzab); + return response ? new LocationInfo().fromReplay(response) : null; + } + + /** + * Retrieves the current temperature of the nozzle (extruder). + * @returns A Promise that resolves to the current nozzle temperature in Celsius, or 0 if unavailable. + * @private + */ + private async getNozzleTemp(): Promise { + const temps = await this.getTempInfo(); + return temps?.getExtruderTemp()?.getCurrent() ?? 0; + } + + /** + * Retrieves the thumbnail image for a specified G-code file stored on the printer. + * The command requires the file path to be prefixed with `/data/`. + * @param fileName The name of the file (e.g., "my_print.gcode") for which to retrieve the thumbnail. + * The `/data/` prefix will be added if not present. + * @returns A Promise that resolves to a `ThumbnailInfo` object containing thumbnail data, + * or null if retrieval fails or the file has no thumbnail. + */ + public async getThumbnail(fileName: string): Promise { + // Ensure the filename has the required /data/ prefix + const filePath = fileName.startsWith('/data/') ? fileName : `/data/${fileName}`; + //console.log(`Getting thumbnail for: ${filePath}`); + + try { + const response = await this.sendCommandAsync(`${GCodes.CmdGetThumbnail} ${filePath}`); + if (!response) { + console.log(`Failed to get thumbnail for ${fileName} - null response`); + return null; + } + + return new ThumbnailInfo().fromReplay(response, fileName); + } catch (error) { + console.log( + `Failed to get thumbnail for ${fileName}: ${error instanceof Error ? error.message : String(error)}` + ); + return null; } - + } } // Helper function for sleep @@ -417,5 +422,5 @@ export class FlashForgeClient extends FlashForgeTcpClient { * @returns A Promise that resolves after the specified delay. */ async function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/tcpapi/FlashForgeTcpClient.test.ts b/src/tcpapi/FlashForgeTcpClient.test.ts index 6f5ed15..5195bd1 100644 --- a/src/tcpapi/FlashForgeTcpClient.test.ts +++ b/src/tcpapi/FlashForgeTcpClient.test.ts @@ -2,14 +2,15 @@ * @fileoverview Tests for FlashForgeTcpClient file list parsing logic, validating extraction * of filenames from M661 command responses across different printer models. */ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { FlashForgeTcpClient } from './FlashForgeTcpClient'; // Suppress logs (from API files) during tests const originalConsole = { ...console }; beforeAll(() => { - console.log = jest.fn(); - console.warn = jest.fn(); - console.error = jest.fn(); + console.log = vi.fn(); + console.warn = vi.fn(); + console.error = vi.fn(); }); afterAll(() => { @@ -21,108 +22,108 @@ afterAll(() => { // Expose the parseFileListResponse method for testing (hacky) const parseFileListResponse = (response: string): string[] => { - // @ts-ignore - const originalConnect = FlashForgeTcpClient.prototype.connect; - // @ts-ignore - FlashForgeTcpClient.prototype.connect = jest.fn(); - const client = new FlashForgeTcpClient('localhost'); - // @ts-ignore - const result = client.parseFileListResponse(response); - // @ts-ignore - FlashForgeTcpClient.prototype.connect = originalConnect; - return result; + // @ts-expect-error + const originalConnect = FlashForgeTcpClient.prototype.connect; + // @ts-expect-error + FlashForgeTcpClient.prototype.connect = vi.fn(); + const client = new FlashForgeTcpClient('localhost'); + // @ts-expect-error + const result = client.parseFileListResponse(response); + // @ts-expect-error + FlashForgeTcpClient.prototype.connect = originalConnect; + return result; }; describe('FlashForgeTcpClient', () => { - // Mock socket methods - beforeAll(() => { - jest.spyOn(FlashForgeTcpClient.prototype, 'dispose').mockImplementation(() => {}); + // Mock socket methods + beforeAll(() => { + vi.spyOn(FlashForgeTcpClient.prototype, 'dispose').mockImplementation(vi.fn()); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + describe('parseFileListResponse', () => { + it('should parse Pro model response correctly', () => { + // Sample response from 5M Pro + const proResponse = `D��D�::��!/data/UniversalConsoleStandx6.3mf::��/data/2x5Baseplate.3mf::��/data/Bin_1x5x5_x2.3mf`; + + const result = parseFileListResponse(proResponse); + + expect(result).toContain('UniversalConsoleStandx6.3mf'); + expect(result).toContain('2x5Baseplate.3mf'); + expect(result).toContain('Bin_1x5x5_x2.3mf'); + expect(result.length).toBe(3); + }); + + it('should parse regular 5M model response correctly', () => { + // Sample response from regular 5M + const regular5MResponse = `D��D::��'/data/First Layer Test Square 0.2.gcode::��%/data/First Layer Test Square 0.2.3mf::��/data/Part 2.gcode::��/data/Part 1.gcode`; + + const result = parseFileListResponse(regular5MResponse); + + expect(result).toContain('First Layer Test Square 0.2.gcode'); + expect(result).toContain('First Layer Test Square 0.2.3mf'); + expect(result).toContain('Part 2.gcode'); + expect(result).toContain('Part 1.gcode'); + expect(result.length).toBe(4); + }); + + it('should handle responses with spaces and special characters in filenames', () => { + const complexResponse = `D��D�::��"/data/GridfinityCalculatorBins.3mf::��#/data/Mason Jar Flower Lid Wood.3mf::��/data/test.3mf`; + const result = parseFileListResponse(complexResponse); + expect(result).toContain('GridfinityCalculatorBins.3mf'); + expect(result).toContain('Mason Jar Flower Lid Wood.3mf'); + expect(result).toContain('test.3mf'); + expect(result.length).toBe(3); }); - - afterAll(() => { - jest.restoreAllMocks(); + + it('should handle empty responses', () => { + const emptyResponse = ''; + const result = parseFileListResponse(emptyResponse); + expect(result).toEqual([]); }); - - describe('parseFileListResponse', () => { - it('should parse Pro model response correctly', () => { - // Sample response from 5M Pro - const proResponse = `D��D�::��!/data/UniversalConsoleStandx6.3mf::��/data/2x5Baseplate.3mf::��/data/Bin_1x5x5_x2.3mf`; - - const result = parseFileListResponse(proResponse); - - expect(result).toContain('UniversalConsoleStandx6.3mf'); - expect(result).toContain('2x5Baseplate.3mf'); - expect(result).toContain('Bin_1x5x5_x2.3mf'); - expect(result.length).toBe(3); - }); - - it('should parse regular 5M model response correctly', () => { - // Sample response from regular 5M - const regular5MResponse = `D��D::��'/data/First Layer Test Square 0.2.gcode::��%/data/First Layer Test Square 0.2.3mf::��/data/Part 2.gcode::��/data/Part 1.gcode`; - - const result = parseFileListResponse(regular5MResponse); - - expect(result).toContain('First Layer Test Square 0.2.gcode'); - expect(result).toContain('First Layer Test Square 0.2.3mf'); - expect(result).toContain('Part 2.gcode'); - expect(result).toContain('Part 1.gcode'); - expect(result.length).toBe(4); - }); - - it('should handle responses with spaces and special characters in filenames', () => { - const complexResponse = `D��D�::��"/data/GridfinityCalculatorBins.3mf::��#/data/Mason Jar Flower Lid Wood.3mf::��/data/test.3mf`; - const result = parseFileListResponse(complexResponse); - expect(result).toContain('GridfinityCalculatorBins.3mf'); - expect(result).toContain('Mason Jar Flower Lid Wood.3mf'); - expect(result).toContain('test.3mf'); - expect(result.length).toBe(3); - }); - - it('should handle empty responses', () => { - const emptyResponse = ''; - const result = parseFileListResponse(emptyResponse); - expect(result).toEqual([]); - }); - - it('should handle responses with no file paths', () => { - const noFilesResponse = 'D��D�::��'; - const result = parseFileListResponse(noFilesResponse); - expect(result).toEqual([]); - }); - - it('should handle the complete Pro response correctly', () => { - // Full M661 response from 5M pro - const fullProResponse = `D��D�::��!/data/UniversalConsoleStandx6.3mf::��/data/2x5Baseplate.3mf::��/data/Bin_1x5x5_x2.3mf::��/data/4x5Baseplate.3mf::��"/data/GridfinityCalculatorBins.3mf::��#/data/Mason Jar Flower Lid Wood.3mf::��/data/test.3mf::��⸮/data/FileUploadTest.gcode::��#/data/FlashPrintUploadTest.gcode.gx::��/data/Mason Jar Flower Lid.3mf::��"/data/platypus-trader-mini-stl.3mf::��(/data/wood-carved-wolf-sculpture-stl.3mf::��!/data/BirdbuddySpillshroud+v3.3mf::��/data/ASA Benchy.3mf::��4/data/ff-adventurer-5m-pro-internal-exhaust-duct.3mf::��3/data/ff-adventurer-5m-pro-100mm-duct-connector.3mf::��4/data/FF Adventurer 5m Pro External Exhuast Duct.3mf::��/data/USB C Port Cleaner x4.3mf::��/data/150-200g Spool Top.3mf::��/data/150-200g Spool Bottom.3mf::��;/data/Gridfinity_UltraLightBin_DividerEdition_1x4x5_1x4.3mf::��5/data/Gridfinity_UltraLightBin_PlainEdition_2x4x5.3mf::��;/data/Gridfinity_UltraLightBin_DividerEdition_2x2x5_2x3.3mf::��/data/MULTIGROOM-5000-BASE.3mf::��(/data/Gridfinity Deoderant Holder x3.3mf::��/data/Silca+Gel+Containerx4.3mf::��/data/Silca+Gel+Container.3mf::��/data/BuildPlateCover.3mf::�� /data/The+Mac+Vase_spiral150.3mf::��/data/SUNLU1kgSpoolHolderx3.3mf::��/data/SUNLU1kgSpoolHolder.3mf::��⸮/data/1kgSpoolHolderx4.3mf::��"/data/FlashForge1kgSpoolHolder.3mf::��/data/Amolen200gSpoolHolder.3mf::��/data/Mika3DSilkDarkPurple.3mf::��&/data/FilamentSampleBox-20x-Angled.3mf::��/data/FilamentSampleBox-20x.3mf::��/data/OVVWalnut.3mf::��/data/OVVOak.3mf::��/data/OVVTeakwood.3mf::��/data/OVV3DCherry.3mf::��/data/FlashForgeHSRed.3mf::��/data/SunluHSGrey.3mf::��/data/SunluHSWhite.3mf::��./data/FlashForgeBurntTitanium+NebulaPurple.3mf::��/data/3DHoJorGoldSilk.3mf::��/data/AmolenSilkRed+Green.3mf::��/data/AmolenSilkRed+Blue.3mf::��/data/AmolenSilkRed+Gold.3mf::��/data/iSHANGUBlueGlitter.3mf::��/data/SunluOliveGreen.3mf::��/data/support.3mf::��/data/frame-right-3-shelf.3mf::��/data/frame-left-3-shelf.3mf::��/data/DisplayShelf x3.3mf::��#/data/Stackable Benchy Shelf x3.3mf::��/data/FlexiGuitar.3mf::�� /data/PIP+Guitar+Pick+Box+V2.3mf::��/data/Guitar Picks x6.3mf::��/data/Dad Trophy.3mf::��/data/wood-chicken.3mf::��/data/wood-eagle-on-perch.3mf::��/data/TulipVase.3mf::��/data/eclipse-bloom-vase.3mf::��/data/SnailPlanter.3mf::��⸮/data/Silk Benchy Test.3mf::��⸮/data/3x Bottle Wrench.3mf::��/data/HS Benchy.3mf::��#/data/Heart Hands Candle Holder.3mf::�� /data/Groot Log Wood PLA 0.6.3mf::��/data/0.6 Wood Benchy Rev1.3mf::��/data/BabyGrootWood.3mf::��ata/Filament Clips x6.3mf::��/data/Wood Benchy v1.3mf::��/data/HeartBear.3mf::��/data/DadSpaceChess.3mf::��/data/9_11_memorial.3mf::��/data/Pawn x4.3mf::��/data/Bishop x2.3mf::��/data/2x Rook + 2x Bishop.3mf::��ata/Transparent Twist.3mf::��/data/Silk Lego Flowers x2.3mf::��/data/Lego Flower Stem Set.3mf::��/data/Silk Lego Flower Pot.3mf::��"/data/Happy Birthday Aunt Rere.3mf::��⸮/data/Star+Trophy+Base.3mf::��⸮/data/Star+Trophy+Star.3mf::��/data/fanculo.3mf::��/data/Little_Boy_Bomb.3mf::��/data/DC_WhiteHouse.3mf::��/data/Chess Queen.3mf::��/data/Chess King.3mf::��/data/Benchy.3mf::��/data/Jewlery Holder Base.3mf::��/data/Jewlery_Tree_Side_2.3mf::��/data/Jewlery_Tree_Side_1.3mf::��"/data/Minecraft Ore Keycap Set.3mf::��/data/EscapeKey.3mf::��/data/LeftShift.3mf::��/data/SpacebarTestTwo.3mf::��/data/Spacebar.3mf::��/data/NumKeysLarge.3mf::��/data/Extras 1.3mf::��/data/NumKeys1.3mf::��/data/Numpad Keys Test 2.3mf::��/data/key+cap+v6.3mf::��/data/Arrow Keys.3mf::��ata/Arrow Keys Test 2.3mf::��ata/Arrow Keys Test 1.3mf::��/data/InsHomeEtc Set.3mf::��⸮/data/Outer Keys Set 1.3mf::��&/data/razer keycap stabilizer test.3mf::��/data/Keys Test 4.3mf::��/data/CTRL_key.3mf::��/data/Misc Keys.3mf::��/data/F Top Row Blackwidow.3mf::��*/data/Top Row Transparent + White Text.3mf::��/data/Transparent Key Test.3mf::��/data/2 Key Test.3mf::��/data/EasyKeycap.3mf::��/data/Keycap Test.3mf::��*/data/Transparent Keys A-Z Test + Brim.3mf::��0/data/Articulated+Christmas+Star+Transparent.3mf::��!/data/Knitting Needles Test 1.3mf::��/data/10cmXLShelf.3mf::��/data/10cm XXL.3mf::��#/data/10cm Center Connectors x8.3mf::��/data/Shelf Connectors x3.3mf::��*/data/10cm Display Shelf SmallMedLarge.3mf::��/data/4 Tier Display Shelf.3mf::��"/data/Gridfinity_Baseplate_4x4.3mf::��"/data/Gridfinity_Baseplate_2x4.3mf::��$/data/3x2 Rugged Drawer Outer x2.3mf`; - - const result = parseFileListResponse(fullProResponse); - - expect(result).toContain('UniversalConsoleStandx6.3mf'); - expect(result).toContain('2x5Baseplate.3mf'); - expect(result).toContain('Bin_1x5x5_x2.3mf'); - // The number will be approximate due to potentially malformed entries - expect(result.length).toBeGreaterThan(50); - }); - - it('should handle the complete regular 5M response correctly', () => { - // Full M661 response from regular 5M - const fullRegular5MResponse = `D��D::��'/data/First Layer Test Square 0.2.gcode::��%/data/First Layer Test Square 0.2.3mf::��/data/Part 2.gcode::��/data/Part 1.gcode::��%/data/Drawer 78mm (PETG) 20m11s.gcode::��$/data/Frame 80mm (PETG) 59m11s.gcode::��2/data/First Layer Test Square 0.2_PETG_4m24s.gcode::��/data/Cube-PLA-Test.gcode::��/data/Boat_PLA_14m3s.gcode::��*/data/Mobile phone holder_PLA_39m30s.gcode::�� /data/Touch Pen_PLA_41m14s.gcode::��/data/Keychain_PLA_4m7s.gcode::��//data/Desk Oragnizer 60percent_PLA_57m28s.gcode::��+/data/Concave Dodecahedron_PLA_38m12s.gcode::��/data/Icecream_PLA_1h2m.gcode`; - const result = parseFileListResponse(fullRegular5MResponse); - expect(result).toContain('First Layer Test Square 0.2.gcode'); - expect(result).toContain('First Layer Test Square 0.2.3mf'); - expect(result).toContain('Part 2.gcode'); - expect(result).toContain('Part 1.gcode'); - expect(result).toContain('Drawer 78mm (PETG) 20m11s.gcode'); - expect(result).toContain('Frame 80mm (PETG) 59m11s.gcode'); - expect(result).toContain('First Layer Test Square 0.2_PETG_4m24s.gcode'); - expect(result).toContain('Cube-PLA-Test.gcode'); - expect(result).toContain('Boat_PLA_14m3s.gcode'); - expect(result).toContain('Mobile phone holder_PLA_39m30s.gcode'); - expect(result).toContain('Touch Pen_PLA_41m14s.gcode'); - expect(result).toContain('Keychain_PLA_4m7s.gcode'); - expect(result).toContain('Desk Oragnizer 60percent_PLA_57m28s.gcode'); - expect(result).toContain('Concave Dodecahedron_PLA_38m12s.gcode'); - expect(result).toContain('Icecream_PLA_1h2m.gcode'); - expect(result.length).toBe(15); - }); + + it('should handle responses with no file paths', () => { + const noFilesResponse = 'D��D�::��'; + const result = parseFileListResponse(noFilesResponse); + expect(result).toEqual([]); + }); + + it('should handle the complete Pro response correctly', () => { + // Full M661 response from 5M pro + const fullProResponse = `D��D�::��!/data/UniversalConsoleStandx6.3mf::��/data/2x5Baseplate.3mf::��/data/Bin_1x5x5_x2.3mf::��/data/4x5Baseplate.3mf::��"/data/GridfinityCalculatorBins.3mf::��#/data/Mason Jar Flower Lid Wood.3mf::��/data/test.3mf::��⸮/data/FileUploadTest.gcode::��#/data/FlashPrintUploadTest.gcode.gx::��/data/Mason Jar Flower Lid.3mf::��"/data/platypus-trader-mini-stl.3mf::��(/data/wood-carved-wolf-sculpture-stl.3mf::��!/data/BirdbuddySpillshroud+v3.3mf::��/data/ASA Benchy.3mf::��4/data/ff-adventurer-5m-pro-internal-exhaust-duct.3mf::��3/data/ff-adventurer-5m-pro-100mm-duct-connector.3mf::��4/data/FF Adventurer 5m Pro External Exhuast Duct.3mf::��/data/USB C Port Cleaner x4.3mf::��/data/150-200g Spool Top.3mf::��/data/150-200g Spool Bottom.3mf::��;/data/Gridfinity_UltraLightBin_DividerEdition_1x4x5_1x4.3mf::��5/data/Gridfinity_UltraLightBin_PlainEdition_2x4x5.3mf::��;/data/Gridfinity_UltraLightBin_DividerEdition_2x2x5_2x3.3mf::��/data/MULTIGROOM-5000-BASE.3mf::��(/data/Gridfinity Deoderant Holder x3.3mf::��/data/Silca+Gel+Containerx4.3mf::��/data/Silca+Gel+Container.3mf::��/data/BuildPlateCover.3mf::�� /data/The+Mac+Vase_spiral150.3mf::��/data/SUNLU1kgSpoolHolderx3.3mf::��/data/SUNLU1kgSpoolHolder.3mf::��⸮/data/1kgSpoolHolderx4.3mf::��"/data/FlashForge1kgSpoolHolder.3mf::��/data/Amolen200gSpoolHolder.3mf::��/data/Mika3DSilkDarkPurple.3mf::��&/data/FilamentSampleBox-20x-Angled.3mf::��/data/FilamentSampleBox-20x.3mf::��/data/OVVWalnut.3mf::��/data/OVVOak.3mf::��/data/OVVTeakwood.3mf::��/data/OVV3DCherry.3mf::��/data/FlashForgeHSRed.3mf::��/data/SunluHSGrey.3mf::��/data/SunluHSWhite.3mf::��./data/FlashForgeBurntTitanium+NebulaPurple.3mf::��/data/3DHoJorGoldSilk.3mf::��/data/AmolenSilkRed+Green.3mf::��/data/AmolenSilkRed+Blue.3mf::��/data/AmolenSilkRed+Gold.3mf::��/data/iSHANGUBlueGlitter.3mf::��/data/SunluOliveGreen.3mf::��/data/support.3mf::��/data/frame-right-3-shelf.3mf::��/data/frame-left-3-shelf.3mf::��/data/DisplayShelf x3.3mf::��#/data/Stackable Benchy Shelf x3.3mf::��/data/FlexiGuitar.3mf::�� /data/PIP+Guitar+Pick+Box+V2.3mf::��/data/Guitar Picks x6.3mf::��/data/Dad Trophy.3mf::��/data/wood-chicken.3mf::��/data/wood-eagle-on-perch.3mf::��/data/TulipVase.3mf::��/data/eclipse-bloom-vase.3mf::��/data/SnailPlanter.3mf::��⸮/data/Silk Benchy Test.3mf::��⸮/data/3x Bottle Wrench.3mf::��/data/HS Benchy.3mf::��#/data/Heart Hands Candle Holder.3mf::�� /data/Groot Log Wood PLA 0.6.3mf::��/data/0.6 Wood Benchy Rev1.3mf::��/data/BabyGrootWood.3mf::��ata/Filament Clips x6.3mf::��/data/Wood Benchy v1.3mf::��/data/HeartBear.3mf::��/data/DadSpaceChess.3mf::��/data/9_11_memorial.3mf::��/data/Pawn x4.3mf::��/data/Bishop x2.3mf::��/data/2x Rook + 2x Bishop.3mf::��ata/Transparent Twist.3mf::��/data/Silk Lego Flowers x2.3mf::��/data/Lego Flower Stem Set.3mf::��/data/Silk Lego Flower Pot.3mf::��"/data/Happy Birthday Aunt Rere.3mf::��⸮/data/Star+Trophy+Base.3mf::��⸮/data/Star+Trophy+Star.3mf::��/data/fanculo.3mf::��/data/Little_Boy_Bomb.3mf::��/data/DC_WhiteHouse.3mf::��/data/Chess Queen.3mf::��/data/Chess King.3mf::��/data/Benchy.3mf::��/data/Jewlery Holder Base.3mf::��/data/Jewlery_Tree_Side_2.3mf::��/data/Jewlery_Tree_Side_1.3mf::��"/data/Minecraft Ore Keycap Set.3mf::��/data/EscapeKey.3mf::��/data/LeftShift.3mf::��/data/SpacebarTestTwo.3mf::��/data/Spacebar.3mf::��/data/NumKeysLarge.3mf::��/data/Extras 1.3mf::��/data/NumKeys1.3mf::��/data/Numpad Keys Test 2.3mf::��/data/key+cap+v6.3mf::��/data/Arrow Keys.3mf::��ata/Arrow Keys Test 2.3mf::��ata/Arrow Keys Test 1.3mf::��/data/InsHomeEtc Set.3mf::��⸮/data/Outer Keys Set 1.3mf::��&/data/razer keycap stabilizer test.3mf::��/data/Keys Test 4.3mf::��/data/CTRL_key.3mf::��/data/Misc Keys.3mf::��/data/F Top Row Blackwidow.3mf::��*/data/Top Row Transparent + White Text.3mf::��/data/Transparent Key Test.3mf::��/data/2 Key Test.3mf::��/data/EasyKeycap.3mf::��/data/Keycap Test.3mf::��*/data/Transparent Keys A-Z Test + Brim.3mf::��0/data/Articulated+Christmas+Star+Transparent.3mf::��!/data/Knitting Needles Test 1.3mf::��/data/10cmXLShelf.3mf::��/data/10cm XXL.3mf::��#/data/10cm Center Connectors x8.3mf::��/data/Shelf Connectors x3.3mf::��*/data/10cm Display Shelf SmallMedLarge.3mf::��/data/4 Tier Display Shelf.3mf::��"/data/Gridfinity_Baseplate_4x4.3mf::��"/data/Gridfinity_Baseplate_2x4.3mf::��$/data/3x2 Rugged Drawer Outer x2.3mf`; + + const result = parseFileListResponse(fullProResponse); + + expect(result).toContain('UniversalConsoleStandx6.3mf'); + expect(result).toContain('2x5Baseplate.3mf'); + expect(result).toContain('Bin_1x5x5_x2.3mf'); + // The number will be approximate due to potentially malformed entries + expect(result.length).toBeGreaterThan(50); + }); + + it('should handle the complete regular 5M response correctly', () => { + // Full M661 response from regular 5M + const fullRegular5MResponse = `D��D::��'/data/First Layer Test Square 0.2.gcode::��%/data/First Layer Test Square 0.2.3mf::��/data/Part 2.gcode::��/data/Part 1.gcode::��%/data/Drawer 78mm (PETG) 20m11s.gcode::��$/data/Frame 80mm (PETG) 59m11s.gcode::��2/data/First Layer Test Square 0.2_PETG_4m24s.gcode::��/data/Cube-PLA-Test.gcode::��/data/Boat_PLA_14m3s.gcode::��*/data/Mobile phone holder_PLA_39m30s.gcode::�� /data/Touch Pen_PLA_41m14s.gcode::��/data/Keychain_PLA_4m7s.gcode::��//data/Desk Oragnizer 60percent_PLA_57m28s.gcode::��+/data/Concave Dodecahedron_PLA_38m12s.gcode::��/data/Icecream_PLA_1h2m.gcode`; + const result = parseFileListResponse(fullRegular5MResponse); + expect(result).toContain('First Layer Test Square 0.2.gcode'); + expect(result).toContain('First Layer Test Square 0.2.3mf'); + expect(result).toContain('Part 2.gcode'); + expect(result).toContain('Part 1.gcode'); + expect(result).toContain('Drawer 78mm (PETG) 20m11s.gcode'); + expect(result).toContain('Frame 80mm (PETG) 59m11s.gcode'); + expect(result).toContain('First Layer Test Square 0.2_PETG_4m24s.gcode'); + expect(result).toContain('Cube-PLA-Test.gcode'); + expect(result).toContain('Boat_PLA_14m3s.gcode'); + expect(result).toContain('Mobile phone holder_PLA_39m30s.gcode'); + expect(result).toContain('Touch Pen_PLA_41m14s.gcode'); + expect(result).toContain('Keychain_PLA_4m7s.gcode'); + expect(result).toContain('Desk Oragnizer 60percent_PLA_57m28s.gcode'); + expect(result).toContain('Concave Dodecahedron_PLA_38m12s.gcode'); + expect(result).toContain('Icecream_PLA_1h2m.gcode'); + expect(result.length).toBe(15); }); + }); }); diff --git a/src/tcpapi/FlashForgeTcpClient.ts b/src/tcpapi/FlashForgeTcpClient.ts index 0146807..2d54460 100644 --- a/src/tcpapi/FlashForgeTcpClient.ts +++ b/src/tcpapi/FlashForgeTcpClient.ts @@ -2,448 +2,457 @@ * @fileoverview Low-level TCP socket client for FlashForge printers, managing connections, * command serialization, multi-line response parsing, and keep-alive mechanisms. */ -import * as net from 'net'; -import {setTimeout as sleep} from 'timers/promises'; -import {GCodes} from "./client/GCodes"; +import * as net from 'node:net'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { GCodes } from './client/GCodes'; export class FlashForgeTcpClient { - /** The underlying network socket for TCP communication. Null if not connected. */ - protected socket: net.Socket | null = null; - /** The default TCP port used for connecting to FlashForge printers. */ - protected readonly port = 8899; - /** The default timeout (in milliseconds) for socket operations. */ - protected readonly timeout = 5000; - /** The hostname or IP address of the printer. */ - protected hostname: string; - /** Token to signal cancellation of the keep-alive loop. */ - private keepAliveCancellationToken: boolean = false; - /** Counter for consecutive keep-alive errors. */ - private keepAliveErrors: number = 0; - /** Flag indicating if the socket is currently busy sending a command and awaiting a response. */ - private socketBusy: boolean = false; - - /** - * Creates an instance of FlashForgeTcpClient. - * Initializes the hostname and attempts to connect to the printer. - * @param hostname The IP address or hostname of the FlashForge printer. - */ - constructor(hostname: string) { - this.hostname = hostname; - try { - console.log("TcpPrinterClient creation"); - this.connect(); - console.log("Connected"); - } catch (error: unknown) { - console.log("TcpPrinterClient failed to init!!!"); + /** The underlying network socket for TCP communication. Null if not connected. */ + protected socket: net.Socket | null = null; + /** The default TCP port used for connecting to FlashForge printers. */ + protected readonly port = 8899; + /** The default timeout (in milliseconds) for socket operations. */ + protected readonly timeout = 5000; + /** The hostname or IP address of the printer. */ + protected hostname: string; + /** Token to signal cancellation of the keep-alive loop. */ + private keepAliveCancellationToken: boolean = false; + /** Counter for consecutive keep-alive errors. */ + private keepAliveErrors: number = 0; + /** Flag indicating if the socket is currently busy sending a command and awaiting a response. */ + private socketBusy: boolean = false; + + /** + * Creates an instance of FlashForgeTcpClient. + * Initializes the hostname and attempts to connect to the printer. + * @param hostname The IP address or hostname of the FlashForge printer. + */ + constructor(hostname: string) { + this.hostname = hostname; + try { + console.log('TcpPrinterClient creation'); + this.connect(); + console.log('Connected'); + } catch (_error: unknown) { + console.log('TcpPrinterClient failed to init!!!'); + } + } + + /** + * Starts a keep-alive mechanism to maintain the TCP connection with the printer. + * Periodically sends a status command (`GCodes.CmdPrintStatus`) to the printer. + * Adjusts the keep-alive interval based on error counts. + * This method runs asynchronously and will continue until `stopKeepAlive` is called + * or too many consecutive errors occur. + */ + public startKeepAlive(): void { + if (this.keepAliveCancellationToken) return; // already running + this.keepAliveCancellationToken = false; + + const runKeepAlive = async () => { + try { + while (!this.keepAliveCancellationToken) { + //console.log("KeepAlive"); + const result = await this.sendCommandAsync(GCodes.CmdPrintStatus); + if (result === null) { + // keep alive failed, connection error/timeout etc + this.keepAliveErrors++; // keep track of errors + //console.log(`Current keep alive failure: ${this.keepAliveErrors}`); + break; + } + + if (this.keepAliveErrors > 0) this.keepAliveErrors--; // move back to 0 errors with each "good" keep-alive + // increase keep alive timeout based on error count + await sleep(5000 + this.keepAliveErrors * 1000); } + } catch (error: unknown) { + const err = error as Error; + console.log(`KeepAlive encountered an exception: ${err.message}`); + } + }; + + runKeepAlive(); + } + + /** + * Stops the keep-alive mechanism. + * @param logout If true, sends a logout command to the printer before stopping. Defaults to false. + */ + public stopKeepAlive(logout: boolean = false): void { + if (logout) { + this.sendCommandAsync(GCodes.CmdLogout).then(() => { + // Ignore: Logout errors during disposal are acceptable + }); + } // release control + this.keepAliveCancellationToken = true; + console.log('Keep-alive stopped.'); + } + + /** + * Checks if the socket is currently busy processing a command. + * @returns A Promise that resolves to true if the socket is busy, false otherwise. + */ + public async isSocketBusy(): Promise { + return this.socketBusy; + } + + /** + * Sends a command string to the printer asynchronously via the TCP socket. + * It ensures the socket is available, writes the command (appending a newline), + * and then waits to receive a multi-line reply. + * Handles socket busy state and various connection errors. + * + * @param cmd The command string to send (e.g., "~M115"). + * @returns A Promise that resolves to the printer's string reply, or null if an error occurs, + * the reply is invalid, or the connection needs to be reset. + */ + public async sendCommandAsync(cmd: string): Promise { + if (this.socketBusy) { + await this.waitUntilSocketAvailable(); } - /** - * Starts a keep-alive mechanism to maintain the TCP connection with the printer. - * Periodically sends a status command (`GCodes.CmdPrintStatus`) to the printer. - * Adjusts the keep-alive interval based on error counts. - * This method runs asynchronously and will continue until `stopKeepAlive` is called - * or too many consecutive errors occur. - */ - public startKeepAlive(): void { - if (this.keepAliveCancellationToken) return; // already running - this.keepAliveCancellationToken = false; - - const runKeepAlive = async () => { - try { - while (!this.keepAliveCancellationToken) { - //console.log("KeepAlive"); - const result = await this.sendCommandAsync(GCodes.CmdPrintStatus); - if (result === null) { - // keep alive failed, connection error/timeout etc - this.keepAliveErrors++; // keep track of errors - //console.log(`Current keep alive failure: ${this.keepAliveErrors}`); - break; - } - - if (this.keepAliveErrors > 0) this.keepAliveErrors--; // move back to 0 errors with each "good" keep-alive - // increase keep alive timeout based on error count - await sleep(5000 + this.keepAliveErrors * 1000); - } - } catch (error: unknown) { - const err = error as Error; - console.log("KeepAlive encountered an exception: " + err.message); - } - }; + this.socketBusy = true; - runKeepAlive() - } + console.log(`sendCommand: ${cmd}`); + try { + this.checkSocket(); - /** - * Stops the keep-alive mechanism. - * @param logout If true, sends a logout command to the printer before stopping. Defaults to false. - */ - public stopKeepAlive(logout: boolean = false): void { - if (logout) { this.sendCommandAsync(GCodes.CmdLogout).then(() => {}); } // release control - this.keepAliveCancellationToken = true; - console.log("Keep-alive stopped."); + return new Promise((resolve, reject) => { + this.socket?.write(`${cmd}\n`, 'ascii', (err) => { + if (err) { + this.socketBusy = false; + console.error('Error writing command to socket:', err); + reject(err); + return; + } + + this.receiveMultiLineReplayAsync(cmd) + .then((reply) => { + this.socketBusy = false; + if (reply !== null) { + //console.log("Received reply for command:", reply); + resolve(reply); + } else { + console.warn('Invalid or no reply received, resetting connection to printer.'); + this.resetSocket(); + this.checkSocket(); + resolve(null); + } + }) + .catch((error) => { + this.socketBusy = false; + console.error('Error receiving reply:', error); + reject(error); + }); + }); + }); + } catch (error: unknown) { + this.socketBusy = false; + const err = error as { code?: string; message: string; stack: string }; + + if (err.code === 'ENETUNREACH') { + const errMsg = `Error while connecting. No route to host [${this.hostname}].`; + console.error(`${errMsg}\n${err.stack}`); + } else if (err.code === 'ENOTFOUND') { + const errMsg = `Error while connecting. Unknown host [${this.hostname}].`; + console.error(`${errMsg}\n${err.stack}`); + } else { + console.error(`Error while sending command: ${err.message}\n${err.stack}`); + } + return null; } - - /** - * Checks if the socket is currently busy processing a command. - * @returns A Promise that resolves to true if the socket is busy, false otherwise. - */ - public async isSocketBusy(): Promise { - return this.socketBusy; + } + + /** + * Waits until the socket is no longer busy or a timeout is reached. + * This is used to serialize commands sent over the socket. + * @throws Error if the socket remains busy for too long (10 seconds). + * @private + */ + private async waitUntilSocketAvailable(): Promise { + const maxWaitTime = 10000; // 10 seconds + const startTime = Date.now(); + + while (this.socketBusy && Date.now() - startTime < maxWaitTime) { + await sleep(100); } - /** - * Sends a command string to the printer asynchronously via the TCP socket. - * It ensures the socket is available, writes the command (appending a newline), - * and then waits to receive a multi-line reply. - * Handles socket busy state and various connection errors. - * - * @param cmd The command string to send (e.g., "~M115"). - * @returns A Promise that resolves to the printer's string reply, or null if an error occurs, - * the reply is invalid, or the connection needs to be reset. - */ - public async sendCommandAsync(cmd: string): Promise { - if (this.socketBusy) { - await this.waitUntilSocketAvailable(); - } - - this.socketBusy = true; + if (this.socketBusy) { + throw new Error('Socket remained busy for too long, timing out'); + } + } + + /** + * Checks the status of the socket connection and attempts to reconnect if it's null or destroyed. + * If reconnection occurs, it also restarts the keep-alive mechanism. + * @private + */ + private checkSocket(): void { + console.log('CheckSocket()'); + let fix = false; + if (this.socket === null) { + fix = true; + //console.warn("TcpPrinterClient socket is null"); + } else if (this.socket.destroyed) { + fix = true; + //console.warn("TcpPrinterClient socket is closed"); + } - console.log("sendCommand: " + cmd); - try { - this.checkSocket(); - - return new Promise((resolve, reject) => { - this.socket!.write(cmd + '\n', 'ascii', (err) => { - if (err) { - this.socketBusy = false; - console.error("Error writing command to socket:", err); - reject(err); - return; - } - - this.receiveMultiLineReplayAsync(cmd) - .then(reply => { - this.socketBusy = false; - if (reply !== null) { - //console.log("Received reply for command:", reply); - resolve(reply); - } else { - console.warn("Invalid or no reply received, resetting connection to printer."); - this.resetSocket(); - this.checkSocket(); - resolve(null); - } - }) - .catch(error => { - this.socketBusy = false; - console.error("Error receiving reply:", error); - reject(error); - }); - }); - }); - } catch (error: unknown) { - this.socketBusy = false; - const err = error as { code?: string, message: string, stack: string }; - - if (err.code === 'ENETUNREACH') { - const errMsg = `Error while connecting. No route to host [${this.hostname}].`; - console.error(errMsg + "\n" + err.stack); - } else if (err.code === 'ENOTFOUND') { - const errMsg = `Error while connecting. Unknown host [${this.hostname}].`; - console.error(errMsg + "\n" + err.stack); - } else { - console.error(`Error while sending command: ${err.message}\n${err.stack}`); - } - return null; - } + if (!fix) return; + + console.warn('Reconnecting to TCP socket...'); + this.connect(); + this.startKeepAlive(); // Start this here rather than Connect() + } + + /** + * Establishes a TCP connection to the printer. + * Initializes the socket, sets the timeout, and sets up an error handler. + * @private + */ + private connect(): void { + //console.log("Connect()"); + this.socket = new net.Socket(); + this.socket.connect(this.port, this.hostname); + this.socket.setTimeout(this.timeout); + + this.socket.on('error', (error) => { + console.log(`Socket error: ${error.message}`); + }); + } + + /** + * Resets the current socket connection. + * Stops the keep-alive mechanism and destroys the socket. + * @private + */ + private resetSocket(): void { + //console.log("ResetSocket()"); + this.stopKeepAlive(); + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + } + + /** + * Asynchronously receives a multi-line reply from the printer for a given command. + * It listens for 'data' events on the socket, concatenates incoming data buffers, + * and determines when the full reply has been received based on command-specific delimiters + * (usually "ok" for text commands, or specific logic for binary data like thumbnails). + * Handles timeouts and errors during reception. + * + * @param cmd The command string for which the reply is expected. This influences how completion is detected. + * @returns A Promise that resolves to the complete string reply from the printer, + * or null if an error occurs, the reply is incomplete, or a timeout happens. + * For thumbnail commands (M662), the response is a binary string. + * @private + */ + private async receiveMultiLineReplayAsync(cmd: string): Promise { + //console.log("ReceiveMultiLineReplayAsync()"); + + if (!this.socket) { + //console.error("Socket is null, cannot receive reply."); + return null; } - /** - * Waits until the socket is no longer busy or a timeout is reached. - * This is used to serialize commands sent over the socket. - * @throws Error if the socket remains busy for too long (10 seconds). - * @private - */ - private async waitUntilSocketAvailable(): Promise { - const maxWaitTime = 10000; // 10 seconds - const startTime = Date.now(); - - while (this.socketBusy && (Date.now() - startTime < maxWaitTime)) { - await sleep(100); + return new Promise((resolve) => { + const answer: Buffer[] = []; + let timeoutId: NodeJS.Timeout; + let _lastDataTime = Date.now(); + + // Create our handler functions + const dataHandler = (data: Buffer) => { + _lastDataTime = Date.now(); + answer.push(data); + + // First, check for completion in non-binary response formats + // This is the standard case for most commands + if (!cmd.startsWith(GCodes.CmdGetThumbnail)) { + // For text commands, we need a complete buffer to check for "ok" + const fullBufferSoFar = Buffer.concat(answer); + const dataSoFar = fullBufferSoFar.toString('ascii'); + + // For M661 file list command + if (cmd === GCodes.CmdListLocalFiles && dataSoFar.includes('ok')) { + clearTimeout(timeoutId); // Clear the main timeout + setTimeout(() => { + cleanup(true); // Resolve after the short delay + }, 500); // Wait 500ms + return; // Prevent immediate cleanup + } + + // For all other standard text commands + if (dataSoFar.includes('ok')) { + clearTimeout(timeoutId); + cleanup(true); + return; + } } - - if (this.socketBusy) { - throw new Error("Socket remained busy for too long, timing out"); + // Special case for M662 (thumbnail) command which returns binary data + else { + // For binary responses, only check the text portion for "ok" + // Look only at the beginning of the buffer for the text header + try { + // Just check for "ok" in the first 100 bytes + const headerBuffer = Buffer.concat(answer).slice(0, 100); + const header = headerBuffer.toString('ascii'); + + if (header.includes('ok')) { + // For thumbnail requests, wait longer after "ok" to ensure we get all binary data + clearTimeout(timeoutId); + setTimeout(() => { + cleanup(true); + }, 1500); // Wait 1.5s for binary data + return; + } + } catch (e) { + console.log(`Error checking binary response header: ${e}`); + } } - } - - /** - * Checks the status of the socket connection and attempts to reconnect if it's null or destroyed. - * If reconnection occurs, it also restarts the keep-alive mechanism. - * @private - */ - private checkSocket(): void { - console.log("CheckSocket()"); - let fix = false; - if (this.socket === null) { - fix = true; - //console.warn("TcpPrinterClient socket is null"); - } else if (this.socket.destroyed) { - fix = true; - //console.warn("TcpPrinterClient socket is closed"); + }; + + const errorHandler = (err: Error) => { + console.error('Error receiving multi-line command reply:', err); + clearTimeout(timeoutId); + cleanup(false, err); + }; + + const cleanup = (success: boolean, error?: Error) => { + // Remove our listeners properly + this.socket?.removeListener('data', dataHandler); + this.socket?.removeListener('error', errorHandler); + + if (!success) { + console.error('Failed to receive complete response:', error?.message); + resolve(null); + return; } - if (!fix) return; - - console.warn("Reconnecting to TCP socket..."); - this.connect(); - this.startKeepAlive(); // Start this here rather than Connect() - } - - /** - * Establishes a TCP connection to the printer. - * Initializes the socket, sets the timeout, and sets up an error handler. - * @private - */ - private connect(): void { - //console.log("Connect()"); - this.socket = new net.Socket(); - this.socket.connect(this.port, this.hostname); - this.socket.setTimeout(this.timeout); - - this.socket.on('error', (error) => { - console.log(`Socket error: ${error.message}`); - }); - } - - /** - * Resets the current socket connection. - * Stops the keep-alive mechanism and destroys the socket. - * @private - */ - private resetSocket(): void { - //console.log("ResetSocket()"); - this.stopKeepAlive(); - if (this.socket) { - this.socket.destroy(); - this.socket = null; + // For binary responses (M662), return the raw buffer as a binary string + if (cmd.startsWith(GCodes.CmdGetThumbnail)) { + const result = Buffer.concat(answer).toString('binary'); + if (!result) { + console.error('Received empty thumbnail response.'); + resolve(null); + } else { + resolve(result); + } + } else { + // For text responses, convert to UTF-8 + const result = Buffer.concat(answer).toString('utf8'); + if (!result) { + console.error('ReceiveMultiLineReplayAsync received an empty response.'); + resolve(null); + } else { + resolve(result); + } } + }; + + let timeoutDuration = 5000; // default timeout + if (cmd === GCodes.CmdListLocalFiles || cmd.startsWith(GCodes.CmdGetThumbnail)) { + timeoutDuration = 10000; + } // increase command timeout + if (cmd === GCodes.CmdHomeAxes || cmd === '~G28') { + timeoutDuration = 15000; + } // homing takes longer + if (this.socket) { + this.socket.setTimeout(timeoutDuration); + } + + timeoutId = setTimeout(() => { + console.error(`ReceiveMultiLineReplayAsync timed out after ${timeoutDuration}ms`); + cleanup(false); + }, timeoutDuration); + + // Add listeners + this.socket?.on('data', dataHandler); + this.socket?.on('error', errorHandler); + }); + } + + /** + * Retrieves a list of G-code files stored on the printer's local storage. + * Sends the `GCodes.CmdListLocalFiles` (M661) command and parses the response. + * @returns A Promise that resolves to an array of file names (strings, without '/data/' prefix). + * Returns an empty array if the command fails or no files are found. + */ + public async getFileListAsync(): Promise { + const response = await this.sendCommandAsync(GCodes.CmdListLocalFiles); + if (response) { + return this.parseFileListResponse(response); } - /** - * Asynchronously receives a multi-line reply from the printer for a given command. - * It listens for 'data' events on the socket, concatenates incoming data buffers, - * and determines when the full reply has been received based on command-specific delimiters - * (usually "ok" for text commands, or specific logic for binary data like thumbnails). - * Handles timeouts and errors during reception. - * - * @param cmd The command string for which the reply is expected. This influences how completion is detected. - * @returns A Promise that resolves to the complete string reply from the printer, - * or null if an error occurs, the reply is incomplete, or a timeout happens. - * For thumbnail commands (M662), the response is a binary string. - * @private - */ - private async receiveMultiLineReplayAsync(cmd: string): Promise { - //console.log("ReceiveMultiLineReplayAsync()"); - - if (!this.socket) { - //console.error("Socket is null, cannot receive reply."); - return null; + return []; + } + + /** + * Parses the raw string response from the `M661` (list files) command. + * The response format typically includes segments separated by "::", with file paths + * prefixed by "/data/". This method extracts and cleans these file names. + * @param response The raw string response from the M661 command. + * @returns An array of file names, with the "/data/" prefix removed and any trailing invalid characters trimmed. + * @private + */ + private parseFileListResponse(response: string): string[] { + const segments = response.split('::'); + + // Extract file paths + const filePaths: string[] = []; + for (const segment of segments) { + const dataIndex = segment.indexOf('/data/'); + if (dataIndex !== -1) { + const fullPath = segment.substring(dataIndex); + if (fullPath.startsWith('/data/')) { + let filename = fullPath.substring(6); + + // Trim at the first invalid character (if any) + const invalidCharIndex = filename.search(/[^\w\s\-.()+%,@[\]{}:;!#$^&*=<>?/]/); + if (invalidCharIndex !== -1) { + filename = filename.substring(0, invalidCharIndex); + } + + // Only add non-empty filenames + if (filename.trim().length > 0) { + filePaths.push(filename); + } } - - return new Promise((resolve) => { - const answer: Buffer[] = []; - let timeoutId: NodeJS.Timeout; - let lastDataTime = Date.now(); - - // Create our handler functions - const dataHandler = (data: Buffer) => { - lastDataTime = Date.now(); - answer.push(data); - - // First, check for completion in non-binary response formats - // This is the standard case for most commands - if (!cmd.startsWith(GCodes.CmdGetThumbnail)) { - // For text commands, we need a complete buffer to check for "ok" - const fullBufferSoFar = Buffer.concat(answer); - const dataSoFar = fullBufferSoFar.toString('ascii'); - - // For M661 file list command - if (cmd === GCodes.CmdListLocalFiles && dataSoFar.includes("ok")) { - clearTimeout(timeoutId); // Clear the main timeout - setTimeout(() => { - cleanup(true); // Resolve after the short delay - }, 500); // Wait 500ms - return; // Prevent immediate cleanup - } - - // For all other standard text commands - if (dataSoFar.includes("ok")) { - clearTimeout(timeoutId); - cleanup(true); - return; - } - } - // Special case for M662 (thumbnail) command which returns binary data - else { - // For binary responses, only check the text portion for "ok" - // Look only at the beginning of the buffer for the text header - try { - // Just check for "ok" in the first 100 bytes - const headerBuffer = Buffer.concat(answer).slice(0, 100); - const header = headerBuffer.toString('ascii'); - - if (header.includes("ok")) { - // For thumbnail requests, wait longer after "ok" to ensure we get all binary data - clearTimeout(timeoutId); - setTimeout(() => { - cleanup(true); - }, 1500); // Wait 1.5s for binary data - return; - } - } catch (e) { - console.log("Error checking binary response header: " + e); - } - } - }; - - const errorHandler = (err: Error) => { - console.error("Error receiving multi-line command reply:", err); - clearTimeout(timeoutId); - cleanup(false, err); - }; - - const cleanup = (success: boolean, error?: Error) => { - // Remove our listeners properly - this.socket!.removeListener('data', dataHandler); - this.socket!.removeListener('error', errorHandler); - - if (!success) { - console.error("Failed to receive complete response:", error?.message); - resolve(null); - return; - } - - // For binary responses (M662), return the raw buffer as a binary string - if (cmd.startsWith(GCodes.CmdGetThumbnail)) { - const result = Buffer.concat(answer).toString('binary'); - if (!result) { - console.error("Received empty thumbnail response."); - resolve(null); - } else { - resolve(result); - } - } else { - // For text responses, convert to UTF-8 - const result = Buffer.concat(answer).toString('utf8'); - if (!result) { - console.error("ReceiveMultiLineReplayAsync received an empty response."); - resolve(null); - } else { - resolve(result); - } - } - }; - - let timeoutDuration = 5000; // default timeout - if (cmd === GCodes.CmdListLocalFiles || cmd.startsWith(GCodes.CmdGetThumbnail)) { timeoutDuration = 10000; } // increase command timeout - if (cmd === GCodes.CmdHomeAxes || cmd === '~G28') { timeoutDuration = 15000; } // homing takes longer - if (this.socket) { this.socket.setTimeout(timeoutDuration); } - - timeoutId = setTimeout(() => { - console.error(`ReceiveMultiLineReplayAsync timed out after ${timeoutDuration}ms`); - cleanup(false); - }, timeoutDuration); - - // Add listeners - this.socket!.on('data', dataHandler); - this.socket!.on('error', errorHandler); - }); + } } - /** - * Retrieves a list of G-code files stored on the printer's local storage. - * Sends the `GCodes.CmdListLocalFiles` (M661) command and parses the response. - * @returns A Promise that resolves to an array of file names (strings, without '/data/' prefix). - * Returns an empty array if the command fails or no files are found. - */ - public async getFileListAsync(): Promise { - const response = await this.sendCommandAsync(GCodes.CmdListLocalFiles); - if (response) { - return this.parseFileListResponse(response); - } + return filePaths; + } - return []; - } + /** + * Cleans up resources by destroying the socket connection. + * This should be called when the client is no longer needed. + */ + public async dispose(): Promise { + try { + console.log('TcpPrinterClient closing socket'); - /** - * Parses the raw string response from the `M661` (list files) command. - * The response format typically includes segments separated by "::", with file paths - * prefixed by "/data/". This method extracts and cleans these file names. - * @param response The raw string response from the M661 command. - * @returns An array of file names, with the "/data/" prefix removed and any trailing invalid characters trimmed. - * @private - */ - private parseFileListResponse(response: string): string[] { - const segments = response.split('::'); - - // Extract file paths - const filePaths: string[] = []; - for (const segment of segments) { - const dataIndex = segment.indexOf('/data/'); - if (dataIndex !== -1) { - const fullPath = segment.substring(dataIndex); - if (fullPath.startsWith('/data/')) { - let filename = fullPath.substring(6); - - // Trim at the first invalid character (if any) - const invalidCharIndex = filename.search(/[^\w\s\-\.\(\)\+%,@\[\]{}:;!#$^&*=<>?\/]/); - if (invalidCharIndex !== -1) { - filename = filename.substring(0, invalidCharIndex); - } - - // Only add non-empty filenames - if (filename.trim().length > 0) { - filePaths.push(filename); - } - } - } - } - - return filePaths; - } + // First stop the keep-alive loop + this.keepAliveCancellationToken = true; - /** - * Cleans up resources by destroying the socket connection. - * This should be called when the client is no longer needed. - */ - public async dispose(): Promise { + // Send logout command if socket is available and not busy + if (this.socket && !this.socket.destroyed && !this.socketBusy) { try { - console.log("TcpPrinterClient closing socket"); - - // First stop the keep-alive loop - this.keepAliveCancellationToken = true; - - // Send logout command if socket is available and not busy - if (this.socket && !this.socket.destroyed && !this.socketBusy) { - try { - await this.sendCommandAsync(GCodes.CmdLogout); - } catch (error) { - // Ignore logout errors during disposal - console.log("Logout command failed during disposal (expected)"); - } - } - - // Now destroy the socket - if (this.socket) { - this.socket.destroy(); - this.socket = null; - } - - console.log("Keep-alive stopped."); - } catch (error: unknown) { - const err = error as Error; - console.log(err.message); + await this.sendCommandAsync(GCodes.CmdLogout); + } catch (_error) { + // Logout errors during disposal are acceptable - the socket cleanup must complete regardless } + } + + // Now destroy the socket + if (this.socket) { + this.socket.destroy(); + this.socket = null; + } + + console.log('Keep-alive stopped.'); + } catch (error: unknown) { + const err = error as Error; + console.log(err.message); } + } } diff --git a/src/tcpapi/client/GCodeController.ts b/src/tcpapi/client/GCodeController.ts index b5d24a2..de6bfb0 100644 --- a/src/tcpapi/client/GCodeController.ts +++ b/src/tcpapi/client/GCodeController.ts @@ -3,220 +3,220 @@ * wrapping operations like LED control, job management, homing, and temperature control. */ // src/tcpapi/client/GCodeController.ts -import { FlashForgeClient } from '../FlashForgeClient'; +import type { FlashForgeClient } from '../FlashForgeClient'; import { GCodes } from './GCodes'; export class GCodeController { - private tcpClient: FlashForgeClient; - - /** - * Creates an instance of GCodeController. - * @param tcpClient The `FlashForgeClient` instance used to send commands to the printer. - */ - constructor(tcpClient: FlashForgeClient) { - this.tcpClient = tcpClient; - } - - /** - * Turns the printer's LED lights on using the `CmdLedOn` G-code. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async ledOn(): Promise { - return await this.tcpClient.sendCmdOk(GCodes.CmdLedOn); - } - - /** - * Turns the printer's LED lights off using the `CmdLedOff` G-code. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async ledOff(): Promise { - return await this.tcpClient.sendCmdOk(GCodes.CmdLedOff); - } - - /** - * Pauses the current print job using the `CmdPausePrint` G-code. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async pauseJob() { - return await this.tcpClient.sendCmdOk(GCodes.CmdPausePrint); - } - - /** - * Resumes a paused print job using the `CmdResumePrint` G-code. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async resumeJob() { - return await this.tcpClient.sendCmdOk(GCodes.CmdResumePrint); - } - - /** - * Stops the current print job using the `CmdStopPrint` G-code. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async stopJob() { - return await this.tcpClient.sendCmdOk(GCodes.CmdStopPrint); - } - - /** - * Starts printing a specified file using the `CmdStartPrint` G-code. - * The filename is embedded into the G-code command string. - * @param filename The name of the file to start printing. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async startJob(filename: string) { - return await this.tcpClient.sendCmdOk(GCodes.CmdStartPrint.replace("%%filename%%", filename)); - } - - /** - * Homes all printer axes (X, Y, Z) using the `CmdHomeAxes` G-code (typically G28). - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async home(): Promise { - return await this.tcpClient.sendCmdOk(GCodes.CmdHomeAxes); - } - - /** - * Performs a "rapid home" sequence. - * This involves setting absolute positioning (G90), moving to a predefined safe position, - * and then performing a standard home operation. - * @returns A Promise that resolves to true if all steps in the sequence are successful, false otherwise. - */ - public async rapidHome(): Promise { - if (!await this.tcpClient.sendCmdOk("~G90")) return false; // Set to absolute positioning - if (!await this.move(105, 105, 220, 9000)) return false; // Move to a predefined position - return await this.home(); // Perform standard homing - } - - /** - * Moves the print head to the specified X, Y, and Z coordinates at a given feedrate. - * Uses the G1 command. - * @param x The target X coordinate. - * @param y The target Y coordinate. - * @param z The target Z coordinate. - * @param feedrate The speed of movement in mm/min. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async move(x: number, y: number, z: number, feedrate: number): Promise { - return await this.tcpClient.sendCmdOk(`~G1 X${x} Y${y} Z${z} F${feedrate}`); - } - - /** - * Moves the print head in the XY plane to the specified coordinates at a given feedrate. - * Uses the G1 command. - * @param x The target X coordinate. - * @param y The target Y coordinate. - * @param feedrate The speed of movement in mm/min. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async moveExtruder(x: number, y: number, feedrate: number): Promise { - return await this.tcpClient.sendCmdOk(`~G1 X${x} Y${y} F${feedrate}`); - } - - /** - * Extrudes a specified length of filament at a given feedrate. - * Uses the G1 E[length] F[feedrate] command. - * @param length The length of filament to extrude in millimeters. - * @param feedrate The speed of extrusion in mm/min. Defaults to 450. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async extrude(length: number, feedrate: number = 450): Promise { - return await this.tcpClient.sendCmdOk(`~G1 E${length} F${feedrate}`); - } - - /** - * Sets the target temperature for the extruder. - * Uses the M104 S[temp] command. - * @param temp The target temperature in Celsius. - * @param waitFor If true, the method will also call `waitForExtruderTemp` to wait until the target temperature is reached. Defaults to false. - * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. - */ - public async setExtruderTemp(temp: number, waitFor: boolean = false): Promise { - const ok = await this.tcpClient.sendCmdOk(`~M104 S${temp}`); - if (!waitFor) return ok; - return await this.waitForExtruderTemp(temp); - } - - /** - * Sets the target temperature for the print bed. - * Uses the M140 S[temp] command. - * @param temp The target temperature in Celsius. - * @param waitFor If true, the method will also call `waitForBedTemp` to wait until the target temperature is reached or cooled down. Defaults to false. - * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. - */ - public async setBedTemp(temp: number, waitFor: boolean = false): Promise { - const ok = await this.tcpClient.sendCmdOk(`~M140 S${temp}`); - if (!waitFor) return ok; - return await this.waitForBedTemp(temp, false); - } - - /** - * Cancels extruder heating by setting its target temperature to 0. - * Uses the M104 S0 command. - * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. - */ - public async cancelExtruderTemp(): Promise { - return await this.tcpClient.sendCmdOk("~M104 S0"); - } - - /** - * Cancels print bed heating by setting its target temperature to 0. - * Uses the M140 S0 command. - * @param waitForCool If true, waits for the bed to cool down to a safe temperature (37°C) after sending the command. Defaults to false. - * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. - */ - public async cancelBedTemp(waitForCool: boolean = false): Promise { - const ok = await this.tcpClient.sendCmdOk("~M140 S0"); - if (!waitForCool) return ok; - return await this.waitForBedTemp(37, true); // *can* remove parts @ 40 but safer side - } - - /** - * Waits for the print bed to reach a specified target temperature. - * This method polls the printer's temperature and also sends a G-code command (M190 or M191) - * to make the printer itself wait. - * @param temp The target bed temperature in Celsius. - * @param cooling If true, waits for the temperature to cool down to or below `temp` (uses M191 R[temp]). - * If false, waits for the temperature to heat up to or above `temp` (uses M190 S[temp]). - * @returns A Promise that resolves to true if the target temperature is reached within the timeout (30s), false otherwise. - * @todo Implement customizable timeouts. - */ - public async waitForBedTemp(temp: number, cooling: boolean): Promise { - // wait machine-side as well - if (cooling) await this.tcpClient.sendCmdOk(GCodes.WaitForBedTemp + `R${temp}`); - else await this.tcpClient.sendCmdOk(GCodes.WaitForBedTemp + `S${temp}`); // M190 S[temp] - const startTime = Date.now(); - const timeout = 30000; // 30s timeout - - while (Date.now() - startTime < timeout) { - const tempInfo = await this.tcpClient.getTempInfo(); - if (tempInfo && tempInfo.getBedTemp()?.getCurrent() === temp) return true; - await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every second - } - - console.log(`WaitForBedTemp (target ${temp}) timed out after 30s.`); - return false; - } - - /** - * Waits for the extruder to reach a specified target temperature. - * This method polls the printer's temperature and also sends a G-code command (M109 S[temp]) - * to make the printer itself wait. - * @param temp The target extruder temperature in Celsius. - * @returns A Promise that resolves to true if the target temperature is reached within the timeout (30s), false otherwise. - * @todo Implement customizable timeouts. - */ - public async waitForExtruderTemp(temp: number): Promise { - // wait machine-side as well - await this.tcpClient.sendCmdOk(GCodes.WaitForHotendTemp + `S${temp}`); // M109 S[temp] - const startTime = Date.now(); - const timeout = 30000; // 30s timeout - - while (Date.now() - startTime < timeout) { - const tempInfo = await this.tcpClient.getTempInfo(); - if (tempInfo && tempInfo.getExtruderTemp()?.getCurrent() === temp) return true; - await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every second - } - - console.log(`WaitForExtruderTemp (target ${temp}) timed out after 30s.`); - return false; - } -} \ No newline at end of file + private tcpClient: FlashForgeClient; + + /** + * Creates an instance of GCodeController. + * @param tcpClient The `FlashForgeClient` instance used to send commands to the printer. + */ + constructor(tcpClient: FlashForgeClient) { + this.tcpClient = tcpClient; + } + + /** + * Turns the printer's LED lights on using the `CmdLedOn` G-code. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async ledOn(): Promise { + return await this.tcpClient.sendCmdOk(GCodes.CmdLedOn); + } + + /** + * Turns the printer's LED lights off using the `CmdLedOff` G-code. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async ledOff(): Promise { + return await this.tcpClient.sendCmdOk(GCodes.CmdLedOff); + } + + /** + * Pauses the current print job using the `CmdPausePrint` G-code. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async pauseJob() { + return await this.tcpClient.sendCmdOk(GCodes.CmdPausePrint); + } + + /** + * Resumes a paused print job using the `CmdResumePrint` G-code. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async resumeJob() { + return await this.tcpClient.sendCmdOk(GCodes.CmdResumePrint); + } + + /** + * Stops the current print job using the `CmdStopPrint` G-code. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async stopJob() { + return await this.tcpClient.sendCmdOk(GCodes.CmdStopPrint); + } + + /** + * Starts printing a specified file using the `CmdStartPrint` G-code. + * The filename is embedded into the G-code command string. + * @param filename The name of the file to start printing. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async startJob(filename: string) { + return await this.tcpClient.sendCmdOk(GCodes.CmdStartPrint.replace('%%filename%%', filename)); + } + + /** + * Homes all printer axes (X, Y, Z) using the `CmdHomeAxes` G-code (typically G28). + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async home(): Promise { + return await this.tcpClient.sendCmdOk(GCodes.CmdHomeAxes); + } + + /** + * Performs a "rapid home" sequence. + * This involves setting absolute positioning (G90), moving to a predefined safe position, + * and then performing a standard home operation. + * @returns A Promise that resolves to true if all steps in the sequence are successful, false otherwise. + */ + public async rapidHome(): Promise { + if (!(await this.tcpClient.sendCmdOk('~G90'))) return false; // Set to absolute positioning + if (!(await this.move(105, 105, 220, 9000))) return false; // Move to a predefined position + return await this.home(); // Perform standard homing + } + + /** + * Moves the print head to the specified X, Y, and Z coordinates at a given feedrate. + * Uses the G1 command. + * @param x The target X coordinate. + * @param y The target Y coordinate. + * @param z The target Z coordinate. + * @param feedrate The speed of movement in mm/min. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async move(x: number, y: number, z: number, feedrate: number): Promise { + return await this.tcpClient.sendCmdOk(`~G1 X${x} Y${y} Z${z} F${feedrate}`); + } + + /** + * Moves the print head in the XY plane to the specified coordinates at a given feedrate. + * Uses the G1 command. + * @param x The target X coordinate. + * @param y The target Y coordinate. + * @param feedrate The speed of movement in mm/min. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async moveExtruder(x: number, y: number, feedrate: number): Promise { + return await this.tcpClient.sendCmdOk(`~G1 X${x} Y${y} F${feedrate}`); + } + + /** + * Extrudes a specified length of filament at a given feedrate. + * Uses the G1 E[length] F[feedrate] command. + * @param length The length of filament to extrude in millimeters. + * @param feedrate The speed of extrusion in mm/min. Defaults to 450. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async extrude(length: number, feedrate: number = 450): Promise { + return await this.tcpClient.sendCmdOk(`~G1 E${length} F${feedrate}`); + } + + /** + * Sets the target temperature for the extruder. + * Uses the M104 S[temp] command. + * @param temp The target temperature in Celsius. + * @param waitFor If true, the method will also call `waitForExtruderTemp` to wait until the target temperature is reached. Defaults to false. + * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. + */ + public async setExtruderTemp(temp: number, waitFor: boolean = false): Promise { + const ok = await this.tcpClient.sendCmdOk(`~M104 S${temp}`); + if (!waitFor) return ok; + return await this.waitForExtruderTemp(temp); + } + + /** + * Sets the target temperature for the print bed. + * Uses the M140 S[temp] command. + * @param temp The target temperature in Celsius. + * @param waitFor If true, the method will also call `waitForBedTemp` to wait until the target temperature is reached or cooled down. Defaults to false. + * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. + */ + public async setBedTemp(temp: number, waitFor: boolean = false): Promise { + const ok = await this.tcpClient.sendCmdOk(`~M140 S${temp}`); + if (!waitFor) return ok; + return await this.waitForBedTemp(temp, false); + } + + /** + * Cancels extruder heating by setting its target temperature to 0. + * Uses the M104 S0 command. + * @returns A Promise that resolves to true if the command was acknowledged successfully, false otherwise. + */ + public async cancelExtruderTemp(): Promise { + return await this.tcpClient.sendCmdOk('~M104 S0'); + } + + /** + * Cancels print bed heating by setting its target temperature to 0. + * Uses the M140 S0 command. + * @param waitForCool If true, waits for the bed to cool down to a safe temperature (37°C) after sending the command. Defaults to false. + * @returns A Promise that resolves to true if the command(s) were acknowledged successfully, false otherwise. + */ + public async cancelBedTemp(waitForCool: boolean = false): Promise { + const ok = await this.tcpClient.sendCmdOk('~M140 S0'); + if (!waitForCool) return ok; + return await this.waitForBedTemp(37, true); // *can* remove parts @ 40 but safer side + } + + /** + * Waits for the print bed to reach a specified target temperature. + * This method polls the printer's temperature and also sends a G-code command (M190 or M191) + * to make the printer itself wait. + * @param temp The target bed temperature in Celsius. + * @param cooling If true, waits for the temperature to cool down to or below `temp` (uses M191 R[temp]). + * If false, waits for the temperature to heat up to or above `temp` (uses M190 S[temp]). + * @returns A Promise that resolves to true if the target temperature is reached within the timeout (30s), false otherwise. + * @todo Implement customizable timeouts. + */ + public async waitForBedTemp(temp: number, cooling: boolean): Promise { + // wait machine-side as well + if (cooling) await this.tcpClient.sendCmdOk(`${GCodes.WaitForBedTemp}R${temp}`); + else await this.tcpClient.sendCmdOk(`${GCodes.WaitForBedTemp}S${temp}`); // M190 S[temp] + const startTime = Date.now(); + const timeout = 30000; // 30s timeout + + while (Date.now() - startTime < timeout) { + const tempInfo = await this.tcpClient.getTempInfo(); + if (tempInfo && tempInfo.getBedTemp()?.getCurrent() === temp) return true; + await new Promise((resolve) => setTimeout(resolve, 1000)); // Poll every second + } + + console.log(`WaitForBedTemp (target ${temp}) timed out after 30s.`); + return false; + } + + /** + * Waits for the extruder to reach a specified target temperature. + * This method polls the printer's temperature and also sends a G-code command (M109 S[temp]) + * to make the printer itself wait. + * @param temp The target extruder temperature in Celsius. + * @returns A Promise that resolves to true if the target temperature is reached within the timeout (30s), false otherwise. + * @todo Implement customizable timeouts. + */ + public async waitForExtruderTemp(temp: number): Promise { + // wait machine-side as well + await this.tcpClient.sendCmdOk(`${GCodes.WaitForHotendTemp}S${temp}`); // M109 S[temp] + const startTime = Date.now(); + const timeout = 30000; // 30s timeout + + while (Date.now() - startTime < timeout) { + const tempInfo = await this.tcpClient.getTempInfo(); + if (tempInfo && tempInfo.getExtruderTemp()?.getCurrent() === temp) return true; + await new Promise((resolve) => setTimeout(resolve, 1000)); // Poll every second + } + + console.log(`WaitForExtruderTemp (target ${temp}) timed out after 30s.`); + return false; + } +} diff --git a/src/tcpapi/client/GCodes.ts b/src/tcpapi/client/GCodes.ts index 0c9d553..785eb1a 100644 --- a/src/tcpapi/client/GCodes.ts +++ b/src/tcpapi/client/GCodes.ts @@ -4,63 +4,63 @@ */ // src/tcpapi/client/GCodes.ts export class GCodes { - /** Command to initiate a control session with the printer (login). */ - public static readonly CmdLogin = "~M601 S1"; - /** Command to terminate a control session with the printer (logout). */ - public static readonly CmdLogout = "~M602"; + /** Command to initiate a control session with the printer (login). */ + public static readonly CmdLogin = '~M601 S1'; + /** Command to terminate a control session with the printer (logout). */ + public static readonly CmdLogout = '~M602'; - /** Command for an emergency stop of all printer activity. */ - public static readonly CmdEmergencyStop = "~M112"; + /** Command for an emergency stop of all printer activity. */ + public static readonly CmdEmergencyStop = '~M112'; - /** Command to request the current print job status. */ - public static readonly CmdPrintStatus = "~M27"; - /** Command to request the status of the printer's endstops. */ - public static readonly CmdEndstopInfo = "~M119"; - /** Command to request general printer information, including firmware version. */ - public static readonly CmdInfoStatus = "~M115"; - /** Command to request the current X, Y, Z, A, B coordinates of the print head. */ - public static readonly CmdInfoXyzab = "~M114"; - /** Command to request current temperatures (extruder, bed). */ - public static readonly CmdTemp = "~M105"; + /** Command to request the current print job status. */ + public static readonly CmdPrintStatus = '~M27'; + /** Command to request the status of the printer's endstops. */ + public static readonly CmdEndstopInfo = '~M119'; + /** Command to request general printer information, including firmware version. */ + public static readonly CmdInfoStatus = '~M115'; + /** Command to request the current X, Y, Z, A, B coordinates of the print head. */ + public static readonly CmdInfoXyzab = '~M114'; + /** Command to request current temperatures (extruder, bed). */ + public static readonly CmdTemp = '~M105'; - /** Command to turn the printer's LED lights on (full white). */ - public static readonly CmdLedOn = "~M146 r255 g255 b255 F0"; - /** Command to turn the printer's LED lights off. */ - public static readonly CmdLedOff = "~M146 r0 g0 b0 F0"; + /** Command to turn the printer's LED lights on (full white). */ + public static readonly CmdLedOn = '~M146 r255 g255 b255 F0'; + /** Command to turn the printer's LED lights off. */ + public static readonly CmdLedOff = '~M146 r0 g0 b0 F0'; - /** Command to enable the filament runout sensor. */ - public static readonly CmdRunoutSensorOn = "~M405"; - /** Command to disable the filament runout sensor. */ - public static readonly CmdRunoutSensorOff = "~M406"; + /** Command to enable the filament runout sensor. */ + public static readonly CmdRunoutSensorOn = '~M405'; + /** Command to disable the filament runout sensor. */ + public static readonly CmdRunoutSensorOff = '~M406'; - /** Command to list files stored locally on the printer (typically on internal storage or SD card). */ - public static readonly CmdListLocalFiles = "~M661"; - /** Command to retrieve a thumbnail image for a specified G-code file. Requires a file path argument. */ - public static readonly CmdGetThumbnail = "~M662"; + /** Command to list files stored locally on the printer (typically on internal storage or SD card). */ + public static readonly CmdListLocalFiles = '~M661'; + /** Command to retrieve a thumbnail image for a specified G-code file. Requires a file path argument. */ + public static readonly CmdGetThumbnail = '~M662'; - /** Command to instruct the printer to take a picture with its camera, if equipped. */ - public static readonly TakePicture = "~M240"; + /** Command to instruct the printer to take a picture with its camera, if equipped. */ + public static readonly TakePicture = '~M240'; - /** Command to home all printer axes (X, Y, Z). (G28) */ - public static readonly CmdHomeAxes = "~G28"; + /** Command to home all printer axes (X, Y, Z). (G28) */ + public static readonly CmdHomeAxes = '~G28'; - /** Command to select a file for printing. `%%filename%%` should be replaced with the actual file path. */ - public static readonly CmdStartPrint = "~M23 0:/user/%%filename%%" - /** Command to pause the current print job. (M25) */ - public static readonly CmdPausePrint = "~M25" - /** Command to resume a paused print job. (M24) */ - public static readonly CmdResumePrint = "~M24" - /** Command to stop/cancel the current print job. (M26) */ - public static readonly CmdStopPrint = "~M26" + /** Command to select a file for printing. `%%filename%%` should be replaced with the actual file path. */ + public static readonly CmdStartPrint = '~M23 0:/user/%%filename%%'; + /** Command to pause the current print job. (M25) */ + public static readonly CmdPausePrint = '~M25'; + /** Command to resume a paused print job. (M24) */ + public static readonly CmdResumePrint = '~M24'; + /** Command to stop/cancel the current print job. (M26) */ + public static readonly CmdStopPrint = '~M26'; - /** Command to set extruder temperature and wait until it's reached. Requires S[temperature] parameter. (M109) */ - public static readonly WaitForHotendTemp = "~M109" - /** Command to set bed temperature and wait until it's reached. Requires S[temperature] or R[temperature] (for cooling) parameter. (M190) */ - public static readonly WaitForBedTemp = "~M190"; + /** Command to set extruder temperature and wait until it's reached. Requires S[temperature] parameter. (M109) */ + public static readonly WaitForHotendTemp = '~M109'; + /** Command to set bed temperature and wait until it's reached. Requires S[temperature] or R[temperature] (for cooling) parameter. (M190) */ + public static readonly WaitForBedTemp = '~M190'; - // Commented out commands, potentially for file upload operations, not currently in active use. - // /** Command to prepare for file upload, specifying size and path. `%%size%%` and `%%filename%%` are placeholders. */ - // public static readonly CmdPrepFileUpload = "~M28 %%size%% 0:/user/%%filename%%" - // /** Command to indicate completion of file upload. */ - // public static readonly CmdCompleteFileUpload = "~M29" -} \ No newline at end of file + // Commented out commands, potentially for file upload operations, not currently in active use. + // /** Command to prepare for file upload, specifying size and path. `%%size%%` and `%%filename%%` are placeholders. */ + // public static readonly CmdPrepFileUpload = "~M28 %%size%% 0:/user/%%filename%%" + // /** Command to indicate completion of file upload. */ + // public static readonly CmdCompleteFileUpload = "~M29" +} diff --git a/src/tcpapi/replays/EndstopStatus.test.ts b/src/tcpapi/replays/EndstopStatus.test.ts index 51aaa15..f03ee13 100644 --- a/src/tcpapi/replays/EndstopStatus.test.ts +++ b/src/tcpapi/replays/EndstopStatus.test.ts @@ -1,7 +1,8 @@ /** * @fileoverview Tests for EndstopStatus parser including M119 response parsing and status checking methods. */ -import { EndstopStatus, Endstop, Status, MachineStatus, MoveMode } from './EndstopStatus'; +import { describe, expect, it } from 'vitest'; +import { Endstop, EndstopStatus, MachineStatus, MoveMode, Status } from './EndstopStatus'; describe('Endstop', () => { it('should parse endstop values correctly', () => { diff --git a/src/tcpapi/replays/EndstopStatus.ts b/src/tcpapi/replays/EndstopStatus.ts index 55306b3..781f944 100644 --- a/src/tcpapi/replays/EndstopStatus.ts +++ b/src/tcpapi/replays/EndstopStatus.ts @@ -9,110 +9,112 @@ * movement mode, LED status, and the currently loaded file. */ export class EndstopStatus { - /** Parsed endstop states (X-max, Y-max, Z-min). See {@link Endstop}. */ - public _Endstop: Endstop | null = null; - /** Current operational status of the machine. See {@link MachineStatus}. */ - public _MachineStatus: MachineStatus = MachineStatus.DEFAULT; - /** Current movement mode of the printer. See {@link MoveMode}. */ - public _MoveMode: MoveMode = MoveMode.DEFAULT; - /** Additional status flags (S, L, J, F). See {@link Status}. */ - public _Status: Status | null = null; - /** Indicates if the printer's LED lights are currently enabled. */ - public _LedEnabled: boolean = false; - /** Name of the file currently loaded or being printed. Null if no file is active. */ - public _CurrentFile: string | null = null; - - /** - * Parses a raw string replay (typically from an M119 or similar status command) - * to populate the properties of this `EndstopStatus` instance. - * The replay is expected to be a multi-line string where each line provides specific information. - * - * Parsing logic: - * - Line 1 (data[0]): Usually a command echo or header, ignored. - * - Line 2 (data[1]): Parsed into the `_Endstop` object. - * - Line 3 (data[2]): Parsed to determine `_MachineStatus` by checking for keywords like "BUILDING_FROM_SD", "PAUSED", "READY". - * - Line 4 (data[3]): Parsed to determine `_MoveMode` by checking for keywords like "MOVING", "HOMING", "READY". - * - Line 5 (data[4]): Parsed into the `_Status` object. - * - Line 6 (data[5]): Parsed to determine `_LedEnabled` (1 for true, 0 for false). - * - Line 7 (data[6]): Parsed to get `_CurrentFile`, or null if empty. - * - * @param replay The raw multi-line string response from the printer. - * @returns The populated `EndstopStatus` instance, or null if parsing fails or the replay is invalid. - */ - public fromReplay(replay: string): EndstopStatus | null { - if (!replay) return null; - - try { - const data = replay.split('\n'); - this._Endstop = new Endstop(data[1]); - - const machineStatus = data[2].replace("MachineStatus: ", "").trim(); - if (machineStatus.includes("BUILDING_FROM_SD")) this._MachineStatus = MachineStatus.BUILDING_FROM_SD; - else if (machineStatus.includes("BUILDING_COMPLETED")) this._MachineStatus = MachineStatus.BUILDING_COMPLETED; - else if (machineStatus.includes("PAUSED")) this._MachineStatus = MachineStatus.PAUSED; - else if (machineStatus.includes("READY")) this._MachineStatus = MachineStatus.READY; - else if (machineStatus.includes("BUSY")) this._MachineStatus = MachineStatus.BUSY; - else { - console.log("EndstopStatus Encountered unknown MachineStatus: " + machineStatus); - this._MachineStatus = MachineStatus.DEFAULT; - } - - const moveM = data[3].replace("MoveMode: ", "").trim(); - if (moveM.includes("MOVING")) this._MoveMode = MoveMode.MOVING; - else if (moveM.includes("PAUSED")) this._MoveMode = MoveMode.PAUSED; - else if (moveM.includes("READY")) this._MoveMode = MoveMode.READY; - else if (moveM.includes("WAIT_ON_TOOL")) this._MoveMode = MoveMode.WAIT_ON_TOOL; - else if (moveM.includes("HOMING")) this._MoveMode = MoveMode.HOMING; - else { - console.log("EndstopStatus Encountered unknown MoveMode: " + moveM); - this._MoveMode = MoveMode.DEFAULT; - } - - this._Status = new Status(data[4]); - this._LedEnabled = parseInt(data[5].replace("LED: ", "").trim()) === 1; - this._CurrentFile = data[6].replace("CurrentFile: ", "").trim(); - if (!this._CurrentFile || this._CurrentFile === "") this._CurrentFile = null; - - return this; - } catch (e) { - console.log("Unable to create EndstopStatus instance from replay"); - console.log(replay); - //console.log(e.stack); - return null; - } - } - - /** - * Checks if the machine status indicates that a print has been completed. - * @returns True if `_MachineStatus` is `BUILDING_COMPLETED`, false otherwise. - */ - public isPrintComplete(): boolean { - return this._MachineStatus === MachineStatus.BUILDING_COMPLETED; - } - - /** - * Checks if the machine status indicates that a print is currently in progress from SD. - * @returns True if `_MachineStatus` is `BUILDING_FROM_SD`, false otherwise. - */ - public isPrinting(): boolean { - return this._MachineStatus === MachineStatus.BUILDING_FROM_SD; - } - - /** - * Checks if the printer is in a ready state (both move mode and machine status are READY). - * @returns True if the printer is ready, false otherwise. - */ - public isReady(): boolean { - return this._MoveMode === MoveMode.READY && this._MachineStatus === MachineStatus.READY; - } - - /** - * Checks if the printer is currently paused (either machine status or move mode is PAUSED). - * @returns True if the printer is paused, false otherwise. - */ - public isPaused(): boolean { - return this._MachineStatus === MachineStatus.PAUSED || this._MoveMode === MoveMode.PAUSED; + /** Parsed endstop states (X-max, Y-max, Z-min). See {@link Endstop}. */ + public _Endstop: Endstop | null = null; + /** Current operational status of the machine. See {@link MachineStatus}. */ + public _MachineStatus: MachineStatus = MachineStatus.DEFAULT; + /** Current movement mode of the printer. See {@link MoveMode}. */ + public _MoveMode: MoveMode = MoveMode.DEFAULT; + /** Additional status flags (S, L, J, F). See {@link Status}. */ + public _Status: Status | null = null; + /** Indicates if the printer's LED lights are currently enabled. */ + public _LedEnabled: boolean = false; + /** Name of the file currently loaded or being printed. Null if no file is active. */ + public _CurrentFile: string | null = null; + + /** + * Parses a raw string replay (typically from an M119 or similar status command) + * to populate the properties of this `EndstopStatus` instance. + * The replay is expected to be a multi-line string where each line provides specific information. + * + * Parsing logic: + * - Line 1 (data[0]): Usually a command echo or header, ignored. + * - Line 2 (data[1]): Parsed into the `_Endstop` object. + * - Line 3 (data[2]): Parsed to determine `_MachineStatus` by checking for keywords like "BUILDING_FROM_SD", "PAUSED", "READY". + * - Line 4 (data[3]): Parsed to determine `_MoveMode` by checking for keywords like "MOVING", "HOMING", "READY". + * - Line 5 (data[4]): Parsed into the `_Status` object. + * - Line 6 (data[5]): Parsed to determine `_LedEnabled` (1 for true, 0 for false). + * - Line 7 (data[6]): Parsed to get `_CurrentFile`, or null if empty. + * + * @param replay The raw multi-line string response from the printer. + * @returns The populated `EndstopStatus` instance, or null if parsing fails or the replay is invalid. + */ + public fromReplay(replay: string): EndstopStatus | null { + if (!replay) return null; + + try { + const data = replay.split('\n'); + this._Endstop = new Endstop(data[1]); + + const machineStatus = data[2].replace('MachineStatus: ', '').trim(); + if (machineStatus.includes('BUILDING_FROM_SD')) + this._MachineStatus = MachineStatus.BUILDING_FROM_SD; + else if (machineStatus.includes('BUILDING_COMPLETED')) + this._MachineStatus = MachineStatus.BUILDING_COMPLETED; + else if (machineStatus.includes('PAUSED')) this._MachineStatus = MachineStatus.PAUSED; + else if (machineStatus.includes('READY')) this._MachineStatus = MachineStatus.READY; + else if (machineStatus.includes('BUSY')) this._MachineStatus = MachineStatus.BUSY; + else { + console.log(`EndstopStatus Encountered unknown MachineStatus: ${machineStatus}`); + this._MachineStatus = MachineStatus.DEFAULT; + } + + const moveM = data[3].replace('MoveMode: ', '').trim(); + if (moveM.includes('MOVING')) this._MoveMode = MoveMode.MOVING; + else if (moveM.includes('PAUSED')) this._MoveMode = MoveMode.PAUSED; + else if (moveM.includes('READY')) this._MoveMode = MoveMode.READY; + else if (moveM.includes('WAIT_ON_TOOL')) this._MoveMode = MoveMode.WAIT_ON_TOOL; + else if (moveM.includes('HOMING')) this._MoveMode = MoveMode.HOMING; + else { + console.log(`EndstopStatus Encountered unknown MoveMode: ${moveM}`); + this._MoveMode = MoveMode.DEFAULT; + } + + this._Status = new Status(data[4]); + this._LedEnabled = parseInt(data[5].replace('LED: ', '').trim(), 10) === 1; + this._CurrentFile = data[6].replace('CurrentFile: ', '').trim(); + if (!this._CurrentFile || this._CurrentFile === '') this._CurrentFile = null; + + return this; + } catch (_e) { + console.log('Unable to create EndstopStatus instance from replay'); + console.log(replay); + //console.log(e.stack); + return null; } + } + + /** + * Checks if the machine status indicates that a print has been completed. + * @returns True if `_MachineStatus` is `BUILDING_COMPLETED`, false otherwise. + */ + public isPrintComplete(): boolean { + return this._MachineStatus === MachineStatus.BUILDING_COMPLETED; + } + + /** + * Checks if the machine status indicates that a print is currently in progress from SD. + * @returns True if `_MachineStatus` is `BUILDING_FROM_SD`, false otherwise. + */ + public isPrinting(): boolean { + return this._MachineStatus === MachineStatus.BUILDING_FROM_SD; + } + + /** + * Checks if the printer is in a ready state (both move mode and machine status are READY). + * @returns True if the printer is ready, false otherwise. + */ + public isReady(): boolean { + return this._MoveMode === MoveMode.READY && this._MachineStatus === MachineStatus.READY; + } + + /** + * Checks if the printer is currently paused (either machine status or move mode is PAUSED). + * @returns True if the printer is paused, false otherwise. + */ + public isPaused(): boolean { + return this._MachineStatus === MachineStatus.PAUSED || this._MoveMode === MoveMode.PAUSED; + } } /** @@ -120,26 +122,26 @@ export class EndstopStatus { * The meaning of S, L, J, F flags can be specific to printer firmware or model. */ export class Status { - /** Status flag S (meaning may vary). */ - public S: number = 0; - /** Status flag L (meaning may vary). */ - public L: number = 0; - /** Status flag J (meaning may vary). */ - public J: number = 0; - /** Status flag F (meaning may vary). */ - public F: number = 0; - - /** - * Creates an instance of Status by parsing a string line. - * It uses a regular expression to find key-value pairs like "S:0". - * @param data The string line containing status flags (e.g., "Status S:0 L:0 J:0 F:0"). - */ - constructor(data: string) { - this.S = getValue(data, "S"); - this.L = getValue(data, "L"); - this.J = getValue(data, "J"); - this.F = getValue(data, "F"); - } + /** Status flag S (meaning may vary). */ + public S: number = 0; + /** Status flag L (meaning may vary). */ + public L: number = 0; + /** Status flag J (meaning may vary). */ + public J: number = 0; + /** Status flag F (meaning may vary). */ + public F: number = 0; + + /** + * Creates an instance of Status by parsing a string line. + * It uses a regular expression to find key-value pairs like "S:0". + * @param data The string line containing status flags (e.g., "Status S:0 L:0 J:0 F:0"). + */ + constructor(data: string) { + this.S = getValue(data, 'S'); + this.L = getValue(data, 'L'); + this.J = getValue(data, 'J'); + this.F = getValue(data, 'F'); + } } /** @@ -147,23 +149,23 @@ export class Status { * Typically, a value of 0 means not triggered, and 1 means triggered. */ export class Endstop { - /** State of the X-axis maximum endstop. */ - public Xmax: number = 0; - /** State of the Y-axis maximum endstop. */ - public Ymax: number = 0; - /** State of the Z-axis minimum endstop. */ - public Zmin: number = 0; - - /** - * Creates an instance of Endstop by parsing a string line. - * It uses a regular expression to find key-value pairs like "X-max:0". - * @param data The string line containing endstop states (e.g., "Endstop X-max:0 Y-max:0 Z-min:1"). - */ - constructor(data: string) { - this.Xmax = getValue(data, "X-max"); - this.Ymax = getValue(data, "Y-max"); - this.Zmin = getValue(data, "Z-min"); - } + /** State of the X-axis maximum endstop. */ + public Xmax: number = 0; + /** State of the Y-axis maximum endstop. */ + public Ymax: number = 0; + /** State of the Z-axis minimum endstop. */ + public Zmin: number = 0; + + /** + * Creates an instance of Endstop by parsing a string line. + * It uses a regular expression to find key-value pairs like "X-max:0". + * @param data The string line containing endstop states (e.g., "Endstop X-max:0 Y-max:0 Z-min:1"). + */ + constructor(data: string) { + this.Xmax = getValue(data, 'X-max'); + this.Ymax = getValue(data, 'Y-max'); + this.Zmin = getValue(data, 'Z-min'); + } } /** @@ -175,44 +177,44 @@ export class Endstop { * @private */ function getValue(input: string, key: string): number { - const pattern = new RegExp(key + `:(\\d+)`); - const match = input.match(pattern); - if (match && match[1]) return parseInt(match[1], 10); - return -1; + const pattern = new RegExp(`${key}:(\\d+)`); + const match = input.match(pattern); + if (match?.[1]) return parseInt(match[1], 10); + return -1; } /** * Enumerates the possible operational statuses of the machine. */ export enum MachineStatus { - /** Printer is actively printing from SD card or internal storage. */ - BUILDING_FROM_SD, - /** Printer has completed the print job. */ - BUILDING_COMPLETED, - /** Printer is paused (often during a print job). */ - PAUSED, - /** Printer is ready for new commands or to start a job. */ - READY, - /** Printer is busy with some other operation. */ - BUSY, - /** Default or unknown machine status. */ - DEFAULT + /** Printer is actively printing from SD card or internal storage. */ + BUILDING_FROM_SD, + /** Printer has completed the print job. */ + BUILDING_COMPLETED, + /** Printer is paused (often during a print job). */ + PAUSED, + /** Printer is ready for new commands or to start a job. */ + READY, + /** Printer is busy with some other operation. */ + BUSY, + /** Default or unknown machine status. */ + DEFAULT, } /** * Enumerates the possible movement modes of the printer. */ export enum MoveMode { - /** Printer head is currently moving. */ - MOVING, - /** Printer movement is paused (e.g., during a filament change). */ - PAUSED, - /** Printer is ready for movement commands. */ - READY, - /** Printer is waiting for a tool-related action (e.g., tool change, heating). */ - WAIT_ON_TOOL, - /** Printer is currently performing a homing sequence. */ - HOMING, - /** Default or unknown movement mode. */ - DEFAULT -} \ No newline at end of file + /** Printer head is currently moving. */ + MOVING, + /** Printer movement is paused (e.g., during a filament change). */ + PAUSED, + /** Printer is ready for movement commands. */ + READY, + /** Printer is waiting for a tool-related action (e.g., tool change, heating). */ + WAIT_ON_TOOL, + /** Printer is currently performing a homing sequence. */ + HOMING, + /** Default or unknown movement mode. */ + DEFAULT, +} diff --git a/src/tcpapi/replays/LocationInfo.test.ts b/src/tcpapi/replays/LocationInfo.test.ts index 009a902..48ca495 100644 --- a/src/tcpapi/replays/LocationInfo.test.ts +++ b/src/tcpapi/replays/LocationInfo.test.ts @@ -1,6 +1,7 @@ /** * @fileoverview Tests for LocationInfo parser including M114 response parsing and coordinate extraction. */ +import { describe, expect, it } from 'vitest'; import { LocationInfo } from './LocationInfo'; describe('LocationInfo', () => { diff --git a/src/tcpapi/replays/LocationInfo.ts b/src/tcpapi/replays/LocationInfo.ts index 0e5a062..3b58ac0 100644 --- a/src/tcpapi/replays/LocationInfo.ts +++ b/src/tcpapi/replays/LocationInfo.ts @@ -8,47 +8,47 @@ * which reports the current position. */ export class LocationInfo { - /** The current X-axis coordinate as a string (e.g., "10.00"). */ - public X: string = ''; - /** The current Y-axis coordinate as a string (e.g., "20.50"). */ - public Y: string = ''; - /** The current Z-axis coordinate as a string (e.g., "5.25"). */ - public Z: string = ''; + /** The current X-axis coordinate as a string (e.g., "10.00"). */ + public X: string = ''; + /** The current Y-axis coordinate as a string (e.g., "20.50"). */ + public Y: string = ''; + /** The current Z-axis coordinate as a string (e.g., "5.25"). */ + public Z: string = ''; - /** - * Parses a raw string replay (typically from an M114 command) to populate - * the X, Y, and Z coordinate properties of this instance. - * - * The parsing logic assumes the replay is a multi-line string where the second line - * (data[1]) contains the coordinate data in a format like "X:10.00 Y:20.50 Z:5.25 ...". - * It splits this line by spaces and then extracts the values for X, Y, and Z by - * removing the prefixes "X:", "Y:", and "Z:". - * - * @param replay The raw multi-line string response from the printer. - * @returns The populated `LocationInfo` instance, or null if parsing fails - * (e.g., due to unexpected format or null/empty replay). - */ - public fromReplay(replay: string): LocationInfo | null { - try { - const data = replay.split('\n'); - // The first line (data[0]) is often the command echo (e.g., "ok M114") or similar, - // actual coordinate data is expected on the second line. - const locData = data[1].split(' '); - this.X = locData[0].replace("X:", "").trim(); - this.Y = locData[1].replace("Y:", "").trim(); - this.Z = locData[2].replace("Z:", "").trim(); - return this; - } catch (error) { - console.log("LocationInfo replay has bad/null data"); - return null; - } + /** + * Parses a raw string replay (typically from an M114 command) to populate + * the X, Y, and Z coordinate properties of this instance. + * + * The parsing logic assumes the replay is a multi-line string where the second line + * (data[1]) contains the coordinate data in a format like "X:10.00 Y:20.50 Z:5.25 ...". + * It splits this line by spaces and then extracts the values for X, Y, and Z by + * removing the prefixes "X:", "Y:", and "Z:". + * + * @param replay The raw multi-line string response from the printer. + * @returns The populated `LocationInfo` instance, or null if parsing fails + * (e.g., due to unexpected format or null/empty replay). + */ + public fromReplay(replay: string): LocationInfo | null { + try { + const data = replay.split('\n'); + // The first line (data[0]) is often the command echo (e.g., "ok M114") or similar, + // actual coordinate data is expected on the second line. + const locData = data[1].split(' '); + this.X = locData[0].replace('X:', '').trim(); + this.Y = locData[1].replace('Y:', '').trim(); + this.Z = locData[2].replace('Z:', '').trim(); + return this; + } catch (_error) { + console.log('LocationInfo replay has bad/null data'); + return null; } + } - /** - * Returns a string representation of the location information. - * @returns A string in the format "X: [X_value] Y: [Y_value] Z: [Z_value]". - */ - public toString(): string { - return "X: " + this.X + " Y: " + this.Y + " Z: " + this.Z; - } -} \ No newline at end of file + /** + * Returns a string representation of the location information. + * @returns A string in the format "X: [X_value] Y: [Y_value] Z: [Z_value]". + */ + public toString(): string { + return `X: ${this.X} Y: ${this.Y} Z: ${this.Z}`; + } +} diff --git a/src/tcpapi/replays/PrintStatus.test.ts b/src/tcpapi/replays/PrintStatus.test.ts index 540582e..307b178 100644 --- a/src/tcpapi/replays/PrintStatus.test.ts +++ b/src/tcpapi/replays/PrintStatus.test.ts @@ -1,6 +1,7 @@ /** * @fileoverview Tests for PrintStatus parser including M27 response parsing and progress calculation. */ +import { describe, expect, it } from 'vitest'; import { PrintStatus } from './PrintStatus'; describe('PrintStatus', () => { diff --git a/src/tcpapi/replays/PrintStatus.ts b/src/tcpapi/replays/PrintStatus.ts index 068ea20..a8a5636 100644 --- a/src/tcpapi/replays/PrintStatus.ts +++ b/src/tcpapi/replays/PrintStatus.ts @@ -8,94 +8,94 @@ * which reports the print progress from the SD card. */ export class PrintStatus { - /** Current byte count processed from the SD card file. */ - public _sdCurrent: string = ''; - /** Total byte count of the file being printed from the SD card. */ - public _sdTotal: string = ''; - /** Current layer number being printed. */ - public _layerCurrent: string = ''; - /** Total number of layers in the print job. */ - public _layerTotal: string = ''; + /** Current byte count processed from the SD card file. */ + public _sdCurrent: string = ''; + /** Total byte count of the file being printed from the SD card. */ + public _sdTotal: string = ''; + /** Current layer number being printed. */ + public _layerCurrent: string = ''; + /** Total number of layers in the print job. */ + public _layerTotal: string = ''; - /** - * Parses a raw string replay (typically from an M27 command) to populate - * the print status properties of this instance. - * - * The parsing logic expects a multi-line string: - * - Line 1 (data[0]): Usually a command echo, ignored. - * - Line 2 (data[1]): Contains SD card progress, e.g., "SD printing byte 12345/67890". - * It extracts the current and total bytes. - * - Line 3 (data[2]): Contains layer progress, e.g., "Layer: 10/250". - * It extracts the current and total layers. - * - * @param replay The raw multi-line string response from the printer. - * @returns The populated `PrintStatus` instance, or null if parsing fails - * (e.g., due to unexpected format, null/empty replay, or missing data). - */ - public fromReplay(replay: string): PrintStatus | null { - try { - const data = replay.split('\n'); - // Example: "SD printing byte 12345/67890" - const sdProgress = data[1].replace("SD printing byte ", "").trim(); - const sdProgressData = sdProgress.split('/'); - this._sdCurrent = sdProgressData[0].trim(); - this._sdTotal = sdProgressData[1].trim(); + /** + * Parses a raw string replay (typically from an M27 command) to populate + * the print status properties of this instance. + * + * The parsing logic expects a multi-line string: + * - Line 1 (data[0]): Usually a command echo, ignored. + * - Line 2 (data[1]): Contains SD card progress, e.g., "SD printing byte 12345/67890". + * It extracts the current and total bytes. + * - Line 3 (data[2]): Contains layer progress, e.g., "Layer: 10/250". + * It extracts the current and total layers. + * + * @param replay The raw multi-line string response from the printer. + * @returns The populated `PrintStatus` instance, or null if parsing fails + * (e.g., due to unexpected format, null/empty replay, or missing data). + */ + public fromReplay(replay: string): PrintStatus | null { + try { + const data = replay.split('\n'); + // Example: "SD printing byte 12345/67890" + const sdProgress = data[1].replace('SD printing byte ', '').trim(); + const sdProgressData = sdProgress.split('/'); + this._sdCurrent = sdProgressData[0].trim(); + this._sdTotal = sdProgressData[1].trim(); - let layerProgress; - try { - // Example: "Layer: 10/250" - layerProgress = data[2].replace("Layer: ", "").trim(); - } catch (error) { - console.log("PrintStatus bad layer progress"); - console.log("Raw printer replay: " + replay); - return null; - } + let layerProgress: string; + try { + // Example: "Layer: 10/250" + layerProgress = data[2].replace('Layer: ', '').trim(); + } catch (_error) { + console.log('PrintStatus bad layer progress'); + console.log(`Raw printer replay: ${replay}`); + return null; + } - try { - const lpData = layerProgress.split('/'); - this._layerCurrent = lpData[0].trim(); - this._layerTotal = lpData[1].trim(); - return this; - } catch (error) { - console.log("PrintStatus bad layer progress"); - console.log("layerProgress: " + layerProgress); - return null; - } - } catch (error) { - console.log("Error parsing print status"); - return null; - } + try { + const lpData = layerProgress.split('/'); + this._layerCurrent = lpData[0].trim(); + this._layerTotal = lpData[1].trim(); + return this; + } catch (_error) { + console.log('PrintStatus bad layer progress'); + console.log(`layerProgress: ${layerProgress}`); + return null; + } + } catch (_error) { + console.log('Error parsing print status'); + return null; } + } - /** - * Calculates the print progress percentage based on the current and total layers. - * The result is clamped between 0 and 100. - * @returns The print progress percentage (0-100), rounded to the nearest integer. - * Returns NaN if layer information is not available or invalid. - */ - public getPrintPercent(): number { - const currentLayer = parseInt(this._layerCurrent, 10); - const totalLayers = parseInt(this._layerTotal, 10); - if (isNaN(currentLayer) || isNaN(totalLayers) || totalLayers === 0) { - return NaN; // Or handle error appropriately, e.g., return 0 or throw - } - const perc = (currentLayer / totalLayers) * 100; - return Math.round(Math.min(100, Math.max(0, perc))); // Clamp between 0 and 100 + /** + * Calculates the print progress percentage based on the current and total layers. + * The result is clamped between 0 and 100. + * @returns The print progress percentage (0-100), rounded to the nearest integer. + * Returns NaN if layer information is not available or invalid. + */ + public getPrintPercent(): number { + const currentLayer = parseInt(this._layerCurrent, 10); + const totalLayers = parseInt(this._layerTotal, 10); + if (Number.isNaN(currentLayer) || Number.isNaN(totalLayers) || totalLayers === 0) { + return NaN; // Or handle error appropriately, e.g., return 0 or throw } + const perc = (currentLayer / totalLayers) * 100; + return Math.round(Math.min(100, Math.max(0, perc))); // Clamp between 0 and 100 + } - /** - * Gets the layer progress as a string. - * @returns A string in the format "currentLayer/totalLayers". - */ - public getLayerProgress(): string { - return this._layerCurrent + "/" + this._layerTotal; - } + /** + * Gets the layer progress as a string. + * @returns A string in the format "currentLayer/totalLayers". + */ + public getLayerProgress(): string { + return `${this._layerCurrent}/${this._layerTotal}`; + } - /** - * Gets the SD card byte progress as a string. - * @returns A string in the format "currentBytes/totalBytes". - */ - public getSdProgress(): string { - return this._sdCurrent + "/" + this._sdTotal; - } -} \ No newline at end of file + /** + * Gets the SD card byte progress as a string. + * @returns A string in the format "currentBytes/totalBytes". + */ + public getSdProgress(): string { + return `${this._sdCurrent}/${this._sdTotal}`; + } +} diff --git a/src/tcpapi/replays/PrinterInfo.test.ts b/src/tcpapi/replays/PrinterInfo.test.ts index fb64e4f..71d9ca3 100644 --- a/src/tcpapi/replays/PrinterInfo.test.ts +++ b/src/tcpapi/replays/PrinterInfo.test.ts @@ -1,6 +1,7 @@ /** * @fileoverview Tests for PrinterInfo parser including M115 response parsing and printer metadata extraction. */ +import { describe, expect, it } from 'vitest'; import { PrinterInfo } from './PrinterInfo'; describe('PrinterInfo', () => { diff --git a/src/tcpapi/replays/PrinterInfo.ts b/src/tcpapi/replays/PrinterInfo.ts index 525de17..c3184db 100644 --- a/src/tcpapi/replays/PrinterInfo.ts +++ b/src/tcpapi/replays/PrinterInfo.ts @@ -9,110 +9,125 @@ * which provides details about the printer's firmware and capabilities. */ export class PrinterInfo { - /** The machine type or model name (e.g., "FlashForge Adventurer 5M Pro"). */ - public TypeName: string = ''; - /** The user-assigned name of the printer. */ - public Name: string = ''; - /** The firmware version currently installed on the printer. */ - public FirmwareVersion: string = ''; - /** The unique serial number of the printer. */ - public SerialNumber: string = ''; - /** The build dimensions of the printer (e.g., "X:220 Y:220 Z:220"). */ - public Dimensions: string = ''; - /** The MAC address of the printer's network interface. */ - public MacAddress: string = ''; - /** The number of tools (extruders) the printer has. Note: Marked as unused in FlashForge firmware by original code. */ - public ToolCount: string = ''; + /** The machine type or model name (e.g., "FlashForge Adventurer 5M Pro"). */ + public TypeName: string = ''; + /** The user-assigned name of the printer. */ + public Name: string = ''; + /** The firmware version currently installed on the printer. */ + public FirmwareVersion: string = ''; + /** The unique serial number of the printer. */ + public SerialNumber: string = ''; + /** The build dimensions of the printer (e.g., "X:220 Y:220 Z:220"). */ + public Dimensions: string = ''; + /** The MAC address of the printer's network interface. */ + public MacAddress: string = ''; + /** The number of tools (extruders) the printer has. Note: Marked as unused in FlashForge firmware by original code. */ + public ToolCount: string = ''; - /** - * Parses a raw string replay (typically from an M115 command) to populate - * the properties of this `PrinterInfo` instance. - * - * The M115 response is expected to be a multi-line string where each line - * provides a piece of information in a "Key: Value" format. - * - * Parsing logic: - * - Splits the replay by newline characters. - * - Line 1 (data[0]): Usually command echo/header, often ignored or assumed specific format. - * - Line 2 (data[1]): Expected to be "Machine Type: [TypeName]". `getRight` extracts the value. - * - Line 3 (data[2]): Expected to be "Machine Name: [Name]". `getRight` extracts the value. - * - Line 4 (data[3]): Expected to be "Firmware: [FirmwareVersion]". `getRight` extracts the value. - * - Line 5 (data[4]): Expected to be "SN: [SerialNumber]". `getRight` extracts the value. - * - Line 6 (data[5]): Expected to be the dimensions string directly (e.g., "X:220 Y:220 Z:220"). - * - Line 7 (data[6]): Expected to be "Tool count: [ToolCount]". `getRight` extracts the value. - * - Line 8 (data[7]): Expected to be "Mac Address:[MacAddress]". The prefix is removed. - * - * The `getRight` helper function is used to extract the value part after the colon for several lines. - * - * @param replay The raw multi-line string response from the M115 command. - * @returns The populated `PrinterInfo` instance, or null if parsing fails - * (e.g., due to unexpected format, null/empty replay, or missing critical data). - */ - public fromReplay(replay: string): PrinterInfo | null { - if (!replay) return null; + /** + * Parses a raw string replay (typically from an M115 command) to populate + * the properties of this `PrinterInfo` instance. + * + * The M115 response is expected to be a multi-line string where each line + * provides a piece of information in a "Key: Value" format. + * + * Parsing logic: + * - Splits the replay by newline characters. + * - Line 1 (data[0]): Usually command echo/header, often ignored or assumed specific format. + * - Line 2 (data[1]): Expected to be "Machine Type: [TypeName]". `getRight` extracts the value. + * - Line 3 (data[2]): Expected to be "Machine Name: [Name]". `getRight` extracts the value. + * - Line 4 (data[3]): Expected to be "Firmware: [FirmwareVersion]". `getRight` extracts the value. + * - Line 5 (data[4]): Expected to be "SN: [SerialNumber]". `getRight` extracts the value. + * - Line 6 (data[5]): Expected to be the dimensions string directly (e.g., "X:220 Y:220 Z:220"). + * - Line 7 (data[6]): Expected to be "Tool count: [ToolCount]". `getRight` extracts the value. + * - Line 8 (data[7]): Expected to be "Mac Address:[MacAddress]". The prefix is removed. + * + * The `getRight` helper function is used to extract the value part after the colon for several lines. + * + * @param replay The raw multi-line string response from the M115 command. + * @returns The populated `PrinterInfo` instance, or null if parsing fails + * (e.g., due to unexpected format, null/empty replay, or missing critical data). + */ + public fromReplay(replay: string): PrinterInfo | null { + if (!replay) return null; - try { - const data = replay.split('\n'); - // Assumes data[0] is "CMD M115 Received." or similar header. + try { + const data = replay.split('\n'); + // Assumes data[0] is "CMD M115 Received." or similar header. - const name = getRight(data[1]); // Expected: "Machine Type: Adventurer 5M Pro" - if (name === null) { - console.log("PrinterInfo replay has null Machine Type"); - return null; - } - this.TypeName = name; + const name = getRight(data[1]); // Expected: "Machine Type: Adventurer 5M Pro" + if (name === null) { + console.log('PrinterInfo replay has null Machine Type'); + return null; + } + this.TypeName = name; - const nick = getRight(data[2]); // Expected: "Machine Name: MyPrinter" - if (nick === null) { - console.log("PrinterInfo replay has null Machine Name"); - return null; - } - this.Name = nick; + const nick = getRight(data[2]); // Expected: "Machine Name: MyPrinter" + if (nick === null) { + console.log('PrinterInfo replay has null Machine Name'); + return null; + } + this.Name = nick; - const fw = getRight(data[3]); // Expected: "Firmware: V1.2.3" - if (fw === null) { - console.log("PrinterInfo replay has null firmware version"); - return null; - } - this.FirmwareVersion = fw; + const fw = getRight(data[3]); // Expected: "Firmware: V1.2.3" + if (fw === null) { + console.log('PrinterInfo replay has null firmware version'); + return null; + } + this.FirmwareVersion = fw; - const sn = getRight(data[4]); // Expected: "SN: SN12345" - if (sn === null) { - console.log("PrinterInfo replay has null serial number"); - return null; - } - this.SerialNumber = sn; + const sn = getRight(data[4]); // Expected: "SN: SN12345" + if (sn === null) { + console.log('PrinterInfo replay has null serial number'); + return null; + } + this.SerialNumber = sn; - this.Dimensions = data[5].trim(); // Expected: "X:220 Y:220 Z:220" (or similar, directly) + this.Dimensions = data[5].trim(); // Expected: "X:220 Y:220 Z:220" (or similar, directly) - const tcs = getRight(data[6]); // Expected: "Tool count: 1" - if (tcs === null) { - console.log("PrinterInfo replay has null tool count"); - return null; - } - this.ToolCount = tcs; + const tcs = getRight(data[6]); // Expected: "Tool count: 1" + if (tcs === null) { + console.log('PrinterInfo replay has null tool count'); + return null; + } + this.ToolCount = tcs; - this.MacAddress = data[7].replace("Mac Address:", "").trim(); // Expected: "Mac Address: XX:XX:XX:XX:XX:XX" - return this; - } catch (error) { - console.log("Error creating PrinterInfo instance from replay"); - return null; - } + this.MacAddress = data[7].replace('Mac Address:', '').trim(); // Expected: "Mac Address: XX:XX:XX:XX:XX:XX" + return this; + } catch (_error) { + console.log('Error creating PrinterInfo instance from replay'); + return null; } + } - /** - * Returns a string representation of the printer information. - * @returns A multi-line string detailing the printer's properties. - */ - public toString(): string { - return "Printer Type: " + this.TypeName + "\n" + - "Name: " + this.Name + "\n" + - "Firmware: " + this.FirmwareVersion + "\n" + - "Serial Number: " + this.SerialNumber + "\n" + - "Print Dimensions: " + this.Dimensions + "\n" + - "Tool Count: " + this.ToolCount + "\n" + - "MAC Address: " + this.MacAddress; - } + /** + * Returns a string representation of the printer information. + * @returns A multi-line string detailing the printer's properties. + */ + public toString(): string { + return ( + 'Printer Type: ' + + this.TypeName + + '\n' + + 'Name: ' + + this.Name + + '\n' + + 'Firmware: ' + + this.FirmwareVersion + + '\n' + + 'Serial Number: ' + + this.SerialNumber + + '\n' + + 'Print Dimensions: ' + + this.Dimensions + + '\n' + + 'Tool Count: ' + + this.ToolCount + + '\n' + + 'MAC Address: ' + + this.MacAddress + ); + } } /** @@ -123,9 +138,9 @@ export class PrinterInfo { * @private */ function getRight(rpData: string): string | null { - try { - return rpData.split(':')[1].trim(); - } catch { - return null; - } -} \ No newline at end of file + try { + return rpData.split(':')[1].trim(); + } catch { + return null; + } +} diff --git a/src/tcpapi/replays/TempInfo.test.ts b/src/tcpapi/replays/TempInfo.test.ts index ef435e5..3f46ce2 100644 --- a/src/tcpapi/replays/TempInfo.test.ts +++ b/src/tcpapi/replays/TempInfo.test.ts @@ -1,7 +1,8 @@ /** * @fileoverview Tests for TempInfo parser including M105 response parsing and temperature data extraction. */ -import { TempInfo, TempData } from './TempInfo'; +import { describe, expect, it } from 'vitest'; +import { TempData, TempInfo } from './TempInfo'; describe('TempData', () => { describe('constructor and parsing', () => { diff --git a/src/tcpapi/replays/TempInfo.ts b/src/tcpapi/replays/TempInfo.ts index 2143d1c..d49ffc6 100644 --- a/src/tcpapi/replays/TempInfo.ts +++ b/src/tcpapi/replays/TempInfo.ts @@ -8,120 +8,125 @@ * which reports the current and target temperatures. */ export class TempInfo { - /** Temperature data for the extruder. See {@link TempData}. */ - private _extruderTemp: TempData | null = null; - /** Temperature data for the print bed. See {@link TempData}. */ - private _bedTemp: TempData | null = null; - - /** - * Parses a raw string replay (typically from an M105 command) to populate - * the extruder and bed temperature properties of this instance. - * - * The M105 response format is usually a single line (after the "ok" or command echo) - * containing temperature segments like "T0:25/0" or "T:210/210 B:60/60". - * This method splits the relevant line by spaces and then parses each segment. - * It looks for segments starting with "T0:", "T):", or "T:" for extruder temperature, - * and "B:" for bed temperature. - * - * @param replay The raw multi-line string response from the printer. - * @returns The populated `TempInfo` instance, or null if parsing fails - * (e.g., due to unexpected format, missing critical temperature data). - */ - public fromReplay(replay: string): TempInfo | null { - if (!replay) return null; - - try { - const data = replay.split('\n'); - if (data.length <= 1) { - console.log("TempInfo replay has invalid data?: " + data); - return null; - } - - // Relevant temperature data is usually on the second line (data[1]) - // e.g., "T0:25/0 B:28/0 @:0 B@:0" or "T:210/210 B:60/60" - const tempData = data[1].split(' '); - let extruderDataStr = null; - let bedDataStr = null; - - // Parse each temperature segment - for (const segment of tempData) { - // Check for extruder temperature (T0, T), or T) for some printers) - if (segment.startsWith('T0:')) { - extruderDataStr = segment.replace('T0:', ''); - } else if (segment.startsWith('T):')) { // Some printers might use T): - extruderDataStr = segment.replace('T):', ''); - } else if (segment.startsWith('T:')) { // General case for T: - extruderDataStr = segment.replace('T:', ''); - } - // Check for bed temperature - else if (segment.startsWith('B:')) { - bedDataStr = segment.replace('B:', ''); - } - } - - // If we found extruder data, create TempData object - if (extruderDataStr) { - this._extruderTemp = new TempData(extruderDataStr); - } else { - console.log("No extruder temperature found in replay data: " + replay); - return null; // Extruder temp is critical - } - - // If we found bed data, create TempData object; otherwise, default to 0/0 - if (bedDataStr) { - this._bedTemp = new TempData(bedDataStr); - } else { - console.log("No bed temperature found in replay data, defaulting to 0/0: " + replay); - this._bedTemp = new TempData('0/0'); // Default if not present - } - - return this; - } catch (error) { - console.log("Unable to create TempInfo instance from replay: " + (error instanceof Error ? error.message : String(error))); - console.log("Raw replay data: " + replay); - return null; + /** Temperature data for the extruder. See {@link TempData}. */ + private _extruderTemp: TempData | null = null; + /** Temperature data for the print bed. See {@link TempData}. */ + private _bedTemp: TempData | null = null; + + /** + * Parses a raw string replay (typically from an M105 command) to populate + * the extruder and bed temperature properties of this instance. + * + * The M105 response format is usually a single line (after the "ok" or command echo) + * containing temperature segments like "T0:25/0" or "T:210/210 B:60/60". + * This method splits the relevant line by spaces and then parses each segment. + * It looks for segments starting with "T0:", "T):", or "T:" for extruder temperature, + * and "B:" for bed temperature. + * + * @param replay The raw multi-line string response from the printer. + * @returns The populated `TempInfo` instance, or null if parsing fails + * (e.g., due to unexpected format, missing critical temperature data). + */ + public fromReplay(replay: string): TempInfo | null { + if (!replay) return null; + + try { + const data = replay.split('\n'); + if (data.length <= 1) { + console.log(`TempInfo replay has invalid data?: ${data}`); + return null; + } + + // Relevant temperature data is usually on the second line (data[1]) + // e.g., "T0:25/0 B:28/0 @:0 B@:0" or "T:210/210 B:60/60" + const tempData = data[1].split(' '); + let extruderDataStr = null; + let bedDataStr = null; + + // Parse each temperature segment + for (const segment of tempData) { + // Check for extruder temperature (T0, T), or T) for some printers) + if (segment.startsWith('T0:')) { + extruderDataStr = segment.replace('T0:', ''); + } else if (segment.startsWith('T):')) { + // Some printers might use T): + extruderDataStr = segment.replace('T):', ''); + } else if (segment.startsWith('T:')) { + // General case for T: + extruderDataStr = segment.replace('T:', ''); } - } + // Check for bed temperature + else if (segment.startsWith('B:')) { + bedDataStr = segment.replace('B:', ''); + } + } - /** - * Gets the extruder temperature data. - * @returns A `TempData` object for the extruder, or null if not available. - */ - public getExtruderTemp(): TempData | null { - return this._extruderTemp; - } + // If we found extruder data, create TempData object + if (extruderDataStr) { + this._extruderTemp = new TempData(extruderDataStr); + } else { + console.log(`No extruder temperature found in replay data: ${replay}`); + return null; // Extruder temp is critical + } - /** - * Gets the print bed temperature data. - * @returns A `TempData` object for the bed, or null if not available. - */ - public getBedTemp(): TempData | null { - return this._bedTemp; - } + // If we found bed data, create TempData object; otherwise, default to 0/0 + if (bedDataStr) { + this._bedTemp = new TempData(bedDataStr); + } else { + console.log(`No bed temperature found in replay data, defaulting to 0/0: ${replay}`); + this._bedTemp = new TempData('0/0'); // Default if not present + } - /** - * Checks if both the bed and extruder are cooled down to relatively low temperatures. - * Bed temperature <= 40°C and extruder temperature <= 200°C (though 200 is still hot). - * Use with caution, as "cooled" here is relative and 200C is still very hot for an extruder. - * @returns True if temperatures are at or below the defined thresholds, false otherwise. - */ - public isCooled(): boolean { - const bedTemp = this._bedTemp ? this._bedTemp.getCurrent() : 0; - const extruderTemp = this._extruderTemp ? this._extruderTemp.getCurrent() : 0; - return bedTemp <= 40 && extruderTemp <= 200; + return this; + } catch (error) { + console.log( + 'Unable to create TempInfo instance from replay: ' + + (error instanceof Error ? error.message : String(error)) + ); + console.log(`Raw replay data: ${replay}`); + return null; } + } - /** - * Checks if the current temperatures are within a generally safe operating range - * to prevent overheating (extruder < 250°C, bed < 100°C). - * These are arbitrary "safe" limits and might need adjustment based on specific printer/material. - * @returns True if temperatures are below the defined "safe" thresholds, false otherwise. - */ - public areTempsSafe(): boolean { - const bedTemp = this._bedTemp ? this._bedTemp.getCurrent() : 0; - const extruderTemp = this._extruderTemp ? this._extruderTemp.getCurrent() : 0; - return extruderTemp < 250 && bedTemp < 100; - } + /** + * Gets the extruder temperature data. + * @returns A `TempData` object for the extruder, or null if not available. + */ + public getExtruderTemp(): TempData | null { + return this._extruderTemp; + } + + /** + * Gets the print bed temperature data. + * @returns A `TempData` object for the bed, or null if not available. + */ + public getBedTemp(): TempData | null { + return this._bedTemp; + } + + /** + * Checks if both the bed and extruder are cooled down to relatively low temperatures. + * Bed temperature <= 40°C and extruder temperature <= 200°C (though 200 is still hot). + * Use with caution, as "cooled" here is relative and 200C is still very hot for an extruder. + * @returns True if temperatures are at or below the defined thresholds, false otherwise. + */ + public isCooled(): boolean { + const bedTemp = this._bedTemp ? this._bedTemp.getCurrent() : 0; + const extruderTemp = this._extruderTemp ? this._extruderTemp.getCurrent() : 0; + return bedTemp <= 40 && extruderTemp <= 200; + } + + /** + * Checks if the current temperatures are within a generally safe operating range + * to prevent overheating (extruder < 250°C, bed < 100°C). + * These are arbitrary "safe" limits and might need adjustment based on specific printer/material. + * @returns True if temperatures are below the defined "safe" thresholds, false otherwise. + */ + public areTempsSafe(): boolean { + const bedTemp = this._bedTemp ? this._bedTemp.getCurrent() : 0; + const extruderTemp = this._extruderTemp ? this._extruderTemp.getCurrent() : 0; + return extruderTemp < 250 && bedTemp < 100; + } } /** @@ -130,70 +135,70 @@ export class TempInfo { * Temperatures are stored as strings but can be retrieved as numbers. */ export class TempData { - /** The current temperature as a string, rounded to the nearest integer. */ - private readonly _current: string; - /** The target (set) temperature as a string, rounded to the nearest integer. Null if not set (e.g., when idle). */ - private readonly _set: string | null; - - /** - * Creates an instance of TempData by parsing a temperature string. - * The input string can be in the format "current/set" (e.g., "210/210") - * or just "current" (e.g., "25") if the target temperature is not specified. - * It also handles and removes a trailing "/0.0" if present from some printer firmwares. - * All temperatures are rounded to the nearest integer. - * @param data The temperature data string (e.g., "210/210", "25", "60/60/0.0"). - */ - constructor(data: string) { - // Handle potential formatting issues by removing any non-relevant part - data = data.replace('/0.0', ''); // Remove trailing '/0.0' if exists, specific to some firmware outputs - - if (data.includes("/")) { - // replay has current/set temps - const splitTemps = data.split('/'); - this._current = this.parseTdata(splitTemps[0].trim()); - this._set = this.parseTdata(splitTemps[1].trim()); - } else { - // replay only has current temp (when printer is idle) - this._current = this.parseTdata(data); - this._set = null; - } - } + /** The current temperature as a string, rounded to the nearest integer. */ + private readonly _current: string; + /** The target (set) temperature as a string, rounded to the nearest integer. Null if not set (e.g., when idle). */ + private readonly _set: string | null; - /** - * Parses a raw temperature string value, rounds it, and returns it as a string. - * If the value contains a decimal point, it's truncated before rounding. - * @param data The raw temperature string (e.g., "210.5", "60"). - * @returns The rounded temperature as a string. - * @private - */ - private parseTdata(data: string): string { - if (data.includes(".")) data = data.split('.')[0].trim(); // Truncate decimal part before rounding - const temp = Math.round(parseFloat(data)); - return temp.toString(); - } + /** + * Creates an instance of TempData by parsing a temperature string. + * The input string can be in the format "current/set" (e.g., "210/210") + * or just "current" (e.g., "25") if the target temperature is not specified. + * It also handles and removes a trailing "/0.0" if present from some printer firmwares. + * All temperatures are rounded to the nearest integer. + * @param data The temperature data string (e.g., "210/210", "25", "60/60/0.0"). + */ + constructor(data: string) { + // Handle potential formatting issues by removing any non-relevant part + data = data.replace('/0.0', ''); // Remove trailing '/0.0' if exists, specific to some firmware outputs - /** - * Gets the full temperature string, including current and set temperatures. - * @returns A string in the format "current/set" or just "current" if the set temperature is not available. - */ - public getFull(): string { - if (this._set === null) return this._current; - return this._current + "/" + this._set; + if (data.includes('/')) { + // replay has current/set temps + const splitTemps = data.split('/'); + this._current = this.parseTdata(splitTemps[0].trim()); + this._set = this.parseTdata(splitTemps[1].trim()); + } else { + // replay only has current temp (when printer is idle) + this._current = this.parseTdata(data); + this._set = null; } + } - /** - * Gets the current temperature as a number. - * @returns The current temperature in Celsius. - */ - public getCurrent(): number { - return parseInt(this._current, 10); - } + /** + * Parses a raw temperature string value, rounds it, and returns it as a string. + * If the value contains a decimal point, it's truncated before rounding. + * @param data The raw temperature string (e.g., "210.5", "60"). + * @returns The rounded temperature as a string. + * @private + */ + private parseTdata(data: string): string { + if (data.includes('.')) data = data.split('.')[0].trim(); // Truncate decimal part before rounding + const temp = Math.round(parseFloat(data)); + return temp.toString(); + } - /** - * Gets the target (set) temperature as a number. - * @returns The set temperature in Celsius, or 0 if not set. - */ - public getSet(): number { - return this._set ? parseInt(this._set, 10) : 0; - } -} \ No newline at end of file + /** + * Gets the full temperature string, including current and set temperatures. + * @returns A string in the format "current/set" or just "current" if the set temperature is not available. + */ + public getFull(): string { + if (this._set === null) return this._current; + return `${this._current}/${this._set}`; + } + + /** + * Gets the current temperature as a number. + * @returns The current temperature in Celsius. + */ + public getCurrent(): number { + return parseInt(this._current, 10); + } + + /** + * Gets the target (set) temperature as a number. + * @returns The set temperature in Celsius, or 0 if not set. + */ + public getSet(): number { + return this._set ? parseInt(this._set, 10) : 0; + } +} diff --git a/src/tcpapi/replays/ThumbnailInfo.test.ts b/src/tcpapi/replays/ThumbnailInfo.test.ts index 890a492..dfeee10 100644 --- a/src/tcpapi/replays/ThumbnailInfo.test.ts +++ b/src/tcpapi/replays/ThumbnailInfo.test.ts @@ -1,28 +1,45 @@ /** * @fileoverview Tests for ThumbnailInfo parser including M662 response parsing and PNG image extraction. */ + +import * as fs from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ThumbnailInfo } from './ThumbnailInfo'; -import * as fs from 'fs'; // Mock fs -jest.mock('fs'); -const mockedFs = fs as jest.Mocked; +vi.mock('fs'); +const mockedFs = fs as typeof fs & { + writeFileSync: ReturnType; +}; describe('ThumbnailInfo', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('fromReplay', () => { it('should parse valid PNG data from response', () => { // Create a minimal PNG signature const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // Chunk length - 0x49, 0x48, 0x44, 0x52 // IHDR chunk type + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, // Chunk length + 0x49, + 0x48, + 0x44, + 0x52, // IHDR chunk type ]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); const result = thumbnailInfo.fromReplay(response, 'test.gcode'); @@ -57,13 +74,12 @@ describe('ThumbnailInfo', () => { // Simulate response with some bytes before PNG signature const precedingBytes = Buffer.from([0x00, 0x01, 0x02, 0x03]); const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, - 0x00, 0x00, 0x00, 0x0D, - 0x49, 0x48, 0x44, 0x52 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, ]); const combinedData = Buffer.concat([precedingBytes, pngSignature]); - const response = 'ok' + combinedData.toString('binary'); + const response = `ok${combinedData.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); const result = thumbnailInfo.fromReplay(response, 'test.gcode'); @@ -76,11 +92,9 @@ describe('ThumbnailInfo', () => { describe('getImageData', () => { it('should return base64 encoded image data', () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'test.gcode'); @@ -90,7 +104,9 @@ describe('ThumbnailInfo', () => { expect(typeof imageData).toBe('string'); // Verify it's valid base64 - expect(() => Buffer.from(imageData!, 'base64')).not.toThrow(); + if (imageData !== null) { + expect(() => Buffer.from(imageData, 'base64')).not.toThrow(); + } }); it('should return null when no image data is available', () => { @@ -103,11 +119,9 @@ describe('ThumbnailInfo', () => { describe('toBase64DataUrl', () => { it('should return data URL with correct format', () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'test.gcode'); @@ -127,16 +141,14 @@ describe('ThumbnailInfo', () => { describe('saveToFile', () => { it('should save image data to specified file path', async () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'test.gcode'); - mockedFs.writeFileSync.mockImplementation(() => {}); + mockedFs.writeFileSync.mockImplementation(vi.fn()); const result = await thumbnailInfo.saveToFile('/path/to/output.png'); @@ -148,24 +160,19 @@ describe('ThumbnailInfo', () => { }); it('should generate filename from original filename when path not provided', async () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'test.gcode'); - mockedFs.writeFileSync.mockImplementation(() => {}); + mockedFs.writeFileSync.mockImplementation(vi.fn()); const result = await thumbnailInfo.saveToFile(); expect(result).toBe(true); - expect(mockedFs.writeFileSync).toHaveBeenCalledWith( - 'test.png', - expect.any(Buffer) - ); + expect(mockedFs.writeFileSync).toHaveBeenCalledWith('test.png', expect.any(Buffer)); }); it('should return false when no image data is available', async () => { @@ -177,11 +184,9 @@ describe('ThumbnailInfo', () => { }); it('should return false when writeFileSync throws error', async () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'test.gcode'); @@ -196,11 +201,9 @@ describe('ThumbnailInfo', () => { }); it('should return false when no filename and no path provided', async () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); // Don't provide filename in fromReplay @@ -215,11 +218,9 @@ describe('ThumbnailInfo', () => { describe('getFileName', () => { it('should return the stored filename', () => { - const pngSignature = Buffer.from([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A - ]); + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - const response = 'ok' + pngSignature.toString('binary'); + const response = `ok${pngSignature.toString('binary')}`; const thumbnailInfo = new ThumbnailInfo(); thumbnailInfo.fromReplay(response, 'myfile.3mf'); diff --git a/src/tcpapi/replays/ThumbnailInfo.ts b/src/tcpapi/replays/ThumbnailInfo.ts index 4582a89..f848d92 100644 --- a/src/tcpapi/replays/ThumbnailInfo.ts +++ b/src/tcpapi/replays/ThumbnailInfo.ts @@ -2,8 +2,8 @@ * @fileoverview Parses M662 command responses to extract PNG thumbnail images from printer files. */ // src/tcpapi/replays/ThumbnailInfo.ts -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; /** * Handles the parsing, storage, and manipulation of 3D print file thumbnail images. @@ -12,140 +12,149 @@ import * as path from 'path'; * This class provides methods to extract the PNG data, convert it to various formats, and save it to a file. */ export class ThumbnailInfo { - /** Raw binary image data for the thumbnail, stored as a Buffer. Null if no data is loaded or parsing fails. */ - private _imageData: Buffer | null = null; - /** The original filename associated with this thumbnail. Null if not set. */ - private _fileName: string | null = null; - - /** - * Parses thumbnail data from a raw printer response string. - * The method expects the response to contain an "ok" text delimiter, after which - * the binary PNG data begins. It searches for the PNG signature (0x89 PNG) - * within the binary portion to correctly extract the image. - * - * @param replay The raw string response from the printer, which may include text and binary data. - * @param fileName The name of the file for which the thumbnail was retrieved. This is stored for reference. - * @returns A `ThumbnailInfo` instance populated with the image data if parsing is successful, - * or null if the replay is invalid, "ok" is not found, or the PNG signature is missing. - */ - public fromReplay(replay: string, fileName: string): ThumbnailInfo | null { - if (!replay) return null; - - try { - // Store the file name - this._fileName = fileName; - - // Find where the PNG data starts (after the "ok" text delimiter) - const okIndex = replay.indexOf('ok'); - if (okIndex === -1) { - console.log("ThumbnailInfo: No 'ok' found in response"); - return null; - } - - // Skip the 'ok' text and any immediately following control characters - // The actual binary data starts after "ok". - const binaryStartIndex = okIndex + 2; // Length of "ok" - const rawBinaryData = replay.substring(binaryStartIndex); - - // Convert the extracted string part (assumed to be binary) into a Buffer. - // The printer sends binary data as part of a string reply. - const binaryBuffer = Buffer.from(rawBinaryData, 'binary'); - - // Look for the PNG file signature (89 50 4E 47 0D 0A 1A 0A) in the buffer - // to correctly identify the start of the actual image data. - let pngStart = -1; - for (let i = 0; i < binaryBuffer.length - 7; i++) { // Ensure there's enough space for the full signature - if (binaryBuffer[i] === 0x89 && - binaryBuffer[i+1] === 0x50 && // P - binaryBuffer[i+2] === 0x4E && // N - binaryBuffer[i+3] === 0x47 && // G - binaryBuffer[i+4] === 0x0D && // CR - binaryBuffer[i+5] === 0x0A && // LF - binaryBuffer[i+6] === 0x1A && // SUB - binaryBuffer[i+7] === 0x0A) { // LF - pngStart = i; - break; - } - } - - if (pngStart >= 0) { - // Slice the buffer from the start of the PNG signature to get the clean image data. - this._imageData = binaryBuffer.slice(pngStart); - return this; - } else { - console.log("ThumbnailInfo: No PNG signature found in binary data."); - return null; - } - } catch (error) { - console.error("ThumbnailInfo: Error parsing response:", error instanceof Error ? error.message : String(error)); - return null; + /** Raw binary image data for the thumbnail, stored as a Buffer. Null if no data is loaded or parsing fails. */ + private _imageData: Buffer | null = null; + /** The original filename associated with this thumbnail. Null if not set. */ + private _fileName: string | null = null; + + /** + * Parses thumbnail data from a raw printer response string. + * The method expects the response to contain an "ok" text delimiter, after which + * the binary PNG data begins. It searches for the PNG signature (0x89 PNG) + * within the binary portion to correctly extract the image. + * + * @param replay The raw string response from the printer, which may include text and binary data. + * @param fileName The name of the file for which the thumbnail was retrieved. This is stored for reference. + * @returns A `ThumbnailInfo` instance populated with the image data if parsing is successful, + * or null if the replay is invalid, "ok" is not found, or the PNG signature is missing. + */ + public fromReplay(replay: string, fileName: string): ThumbnailInfo | null { + if (!replay) return null; + + try { + // Store the file name + this._fileName = fileName; + + // Find where the PNG data starts (after the "ok" text delimiter) + const okIndex = replay.indexOf('ok'); + if (okIndex === -1) { + console.log("ThumbnailInfo: No 'ok' found in response"); + return null; + } + + // Skip the 'ok' text and any immediately following control characters + // The actual binary data starts after "ok". + const binaryStartIndex = okIndex + 2; // Length of "ok" + const rawBinaryData = replay.substring(binaryStartIndex); + + // Convert the extracted string part (assumed to be binary) into a Buffer. + // The printer sends binary data as part of a string reply. + const binaryBuffer = Buffer.from(rawBinaryData, 'binary'); + + // Look for the PNG file signature (89 50 4E 47 0D 0A 1A 0A) in the buffer + // to correctly identify the start of the actual image data. + let pngStart = -1; + for (let i = 0; i < binaryBuffer.length - 7; i++) { + // Ensure there's enough space for the full signature + if ( + binaryBuffer[i] === 0x89 && + binaryBuffer[i + 1] === 0x50 && // P + binaryBuffer[i + 2] === 0x4e && // N + binaryBuffer[i + 3] === 0x47 && // G + binaryBuffer[i + 4] === 0x0d && // CR + binaryBuffer[i + 5] === 0x0a && // LF + binaryBuffer[i + 6] === 0x1a && // SUB + binaryBuffer[i + 7] === 0x0a + ) { + // LF + pngStart = i; + break; } - } + } - /** - * Gets the raw thumbnail image data as a Base64 encoded string. - * @returns A Base64 encoded string of the PNG image data, or null if no image data is available. - */ - public getImageData(): string | null { - if (!this._imageData) return null; - return this._imageData.toString('base64'); + if (pngStart >= 0) { + // Slice the buffer from the start of the PNG signature to get the clean image data. + this._imageData = binaryBuffer.slice(pngStart); + return this; + } else { + console.log('ThumbnailInfo: No PNG signature found in binary data.'); + return null; + } + } catch (error) { + console.error( + 'ThumbnailInfo: Error parsing response:', + error instanceof Error ? error.message : String(error) + ); + return null; } + } - /** - * Gets the file name associated with this thumbnail. - * @returns The file name string, or null if it was not set during parsing. - */ - public getFileName(): string | null { - return this._fileName; - } + /** + * Gets the raw thumbnail image data as a Base64 encoded string. + * @returns A Base64 encoded string of the PNG image data, or null if no image data is available. + */ + public getImageData(): string | null { + if (!this._imageData) return null; + return this._imageData.toString('base64'); + } + + /** + * Gets the file name associated with this thumbnail. + * @returns The file name string, or null if it was not set during parsing. + */ + public getFileName(): string | null { + return this._fileName; + } + + /** + * Converts the thumbnail image data to a Base64 data URL, suitable for embedding in web pages (e.g., `` src attribute). + * @returns A Base64 data URL string (e.g., "data:image/png;base64,..."), or null if no image data is available. + */ + public toBase64DataUrl(): string | null { + if (!this._imageData) return null; + + const base64Data = this._imageData.toString('base64'); + return `data:image/png;base64,${base64Data}`; + } - /** - * Converts the thumbnail image data to a Base64 data URL, suitable for embedding in web pages (e.g., `` src attribute). - * @returns A Base64 data URL string (e.g., "data:image/png;base64,..."), or null if no image data is available. - */ - public toBase64DataUrl(): string | null { - if (!this._imageData) return null; - - const base64Data = this._imageData.toString('base64'); - return `data:image/png;base64,${base64Data}`; + /** + * Saves the thumbnail image data to a file. + * If no `filePath` is provided, it attempts to generate a filename using the + * original filename (stored during `fromReplay`) with a ".png" extension. + * + * @param filePath Optional. The full path (including filename and extension) where the thumbnail should be saved. + * If not provided, a filename is generated from `this._fileName`. + * @returns A Promise that resolves to true if the file was saved successfully, false otherwise. + */ + public async saveToFile(filePath?: string): Promise { + if (!this._imageData) { + console.log('ThumbnailInfo: No image data to save'); + return false; } - /** - * Saves the thumbnail image data to a file. - * If no `filePath` is provided, it attempts to generate a filename using the - * original filename (stored during `fromReplay`) with a ".png" extension. - * - * @param filePath Optional. The full path (including filename and extension) where the thumbnail should be saved. - * If not provided, a filename is generated from `this._fileName`. - * @returns A Promise that resolves to true if the file was saved successfully, false otherwise. - */ - public async saveToFile(filePath?: string): Promise { - if (!this._imageData) { - console.log("ThumbnailInfo: No image data to save"); - return false; - } + try { + // If no file path is provided, generate one based on the original filename + if (!filePath && this._fileName) { + // Extract the filename without extension + const baseName = path.basename(this._fileName, path.extname(this._fileName)); + filePath = `${baseName}.png`; + } - try { - // If no file path is provided, generate one based on the original filename - if (!filePath && this._fileName) { - // Extract the filename without extension - const baseName = path.basename(this._fileName, path.extname(this._fileName)); - filePath = `${baseName}.png`; - } - - if (!filePath) { - console.log("ThumbnailInfo: No file path provided and no filename to generate one from"); - return false; - } - - // Write the buffer to file - fs.writeFileSync(filePath, this._imageData); - console.log(`ThumbnailInfo: Saved thumbnail to ${filePath}`); - return true; - } catch (error) { - console.log("ThumbnailInfo: Error saving thumbnail to file: " + - (error instanceof Error ? error.message : String(error))); - return false; - } + if (!filePath) { + console.log('ThumbnailInfo: No file path provided and no filename to generate one from'); + return false; + } + + // Write the buffer to file + fs.writeFileSync(filePath, this._imageData); + console.log(`ThumbnailInfo: Saved thumbnail to ${filePath}`); + return true; + } catch (error) { + console.log( + 'ThumbnailInfo: Error saving thumbnail to file: ' + + (error instanceof Error ? error.message : String(error)) + ); + return false; } + } } diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..bbb0464 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,44 @@ +/** + * @fileoverview Type-safe error handling utilities + */ + +import type { GenericResponse } from '../api/controls/Control'; + +/** + * Type guard for errors with cause property (ES2022) + * @param error - The error to check + * @returns True if the error has a cause property + */ +export function isErrorWithCause(error: Error): error is Error & { cause: unknown } { + return 'cause' in error; +} + +/** + * Type guard for Axios errors with response data + * @param error - The error to check + * @returns True if the error is an AxiosError with a response + */ +export function isAxiosErrorWithResponse( + error: unknown +): error is { + isAxiosError: boolean; + response: T; + config: { url?: string }; +} { + return ( + typeof error === 'object' && + error !== null && + 'isAxiosError' in error && + (error as { isAxiosError: boolean }).isAxiosError === true && + 'response' in error + ); +} + +/** + * Type guard for GenericResponse validation + * @param data - The data to validate + * @returns True if the data matches GenericResponse structure + */ +export function isGenericResponse(data: unknown): data is GenericResponse { + return typeof data === 'object' && data !== null && 'code' in data && 'message' in data; +} diff --git a/tsconfig.json b/tsconfig.json index 1d2814a..94717cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,4 +12,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.test.ts", "dist"] -} \ No newline at end of file +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b4746c6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/firmware-test.ts'], + thresholds: { + branches: 40, + functions: 20, + lines: 30, + statements: 30, + }, + }, + }, +});