diff --git a/CLAUDE.md b/CLAUDE.md index 750e952..8a5189b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Raw API responses (`FFPrinterDetail` in `src/models/ff-models.ts`) are transform - `NetworkUtils` (`src/api/network/NetworkUtils.ts`) — Response validation helpers; checks `GenericResponse.code` for success. - `FNetCode` (`src/api/network/FNetCode.ts`) — Network code constants. -- `FlashForgePrinterDiscovery` (`src/api/PrinterDiscovery.ts`) — UDP broadcast discovery on port 48899, parses binary response buffers at fixed offsets for printer name and serial number. +- `PrinterDiscovery` (`src/api/PrinterDiscovery.ts`) — Universal UDP multicast/broadcast discovery supporting all FlashForge models (AD5X, 5M, 5M Pro, Adventurer 4, Adventurer 3) with multi-protocol response parsing (276-byte modern, 140-byte legacy). ### TCP Response Parsers diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md new file mode 100644 index 0000000..0df906d --- /dev/null +++ b/docs/MIGRATION_GUIDE.md @@ -0,0 +1,288 @@ +# Printer Discovery API Migration Guide + +**Version:** 2.0.0 +**Date:** 2025-02-08 +**Breaking Changes:** Yes + +## Overview + +The printer discovery API has been completely rewritten to support all FlashForge printer models (AD5X, 5M, 5M Pro, Adventurer 4, Adventurer 3) with multi-protocol UDP discovery. The legacy API has been removed. + +## What Changed + +| Old API | New API | +|---------|---------| +| `FlashForgePrinterDiscovery` | `PrinterDiscovery` | +| `FlashForgePrinter` | `DiscoveredPrinter` (interface) | +| `discoverPrintersAsync(timeout, idleTimeout, maxRetries)` | `discover({ timeout, idleTimeout, maxRetries })` | +| `isAD5X?: boolean` | `model: PrinterModel` | +| Limited metadata (name, serial, IP) | Complete metadata (model, ports, status, etc.) | + +## Migration Examples + +### Basic Discovery + +**Before (v1.x):** +```typescript +import { FlashForgePrinterDiscovery, FlashForgePrinter } from '@ghosttypes/ff-api'; + +const discovery = new FlashForgePrinterDiscovery(); +const printers: FlashForgePrinter[] = await discovery.discoverPrintersAsync(10000, 1500, 3); + +printers.forEach(printer => { + console.log(`${printer.name} - ${printer.ipAddress}`); + if (printer.isAD5X) { + console.log(' AD5X detected'); + } +}); +``` + +**After (v2.x):** +```typescript +import { PrinterDiscovery, PrinterModel, type DiscoveredPrinter } from '@ghosttypes/ff-api'; + +const discovery = new PrinterDiscovery(); +const printers: DiscoveredPrinter[] = await discovery.discover({ + timeout: 10000, + idleTimeout: 1500, + maxRetries: 3 +}); + +printers.forEach(printer => { + console.log(`${printer.model}: ${printer.name} - ${printer.ipAddress}`); + if (printer.model === PrinterModel.AD5X) { + console.log(' AD5X detected'); + } +}); +``` + +### Model Detection + +**Before:** +```typescript +if (printer.isAD5X) { + // Handle AD5X +} else { + // Handle other models +} +``` + +**After:** +```typescript +switch (printer.model) { + case PrinterModel.AD5X: + // Handle AD5X + break; + case PrinterModel.Adventurer5MPro: + // Handle 5M Pro + break; + case PrinterModel.Adventurer5M: + // Handle 5M + break; + case PrinterModel.Adventurer4: + // Handle Adventurer 4 + break; + case PrinterModel.Adventurer3: + // Handle Adventurer 3 + break; + default: + // Unknown model + break; +} +``` + +### Accessing Additional Properties + +**Before:** +```typescript +const printer: FlashForgePrinter = { + name: 'AD5X', + serialNumber: 'SN123', + ipAddress: '192.168.1.100', + isAD5X: true +}; +``` + +**After:** +```typescript +const printer: DiscoveredPrinter = { + model: PrinterModel.AD5X, + protocolFormat: DiscoveryProtocol.Modern, + name: 'AD5X', + ipAddress: '192.168.1.100', + commandPort: 8899, + serialNumber: 'SN123', + eventPort: 8898, + vendorId: 0x2B71, + productId: 0x0024, + productType: 0x5A02, + statusCode: 0, + status: PrinterStatus.Ready +}; +``` + +### Custom Discovery Options + +**Before:** +```typescript +await discovery.discoverPrintersAsync(5000, 1000, 1); +``` + +**After:** +```typescript +await discovery.discover({ + timeout: 5000, + idleTimeout: 1000, + maxRetries: 1, + useMulticast: true, + useBroadcast: true, + ports: [19000, 8899] +}); +``` + +## New Features + +### Event-Based Monitoring + +The new API supports continuous monitoring with events: + +```typescript +const discovery = new PrinterDiscovery(); +const monitor = discovery.monitor({ timeout: 30000 }); + +monitor.on('discovered', (printer: DiscoveredPrinter) => { + console.log(`✓ Found: ${printer.model} - ${printer.name}`); +}); + +monitor.on('end', () => { + console.log('Discovery complete'); +}); + +monitor.on('error', (error: Error) => { + console.error('Discovery error:', error); +}); +``` + +### Printer Status + +The new API includes printer status from discovery: + +```typescript +const printers = await discovery.discover(); +if (printers.length > 0) { + const printer = printers[0]; + + if (printer.status === PrinterStatus.Ready) { + console.log('Printer is ready to print'); + } else if (printer.status === PrinterStatus.Busy) { + console.log('Printer is busy'); + } else if (printer.status === PrinterStatus.Error) { + console.log('Printer has an error'); + } +} +``` + +### Multi-Protocol Support + +The new API automatically handles multiple protocols: + +- **Modern Protocol (276-byte)**: AD5X, 5M, 5M Pro +- **Legacy Protocol (140-byte)**: Adventurer 3, Adventurer 4 + +```typescript +printers.forEach(printer => { + if (printer.protocolFormat === DiscoveryProtocol.Modern) { + console.log('Modern printer - full metadata available'); + console.log(` Serial: ${printer.serialNumber}`); + console.log(` HTTP API: ${printer.ipAddress}:${printer.eventPort}`); + } else if (printer.protocolFormat === DiscoveryProtocol.Legacy) { + console.log('Legacy printer - basic metadata'); + } +}); +``` + +## Type Reference + +### PrinterModel Enum + +```typescript +enum PrinterModel { + AD5X = 'AD5X', + Adventurer5M = 'Adventurer5M', + Adventurer5MPro = 'Adventurer5MPro', + Adventurer4 = 'Adventurer4', + Adventurer3 = 'Adventurer3', + Unknown = 'Unknown' +} +``` + +### DiscoveryProtocol Enum + +```typescript +enum DiscoveryProtocol { + Modern = 'modern', // 276-byte responses + Legacy = 'legacy' // 140-byte responses +} +``` + +### PrinterStatus Enum + +```typescript +enum PrinterStatus { + Ready = 0, + Busy = 1, + Error = 2, + Unknown = 3 +} +``` + +### DiscoveredPrinter Interface + +```typescript +interface DiscoveredPrinter { + model: PrinterModel; + protocolFormat: DiscoveryProtocol; + name: string; + ipAddress: string; + commandPort: number; + serialNumber?: string; // Modern protocol only + eventPort?: number; // Modern protocol only (typically 8898) + vendorId?: number; + productId?: number; + productType?: number; // Modern protocol only + statusCode?: number; + status?: PrinterStatus; +} +``` + +### DiscoveryOptions Interface + +```typescript +interface DiscoveryOptions { + timeout?: number; // Default: 10000 + idleTimeout?: number; // Default: 1500 + maxRetries?: number; // Default: 3 + useMulticast?: boolean; // Default: true + useBroadcast?: boolean; // Default: true + ports?: number[]; // Default: [8899, 19000, 48899] +} +``` + +## Full Migration Checklist + +- [ ] Update imports from `FlashForgePrinterDiscovery` to `PrinterDiscovery` +- [ ] Change `discoverPrintersAsync()` calls to `discover()` +- [ ] Update parameter style from positional to options object +- [ ] Replace `FlashForgePrinter` type with `DiscoveredPrinter` +- [ ] Replace `isAD5X` boolean checks with `model` enum comparisons +- [ ] Update any destructuring to use new property names +- [ ] Remove `toString()` calls (not available on interface) +- [ ] Test with all printer models you support +- [ ] Update any documentation or examples + +## Need Help? + +- **New API Documentation**: See `docs/README.md` and `docs/clients.md` +- **Full Specification**: See `docs/specs/printer-discovery.md` +- **Type Definitions**: See `src/models/PrinterDiscovery.ts` +- **Example Usage**: See test files in `src/api/PrinterDiscovery.test.ts` diff --git a/docs/README.md b/docs/README.md index bde8730..aed2b4c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,13 +30,16 @@ pnpm add @ghosttypes/ff-api To start interacting with a printer, you first need to discover it on your local network. ```typescript -import { FlashForgePrinterDiscovery } from '@ghosttypes/ff-api'; +import { PrinterDiscovery } from '@ghosttypes/ff-api'; -const discovery = new FlashForgePrinterDiscovery(); -const printers = await discovery.discoverPrintersAsync(); +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover(); printers.forEach(printer => { - console.log(`Found printer: ${printer.name} at ${printer.ipAddress}`); + console.log(`Found ${printer.model}: ${printer.name} at ${printer.ipAddress}`); + if (printer.serialNumber) { + console.log(` Serial: ${printer.serialNumber}`); + } }); ``` diff --git a/docs/clients.md b/docs/clients.md index 35af004..5b41011 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -123,22 +123,61 @@ Sets the extruder target temperature. --- -## FlashForgePrinterDiscovery +## PrinterDiscovery -A utility class for finding printers on the local network. +A utility class for finding FlashForge printers on the local network via UDP multicast/broadcast. Supports all FlashForge models including AD5X, 5M, 5M Pro, Adventurer 4, and Adventurer 3. + +### Constructor + +```typescript +constructor() +``` ### Methods -#### `discoverPrintersAsync()` +#### `discover()` ```typescript -public async discoverPrintersAsync(timeoutMs: number = 10000, idleTimeoutMs: number = 1500, maxRetries: number = 3): Promise +public async discover(options?: DiscoveryOptions): Promise ``` -Broadcasts a discovery packet via UDP and listens for responses. +Discovers printers on the local network using UDP multicast and broadcast. -- **`timeoutMs`**: Max time to wait for responses. -- **`idleTimeoutMs`**: Time to wait after the last response before returning. -- **`maxRetries`**: Number of broadcast attempts if no printers are found initially. +**Options:** +- **`timeout`** (number): Total time to wait for responses (default: 10000ms) +- **`idleTimeout`** (number): Time to wait after last response (default: 1500ms) +- **`maxRetries`** (number): Maximum retry attempts (default: 3) +- **`useMulticast`** (boolean): Use multicast discovery (default: true) +- **`useBroadcast`** (boolean): Use subnet broadcast discovery (default: true) +- **`ports`** (number[]): Specific ports to scan (default: [8899, 19000, 48899]) -**Returns:** An array of `FlashForgePrinter` objects containing IP, Serial, and Name. +**Returns:** An array of `DiscoveredPrinter` objects with comprehensive printer information. + +#### `monitor()` + +```typescript +public monitor(options?: DiscoveryOptions): EventEmitter +``` + +Starts continuous monitoring for printers, emitting events as printers are discovered. + +**Returns:** EventEmitter that emits: +- **`discovered`**: Emitted for each new printer found +- **`end`**: Emitted when monitoring completes +- **`error`**: Emitted on errors + +### Example + +```typescript +import { PrinterDiscovery } from '@ghosttypes/ff-api'; + +const discovery = new PrinterDiscovery(); +const printers = await discovery.discover({ timeout: 5000 }); + +printers.forEach(printer => { + console.log(`${printer.model}: ${printer.name}`); + console.log(` IP: ${printer.ipAddress}:${printer.commandPort}`); + console.log(` Serial: ${printer.serialNumber || 'N/A'}`); + console.log(` Status: ${printer.status}`); +}); +``` diff --git a/docs/specs/printer-discovery.md b/docs/specs/printer-discovery.md index d181765..01b8598 100644 --- a/docs/specs/printer-discovery.md +++ b/docs/specs/printer-discovery.md @@ -453,12 +453,7 @@ export class PrinterDiscovery extends EventEmitter { * }); * ``` */ - public monitor(options?: DiscoveryOptions): EventEmitter; - - /** - * Stop active discovery monitoring. - */ - public stop(): void; + public monitor(options?: DiscoveryOptions): DiscoveryMonitor; } ``` @@ -640,7 +635,6 @@ const DEFAULT_OPTIONS: Required = { */ export class PrinterDiscovery extends EventEmitter { private socket?: dgram.Socket; - private active = false; /** * Discover printers once (one-shot discovery). @@ -713,14 +707,6 @@ export class PrinterDiscovery extends EventEmitter { return this; } - /** - * Stop active discovery. - */ - public stop(): void { - this.active = false; - this.cleanup(); - } - /** * Send discovery packets to all configured ports. * @private @@ -980,10 +966,6 @@ describe('PrinterDiscovery Integration Tests', () => { 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 @@ -1076,7 +1058,7 @@ monitor.on('error', (error) => { // Stop after 1 minute setTimeout(() => { - discovery.stop(); + monitor.stop(); console.log('Monitoring stopped'); }, 60000); ``` @@ -1196,53 +1178,14 @@ printers.forEach(printer => { | 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); - } -} +### Backward Compatibility -/** - * @deprecated Use DiscoveredPrinter instead - */ -export class FlashForgePrinter { - public name: string = ''; - public serialNumber: string = ''; - public ipAddress: string = ''; - public isAD5X?: boolean; -} +No compatibility shim is provided in this implementation. -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; -} -``` +This is an intentional breaking change. Consumers must migrate from: +- `FlashForgePrinterDiscovery` -> `PrinterDiscovery` +- `FlashForgePrinter` -> `DiscoveredPrinter` +- `discoverPrintersAsync(timeout, idleTimeout, maxRetries)` -> `discover({ timeout, idleTimeout, maxRetries })` --- diff --git a/scripts/test-discovery.js b/scripts/test-discovery.js new file mode 100644 index 0000000..6f793a5 --- /dev/null +++ b/scripts/test-discovery.js @@ -0,0 +1,57 @@ +/** + * Quick test script to verify printer discovery works + * Run with: node scripts/test-discovery.js + */ + +// Import the built version +const { PrinterDiscovery } = require('../dist/api/PrinterDiscovery.js'); + +async function testDiscovery() { + console.log('Starting FlashForge printer discovery...'); + console.log('This will scan for 10 seconds. Press Ctrl+C to stop early.\n'); + + const discovery = new PrinterDiscovery(); + + try { + const printers = await discovery.discover({ + timeout: 10000, + idleTimeout: 2000 + }); + + console.log(`\nFound ${printers.length} printer(s):`); + + if (printers.length === 0) { + console.log(' No printers found on the network.'); + console.log(' Make sure:'); + console.log(' - You are on the same network as your printer(s)'); + console.log(' - Your printer(s) are powered on'); + console.log(' - UDP multicast/broadcast is not blocked by your firewall'); + } else { + printers.forEach((printer, index) => { + console.log(`\n${index + 1}. ${printer.name}`); + console.log(` Model: ${printer.model}`); + console.log(` IP: ${printer.ipAddress}:${printer.commandPort}`); + console.log(` Protocol: ${printer.protocolFormat}`); + + if (printer.serialNumber) { + console.log(` Serial: ${printer.serialNumber}`); + } + if (printer.eventPort) { + console.log(` HTTP API: ${printer.ipAddress}:${printer.eventPort}`); + } + if (printer.status !== undefined) { + console.log(` Status: ${printer.status === 0 ? 'Ready' : printer.status === 1 ? 'Busy' : printer.status === 2 ? 'Error' : 'Unknown'}`); + } + }); + } + + console.log('\n✓ Discovery test complete!'); + process.exitCode = printers.length > 0 ? 0 : 1; + } catch (error) { + console.error('✗ Discovery failed:', error.message); + console.error(error.stack); + process.exitCode = 1; + } +} + +testDiscovery(); diff --git a/src/api/PrinterDiscovery.test.ts b/src/api/PrinterDiscovery.test.ts new file mode 100644 index 0000000..4a37b28 --- /dev/null +++ b/src/api/PrinterDiscovery.test.ts @@ -0,0 +1,806 @@ +/** + * @fileoverview Comprehensive test suite for FlashForge printer discovery. + * + * Tests protocol parsers (modern 276-byte, legacy 140-byte), model detection, + * status mapping, multi-port discovery, timeout handling, deduplication, + * and monitor/event behavior. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type * as dgram from 'node:dgram'; +import { EventEmitter } from 'node:events'; +import { PrinterDiscovery } from './PrinterDiscovery'; +import { + DiscoveryProtocol, + PrinterModel, + PrinterStatus, + type DiscoveredPrinter, + type DiscoveryOptions, +} from '../models/PrinterDiscovery'; +import { InvalidResponseError } from './network/DiscoveryErrors'; + +// Suppress logs during tests +const originalConsole = { ...console }; +beforeAll(() => { + console.log = vi.fn(); + console.warn = vi.fn(); + console.error = vi.fn(); +}); + +afterAll(() => { + console.log = originalConsole.log; + console.warn = originalConsole.warn; + console.error = originalConsole.error; +}); + +describe('PrinterDiscovery', () => { + // Helper to create a test discovery instance + const createDiscovery = (): PrinterDiscovery => { + return new PrinterDiscovery(); + }; + + // Helper to create mock modern protocol buffer + const createModernBuffer = (overrides: { + name?: string; + commandPort?: number; + vendorId?: number; + productId?: number; + productType?: number; + eventPort?: number; + statusCode?: number; + serialNumber?: string; + }): Buffer => { + const buffer = Buffer.alloc(276); + + // Default values + const name = overrides.name ?? 'Adventurer 5M'; + const commandPort = overrides.commandPort ?? 8899; + const vendorId = overrides.vendorId ?? 0x0403; + const productId = overrides.productId ?? 0x6001; + const productType = overrides.productType ?? 0x5A02; + const eventPort = overrides.eventPort ?? 8898; + const statusCode = overrides.statusCode ?? 0; + const serialNumber = overrides.serialNumber ?? 'SN123456'; + + // Write printer name (UTF-8, null-terminated) + buffer.write(name, 0, 'utf8'); + buffer.fill(0, name.length, 0x84); + + // Write network configuration (big-endian uint16) + buffer.writeUInt16BE(commandPort, 0x84); + buffer.writeUInt16BE(vendorId, 0x86); + buffer.writeUInt16BE(productId, 0x88); + buffer.writeUInt16BE(productType, 0x8C); + buffer.writeUInt16BE(eventPort, 0x8E); + buffer.writeUInt16BE(statusCode, 0x90); + + // Write serial number (UTF-8, null-terminated) + buffer.write(serialNumber, 0x92, 'utf8'); + buffer.fill(0, 0x92 + serialNumber.length, 0x92 + 130); + + return buffer; + }; + + // Helper to create mock legacy protocol buffer + const createLegacyBuffer = (overrides: { + name?: string; + commandPort?: number; + vendorId?: number; + productId?: number; + statusCode?: number; + }): Buffer => { + const buffer = Buffer.alloc(140); + + // Default values + const name = overrides.name ?? 'Adventurer 3'; + const commandPort = overrides.commandPort ?? 8899; + const vendorId = overrides.vendorId ?? 0x0403; + const productId = overrides.productId ?? 0x6001; + const statusCode = overrides.statusCode ?? 0; + + // Write printer name (UTF-8, null-terminated) + buffer.write(name, 0, 'utf8'); + buffer.fill(0, name.length, 0x80); + + // Padding at 0x80 (4 bytes) - already zeroed + + // Write network configuration (big-endian uint16) + buffer.writeUInt16BE(commandPort, 0x84); + buffer.writeUInt16BE(vendorId, 0x86); + buffer.writeUInt16BE(productId, 0x88); + buffer.writeUInt16BE(statusCode, 0x8A); + + return buffer; + }; + + describe('Modern Protocol Parser', () => { + it('should parse AD5X response correctly', () => { + const discovery = createDiscovery(); + const buffer = createModernBuffer({ + name: 'AD5X', + productType: 0x5A02, + serialNumber: 'AD5X123456', + }); + + const result = discovery['parseModernProtocol'](buffer, { + address: '192.168.1.100', + port: 8899, + family: 'IPv4', + size: 276, + }); + + expect(result).not.toBeNull(); + expect(result?.model).toBe(PrinterModel.AD5X); + expect(result?.name).toBe('AD5X'); + expect(result?.serialNumber).toBe('AD5X123456'); + expect(result?.ipAddress).toBe('192.168.1.100'); + expect(result?.commandPort).toBe(8899); + expect(result?.eventPort).toBe(8898); + expect(result?.protocolFormat).toBe(DiscoveryProtocol.Modern); + expect(result?.productType).toBe(0x5A02); + }); + + it('should parse Adventurer 5M Pro response correctly', () => { + const discovery = createDiscovery(); + const buffer = createModernBuffer({ + name: 'Adventurer 5M Pro', + productType: 0x5A02, + }); + + const result = discovery['parseModernProtocol'](buffer, { + address: '192.168.1.101', + port: 8899, + family: 'IPv4', + size: 276, + }); + + expect(result?.model).toBe(PrinterModel.Adventurer5MPro); + expect(result?.name).toBe('Adventurer 5M Pro'); + expect(result?.productType).toBe(0x5A02); + }); + + it('should parse Adventurer 5M response correctly', () => { + const discovery = createDiscovery(); + const buffer = createModernBuffer({ + name: 'Adventurer 5M', + productType: 0x5A02, + }); + + const result = discovery['parseModernProtocol'](buffer, { + address: '192.168.1.102', + port: 8899, + family: 'IPv4', + size: 276, + }); + + expect(result?.model).toBe(PrinterModel.Adventurer5M); + expect(result?.name).toBe('Adventurer 5M'); + }); + + it('should extract all fields from modern protocol', () => { + const discovery = createDiscovery(); + const buffer = createModernBuffer({ + name: 'Test Printer', + commandPort: 9100, + vendorId: 0x1234, + productId: 0x5678, + productType: 0xABCD, + eventPort: 9200, + statusCode: 1, + serialNumber: 'TESTSN001', + }); + + const result = discovery['parseModernProtocol'](buffer, { + address: '10.0.0.50', + port: 8899, + family: 'IPv4', + size: 276, + }); + + expect(result?.commandPort).toBe(9100); + expect(result?.vendorId).toBe(0x1234); + expect(result?.productId).toBe(0x5678); + expect(result?.productType).toBe(0xABCD); + expect(result?.eventPort).toBe(9200); + expect(result?.statusCode).toBe(1); + expect(result?.status).toBe(PrinterStatus.Busy); + expect(result?.serialNumber).toBe('TESTSN001'); + }); + + it('should throw InvalidResponseError for undersized buffer', () => { + const discovery = createDiscovery(); + const buffer = Buffer.alloc(100); // Too small + + expect(() => { + discovery['parseModernProtocol'](buffer, { + address: '192.168.1.100', + port: 8899, + family: 'IPv4', + size: 100, + }); + }).toThrow(InvalidResponseError); + }); + }); + + describe('Legacy Protocol Parser', () => { + it('should parse Adventurer 4 response correctly', () => { + const discovery = createDiscovery(); + const buffer = createLegacyBuffer({ + name: 'Adventurer 4', + }); + + const result = discovery['parseLegacyProtocol'](buffer, { + address: '192.168.1.200', + port: 8899, + family: 'IPv4', + size: 140, + }); + + expect(result).not.toBeNull(); + expect(result?.model).toBe(PrinterModel.Adventurer4); + expect(result?.name).toBe('Adventurer 4'); + expect(result?.ipAddress).toBe('192.168.1.200'); + expect(result?.commandPort).toBe(8899); + expect(result?.protocolFormat).toBe(DiscoveryProtocol.Legacy); + expect(result?.serialNumber).toBeUndefined(); + expect(result?.eventPort).toBeUndefined(); + }); + + it('should parse Adventurer 3 response correctly', () => { + const discovery = createDiscovery(); + const buffer = createLegacyBuffer({ + name: 'Adventurer 3', + }); + + const result = discovery['parseLegacyProtocol'](buffer, { + address: '192.168.1.201', + port: 8899, + family: 'IPv4', + size: 140, + }); + + expect(result?.model).toBe(PrinterModel.Adventurer3); + expect(result?.name).toBe('Adventurer 3'); + }); + + it('should extract all fields from legacy protocol', () => { + const discovery = createDiscovery(); + const buffer = createLegacyBuffer({ + name: 'Legacy Printer', + commandPort: 9100, + vendorId: 0x1234, + productId: 0x5678, + statusCode: 2, + }); + + const result = discovery['parseLegacyProtocol'](buffer, { + address: '10.0.0.100', + port: 8899, + family: 'IPv4', + size: 140, + }); + + expect(result?.commandPort).toBe(9100); + expect(result?.vendorId).toBe(0x1234); + expect(result?.productId).toBe(0x5678); + expect(result?.statusCode).toBe(2); + expect(result?.status).toBe(PrinterStatus.Error); + }); + + it('should throw InvalidResponseError for undersized buffer', () => { + const discovery = createDiscovery(); + const buffer = Buffer.alloc(50); // Too small + + expect(() => { + discovery['parseLegacyProtocol'](buffer, { + address: '192.168.1.200', + port: 8899, + family: 'IPv4', + size: 50, + }); + }).toThrow(InvalidResponseError); + }); + }); + + describe('Status Code Mapping', () => { + it('should map status code 0 to Ready', () => { + const discovery = createDiscovery(); + expect(discovery['mapStatusCode'](0)).toBe(PrinterStatus.Ready); + }); + + it('should map status code 1 to Busy', () => { + const discovery = createDiscovery(); + expect(discovery['mapStatusCode'](1)).toBe(PrinterStatus.Busy); + }); + + it('should map status code 2 to Error', () => { + const discovery = createDiscovery(); + expect(discovery['mapStatusCode'](2)).toBe(PrinterStatus.Error); + }); + + it('should map unknown status codes to Unknown', () => { + const discovery = createDiscovery(); + expect(discovery['mapStatusCode'](99)).toBe(PrinterStatus.Unknown); + expect(discovery['mapStatusCode'](-1)).toBe(PrinterStatus.Unknown); + }); + }); + + describe('Model Detection', () => { + describe('Modern Protocol', () => { + it('should detect AD5X by name', () => { + const discovery = createDiscovery(); + expect(discovery['detectModernModel']('AD5X', 0x5A02)).toBe(PrinterModel.AD5X); + expect(discovery['detectModernModel']('AD5X', 0x0000)).toBe(PrinterModel.AD5X); + }); + + it('should detect 5M Pro by product type and name', () => { + const discovery = createDiscovery(); + expect(discovery['detectModernModel']('Adventurer 5M Pro', 0x5A02)).toBe( + PrinterModel.Adventurer5MPro + ); + }); + + it('should detect 5M by product type', () => { + const discovery = createDiscovery(); + expect(discovery['detectModernModel']('Adventurer 5M', 0x5A02)).toBe( + PrinterModel.Adventurer5M + ); + }); + + it('should return Unknown for unrecognized models', () => { + const discovery = createDiscovery(); + expect(discovery['detectModernModel']('Unknown Printer', 0x0000)).toBe( + PrinterModel.Unknown + ); + }); + }); + + describe('Legacy Protocol', () => { + it('should detect Adventurer 4 by name', () => { + const discovery = createDiscovery(); + expect(discovery['detectLegacyModel']('Adventurer 4')).toBe(PrinterModel.Adventurer4); + expect(discovery['detectLegacyModel']('Adventurer4')).toBe(PrinterModel.Adventurer4); + expect(discovery['detectLegacyModel']('AD4')).toBe(PrinterModel.Adventurer4); + }); + + it('should detect Adventurer 3 by name', () => { + const discovery = createDiscovery(); + expect(discovery['detectLegacyModel']('Adventurer 3')).toBe(PrinterModel.Adventurer3); + expect(discovery['detectLegacyModel']('Adventurer3')).toBe(PrinterModel.Adventurer3); + expect(discovery['detectLegacyModel']('AD3')).toBe(PrinterModel.Adventurer3); + }); + + it('should return Unknown for unrecognized models', () => { + const discovery = createDiscovery(); + expect(discovery['detectLegacyModel']('Unknown Printer')).toBe(PrinterModel.Unknown); + }); + }); + }); + + describe('Response Parsing', () => { + it('should delegate to modern parser for 276-byte responses', () => { + const discovery = createDiscovery(); + const buffer = createModernBuffer({ name: 'Test 5M' }); + + const result = discovery['parseDiscoveryResponse'](buffer, { + address: '192.168.1.1', + port: 8899, + family: 'IPv4', + size: 276, + }); + + expect(result?.protocolFormat).toBe(DiscoveryProtocol.Modern); + expect(result?.name).toBe('Test 5M'); + }); + + it('should delegate to legacy parser for 140-byte responses', () => { + const discovery = createDiscovery(); + const buffer = createLegacyBuffer({ name: 'Test A4' }); + + const result = discovery['parseDiscoveryResponse'](buffer, { + address: '192.168.1.2', + port: 8899, + family: 'IPv4', + size: 140, + }); + + expect(result?.protocolFormat).toBe(DiscoveryProtocol.Legacy); + expect(result?.name).toBe('Test A4'); + }); + + it('should return null for empty buffer', () => { + const discovery = createDiscovery(); + const buffer = Buffer.alloc(0); + + const result = discovery['parseDiscoveryResponse'](buffer, { + address: '192.168.1.3', + port: 8899, + family: 'IPv4', + size: 0, + }); + + expect(result).toBeNull(); + }); + + it('should return null for oversized buffer that matches neither protocol', () => { + const discovery = createDiscovery(); + const buffer = Buffer.alloc(50); + + const result = discovery['parseDiscoveryResponse'](buffer, { + address: '192.168.1.4', + port: 8899, + family: 'IPv4', + size: 50, + }); + + expect(result).toBeNull(); + }); + }); + + describe('Integration Tests', () => { + it('should handle successful discovery with mocked socket', async () => { + // Create a test discovery class with mocked socket creation + class TestPrinterDiscovery extends PrinterDiscovery { + public mockSocket: dgram.Socket | null = null; + + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + this.mockSocket = mockSocket; + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + + // Send a modern protocol response after socket creation + setTimeout(() => { + const mockSocket = discovery.mockSocket; + if (mockSocket) { + const buffer = createModernBuffer({ + name: 'AD5X', + serialNumber: 'AD5X001', + }); + mockSocket.emit('message', buffer, { + address: '192.168.1.100', + port: 8899, + family: 'IPv4', + size: 276, + }); + } + }, 50); + + const printers = await discovery.discover({ timeout: 200, idleTimeout: 100 }); + + expect(printers).toHaveLength(1); + expect(printers[0].name).toBe('AD5X'); + expect(printers[0].serialNumber).toBe('AD5X001'); + }); + + it('should deduplicate printers by IP:port', async () => { + // Create a test discovery class with mocked socket creation + class TestPrinterDiscovery extends PrinterDiscovery { + public mockSocket: dgram.Socket | null = null; + + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + this.mockSocket = mockSocket; + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + + // Send multiple responses from same printer + setTimeout(() => { + const mockSocket = discovery.mockSocket; + if (mockSocket) { + const modernBuffer = createModernBuffer({ + name: 'Adventurer 5M', + serialNumber: 'SN123', + }); + const legacyBuffer = createLegacyBuffer({ name: 'Adventurer 5M' }); + + mockSocket.emit('message', modernBuffer, { + address: '192.168.1.50', + port: 8899, + family: 'IPv4', + size: 276, + }); + + mockSocket.emit('message', legacyBuffer, { + address: '192.168.1.50', + port: 8899, + family: 'IPv4', + size: 140, + }); + } + }, 50); + + const printers = await discovery.discover({ timeout: 200, idleTimeout: 100 }); + + expect(printers).toHaveLength(1); + expect(printers[0].protocolFormat).toBe(DiscoveryProtocol.Modern); // Should prefer modern + }); + + it('should handle timeout with no responses', async () => { + // Create a test discovery class with mocked socket creation + class TestPrinterDiscovery extends PrinterDiscovery { + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + + const printers = await discovery.discover({ timeout: 200, idleTimeout: 100 }); + + expect(printers).toHaveLength(0); + }); + }); + + describe('Monitor Functionality', () => { + it('should emit discovered events for printers', async () => { + // Create a test discovery class with mocked socket creation + class TestPrinterDiscovery extends PrinterDiscovery { + public mockSocket: dgram.Socket | null = null; + + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + this.mockSocket = mockSocket; + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + + const monitor = discovery.monitor({ timeout: 200, idleTimeout: 100 }); + + const discoveredPrinters: DiscoveredPrinter[] = []; + const endPromise = new Promise((resolve) => { + monitor.on('discovered', (printer: DiscoveredPrinter) => { + discoveredPrinters.push(printer); + }); + + monitor.on('end', () => { + expect(discoveredPrinters).toHaveLength(1); + expect(discoveredPrinters[0].name).toBe('Test Monitor'); + resolve(); + }); + }); + + // Send a printer response + setTimeout(() => { + const mockSocket = discovery.mockSocket; + if (mockSocket) { + const buffer = createModernBuffer({ name: 'Test Monitor' }); + mockSocket.emit('message', buffer, { + address: '192.168.1.150', + port: 8899, + family: 'IPv4', + size: 276, + }); + } + }, 50); + + await endPromise; + }); + + it('should emit end when stopped manually', async () => { + class TestPrinterDiscovery extends PrinterDiscovery { + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + const monitor = discovery.monitor({ timeout: 1000, maxRetries: 10 }); + + const endPromise = new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error('Timed out waiting for end event')); + }, 500); + + monitor.on('error', reject); + monitor.on('end', () => { + clearTimeout(timeoutHandle); + resolve(); + }); + }); + + setTimeout(() => { + monitor.stop(); + }, 50); + + await endPromise; + }); + + it('should honor idleTimeout after first discovery response', async () => { + class TestPrinterDiscovery extends PrinterDiscovery { + public mockSocket: dgram.Socket | null = null; + + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + this.mockSocket = mockSocket; + return mockSocket; + } + } + + const discovery = new TestPrinterDiscovery(); + const start = Date.now(); + const monitor = discovery.monitor({ timeout: 1000, idleTimeout: 100, maxRetries: 10 }); + + const endPromise = new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error('Timed out waiting for idleTimeout end event')); + }, 1000); + + monitor.on('error', reject); + monitor.on('end', () => { + clearTimeout(timeoutHandle); + resolve(); + }); + }); + + setTimeout(() => { + const mockSocket = discovery.mockSocket; + if (mockSocket) { + const buffer = createModernBuffer({ name: 'Idle Timeout Test' }); + mockSocket.emit('message', buffer, { + address: '192.168.1.160', + port: 8899, + family: 'IPv4', + size: 276, + }); + } + }, 50); + + await endPromise; + const elapsedMs = Date.now() - start; + + expect(elapsedMs).toBeLessThan(500); + }); + }); + + describe('Broadcast Address Calculation', () => { + it('should calculate broadcast address correctly', () => { + const discovery = createDiscovery(); + expect(discovery['calculateBroadcastAddress']('192.168.1.10', '255.255.255.0')).toBe( + '192.168.1.255' + ); + expect(discovery['calculateBroadcastAddress']('10.0.0.5', '255.0.0.0')).toBe('10.255.255.255'); + expect(discovery['calculateBroadcastAddress']('172.16.5.10', '255.255.0.0')).toBe( + '172.16.255.255' + ); + }); + + it('should return null for invalid inputs', () => { + const discovery = createDiscovery(); + expect(discovery['calculateBroadcastAddress']('invalid', '255.255.255.0')).toBeNull(); + expect(discovery['calculateBroadcastAddress']('192.168.1.1', 'invalid')).toBeNull(); + }); + }); +}); + +describe('DiscoveryOptions', () => { + // Test discovery class with mocked socket + class TestPrinterDiscovery extends PrinterDiscovery { + public mockSocket: dgram.Socket | null = null; + + protected async createDiscoverySocket(): Promise { + const mockSocket = new EventEmitter() as dgram.Socket; + mockSocket.bind = vi.fn((_port, callback) => { + if (callback) callback(); + }); + mockSocket.setBroadcast = vi.fn(); + mockSocket.send = vi.fn(); + mockSocket.close = vi.fn(); + mockSocket.addMembership = vi.fn(); + this.mockSocket = mockSocket; + return mockSocket; + } + } + + it('should merge user options with defaults', async () => { + const discovery = new TestPrinterDiscovery(); + + const customOptions: DiscoveryOptions = { + timeout: 200, + maxRetries: 2, + ports: [8899], + }; + + const printers = await discovery.discover(customOptions); + + // Should complete without printers (no responses) + expect(printers).toHaveLength(0); + }); + + it('should support disabling multicast', async () => { + const discovery = new TestPrinterDiscovery(); + + const options: DiscoveryOptions = { + timeout: 200, + idleTimeout: 100, + useMulticast: false, + useBroadcast: true, + }; + + const printers = await discovery.discover(options); + expect(printers).toHaveLength(0); + }); + + it('should support disabling broadcast', async () => { + const discovery = new TestPrinterDiscovery(); + + const options: DiscoveryOptions = { + timeout: 200, + idleTimeout: 100, + useMulticast: true, + useBroadcast: false, + }; + + const printers = await discovery.discover(options); + expect(printers).toHaveLength(0); + }); + + it('should not send packets when multicast and broadcast are both disabled', async () => { + const discovery = new TestPrinterDiscovery(); + + const options: DiscoveryOptions = { + timeout: 50, + idleTimeout: 25, + maxRetries: 1, + useMulticast: false, + useBroadcast: false, + ports: [8899, 19000, 48899], + }; + + const printers = await discovery.discover(options); + expect(printers).toHaveLength(0); + expect(discovery.mockSocket).not.toBeNull(); + expect((discovery.mockSocket?.send as ReturnType)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/PrinterDiscovery.ts b/src/api/PrinterDiscovery.ts index abbe381..ad0d507 100644 --- a/src/api/PrinterDiscovery.ts +++ b/src/api/PrinterDiscovery.ts @@ -1,325 +1,716 @@ /** - * @fileoverview UDP broadcast discovery for FlashForge 3D printers on local network + * @fileoverview Universal FlashForge printer discovery using UDP broadcast/multicast. * - * Sends structured UDP packets to port 48899 and parses binary responses to extract - * printer name, serial number, and IP address from fixed buffer offsets. + * Implements multi-port, multi-format UDP discovery supporting all FlashForge models: + * - AD5X, 5M, 5M Pro (276-byte modern protocol) + * - Adventurer 4, Adventurer 3 (140-byte legacy protocol) */ -// src/api/PrinterDiscovery.ts import * as dgram from 'node:dgram'; +import { EventEmitter } from 'node:events'; import { networkInterfaces } from 'node:os'; +import { + type DiscoveredPrinter, + type DiscoveryOptions, + PrinterModel, + PrinterStatus, + DiscoveryProtocol, +} from '../models/PrinterDiscovery'; +import { InvalidResponseError, SocketCreationError } from './network/DiscoveryErrors'; /** - * Represents a discovered FlashForge 3D printer. - * Stores information such as name, serial number, and IP address. + * Default configuration values for printer discovery. */ -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'; +const DEFAULT_DISCOVERY_OPTIONS: Required = { + timeout: 10000, + idleTimeout: 1500, + maxRetries: 3, + useMulticast: true, + useBroadcast: true, + ports: [8899, 19000, 48899], +}; + +/** + * Multicast group address used by FlashForge printers. + */ +const MULTICAST_ADDRESS = '225.0.0.9'; + +/** + * Modern protocol: 276-byte responses (AD5X, 5M, 5M Pro) + */ +const MODERN_PROTOCOL_SIZE = 276; + +/** + * Legacy protocol: 140-byte responses (Adventurer 3, Adventurer 4) + */ +const LEGACY_PROTOCOL_SIZE = 140; + +/** + * EventEmitter-based continuous discovery monitor. + * + * Emits 'discovered', 'end', and 'error' events during printer discovery. + */ +class DiscoveryMonitor extends EventEmitter { + private socket: dgram.Socket | null = null; + private intervalHandle: NodeJS.Timeout | null = null; + private timeoutHandle: NodeJS.Timeout | null = null; + private idleTimeoutHandle: NodeJS.Timeout | null = null; + private discovered: Set = new Set(); + private stopped = false; + private endEmitted = false; + + constructor( + private readonly discovery: PrinterDiscovery, + private readonly config: Required + ) { + super(); + } + + private emitEndIfNeeded(): void { + if (!this.endEmitted) { + this.endEmitted = true; + this.emit('end'); + } + } + + private resetIdleTimeout(): void { + if (this.idleTimeoutHandle) { + clearTimeout(this.idleTimeoutHandle); + } + + this.idleTimeoutHandle = setTimeout(() => { + this.stop(); + }, this.config.idleTimeout); + } + + /** + * Start the monitoring process. + */ + public async start(): Promise { + if (this.stopped) { + throw new Error('Monitor cannot be started after being stopped'); + } + + try { + this.socket = await this.discovery.createDiscoverySocket(); + await this.discovery.bindSocket(this.socket); + + this.socket.on('message', (buffer: Buffer, rinfo: dgram.RemoteInfo) => { + const printer = this.discovery.parseDiscoveryResponse(buffer, rinfo); + if (printer) { + this.resetIdleTimeout(); + + const key = `${printer.ipAddress}:${printer.commandPort}`; + if (!this.discovered.has(key)) { + this.discovered.add(key); + this.emit('discovered', printer); + } + } + }); + + // Send discovery packets periodically + const sendPackets = () => { + if (this.socket && !this.stopped) { + this.discovery.sendDiscoveryPackets(this.socket, this.config); + } + }; + + sendPackets(); + this.intervalHandle = setInterval(sendPackets, this.config.timeout); + + // Auto-stop after specified timeout + this.timeoutHandle = setTimeout(() => { + this.stop(); + }, this.config.timeout * this.config.maxRetries); + } catch (error) { + if (this.listenerCount('error') > 0) { + this.emit('error', error); + } else { + console.error('Discovery monitor error:', error); + } + this.stop(); + } + } + + /** + * Stop monitoring and clean up resources. + */ + public stop(): void { + if (this.stopped) { + return; + } + + this.stopped = true; + + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + } + + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle); + this.timeoutHandle = null; + } + + if (this.idleTimeoutHandle) { + clearTimeout(this.idleTimeoutHandle); + this.idleTimeoutHandle = null; + } + + if (this.socket) { + this.socket.close(); + this.socket = null; + } + + this.emitEndIfNeeded(); } - return str; - } } /** - * Handles the discovery of FlashForge printers on the local network. - * Uses UDP broadcast messages to find printers and parses their responses. + * Universal FlashForge printer discovery using UDP broadcast/multicast. + * + * Supports discovery across multiple protocols and port configurations: + * - Modern protocol (276 bytes): AD5X, 5M, 5M Pro + * - Legacy protocol (140 bytes): Adventurer 3, Adventurer 4 + * + * Example usage: + * ```typescript + * const discovery = new PrinterDiscovery(); + * const printers = await discovery.discover({ timeout: 5000 }); + * + * // Or use event-based monitoring + * const monitor = discovery.monitor(); + * monitor.on('discovered', (printer: DiscoveredPrinter) => { + * console.log(`Found: ${printer.name} at ${printer.ipAddress}`); + * }); + * ``` */ -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(); - }); +export class PrinterDiscovery { + /** + * Discovers FlashForge printers on the local network. + * + * Sends UDP discovery packets to multiple ports and protocols, + * collects responses, and returns deduplicated printer information. + * + * @param options Optional configuration for discovery behavior + * @returns Promise resolving to array of discovered printers + */ + public async discover(options?: DiscoveryOptions): Promise { + const config: Required = { ...DEFAULT_DISCOVERY_OPTIONS, ...options }; + const printers = new Map(); + let attempt = 0; + + while (attempt < config.maxRetries) { + attempt++; + const socket = await this.createDiscoverySocket(); + + try { + await this.bindSocket(socket); + this.sendDiscoveryPackets(socket, config); + + const discoveredPrinters = await this.receiveResponses( + socket, + config.timeout, + config.idleTimeout + ); + + // Merge with existing printers, preferring modern protocol responses + for (const printer of discoveredPrinters) { + const key = `${printer.ipAddress}:${printer.commandPort}`; + const existing = printers.get(key); + + if (!existing || printer.protocolFormat === DiscoveryProtocol.Modern) { + printers.set(key, printer); + } + } + + if (printers.size > 0) { + break; // Printers found, exit retry loop + } + } finally { + socket.close(); + } + + if (attempt < config.maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return Array.from(printers.values()); + } + + /** + * Starts continuous monitoring for printers on the network. + * + * Returns a DiscoveryMonitor that emits 'discovered' events for each printer found. + * Monitoring continues until stop() is called or the timeout expires. + * If one or more printers have been discovered, idleTimeout will stop monitoring + * early after the configured period of inactivity. + * + * @param options Optional configuration for monitoring behavior + * @returns DiscoveryMonitor that emits 'discovered' events + */ + public monitor(options?: DiscoveryOptions): DiscoveryMonitor { + const config: Required = { ...DEFAULT_DISCOVERY_OPTIONS, ...options }; + const monitor = new DiscoveryMonitor(this, config); + + // Start on next tick so callers can attach listeners before events are emitted + queueMicrotask(() => { + void monitor.start(); + }); + + return monitor; + } + + /** + * Creates a UDP socket for discovery operations. + * + * @returns Promise resolving to configured UDP socket + * @public + */ + public async createDiscoverySocket(): Promise { + return new Promise((resolve, reject) => { + try { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + socket.on('error', (error) => { + reject(new SocketCreationError(error.message)); + }); + resolve(socket); + } catch (error) { + reject(new SocketCreationError((error as Error).message)); + } + }); + } + + /** + * Binds the discovery socket to an available port. + * + * @param socket The UDP socket to bind + * @returns Promise that resolves when binding is complete + * @public + */ + public async bindSocket(socket: dgram.Socket): Promise { + return new Promise((resolve, reject) => { + socket.bind(0, () => { + socket.setBroadcast(true); + resolve(); + }); + socket.on('error', reject); }); + } - // 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}`); - } + /** + * Sends UDP discovery packets to all configured ports and addresses. + * + * @param socket The UDP socket to use for sending + * @param options Discovery configuration options + * @public + */ + public sendDiscoveryPackets( + socket: dgram.Socket, + options: Required + ): void { + const emptyPacket = Buffer.alloc(0); + + // Multicast discovery - join group once, then send to all relevant ports + if (options.useMulticast) { + try { + socket.addMembership(MULTICAST_ADDRESS); + } catch (error) { + // Log but continue - sending may still work depending on OS/network config + console.warn(`Discovery: Failed to join multicast group ${MULTICAST_ADDRESS} - ${(error as Error).message}`); + } + + for (const port of options.ports) { + if (port === 8899 || port === 19000) { + try { + socket.send(emptyPacket, 0, 0, port, MULTICAST_ADDRESS); + } catch (error) { + console.warn(`Discovery: Failed to send multicast to ${MULTICAST_ADDRESS}:${port} - ${(error as Error).message}`); + } + } + } } - try { - await this.receivePrinterResponses(udpClient, printers, timeoutMs, idleTimeoutMs); - } catch (ex) { - console.log(`ReceivePrinterResponses error: ${(ex as Error).message}`); + // Broadcast discovery + if (options.useBroadcast) { + const broadcastAddresses = this.getBroadcastAddresses(); + for (const address of broadcastAddresses) { + for (const port of options.ports) { + if (port === 48899) { + try { + socket.send(emptyPacket, 0, 0, port, address); + } catch (error) { + console.warn(`Discovery: Failed to send broadcast to ${address}:${port} - ${(error as Error).message}`); + } + } + } + } } - } finally { - udpClient.close(); - } - if (printers.length > 0) { - break; // Printers found, exit the retry loop - } + // Direct broadcast fallback probes + if (options.useBroadcast) { + for (const port of options.ports) { + try { + socket.send(emptyPacket, 0, 0, port, '255.255.255.255'); + } catch (error) { + console.warn(`Discovery: Failed to send to broadcast 255.255.255.255:${port} - ${(error as Error).message}`); + } + } + } + } - if (attempt >= maxRetries) continue; - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait before retrying + /** + * Receives and parses printer responses from the UDP socket. + * + * @param socket The UDP socket to listen on + * @param totalTimeoutMs Total time to wait for responses + * @param idleTimeoutMs Idle time before stopping after last response + * @returns Promise resolving to array of discovered printers + * @private + */ + protected async receiveResponses( + socket: dgram.Socket, + totalTimeoutMs: number, + idleTimeoutMs: number + ): Promise { + const printers: DiscoveredPrinter[] = []; + + return new Promise((resolve) => { + let totalTimeoutHandle: NodeJS.Timeout | null = null; + let idleTimeoutHandle: NodeJS.Timeout | null = null; + + const cleanupAndResolve = () => { + if (totalTimeoutHandle) { + clearTimeout(totalTimeoutHandle); + } + if (idleTimeoutHandle) { + clearTimeout(idleTimeoutHandle); + } + socket.removeAllListeners('message'); + socket.removeAllListeners('error'); + resolve(printers); + }; + + // Set total timeout + totalTimeoutHandle = setTimeout(() => { + cleanupAndResolve(); + }, totalTimeoutMs); + + const resetIdleTimeout = () => { + if (idleTimeoutHandle) { + clearTimeout(idleTimeoutHandle); + } + idleTimeoutHandle = setTimeout(() => { + cleanupAndResolve(); + }, idleTimeoutMs); + }; + + // Handle incoming messages + socket.on('message', (buffer: Buffer, rinfo: dgram.RemoteInfo) => { + resetIdleTimeout(); + + const printer = this.parseDiscoveryResponse(buffer, rinfo); + if (printer) { + printers.push(printer); + } + }); + + // Handle errors gracefully + socket.on('error', (error) => { + console.error(`Socket error during discovery: ${error.message}`); + }); + + // Start the idle timeout + resetIdleTimeout(); + }); } - 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); + /** + * Parses a UDP discovery response from a printer. + * + * Determines protocol type from response size and delegates to + * the appropriate parser. + * + * @param buffer The response buffer + * @param rinfo Remote address information + * @returns Parsed printer information or null if parsing fails + * @public + */ + public parseDiscoveryResponse( + buffer: Buffer, + rinfo: dgram.RemoteInfo + ): DiscoveredPrinter | null { + if (!buffer || buffer.length === 0) { + return null; } - }); - - // 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; - } - // 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 { + // Detect protocol by response size + if (buffer.length >= MODERN_PROTOCOL_SIZE) { + return this.parseModernProtocol(buffer, rinfo); + } + + if (buffer.length >= LEGACY_PROTOCOL_SIZE) { + return this.parseLegacyProtocol(buffer, rinfo); + } + + // Log invalid response size but don't throw + console.warn( + `Invalid discovery response: ${buffer.length} bytes from ${rinfo.address}` + ); + return null; + } catch (error) { + console.error(`Error parsing discovery response: ${(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; + /** + * Parses modern protocol (276-byte) discovery responses. + * + * Modern protocol structure: + * - Printer name: 0x00, 132 bytes, UTF-8 + * - Command port: 0x84, uint16 BE + * - Vendor ID: 0x86, uint16 BE + * - Product ID: 0x88, uint16 BE + * - Product type: 0x8C, uint16 BE + * - Event port: 0x8E, uint16 BE + * - Status code: 0x90, uint16 BE + * - Serial number: 0x92, 130 bytes, UTF-8 + * + * @param buffer The response buffer (276 bytes) + * @param rinfo Remote address information + * @returns Parsed printer information + * @private + */ + protected parseModernProtocol( + buffer: Buffer, + rinfo: dgram.RemoteInfo + ): DiscoveredPrinter | null { + if (buffer.length < MODERN_PROTOCOL_SIZE) { + throw new InvalidResponseError(buffer.length, rinfo.address); } - // Calculate broadcast address based on IP and netmask - const broadcastAddress = this.calculateBroadcastAddress(iface.address, iface.netmask); - if (broadcastAddress) { - broadcastAddresses.push(broadcastAddress); + // Extract printer name (UTF-8, null-terminated) + const name = buffer.toString('utf8', 0x00, 0x84).replace(/\0.*$/, ''); + + // Extract network configuration (big-endian uint16) + 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); + + // Extract status + const statusCode = buffer.readUInt16BE(0x90); + const status = this.mapStatusCode(statusCode); + + // Extract serial number (UTF-8, null-terminated) + const serialNumber = buffer.toString('utf8', 0x92, 0x92 + 130).replace(/\0.*$/, ''); + + // Detect model + const model = this.detectModernModel(name, productType); + + return { + model, + protocolFormat: DiscoveryProtocol.Modern, + name, + ipAddress: rinfo.address, + commandPort, + serialNumber, + eventPort, + vendorId, + productId, + productType, + statusCode, + status, + }; + } + + /** + * Parses legacy protocol (140-byte) discovery responses. + * + * Legacy protocol structure: + * - Printer name: 0x00, 128 bytes, UTF-8 + * - Padding: 0x80, 4 bytes + * - Command port: 0x84, uint16 BE + * - Vendor ID: 0x86, uint16 BE + * - Product ID: 0x88, uint16 BE + * - Status code: 0x8A, uint16 BE + * + * @param buffer The response buffer (140 bytes) + * @param rinfo Remote address information + * @returns Parsed printer information + * @private + */ + protected parseLegacyProtocol( + buffer: Buffer, + rinfo: dgram.RemoteInfo + ): DiscoveredPrinter | null { + if (buffer.length < LEGACY_PROTOCOL_SIZE) { + throw new InvalidResponseError(buffer.length, rinfo.address); } - } + + // Extract printer name (UTF-8, null-terminated) + const name = buffer.toString('utf8', 0x00, 0x80).replace(/\0.*$/, ''); + + // Extract network configuration (big-endian uint16) + const commandPort = buffer.readUInt16BE(0x84); + const vendorId = buffer.readUInt16BE(0x86); + const productId = buffer.readUInt16BE(0x88); + + // Extract status + const statusCode = buffer.readUInt16BE(0x8A); + const status = this.mapStatusCode(statusCode); + + // Detect model + const model = this.detectLegacyModel(name); + + return { + model, + protocolFormat: DiscoveryProtocol.Legacy, + name, + ipAddress: rinfo.address, + commandPort, + vendorId, + productId, + statusCode, + status, + }; } - 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; + /** + * Detects printer model from modern protocol response. + * + * Uses both printer name and product type for accurate detection. + * + * @param name Printer name from response + * @param productType Product type code (e.g., 0x5A02 for 5M series) + * @returns Detected printer model + * @private + */ + protected detectModernModel(name: string, productType: number): PrinterModel { + const upperName = name.toUpperCase(); + + // Direct name matches (highest priority) + if (upperName === 'AD5X') { + return PrinterModel.AD5X; + } + + // Product type-based detection (0x5A02 = 5M series) + if (productType === 0x5A02) { + if (upperName.includes('PRO')) { + return PrinterModel.Adventurer5MPro; + } + return PrinterModel.Adventurer5M; + } + + // Name-based fallback + if (upperName.includes('ADVENTURER 5M') || upperName.includes('AD5M')) { + if (upperName.includes('PRO')) { + return PrinterModel.Adventurer5MPro; + } + return PrinterModel.Adventurer5M; + } + + return PrinterModel.Unknown; } - } - - /** - * 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 += ' '; + + /** + * Detects printer model from legacy protocol response. + * + * Uses printer name heuristics for legacy models. + * + * @param name Printer name from response + * @returns Detected printer model + * @private + */ + protected detectLegacyModel(name: string): PrinterModel { + const upperName = name.toUpperCase(); + + if (upperName.includes('ADVENTURER 4') || upperName.includes('ADVENTURER4') || upperName.includes('AD4')) { + return PrinterModel.Adventurer4; } - if (j === 7) line += ' '; - } + if (upperName.includes('ADVENTURER 3') || upperName.includes('ADVENTURER3') || upperName.includes('AD3')) { + return PrinterModel.Adventurer3; + } + + return PrinterModel.Unknown; + } + + /** + * Maps status code to PrinterStatus enum. + * + * @param statusCode Status code from printer response + * @returns Mapped printer status + * @private + */ + protected mapStatusCode(statusCode: number): PrinterStatus { + switch (statusCode) { + case 0: + return PrinterStatus.Ready; + case 1: + return PrinterStatus.Busy; + case 2: + return PrinterStatus.Error; + default: + return PrinterStatus.Unknown; + } + } - // 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) : '.'; - } + /** + * Retrieves broadcast addresses for all active IPv4 network interfaces. + * + * @returns Array of broadcast address strings + * @private + */ + protected 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 + const broadcastAddress = this.calculateBroadcastAddress(iface.address, iface.netmask); + if (broadcastAddress) { + broadcastAddresses.push(broadcastAddress); + } + } + } - console.log(line); + return broadcastAddresses; } - // ASCII dump - console.log('ASCII dump:'); - console.log(response.toString('ascii')); - } + /** + * Calculates broadcast address from IP address and subnet mask. + * + * @param ipAddress IPv4 address string + * @param subnetMask IPv4 subnet mask string + * @returns Broadcast address string or null if calculation fails + * @private + */ + protected calculateBroadcastAddress(ipAddress: string, subnetMask: string): string | null { + try { + const ip = ipAddress.split('.').map(Number); + const mask = subnetMask.split('.').map(Number); + + if (ip.length !== 4 || mask.length !== 4) { + return null; + } + + // Calculate broadcast: IP | (~MASK) + const broadcast = ip.map((octet, index) => octet | (~mask[index] & 255)); + return broadcast.join('.'); + } catch { + return null; + } + } } diff --git a/src/api/network/DiscoveryErrors.ts b/src/api/network/DiscoveryErrors.ts new file mode 100644 index 0000000..f48d6e2 --- /dev/null +++ b/src/api/network/DiscoveryErrors.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Typed error classes for FlashForge printer discovery. + * + * Provides a hierarchy of error types for discovery failures following + * the FNetCode pattern used elsewhere in the codebase. + */ + +/** + * Base error class for all discovery-related errors. + * Extends Error with an additional code property for error categorization. + */ +export class DiscoveryError extends Error { + /** Error code for identifying the type of discovery failure */ + public readonly code: string; + + /** + * Creates a new DiscoveryError. + * @param message Human-readable error description + * @param code Machine-readable error code identifier + */ + constructor(message: string, code: string) { + super(message); + this.name = 'DiscoveryError'; + this.code = code; + } +} + +/** + * Error thrown when a printer response has an invalid size. + * Indicates the received UDP response doesn't match expected protocol formats. + */ +export class InvalidResponseError extends DiscoveryError { + /** Size of the invalid response in bytes */ + public readonly responseSize: number; + /** IP address that sent the invalid response */ + public readonly address: string; + + /** + * Creates a new InvalidResponseError. + * @param size Size of the invalid response in bytes + * @param address IP address that sent the invalid response + */ + constructor(size: number, address: string) { + super(`Invalid response size: ${size} bytes from ${address}`, 'INVALID_RESPONSE'); + this.name = 'InvalidResponseError'; + this.responseSize = size; + this.address = address; + } +} + +/** + * Error thrown when UDP socket creation fails. + * Indicates a system-level networking issue preventing discovery. + */ +export class SocketCreationError extends DiscoveryError { + /** + * Creates a new SocketCreationError. + * @param message Description of the socket creation failure + */ + constructor(message: string) { + super(message, 'SOCKET_CREATION_FAILED'); + this.name = 'SocketCreationError'; + } +} + +/** + * Error thrown when discovery timeout expires. + * Indicates no printers were found within the specified time window. + */ +export class DiscoveryTimeoutError extends DiscoveryError { + /** Timeout duration in milliseconds */ + public readonly timeoutMs: number; + + /** + * Creates a new DiscoveryTimeoutError. + * @param timeoutMs Timeout duration in milliseconds + */ + constructor(timeoutMs: number) { + super(`Discovery timeout after ${timeoutMs}ms`, 'DISCOVERY_TIMEOUT'); + this.name = 'DiscoveryTimeoutError'; + this.timeoutMs = timeoutMs; + } +} diff --git a/src/index.ts b/src/index.ts index e085037..484c962 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,16 @@ export { formatScientificNotation } from './api/misc/ScientificNotationFloatConv // Network Utilities export { FNetCode } from './api/network/FNetCode'; export { NetworkUtils } from './api/network/NetworkUtils'; -export { FlashForgePrinter, FlashForgePrinterDiscovery } from './api/PrinterDiscovery'; + +// Printer Discovery +export { PrinterDiscovery } from './api/PrinterDiscovery'; +export { + PrinterModel, + DiscoveryProtocol, + PrinterStatus, + type DiscoveredPrinter, + type DiscoveryOptions, +} from './models/PrinterDiscovery'; // Server constants export { Commands } from './api/server/Commands'; diff --git a/src/models/PrinterDiscovery.ts b/src/models/PrinterDiscovery.ts new file mode 100644 index 0000000..34c3054 --- /dev/null +++ b/src/models/PrinterDiscovery.ts @@ -0,0 +1,104 @@ +/** + * @fileoverview Type definitions for FlashForge printer discovery system. + * + * Provides enums and interfaces for universal printer discovery supporting + * all FlashForge models (AD5X, 5M, 5M Pro, Adventurer 4, Adventurer 3) through + * multi-port, multi-format UDP discovery. + */ + +/** + * FlashForge printer model enumeration. + * Identifies specific printer models based on name and product type information. + */ +export enum PrinterModel { + /** Adventurer 5X (AD5X) with Intelligent Filament Station */ + AD5X = 'AD5X', + /** Adventurer 5M standard model */ + Adventurer5M = 'Adventurer5M', + /** Adventurer 5M Pro model */ + Adventurer5MPro = 'Adventurer5MPro', + /** Adventurer 4 model */ + Adventurer4 = 'Adventurer4', + /** Adventurer 3 model */ + Adventurer3 = 'Adventurer3', + /** Unknown or unrecognized printer model */ + Unknown = 'Unknown' +} + +/** + * Discovery protocol format enumeration. + * Indicates whether a printer response uses the modern or legacy protocol. + */ +export enum DiscoveryProtocol { + /** Modern protocol: 276-byte responses from AD5X, 5M, 5M Pro */ + Modern = 'modern', + /** Legacy protocol: 140-byte responses from Adventurer 3, Adventurer 4 */ + Legacy = 'legacy' +} + +/** + * Printer status enumeration. + * Represents the current operational state of a discovered printer. + */ +export enum PrinterStatus { + /** Printer is ready to accept print jobs */ + Ready = 0, + /** Printer is busy (printing, heating, or processing) */ + Busy = 1, + /** Printer is in an error state */ + Error = 2, + /** Printer status could not be determined */ + Unknown = 3 +} + +/** + * Represents a discovered FlashForge printer with all available metadata. + * Contains information extracted from UDP discovery responses including identification, + * network configuration, and current status. + */ +export interface DiscoveredPrinter { + /** Printer model identifier */ + model: PrinterModel; + /** Protocol format used in discovery response */ + protocolFormat: DiscoveryProtocol; + /** Printer name (UTF-8 encoded) */ + name: string; + /** IP address for command communication */ + ipAddress: string; + /** TCP port for G-code commands (typically 8899) */ + commandPort: number; + /** Serial number (modern protocol only) */ + serialNumber?: string; + /** HTTP API event port (modern protocol only, typically 8898) */ + eventPort?: number; + /** USB vendor ID */ + vendorId?: number; + /** USB product ID */ + productId?: number; + /** Product type from modern protocol (e.g., 0x5A02 for 5M series) */ + productType?: number; + /** Status code from printer */ + statusCode?: number; + /** Decoded printer status */ + status?: PrinterStatus; +} + +/** + * Configuration options for printer discovery. + * Allows customization of discovery behavior including timeouts, retry logic, + * and which discovery methods to use. + */ +export interface DiscoveryOptions { + /** Total time in milliseconds to wait for responses (default: 10000) */ + timeout?: number; + /** Idle time in milliseconds to wait after last response before stopping (default: 1500) */ + idleTimeout?: number; + /** Maximum number of discovery retry attempts (default: 3) */ + maxRetries?: number; + /** Whether to use multicast discovery on ports 8899 and 19000 (default: true) */ + useMulticast?: boolean; + /** Whether to use broadcast discovery on local subnet addresses (default: true) */ + useBroadcast?: boolean; + /** Specific ports to use for discovery (default: [8899, 19000, 48899]) */ + ports?: number[]; +}