From eb033229ecae7f97a99969f8440a75dded1dbe47 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:14:51 -0400 Subject: [PATCH 01/59] ci(release): generate release notes from CHANGELOG.md section --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b2a14b..522eab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -206,6 +206,11 @@ jobs: RELEASE_TYPE_BADGE=" (Pre-release)" fi + PRERELEASE_NOTICE="" + if [[ "$IS_PRERELEASE" == "true" ]]; then + PRERELEASE_NOTICE="This is an alpha pre-release build intended for testing before the next stable WebUI release." + fi + VERSION_TAG="v${VERSION}" PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -vx "${VERSION_TAG}" | head -n 1) CHANGELOG_URL="https://github.com/${{ github.repository }}/blob/${VERSION_TAG}/CHANGELOG.md" @@ -215,11 +220,35 @@ jobs: COMPARE_URL="https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${VERSION_TAG}" fi + CHANGELOG_SECTION=$(VERSION="$VERSION" python3 <<'PY' + from pathlib import Path + import os + import re + + version = os.environ["VERSION"] + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + pattern = rf"^## \[{re.escape(version)}\] - [^\n]*\n(.*?)(?=^## \[|\Z)" + match = re.search(pattern, text, re.MULTILINE | re.DOTALL) + if match: + print(match.group(1).strip()) + PY + ) + + if [[ -z "$(printf '%s' "$CHANGELOG_SECTION" | tr -d '[:space:]')" ]]; then + CHANGELOG_SECTION="- See the [CHANGELOG](${CHANGELOG_URL}) for the detailed release notes." + fi + cat > RELEASE_NOTES.md << EOF # FlashForgeWebUI v${VERSION}${RELEASE_TYPE_BADGE} Standalone web-based interface for FlashForge 3D printers. + ${PRERELEASE_NOTICE} + + ## Changes + + ${CHANGELOG_SECTION} + ## Downloads Choose the appropriate binary for your platform: From 65eeed63d03c58c817a9ab04ca2efb39b7f226a2 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:15:11 -0400 Subject: [PATCH 02/59] chore(release): 1.2.0-alpha.1 --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3002b8f..f17cb9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0-alpha.1] - 2026-07-04 + ### Added - Creator 5 / Creator 5 Pro printer support via the new `Creator5Backend`, including model detection, an HTTP-only connection flow, and material station support @@ -200,7 +202,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional password authentication - Configuration persistence in `data/config.json` -[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.1...HEAD +[1.2.0-alpha.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...v1.2.0-alpha.1 [1.1.0]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.0...v1.0.1 diff --git a/package-lock.json b/package-lock.json index 7bbd54d..87a73b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flashforge-webui", - "version": "1.1.0", + "version": "1.2.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flashforge-webui", - "version": "1.1.0", + "version": "1.2.0-alpha.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b93cf21..100d192 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flashforge-webui", - "version": "1.1.0", + "version": "1.2.0-alpha.1", "description": "Standalone WebUI for FlashForge 3D Printers", "main": "dist/index.js", "bin": "dist/index.js", From e66c3e9398b5aeefea894a37ea86f42a18ea34ac Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:05:13 -0400 Subject: [PATCH 03/59] fix(webui): show TVOC reading on filtration card, disable controls for C5 Pro --- src/webui/static/ui/panels.ts | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/webui/static/ui/panels.ts b/src/webui/static/ui/panels.ts index 8e295fc..4489c85 100644 --- a/src/webui/static/ui/panels.ts +++ b/src/webui/static/ui/panels.ts @@ -228,9 +228,11 @@ export function updateCreator5TemperatureCard(status: PrinterStatus | null): voi } /** - * Show the read-only TVOC air-quality reading for the Creator 5 Pro and hide its - * (non-functional) filtration controls. Other printers keep their filtration - * controls and hide the TVOC block. + * Populate the read-only TVOC air-quality reading and gate the filtration + * controls. Every printer that mounts this card (filtration-capable printers and + * the Creator 5 Pro) shows both its filtration mode/controls and its TVOC level. + * The Creator 5 Pro reports TVOC but cannot switch filtration modes, so its + * control buttons are shown but disabled. */ export function updateTvocDisplay(status: PrinterStatus | null): void { const tvocInfo = $('tvoc-info'); @@ -239,17 +241,25 @@ export function updateTvocDisplay(status: PrinterStatus | null): void { return; } - const isCreator5Pro = Boolean(state.printerFeatures?.isCreator5Pro); + // The card itself is only mounted for filtration-capable printers or the + // Creator 5 Pro (see layout-theme component gating), so whenever it exists we + // show both the filtration controls and the TVOC readout together. + filtrationSection?.classList.remove('hidden'); + tvocInfo?.classList.remove('hidden'); - filtrationSection?.classList.toggle('hidden', isCreator5Pro); - tvocInfo?.classList.toggle('hidden', !isCreator5Pro); + const tvoc = status?.tvocLevel; + setTextContent( + 'tvoc-status', + tvoc !== undefined && !Number.isNaN(tvoc) ? `${Math.round(tvoc)}` : '--' + ); - if (isCreator5Pro) { - const tvoc = status?.tvocLevel; - setTextContent( - 'tvoc-status', - tvoc !== undefined && !Number.isNaN(tvoc) ? `${Math.round(tvoc)}` : '--' - ); + // The Creator 5 Pro exposes a TVOC sensor but no filtration-mode control. + const isCreator5Pro = Boolean(state.printerFeatures?.isCreator5Pro); + for (const id of ['btn-external-filtration', 'btn-internal-filtration', 'btn-no-filtration']) { + const btn = $(id) as HTMLButtonElement | null; + if (btn) { + btn.disabled = isCreator5Pro; + } } } From cca37e89263140dcd49316dcf269dc2b206f1c97 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:35:22 -0400 Subject: [PATCH 04/59] feat(webui): add thumbnail previews to the file dialog with a persistent cache --- src/index.ts | 5 + src/services/ThumbnailCacheService.ts | 395 ++++++++++++++++++++++++++ src/webui/server/routes/job-routes.ts | 70 +++++ src/webui/static/ui/dialogs.ts | 91 +++++- src/webui/static/webui.css | 45 ++- 5 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 src/services/ThumbnailCacheService.ts diff --git a/src/index.ts b/src/index.ts index 924a221..5889415 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { getMultiContextSpoolmanTracker } from './services/MultiContextSpoolmanT import { getMultiContextTemperatureMonitor } from './services/MultiContextTemperatureMonitor'; import { getSavedPrinterService } from './services/SavedPrinterService'; import { initializeSpoolmanIntegrationService } from './services/SpoolmanIntegrationService'; +import { getThumbnailCacheService } from './services/ThumbnailCacheService'; import type { PollingData } from './types/polling'; import type { PrinterClientType, PrinterDetails } from './types/printer'; import { getCameraUserConfig } from './utils/camera-utils'; @@ -433,6 +434,10 @@ async function main(): Promise { console.log('[Init] Initializing data directory...'); initializeDataDirectory(); + // 1a. Initialize the persistent per-printer thumbnail cache used by the file dialog + await getThumbnailCacheService().initialize(); + console.log('[Init] Thumbnail cache initialized'); + // 2. Parse CLI arguments const config = parseHeadlessArguments(); console.log(`[Init] Mode: ${config.mode}`); diff --git a/src/services/ThumbnailCacheService.ts b/src/services/ThumbnailCacheService.ts new file mode 100644 index 0000000..af3daeb --- /dev/null +++ b/src/services/ThumbnailCacheService.ts @@ -0,0 +1,395 @@ +/** + * @fileoverview Persistent file-based cache service for printer job thumbnails + * + * Provides a robust file-based caching system for printer job thumbnails to minimize + * network requests and improve UI responsiveness. Organizes cache by printer serial + * number with MD5-hashed filenames for collision avoidance. Includes metadata tracking, + * validation, and comprehensive cache management operations. + * + * This mirrors the FlashForgeUI-Electron ThumbnailCacheService, adapted for the standalone + * WebUI: instead of Electron's userData directory it stores thumbnails under the app data + * directory (see getDataPath()), so the same lazy-loaded file dialog cache works headlessly. + * + * Key Features: + * - File-based persistence under the data directory + * - Per-printer cache organization with metadata tracking + * - MD5 hashing of filenames to prevent collisions + * - Base64 image storage with automatic data URL handling + * - Cache validation and automatic cleanup of orphaned metadata + * - Statistics reporting for cache monitoring + * - Graceful error handling with detailed result types + * + * Cache Structure: + * - Thumbnails/{printerSerial}/{fileNameHash}.png - Thumbnail images + * - Thumbnails/{printerSerial}/metadata.json - Cache metadata and timestamps + * + * Singleton Pattern: + * Access via getThumbnailCacheService() factory function. + * + * @module services/ThumbnailCacheService + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { getDataPath } from '../utils/setup'; + +/** + * Metadata for a cached thumbnail + */ +interface ThumbnailMetadata { + readonly fileName: string; + readonly hash: string; + readonly cachedAt: number; // Unix timestamp + readonly size: number; // File size in bytes +} + +/** + * Cache metadata collection + */ +interface CacheMetadata { + version: number; + entries: Record; +} + +/** + * Options for cache service + */ +interface CacheOptions { + readonly basePath?: string; +} + +/** + * Result of cache operations + */ +interface CacheResult { + readonly success: boolean; + readonly data?: T; + readonly error?: string; +} + +/** + * Service for managing thumbnail cache with file-based persistence + */ +export class ThumbnailCacheService { + private static instance: ThumbnailCacheService | null = null; + + private readonly basePath: string; + private readonly metadataCache = new Map(); + + private constructor(options: CacheOptions = {}) { + // Dedicated Thumbnails directory under the app data directory + this.basePath = options.basePath || path.join(getDataPath(), 'Thumbnails'); + } + + /** + * Get singleton instance + */ + public static getInstance(options?: CacheOptions): ThumbnailCacheService { + if (!ThumbnailCacheService.instance) { + ThumbnailCacheService.instance = new ThumbnailCacheService(options); + } + return ThumbnailCacheService.instance; + } + + /** + * Initialize cache service and ensure directories exist + */ + public async initialize(): Promise { + try { + await fs.mkdir(this.basePath, { recursive: true }); + console.log(`ThumbnailCacheService initialized at: ${this.basePath}`); + } catch (error) { + console.error('Failed to initialize thumbnail cache:', error); + throw error; + } + } + + /** + * Get cached thumbnail if available + */ + public async get(printerSerial: string, fileName: string): Promise> { + try { + const hash = this.hashFileName(fileName); + const metadata = await this.getMetadata(printerSerial); + + // Check if entry exists in metadata + const entry = metadata.entries[hash]; + if (!entry) { + return { success: false, error: 'Not in cache' }; + } + + // Read the cached file + const filePath = this.getThumbnailPath(printerSerial, hash); + const data = await fs.readFile(filePath, 'base64'); + + console.log(`[ThumbnailCache] Cache hit for ${fileName} (${printerSerial})`); + return { success: true, data }; + } catch (error) { + console.error(`Error reading cached thumbnail for ${fileName}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Store thumbnail in cache + */ + public async set(printerSerial: string, fileName: string, base64Data: string): Promise> { + try { + const hash = this.hashFileName(fileName); + const printerDir = path.join(this.basePath, printerSerial); + + // Ensure printer directory exists + await fs.mkdir(printerDir, { recursive: true }); + + // Strip data URL prefix if present + const imageData = base64Data.replace(/^data:image\/\w+;base64,/, ''); + + // Write thumbnail file + const filePath = this.getThumbnailPath(printerSerial, hash); + const buffer = Buffer.from(imageData, 'base64'); + await fs.writeFile(filePath, buffer); + + // Update metadata + const metadata = await this.getMetadata(printerSerial); + metadata.entries[hash] = { + fileName, + hash, + cachedAt: Date.now(), + size: buffer.length, + }; + + await this.saveMetadata(printerSerial, metadata); + + console.log(`[ThumbnailCache] Cached thumbnail for ${fileName} (${printerSerial})`); + return { success: true }; + } catch (error) { + console.error(`Error caching thumbnail for ${fileName}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Check if thumbnail exists in cache (without reading the file) + */ + public async has(printerSerial: string, fileName: string): Promise { + try { + const hash = this.hashFileName(fileName); + const metadata = await this.getMetadata(printerSerial); + + const entry = metadata.entries[hash]; + if (!entry) { + return false; + } + + // Verify file actually exists + const filePath = this.getThumbnailPath(printerSerial, hash); + try { + await fs.access(filePath); + return true; + } catch { + // File doesn't exist, clean up metadata + delete metadata.entries[hash]; + await this.saveMetadata(printerSerial, metadata); + return false; + } + } catch (error) { + console.error(`Error checking cache for ${fileName}:`, error); + return false; + } + } + + /** + * Invalidate (remove) a specific thumbnail from cache + */ + public async invalidate(printerSerial: string, fileName: string): Promise { + try { + const hash = this.hashFileName(fileName); + await this.removeEntry(printerSerial, hash); + console.log(`[ThumbnailCache] Invalidated cache for ${fileName} (${printerSerial})`); + } catch (error) { + console.error(`Error invalidating cache for ${fileName}:`, error); + } + } + + /** + * Clear all thumbnails for a specific printer + */ + public async clearPrinter(printerSerial: string): Promise { + try { + const printerDir = path.join(this.basePath, printerSerial); + await fs.rm(printerDir, { recursive: true, force: true }); + this.metadataCache.delete(printerSerial); + console.log(`[ThumbnailCache] Cleared all cache for printer ${printerSerial}`); + } catch (error) { + console.error(`Error clearing cache for printer ${printerSerial}:`, error); + } + } + + /** + * Clear entire cache + */ + public async clearAll(): Promise { + try { + await fs.rm(this.basePath, { recursive: true, force: true }); + await fs.mkdir(this.basePath, { recursive: true }); + this.metadataCache.clear(); + console.log('[ThumbnailCache] Cleared entire cache'); + } catch (error) { + console.error('Error clearing entire cache:', error); + } + } + + /** + * Get cache statistics + */ + public async getStats(): Promise<{ + totalFiles: number; + totalSize: number; + printerCount: number; + oldestEntry: number | null; + newestEntry: number | null; + }> { + try { + let totalFiles = 0; + let totalSize = 0; + let oldestEntry: number | null = null; + let newestEntry: number | null = null; + + const printers = await fs.readdir(this.basePath); + const printerCount = printers.length; + + for (const printerSerial of printers) { + const metadata = await this.getMetadata(printerSerial); + + for (const entry of Object.values(metadata.entries)) { + totalFiles++; + totalSize += entry.size; + + if (!oldestEntry || entry.cachedAt < oldestEntry) { + oldestEntry = entry.cachedAt; + } + if (!newestEntry || entry.cachedAt > newestEntry) { + newestEntry = entry.cachedAt; + } + } + } + + return { + totalFiles, + totalSize, + printerCount, + oldestEntry, + newestEntry, + }; + } catch (error) { + console.error('Error getting cache stats:', error); + return { + totalFiles: 0, + totalSize: 0, + printerCount: 0, + oldestEntry: null, + newestEntry: null, + }; + } + } + + // Private helper methods + + /** + * Generate hash for file name to use as cache key + */ + private hashFileName(fileName: string): string { + return crypto.createHash('md5').update(fileName).digest('hex'); + } + + /** + * Get path to thumbnail file + */ + private getThumbnailPath(printerSerial: string, hash: string): string { + return path.join(this.basePath, printerSerial, `${hash}.png`); + } + + /** + * Get path to metadata file + */ + private getMetadataPath(printerSerial: string): string { + return path.join(this.basePath, printerSerial, 'metadata.json'); + } + + /** + * Load metadata for a printer + */ + private async getMetadata(printerSerial: string): Promise { + // Check memory cache first + const cached = this.metadataCache.get(printerSerial); + if (cached) { + return cached; + } + + try { + const metadataPath = this.getMetadataPath(printerSerial); + const data = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(data) as CacheMetadata; + + // Validate and migrate if needed + if (!metadata.version || metadata.version < 1) { + metadata.version = 1; + } + if (!metadata.entries) { + metadata.entries = {}; + } + + this.metadataCache.set(printerSerial, metadata); + return metadata; + } catch { + // Return empty metadata if file doesn't exist + const emptyMetadata: CacheMetadata = { + version: 1, + entries: {}, + }; + this.metadataCache.set(printerSerial, emptyMetadata); + return emptyMetadata; + } + } + + /** + * Save metadata for a printer + */ + private async saveMetadata(printerSerial: string, metadata: CacheMetadata): Promise { + const metadataPath = this.getMetadataPath(printerSerial); + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); + this.metadataCache.set(printerSerial, metadata); + } + + /** + * Remove a specific entry + */ + private async removeEntry(printerSerial: string, hash: string): Promise { + // Remove file + const filePath = this.getThumbnailPath(printerSerial, hash); + try { + await fs.unlink(filePath); + } catch { + // Ignore if file doesn't exist + } + + // Update metadata + const metadata = await this.getMetadata(printerSerial); + delete metadata.entries[hash]; + await this.saveMetadata(printerSerial, metadata); + } +} + +/** + * Get singleton instance of ThumbnailCacheService + */ +export function getThumbnailCacheService(): ThumbnailCacheService { + return ThumbnailCacheService.getInstance(); +} diff --git a/src/webui/server/routes/job-routes.ts b/src/webui/server/routes/job-routes.ts index 4b4c008..5c3af57 100644 --- a/src/webui/server/routes/job-routes.ts +++ b/src/webui/server/routes/job-routes.ts @@ -4,6 +4,7 @@ import type { Response, Router } from 'express'; import { isAD5XJobInfo } from '../../../printer-backends/ad5x/ad5x-utils'; +import { getThumbnailCacheService } from '../../../services/ThumbnailCacheService'; import type { AD5XJobInfo, BasicJobInfo } from '../../../types/printer-backend/backend-operations'; import { toAppError } from '../../../utils/error.utils'; import { createValidationError, JobStartRequestSchema } from '../../schemas/web-api.schemas'; @@ -13,6 +14,10 @@ import { type RouteDependencies, resolveContext, sendErrorResponse } from './rou type JobSource = 'local' | 'recent'; +interface ThumbnailAPIResponse extends StandardAPIResponse { + readonly thumbnail?: string | null; +} + export function registerJobRoutes(router: Router, deps: RouteDependencies): void { router.get('/jobs/local', async (req: AuthenticatedRequest, res: Response) => { await handleJobListRequest(req, res, deps, 'local'); @@ -22,6 +27,10 @@ export function registerJobRoutes(router: Router, deps: RouteDependencies): void await handleJobListRequest(req, res, deps, 'recent'); }); + router.get('/jobs/thumbnail', async (req: AuthenticatedRequest, res: Response) => { + await handleThumbnailRequest(req, res, deps); + }); + router.post('/jobs/start', async (req: AuthenticatedRequest, res: Response) => { try { const contextResult = resolveContext(req, deps, { requireBackendReady: true }); @@ -127,6 +136,67 @@ async function handleJobListRequest( } } +/** + * Serve a single job thumbnail for the lazy-loading file dialog. Mirrors the desktop + * job picker: check the shared per-printer {@link getThumbnailCacheService} first, and on + * a miss fetch from the resolved context's backend and cache the result. Fetching by the + * request's resolved context (not the active one) keeps multi-printer clients correct. + * Returns raw base64 (no data URL prefix); `thumbnail` is null when none is available. + */ +async function handleThumbnailRequest( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies +): Promise { + try { + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); + if (!contextResult.success) { + return sendErrorResponse( + res, + contextResult.statusCode, + contextResult.error + ); + } + + const filenameParam = req.query.filename; + const filename = typeof filenameParam === 'string' ? filenameParam : ''; + if (!filename) { + return sendErrorResponse( + res, + 400, + 'filename query parameter is required' + ); + } + + const serialNumber = deps.backendManager.getPrinterDetailsForContext( + contextResult.contextId + )?.SerialNumber; + const cache = getThumbnailCacheService(); + + if (serialNumber) { + const cached = await cache.get(serialNumber, filename); + if (cached.success && cached.data) { + return res.json({ success: true, thumbnail: cached.data } satisfies ThumbnailAPIResponse); + } + } + + const thumbnail = await deps.backendManager.getJobThumbnail(contextResult.contextId, filename); + if (!thumbnail) { + return res.json({ success: true, thumbnail: null } satisfies ThumbnailAPIResponse); + } + + const base64 = thumbnail.replace(/^data:image\/\w+;base64,/, ''); + if (serialNumber) { + await cache.set(serialNumber, filename, base64); + } + + return res.json({ success: true, thumbnail: base64 } satisfies ThumbnailAPIResponse); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} + function mapJobInfo(job: AD5XJobInfo | BasicJobInfo) { const base = { fileName: job.fileName, diff --git a/src/webui/static/ui/dialogs.ts b/src/webui/static/ui/dialogs.ts index 8e9a073..72e6316 100644 --- a/src/webui/static/ui/dialogs.ts +++ b/src/webui/static/ui/dialogs.ts @@ -7,7 +7,7 @@ * material matching, printer commands) through dependency callbacks. */ -import type { FileListResponse, WebUIJobFile } from '../app.js'; +import type { ApiResponse, FileListResponse, WebUIJobFile } from '../app.js'; import { state } from '../core/AppState.js'; import { apiRequest } from '../core/Transport.js'; import { $, hideElement, showElement, showToast } from '../shared/dom.js'; @@ -75,6 +75,9 @@ function currentTargetTemperature(target: TemperatureTarget): number { let dialogHandlers: DialogHandlers = {}; +/** Monotonic token identifying the current thumbnail-loading batch (for cancellation). */ +let thumbnailRunId = 0; + export async function loadFileList(source: 'recent' | 'local'): Promise { if (state.authRequired && !state.authToken) { return; @@ -122,6 +125,14 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') item.className = 'file-item'; item.dataset.filename = file.fileName; + const thumbnail = document.createElement('div'); + thumbnail.className = 'file-thumbnail loading'; + thumbnail.textContent = 'Loading…'; + item.appendChild(thumbnail); + + const body = document.createElement('div'); + body.className = 'file-item-body'; + const header = document.createElement('div'); header.className = 'file-item-header'; @@ -138,7 +149,7 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') header.appendChild(badge); } - item.appendChild(header); + body.appendChild(header); const meta = document.createElement('div'); meta.className = 'file-meta'; @@ -183,9 +194,11 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') } if (meta.childElementCount > 0) { - item.appendChild(meta); + body.appendChild(meta); } + item.appendChild(body); + item.addEventListener('click', () => { fileList.querySelectorAll('.file-item').forEach((el) => { el.classList.remove('selected'); @@ -203,6 +216,76 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') }); showElement('file-modal'); + + // Lazily fetch preview thumbnails once the list is on screen. The run token lets an + // in-flight batch bail out when the modal is closed or a different list is opened. + void loadThumbnails(files, ++thumbnailRunId); +} + +interface JobThumbnailResponse extends ApiResponse { + thumbnail?: string | null; +} + +/** + * Sequentially request a preview thumbnail for each file and paint it into the matching + * card. Requests are serialized (one in flight at a time) to stay gentle on the printer's + * single connection; the server caches results per printer so re-opening the dialog is + * instant. Aborts as soon as `runId` no longer matches the active batch. + */ +async function loadThumbnails(files: WebUIJobFile[], runId: number): Promise { + for (const file of files) { + if (runId !== thumbnailRunId) { + return; + } + + let thumbnail: string | null = null; + try { + const result = await apiRequest( + `/api/jobs/thumbnail?filename=${encodeURIComponent(file.fileName)}` + ); + thumbnail = result.success ? result.thumbnail ?? null : null; + } catch (error) { + console.error(`Failed to load thumbnail for ${file.fileName}:`, error); + } + + if (runId !== thumbnailRunId) { + return; + } + + applyThumbnail(file.fileName, thumbnail); + } +} + +/** Replace a card's loading placeholder with the fetched image or a "No Preview" note. */ +function applyThumbnail(fileName: string, thumbnail: string | null): void { + const fileList = $('file-list'); + if (!fileList) { + return; + } + + const item = Array.from(fileList.querySelectorAll('.file-item')).find( + (el) => el.dataset.filename === fileName + ); + const slot = item?.querySelector('.file-thumbnail'); + if (!slot) { + return; + } + + slot.classList.remove('loading'); + + if (thumbnail) { + const image = document.createElement('img'); + image.src = thumbnail.startsWith('data:image/') + ? thumbnail + : `data:image/png;base64,${thumbnail}`; + image.alt = fileName; + slot.textContent = ''; + slot.appendChild(image); + slot.classList.add('has-image'); + } else { + slot.textContent = 'No Preview'; + slot.classList.add('no-preview'); + } } export function showTemperatureDialog(target: TemperatureTarget): void { @@ -321,6 +404,8 @@ export function setupDialogEventHandlers(handlers: DialogHandlers = {}): void { function closeFileModal(): void { hideElement('file-modal'); state.selectedFile = null; + // Cancel any in-flight thumbnail batch for the now-closed list. + thumbnailRunId++; if (isMaterialMatchingVisible()) { dialogHandlers.onMaterialMatchingClosed?.(); diff --git a/src/webui/static/webui.css b/src/webui/static/webui.css index 58f034a..c7e5fd6 100644 --- a/src/webui/static/webui.css +++ b/src/webui/static/webui.css @@ -1178,8 +1178,9 @@ body { background-color var(--transition-fast), transform var(--transition-fast); display: flex; - flex-direction: column; - gap: 8px; + flex-direction: row; + align-items: center; + gap: 12px; border: 1px solid var(--border-color); } @@ -1194,6 +1195,46 @@ body { color: #ffffff; } +.file-item-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} + +.file-thumbnail { + flex: 0 0 auto; + width: 72px; + height: 72px; + border-radius: 8px; + border: 1px solid var(--border-color); + background-color: var(--card-bg-hover); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 11px; + text-align: center; + color: var(--text-color-secondary); + padding: 4px; +} + +.file-thumbnail img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.file-thumbnail.loading { + opacity: 0.7; +} + +.file-item.selected .file-thumbnail { + border-color: #ffffff; +} + .file-item-header { display: flex; align-items: center; From 4a1a8114a4a2ecda3ab1f2a4371c4228e74f3934 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:40:35 -0400 Subject: [PATCH 05/59] chore(release): 1.2.0-alpha.2 --- CHANGELOG.md | 13 ++++++++++++- package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17cb9d..cc16447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0-alpha.2] - 2026-07-05 + +### Added + +- Thumbnail previews in the Recent/Local file dialog: each file now shows a lazily loaded preview image (with a "No Preview" fallback), backed by a persistent per-printer on-disk cache (`data/Thumbnails/{serial}/`) so re-opening the dialog is instant + +### Fixed + +- TVOC air-quality reading now displays on the filtration card for every printer that reports it (e.g. the Adventurer 5M Pro), instead of only the Creator 5 Pro. The Creator 5 Pro now shows the filtration card with its controls disabled rather than hiding it entirely + ## [1.2.0-alpha.1] - 2026-07-04 ### Added @@ -202,7 +212,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional password authentication - Configuration persistence in `data/config.json` -[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.1...HEAD +[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.2...HEAD +[1.2.0-alpha.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.1...v1.2.0-alpha.2 [1.2.0-alpha.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...v1.2.0-alpha.1 [1.1.0]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.1...v1.0.2 diff --git a/package.json b/package.json index 100d192..6fe6ed0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flashforge-webui", - "version": "1.2.0-alpha.1", + "version": "1.2.0-alpha.2", "description": "Standalone WebUI for FlashForge 3D Printers", "main": "dist/index.js", "bin": "dist/index.js", From f4d0ea66e1319dfef80b997c92718ffd9239afe4 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:32:36 -0400 Subject: [PATCH 06/59] feat: port the SSH toolset from the desktop app - file manager, calibration, SSH settings, and remote reboot - Brings over the desktop app's SSH-based features in one pass: printer file browsing and management, the full calibration assistant, per-printer SSH settings, and one-click printer reboot with live progress - Passwords can be changed but never read back from the browser - Only works on Adventurer 5M / 5M Pro / AD5X printers set up with the EasySSH USB tool; buttons stay hidden on other models - Fixed the calibration window being too narrow and its export button rendering comically large Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +- package-lock.json | 168 +- package.json | 5 + src/managers/CalibrationManager.ts | 668 ++++++++ src/services/FileManagerService.ts | 864 +++++++++++ src/services/PrinterRebootService.ts | 389 +++++ src/services/SSHSettingsService.ts | 190 +++ .../calibration/__tests__/Bed.test.ts | 366 +++++ .../__tests__/DeviationAnalyzer.test.ts | 347 +++++ .../__tests__/FFTProcessor.test.ts | 319 ++++ .../__tests__/KlipperConfigParser.test.ts | 329 ++++ .../calibration/__tests__/ScrewSolver.test.ts | 302 ++++ .../__tests__/ShaperAnalyzer.test.ts | 305 ++++ .../__tests__/TapeCalculator.test.ts | 346 +++++ .../__tests__/WorkflowEngine.test.ts | 381 +++++ src/services/calibration/engine/Bed.ts | 386 +++++ .../calibration/engine/DeviationAnalyzer.ts | 347 +++++ .../calibration/engine/ScrewSolver.ts | 259 ++++ .../calibration/engine/TapeCalculator.ts | 378 +++++ .../calibration/engine/WorkflowEngine.ts | 662 ++++++++ src/services/calibration/engine/index.ts | 18 + src/services/calibration/index.ts | 35 + .../parsers/KlipperConfigParser.ts | 302 ++++ .../calibration/report/ReportRenderer.ts | 261 ++++ .../calibration/shaper/FFTProcessor.ts | 372 +++++ .../calibration/shaper/ShaperAnalyzer.ts | 378 +++++ src/services/calibration/shaper/index.ts | 16 + .../calibration/ssh/SCPFileTransfer.ts | 829 ++++++++++ .../calibration/ssh/SSHConnectionManager.ts | 389 +++++ src/services/calibration/ssh/index.ts | 13 + src/types/calibration.ts | 708 +++++++++ src/types/file-manager.ts | 92 ++ src/types/printer-power.ts | 40 + src/types/ssh-settings.ts | 46 + src/utils/PrinterUtils.ts | 22 + src/utils/SecureStorage.ts | 43 + src/webui/server/WebSocketManager.ts | 19 + src/webui/server/api-routes.ts | 8 + src/webui/server/routes/calibration-routes.ts | 577 +++++++ .../server/routes/file-manager-routes.ts | 187 +++ .../server/routes/printer-power-routes.ts | 52 + .../server/routes/ssh-settings-routes.ts | 134 ++ src/webui/static/app.ts | 40 +- src/webui/static/core/Transport.ts | 24 +- src/webui/static/features/calibration.ts | 1200 ++++++++++++++ .../AnimatedRecommendationVisualizer.ts | 233 +++ .../features/calibration/BedMeshVisualizer.ts | 453 ++++++ .../features/calibration/ColorScales.ts | 135 ++ .../calibration/ShaperPlotVisualizer.ts | 272 ++++ .../static/features/calibration/types.ts | 133 ++ src/webui/static/features/file-manager.ts | 918 +++++++++++ src/webui/static/features/reboot.ts | 225 +++ src/webui/static/features/ssh.ts | 177 +++ src/webui/static/index.html | 495 +++++- src/webui/static/shared/icons.ts | 4 +- src/webui/static/webui.css | 1372 ++++++++++++++++- src/webui/types/web-api.types.ts | 7 +- 57 files changed, 17235 insertions(+), 13 deletions(-) create mode 100644 src/managers/CalibrationManager.ts create mode 100644 src/services/FileManagerService.ts create mode 100644 src/services/PrinterRebootService.ts create mode 100644 src/services/SSHSettingsService.ts create mode 100644 src/services/calibration/__tests__/Bed.test.ts create mode 100644 src/services/calibration/__tests__/DeviationAnalyzer.test.ts create mode 100644 src/services/calibration/__tests__/FFTProcessor.test.ts create mode 100644 src/services/calibration/__tests__/KlipperConfigParser.test.ts create mode 100644 src/services/calibration/__tests__/ScrewSolver.test.ts create mode 100644 src/services/calibration/__tests__/ShaperAnalyzer.test.ts create mode 100644 src/services/calibration/__tests__/TapeCalculator.test.ts create mode 100644 src/services/calibration/__tests__/WorkflowEngine.test.ts create mode 100644 src/services/calibration/engine/Bed.ts create mode 100644 src/services/calibration/engine/DeviationAnalyzer.ts create mode 100644 src/services/calibration/engine/ScrewSolver.ts create mode 100644 src/services/calibration/engine/TapeCalculator.ts create mode 100644 src/services/calibration/engine/WorkflowEngine.ts create mode 100644 src/services/calibration/engine/index.ts create mode 100644 src/services/calibration/index.ts create mode 100644 src/services/calibration/parsers/KlipperConfigParser.ts create mode 100644 src/services/calibration/report/ReportRenderer.ts create mode 100644 src/services/calibration/shaper/FFTProcessor.ts create mode 100644 src/services/calibration/shaper/ShaperAnalyzer.ts create mode 100644 src/services/calibration/shaper/index.ts create mode 100644 src/services/calibration/ssh/SCPFileTransfer.ts create mode 100644 src/services/calibration/ssh/SSHConnectionManager.ts create mode 100644 src/services/calibration/ssh/index.ts create mode 100644 src/types/calibration.ts create mode 100644 src/types/file-manager.ts create mode 100644 src/types/printer-power.ts create mode 100644 src/types/ssh-settings.ts create mode 100644 src/utils/SecureStorage.ts create mode 100644 src/webui/server/routes/calibration-routes.ts create mode 100644 src/webui/server/routes/file-manager-routes.ts create mode 100644 src/webui/server/routes/printer-power-routes.ts create mode 100644 src/webui/server/routes/ssh-settings-routes.ts create mode 100644 src/webui/static/features/calibration.ts create mode 100644 src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts create mode 100644 src/webui/static/features/calibration/BedMeshVisualizer.ts create mode 100644 src/webui/static/features/calibration/ColorScales.ts create mode 100644 src/webui/static/features/calibration/ShaperPlotVisualizer.ts create mode 100644 src/webui/static/features/calibration/types.ts create mode 100644 src/webui/static/features/file-manager.ts create mode 100644 src/webui/static/features/reboot.ts create mode 100644 src/webui/static/features/ssh.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8886c76..61785fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ src/index.ts # Entry point - initializes all singletons, conn - `AD5XBackend` - Adventurer 5M X series (uses AD5X API) - `Adventurer5MBackend` - Adventurer 5M (FiveMClient) - `Adventurer5MProBackend` - Adventurer 5M Pro (FiveMClient) +- `Creator5Backend` - Creator 5 / Creator 5 Pro (HTTP-only connection flow; IFS Material Station support) - `DualAPIBackend` - Base for printers supporting both FiveMClient and FlashForgeClient - `GenericLegacyBackend` - Fallback for older printers (FlashForgeClient only) @@ -173,6 +174,7 @@ src/index.ts # Entry point - initializes all singletons, conn - `WebUIManager` - Express HTTP server and static file serving - `WebSocketManager` - Real-time bidirectional communication with the frontend - `AuthManager` - Optional password authentication +- `CameraStreamProxy` - Authenticated bridge between WebUI clients and the local go2rtc API at `/api/camera/ws`; browsers never reach the go2rtc port directly. `WebUIManager` owns a single HTTP `upgrade` router that dispatches by pathname: `/ws` → `WebSocketManager.handleUpgrade`, `/api/camera/ws` → `CameraStreamProxy.handleUpgrade`, else `socket.destroy()`. - API routes organized by feature (context, printer-control, printer-status, printer-detection, printer-management, job, camera, temperature, filtration, spoolman, theme, discovery) - Frontend uses GridStack for dashboard layout and the bundled `video-rtc` player for go2rtc-backed camera streaming @@ -264,6 +266,7 @@ Some printers support **both** (dual API). The backend system abstracts these di - Adventurer 5M X -> `AD5XBackend` - Adventurer 5M -> `Adventurer5MBackend` - Adventurer 5M Pro -> `Adventurer5MProBackend` +- Creator 5 / Creator 5 Pro -> `Creator5Backend` - Other new-API printers -> backend selected from discovery metadata and `clientType` - Legacy printers -> `GenericLegacyBackend` @@ -350,7 +353,7 @@ class Service extends EventEmitter { 1. **Dual Build System**: Backend uses esbuild bundling, frontend uses a separate `tsconfig` for browser modules. 2. **Data Directory Tracking**: Runtime state lives in `/data/`, but only `data/runtime/` is ignored by the current `.gitignore`. -3. **Camera Streams**: go2rtc manages camera streams per context; there is no user-facing global `CameraProxyPort` setting. +3. **Camera Streams**: go2rtc manages camera streams per context, but browsers never reach the go2rtc port directly — video flows through an authenticated WebUI proxy at `/api/camera/ws` (see `CameraStreamProxy`). Only the WebUI port should be exposed/forwarded; the unauthenticated go2rtc port (default `1984`) must NOT be forwarded. There is no user-facing global `CameraProxyPort` setting. 4. **go2rtc Binary**: The binary is downloaded at `npm install` time and stored under `resources/bin/`. If download or packaging fails, camera streaming will not work. 5. **Polling Frequency**: All contexts poll every 3 seconds to avoid inactive-context TCP keep-alive failures. 6. **Context IDs**: IDs are UUID-based and generated at connection time; they are not tied to IP or serial number. @@ -358,11 +361,12 @@ class Service extends EventEmitter { 8. **Graceful Shutdown**: Shutdown stops polling, Discord, printer connections, go2rtc, and the WebUI with layered timeouts. 9. **Windows Compatibility**: Ctrl+C handling includes a readline bridge on Windows. 10. **ARMv7 Builds**: Raspberry Pi 32-bit builds target `node20-linuxstatic-armv7`, which depends on `@yao-pkg/pkg`. +11. **CRLF vs Biome LF**: The repo is checked out CRLF repo-wide, but `biome.json` sets `lineEnding: "lf"`. As a result `npm run check` (and `lint`/`format`) reports ~30+ format errors across nearly every source file regardless of what changed — this is pre-existing, not a regression from your edit. Only fix lint/format issues you actually introduced. ## Testing Notes **Jest unit/integration tests** (`src/`): -- ConfigManager, EnvironmentService, DiscordNotificationService, error utilities, WebUIManager integration +- ConfigManager, EnvironmentService, DiscordNotificationService, error utilities, WebUIManager integration, `CameraStreamProxy`, and camera routes (via the `test-server.ts` Express fixture helper) **Verified and tested**: - Multi-printer context switching diff --git a/package-lock.json b/package-lock.json index 87a73b2..2ce16b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flashforge-webui", - "version": "1.2.0-alpha.1", + "version": "1.2.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flashforge-webui", - "version": "1.2.0-alpha.1", + "version": "1.2.0-alpha.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -17,6 +17,9 @@ "form-data": "^4.0.0", "gridstack": "^12.3.3", "lucide": "^0.552.0", + "pdf-lib": "^1.17.1", + "pngjs": "^7.0.0", + "ssh2": "^1.17.0", "ws": "^8.18.3", "zod": "^4.0.5" }, @@ -29,6 +32,8 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/node": "^20.17.9", + "@types/pngjs": "^6.0.5", + "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.13", "@yao-pkg/pkg": "^5.15.0", @@ -1888,6 +1893,24 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2118,6 +2141,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2165,6 +2198,33 @@ "@types/node": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2695,6 +2755,15 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2849,6 +2918,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3025,6 +3103,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3380,6 +3467,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5620,6 +5721,13 @@ "node": ">= 6" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -5905,6 +6013,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6007,6 +6121,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6106,6 +6238,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -6845,6 +6986,23 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -7895,6 +8053,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/package.json b/package.json index 6fe6ed0..d982b8c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "form-data": "^4.0.0", "gridstack": "^12.3.3", "lucide": "^0.552.0", + "pdf-lib": "^1.17.1", + "pngjs": "^7.0.0", + "ssh2": "^1.17.0", "ws": "^8.18.3", "zod": "^4.0.5" }, @@ -75,6 +78,8 @@ "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/node": "^20.17.9", + "@types/pngjs": "^6.0.5", + "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.13", "@yao-pkg/pkg": "^5.15.0", diff --git a/src/managers/CalibrationManager.ts b/src/managers/CalibrationManager.ts new file mode 100644 index 0000000..808d581 --- /dev/null +++ b/src/managers/CalibrationManager.ts @@ -0,0 +1,668 @@ +/** + * @fileoverview Central manager for calibration operations. + * Coordinates between parsers, analysis engines, and UI components. + * Provides a unified interface for all calibration functionality. + * + * @module managers/CalibrationManager + */ + +import { getEnvironmentService } from '../services/EnvironmentService'; +import fs from 'fs/promises'; +import path from 'path'; +import type { + AnalysisResult, + BedConfig, + BedWorkspace, + CalibrationHistoryEntry, + CalibrationSettings, + MeshData, + PrinterCalibrationData, + ShaperResult, + StageResult, + WorkflowData, + WorkflowStage, +} from '../types/calibration'; +import { DEFAULT_BED_CONFIG, DEFAULT_CALIBRATION_SETTINGS } from '../types/calibration'; +import { Bed, DeviationAnalyzer, ScrewSolver, TapeCalculator, WorkflowEngine } from '../services/calibration/engine'; +import { KlipperConfigParser } from '../services/calibration/parsers/KlipperConfigParser'; +import { renderHeatmapPNG, renderReportPDF } from '../services/calibration/report/ReportRenderer'; + +type SerializedWorkflowData = Omit & { + stages: Array<[WorkflowStage, StageResult]>; +}; + +/** + * Manager for all calibration operations. + */ +export class CalibrationManager { + /** Singleton instance */ + private static instance: CalibrationManager | null = null; + + /** Parser for Klipper config files */ + private readonly configParser: KlipperConfigParser; + + /** Per-printer workspaces */ + private readonly workspaces: Map; + + /** Global calibration settings */ + private settings: CalibrationSettings; + + /** Per-printer calibration data */ + private readonly printerData: Map; + + /** Base path for calibration data storage */ + private readonly dataPath: string; + + /** Whether manager has been initialized */ + private initialized = false; + + /** + * Private constructor for singleton pattern. + */ + private constructor() { + this.configParser = new KlipperConfigParser(); + this.workspaces = new Map(); + this.printerData = new Map(); + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + this.dataPath = path.join(getEnvironmentService().getDataPath(), 'calibration'); + } + + /** + * Get the singleton instance. + */ + static getInstance(): CalibrationManager { + if (!CalibrationManager.instance) { + CalibrationManager.instance = new CalibrationManager(); + } + return CalibrationManager.instance; + } + + /** + * Initialize the calibration manager. + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + // Ensure data directories exist + await fs.mkdir(this.dataPath, { recursive: true }); + await fs.mkdir(path.join(this.dataPath, 'printers'), { recursive: true }); + + // Load global settings + await this.loadSettings(); + + this.initialized = true; + } + + /** + * Load global calibration settings from disk. + */ + private async loadSettings(): Promise { + const settingsPath = path.join(this.dataPath, 'settings.json'); + + try { + const data = await fs.readFile(settingsPath, 'utf-8'); + const loaded = JSON.parse(data) as Partial; + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS, ...loaded }; + } catch { + // Use defaults if file doesn't exist or is invalid + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + } + } + + /** + * Save global calibration settings to disk. + */ + private async saveSettings(): Promise { + const settingsPath = path.join(this.dataPath, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(this.settings, null, 2)); + } + + /** + * Get per-printer storage paths. + */ + private getPrinterPaths(contextId: string): { + baseDir: string; + historyPath: string; + lastMeshPath: string; + shaperXPath: string; + shaperYPath: string; + sshPath: string; + exportsDir: string; + legacyPath: string; + } { + const baseDir = path.join(this.dataPath, 'printers', contextId); + return { + baseDir, + historyPath: path.join(baseDir, 'history.json'), + lastMeshPath: path.join(baseDir, 'last-mesh.json'), + shaperXPath: path.join(baseDir, 'shaper-x.json'), + shaperYPath: path.join(baseDir, 'shaper-y.json'), + sshPath: path.join(baseDir, 'ssh.json'), + exportsDir: path.join(baseDir, 'exports'), + legacyPath: path.join(baseDir, 'calibration-data.json'), + }; + } + + /** + * Ensure printer directories exist. + */ + private async ensurePrinterDirs(contextId: string): Promise { + const paths = this.getPrinterPaths(contextId); + await fs.mkdir(paths.baseDir, { recursive: true }); + await fs.mkdir(paths.exportsDir, { recursive: true }); + } + + private async readJSONFile(filePath: string): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data) as T; + } catch { + return null; + } + } + + private async writeJSONFile(filePath: string, data: unknown): Promise { + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + } + + private serializeWorkflowData(data: WorkflowData): SerializedWorkflowData { + return { + ...data, + stages: Array.from(data.stages.entries()), + }; + } + + private deserializeWorkflowData(data: WorkflowData | SerializedWorkflowData): WorkflowData { + if (data.stages instanceof Map) { + return data as WorkflowData; + } + const stages = new Map(data.stages); + return { + ...(data as WorkflowData), + stages, + }; + } + + private serializeHistoryEntries(entries: CalibrationHistoryEntry[]): CalibrationHistoryEntry[] { + return entries.map((entry) => { + const workflow = entry.data as WorkflowData | undefined; + if (workflow?.stages instanceof Map) { + const serialized = this.serializeWorkflowData(workflow); + return { + ...entry, + data: serialized as unknown as WorkflowData, + }; + } + return entry; + }); + } + + private deserializeHistoryEntries(entries: CalibrationHistoryEntry[]): CalibrationHistoryEntry[] { + return entries.map((entry) => { + const data = entry.data as WorkflowData | SerializedWorkflowData | undefined; + if (data && (data as SerializedWorkflowData).stages && Array.isArray((data as SerializedWorkflowData).stages)) { + return { + ...entry, + data: this.deserializeWorkflowData(data) as WorkflowData, + }; + } + return entry; + }); + } + + /** + * Get current calibration settings. + */ + getSettings(): CalibrationSettings { + return { ...this.settings }; + } + + /** + * Update calibration settings. + */ + async updateSettings(settings: Partial): Promise { + this.settings = { ...this.settings, ...settings }; + await this.saveSettings(); + } + + /** + * Get or create a workspace for a printer context. + */ + getWorkspace(contextId: string): BedWorkspace | undefined { + return this.workspaces.get(contextId); + } + + /** + * Create a new workspace for a printer context. + */ + createWorkspace(contextId: string, meshData?: MeshData): BedWorkspace { + const bedConfig: BedConfig = meshData + ? { + sizeX: meshData.maxX - meshData.minX, + sizeY: meshData.maxY - meshData.minY, + meshPointsX: meshData.pointsX, + meshPointsY: meshData.pointsY, + } + : { ...DEFAULT_BED_CONFIG }; + + const workspace: BedWorkspace = { + contextId, + meshData: meshData || null, + bedConfig, + analysis: null, + workflow: null, + isDirty: false, + }; + + this.workspaces.set(contextId, workspace); + return workspace; + } + + /** + * Clear a workspace. + */ + clearWorkspace(contextId: string): void { + this.workspaces.delete(contextId); + } + + /** + * Parse a Klipper config file and extract mesh data. + */ + parseConfigFile(content: string, profileName = 'default'): MeshData | null { + const result = this.configParser.parseConfigFile(content, profileName); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * Get available mesh profiles from a config file. + */ + getAvailableProfiles(content: string): string[] { + return this.configParser.getAvailableProfiles(content); + } + + /** + * Load mesh data into a workspace from config content. + */ + loadMeshFromConfig(contextId: string, configContent: string, profileName = 'default'): BedWorkspace | null { + const meshData = this.parseConfigFile(configContent, profileName); + if (!meshData) { + return null; + } + + // Validate mesh data + const validation = this.configParser.validateMeshData(meshData); + if (!validation.isValid) { + console.error('Invalid mesh data:', validation.error); + return null; + } + + // Create or update workspace + let workspace = this.workspaces.get(contextId); + if (!workspace) { + workspace = this.createWorkspace(contextId, meshData); + } else { + workspace.meshData = meshData; + workspace.bedConfig = this.configParser.createBedConfigFromMesh(meshData); + workspace.isDirty = true; + } + + return workspace; + } + + /** + * Analyze the current mesh in a workspace. + */ + analyzeMesh(contextId: string): AnalysisResult | null { + const workspace = this.workspaces.get(contextId); + if (!workspace?.meshData) { + return null; + } + + const bed = new Bed(workspace.bedConfig); + bed.setMeshData(workspace.meshData.matrix); + + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: this.settings.hardware.cornerAveraging, + screwThreshold: this.settings.thresholds.screwThreshold, + tapeThreshold: this.settings.thresholds.tapeThreshold, + beltThreshold: this.settings.thresholds.beltThreshold, + screwConfig: { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }, + }); + + const analysis = analyzer.analyze(); + workspace.analysis = analysis; + + return analysis; + } + + /** + * Compute the full calibration workflow for a workspace. + */ + computeWorkflow(contextId: string): WorkflowData | null { + const workspace = this.workspaces.get(contextId); + if (!workspace?.meshData) { + return null; + } + + const bed = new Bed(workspace.bedConfig); + bed.setMeshData(workspace.meshData.matrix); + + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: this.settings.hardware.cornerAveraging, + screwThreshold: this.settings.thresholds.screwThreshold, + tapeThreshold: this.settings.thresholds.tapeThreshold, + beltThreshold: this.settings.thresholds.beltThreshold, + screwConfig: { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }, + }); + + const screwSolver = new ScrewSolver(bed, { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }); + + const tapeCalculator = new TapeCalculator(bed, { + tapeThickness: this.settings.hardware.tapeThickness, + minHeightDiff: this.settings.thresholds.tapeThreshold, + }); + + const workflowEngine = new WorkflowEngine(bed, screwSolver, tapeCalculator, this.settings); + + const workflow = workflowEngine.computeWorkflow(); + workspace.workflow = workflow; + workspace.analysis = analyzer.analyze(); + + return workflow; + } + + /** + * Get printer-specific calibration data. + */ + async getPrinterData(contextId: string): Promise { + // Check cache first + const cached = this.printerData.get(contextId); + if (cached) { + return cached; + } + + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + + // Legacy migration + const legacyData = await this.readJSONFile(paths.legacyPath); + if (legacyData) { + await this.savePrinterData(contextId, legacyData); + try { + await fs.rename(paths.legacyPath, path.join(paths.baseDir, 'calibration-data.legacy.json')); + } catch { + // Ignore migration cleanup errors + } + return this.printerData.get(contextId) || {}; + } + + const [sshData, historyData, lastMesh, shaperX, shaperY] = await Promise.all([ + this.readJSONFile(paths.sshPath), + this.readJSONFile(paths.historyPath), + this.readJSONFile(paths.lastMeshPath), + this.readJSONFile(paths.shaperXPath), + this.readJSONFile(paths.shaperYPath), + ]); + + const data: PrinterCalibrationData = { + ...(sshData || {}), + lastBedMesh: lastMesh || undefined, + inputShaperX: shaperX || undefined, + inputShaperY: shaperY || undefined, + calibrationHistory: historyData ? this.deserializeHistoryEntries(historyData) : undefined, + }; + + this.printerData.set(contextId, data); + return data; + } + + /** + * Save printer-specific calibration data. + */ + async savePrinterData(contextId: string, data: PrinterCalibrationData): Promise { + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + + const sshPayload: PrinterCalibrationData = { + sshConfigPath: data.sshConfigPath, + }; + + const sshHasValues = Object.values(sshPayload).some((value) => value !== undefined && value !== ''); + if (sshHasValues) { + await this.writeJSONFile(paths.sshPath, sshPayload); + } else { + await fs.rm(paths.sshPath, { force: true }); + } + + if (data.calibrationHistory && data.calibrationHistory.length > 0) { + const serializedHistory = this.serializeHistoryEntries(data.calibrationHistory); + await this.writeJSONFile(paths.historyPath, serializedHistory); + } else { + await fs.rm(paths.historyPath, { force: true }); + } + + if (data.lastBedMesh) { + await this.writeJSONFile(paths.lastMeshPath, data.lastBedMesh); + } else { + await fs.rm(paths.lastMeshPath, { force: true }); + } + + if (data.inputShaperX) { + await this.writeJSONFile(paths.shaperXPath, data.inputShaperX); + } else { + await fs.rm(paths.shaperXPath, { force: true }); + } + + if (data.inputShaperY) { + await this.writeJSONFile(paths.shaperYPath, data.inputShaperY); + } else { + await fs.rm(paths.shaperYPath, { force: true }); + } + + this.printerData.set(contextId, data); + } + + /** + * Add a calibration history entry for a printer. + */ + async addHistoryEntry( + contextId: string, + type: 'bed_level' | 'input_shaper', + summary: string, + data: WorkflowData | unknown + ): Promise { + const printerData = await this.getPrinterData(contextId); + + const entry: CalibrationHistoryEntry = { + timestamp: Date.now(), + type, + summary, + data: data as WorkflowData, + }; + + if (!printerData.calibrationHistory) { + printerData.calibrationHistory = []; + } + + // Add new entry at the beginning + printerData.calibrationHistory.unshift(entry); + + // Trim to max entries + const maxEntries = this.settings.history.maxEntries; + if (printerData.calibrationHistory.length > maxEntries) { + printerData.calibrationHistory = printerData.calibrationHistory.slice(0, maxEntries); + } + + await this.savePrinterData(contextId, printerData); + } + + /** + * Get calibration history for a printer. + */ + async getHistory(contextId: string): Promise { + const printerData = await this.getPrinterData(contextId); + return printerData.calibrationHistory || []; + } + + /** + * Clear calibration history for a printer. + */ + async clearHistory(contextId: string): Promise { + const printerData = await this.getPrinterData(contextId); + printerData.calibrationHistory = []; + await this.savePrinterData(contextId, printerData); + } + + /** + * Persist the latest bed mesh analysis for a printer. + */ + async saveLastBedMesh(contextId: string, matrix: number[][], analysis: AnalysisResult): Promise { + const printerData = await this.getPrinterData(contextId); + printerData.lastBedMesh = { + timestamp: Date.now(), + matrix, + analysis, + }; + await this.savePrinterData(contextId, printerData); + } + + /** + * Persist a recommended input shaper result for a printer. + */ + async saveShaperResult(contextId: string, axis: 'x' | 'y', result: ShaperResult): Promise { + const printerData = await this.getPrinterData(contextId); + if (axis === 'x') { + printerData.inputShaperX = result; + } else { + printerData.inputShaperY = result; + } + await this.savePrinterData(contextId, printerData); + } + + /** + * Export calibration report. + */ + async exportReport(contextId: string, format: 'json' | 'csv' | 'png' | 'pdf'): Promise { + const workspace = this.workspaces.get(contextId); + if (!workspace || !workspace.meshData) { + throw new Error('No workspace found for context'); + } + + if (!workspace.analysis) { + this.analyzeMesh(contextId); + } + + if (format === 'json') { + return JSON.stringify( + { + contextId, + timestamp: Date.now(), + meshData: workspace.meshData, + analysis: workspace.analysis, + workflow: workspace.workflow + ? { + initialRange: workspace.workflow.initialRange, + finalRange: workspace.workflow.finalRange, + improvementPercent: workspace.workflow.improvementPercent, + screwAdjustments: workspace.workflow.screwAdjustments, + tapeRecommendations: workspace.workflow.tapeRecommendations, + } + : null, + settings: this.settings, + }, + null, + 2 + ); + } + + if (format === 'csv') { + // Export mesh as CSV + const lines: string[] = []; + lines.push('# Calibration Report'); + lines.push(`# Context: ${contextId}`); + lines.push(`# Timestamp: ${new Date().toISOString()}`); + lines.push(''); + lines.push('# Mesh Data'); + lines.push(`# X: ${workspace.meshData.minX} to ${workspace.meshData.maxX}`); + lines.push(`# Y: ${workspace.meshData.minY} to ${workspace.meshData.maxY}`); + lines.push(''); + + // Header row + const cols = workspace.meshData.pointsX; + const headerRow = Array.from({ length: cols }, (_, i) => `X${i}`).join(','); + lines.push(`Row,${headerRow}`); + + // Data rows + for (let row = 0; row < workspace.meshData.matrix.length; row++) { + const rowData = workspace.meshData.matrix[row].map((v) => v.toFixed(4)).join(','); + lines.push(`${row},${rowData}`); + } + + return lines.join('\n'); + } + + if (format === 'png') { + return renderHeatmapPNG(workspace.meshData, { + colorScheme: this.settings.visualization.colorScheme, + cellSize: 24, + padding: 16, + showGrid: true, + }); + } + + if (format === 'pdf') { + return renderReportPDF({ + contextId, + meshData: workspace.meshData, + analysis: workspace.analysis, + workflow: workspace.workflow, + settings: this.settings, + }); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Get default export path for a calibration report. + */ + async getDefaultExportPath(contextId: string, format: 'json' | 'csv' | 'png' | 'pdf'): Promise { + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + return path.join(paths.exportsDir, `report-${Date.now()}.${format}`); + } + + /** + * Shutdown and cleanup. + */ + async shutdown(): Promise { + await this.saveSettings(); + this.workspaces.clear(); + this.printerData.clear(); + this.initialized = false; + } +} + +/** + * Get the CalibrationManager singleton instance. + */ +export function getCalibrationManager(): CalibrationManager { + return CalibrationManager.getInstance(); +} diff --git a/src/services/FileManagerService.ts b/src/services/FileManagerService.ts new file mode 100644 index 0000000..5592a04 --- /dev/null +++ b/src/services/FileManagerService.ts @@ -0,0 +1,864 @@ +/** + * @fileoverview SFTP-backed file management service for FlashForge printers. + * + * Provides listing, deletion, renaming, and thumbnail retrieval for print files + * stored on the printer's internal flash and on plugged-in USB drives. Works over + * the SSH/SFTP access provisioned by the flashforge-easyssh USB script: + * - AD5X: files in /usr/data/gcodes, thumbnail caches in /usr/data/gcodes/thum + * and /usr/data/gcodes/3mf/thum, USB drives mounted under /mnt. + * - Adventurer 5M / 5M Pro: files in /data (flat), thumbnails in + * /data/uploadThumbnail, USB drives mounted under /mnt/usb. + * + * Thumbnail resolution order: local ThumbnailCacheService -> printer-side + * thumbnail cache PNGs -> ranged-read extraction of Metadata/plate_1(_small).png + * from .3mf archives (OrcaSlicer zips) without downloading the whole file. + * Fetched thumbnails are persisted into the shared ThumbnailCacheService so the + * job picker and WebUI benefit from the same cache. + * + * Connections are pooled through the shared SSHConnectionManager under + * 'file-manager:' keys, using the centralized per-printer SSH + * settings store (SSHSettingsService; easyssh defaults root / flashforge). + * + * Key exports: + * - FileManagerService / getFileManagerService(): singleton service + * - FileManagerTarget: printer identity required for operations + */ + +import * as zlib from 'zlib'; +import type { SFTPWrapper } from 'ssh2'; +import type { + FileManagerCapabilities, + FileManagerDeleteOutcome, + FileManagerDeleteResult, + FileManagerListing, + FileManagerRenameResult, + FileManagerStorageKind, + FileManagerThumbnailResult, + PrinterFileEntry, +} from '../types/file-manager'; +import { PRINTABLE_FILE_EXTENSIONS } from '../types/file-manager'; +import type { PrinterModelType } from '../types/printer-backend'; +import { getSSHConnectionManager } from './calibration/ssh'; +import { getSSHSettingsService } from './SSHSettingsService'; +import { getThumbnailCacheService } from './ThumbnailCacheService'; + +/** Printer identity needed to perform file manager operations. */ +export interface FileManagerTarget { + readonly contextId: string; + readonly ipAddress: string; + readonly serialNumber: string; + readonly printerName: string; + readonly modelType?: PrinterModelType; +} + +/** Per-family filesystem layout on the printer. */ +interface FamilyProfile { + /** Directory holding printable files on internal storage. */ + readonly internalRoot: string; + /** Printer-side thumbnail cache directories (checked in order). */ + readonly thumbnailDirs: readonly string[]; + /** Base directory USB drives are mounted under. */ + readonly usbBase: string; +} + +const FIVE_M_PROFILE: FamilyProfile = { + internalRoot: '/data', + thumbnailDirs: ['/data/uploadThumbnail'], + usbBase: '/mnt/usb', +}; + +const FAMILY_PROFILES: Partial> = { + ad5x: { + internalRoot: '/usr/data/gcodes', + thumbnailDirs: ['/usr/data/gcodes/thum', '/usr/data/gcodes/3mf/thum'], + usbBase: '/mnt', + }, + 'adventurer-5m': FIVE_M_PROFILE, + 'adventurer-5m-pro': FIVE_M_PROFILE, +}; + +/** Extensions surfaced in listings (lowercase, with dot). */ +const PRINTABLE_EXTENSIONS = new Set(PRINTABLE_FILE_EXTENSIONS); + +/** Filesystem types that are never USB drives. */ +const NON_USB_FS_TYPES = new Set([ + 'proc', + 'sysfs', + 'devtmpfs', + 'devpts', + 'tmpfs', + 'functionfs', + 'cgroup', + 'cgroup2', + 'debugfs', + 'configfs', + 'overlay', + 'squashfs', + 'ramfs', +]); + +/** Skip .3mf thumbnail extraction when the embedded PNG is unreasonably large. */ +const MAX_EMBEDDED_THUMBNAIL_BYTES = 5 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Promisified SFTP helpers +// --------------------------------------------------------------------------- + +interface SftpDirEntry { + readonly filename: string; + readonly longname: string; + readonly attrs: { readonly size: number; readonly mtime: number }; +} + +function sftpReaddir(sftp: SFTPWrapper, dir: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(dir, (err, list) => (err ? reject(err) : resolve(list as unknown as SftpDirEntry[]))); + }); +} + +function sftpReadFile(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(path, (err: Error | undefined, data: Buffer) => (err ? reject(err) : resolve(data))); + }); +} + +function sftpUnlink(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(path, (err) => (err ? reject(err) : resolve())); + }); +} + +function sftpRename(sftp: SFTPWrapper, oldPath: string, newPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve())); + }); +} + +function sftpStatSize(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve) => { + sftp.stat(path, (err, stats) => resolve(err ? null : stats.size)); + }); +} + +function sftpExists(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve) => { + sftp.stat(path, (err) => resolve(!err)); + }); +} + +/** Read an exact byte range from a remote file. */ +function sftpReadRange(sftp: SFTPWrapper, path: string, start: number, length: number): Promise { + return new Promise((resolve, reject) => { + sftp.open(path, 'r', (openErr, handle) => { + if (openErr) { + reject(openErr); + return; + } + + const buffer = Buffer.alloc(length); + let filled = 0; + + const finish = (error: Error | null): void => { + sftp.close(handle, () => { + if (error) { + reject(error); + } else { + resolve(buffer.subarray(0, filled)); + } + }); + }; + + const readChunk = (): void => { + if (filled >= length) { + finish(null); + return; + } + sftp.read(handle, buffer, filled, length - filled, start + filled, (readErr, bytesRead) => { + if (readErr) { + finish(readErr); + return; + } + if (bytesRead <= 0) { + finish(null); // EOF + return; + } + filled += bytesRead; + readChunk(); + }); + }; + + readChunk(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Minimal remote-zip reader (for extracting thumbnails from .3mf archives) +// --------------------------------------------------------------------------- + +interface ZipEntry { + readonly name: string; + readonly compressionMethod: number; + readonly compressedSize: number; + readonly localHeaderOffset: number; +} + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_DIR_SIGNATURE = 0x02014b50; +const LOCAL_HEADER_SIGNATURE = 0x04034b50; +/** EOCD record is 22 bytes plus up to 65535 bytes of zip comment. */ +const EOCD_SEARCH_SPAN = 22 + 65535; + +/** Parse the central directory of a remote zip using ranged reads. */ +async function readRemoteZipEntries(sftp: SFTPWrapper, path: string, fileSize: number): Promise { + const tailLength = Math.min(fileSize, EOCD_SEARCH_SPAN); + const tail = await sftpReadRange(sftp, path, fileSize - tailLength, tailLength); + + let eocdOffset = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === EOCD_SIGNATURE) { + eocdOffset = i; + break; + } + } + if (eocdOffset < 0) { + throw new Error('Not a zip archive (no end-of-central-directory record)'); + } + + const centralDirSize = tail.readUInt32LE(eocdOffset + 12); + const centralDirOffset = tail.readUInt32LE(eocdOffset + 16); + if (centralDirOffset === 0xffffffff || centralDirSize === 0xffffffff) { + throw new Error('Zip64 archives are not supported for thumbnail extraction'); + } + + const centralDir = await sftpReadRange(sftp, path, centralDirOffset, centralDirSize); + const entries: ZipEntry[] = []; + let cursor = 0; + + while (cursor + 46 <= centralDir.length) { + if (centralDir.readUInt32LE(cursor) !== CENTRAL_DIR_SIGNATURE) { + break; + } + const compressionMethod = centralDir.readUInt16LE(cursor + 10); + const compressedSize = centralDir.readUInt32LE(cursor + 20); + const nameLength = centralDir.readUInt16LE(cursor + 28); + const extraLength = centralDir.readUInt16LE(cursor + 30); + const commentLength = centralDir.readUInt16LE(cursor + 32); + const localHeaderOffset = centralDir.readUInt32LE(cursor + 42); + const name = centralDir.toString('utf8', cursor + 46, cursor + 46 + nameLength); + + entries.push({ name, compressionMethod, compressedSize, localHeaderOffset }); + cursor += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +/** Promise wrapper around zlib.inflateRaw for non-blocking decompression. */ +function inflateRawAsync(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + zlib.inflateRaw(buffer, (err, result) => (err ? reject(err) : resolve(result))); + }); +} + +/** Extract and decompress a single entry from a remote zip. */ +async function extractRemoteZipEntry(sftp: SFTPWrapper, path: string, entry: ZipEntry): Promise { + const localHeader = await sftpReadRange(sftp, path, entry.localHeaderOffset, 30); + if (localHeader.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { + throw new Error('Corrupt zip local header'); + } + const nameLength = localHeader.readUInt16LE(26); + const extraLength = localHeader.readUInt16LE(28); + const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength; + + const compressed = await sftpReadRange(sftp, path, dataStart, entry.compressedSize); + if (entry.compressionMethod === 0) { + return compressed; // stored + } + if (entry.compressionMethod === 8) { + return inflateRawAsync(compressed); // deflate + } + throw new Error(`Unsupported zip compression method: ${entry.compressionMethod}`); +} + +/** Pick the best thumbnail entry from a .3mf archive (OrcaSlicer layout). */ +function pickThumbnailEntry(entries: readonly ZipEntry[]): ZipEntry | null { + const patterns = [ + /^Metadata\/plate_1_small\.png$/i, + /^Metadata\/plate_\d+_small\.png$/i, + /^Metadata\/plate_1\.png$/i, + /^Metadata\/plate_\d+\.png$/i, + /^Metadata\/thumbnail\.png$/i, + ]; + for (const pattern of patterns) { + const match = entries.find((entry) => pattern.test(entry.name)); + if (match && match.compressedSize <= MAX_EMBEDDED_THUMBNAIL_BYTES) { + return match; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Path / name helpers +// --------------------------------------------------------------------------- + +function baseName(remotePath: string): string { + const idx = remotePath.lastIndexOf('/'); + return idx >= 0 ? remotePath.slice(idx + 1) : remotePath; +} + +function dirName(remotePath: string): string { + const idx = remotePath.lastIndexOf('/'); + return idx > 0 ? remotePath.slice(0, idx) : '/'; +} + +function joinRemote(dir: string, name: string): string { + return dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`; +} + +function fileExtension(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.slice(idx).toLowerCase() : ''; +} + +function isPrintableFile(name: string): boolean { + return PRINTABLE_EXTENSIONS.has(fileExtension(name)); +} + +/** Strip the final printable extension for thumbnail stem candidates. */ +function thumbnailStem(name: string): string { + return name.replace(/\.(3mf|gcode|gx|g)$/i, ''); +} + +/** Validate a user-supplied file name (single path segment, no traversal). */ +function isSafeFileName(name: string): boolean { + if (!name || name === '.' || name === '..') return false; + if (name.length > 200) return false; + if (name.includes('/') || name.includes('\\')) return false; + // Disallow control characters + for (let i = 0; i < name.length; i++) { + if (name.charCodeAt(i) < 0x20) return false; + } + return true; +} + +/** Ensure a path is the given root or nested under it. */ +function isUnderRoot(path: string, root: string): boolean { + if (!path.startsWith('/') || path.includes('..')) return false; + const normalizedRoot = root.endsWith('/') ? root.slice(0, -1) : root; + return path === normalizedRoot || path.startsWith(`${normalizedRoot}/`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * SFTP-based file management for printers provisioned with flashforge-easyssh. + */ +export class FileManagerService { + private static instance: FileManagerService | null = null; + + /** Cached SFTP sessions keyed by SSH connection key. */ + private readonly sftpSessions = new Map(); + + private constructor() { + const sshManager = getSSHConnectionManager(); + sshManager.on('connection-closed', (id: string) => { + if (id.startsWith('file-manager:')) { + this.sftpSessions.delete(id); + } + }); + } + + public static getInstance(): FileManagerService { + if (!FileManagerService.instance) { + FileManagerService.instance = new FileManagerService(); + } + return FileManagerService.instance; + } + + /** Whether the model family supports SFTP file management. */ + public static isModelSupported(modelType?: PrinterModelType): boolean { + return !!modelType && !!FAMILY_PROFILES[modelType]; + } + + private getProfile(target: FileManagerTarget): FamilyProfile { + const profile = target.modelType ? FAMILY_PROFILES[target.modelType] : undefined; + if (!profile) { + throw new Error(`File management is not supported for this printer model (${target.modelType ?? 'unknown'})`); + } + return profile; + } + + private connectionKey(target: FileManagerTarget): string { + return `file-manager:${target.contextId}`; + } + + /** Ensure an SSH connection exists for the target and return its key. */ + private async ensureConnection(target: FileManagerTarget): Promise { + const sshManager = getSSHConnectionManager(); + const key = this.connectionKey(target); + + if (sshManager.isConnected(key)) { + sshManager.touch(key); + return key; + } + + this.sftpSessions.delete(key); + // Credentials come from the centralized per-printer SSH settings store + const config = await getSSHSettingsService().buildConnectionConfig(target.serialNumber, target.ipAddress); + await sshManager.connect(key, config); + return key; + } + + /** Get (or open) an SFTP session on the pooled SSH connection. */ + private async getSftp(target: FileManagerTarget): Promise { + const key = await this.ensureConnection(target); + const cached = this.sftpSessions.get(key); + if (cached) { + return cached; + } + + const connection = getSSHConnectionManager().getConnection(key); + if (!connection) { + throw new Error('SSH connection unavailable'); + } + + const sftp = await new Promise((resolve, reject) => { + connection.client.sftp((err, session) => (err ? reject(err) : resolve(session))); + }); + + sftp.on('close', () => { + if (this.sftpSessions.get(key) === sftp) { + this.sftpSessions.delete(key); + } + }); + + this.sftpSessions.set(key, sftp); + return sftp; + } + + /** Run an SFTP operation, retrying once with a fresh connection on failure. */ + private async withSftp(target: FileManagerTarget, operation: (sftp: SFTPWrapper) => Promise): Promise { + try { + const sftp = await this.getSftp(target); + return await operation(sftp); + } catch (error) { + // Session may be stale (printer rebooted, connection dropped) - reconnect once + const key = this.connectionKey(target); + this.sftpSessions.delete(key); + await getSSHConnectionManager().disconnect(key); + const sftp = await this.getSftp(target); + console.warn(`[FileManager] Retried SFTP operation after error: ${errorMessage(error)}`); + return await operation(sftp); + } + } + + /** Detect USB drive mount points by parsing /proc/mounts. */ + private async detectUsbMounts(target: FileManagerTarget, profile: FamilyProfile): Promise { + const key = await this.ensureConnection(target); + const result = await getSSHConnectionManager().executeCommand(key, 'cat /proc/mounts'); + if (!result.success) { + throw new Error(result.error || result.stderr || 'Failed to read /proc/mounts'); + } + + const usbBase = profile.usbBase.endsWith('/') ? profile.usbBase.slice(0, -1) : profile.usbBase; + const mounts: string[] = []; + + for (const line of result.stdout.split('\n')) { + const fields = line.trim().split(/\s+/); + if (fields.length < 3) continue; + // /proc/mounts escapes spaces in mount points as \040 + const mountPoint = fields[1].replace(/\\040/g, ' ').replace(/\\011/g, '\t'); + const fsType = fields[2]; + if (NON_USB_FS_TYPES.has(fsType)) continue; + if (mountPoint === usbBase || mountPoint.startsWith(`${usbBase}/`)) { + mounts.push(mountPoint); + } + } + + // De-duplicate while preserving order (bind mounts can repeat) + return Array.from(new Set(mounts)); + } + + /** + * Probe printer support and USB presence. + */ + public async getCapabilities(target: FileManagerTarget): Promise { + if (!FileManagerService.isModelSupported(target.modelType)) { + return { + supported: false, + reason: + 'File management requires SSH/SFTP access, which is available on AD5X and Adventurer 5M series printers provisioned with the FlashForge easy-SSH setup.', + printerName: target.printerName, + usbPresent: false, + usbMounts: [], + }; + } + + const profile = this.getProfile(target); + try { + const usbMounts = await this.detectUsbMounts(target, profile); + return { + supported: true, + printerName: target.printerName, + usbPresent: usbMounts.length > 0, + usbMounts, + }; + } catch (error) { + return { + supported: true, + printerName: target.printerName, + usbPresent: false, + usbMounts: [], + error: `Could not reach the printer over SSH: ${errorMessage(error)}`, + }; + } + } + + /** + * List printable files (and, on USB, directories) at a storage location. + * + * @param path - Absolute directory to list. Empty string means the storage + * root (internal root, the sole USB mount, or a synthetic + * listing of mounts when several USB drives are present). + */ + public async listFiles( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string + ): Promise { + try { + const profile = this.getProfile(target); + + if (storage === 'internal') { + const root = profile.internalRoot; + const entries = await this.withSftp(target, async (sftp) => { + const list = await sftpReaddir(sftp, root); + return list + .filter((entry) => !entry.longname.startsWith('d') && isPrintableFile(entry.filename)) + .map( + (entry): PrinterFileEntry => ({ + name: entry.filename, + path: joinRemote(root, entry.filename), + size: entry.attrs.size, + modifiedAt: entry.attrs.mtime, + isDirectory: false, + }) + ); + }); + return { success: true, storage, path: root, rootPath: root, entries: sortEntries(entries) }; + } + + // USB storage + const usbMounts = await this.detectUsbMounts(target, profile); + if (usbMounts.length === 0) { + return { success: false, storage, path: '', rootPath: '', entries: [], error: 'No USB drive detected' }; + } + + // Multiple drives with no explicit path: synthesize a root listing of mounts + if (!path && usbMounts.length > 1) { + const entries = usbMounts.map( + (mount): PrinterFileEntry => ({ + name: baseName(mount) || mount, + path: mount, + size: 0, + modifiedAt: 0, + isDirectory: true, + }) + ); + return { success: true, storage, path: '', rootPath: '', entries }; + } + + const listDir = path || usbMounts[0]; + const withinMount = usbMounts.some((mount) => isUnderRoot(listDir, mount)); + if (!withinMount) { + return { success: false, storage, path: listDir, rootPath: '', entries: [], error: 'Invalid USB path' }; + } + const rootPath = usbMounts.length > 1 ? '' : usbMounts[0]; + + const entries = await this.withSftp(target, async (sftp) => { + const list = await sftpReaddir(sftp, listDir); + return list + .filter((entry) => { + if (entry.filename.startsWith('.')) return false; + const isDir = entry.longname.startsWith('d'); + return isDir || isPrintableFile(entry.filename); + }) + .map( + (entry): PrinterFileEntry => ({ + name: entry.filename, + path: joinRemote(listDir, entry.filename), + size: entry.attrs.size, + modifiedAt: entry.attrs.mtime, + isDirectory: entry.longname.startsWith('d'), + }) + ); + }); + + return { success: true, storage, path: listDir, rootPath, entries: sortEntries(entries) }; + } catch (error) { + return { success: false, storage, path, rootPath: '', entries: [], error: errorMessage(error) }; + } + } + + /** + * Delete one or more files. For internal storage the printer-side sidecar + * thumbnails are removed too, and the local thumbnail cache is invalidated. + */ + public async deleteFiles( + target: FileManagerTarget, + storage: FileManagerStorageKind, + paths: readonly string[] + ): Promise { + try { + const profile = this.getProfile(target); + const outcomes: FileManagerDeleteOutcome[] = []; + + await this.withSftp(target, async (sftp) => { + for (const path of paths) { + const validationError = await this.validateMutationPath(target, profile, storage, path); + if (validationError) { + outcomes.push({ path, success: false, error: validationError }); + continue; + } + + try { + await sftpUnlink(sftp, path); + if (storage === 'internal') { + await this.deleteSidecarThumbnails(sftp, profile, baseName(path)); + } + await getThumbnailCacheService().invalidate(target.serialNumber, baseName(path)); + outcomes.push({ path, success: true }); + } catch (error) { + outcomes.push({ path, success: false, error: errorMessage(error) }); + } + } + }); + + return { success: outcomes.every((outcome) => outcome.success), outcomes }; + } catch (error) { + return { success: false, outcomes: [], error: errorMessage(error) }; + } + } + + /** + * Rename a file in place (same directory, same extension). Printer-side + * sidecar thumbnails follow the rename and the local cache entry is migrated. + */ + public async renameFile( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string, + newName: string + ): Promise { + try { + const profile = this.getProfile(target); + + const validationError = await this.validateMutationPath(target, profile, storage, path); + if (validationError) { + return { success: false, error: validationError }; + } + if (!isSafeFileName(newName)) { + return { success: false, error: 'Invalid file name' }; + } + + const oldName = baseName(path); + if (fileExtension(newName) !== fileExtension(oldName)) { + return { success: false, error: `File extension must remain ${fileExtension(oldName)}` }; + } + if (newName === oldName) { + return { success: true, newPath: path }; + } + + const newPath = joinRemote(dirName(path), newName); + + return await this.withSftp(target, async (sftp) => { + if (await sftpExists(sftp, newPath)) { + return { success: false, error: 'A file with that name already exists' }; + } + + await sftpRename(sftp, path, newPath); + + if (storage === 'internal') { + await this.renameSidecarThumbnails(sftp, profile, oldName, newName); + } + + // Migrate the local thumbnail cache entry to the new name + const cache = getThumbnailCacheService(); + const cached = await cache.get(target.serialNumber, oldName); + if (cached.success && cached.data) { + await cache.set(target.serialNumber, newName, cached.data); + } + await cache.invalidate(target.serialNumber, oldName); + + return { success: true, newPath }; + }); + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + } + + /** + * Fetch a thumbnail for a file: local cache -> printer thumbnail cache dirs + * -> embedded .3mf archive extraction. Successful fetches are cached locally. + */ + public async getThumbnail( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string + ): Promise { + const name = baseName(path); + + try { + const cache = getThumbnailCacheService(); + const cached = await cache.get(target.serialNumber, name); + if (cached.success && cached.data) { + return { success: true, thumbnail: cached.data }; + } + + const profile = this.getProfile(target); + + const thumbnail = await this.withSftp(target, async (sftp) => { + // 1) Printer-side thumbnail cache (internal storage files only) + if (storage === 'internal') { + const stems = [thumbnailStem(name), name]; + for (const dir of profile.thumbnailDirs) { + for (const stem of stems) { + try { + const data = await sftpReadFile(sftp, joinRemote(dir, `${stem}.png`)); + if (data.length > 0) { + return data; + } + } catch { + // Candidate missing - try the next one + } + } + } + } + + // 2) Embedded thumbnail inside the .3mf archive (ranged reads) + if (fileExtension(name) === '.3mf') { + const size = await sftpStatSize(sftp, path); + if (size && size > 0) { + const entries = await readRemoteZipEntries(sftp, path, size); + const entry = pickThumbnailEntry(entries); + if (entry) { + return await extractRemoteZipEntry(sftp, path, entry); + } + } + } + + return null; + }); + + if (!thumbnail) { + return { success: false, error: 'No thumbnail available' }; + } + + const base64 = thumbnail.toString('base64'); + await cache.set(target.serialNumber, name, base64); + return { success: true, thumbnail: base64 }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + } + + /** Close the pooled connection for a context (called on context removal). */ + public async disconnect(contextId: string): Promise { + const key = `file-manager:${contextId}`; + this.sftpSessions.delete(key); + await getSSHConnectionManager().disconnect(key); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** Validate that a mutation path is a safe file path inside the storage root. */ + private async validateMutationPath( + target: FileManagerTarget, + profile: FamilyProfile, + storage: FileManagerStorageKind, + path: string + ): Promise { + if (!isSafeFileName(baseName(path))) { + return 'Invalid file path'; + } + + if (storage === 'internal') { + if (!isUnderRoot(path, profile.internalRoot) || dirName(path) !== profile.internalRoot) { + return 'Path is outside the printer file directory'; + } + return null; + } + + const usbMounts = await this.detectUsbMounts(target, profile); + if (!usbMounts.some((mount) => isUnderRoot(path, mount))) { + return 'Path is outside the USB drive'; + } + return null; + } + + /** Remove printer-side sidecar thumbnail PNGs for a deleted file. */ + private async deleteSidecarThumbnails(sftp: SFTPWrapper, profile: FamilyProfile, name: string): Promise { + const stems = new Set([thumbnailStem(name), name]); + for (const dir of profile.thumbnailDirs) { + for (const stem of stems) { + try { + await sftpUnlink(sftp, joinRemote(dir, `${stem}.png`)); + } catch { + // Sidecar not present - fine + } + } + } + } + + /** Rename printer-side sidecar thumbnail PNGs to follow a file rename. */ + private async renameSidecarThumbnails( + sftp: SFTPWrapper, + profile: FamilyProfile, + oldName: string, + newName: string + ): Promise { + const oldStems = [thumbnailStem(oldName), oldName]; + const newStems = [thumbnailStem(newName), newName]; + + for (const dir of profile.thumbnailDirs) { + for (let i = 0; i < oldStems.length; i++) { + const oldPng = joinRemote(dir, `${oldStems[i]}.png`); + const newPng = joinRemote(dir, `${newStems[i]}.png`); + try { + if ((await sftpExists(sftp, oldPng)) && !(await sftpExists(sftp, newPng))) { + await sftpRename(sftp, oldPng, newPng); + } + } catch { + // Best effort - the printer regenerates thumbnails on rescan + } + } + } + } +} + +/** Sort listings: directories first, then case-insensitive by name. */ +function sortEntries(entries: PrinterFileEntry[]): PrinterFileEntry[] { + return entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); +} + +/** + * Get the FileManagerService singleton. + */ +export function getFileManagerService(): FileManagerService { + return FileManagerService.getInstance(); +} diff --git a/src/services/PrinterRebootService.ts b/src/services/PrinterRebootService.ts new file mode 100644 index 0000000..ff13809 --- /dev/null +++ b/src/services/PrinterRebootService.ts @@ -0,0 +1,389 @@ +/** + * @fileoverview Remote "Reboot Printer" orchestration for the WebUI. + * + * Standalone port of the FlashForgeUI printer-power core. Reboots an + * Adventurer 5M / 5M Pro / AD5X over the flashforge-easyssh root SSH surface. + * The WebUI hides the button for unsupported models; this service re-validates + * the model (defense-in-depth) before dispatching the command. + * + * Lifecycle (broadcast over REBOOT_STATUS WebSocket messages): + * 1. rebooting - emitted once the SSH reboot command has been accepted. + * 2. reconnecting - emitted when polling starts failing (printer going down). + * 3. reconnecting-services - emitted once polling resumes; waits for + * REBOOT_STABLE_POLLS_REQUIRED consecutive stable polls + * so the TCP command socket and camera stream can catch up. + * 4. success - emitted after the consecutive-poll threshold (fully back). + * 5. timeout - emitted after REBOOT_RECONNECT_TIMEOUT_MS with no reconnect. + * 6. failed - emitted if the context is removed mid-reboot. + * + * The reboot command is fire-and-forget: the BusyBox 'reboot' applet SIGTERMs + * every process (including our SSH session), so the exec channel is expected + * to drop. dispatchRebootCommand() races the exec against a short timeout and + * treats a channel-drop / error as success. + * + * Key exports: + * - startPrinterReboot(): validate/dispatch/monitor core (used by the + * printer-power route). + * - dispatchRebootCommand(), REBOOT_COMMAND, REBOOT_STABLE_POLLS_REQUIRED, + * createStablePollCounter(): testable primitives. + */ + +import { getPrinterContextManager } from '../managers/PrinterContextManager'; +import type { PrinterModelType } from '../types/printer-backend'; +import type { ContextRemovedEvent } from '../types/printer'; +import type { RebootStatusPayload } from '../types/printer-power'; +import { isRebootSupportedModel } from '../utils/PrinterUtils'; +import { getWebSocketManager } from '../webui/server/WebSocketManager'; +import { + getSSHConnectionManager, + type SSHConnectionManager, +} from './calibration/ssh/SSHConnectionManager'; +import { getGo2rtcService } from './Go2rtcService'; +import { getMultiContextPollingCoordinator } from './MultiContextPollingCoordinator'; +import { getSSHSettingsService } from './SSHSettingsService'; + +/** + * Reboot shell command sent to the printer. + * + * Backgrounded behind a 2s sleep so the exec channel returns cleanly before + * the printer dies. Redirecting both streams keeps the channel quiet. The + * trailing '&' detaches the subshell so the foreground exec exits immediately. + */ +export const REBOOT_COMMAND = '(sleep 2; reboot) >/dev/null 2>&1 &'; + +/** How long to wait for the reboot exec to return before assuming dispatch. */ +export const REBOOT_DISPATCH_TIMEOUT_MS = 3000; + +/** Grace window before optimistically transitioning to "reconnecting". */ +export const REBOOT_GRACE_PERIOD_MS = 5000; + +/** Hard cap on how long we wait for the printer to come back online. */ +export const REBOOT_RECONNECT_TIMEOUT_MS = 150_000; + +/** + * Number of consecutive successful polls required before declaring the printer + * fully recovered. Polling runs at ~3s intervals, so 3 polls is roughly 6-9s of + * stable data — enough for the TCP command socket and camera stream to catch up + * after the HTTP endpoint comes back first. + */ +export const REBOOT_STABLE_POLLS_REQUIRED = 3; + +/** Minimal SSH surface dispatchRebootCommand depends on (keeps it unit-testable). */ +export interface RebootSshClient { + executeCommand: (contextId: string, command: string) => Promise; + disconnect: (contextId: string) => Promise; +} + +/** + * Fire-and-forget the reboot command. NEVER throws: a connection drop / non-zero + * exit is the expected outcome once the printer dies, so it is treated as + * success. The dedicated power SSH session is torn down best-effort afterward + * (the reboot kills the remote side regardless). + */ +export async function dispatchRebootCommand(ssh: RebootSshClient, powerKey: string): Promise { + const execPromise = Promise.resolve(ssh.executeCommand(powerKey, REBOOT_COMMAND)).then( + () => undefined, + () => undefined + ); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, REBOOT_DISPATCH_TIMEOUT_MS); + }); + + await Promise.race([execPromise, timeoutPromise]); + + await Promise.resolve(ssh.disconnect(powerKey)).then( + () => undefined, + () => undefined + ); +} + +/** Records and evaluates a streak of consecutive successful polls. */ +export interface StablePollCounter { + /** Record one successful poll; returns the new streak length. */ + recordPoll: () => number; + /** Reset the streak to zero (a poll failed during recovery). */ + reset: () => void; + /** True once `required` consecutive polls have been recorded without a reset. */ + isStable: () => boolean; +} + +/** + * Count consecutive successful polls until a stability threshold is reached. + */ +export function createStablePollCounter(required: number): StablePollCounter { + let count = 0; + return { + recordPoll: () => { + count += 1; + return count; + }, + reset: () => { + count = 0; + }, + isStable: () => count >= required, + }; +} + +/** Active reconnect monitor for a context (one at most per context). */ +interface RebootMonitor { + readonly contextId: string; + readonly printerName: string; + seenError: boolean; + counter: StablePollCounter; + cameraRestarted: boolean; + graceTimer: NodeJS.Timeout | null; + reconnectTimer: NodeJS.Timeout | null; + onData: (contextId: string) => void; + onError: (contextId: string) => void; + cleanup: () => void; +} + +const activeMonitors = new Map(); +let contextRemovalListenerRegistered = false; + +/** Broadcast a reboot status update to connected WebUI WebSocket clients. */ +function sendRebootStatus(contextId: string, payload: RebootStatusPayload): void { + try { + getWebSocketManager().broadcastRebootStatus(contextId, payload); + } catch (error) { + // Broadcasting is best-effort; a WebUI failure must never break the reboot. + console.warn('[PrinterReboot] Failed to broadcast reboot status:', describeError(error)); + } +} + +/** Coerce an unknown thrown value into a single-line message. */ +function describeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return typeof error === 'string' ? error : 'Unknown error'; +} + +/** + * Best-effort: restart the go2rtc camera stream so it reconnects on our + * schedule rather than waiting for a UI interaction. Non-throwing — camera + * failures must never block the reboot from completing. + */ +async function restartCameraStream(contextId: string): Promise { + try { + const go2rtc = getGo2rtcService(); + if (go2rtc.hasStream(contextId)) { + await go2rtc.restartStream(contextId); + } + } catch (error) { + console.warn(`[PrinterReboot] Camera stream restart failed for ${contextId}:`, describeError(error)); + } +} + +/** + * Begin monitoring the polling coordinator for the printer going offline and + * coming back. Emits reconnecting / reconnecting-services / success / timeout. + */ +function startReconnectMonitor( + contextId: string, + printerName: string, + emit: (payload: RebootStatusPayload) => void +): void { + const coordinator = getMultiContextPollingCoordinator(); + + const monitor: RebootMonitor = { + contextId, + printerName, + seenError: false, + counter: createStablePollCounter(REBOOT_STABLE_POLLS_REQUIRED), + cameraRestarted: false, + graceTimer: null, + reconnectTimer: null, + onData: () => undefined, + onError: () => undefined, + cleanup: () => undefined, + }; + + const onData = (pollingContextId: string): void => { + if (pollingContextId !== contextId) { + return; + } + // Only count data that resumes AFTER the printer went offline. Ignoring + // healthy data received before the printer went down prevents a false + // success if polling was healthy the whole time. + if (!monitor.seenError) { + return; + } + + const newCount = monitor.counter.recordPoll(); + if (newCount === 1) { + emit({ + phase: 'reconnecting-services', + message: `Reconnecting services for ${printerName}...`, + printerName, + }); + if (!monitor.cameraRestarted) { + monitor.cameraRestarted = true; + void restartCameraStream(contextId); + } + } + + if (monitor.counter.isStable()) { + emit({ + phase: 'success', + message: `${printerName} is back online`, + printerName, + }); + monitor.cleanup(); + } + }; + + const onError = (pollingContextId: string): void => { + if (pollingContextId !== contextId) { + return; + } + if (!monitor.seenError) { + monitor.seenError = true; + emit({ + phase: 'reconnecting', + message: `Waiting for ${printerName} to come back online...`, + printerName, + }); + } else { + // A poll failed DURING the recovery window: reset the streak so we + // require another clean run, and allow the camera to be re-kicked. + monitor.counter.reset(); + monitor.cameraRestarted = false; + } + }; + + monitor.onData = onData; + monitor.onError = onError; + + monitor.cleanup = (): void => { + coordinator.off('polling-data', onData); + coordinator.off('polling-error', onError); + if (monitor.graceTimer) { + clearTimeout(monitor.graceTimer); + monitor.graceTimer = null; + } + if (monitor.reconnectTimer) { + clearTimeout(monitor.reconnectTimer); + monitor.reconnectTimer = null; + } + activeMonitors.delete(contextId); + }; + + coordinator.on('polling-data', onData); + coordinator.on('polling-error', onError); + + // Grace period: if no polling-error fires in time, optimistically assume the + // printer is going down and transition to reconnecting. + monitor.graceTimer = setTimeout(() => { + if (!monitor.seenError) { + monitor.seenError = true; + emit({ + phase: 'reconnecting', + message: `Waiting for ${printerName} to come back online...`, + printerName, + }); + } + }, REBOOT_GRACE_PERIOD_MS); + + // Hard timeout: surface an actionable error if the printer never reconnects. + monitor.reconnectTimer = setTimeout(() => { + emit({ + phase: 'timeout', + message: `${printerName} didn't come back online. Check that it's powered on and reachable.`, + printerName, + }); + monitor.cleanup(); + }, REBOOT_RECONNECT_TIMEOUT_MS); + + activeMonitors.set(contextId, monitor); +} + +/** Register a one-time listener that aborts any in-flight reboot on context removal. */ +function ensureContextRemovalCleanup(): void { + if (contextRemovalListenerRegistered) { + return; + } + contextRemovalListenerRegistered = true; + + const contextManager = getPrinterContextManager(); + contextManager.on('context-removed', (event: ContextRemovedEvent) => { + const monitor = activeMonitors.get(event.contextId); + if (!monitor) { + return; + } + sendRebootStatus(event.contextId, { + phase: 'failed', + message: `${monitor.printerName} was disconnected during reboot.`, + printerName: monitor.printerName, + }); + monitor.cleanup(); + }); +} + +/** + * Validate, dispatch, and monitor a printer reboot. Entry point for the WebUI + * POST /printer/reboot route. Throws with an actionable message on validation + * or SSH-connect failure. + */ +export async function startPrinterReboot(contextId: unknown): Promise<{ success: true }> { + if (typeof contextId !== 'string' || contextId.length === 0) { + throw new Error('Invalid reboot request: missing context id'); + } + + ensureContextRemovalCleanup(); + + // STRICT context resolution — no active-context fallback. + const context = getPrinterContextManager().getContext(contextId); + if (!context) { + throw new Error('Printer context not found'); + } + + const details = context.printerDetails; + const modelType: PrinterModelType | undefined = details.modelType; + + // Defense-in-depth model guard (the WebUI also hides the button). + if (!isRebootSupportedModel(modelType)) { + throw new Error('Reboot is only supported on Adventurer 5M / 5M Pro / AD5X printers'); + } + + const serialNumber = details.SerialNumber; + const host = details.IPAddress; + const printerName = details.Name || host || 'Printer'; + const powerKey = `power:${contextId}`; + + if (!serialNumber || !host) { + throw new Error('Printer is missing the serial number or IP address required for reboot'); + } + + // Abort any prior in-flight monitor for this context (Retry path). + const existing = activeMonitors.get(contextId); + if (existing) { + existing.cleanup(); + } + + // Dedicated SSH namespace — a reboot must not tear down the file-manager + // or calibration sessions (those use separate connection keys). + const config = await getSSHSettingsService().buildConnectionConfig(serialNumber, host); + try { + await getSSHConnectionManager().connect(powerKey, config); + } catch (error) { + // A genuine SSH failure (unreachable / wrong creds) is a real failure. + throw new Error(`Could not reach ${printerName} over SSH: ${describeError(error)}`); + } + + // Fire-and-forget the reboot command (channel-drop treated as success). + await dispatchRebootCommand(getSSHConnectionManager() as SSHConnectionManager, powerKey); + + // Notify connected clients that the reboot has been dispatched. + sendRebootStatus(contextId, { + phase: 'rebooting', + message: `Rebooting ${printerName}...`, + printerName, + }); + + // Watch for the printer going offline and coming back. + startReconnectMonitor(contextId, printerName, (payload) => sendRebootStatus(contextId, payload)); + + return { success: true }; +} diff --git a/src/services/SSHSettingsService.ts b/src/services/SSHSettingsService.ts new file mode 100644 index 0000000..bc840ba --- /dev/null +++ b/src/services/SSHSettingsService.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview Centralized per-printer SSH credential store. + * + * Single source of truth for SSH credentials used by every SSH-based feature + * (file manager, calibration routes, printer reboot). Settings are keyed by + * printer serial number — stable across sessions, unlike runtime context ids — + * and persisted to ssh-settings.json in the data directory with passwords + * encoded via SecureStorage (base64 in this standalone build). + * + * Resolution rules: + * - Missing/empty fields fall back to the flashforge-easyssh defaults + * (root / flashforge, port 22), so provisioned printers work with zero setup. + * - An optional private key path takes precedence over password auth when set. + * + * Key exports: + * - SSHSettingsService / getSSHSettingsService(): singleton service + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { SSHConnectionConfig } from '../types/calibration'; +import { SSH_DEFAULTS, type SSHSettings, type SSHSettingsUpdate } from '../types/ssh-settings'; +import { decryptSecret, encryptSecret } from '../utils/SecureStorage'; +import { getEnvironmentService } from './EnvironmentService'; + +/** On-disk entry (password encoded; absent fields mean "use default"). */ +interface StoredSSHEntry { + username?: string; + password?: string; + port?: number; + keyPath?: string; +} + +interface SSHSettingsFile { + version: number; + printers: Record; +} + +const STORE_VERSION = 1; +const STORE_FILENAME = 'ssh-settings.json'; + +/** + * Centralized per-printer SSH settings, keyed by printer serial number. + */ +export class SSHSettingsService { + private static instance: SSHSettingsService | null = null; + + private readonly storePath: string; + private cache: SSHSettingsFile | null = null; + + private constructor() { + this.storePath = path.join(getEnvironmentService().getDataPath(), STORE_FILENAME); + } + + public static getInstance(): SSHSettingsService { + if (!SSHSettingsService.instance) { + SSHSettingsService.instance = new SSHSettingsService(); + } + return SSHSettingsService.instance; + } + + private async load(): Promise { + if (this.cache) { + return this.cache; + } + + try { + const raw = await fs.readFile(this.storePath, 'utf-8'); + const parsed = JSON.parse(raw) as SSHSettingsFile; + this.cache = { + version: parsed.version || STORE_VERSION, + printers: parsed.printers || {}, + }; + } catch { + this.cache = { version: STORE_VERSION, printers: {} }; + } + + return this.cache; + } + + private async persist(store: SSHSettingsFile): Promise { + this.cache = store; + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + await fs.writeFile(this.storePath, JSON.stringify(store, null, 2), 'utf-8'); + } + + /** + * Get resolved SSH settings for a printer (easy-SSH defaults applied). + */ + public async getSettings(serialNumber: string): Promise { + const store = await this.load(); + const entry = store.printers[serialNumber] || {}; + + const username = entry.username || SSH_DEFAULTS.username; + const password = (entry.password ? decryptSecret(entry.password) : undefined) || SSH_DEFAULTS.password; + const port = entry.port || SSH_DEFAULTS.port; + const keyPath = entry.keyPath || undefined; + + return { + username, + password, + port, + keyPath, + isCustom: + username !== SSH_DEFAULTS.username || + password !== SSH_DEFAULTS.password || + port !== SSH_DEFAULTS.port || + !!keyPath, + }; + } + + /** + * Update settings for a printer. Empty strings / default values are stored + * as "unset" so the printer keeps tracking the easy-SSH defaults. + */ + public async updateSettings(serialNumber: string, update: SSHSettingsUpdate): Promise { + const store = await this.load(); + const entry: StoredSSHEntry = { ...(store.printers[serialNumber] || {}) }; + + if (update.username !== undefined) { + const username = update.username.trim(); + entry.username = username && username !== SSH_DEFAULTS.username ? username : undefined; + } + if (update.password !== undefined) { + const password = update.password; + entry.password = password && password !== SSH_DEFAULTS.password ? encryptSecret(password) : undefined; + } + if (update.port !== undefined) { + entry.port = update.port > 0 && update.port !== SSH_DEFAULTS.port ? update.port : undefined; + } + if (update.keyPath !== undefined) { + const keyPath = update.keyPath.trim(); + entry.keyPath = keyPath || undefined; + } + + const hasValues = Object.values(entry).some((value) => value !== undefined); + if (hasValues) { + store.printers[serialNumber] = entry; + } else { + delete store.printers[serialNumber]; + } + + await this.persist(store); + console.log(`[SSHSettings] Updated SSH settings for ${serialNumber} (custom: ${hasValues})`); + } + + /** + * Reset a printer back to the easy-SSH defaults. + */ + public async resetSettings(serialNumber: string): Promise { + const store = await this.load(); + delete store.printers[serialNumber]; + await this.persist(store); + console.log(`[SSHSettings] Reset SSH settings for ${serialNumber} to defaults`); + } + + /** + * Build a ready-to-use SSH connection config for a printer. + * Reads the private key file into memory when a key path is configured. + */ + public async buildConnectionConfig(serialNumber: string, host: string): Promise { + const settings = await this.getSettings(serialNumber); + + const config: SSHConnectionConfig = { + host, + port: settings.port, + username: settings.username, + password: settings.password, + timeout: 10000, + keepaliveInterval: 10000, + }; + + if (settings.keyPath) { + try { + config.privateKey = await fs.readFile(settings.keyPath, 'utf-8'); + } catch (error) { + console.warn(`[SSHSettings] Failed to read SSH key at ${settings.keyPath}, falling back to password:`, error); + } + } + + return config; + } +} + +/** + * Get the SSHSettingsService singleton. + */ +export function getSSHSettingsService(): SSHSettingsService { + return SSHSettingsService.getInstance(); +} diff --git a/src/services/calibration/__tests__/Bed.test.ts b/src/services/calibration/__tests__/Bed.test.ts new file mode 100644 index 0000000..9a29c19 --- /dev/null +++ b/src/services/calibration/__tests__/Bed.test.ts @@ -0,0 +1,366 @@ +/** + * @fileoverview Unit tests for the Bed model class. + * Tests bed mesh data management, corner calculations, and statistics. + * + * @module services/calibration/__tests__/Bed.test + */ + +import { describe, expect, it } from '@jest/globals'; +import type { BedConfig, MeshData } from '../../../types/calibration'; +import { BedCorner, DEFAULT_BED_CONFIG } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; + +describe('Bed', () => { + // Sample 3x3 mesh for testing + const sampleMesh3x3 = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + + // Sample 5x5 mesh with more variation + const sampleMesh5x5 = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + const config3x3: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 3, + meshPointsY: 3, + }; + + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + describe('constructor', () => { + it('should create a Bed with default config', () => { + const bed = new Bed(); + expect(bed.config).toEqual(DEFAULT_BED_CONFIG); + expect(bed.hasMeshData).toBe(false); + }); + + it('should create a Bed with custom config', () => { + const bed = new Bed(config3x3); + expect(bed.config.meshPointsX).toBe(3); + expect(bed.config.meshPointsY).toBe(3); + expect(bed.config.sizeX).toBe(220); + expect(bed.config.sizeY).toBe(220); + }); + }); + + describe('setMeshData', () => { + it('should accept valid mesh data', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + expect(bed.hasMeshData).toBe(true); + expect(bed.meshData).toEqual(sampleMesh3x3); + }); + + it('should deep copy mesh data', () => { + const bed = new Bed(config3x3); + const originalMesh = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + bed.setMeshData(originalMesh); + + // Modify original + originalMesh[0][0] = 999; + + // Bed data should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should throw on invalid row count', () => { + const bed = new Bed(config3x3); + const invalidMesh = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + ]; // Only 2 rows + expect(() => bed.setMeshData(invalidMesh)).toThrow(/Invalid mesh row count/); + }); + + it('should throw on invalid column count', () => { + const bed = new Bed(config3x3); + const invalidMesh = [ + [0.1, 0.0], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; // First row has only 2 columns + expect(() => bed.setMeshData(invalidMesh)).toThrow(/Invalid mesh column count/); + }); + }); + + describe('loadFromMeshData', () => { + it('should load mesh from MeshData object', () => { + const bed = new Bed(); + const meshData: MeshData = { + matrix: sampleMesh5x5, + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 5, + pointsY: 5, + profileName: 'default', + }; + + bed.loadFromMeshData(meshData); + expect(bed.hasMeshData).toBe(true); + expect(bed.config.meshPointsX).toBe(5); + expect(bed.config.meshPointsY).toBe(5); + }); + }); + + describe('clearMeshData', () => { + it('should clear mesh data', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + expect(bed.hasMeshData).toBe(true); + + bed.clearMeshData(); + expect(bed.hasMeshData).toBe(false); + expect(bed.meshData).toBeNull(); + }); + }); + + describe('getCornerHeight', () => { + it('should get correct corner heights without averaging', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getCornerHeight(BedCorner.FRONT_LEFT)).toBe(0.1); + expect(bed.getCornerHeight(BedCorner.FRONT_RIGHT)).toBe(-0.1); + expect(bed.getCornerHeight(BedCorner.REAR_LEFT)).toBe(0.0); + expect(bed.getCornerHeight(BedCorner.REAR_RIGHT)).toBe(-0.1); + }); + + it('should throw when no mesh data loaded', () => { + const bed = new Bed(config3x3); + expect(() => bed.getCornerHeight(BedCorner.FRONT_LEFT)).toThrow('No mesh data loaded'); + }); + + it('should average corner heights when averaging size > 0', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + // With averaging size 1, should average a 3x3 area around corner (clamped to mesh bounds) + const avgFrontLeft = bed.getCornerHeight(BedCorner.FRONT_LEFT, 1); + // Front-left (0,0) with averaging: (0.1 + 0.0 + 0.05 + 0.0) / 4 + const expected = (0.1 + 0.0 + 0.05 + 0.0) / 4; + expect(avgFrontLeft).toBeCloseTo(expected, 5); + }); + }); + + describe('getCornerValues', () => { + it('should return all corner values', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const corners = bed.getCornerValues(); + expect(corners.frontLeft).toBe(0.1); + expect(corners.frontRight).toBe(-0.1); + expect(corners.rearLeft).toBe(0.0); + expect(corners.rearRight).toBe(-0.1); + }); + }); + + describe('getPointHeight', () => { + it('should return height at specific point', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getPointHeight(0, 0)).toBe(0.1); + expect(bed.getPointHeight(1, 1)).toBe(0.0); + expect(bed.getPointHeight(2, 2)).toBe(-0.1); + }); + + it('should throw for out of range coordinates', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(() => bed.getPointHeight(-1, 0)).toThrow(/out of range/); + expect(() => bed.getPointHeight(0, 5)).toThrow(/out of range/); + expect(() => bed.getPointHeight(10, 0)).toThrow(/out of range/); + }); + }); + + describe('getMeshStats', () => { + it('should calculate correct mesh statistics', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const stats = bed.getMeshStats(); + + // min = -0.1, max = 0.1, range = 0.2 + expect(stats.min).toBeCloseTo(-0.1, 5); + expect(stats.max).toBeCloseTo(0.1, 5); + expect(stats.range).toBeCloseTo(0.2, 5); + + // Mean of all values + const allValues = sampleMesh3x3.flat(); + const expectedMean = allValues.reduce((a, b) => a + b, 0) / allValues.length; + expect(stats.mean).toBeCloseTo(expectedMean, 5); + + // Standard deviation should be positive + expect(stats.standardDeviation).toBeGreaterThan(0); + }); + + it('should throw when no mesh data loaded', () => { + const bed = new Bed(config3x3); + expect(() => bed.getMeshStats()).toThrow('No mesh data loaded'); + }); + }); + + describe('getMinValue/getMaxValue/getRange/getMeanValue', () => { + it('should return correct values', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getMinValue()).toBeCloseTo(-0.1, 5); + expect(bed.getMaxValue()).toBeCloseTo(0.1, 5); + expect(bed.getRange()).toBeCloseTo(0.2, 5); + }); + }); + + describe('getMmPerPoint', () => { + it('should calculate correct mm per point', () => { + const bed = new Bed(config3x3); + const mmPerPoint = bed.getMmPerPoint(); + + // 220mm / (3-1) = 110mm per point + expect(mmPerPoint.x).toBe(110); + expect(mmPerPoint.y).toBe(110); + }); + + it('should handle different mesh sizes', () => { + const bed = new Bed(config5x5); + const mmPerPoint = bed.getMmPerPoint(); + + // 220mm / (5-1) = 55mm per point + expect(mmPerPoint.x).toBe(55); + expect(mmPerPoint.y).toBe(55); + }); + }); + + describe('generateIdealPlane', () => { + it('should generate flat plane at mean height', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const idealPlane = bed.generateIdealPlane(); + const meanHeight = bed.getMeanValue(); + + // All values should equal the mean + for (const row of idealPlane) { + for (const val of row) { + expect(val).toBeCloseTo(meanHeight, 5); + } + } + + // Should have correct dimensions + expect(idealPlane.length).toBe(3); + expect(idealPlane[0].length).toBe(3); + }); + }); + + describe('calculateDeviationMap', () => { + it('should calculate deviations from ideal plane', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const deviationMap = bed.calculateDeviationMap(); + const meanHeight = bed.getMeanValue(); + + // Check that deviations are correct + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + const expected = sampleMesh3x3[row][col] - meanHeight; + expect(deviationMap[row][col]).toBeCloseTo(expected, 5); + } + } + }); + }); + + describe('simulateAdjustment', () => { + it('should simulate corner adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: -0.1, // Lower front-left by 0.1 + }); + + // Front-left corner should be affected most + expect(adjustedMesh[0][0]).toBeLessThan(sampleMesh3x3[0][0]); + + // Original mesh should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should handle multiple corner adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: -0.05, + [BedCorner.REAR_RIGHT]: 0.05, + }); + + // Both corners should be affected + expect(adjustedMesh[0][0]).toBeLessThan(sampleMesh3x3[0][0]); + expect(adjustedMesh[2][2]).toBeGreaterThan(sampleMesh3x3[2][2]); + }); + + it('should skip zero adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: 0, + }); + + // Mesh should be unchanged + expect(adjustedMesh).toEqual(sampleMesh3x3); + }); + }); + + describe('clone', () => { + it('should create an independent copy', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const cloned = bed.clone(); + + expect(cloned.config).toEqual(bed.config); + expect(cloned.meshData).toEqual(bed.meshData); + + // Modify cloned mesh + if (!cloned.meshData) { + throw new Error('Expected cloned mesh data'); + } + cloned.meshData[0][0] = 999; + + // Original should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should clone bed without mesh data', () => { + const bed = new Bed(config3x3); + const cloned = bed.clone(); + + expect(cloned.hasMeshData).toBe(false); + expect(cloned.config).toEqual(bed.config); + }); + }); +}); diff --git a/src/services/calibration/__tests__/DeviationAnalyzer.test.ts b/src/services/calibration/__tests__/DeviationAnalyzer.test.ts new file mode 100644 index 0000000..9fd406c --- /dev/null +++ b/src/services/calibration/__tests__/DeviationAnalyzer.test.ts @@ -0,0 +1,347 @@ +/** + * @fileoverview Unit tests for the DeviationAnalyzer class. + * Tests mesh deviation analysis and calibration strategy recommendations. + * + * @module services/calibration/__tests__/DeviationAnalyzer.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig } from '../../../types/calibration'; +import { BedCorner } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { DeviationAnalyzer } from '../engine/DeviationAnalyzer'; + +describe('DeviationAnalyzer', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Tilted mesh (front-left high, rear-right low) + const tiltedMesh = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + // Slight deviation (within tape threshold) + const slightMesh = [ + [0.015, 0.01, 0.005, 0.0, -0.005], + [0.01, 0.005, 0.0, -0.005, -0.01], + [0.005, 0.0, 0.0, 0.0, -0.005], + [0.0, -0.005, 0.0, 0.005, 0.01], + [-0.005, -0.01, 0.005, 0.01, 0.015], + ]; + + // X-axis tilt (belt sync needed) + const xTiltMesh = [ + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + ]; + + let bed: Bed; + let analyzer: DeviationAnalyzer; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(tiltedMesh); + analyzer = new DeviationAnalyzer(bed); + }); + + describe('constructor', () => { + it('should create analyzer with default options', () => { + const analyzer = new DeviationAnalyzer(bed); + expect(analyzer).toBeDefined(); + }); + + it('should create analyzer with custom options', () => { + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: 2, + screwThreshold: 0.05, + tapeThreshold: 0.03, + }); + expect(analyzer).toBeDefined(); + }); + }); + + describe('setOptions', () => { + it('should update analyzer options', () => { + analyzer.setOptions({ screwThreshold: 0.1 }); + // Verify by analyzing (thresholds affect recommendations) + const result = analyzer.analyze(); + expect(result).toBeDefined(); + }); + }); + + describe('setCornerAveragingSize', () => { + it('should update corner averaging size', () => { + analyzer.setCornerAveragingSize(2); + const stats = analyzer.getStats(); + expect(stats).toBeDefined(); + }); + + it('should clamp negative values to 0', () => { + analyzer.setCornerAveragingSize(-5); + // Should not throw and stats should still work + const stats = analyzer.getStats(); + expect(stats).toBeDefined(); + }); + }); + + describe('setScrewThreshold', () => { + it('should update screw threshold', () => { + analyzer.setScrewThreshold(0.1); + const result = analyzer.analyze(); + // With higher threshold, might not need screw adjustment + expect(result).toBeDefined(); + }); + }); + + describe('setTapeThreshold', () => { + it('should update tape threshold', () => { + analyzer.setTapeThreshold(0.05); + const result = analyzer.analyze(); + expect(result).toBeDefined(); + }); + }); + + describe('getStats', () => { + it('should calculate deviation statistics', () => { + const stats = analyzer.getStats(); + + expect(stats.meanHeight).toBeDefined(); + expect(stats.maxDeviation).toBeGreaterThan(0); + expect(stats.cornerDeviations).toBeDefined(); + expect(stats.hasCriticalDeviation).toBe(true); // Tilted mesh has deviations + }); + + it('should not have critical deviation for level mesh', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const stats = levelAnalyzer.getStats(); + expect(stats.hasCriticalDeviation).toBe(false); + }); + + it('should calculate corner deviations from mean', () => { + const stats = analyzer.getStats(); + + // All corner deviations should be non-negative (absolute values) + expect(stats.cornerDeviations.frontLeft).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.frontRight).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.rearLeft).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.rearRight).toBeGreaterThanOrEqual(0); + }); + }); + + describe('analyzeLevelingStage', () => { + it('should identify need for screw adjustment', () => { + const stage = analyzer.analyzeLevelingStage(); + + expect(stage.needsScrewAdjustment).toBe(true); + expect(stage.maxCornerDiff).toBeGreaterThan(0); + }); + + it('should identify that screws can fix the problem', () => { + const stage = analyzer.analyzeLevelingStage(); + + // Tilted mesh deviation is within max adjustment range + expect(stage.canUseScrews).toBe(true); + }); + + it('should identify problematic corners', () => { + const stage = analyzer.analyzeLevelingStage(); + + expect(stage.problematicCorners.length).toBeGreaterThan(0); + }); + + it('should not recommend adjustment for level mesh', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const stage = levelAnalyzer.analyzeLevelingStage(); + + expect(stage.needsScrewAdjustment).toBe(false); + expect(stage.needsTape).toBe(false); + expect(stage.problematicCorners.length).toBe(0); + }); + }); + + describe('analyze', () => { + it('should perform full analysis', () => { + const result = analyzer.analyze(); + + expect(result.meshRange).toBeGreaterThan(0); + expect(result.maxDeviation).toBeGreaterThan(0); + expect(result.standardDeviation).toBeGreaterThan(0); + expect(result.cornerDeviations).toBeDefined(); + expect(result.referenceCorner).toBeDefined(); + expect(result.recommendations).toBeDefined(); + }); + + it('should identify reference corner as lowest', () => { + const result = analyzer.analyze(); + + // Rear-right is lowest in tilted mesh + expect(result.referenceCorner).toBe(BedCorner.REAR_RIGHT); + }); + + it('should recommend screw adjustment for tilted bed', () => { + const result = analyzer.analyze(); + + expect(result.recommendations.needsScrewAdjust).toBe(true); + }); + + it('should recommend belt sync for X-axis tilt', () => { + const xTiltBed = new Bed(config5x5); + xTiltBed.setMeshData(xTiltMesh); + const xTiltAnalyzer = new DeviationAnalyzer(xTiltBed, { + cornerAveragingSize: 0, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: { pitch: 0.5, minAdjust: 0.01, maxAdjust: 2.0 }, + }); + + const result = xTiltAnalyzer.analyze(); + + // Front-left to front-right difference is 0.2, which exceeds 0.1 threshold + expect(result.recommendations.needsBeltSync).toBe(true); + }); + + it('should not recommend actions for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const result = levelAnalyzer.analyze(); + + expect(result.recommendations.needsBeltSync).toBe(false); + expect(result.recommendations.needsScrewAdjust).toBe(false); + expect(result.recommendations.needsTapeCompensation).toBe(false); + }); + }); + + describe('getIdealPlane', () => { + it('should return ideal plane at mean height', () => { + const idealPlane = analyzer.getIdealPlane(); + const meanHeight = bed.getMeanValue(); + + // All values should equal mean + for (const row of idealPlane) { + for (const val of row) { + expect(val).toBeCloseTo(meanHeight, 5); + } + } + }); + }); + + describe('estimateBedAfterScrewAdjustment', () => { + it('should simulate screw adjustment effect', () => { + const adjusted = analyzer.estimateBedAfterScrewAdjustment(); + + expect(adjusted.length).toBe(5); + expect(adjusted[0].length).toBe(5); + + // Range should be reduced after adjustment + let minAdj = Infinity; + let maxAdj = -Infinity; + for (const row of adjusted) { + for (const val of row) { + minAdj = Math.min(minAdj, val); + maxAdj = Math.max(maxAdj, val); + } + } + const adjustedRange = maxAdj - minAdj; + const originalRange = bed.getRange(); + + // Adjusted range should be less than or equal to original + expect(adjustedRange).toBeLessThanOrEqual(originalRange + 0.001); + }); + }); + + describe('findOptimalStrategy', () => { + it('should find optimal calibration strategy', () => { + const strategy = analyzer.findOptimalStrategy(); + + expect(strategy.originalDeviation).toBeGreaterThan(0); + expect(strategy.deviationAfterScrews).toBeDefined(); + expect(strategy.needsScrews).toBe(true); + expect(strategy.simulatedBedAfterScrews).toBeDefined(); + }); + + it('should show improvement with screws', () => { + const strategy = analyzer.findOptimalStrategy(); + + // Deviation should be reduced after screws + expect(strategy.deviationAfterScrews).toBeLessThanOrEqual(strategy.originalDeviation); + }); + }); + + describe('getScrewSolver', () => { + it('should return screw solver instance', () => { + const solver = analyzer.getScrewSolver(); + expect(solver).toBeDefined(); + + // Should be functional + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + expect(adjustments.length).toBe(4); + }); + }); + + describe('edge cases', () => { + it('should handle mesh with all same values', () => { + const sameMesh = [ + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + ]; + + const sameBed = new Bed(config5x5); + sameBed.setMeshData(sameMesh); + const sameAnalyzer = new DeviationAnalyzer(sameBed); + + const result = sameAnalyzer.analyze(); + expect(result.meshRange).toBeCloseTo(0, 5); + expect(result.recommendations.needsScrewAdjust).toBe(false); + }); + + it('should handle mesh with slight variations', () => { + const slightBed = new Bed(config5x5); + slightBed.setMeshData(slightMesh); + const slightAnalyzer = new DeviationAnalyzer(slightBed, { + cornerAveragingSize: 0, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: { pitch: 0.5, minAdjust: 0.01, maxAdjust: 2.0 }, + }); + + const result = slightAnalyzer.analyze(); + + // With 0.02 threshold and max corner deviation ~0.015, should not need major adjustment + expect(result.meshRange).toBeLessThan(0.05); + }); + }); +}); diff --git a/src/services/calibration/__tests__/FFTProcessor.test.ts b/src/services/calibration/__tests__/FFTProcessor.test.ts new file mode 100644 index 0000000..2aaed7a --- /dev/null +++ b/src/services/calibration/__tests__/FFTProcessor.test.ts @@ -0,0 +1,319 @@ +/** + * @fileoverview Unit tests for the FFTProcessor class. + * Tests FFT computation, CSV parsing, and power spectral density analysis. + * + * @module services/calibration/__tests__/FFTProcessor.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { FFTProcessor } from '../shaper/FFTProcessor'; + +describe('FFTProcessor', () => { + let processor: FFTProcessor; + + beforeEach(() => { + processor = new FFTProcessor(); + }); + + describe('constructor', () => { + it('should create processor with default options', () => { + const proc = new FFTProcessor(); + expect(proc).toBeDefined(); + }); + + it('should create processor with custom options', () => { + const proc = new FFTProcessor({ + windowFunction: 'hamming', + minFreq: 10, + maxFreq: 150, + }); + expect(proc).toBeDefined(); + }); + }); + + describe('setOptions', () => { + it('should update processing options', () => { + processor.setOptions({ minFreq: 20, maxFreq: 100 }); + // Options should be updated (verified by processing behavior) + expect(processor).toBeDefined(); + }); + }); + + describe('parseCSV', () => { + it('should parse valid accelerometer CSV', () => { + const csvContent = `time,accel_x,accel_y,accel_z +0.000,0.1,0.2,9.8 +0.001,0.15,0.25,9.81 +0.002,0.12,0.22,9.79 +0.003,0.11,0.21,9.8 +0.004,0.13,0.23,9.82`; + + const data = processor.parseCSV(csvContent); + + expect(data.time.length).toBe(5); + expect(data.accelX.length).toBe(5); + expect(data.accelY.length).toBe(5); + expect(data.accelZ.length).toBe(5); + + expect(data.time[0]).toBe(0); + expect(data.accelX[0]).toBeCloseTo(0.1, 5); + expect(data.sampleRate).toBeGreaterThan(0); + }); + + it('should skip header line automatically', () => { + const csvWithHeader = `time,accel_x,accel_y,accel_z +0.0,1.0,2.0,9.8 +0.001,1.1,2.1,9.81`; + + const data = processor.parseCSV(csvWithHeader); + expect(data.time.length).toBe(2); + expect(data.time[0]).toBe(0); + }); + + it('should skip comment lines', () => { + const csvWithComments = `# Comment line +time,accel_x,accel_y,accel_z +# Another comment +0.0,1.0,2.0,9.8 +0.001,1.1,2.1,9.81`; + + const data = processor.parseCSV(csvWithComments); + expect(data.time.length).toBe(2); + }); + + it('should calculate sample rate from time values', () => { + const csvContent = `time,accel_x,accel_y,accel_z +0.0,0.1,0.2,9.8 +0.001,0.1,0.2,9.8 +0.002,0.1,0.2,9.8 +0.003,0.1,0.2,9.8`; + + const data = processor.parseCSV(csvContent); + // Delta t = 0.001s, so sample rate = 1000 Hz + expect(data.sampleRate).toBeCloseTo(1000, 0); + }); + + it('should handle empty CSV', () => { + const emptyCSV = `time,accel_x,accel_y,accel_z`; + const data = processor.parseCSV(emptyCSV); + expect(data.time.length).toBe(0); + }); + }); + + describe('processAxis', () => { + it('should compute power spectrum for sinusoidal input', () => { + // Generate a simple sinusoidal signal at 50 Hz + const sampleRate = 1000; + const frequency = 50; + const samples = 1024; + const data: number[] = []; + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + data.push(Math.sin(2 * Math.PI * frequency * t)); + } + + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.power.length).toBe(spectrum.frequencies.length); + expect(spectrum.peakFrequency).toBeGreaterThan(0); + + // Peak should be near 50 Hz (within FFT resolution) + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should filter frequencies to specified range', () => { + processor.setOptions({ minFreq: 20, maxFreq: 100 }); + + const sampleRate = 1000; + const data = new Array(512).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // All frequencies should be within range + for (const freq of spectrum.frequencies) { + expect(freq).toBeGreaterThanOrEqual(20); + expect(freq).toBeLessThanOrEqual(100); + } + }); + + it('should normalize power when requested', () => { + processor.setOptions({ normalize: true }); + + const sampleRate = 1000; + const data = new Array(512).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // Max power should be 1 when normalized + const maxPower = Math.max(...spectrum.power); + expect(maxPower).toBeCloseTo(1, 5); + }); + + it('should handle constant signal (DC)', () => { + const sampleRate = 1000; + const data = new Array(256).fill(1.0); // Constant value + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce a spectrum (mostly DC component) + expect(spectrum.frequencies.length).toBeGreaterThan(0); + }); + + it('should handle noisy signal', () => { + const sampleRate = 1000; + const data = new Array(512).fill(0).map(() => Math.random() - 0.5); + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce valid spectrum + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.power.every((p) => !Number.isNaN(p))).toBe(true); + }); + }); + + describe('processData', () => { + it('should process all three axes', () => { + const sampleRate = 1000; + const samples = 256; + + const mockData = { + time: new Array(samples).fill(0).map((_, i) => i / sampleRate), + accelX: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 30 * (i / sampleRate))), + accelY: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 40 * (i / sampleRate))), + accelZ: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))), + sampleRate, + }; + + const result = processor.processData(mockData); + + expect(result.x).toBeDefined(); + expect(result.y).toBeDefined(); + expect(result.z).toBeDefined(); + + // Each axis should have different peak (approximately) + expect(result.x.peakFrequency).toBeGreaterThan(25); + expect(result.x.peakFrequency).toBeLessThan(35); + + expect(result.y.peakFrequency).toBeGreaterThan(35); + expect(result.y.peakFrequency).toBeLessThan(45); + + expect(result.z.peakFrequency).toBeGreaterThan(45); + expect(result.z.peakFrequency).toBeLessThan(55); + }); + }); + + describe('findPeaks', () => { + it('should find resonance peaks in spectrum', () => { + // Generate signal with multiple frequency components + const sampleRate = 1000; + const samples = 1024; + const data: number[] = []; + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + // 30 Hz and 70 Hz components + data.push(Math.sin(2 * Math.PI * 30 * t) + 0.5 * Math.sin(2 * Math.PI * 70 * t)); + } + + const spectrum = processor.processAxis(data, sampleRate); + const peaks = processor.findPeaks(spectrum, 0.2); + + // Should find at least one peak + expect(peaks.length).toBeGreaterThan(0); + + // One peak should be near 30 Hz + const has30Hz = peaks.some((p) => p > 25 && p < 35); + expect(has30Hz).toBe(true); + }); + + it('should return empty array for flat spectrum', () => { + // Create a flat spectrum manually + const flatSpectrum = { + frequencies: [10, 20, 30, 40, 50], + power: [0.5, 0.5, 0.5, 0.5, 0.5], + peakFrequency: 30, + peakPower: 0.5, + }; + + const peaks = processor.findPeaks(flatSpectrum, 0.3); + expect(peaks.length).toBe(0); + }); + + it('should handle very short spectrum', () => { + const shortSpectrum = { + frequencies: [10, 20], + power: [0.5, 0.8], + peakFrequency: 20, + peakPower: 0.8, + }; + + const peaks = processor.findPeaks(shortSpectrum); + // Too short to find peaks (need at least 3 points for local maximum) + expect(peaks.length).toBe(0); + }); + }); + + describe('window functions', () => { + it('should apply Hanning window by default', () => { + processor.setOptions({ windowFunction: 'hanning' }); + + const sampleRate = 1000; + const data = new Array(256).fill(1.0); + const spectrum = processor.processAxis(data, sampleRate); + + // Should produce valid spectrum (windowing reduces DC leakage) + expect(spectrum.frequencies.length).toBeGreaterThan(0); + }); + + it('should apply Hamming window', () => { + processor.setOptions({ windowFunction: 'hamming' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should apply Blackman window', () => { + processor.setOptions({ windowFunction: 'blackman' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should work without window function', () => { + processor.setOptions({ windowFunction: 'none' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + }); + + describe('padding', () => { + it('should pad non-power-of-2 input to next power of 2', () => { + const sampleRate = 1000; + // 300 samples - should be padded to 512 + const data = new Array(300).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce valid spectrum + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + }); +}); diff --git a/src/services/calibration/__tests__/KlipperConfigParser.test.ts b/src/services/calibration/__tests__/KlipperConfigParser.test.ts new file mode 100644 index 0000000..1e9de0d --- /dev/null +++ b/src/services/calibration/__tests__/KlipperConfigParser.test.ts @@ -0,0 +1,329 @@ +/** + * @fileoverview Unit tests for the KlipperConfigParser class. + * Tests parsing of Klipper printer.cfg files with bed mesh data. + * + * @module services/calibration/__tests__/KlipperConfigParser.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { KlipperConfigParser, klipperConfigParser } from '../parsers/KlipperConfigParser'; + +describe('KlipperConfigParser', () => { + // Sample valid printer.cfg content (using spaces after #*#) + const validConfig = `# Klipper config file +[printer] +kinematics: cartesian + +#*# <---------------------- SAVE_CONFIG ----------------------> +#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated. +#*# +#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.025000, 0.012500, -0.003125, 0.009375, 0.015625, 0.018750, 0.031250 +#*# 0.018750, 0.005625, -0.010000, 0.000000, 0.009375, 0.012500, 0.025000 +#*# 0.012500, -0.001250, -0.016875, -0.006250, 0.003125, 0.006250, 0.018750 +#*# 0.006250, -0.007500, -0.023750, -0.012500, -0.003125, 0.000000, 0.012500 +#*# 0.000000, -0.013750, -0.030625, -0.018750, -0.009375, -0.006250, 0.006250 +#*# -0.006250, -0.020000, -0.037500, -0.025000, -0.015625, -0.012500, 0.000000 +#*# -0.012500, -0.026250, -0.044375, -0.031250, -0.021875, -0.018750, -0.006250 +#*# x_count = 7 +#*# y_count = 7 +#*# mesh_x_pps = 2 +#*# mesh_y_pps = 2 +#*# algo = bicubic +#*# tension = 0.2 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + + // Config with multiple profiles + const multiProfileConfig = `#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.1, 0.0, -0.1 +#*# 0.05, 0.0, -0.05 +#*# 0.0, -0.05, -0.1 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0 +#*# +#*# [bed_mesh petg] +#*# version = 1 +#*# points = +#*# 0.15, 0.05, -0.05 +#*# 0.1, 0.0, -0.1 +#*# 0.05, -0.05, -0.15 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + + let parser: KlipperConfigParser; + + beforeEach(() => { + parser = new KlipperConfigParser(); + }); + + describe('parseConfigFile', () => { + it('should parse valid config with default profile', () => { + const result = parser.parseConfigFile(validConfig); + + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data?.profileName).toBe('default'); + expect(result.data?.pointsX).toBe(7); + expect(result.data?.pointsY).toBe(7); + expect(result.data?.minX).toBe(15.0); + expect(result.data?.maxX).toBe(205.0); + expect(result.data?.minY).toBe(15.0); + expect(result.data?.maxY).toBe(205.0); + }); + + it('should parse mesh matrix correctly', () => { + const result = parser.parseConfigFile(validConfig); + + expect(result.success).toBe(true); + expect(result.data?.matrix.length).toBe(7); // 7 rows + expect(result.data?.matrix[0].length).toBe(7); // 7 columns + + // Check first row values + expect(result.data?.matrix[0][0]).toBeCloseTo(0.025, 5); + expect(result.data?.matrix[0][1]).toBeCloseTo(0.0125, 5); + expect(result.data?.matrix[0][2]).toBeCloseTo(-0.003125, 5); + }); + + it('should parse specific profile from multi-profile config', () => { + const result = parser.parseConfigFile(multiProfileConfig, 'petg'); + + expect(result.success).toBe(true); + expect(result.data?.profileName).toBe('petg'); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.15, 5); + }); + + it('should fail for non-existent profile', () => { + const result = parser.parseConfigFile(multiProfileConfig, 'nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toContain("No mesh data found for profile 'nonexistent'"); + }); + + it('should fail for config without mesh data', () => { + const noMeshConfig = ` +[printer] +kinematics: cartesian +`; + const result = parser.parseConfigFile(noMeshConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('No mesh data found'); + }); + + it('should fail for missing required parameters', () => { + const incompleteConfig = `#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.1, 0.0, -0.1 +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0`; + const result = parser.parseConfigFile(incompleteConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameter'); + }); + + it('should fail for dimension mismatch', () => { + const mismatchConfig = `#*# [bed_mesh default] +#*# points = +#*# 0.1, 0.0, -0.1 +#*# 0.05, 0.0, -0.05 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(mismatchConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('Row count mismatch'); + }); + }); + + describe('getAvailableProfiles', () => { + it('should list all available profiles', () => { + const profiles = parser.getAvailableProfiles(multiProfileConfig); + + expect(profiles).toContain('default'); + expect(profiles).toContain('petg'); + expect(profiles.length).toBe(2); + }); + + it('should return empty array for no profiles', () => { + const noProfileConfig = ` +[printer] +kinematics: cartesian +`; + const profiles = parser.getAvailableProfiles(noProfileConfig); + + expect(profiles.length).toBe(0); + }); + }); + + describe('validateMeshData', () => { + it('should validate correct mesh data', () => { + const result = parser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + + if (!result.data) { + throw new Error('Expected parsed mesh data'); + } + const validation = parser.validateMeshData(result.data); + expect(validation.isValid).toBe(true); + }); + + it('should reject mesh with dimension mismatch', () => { + const validation = parser.validateMeshData({ + matrix: [ + [0.1, 0.0], + [0.05, 0.0, -0.05], + ], // Inconsistent columns + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 2, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain("doesn't match pointsX"); + }); + + it('should reject mesh with unreasonable values', () => { + const validation = parser.validateMeshData({ + matrix: [[15.0, 0.0, -0.1]], // 15mm is way too high + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Unreasonable deviation'); + }); + + it('should reject mesh with invalid coordinate bounds', () => { + const validation = parser.validateMeshData({ + matrix: [[0.1, 0.0, -0.1]], + minX: 205, // minX > maxX + maxX: 15, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid X bounds'); + }); + + it('should reject mesh with NaN values', () => { + const validation = parser.validateMeshData({ + matrix: [[0.1, NaN, -0.1]], + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid value'); + }); + }); + + describe('createBedConfigFromMesh', () => { + it('should create bed config from mesh data', () => { + const result = parser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + + if (!result.data) { + throw new Error('Expected parsed mesh data'); + } + const bedConfig = parser.createBedConfigFromMesh(result.data); + + expect(bedConfig.sizeX).toBe(190); // 205 - 15 + expect(bedConfig.sizeY).toBe(190); // 205 - 15 + expect(bedConfig.meshPointsX).toBe(7); + expect(bedConfig.meshPointsY).toBe(7); + }); + }); + + describe('singleton instance', () => { + it('should provide a singleton parser instance', () => { + expect(klipperConfigParser).toBeInstanceOf(KlipperConfigParser); + + const result = klipperConfigParser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty lines in config', () => { + const configWithEmptyLines = ` +#*# [bed_mesh default] + +#*# points = +#*# 0.1, 0.0, -0.1 + +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(configWithEmptyLines); + expect(result.success).toBe(true); + }); + + it('should handle tabs vs spaces in point values', () => { + // Use actual tab characters + const configWithTabs = + '#*# [bed_mesh default]\n#*# points =\n#*#\t0.1,\t0.0,\t-0.1\n#*# x_count = 3\n#*# y_count = 1\n#*# min_x = 15.0\n#*# max_x = 205.0\n#*# min_y = 15.0\n#*# max_y = 205.0'; + const result = parser.parseConfigFile(configWithTabs); + expect(result.success).toBe(true); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.1, 5); + }); + + it('should handle scientific notation in values', () => { + const configWithScientific = `#*# [bed_mesh default] +#*# points = +#*# 1e-2, 0.0, -1e-2 +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(configWithScientific); + expect(result.success).toBe(true); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.01, 5); + }); + }); +}); diff --git a/src/services/calibration/__tests__/ScrewSolver.test.ts b/src/services/calibration/__tests__/ScrewSolver.test.ts new file mode 100644 index 0000000..bd708aa --- /dev/null +++ b/src/services/calibration/__tests__/ScrewSolver.test.ts @@ -0,0 +1,302 @@ +/** + * @fileoverview Unit tests for the ScrewSolver class. + * Tests screw adjustment calculations, rotation conversions, and direction determination. + * + * @module services/calibration/__tests__/ScrewSolver.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, RotationDirection } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { ScrewSolver } from '../engine/ScrewSolver'; + +describe('ScrewSolver', () => { + const config3x3: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 3, + meshPointsY: 3, + }; + + // Mesh with front-left high, rear-right low + const tiltedMesh = [ + [0.2, 0.1, 0.0], + [0.1, 0.0, -0.1], + [0.0, -0.1, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ]; + + let bed: Bed; + let solver: ScrewSolver; + + beforeEach(() => { + bed = new Bed(config3x3); + bed.setMeshData(tiltedMesh); + solver = new ScrewSolver(bed); + }); + + describe('constructor', () => { + it('should create solver with default config', () => { + const solver = new ScrewSolver(bed); + expect(solver).toBeDefined(); + }); + + it('should create solver with custom config', () => { + const customConfig: ScrewConfig = { + pitch: 0.8, + minAdjust: 0.02, + maxAdjust: 3.0, + }; + const solver = new ScrewSolver(bed, customConfig); + expect(solver).toBeDefined(); + }); + }); + + describe('setReferenceCorner', () => { + it('should set reference corner manually', () => { + solver.setReferenceCorner(BedCorner.REAR_RIGHT); + const adjustments = solver.calculateAdjustments(); + + // Rear-right should have no adjustment (it's the reference) + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0, 5); + }); + }); + + describe('autoSelectReferenceCorner', () => { + it('should select lowest corner as reference', () => { + const selected = solver.autoSelectReferenceCorner(); + // Rear-right is lowest at -0.2 + expect(selected).toBe(BedCorner.REAR_RIGHT); + }); + + it('should handle level bed', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + const selected = levelSolver.autoSelectReferenceCorner(); + // When all are equal, front-left is chosen (first checked) + expect(selected).toBe(BedCorner.FRONT_LEFT); + }); + }); + + describe('deviationToMinutes', () => { + it('should convert deviation to minutes correctly', () => { + // With default pitch of 0.5mm: + // 0.5mm deviation = 1 full turn = 360 * 60 = 21600 minutes + // 0.1mm deviation = 0.2 turns = 72 * 60 = 4320 minutes + const minutes = solver.deviationToMinutes(0.1); + expect(minutes).toBeCloseTo(4320, 0); + }); + + it('should handle negative deviation', () => { + // Should return absolute value + const minutes = solver.deviationToMinutes(-0.1); + expect(minutes).toBeCloseTo(4320, 0); + }); + + it('should return 0 for zero deviation', () => { + const minutes = solver.deviationToMinutes(0); + expect(minutes).toBe(0); + }); + }); + + describe('deviationToDegrees', () => { + it('should convert deviation to degrees correctly', () => { + // 0.5mm deviation = 360 degrees (1 full turn) + // 0.1mm deviation = 72 degrees + const degrees = solver.deviationToDegrees(0.1); + expect(degrees).toBeCloseTo(72, 0); + }); + }); + + describe('deviationToTurns', () => { + it('should convert deviation to turns correctly', () => { + // 0.5mm deviation = 1 turn + // 0.1mm deviation = 0.2 turns + const turns = solver.deviationToTurns(0.1); + expect(turns).toBeCloseTo(0.2, 5); + }); + }); + + describe('getDirection', () => { + it('should return CCW for positive deviation (bed too low)', () => { + const direction = solver.getDirection(0.1); + expect(direction).toBe(RotationDirection.COUNTERCLOCKWISE); + }); + + it('should return CW for negative deviation (bed too high)', () => { + const direction = solver.getDirection(-0.1); + expect(direction).toBe(RotationDirection.CLOCKWISE); + }); + + it('should return CW for zero deviation', () => { + const direction = solver.getDirection(0); + // Zero is not > 0, so returns CW + expect(direction).toBe(RotationDirection.CLOCKWISE); + }); + }); + + describe('heightChangeFromMinutes', () => { + it('should calculate height change for CW rotation', () => { + // CW should lower the bed (negative change) + const change = solver.heightChangeFromMinutes(4320, RotationDirection.CLOCKWISE); + expect(change).toBeLessThan(0); + expect(change).toBeCloseTo(-0.1, 2); + }); + + it('should calculate height change for CCW rotation', () => { + // CCW should raise the bed (positive change) + const change = solver.heightChangeFromMinutes(4320, RotationDirection.COUNTERCLOCKWISE); + expect(change).toBeGreaterThan(0); + expect(change).toBeCloseTo(0.1, 2); + }); + }); + + describe('calculateCornerAdjustment', () => { + it('should calculate adjustment for single corner', () => { + const adjustment = solver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + + expect(adjustment.corner).toBe(BedCorner.FRONT_LEFT); + expect(adjustment.deviation).toBeCloseTo(-0.2, 5); + expect(adjustment.direction).toBe(RotationDirection.CLOCKWISE); + expect(adjustment.requiresAdjustment).toBe(true); + }); + + it('should not require adjustment for minimal deviation', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + const adjustment = levelSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + expect(adjustment.requiresAdjustment).toBe(false); + expect(adjustment.formattedAmount).toBe('No adjustment needed'); + }); + + it('should format large adjustments as turns', () => { + // Create mesh with large deviation + const largeMesh = [ + [1.0, 0.5, 0.0], + [0.5, 0.0, -0.5], + [0.0, -0.5, -1.0], + ]; + const largeBed = new Bed(config3x3); + largeBed.setMeshData(largeMesh); + const largeSolver = new ScrewSolver(largeBed); + + const adjustment = largeSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + expect(adjustment.turns).toBeGreaterThanOrEqual(1); + expect(adjustment.formattedAmount).toContain('turns'); + }); + + it('should format medium adjustments as degrees', () => { + const medMesh = [ + [0.1, 0.05, 0.0], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + const medBed = new Bed(config3x3); + medBed.setMeshData(medMesh); + const medSolver = new ScrewSolver(medBed); + + const adjustment = medSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + // 0.1mm = 72 degrees, which is > 30 degrees + expect(adjustment.formattedAmount).toContain('°'); + }); + }); + + describe('calculateAdjustments', () => { + it('should calculate adjustments for all corners', () => { + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + + expect(adjustments.length).toBe(4); + + // Check that all corners are represented + const corners = adjustments.map((a) => a.corner); + expect(corners).toContain(BedCorner.FRONT_LEFT); + expect(corners).toContain(BedCorner.FRONT_RIGHT); + expect(corners).toContain(BedCorner.REAR_LEFT); + expect(corners).toContain(BedCorner.REAR_RIGHT); + }); + + it('should have zero deviation for reference corner', () => { + solver.autoSelectReferenceCorner(); // Sets rear-right as reference + const adjustments = solver.calculateAdjustments(); + + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0, 5); + expect(rrAdj?.requiresAdjustment).toBe(false); + }); + + it('should calculate correct deviations relative to reference', () => { + solver.setReferenceCorner(BedCorner.REAR_RIGHT); + const adjustments = solver.calculateAdjustments(); + + // Front-left is 0.2, rear-right is -0.2, target is -0.2, so deviation = -0.4 + const flAdj = adjustments.find((a) => a.corner === BedCorner.FRONT_LEFT); + expect(flAdj?.deviation).toBeCloseTo(-0.4, 5); + }); + }); + + describe('calculateAdjustmentsToTarget', () => { + it('should calculate adjustments to reach target height', () => { + const targetHeight = 0; + const adjustments = solver.calculateAdjustmentsToTarget(targetHeight); + + // Front-left at 0.2 should need adjustment to reach 0 + const flAdj = adjustments.find((a) => a.corner === BedCorner.FRONT_LEFT); + expect(flAdj?.deviation).toBeCloseTo(-0.2, 5); + expect(flAdj?.direction).toBe(RotationDirection.CLOCKWISE); // Lower it + + // Rear-right at -0.2 should need adjustment to reach 0 + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0.2, 5); + expect(rrAdj?.direction).toBe(RotationDirection.COUNTERCLOCKWISE); // Raise it + }); + }); + + describe('getAdjustmentSummary', () => { + it('should provide adjustment summary', () => { + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + const summary = solver.getAdjustmentSummary(adjustments); + + expect(summary.totalAdjustmentsNeeded).toBeGreaterThan(0); + expect(summary.maxMinutes).toBeGreaterThan(0); + expect(summary.corners.length).toBe(4); + }); + + it('should show no adjustments for level bed', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + levelSolver.autoSelectReferenceCorner(); + const adjustments = levelSolver.calculateAdjustments(); + const summary = levelSolver.getAdjustmentSummary(adjustments); + + expect(summary.totalAdjustmentsNeeded).toBe(0); + }); + }); + + describe('setConfig', () => { + it('should update screw configuration', () => { + const newConfig: Partial = { pitch: 0.8 }; + solver.setConfig(newConfig); + + // With larger pitch, same deviation should produce fewer minutes + const minutesWithNewPitch = solver.deviationToMinutes(0.1); + const expectedMinutes = (0.1 * 360 * 60) / 0.8; + expect(minutesWithNewPitch).toBeCloseTo(expectedMinutes, 0); + }); + }); +}); diff --git a/src/services/calibration/__tests__/ShaperAnalyzer.test.ts b/src/services/calibration/__tests__/ShaperAnalyzer.test.ts new file mode 100644 index 0000000..4cf3ba5 --- /dev/null +++ b/src/services/calibration/__tests__/ShaperAnalyzer.test.ts @@ -0,0 +1,305 @@ +/** + * @fileoverview Unit tests for the ShaperAnalyzer class. + * Tests input shaper analysis, shaper evaluation, and Klipper config generation. + * + * @module services/calibration/__tests__/ShaperAnalyzer.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { ShaperType } from '../../../types/calibration'; +import { + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from '../shaper/ShaperAnalyzer'; + +describe('ShaperAnalyzer', () => { + let analyzer: ShaperAnalyzer; + + beforeEach(() => { + analyzer = new ShaperAnalyzer(); + }); + + // Generate test CSV data simulating accelerometer output with a resonance at a specific frequency + function generateTestCSV(resonanceFreq: number, sampleRate = 1000, duration = 1.0): string { + const lines = ['time,accel_x,accel_y,accel_z']; + const samples = Math.floor(sampleRate * duration); + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + // Primary resonance + noise + const accelX = Math.sin(2 * Math.PI * resonanceFreq * t) + 0.1 * Math.sin(2 * Math.PI * 15 * t); + const accelY = Math.sin(2 * Math.PI * resonanceFreq * t) + 0.1 * Math.sin(2 * Math.PI * 20 * t); + const accelZ = 9.8 + 0.01 * Math.random(); + + lines.push(`${t.toFixed(4)},${accelX.toFixed(4)},${accelY.toFixed(4)},${accelZ.toFixed(4)}`); + } + + return lines.join('\n'); + } + + describe('constructor', () => { + it('should create analyzer with default options', () => { + const analyzer = new ShaperAnalyzer(); + expect(analyzer).toBeDefined(); + }); + + it('should create analyzer with custom FFT options', () => { + const analyzer = new ShaperAnalyzer({ minFreq: 10, maxFreq: 150 }); + expect(analyzer).toBeDefined(); + }); + }); + + describe('analyzeAxis', () => { + it('should analyze X axis data and return calibration', () => { + const csvData = generateTestCSV(45, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + expect(calibration.axis).toBe('x'); + expect(calibration.frequencyBins.length).toBeGreaterThan(0); + expect(calibration.powerSpectralDensity.length).toBe(calibration.frequencyBins.length); + expect(calibration.peakFrequencies.length).toBeGreaterThan(0); + expect(calibration.recommendedShaper).toBeDefined(); + expect(calibration.allShaperResults.length).toBe(5); // All 5 shaper types + }); + + it('should analyze Y axis data', () => { + const csvData = generateTestCSV(50, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'y'); + + expect(calibration.axis).toBe('y'); + expect(calibration.recommendedShaper).toBeDefined(); + }); + + it('should detect resonance frequency near input', () => { + const resonanceFreq = 45; + const csvData = generateTestCSV(resonanceFreq, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + // Peak frequency should be detected near the input resonance + expect(calibration.peakFrequencies.length).toBeGreaterThan(0); + + // At least one peak should be near our input frequency + const nearbyPeak = calibration.peakFrequencies.some( + (f) => Math.abs(f - resonanceFreq) < 10 // Within 10 Hz + ); + expect(nearbyPeak).toBe(true); + }); + }); + + describe('evaluateShapers', () => { + it('should evaluate all shaper types', () => { + const mockSpectrum = { + frequencies: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + power: [0.1, 0.2, 0.3, 0.8, 1.0, 0.7, 0.3, 0.2, 0.1, 0.05], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + expect(results.length).toBe(5); + + // Should include all shaper types + const types = results.map((r) => r.type); + expect(types).toContain('zv' as ShaperType); + expect(types).toContain('mzv' as ShaperType); + expect(types).toContain('ei' as ShaperType); + expect(types).toContain('2hump_ei' as ShaperType); + expect(types).toContain('3hump_ei' as ShaperType); + }); + + it('should sort results by score (best first)', () => { + const mockSpectrum = { + frequencies: [30, 40, 50, 60, 70], + power: [0.3, 0.7, 1.0, 0.6, 0.2], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + // Results should be sorted by score descending + for (let i = 1; i < results.length; i++) { + expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score); + } + }); + + it('should calculate valid shaper parameters', () => { + const mockSpectrum = { + frequencies: [40, 45, 50, 55, 60], + power: [0.5, 0.8, 1.0, 0.7, 0.4], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + for (const result of results) { + expect(result.frequency).toBeGreaterThan(0); + expect(result.vibrationReduction).toBeGreaterThanOrEqual(0); + expect(result.vibrationReduction).toBeLessThanOrEqual(1); + expect(result.smoothingTime).toBeGreaterThan(0); + expect(result.maxAcceleration).toBeGreaterThan(0); + expect(result.score).toBeGreaterThan(0); + } + }); + }); + + describe('findOptimalFrequency', () => { + it('should find optimal frequency for ZV shaper', () => { + const mockSpectrum = { + frequencies: [40, 45, 50, 55, 60], + power: [0.3, 0.6, 1.0, 0.5, 0.2], + peakFrequency: 50, + peakPower: 1.0, + }; + + const optimal = analyzer.findOptimalFrequency('zv' as ShaperType, 50, mockSpectrum, 10, 100); + + // Should find a frequency that provides good vibration reduction + expect(optimal).toBeGreaterThan(10); + expect(optimal).toBeLessThan(100); + }); + + it('should respect min/max frequency bounds', () => { + const mockSpectrum = { + frequencies: [20, 30, 40, 50, 60], + power: [0.3, 0.6, 1.0, 0.5, 0.2], + peakFrequency: 40, + peakPower: 1.0, + }; + + const optimal = analyzer.findOptimalFrequency('mzv' as ShaperType, 40, mockSpectrum, 30, 60); + + expect(optimal).toBeGreaterThanOrEqual(30); + expect(optimal).toBeLessThanOrEqual(60); + }); + }); + + describe('findBestShaper', () => { + it('should return the highest scoring shaper', () => { + const mockSpectrum = { + frequencies: [40, 50, 60, 70, 80], + power: [0.5, 1.0, 0.6, 0.3, 0.1], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + const best = analyzer.findBestShaper(results); + + // Best should have the highest score + expect(best.score).toBe(Math.max(...results.map((r) => r.score))); + }); + }); + + describe('generateKlipperConfig', () => { + it('should generate valid Klipper config for X axis', () => { + const result = { + type: 'mzv' as ShaperType, + frequency: 48.5, + vibrationReduction: 0.85, + smoothingTime: 12.3, + maxAcceleration: 8000, + score: 75.2, + }; + + const config = analyzer.generateKlipperConfig('x', result); + + expect(config).toContain('[input_shaper]'); + expect(config).toContain('shaper_freq_x: 48.5'); + expect(config).toContain('shaper_type_x: mzv'); + }); + + it('should generate valid Klipper config for Y axis', () => { + const result = { + type: 'ei' as ShaperType, + frequency: 52.3, + vibrationReduction: 0.78, + smoothingTime: 15.6, + maxAcceleration: 6500, + score: 68.4, + }; + + const config = analyzer.generateKlipperConfig('y', result); + + expect(config).toContain('shaper_freq_y: 52.3'); + expect(config).toContain('shaper_type_y: ei'); + }); + }); + + describe('generateReport', () => { + it('should generate human-readable calibration report', () => { + const csvData = generateTestCSV(45, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + const report = analyzer.generateReport(calibration); + + expect(report).toContain('X Axis'); + expect(report).toContain('Recommended Shaper'); + expect(report).toContain('Frequency'); + expect(report).toContain('Vibration Reduction'); + expect(report).toContain('Klipper Configuration'); + }); + + it('should include all shaper results in report', () => { + const csvData = generateTestCSV(50, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'y'); + + const report = analyzer.generateReport(calibration); + + // Should mention all shaper types + expect(report).toContain('zv'); + expect(report).toContain('mzv'); + expect(report).toContain('ei'); + }); + }); +}); + +describe('SHAPER_DEFINITIONS', () => { + it('should define all 5 shaper types', () => { + expect(SHAPER_DEFINITIONS.length).toBe(5); + }); + + it('should have valid frequency ranges', () => { + for (const def of SHAPER_DEFINITIONS) { + expect(def.minFreq).toBeGreaterThan(0); + expect(def.maxFreq).toBeGreaterThan(def.minFreq); + expect(def.initFreq).toBeGreaterThanOrEqual(def.minFreq); + expect(def.initFreq).toBeLessThanOrEqual(def.maxFreq); + } + }); + + it('should have proper type identifiers', () => { + const types = SHAPER_DEFINITIONS.map((d) => d.type); + expect(types).toContain('zv'); + expect(types).toContain('mzv'); + expect(types).toContain('ei'); + expect(types).toContain('2hump_ei'); + expect(types).toContain('3hump_ei'); + }); +}); + +describe('getShaperDefinition', () => { + it('should return definition for valid type', () => { + const def = getShaperDefinition('mzv' as ShaperType); + expect(def).toBeDefined(); + expect(def?.type).toBe('mzv'); + expect(def?.name).toContain('MZV'); + }); + + it('should return undefined for invalid type', () => { + const def = getShaperDefinition('invalid' as ShaperType); + expect(def).toBeUndefined(); + }); +}); + +describe('getAllShaperDefinitions', () => { + it('should return copy of all definitions', () => { + const defs = getAllShaperDefinitions(); + expect(defs.length).toBe(5); + expect(defs).not.toBe(SHAPER_DEFINITIONS); // Should be a copy + }); +}); diff --git a/src/services/calibration/__tests__/TapeCalculator.test.ts b/src/services/calibration/__tests__/TapeCalculator.test.ts new file mode 100644 index 0000000..14874b1 --- /dev/null +++ b/src/services/calibration/__tests__/TapeCalculator.test.ts @@ -0,0 +1,346 @@ +/** + * @fileoverview Unit tests for the TapeCalculator class. + * Tests tape compensation calculations for bed leveling. + * + * @module services/calibration/__tests__/TapeCalculator.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, TapeConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_TAPE_CONFIG } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { TapeCalculator } from '../engine/TapeCalculator'; + +describe('TapeCalculator', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Mesh with one corner high + const unevenMesh = [ + [0.2, 0.1, 0.0, -0.05, -0.1], + [0.1, 0.05, 0.0, -0.03, -0.05], + [0.0, 0.0, 0.0, 0.0, 0.0], + [-0.05, -0.03, 0.0, 0.05, 0.1], + [-0.1, -0.05, 0.0, 0.1, 0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + // Mesh with center low (bowl shape) + const bowlMesh = [ + [0.1, 0.05, 0.0, 0.05, 0.1], + [0.05, 0.02, -0.05, 0.02, 0.05], + [0.0, -0.05, -0.15, -0.05, 0.0], + [0.05, 0.02, -0.05, 0.02, 0.05], + [0.1, 0.05, 0.0, 0.05, 0.1], + ]; + + let bed: Bed; + let calculator: TapeCalculator; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(unevenMesh); + calculator = new TapeCalculator(bed); + }); + + describe('constructor', () => { + it('should create calculator with default config', () => { + const calc = new TapeCalculator(bed); + expect(calc).toBeDefined(); + }); + + it('should create calculator with custom config', () => { + const customConfig: TapeConfig = { + tapeThickness: 0.08, + minHeightDiff: 0.03, + }; + const calc = new TapeCalculator(bed, customConfig); + expect(calc).toBeDefined(); + }); + }); + + describe('setConfig', () => { + it('should update tape configuration', () => { + calculator.setConfig({ tapeThickness: 0.1 }); + // Verify by checking calculated layers + const recommendations = calculator.calculateLayers(); + // With thicker tape, fewer layers should be needed + expect(recommendations.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('calculateLayers', () => { + it('should calculate tape recommendations for corners', () => { + const recommendations = calculator.calculateLayers(); + + expect(recommendations.length).toBeGreaterThan(0); + + for (const rec of recommendations) { + expect(rec.layers).toBeGreaterThan(0); + expect(rec.totalThickness).toBe(rec.layers * DEFAULT_TAPE_CONFIG.tapeThickness); + expect(rec.deviation).toBeGreaterThan(0); + expect(Object.values(BedCorner)).toContain(rec.corner); + } + }); + + it('should not recommend tape for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelCalc = new TapeCalculator(levelBed); + + const recommendations = levelCalc.calculateLayers(); + expect(recommendations.length).toBe(0); + }); + + it('should use lowest corner as reference', () => { + const recommendations = calculator.calculateLayers(); + + // The lowest corner(s) should not have recommendations + // since they're the reference point + const corners = bed.getCornerValues(); + const lowestValue = Math.min(corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight); + + // Find which corners are at lowest value + const lowestCorners: BedCorner[] = []; + if (Math.abs(corners.frontLeft - lowestValue) < 0.001) lowestCorners.push(BedCorner.FRONT_LEFT); + if (Math.abs(corners.frontRight - lowestValue) < 0.001) lowestCorners.push(BedCorner.FRONT_RIGHT); + if (Math.abs(corners.rearLeft - lowestValue) < 0.001) lowestCorners.push(BedCorner.REAR_LEFT); + if (Math.abs(corners.rearRight - lowestValue) < 0.001) lowestCorners.push(BedCorner.REAR_RIGHT); + + // Lowest corners should not have tape recommendations + for (const rec of recommendations) { + expect(lowestCorners).not.toContain(rec.corner); + } + }); + + it('should respect corner averaging size', () => { + const rec1 = calculator.calculateLayers(0); + const rec2 = calculator.calculateLayers(1); + + // Different averaging should potentially give different results + // (depending on mesh data) + expect(rec1).toBeDefined(); + expect(rec2).toBeDefined(); + }); + }); + + describe('getTotalLayers', () => { + it('should sum all tape layers', () => { + const recommendations = calculator.calculateLayers(); + const total = calculator.getTotalLayers(); + + const expectedTotal = recommendations.reduce((sum, r) => sum + r.layers, 0); + expect(total).toBe(expectedTotal); + }); + + it('should return 0 for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelCalc = new TapeCalculator(levelBed); + + expect(levelCalc.getTotalLayers()).toBe(0); + }); + }); + + describe('findLowSpots', () => { + it('should find low spots in mesh', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + // Bowl mesh has center low, so should find spots there + expect(spots.length).toBeGreaterThan(0); + + // Center spot should be identified + const centerSpot = spots.find((s) => s.x === 2 && s.y === 2); + expect(centerSpot).toBeDefined(); + }); + + it('should skip screw corner positions', () => { + const spots = calculator.findLowSpots(unevenMesh); + + // No spots should be at corner positions + for (const spot of spots) { + const isCorner = + (spot.x === 0 && spot.y === 0) || + (spot.x === 4 && spot.y === 0) || + (spot.x === 0 && spot.y === 4) || + (spot.x === 4 && spot.y === 4); + expect(isCorner).toBe(false); + } + }); + + it('should assign correct priority based on height difference', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + for (const spot of spots) { + // Priority should be 1-3 + expect(spot.priority).toBeGreaterThanOrEqual(1); + expect(spot.priority).toBeLessThanOrEqual(3); + + // Higher deviation should have higher priority (lower number) + if (spot.heightDiff > 0.3) { + expect(spot.priority).toBe(1); + } + } + }); + + it('should sort spots by priority and height diff', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + // Should be sorted by priority (ascending) then height diff (descending) + for (let i = 1; i < spots.length; i++) { + if (spots[i].priority === spots[i - 1].priority) { + expect(spots[i].heightDiff).toBeLessThanOrEqual(spots[i - 1].heightDiff); + } else { + expect(spots[i].priority).toBeGreaterThanOrEqual(spots[i - 1].priority); + } + } + }); + }); + + describe('optimizeTapeLayout', () => { + it('should merge nearby spots', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + const optimized = bowlCalc.optimizeTapeLayout(spots); + + // Optimized should have same or fewer spots + expect(optimized.length).toBeLessThanOrEqual(spots.length); + }); + + it('should handle empty spots array', () => { + const optimized = calculator.optimizeTapeLayout([]); + expect(optimized.length).toBe(0); + }); + + it('should preserve isolated spots', () => { + const isolatedSpots = [ + { x: 1, y: 1, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }, + { x: 3, y: 3, layers: 1, heightDiff: 0.05, priority: 3, areaSize: 100 }, + ]; + + const optimized = calculator.optimizeTapeLayout(isolatedSpots); + + // Isolated spots should be preserved + expect(optimized.length).toBe(2); + }); + }); + + describe('applySpots', () => { + it('should apply tape spots to mesh', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const applied = calculator.applySpots(bowlMesh, spots); + + // Center area should be raised + expect(applied[2][2]).toBeGreaterThan(bowlMesh[2][2]); + }); + + it('should not modify original mesh', () => { + const originalValue = bowlMesh[2][2]; + const spots = [{ x: 2, y: 2, layers: 3, heightDiff: 0.15, priority: 1, areaSize: 100 }]; + + calculator.applySpots(bowlMesh, spots); + + expect(bowlMesh[2][2]).toBe(originalValue); + }); + + it('should affect surrounding area', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const applied = calculator.applySpots(bowlMesh, spots); + + // Adjacent cells should also be affected + expect(applied[1][2]).toBeGreaterThan(bowlMesh[1][2]); + expect(applied[2][1]).toBeGreaterThan(bowlMesh[2][1]); + expect(applied[3][2]).toBeGreaterThan(bowlMesh[3][2]); + expect(applied[2][3]).toBeGreaterThan(bowlMesh[2][3]); + }); + }); + + describe('estimateImprovement', () => { + it('should estimate deviation change', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + const improvement = bowlCalc.estimateImprovement(spots); + + // Should return a valid number (improvement can be positive or negative + // depending on mesh geometry and spot locations) + expect(typeof improvement).toBe('number'); + expect(Number.isFinite(improvement)).toBe(true); + }); + + it('should return 0 for no mesh data', () => { + const emptyBed = new Bed(config5x5); + const emptyCalc = new TapeCalculator(emptyBed); + + const improvement = emptyCalc.estimateImprovement([]); + expect(improvement).toBe(0); + }); + }); + + describe('getTapeInstructions', () => { + it('should generate instructions for spots', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + expect(instructions.length).toBeGreaterThan(0); + expect(instructions.join('\n')).toContain('location'); + expect(instructions.join('\n')).toContain('layer'); + }); + + it('should return "no compensation needed" for empty spots', () => { + const instructions = calculator.getTapeInstructions([]); + + expect(instructions.length).toBe(1); + expect(instructions[0]).toContain('No tape compensation required'); + }); + + it('should include position identifiers', () => { + const spots = [{ x: 1, y: 2, layers: 1, heightDiff: 0.05, priority: 3, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + // Position should be in format like "3B" (row 3, column B) + expect(instructions.join('\n')).toContain('Position'); + }); + + it('should flag high priority spots', () => { + const spots = [{ x: 2, y: 2, layers: 3, heightDiff: 0.35, priority: 1, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + expect(instructions.join('\n')).toContain('High priority'); + }); + }); +}); diff --git a/src/services/calibration/__tests__/WorkflowEngine.test.ts b/src/services/calibration/__tests__/WorkflowEngine.test.ts new file mode 100644 index 0000000..18d6d8c --- /dev/null +++ b/src/services/calibration/__tests__/WorkflowEngine.test.ts @@ -0,0 +1,381 @@ +/** + * @fileoverview Unit tests for the WorkflowEngine class. + * Tests multi-stage calibration workflow computation. + * + * @module services/calibration/__tests__/WorkflowEngine.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, CalibrationSettings } from '../../../types/calibration'; +import { + DEFAULT_CALIBRATION_SETTINGS, + DEFAULT_SCREW_CONFIG, + DEFAULT_TAPE_CONFIG, + WorkflowStage, +} from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { ScrewSolver } from '../engine/ScrewSolver'; +import { TapeCalculator } from '../engine/TapeCalculator'; +import { WorkflowEngine } from '../engine/WorkflowEngine'; + +describe('WorkflowEngine', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Tilted mesh requiring calibration + const tiltedMesh = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + let bed: Bed; + let screwSolver: ScrewSolver; + let tapeCalculator: TapeCalculator; + let settings: CalibrationSettings; + let engine: WorkflowEngine; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(tiltedMesh); + + screwSolver = new ScrewSolver(bed, DEFAULT_SCREW_CONFIG); + tapeCalculator = new TapeCalculator(bed, DEFAULT_TAPE_CONFIG); + settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + + engine = new WorkflowEngine(bed, screwSolver, tapeCalculator, settings); + }); + + describe('constructor', () => { + it('should create workflow engine', () => { + expect(engine).toBeDefined(); + }); + }); + + describe('setSettings', () => { + it('should update settings', () => { + const newSettings = { ...settings, workflow: { enableBelt: false, enableScrews: true, enableTape: true } }; + engine.setSettings(newSettings); + // Verify by computing workflow + const workflow = engine.computeWorkflow(); + expect(workflow).toBeDefined(); + }); + }); + + describe('computeWorkflow', () => { + it('should compute complete workflow', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.currentStage).toBe(WorkflowStage.COMPLETE); + expect(workflow.startTime).toBeGreaterThan(0); + expect(workflow.completedStages.length).toBeGreaterThan(0); + expect(workflow.initialRange).toBeGreaterThan(0); + }); + + it('should include all stages', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.stages.has(WorkflowStage.INITIAL)).toBe(true); + expect(workflow.stages.has(WorkflowStage.BELT_SYNC)).toBe(true); + expect(workflow.stages.has(WorkflowStage.SCREW_ADJUST)).toBe(true); + expect(workflow.stages.has(WorkflowStage.TAPE_COMPENSATE)).toBe(true); + expect(workflow.stages.has(WorkflowStage.THERMAL_PREDICT)).toBe(true); + }); + + it('should calculate improvement percentage', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.initialRange).toBeGreaterThan(0); + expect(workflow.improvementPercent).toBeDefined(); + }); + + it('should include screw adjustments', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.screwAdjustments).toBeDefined(); + expect(workflow.screwAdjustments?.length).toBe(4); + }); + + it('should include belt sync result', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.beltSyncResult).toBeDefined(); + expect(typeof workflow.beltSyncResult?.needsSync).toBe('boolean'); + }); + + it('should include tape recommendations', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.tapeRecommendations).toBeDefined(); + }); + + it('should include thermal prediction', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.thermalPrediction).toBeDefined(); + expect(workflow.thermalPrediction?.predictedMesh).toBeDefined(); + }); + + it('should throw when no mesh data loaded', () => { + const emptyBed = new Bed(config5x5); + const emptySolver = new ScrewSolver(emptyBed); + const emptyCalc = new TapeCalculator(emptyBed); + const emptyEngine = new WorkflowEngine(emptyBed, emptySolver, emptyCalc, settings); + + expect(() => emptyEngine.computeWorkflow()).toThrow('No mesh data loaded'); + }); + }); + + describe('stage processing', () => { + it('should process initial stage correctly', () => { + const workflow = engine.computeWorkflow(); + const initialStage = workflow.stages.get(WorkflowStage.INITIAL); + + expect(initialStage).toBeDefined(); + expect(initialStage?.success).toBe(true); + expect(initialStage?.metrics.deviation).toBeGreaterThan(0); + }); + + it('should process belt stage', () => { + const workflow = engine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + expect(beltStage).toBeDefined(); + expect(beltStage?.success).toBe(true); + }); + + it('should process screw stage', () => { + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + expect(screwStage).toBeDefined(); + expect(screwStage?.success).toBe(true); + }); + + it('should process tape stage', () => { + const workflow = engine.computeWorkflow(); + const tapeStage = workflow.stages.get(WorkflowStage.TAPE_COMPENSATE); + + expect(tapeStage).toBeDefined(); + expect(tapeStage?.success).toBe(true); + }); + + it('should process thermal stage', () => { + const workflow = engine.computeWorkflow(); + const thermalStage = workflow.stages.get(WorkflowStage.THERMAL_PREDICT); + + expect(thermalStage).toBeDefined(); + expect(thermalStage?.success).toBe(true); + }); + }); + + describe('stage disabling', () => { + it('should skip belt stage when disabled', () => { + const noSyncSettings = { + ...settings, + workflow: { enableBelt: false, enableScrews: true, enableTape: true }, + }; + engine.setSettings(noSyncSettings); + + const workflow = engine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + expect(beltStage?.data).toHaveProperty('enabled', false); + }); + + it('should skip screw stage when disabled', () => { + const noScrewSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: false, enableTape: true }, + }; + engine.setSettings(noScrewSettings); + + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + expect(screwStage?.data).toHaveProperty('enabled', false); + }); + + it('should skip tape stage when disabled', () => { + const noTapeSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: true, enableTape: false }, + }; + engine.setSettings(noTapeSettings); + + const workflow = engine.computeWorkflow(); + const tapeStage = workflow.stages.get(WorkflowStage.TAPE_COMPENSATE); + + expect(tapeStage?.data).toHaveProperty('enabled', false); + }); + }); + + describe('getStageResult', () => { + it('should return result for specific stage', () => { + const workflow = engine.computeWorkflow(); + const result = engine.getStageResult(workflow, WorkflowStage.SCREW_ADJUST); + + expect(result).toBeDefined(); + expect(result?.stage).toBe(WorkflowStage.SCREW_ADJUST); + }); + + it('should return undefined for non-existent stage', () => { + const workflow = engine.computeWorkflow(); + // Remove a stage manually for testing + workflow.stages.delete(WorkflowStage.BELT_SYNC); + + const result = engine.getStageResult(workflow, WorkflowStage.BELT_SYNC); + expect(result).toBeUndefined(); + }); + }); + + describe('canSkipStage', () => { + it('should indicate belt stage can be skipped when disabled', () => { + const noSyncSettings = { + ...settings, + workflow: { enableBelt: false, enableScrews: true, enableTape: true }, + }; + engine.setSettings(noSyncSettings); + + expect(engine.canSkipStage(WorkflowStage.BELT_SYNC)).toBe(true); + }); + + it('should indicate screw stage can be skipped when disabled', () => { + const noScrewSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: false, enableTape: true }, + }; + engine.setSettings(noScrewSettings); + + expect(engine.canSkipStage(WorkflowStage.SCREW_ADJUST)).toBe(true); + }); + + it('should indicate tape stage can be skipped when disabled', () => { + const noTapeSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: true, enableTape: false }, + }; + engine.setSettings(noTapeSettings); + + expect(engine.canSkipStage(WorkflowStage.TAPE_COMPENSATE)).toBe(true); + }); + + it('should not skip INITIAL or COMPLETE stages', () => { + expect(engine.canSkipStage(WorkflowStage.INITIAL)).toBe(false); + expect(engine.canSkipStage(WorkflowStage.COMPLETE)).toBe(false); + }); + }); + + describe('thermal preset handling', () => { + it('should apply thermal preset when selected', () => { + const thermalSettings = { + ...settings, + activeThermalPreset: 'PEI Spring Steel', + }; + engine.setSettings(thermalSettings); + + const workflow = engine.computeWorkflow(); + expect(workflow.thermalPrediction).toBeDefined(); + }); + + it('should skip thermal when no preset selected', () => { + const noThermalSettings = { + ...settings, + activeThermalPreset: null, + }; + engine.setSettings(noThermalSettings); + + const workflow = engine.computeWorkflow(); + const thermalStage = workflow.stages.get(WorkflowStage.THERMAL_PREDICT); + + // Should have warning about no preset + expect(thermalStage?.data).toHaveProperty('warnings'); + }); + }); + + describe('level bed handling', () => { + it('should handle level bed gracefully', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + + const levelSolver = new ScrewSolver(levelBed); + const levelCalc = new TapeCalculator(levelBed); + const levelEngine = new WorkflowEngine(levelBed, levelSolver, levelCalc, settings); + + const workflow = levelEngine.computeWorkflow(); + + expect(workflow.currentStage).toBe(WorkflowStage.COMPLETE); + expect(workflow.initialRange).toBeCloseTo(0, 5); + + // Screw stage should indicate no adjustments needed + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + expect(screwStage?.data).toHaveProperty('actions'); + }); + }); + + describe('stage actions', () => { + it('should generate belt actions when needed', () => { + // X-tilt mesh + const xTiltMesh = [ + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + ]; + + const xTiltBed = new Bed(config5x5); + xTiltBed.setMeshData(xTiltMesh); + + const xSolver = new ScrewSolver(xTiltBed); + const xCalc = new TapeCalculator(xTiltBed); + const xEngine = new WorkflowEngine(xTiltBed, xSolver, xCalc, settings); + + const workflow = xEngine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + // Should have belt actions due to X-axis tilt + expect(beltStage?.data).toHaveProperty('actions'); + const actions = (beltStage?.data as { actions: unknown[] }).actions; + expect(actions.length).toBeGreaterThan(0); + }); + + it('should generate screw actions for tilted bed', () => { + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + const actions = (screwStage?.data as { actions: unknown[] }).actions; + expect(actions.length).toBeGreaterThan(0); + }); + }); + + describe('deviation tracking', () => { + it('should track deviation through stages', () => { + const workflow = engine.computeWorkflow(); + + const initialStage = workflow.stages.get(WorkflowStage.INITIAL); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + const initialDev = (initialStage?.data as { deviation: number }).deviation; + const screwDev = (screwStage?.data as { deviation: number }).deviation; + + // Deviation should decrease after screw adjustment + expect(screwDev).toBeLessThanOrEqual(initialDev); + }); + }); +}); diff --git a/src/services/calibration/engine/Bed.ts b/src/services/calibration/engine/Bed.ts new file mode 100644 index 0000000..0c2c22d --- /dev/null +++ b/src/services/calibration/engine/Bed.ts @@ -0,0 +1,386 @@ +/** + * @fileoverview Model representing the 3D printer bed with mesh data management. + * Provides methods for analyzing bed mesh, calculating corner heights, and + * simulating adjustments. + * + * @module services/calibration/engine/Bed + */ + +import type { BedConfig, BedCorners, MeshData } from '../../../types/calibration'; +import { BedCorner, DEFAULT_BED_CONFIG } from '../../../types/calibration'; + +/** + * Corner index mapping for mesh data access. + * Maps corner names to [row, col] indices in the mesh matrix. + */ +interface CornerIndices { + frontLeft: [number, number]; + frontRight: [number, number]; + rearLeft: [number, number]; + rearRight: [number, number]; +} + +/** + * Mesh statistics. + */ +export interface MeshStats { + mean: number; + min: number; + max: number; + range: number; + standardDeviation: number; +} + +/** + * Model of the printer bed with mesh data management. + */ +export class Bed { + /** Bed configuration (dimensions, mesh points) */ + public readonly config: BedConfig; + + /** Current mesh data (2D array of Z heights) */ + private _meshData: number[][] | null = null; + + /** Corner indices for mesh access */ + private readonly cornerIndices: CornerIndices; + + /** + * Create a new Bed instance. + * + * @param config - Bed configuration, defaults to AD5M config + */ + constructor(config: BedConfig = DEFAULT_BED_CONFIG) { + this.config = config; + + // Calculate corner indices based on mesh dimensions + // Note: mesh is stored as [row][col] where row=Y, col=X + // Front = row 0, Rear = last row + // Left = col 0, Right = last col + this.cornerIndices = { + frontLeft: [0, 0], + frontRight: [0, config.meshPointsX - 1], + rearLeft: [config.meshPointsY - 1, 0], + rearRight: [config.meshPointsY - 1, config.meshPointsX - 1], + }; + } + + /** + * Get the current mesh data. + */ + get meshData(): number[][] | null { + return this._meshData; + } + + /** + * Check if mesh data is loaded. + */ + get hasMeshData(): boolean { + return this._meshData !== null; + } + + /** + * Set mesh data with dimension validation. + * + * @param data - 2D array of Z-height values + * @throws Error if dimensions don't match config + */ + setMeshData(data: number[][]): void { + // Validate row count (Y dimension) + if (data.length !== this.config.meshPointsY) { + throw new Error(`Invalid mesh row count: ${data.length}, expected: ${this.config.meshPointsY}`); + } + + // Validate column count for each row (X dimension) + for (let row = 0; row < data.length; row++) { + if (data[row].length !== this.config.meshPointsX) { + throw new Error( + `Invalid mesh column count in row ${row}: ${data[row].length}, expected: ${this.config.meshPointsX}` + ); + } + } + + // Deep copy to prevent external modification + this._meshData = data.map((row) => [...row]); + } + + /** + * Load mesh data from a parsed MeshData object. + * + * @param meshData - Parsed mesh data from config file + */ + loadFromMeshData(meshData: MeshData): void { + // Update config from mesh data dimensions + (this.config as BedConfig).meshPointsX = meshData.pointsX; + (this.config as BedConfig).meshPointsY = meshData.pointsY; + (this.config as BedConfig).sizeX = meshData.maxX - meshData.minX; + (this.config as BedConfig).sizeY = meshData.maxY - meshData.minY; + + // Update corner indices + this.cornerIndices.frontRight = [0, meshData.pointsX - 1]; + this.cornerIndices.rearLeft = [meshData.pointsY - 1, 0]; + this.cornerIndices.rearRight = [meshData.pointsY - 1, meshData.pointsX - 1]; + + this._meshData = meshData.matrix.map((row) => [...row]); + } + + /** + * Clear mesh data. + */ + clearMeshData(): void { + this._meshData = null; + } + + /** + * Get the height value at a specific corner. + * + * @param corner - Which corner to get + * @param averagingSize - Number of points to average around corner (0 = just corner point) + * @returns Height value at corner + * @throws Error if no mesh data loaded + */ + getCornerHeight(corner: BedCorner, averagingSize = 0): number { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const indices = this.cornerIndices[corner]; + const [row, col] = indices; + + if (averagingSize <= 0) { + return this._meshData[row][col]; + } + + // Calculate averaging bounds + const rowStart = Math.max(0, row - averagingSize); + const rowEnd = Math.min(this.config.meshPointsY, row + averagingSize + 1); + const colStart = Math.max(0, col - averagingSize); + const colEnd = Math.min(this.config.meshPointsX, col + averagingSize + 1); + + // Calculate average + let sum = 0; + let count = 0; + + for (let r = rowStart; r < rowEnd; r++) { + for (let c = colStart; c < colEnd; c++) { + sum += this._meshData[r][c]; + count++; + } + } + + return sum / count; + } + + /** + * Get all corner heights. + * + * @param averagingSize - Number of points to average around each corner + * @returns Object with height values for all corners + */ + getCornerValues(averagingSize = 0): BedCorners { + return { + frontLeft: this.getCornerHeight(BedCorner.FRONT_LEFT, averagingSize), + frontRight: this.getCornerHeight(BedCorner.FRONT_RIGHT, averagingSize), + rearLeft: this.getCornerHeight(BedCorner.REAR_LEFT, averagingSize), + rearRight: this.getCornerHeight(BedCorner.REAR_RIGHT, averagingSize), + }; + } + + /** + * Get height at a specific mesh point. + * + * @param row - Row index (Y) + * @param col - Column index (X) + * @returns Height value at point + * @throws Error if coordinates out of range or no mesh data + */ + getPointHeight(row: number, col: number): number { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + if (row < 0 || row >= this.config.meshPointsY || col < 0 || col >= this.config.meshPointsX) { + throw new Error( + `Coordinates (${row}, ${col}) out of range. Mesh size: ${this.config.meshPointsY}x${this.config.meshPointsX}` + ); + } + + return this._meshData[row][col]; + } + + /** + * Get mesh statistics. + * + * @returns Statistics about the mesh (mean, min, max, range, std dev) + * @throws Error if no mesh data loaded + */ + getMeshStats(): MeshStats { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + // Flatten mesh to single array for calculations + const values: number[] = []; + for (const row of this._meshData) { + values.push(...row); + } + + const n = values.length; + const sum = values.reduce((a, b) => a + b, 0); + const mean = sum / n; + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + + // Calculate standard deviation + const squaredDiffs = values.map((v) => (v - mean) ** 2); + const variance = squaredDiffs.reduce((a, b) => a + b, 0) / n; + const standardDeviation = Math.sqrt(variance); + + return { mean, min, max, range, standardDeviation }; + } + + /** + * Get minimum height value in mesh. + */ + getMinValue(): number { + return this.getMeshStats().min; + } + + /** + * Get maximum height value in mesh. + */ + getMaxValue(): number { + return this.getMeshStats().max; + } + + /** + * Get height range (max - min) in mesh. + */ + getRange(): number { + return this.getMeshStats().range; + } + + /** + * Get mean height value in mesh. + */ + getMeanValue(): number { + return this.getMeshStats().mean; + } + + /** + * Get physical distance between mesh points. + * + * @returns Object with mm per point in X and Y directions + */ + getMmPerPoint(): { x: number; y: number } { + return { + x: this.config.sizeX / (this.config.meshPointsX - 1), + y: this.config.sizeY / (this.config.meshPointsY - 1), + }; + } + + /** + * Generate an ideal (flat) reference plane at mean height. + * + * @returns 2D array filled with mean height value + * @throws Error if no mesh data loaded + */ + generateIdealPlane(): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const meanHeight = this.getMeanValue(); + const plane: number[][] = []; + + for (let row = 0; row < this.config.meshPointsY; row++) { + plane.push(new Array(this.config.meshPointsX).fill(meanHeight)); + } + + return plane; + } + + /** + * Calculate deviation map from ideal (flat) plane. + * + * @returns 2D array of deviations (positive = above ideal, negative = below) + * @throws Error if no mesh data loaded + */ + calculateDeviationMap(): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const idealPlane = this.generateIdealPlane(); + const deviationMap: number[][] = []; + + for (let row = 0; row < this.config.meshPointsY; row++) { + const deviationRow: number[] = []; + for (let col = 0; col < this.config.meshPointsX; col++) { + deviationRow.push(this._meshData[row][col] - idealPlane[row][col]); + } + deviationMap.push(deviationRow); + } + + return deviationMap; + } + + /** + * Simulate the result of corner adjustments on the mesh. + * Adjustments at corners influence nearby points with decreasing effect. + * + * @param cornerAdjustments - Partial object with corner adjustments in mm + * @returns Simulated mesh after adjustments + * @throws Error if no mesh data loaded + */ + simulateAdjustment(cornerAdjustments: Partial>): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + // Deep copy current mesh + const adjustedMesh = this._meshData.map((row) => [...row]); + + // Maximum diagonal distance for influence calculation + const maxDistance = Math.sqrt((this.config.meshPointsY - 1) ** 2 + (this.config.meshPointsX - 1) ** 2); + + // Apply each corner adjustment + for (const [corner, adjustment] of Object.entries(cornerAdjustments)) { + if (adjustment === undefined || adjustment === 0) { + continue; + } + + const indices = this.cornerIndices[corner as BedCorner]; + if (!indices) { + continue; + } + + const [cornerRow, cornerCol] = indices; + + // Apply adjustment with distance-based influence + for (let row = 0; row < this.config.meshPointsY; row++) { + for (let col = 0; col < this.config.meshPointsX; col++) { + const distance = Math.sqrt((row - cornerRow) ** 2 + (col - cornerCol) ** 2); + const influence = Math.max(0, 1 - distance / maxDistance); + adjustedMesh[row][col] += adjustment * influence; + } + } + } + + return adjustedMesh; + } + + /** + * Create a deep copy of this Bed instance. + * + * @returns New Bed instance with copied data + */ + clone(): Bed { + const cloned = new Bed({ ...this.config }); + if (this._meshData) { + cloned.setMeshData(this._meshData.map((row) => [...row])); + } + return cloned; + } +} diff --git a/src/services/calibration/engine/DeviationAnalyzer.ts b/src/services/calibration/engine/DeviationAnalyzer.ts new file mode 100644 index 0000000..54b575c --- /dev/null +++ b/src/services/calibration/engine/DeviationAnalyzer.ts @@ -0,0 +1,347 @@ +/** + * @fileoverview Analyzer for bed mesh deviations and calibration strategy determination. + * Analyzes mesh data to identify problems and recommend calibration approaches. + * + * @module services/calibration/engine/DeviationAnalyzer + */ + +import type { AnalysisResult, BedCorners, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_SCREW_CONFIG } from '../../../types/calibration'; +import type { Bed } from './Bed'; +import { ScrewSolver } from './ScrewSolver'; + +/** + * Statistics about bed mesh deviations. + */ +export interface DeviationStats { + /** Mean height of the mesh */ + meanHeight: number; + /** Maximum deviation from mean */ + maxDeviation: number; + /** Corner deviations from mean */ + cornerDeviations: BedCorners; + /** Whether there are critical deviations */ + hasCriticalDeviation: boolean; +} + +/** + * Leveling stage determination result. + */ +export interface LevelingStage { + /** Whether screw adjustment is needed */ + needsScrewAdjustment: boolean; + /** Whether screws can fix the problem (within max adjustment) */ + canUseScrews: boolean; + /** Whether tape compensation is needed */ + needsTape: boolean; + /** Maximum height difference between corners */ + maxCornerDiff: number; + /** List of corners with problems */ + problematicCorners: BedCorner[]; +} + +/** + * Options for the deviation analyzer. + */ +export interface DeviationAnalyzerOptions { + /** Number of grid points to average around corners */ + cornerAveragingSize: number; + /** Threshold for recommending screw adjustment (mm) */ + screwThreshold: number; + /** Threshold for recommending tape compensation (mm) */ + tapeThreshold: number; + /** Threshold for recommending belt sync (mm) */ + beltThreshold: number; + /** Screw configuration */ + screwConfig: ScrewConfig; +} + +/** + * Default analyzer options. + */ +const DEFAULT_OPTIONS: DeviationAnalyzerOptions = { + cornerAveragingSize: 1, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: DEFAULT_SCREW_CONFIG, +}; + +/** + * Analyzer for bed mesh deviations and calibration recommendations. + */ +export class DeviationAnalyzer { + /** Reference to bed model */ + private readonly bed: Bed; + + /** Analyzer options */ + private options: DeviationAnalyzerOptions; + + /** Screw solver for adjustment calculations */ + private readonly screwSolver: ScrewSolver; + + /** + * Create a new DeviationAnalyzer. + * + * @param bed - Bed model with mesh data + * @param options - Analyzer options + */ + constructor(bed: Bed, options: Partial = {}) { + this.bed = bed; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.screwSolver = new ScrewSolver(bed, this.options.screwConfig); + } + + /** + * Update analyzer options. + * + * @param options - New options (partial) + */ + setOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + if (options.screwConfig) { + this.screwSolver.setConfig(options.screwConfig); + } + } + + /** + * Set corner averaging size. + * + * @param size - Number of grid points to average + */ + setCornerAveragingSize(size: number): void { + this.options.cornerAveragingSize = Math.max(0, Math.floor(size)); + } + + /** + * Set screw adjustment threshold. + * + * @param threshold - Threshold in mm + */ + setScrewThreshold(threshold: number): void { + this.options.screwThreshold = threshold; + } + + /** + * Set tape compensation threshold. + * + * @param threshold - Threshold in mm + */ + setTapeThreshold(threshold: number): void { + this.options.tapeThreshold = threshold; + } + + /** + * Get deviation statistics from the mesh. + * + * @returns DeviationStats object + */ + getStats(): DeviationStats { + const stats = this.bed.getMeshStats(); + const meanHeight = stats.mean; + + // Calculate corner deviations from mean + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + const cornerDeviations: BedCorners = { + frontLeft: Math.abs(corners.frontLeft - meanHeight), + frontRight: Math.abs(corners.frontRight - meanHeight), + rearLeft: Math.abs(corners.rearLeft - meanHeight), + rearRight: Math.abs(corners.rearRight - meanHeight), + }; + + const maxDeviation = Math.max( + cornerDeviations.frontLeft, + cornerDeviations.frontRight, + cornerDeviations.rearLeft, + cornerDeviations.rearRight + ); + + return { + meanHeight, + maxDeviation, + cornerDeviations, + hasCriticalDeviation: maxDeviation > this.options.screwThreshold, + }; + } + + /** + * Analyze and determine the required leveling stages. + * + * @returns LevelingStage object + */ + analyzeLevelingStage(): LevelingStage { + const stats = this.getStats(); + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + + // Find max difference between any corners + const heights = [corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight]; + const maxCornerDiff = Math.max(...heights) - Math.min(...heights); + + // Identify problematic corners + const problematicCorners: BedCorner[] = []; + for (const [corner, deviation] of Object.entries(stats.cornerDeviations)) { + if (deviation > this.options.tapeThreshold) { + problematicCorners.push(corner as BedCorner); + } + } + + // Determine if screws can fix the problem + const canUseScrews = maxCornerDiff <= this.options.screwConfig.maxAdjust; + + // Determine required stages + const needsScrewAdjustment = stats.maxDeviation > this.options.screwThreshold; + const needsTape = stats.maxDeviation > this.options.tapeThreshold; + + return { + needsScrewAdjustment, + canUseScrews, + needsTape, + maxCornerDiff, + problematicCorners, + }; + } + + /** + * Perform full analysis and return comprehensive results. + * + * @returns AnalysisResult object + */ + analyze(): AnalysisResult { + const meshStats = this.bed.getMeshStats(); + const deviationStats = this.getStats(); + const levelingStage = this.analyzeLevelingStage(); + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + + // Find the reference corner (lowest) + let referenceCorner = BedCorner.FRONT_LEFT; + let lowestValue = corners.frontLeft; + + if (corners.frontRight < lowestValue) { + referenceCorner = BedCorner.FRONT_RIGHT; + lowestValue = corners.frontRight; + } + if (corners.rearLeft < lowestValue) { + referenceCorner = BedCorner.REAR_LEFT; + lowestValue = corners.rearLeft; + } + if (corners.rearRight < lowestValue) { + referenceCorner = BedCorner.REAR_RIGHT; + } + + // Check if belt sync is needed (significant X-axis tilt) + // Compare front-left to front-right and rear-left to rear-right + const frontTilt = Math.abs(corners.frontLeft - corners.frontRight); + const rearTilt = Math.abs(corners.rearLeft - corners.rearRight); + const needsBeltSync = frontTilt > this.options.beltThreshold || rearTilt > this.options.beltThreshold; + + return { + meshRange: meshStats.range, + maxDeviation: deviationStats.maxDeviation, + minDeviation: 0, // Min deviation from mean is always 0 at the mean point + averageDeviation: meshStats.mean, + standardDeviation: meshStats.standardDeviation, + cornerDeviations: corners, + referenceCorner, + recommendations: { + needsBeltSync, + needsScrewAdjust: levelingStage.needsScrewAdjustment, + needsTapeCompensation: levelingStage.needsTape && !levelingStage.canUseScrews, + }, + }; + } + + /** + * Get the ideal (flat) reference plane. + * + * @returns 2D array representing the ideal plane at mean height + */ + getIdealPlane(): number[][] { + return this.bed.generateIdealPlane(); + } + + /** + * Estimate bed state after screw adjustments. + * + * @returns Simulated mesh after applying screw adjustments + */ + estimateBedAfterScrewAdjustment(): number[][] { + const meanHeight = this.bed.getMeanValue(); + + // Auto-select reference corner + this.screwSolver.autoSelectReferenceCorner(this.options.cornerAveragingSize); + + // Calculate adjustments + const adjustments = this.screwSolver.calculateAdjustmentsToTarget(meanHeight, this.options.cornerAveragingSize); + + // Build adjustment map for simulation + const cornerAdjustments: Partial> = {}; + for (const adj of adjustments) { + if (adj.requiresAdjustment) { + const heightChange = this.screwSolver.heightChangeFromMinutes(adj.minutes, adj.direction); + cornerAdjustments[adj.corner] = heightChange; + } + } + + // Simulate the adjustment + return this.bed.simulateAdjustment(cornerAdjustments); + } + + /** + * Find the optimal calibration strategy. + * + * @returns Strategy recommendation object + */ + findOptimalStrategy(): { + originalDeviation: number; + deviationAfterScrews: number; + needsScrews: boolean; + needsTape: boolean; + expectedFinalDeviation: number; + simulatedBedAfterScrews: number[][]; + } { + const meshStats = this.bed.getMeshStats(); + const originalDeviation = meshStats.range / 2; // Half of range as "deviation" + + // Simulate screw adjustment + const bedAfterScrews = this.estimateBedAfterScrewAdjustment(); + + // Calculate deviation after screws + let sum = 0; + let count = 0; + for (const row of bedAfterScrews) { + for (const val of row) { + sum += val; + count++; + } + } + const meanAfter = sum / count; + + let maxDeviationAfter = 0; + for (const row of bedAfterScrews) { + for (const val of row) { + maxDeviationAfter = Math.max(maxDeviationAfter, Math.abs(val - meanAfter)); + } + } + + const needsScrews = originalDeviation > this.options.screwThreshold; + const needsTape = maxDeviationAfter > this.options.tapeThreshold; + + return { + originalDeviation, + deviationAfterScrews: maxDeviationAfter, + needsScrews, + needsTape, + expectedFinalDeviation: needsTape ? this.options.tapeThreshold : maxDeviationAfter, + simulatedBedAfterScrews: bedAfterScrews, + }; + } + + /** + * Get the screw solver instance for direct adjustment calculations. + * + * @returns ScrewSolver instance + */ + getScrewSolver(): ScrewSolver { + return this.screwSolver; + } +} diff --git a/src/services/calibration/engine/ScrewSolver.ts b/src/services/calibration/engine/ScrewSolver.ts new file mode 100644 index 0000000..ba32445 --- /dev/null +++ b/src/services/calibration/engine/ScrewSolver.ts @@ -0,0 +1,259 @@ +/** + * @fileoverview Calculator for bed leveling screw adjustments. + * Converts height deviations to rotation amounts (minutes, degrees, turns) + * and determines rotation direction for each corner screw. + * + * @module services/calibration/engine/ScrewSolver + */ + +import type { ScrewAdjustment, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_SCREW_CONFIG, RotationDirection } from '../../../types/calibration'; +import type { Bed } from './Bed'; + +/** + * Calculator for screw adjustments based on corner height deviations. + */ +export class ScrewSolver { + /** Screw configuration */ + private config: ScrewConfig; + + /** Reference to the bed model */ + private readonly bed: Bed; + + /** Which corner is used as reference (typically the lowest) */ + private referenceCorner: BedCorner = BedCorner.FRONT_LEFT; + + /** Calculated mm per minute of rotation */ + private readonly mmPerMinute: number; + + /** + * Create a new ScrewSolver. + * + * @param bed - Bed model with mesh data + * @param config - Screw configuration, defaults to standard config + */ + constructor(bed: Bed, config: ScrewConfig = DEFAULT_SCREW_CONFIG) { + this.bed = bed; + this.config = { ...config }; + + // Pre-calculate conversion factors + // Full rotation (360 degrees) = pitch mm of height change + // 1 minute = 1/60 of a degree + this.mmPerMinute = this.config.pitch / (360 * 60); // mm per arc minute + } + + /** + * Update the screw configuration. + * + * @param config - New screw configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Set which corner to use as the reference point. + * The reference corner won't need adjustment. + * + * @param corner - Corner to use as reference + */ + setReferenceCorner(corner: BedCorner): void { + this.referenceCorner = corner; + } + + /** + * Automatically select the lowest corner as reference. + * + * @param averagingSize - Size of area to average for corner height + * @returns The selected reference corner + */ + autoSelectReferenceCorner(averagingSize = 0): BedCorner { + const corners = this.bed.getCornerValues(averagingSize); + + let lowestCorner = BedCorner.FRONT_LEFT; + let lowestValue = corners.frontLeft; + + if (corners.frontRight < lowestValue) { + lowestCorner = BedCorner.FRONT_RIGHT; + lowestValue = corners.frontRight; + } + if (corners.rearLeft < lowestValue) { + lowestCorner = BedCorner.REAR_LEFT; + lowestValue = corners.rearLeft; + } + if (corners.rearRight < lowestValue) { + lowestCorner = BedCorner.REAR_RIGHT; + } + + this.referenceCorner = lowestCorner; + return lowestCorner; + } + + /** + * Convert height deviation to rotation minutes. + * + * @param deviation - Height deviation in mm + * @returns Rotation in minutes (absolute value) + */ + deviationToMinutes(deviation: number): number { + // minutes = deviation / (pitch / (360 * 60)) + // Simplified: minutes = deviation * 360 * 60 / pitch + return (Math.abs(deviation) * 360 * 60) / this.config.pitch; + } + + /** + * Convert height deviation to rotation degrees. + * + * @param deviation - Height deviation in mm + * @returns Rotation in degrees (absolute value) + */ + deviationToDegrees(deviation: number): number { + return (Math.abs(deviation) * 360) / this.config.pitch; + } + + /** + * Convert height deviation to full turns. + * + * @param deviation - Height deviation in mm + * @returns Number of full rotations (absolute value) + */ + deviationToTurns(deviation: number): number { + return Math.abs(deviation) / this.config.pitch; + } + + /** + * Determine rotation direction based on deviation. + * + * @param deviation - Height deviation in mm (positive = too high, negative = too low) + * @returns Rotation direction + */ + getDirection(deviation: number): RotationDirection { + // Positive deviation = bed too low at this corner = turn CCW to raise it + // Negative deviation = bed too high at this corner = turn CW to lower it + return deviation > 0 ? RotationDirection.COUNTERCLOCKWISE : RotationDirection.CLOCKWISE; + } + + /** + * Calculate height change from rotation in minutes. + * + * @param minutes - Rotation amount in minutes + * @param direction - Rotation direction + * @returns Height change in mm + */ + heightChangeFromMinutes(minutes: number, direction: RotationDirection): number { + const heightChange = minutes * this.mmPerMinute; + // CW lowers the bed (negative change), CCW raises it (positive change) + return direction === RotationDirection.CLOCKWISE ? -heightChange : heightChange; + } + + /** + * Calculate adjustment for a single corner. + * + * @param corner - Which corner to calculate + * @param targetHeight - Target height to adjust to + * @param averagingSize - Size of area to average for corner height + * @returns ScrewAdjustment object + */ + calculateCornerAdjustment(corner: BedCorner, targetHeight: number, averagingSize = 0): ScrewAdjustment { + const currentHeight = this.bed.getCornerHeight(corner, averagingSize); + const deviation = targetHeight - currentHeight; + + // Check if adjustment is needed + const requiresAdjustment = Math.abs(deviation) >= this.config.minAdjust; + + // Clamp deviation to max adjustment + const clampedDeviation = Math.sign(deviation) * Math.min(Math.abs(deviation), this.config.maxAdjust); + + const direction = this.getDirection(clampedDeviation); + const minutes = this.deviationToMinutes(clampedDeviation); + const degrees = this.deviationToDegrees(clampedDeviation); + const turns = this.deviationToTurns(clampedDeviation); + + // Format the adjustment string + let formattedAmount: string; + if (!requiresAdjustment) { + formattedAmount = 'No adjustment needed'; + } else if (turns >= 1) { + formattedAmount = `${turns.toFixed(1)} turns ${direction}`; + } else if (degrees >= 30) { + formattedAmount = `${Math.round(degrees)}° ${direction}`; + } else { + formattedAmount = `${Math.round(minutes)} minutes ${direction}`; + } + + return { + corner, + deviation, + direction, + minutes, + degrees, + turns, + formattedAmount, + requiresAdjustment, + }; + } + + /** + * Calculate adjustments for all corners relative to reference. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Array of ScrewAdjustment objects for all corners + */ + calculateAdjustments(averagingSize = 0): ScrewAdjustment[] { + const corners = this.bed.getCornerValues(averagingSize); + + // Get reference height (the target all other corners should match) + const referenceHeight = corners[this.referenceCorner]; + + const adjustments: ScrewAdjustment[] = []; + + // Calculate adjustment for each corner + for (const corner of Object.values(BedCorner)) { + const adjustment = this.calculateCornerAdjustment(corner, referenceHeight, averagingSize); + adjustments.push(adjustment); + } + + return adjustments; + } + + /** + * Calculate adjustments to reach a target plane (e.g., mean height). + * + * @param targetHeight - Target height for all corners + * @param averagingSize - Size of area to average for corner heights + * @returns Array of ScrewAdjustment objects + */ + calculateAdjustmentsToTarget(targetHeight: number, averagingSize = 0): ScrewAdjustment[] { + const adjustments: ScrewAdjustment[] = []; + + for (const corner of Object.values(BedCorner)) { + const adjustment = this.calculateCornerAdjustment(corner, targetHeight, averagingSize); + adjustments.push(adjustment); + } + + return adjustments; + } + + /** + * Get summary of required adjustments. + * + * @param adjustments - Array of calculated adjustments + * @returns Summary object + */ + getAdjustmentSummary(adjustments: ScrewAdjustment[]): { + totalAdjustmentsNeeded: number; + maxMinutes: number; + corners: { name: string; amount: string }[]; + } { + const needing = adjustments.filter((a) => a.requiresAdjustment); + + return { + totalAdjustmentsNeeded: needing.length, + maxMinutes: Math.max(...adjustments.map((a) => a.minutes), 0), + corners: adjustments.map((a) => ({ + name: a.corner, + amount: a.formattedAmount, + })), + }; + } +} diff --git a/src/services/calibration/engine/TapeCalculator.ts b/src/services/calibration/engine/TapeCalculator.ts new file mode 100644 index 0000000..d574db3 --- /dev/null +++ b/src/services/calibration/engine/TapeCalculator.ts @@ -0,0 +1,378 @@ +/** + * @fileoverview Calculator for tape compensation recommendations. + * Identifies low spots on the bed that need tape shims and calculates + * the number of layers needed to compensate for height differences. + * + * @module services/calibration/engine/TapeCalculator + */ + +import type { TapeConfig, TapeRecommendation } from '../../../types/calibration'; +import { type BedCorner, DEFAULT_TAPE_CONFIG } from '../../../types/calibration'; +import type { Bed } from './Bed'; + +/** + * Detailed tape spot information for mesh-based analysis. + */ +export interface TapeSpot { + /** X coordinate in mesh grid */ + x: number; + /** Y coordinate in mesh grid */ + y: number; + /** Number of tape layers needed */ + layers: number; + /** Height difference to compensate (mm) */ + heightDiff: number; + /** Priority (1 = highest) */ + priority: number; + /** Approximate area size in mm² */ + areaSize: number; +} + +/** + * Calculator for tape compensation on the print bed. + */ +export class TapeCalculator { + /** Bed model reference */ + private readonly bed: Bed; + + /** Tape configuration */ + private config: TapeConfig; + + /** + * Create a new TapeCalculator. + * + * @param bed - Bed model with mesh data + * @param config - Tape configuration + */ + constructor(bed: Bed, config: TapeConfig = DEFAULT_TAPE_CONFIG) { + this.bed = bed; + this.config = { ...config }; + } + + /** + * Update tape configuration. + * + * @param config - New configuration (partial) + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Calculate priority based on height difference. + * + * @param heightDiff - Height difference in mm + * @returns Priority level (1 = highest) + */ + private calculatePriority(heightDiff: number): number { + if (heightDiff > 0.3) { + return 1; + } else if (heightDiff > 0.2) { + return 2; + } + return 3; + } + + /** + * Check if a mesh point is at a screw corner position. + * + * @param row - Row index + * @param col - Column index + * @returns True if position is a screw corner + */ + private isAtScrewCorner(row: number, col: number): boolean { + const lastRow = this.bed.config.meshPointsY - 1; + const lastCol = this.bed.config.meshPointsX - 1; + + // Check all four corners + return ( + (row === 0 && col === 0) || // Front left + (row === 0 && col === lastCol) || // Front right + (row === lastRow && col === 0) || // Rear left + (row === lastRow && col === lastCol) // Rear right + ); + } + + /** + * Calculate approximate area size for a tape spot. + * + * @param heightDiff - Height difference at spot + * @returns Area size in mm² + */ + private calculateAreaSize(heightDiff: number): number { + const { x, y } = this.bed.getMmPerPoint(); + const baseArea = x * y; + + // Increase area for larger deviations + if (heightDiff > 0.3) { + return baseArea * 1.5; + } + return baseArea; + } + + /** + * Calculate tape recommendations for bed corners. + * This is the simple corner-based calculation. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Array of TapeRecommendation for each corner + */ + calculateLayers(averagingSize = 0): TapeRecommendation[] { + const corners = this.bed.getCornerValues(averagingSize); + const heights = [corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight]; + const referenceHeight = Math.min(...heights); // Lowest corner as reference + + const recommendations: TapeRecommendation[] = []; + + for (const [cornerKey, height] of Object.entries(corners)) { + const deviation = height - referenceHeight; + + if (deviation > this.config.minHeightDiff) { + const layers = Math.ceil(deviation / this.config.tapeThickness); + + recommendations.push({ + corner: cornerKey as BedCorner, + layers, + totalThickness: layers * this.config.tapeThickness, + deviation, + }); + } + } + + return recommendations; + } + + /** + * Get total number of tape layers across all corners. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Total number of layers + */ + getTotalLayers(averagingSize = 0): number { + const recommendations = this.calculateLayers(averagingSize); + return recommendations.reduce((sum, r) => sum + r.layers, 0); + } + + /** + * Find low spots in a mesh that need tape compensation. + * This performs mesh-wide analysis, not just corners. + * + * @param simulatedMesh - Mesh data (typically after screw adjustments) + * @returns Array of TapeSpot objects + */ + findLowSpots(simulatedMesh: number[][]): TapeSpot[] { + // Calculate mean height + let sum = 0; + let count = 0; + for (const row of simulatedMesh) { + for (const val of row) { + sum += val; + count++; + } + } + const meanHeight = sum / count; + + const spots: TapeSpot[] = []; + + for (let row = 0; row < this.bed.config.meshPointsY; row++) { + for (let col = 0; col < this.bed.config.meshPointsX; col++) { + // Skip screw corner positions + if (this.isAtScrewCorner(row, col)) { + continue; + } + + const height = simulatedMesh[row][col]; + const diff = meanHeight - height; + + // If point is lower than mean by more than threshold + if (diff > this.config.minHeightDiff) { + const layers = Math.max(1, Math.ceil(diff / this.config.tapeThickness)); + + spots.push({ + x: col, + y: row, + layers, + heightDiff: diff, + priority: this.calculatePriority(diff), + areaSize: this.calculateAreaSize(diff), + }); + } + } + } + + // Sort by priority (ascending) and height diff (descending) + return spots.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return b.heightDiff - a.heightDiff; + }); + } + + /** + * Optimize tape layout by merging nearby spots. + * + * @param spots - Array of tape spots + * @returns Optimized array of tape spots + */ + optimizeTapeLayout(spots: TapeSpot[]): TapeSpot[] { + const optimized: TapeSpot[] = []; + const used = new Set(); + + for (const spot of spots) { + const key = `${spot.x},${spot.y}`; + if (used.has(key)) { + continue; + } + + // Find nearby spots + const nearby = spots.filter( + (s) => Math.abs(s.x - spot.x) <= 1 && Math.abs(s.y - spot.y) <= 1 && !used.has(`${s.x},${s.y}`) + ); + + if (nearby.length > 0) { + // Merge nearby spots + const avgDiff = nearby.reduce((sum, s) => sum + s.heightDiff, 0) / nearby.length; + const avgLayers = Math.max(1, Math.ceil(avgDiff / this.config.tapeThickness)); + const totalArea = nearby.reduce((sum, s) => sum + s.areaSize, 0); + const minPriority = Math.min(...nearby.map((s) => s.priority)); + + // Find center spot + const avgX = nearby.reduce((sum, s) => sum + s.x, 0) / nearby.length; + const avgY = nearby.reduce((sum, s) => sum + s.y, 0) / nearby.length; + const center = nearby.reduce((best, s) => { + const bestDist = Math.abs(best.x - avgX) + Math.abs(best.y - avgY); + const sDist = Math.abs(s.x - avgX) + Math.abs(s.y - avgY); + return sDist < bestDist ? s : best; + }, nearby[0]); + + optimized.push({ + x: center.x, + y: center.y, + layers: avgLayers, + heightDiff: avgDiff, + priority: minPriority, + areaSize: totalArea, + }); + + // Mark all nearby as used + for (const s of nearby) { + used.add(`${s.x},${s.y}`); + } + } else { + optimized.push(spot); + used.add(key); + } + } + + return optimized; + } + + /** + * Apply tape spots to a mesh to simulate the result. + * + * @param baseMesh - Original mesh data + * @param spots - Tape spots to apply + * @returns Simulated mesh after tape application + */ + applySpots(baseMesh: number[][], spots: TapeSpot[]): number[][] { + // Deep copy mesh + const simulated = baseMesh.map((row) => [...row]); + + for (const spot of spots) { + const heightIncrease = spot.layers * this.config.tapeThickness; + + // Apply to surrounding area + const rowStart = Math.max(0, spot.y - 1); + const rowEnd = Math.min(this.bed.config.meshPointsY, spot.y + 2); + const colStart = Math.max(0, spot.x - 1); + const colEnd = Math.min(this.bed.config.meshPointsX, spot.x + 2); + + for (let row = rowStart; row < rowEnd; row++) { + for (let col = colStart; col < colEnd; col++) { + simulated[row][col] += heightIncrease; + } + } + } + + return simulated; + } + + /** + * Estimate improvement after applying tape spots. + * + * @param spots - Tape spots to apply + * @returns Estimated deviation reduction in mm + */ + estimateImprovement(spots: TapeSpot[]): number { + if (!this.bed.meshData) { + return 0; + } + + const simulatedMesh = this.applySpots(this.bed.meshData, spots); + + // Calculate current deviation + const currentStats = this.bed.getMeshStats(); + const currentDeviation = currentStats.range / 2; + + // Calculate simulated deviation + let _sum = 0; + let _count = 0; + let min = Infinity; + let max = -Infinity; + + for (const row of simulatedMesh) { + for (const val of row) { + _sum += val; + _count++; + min = Math.min(min, val); + max = Math.max(max, val); + } + } + + const simulatedDeviation = (max - min) / 2; + + return currentDeviation - simulatedDeviation; + } + + /** + * Generate tape instructions as text. + * + * @param spots - Tape spots for instructions + * @returns Array of instruction strings + */ + getTapeInstructions(spots: TapeSpot[]): string[] { + if (spots.length === 0) { + return ['No tape compensation required']; + } + + const instructions: string[] = []; + const totalArea = spots.reduce((sum, s) => sum + s.areaSize, 0); + const totalLayers = spots.reduce((sum, s) => sum + s.layers, 0); + + instructions.push( + `Apply tape in ${spots.length} location(s)\n` + + `Total area: ${totalArea.toFixed(1)}mm²\n` + + `Total layers: ${totalLayers}` + ); + + for (const spot of spots) { + // Convert grid coordinates to alphanumeric + const position = `${spot.y + 1}${String.fromCharCode(65 + spot.x)}`; + + let instruction = + `Position ${position}:\n` + + `• Deviation: ${spot.heightDiff.toFixed(3)}mm\n` + + `• Apply ${spot.layers} layer(s) of tape\n` + + `• Area: ${spot.areaSize.toFixed(1)}mm²`; + + if (spot.priority === 1) { + instruction += '\nâš  High priority'; + } + + instructions.push(instruction); + } + + return instructions; + } +} diff --git a/src/services/calibration/engine/WorkflowEngine.ts b/src/services/calibration/engine/WorkflowEngine.ts new file mode 100644 index 0000000..f5f5079 --- /dev/null +++ b/src/services/calibration/engine/WorkflowEngine.ts @@ -0,0 +1,662 @@ +/** + * @fileoverview Multi-stage calibration workflow orchestration engine. + * Computes sequential calibration stages (belt sync, screw adjust, tape, thermal) + * and aggregates results for UI consumption. + * + * @module services/calibration/engine/WorkflowEngine + */ + +import type { + BeltSyncResult, + CalibrationSettings, + StageResult, + ThermalPrediction, + ThermalPreset, + WorkflowData, +} from '../../../types/calibration'; +import { BedCorner, WorkflowStage } from '../../../types/calibration'; +import type { Bed } from './Bed'; +import type { ScrewSolver } from './ScrewSolver'; +import type { TapeCalculator } from './TapeCalculator'; + +/** + * Action step within a calibration stage. + */ +export interface StageAction { + /** Type of action (belt, screw, tape, info) */ + kind: 'belt' | 'screw' | 'tape' | 'info'; + /** Unique identifier */ + identifier: string; + /** Display label */ + label: string; + /** Rotation/adjustment direction */ + direction?: 'clockwise' | 'counterclockwise' | 'up' | 'down'; + /** Magnitude in mm */ + magnitudeMm?: number; + /** Belt teeth count */ + teeth?: number; + /** Rotation in minutes */ + minutes?: number; + /** Rotation in degrees */ + degrees?: number; + /** Additional metadata */ + metadata?: Record; +} + +/** + * Result from a single workflow stage. + */ +export interface WorkflowStageResult { + /** Stage key identifier */ + key: string; + /** Display label */ + label: string; + /** Stage description */ + description: string; + /** Whether stage is enabled */ + enabled: boolean; + /** Deviation after this stage */ + deviation: number; + /** Baseline deviation before stage */ + baseline: number | null; + /** Mesh state after this stage */ + mesh: number[][]; + /** Actions to take in this stage */ + actions: StageAction[]; + /** Warning messages */ + warnings: string[]; +} + +/** + * Workflow flags for enabling/disabling stages. + */ +interface WorkflowFlags { + enableBelt: boolean; + enableScrews: boolean; + enableTape: boolean; +} + +/** + * Multi-stage calibration workflow engine. + */ +export class WorkflowEngine { + private readonly bed: Bed; + private readonly screwSolver: ScrewSolver; + private readonly tapeCalculator: TapeCalculator; + private settings: CalibrationSettings; + + /** + * Create a new WorkflowEngine. + */ + constructor(bed: Bed, screwSolver: ScrewSolver, tapeCalculator: TapeCalculator, settings: CalibrationSettings) { + this.bed = bed; + this.screwSolver = screwSolver; + this.tapeCalculator = tapeCalculator; + this.settings = settings; + } + + /** + * Update settings. + */ + setSettings(settings: CalibrationSettings): void { + this.settings = settings; + } + + /** + * Compute stage deviation (max - min). + */ + private computeStageDeviation(mesh: number[][]): number { + let min = Infinity; + let max = -Infinity; + + for (const row of mesh) { + for (const val of row) { + min = Math.min(min, val); + max = Math.max(max, val); + } + } + + return max - min; + } + + /** + * Deep copy a mesh. + */ + private copyMesh(mesh: number[][]): number[][] { + return mesh.map((row) => [...row]); + } + + /** + * Compute the initial stage (before any corrections). + */ + private computeInitialStage(mesh: number[][]): WorkflowStageResult { + const deviation = this.computeStageDeviation(mesh); + + return { + key: 'initial', + label: 'Initial State', + description: 'Current bed mesh before any adjustments', + enabled: true, + deviation, + baseline: null, + mesh: this.copyMesh(mesh), + actions: [], + warnings: [], + }; + } + + /** + * Build the belt sync stage. + */ + private buildBeltStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + const rows = meshBefore.length; + const cols = meshBefore[0]?.length || 0; + + if (rows === 0 || cols === 0) { + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: true, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['No mesh data'], + }, + meshAfter: meshBefore, + }; + } + + // Calculate belt adjustments + const beltThreshold = this.settings.thresholds.beltThreshold; + const toothMm = this.settings.hardware.beltToothMm; + + const leftFront = meshBefore[0][0]; + const rightFront = meshBefore[0][cols - 1]; + const backCenter = meshBefore[rows - 1][Math.floor(cols / 2)]; + const frontAvg = (leftFront + rightFront) / 2; + + const actions: StageAction[] = []; + + // Left-right tilt + const lrDiff = rightFront - leftFront; + if (Math.abs(lrDiff) > beltThreshold) { + const teeth = Math.max(1, Math.ceil(Math.abs(lrDiff) / toothMm)); + const targetCorner = lrDiff > 0 ? 'front_left' : 'front_right'; + + actions.push({ + kind: 'belt', + identifier: targetCorner, + label: targetCorner === 'front_left' ? 'Front Left' : 'Front Right', + direction: 'up', + magnitudeMm: teeth * toothMm, + teeth, + metadata: { rawDifference: lrDiff }, + }); + } + + // Front-back tilt + const fbDiff = backCenter - frontAvg; + if (Math.abs(fbDiff) > beltThreshold) { + const teeth = Math.max(1, Math.ceil(Math.abs(fbDiff) / toothMm)); + + actions.push({ + kind: 'belt', + identifier: 'back', + label: 'Back Center', + direction: fbDiff < 0 ? 'up' : 'down', + magnitudeMm: teeth * toothMm, + teeth, + metadata: { rawDifference: fbDiff }, + }); + } + + // Simulate belt adjustment effect (simplified) + const meshAfter = this.copyMesh(meshBefore); + // In a full implementation, we'd apply weighted adjustments + // For now, we assume belt adjustments reduce tilt proportionally + + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No belt adjustments needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the screw adjustment stage. + */ + private buildScrewStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_screws', + label: 'Screw Adjustments', + description: 'Corner screw leveling adjustments', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + // Auto-select reference corner and calculate adjustments + this.screwSolver.autoSelectReferenceCorner(this.settings.hardware.cornerAveraging); + const adjustments = this.screwSolver.calculateAdjustments(this.settings.hardware.cornerAveraging); + + // Build actions from adjustments + const actions: StageAction[] = adjustments + .filter((adj) => adj.requiresAdjustment) + .map((adj) => ({ + kind: 'screw' as const, + identifier: adj.corner, + label: this.formatCornerName(adj.corner), + direction: adj.direction === 'CW' ? ('clockwise' as const) : ('counterclockwise' as const), + minutes: adj.minutes, + degrees: adj.degrees, + magnitudeMm: Math.abs(adj.deviation), + metadata: { turns: adj.turns }, + })); + + // Simulate screw adjustments + const cornerAdjustments: Partial> = {}; + for (const adj of adjustments) { + if (adj.requiresAdjustment) { + const change = this.screwSolver.heightChangeFromMinutes(adj.minutes, adj.direction); + cornerAdjustments[adj.corner] = change; + } + } + + const meshAfter = this.bed.simulateAdjustment(cornerAdjustments); + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_screws', + label: 'Screw Adjustments', + description: 'Corner screw leveling adjustments', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No screw adjustments needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the tape compensation stage. + */ + private buildTapeStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_tape', + label: 'Tape Compensation', + description: 'Fine adjustment using tape shims', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + // Find and optimize tape spots + const spots = this.tapeCalculator.findLowSpots(meshBefore); + const optimizedSpots = this.tapeCalculator.optimizeTapeLayout(spots); + + // Build actions from spots + const actions: StageAction[] = optimizedSpots.map((spot) => ({ + kind: 'tape' as const, + identifier: `${spot.y + 1}${String.fromCharCode(65 + spot.x)}`, + label: `Position ${spot.y + 1}${String.fromCharCode(65 + spot.x)}`, + magnitudeMm: spot.heightDiff, + metadata: { + layers: spot.layers, + thickness: spot.layers * this.settings.hardware.tapeThickness, + }, + })); + + // Apply tape spots + const meshAfter = + optimizedSpots.length > 0 + ? this.tapeCalculator.applySpots(meshBefore, optimizedSpots) + : this.copyMesh(meshBefore); + + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_tape', + label: 'Tape Compensation', + description: 'Fine adjustment using tape shims', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No tape compensation needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the thermal prediction stage. + */ + private buildTemperatureStage(meshBefore: number[][]): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + // Get active thermal preset + const activePreset = this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset); + + if (!activePreset) { + return { + stage: { + key: 'after_temperature', + label: 'Temperature Prediction', + description: 'Thermal expansion effects at operating temperature', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['No thermal preset selected'], + }, + meshAfter: meshBefore, + }; + } + + // Apply thermal model + const meshAfter = this.applyThermalEffect(meshBefore, activePreset); + const deviation = this.computeStageDeviation(meshAfter); + + const hasEffect = Math.abs(deviation - baseline) > 0.001; + + return { + stage: { + key: 'after_temperature', + label: 'Temperature Prediction', + description: `Thermal expansion at ${activePreset.targetTemp}°C`, + enabled: hasEffect, + deviation, + baseline, + mesh: meshAfter, + actions: [], + warnings: hasEffect ? [] : ['Minimal thermal effect'], + }, + meshAfter, + }; + } + + /** + * Apply thermal expansion effect to mesh. + */ + private applyThermalEffect(mesh: number[][], preset: ThermalPreset): number[][] { + const rows = mesh.length; + const cols = mesh[0]?.length || 0; + + if (rows === 0 || cols === 0) { + return this.copyMesh(mesh); + } + + const deltaTemp = preset.targetTemp - preset.measurementTemp; + if (Math.abs(deltaTemp) < 0.1) { + return this.copyMesh(mesh); + } + + const { x: xStep, y: yStep } = this.bed.getMmPerPoint(); + const centerX = this.bed.config.sizeX / 2; + const centerY = this.bed.config.sizeY / 2; + + const result = this.copyMesh(mesh); + + // Simple radial thermal expansion model + const expansionCoeff = preset.alphaSteel || this.settings.environment.thermalExpansionCoeff; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * xStep - centerX; + const y = row * yStep - centerY; + const radiusSq = x * x + y * y; + const maxRadiusSq = centerX * centerX + centerY * centerY; + + // Warp increases with distance from center + const warp = expansionCoeff * deltaTemp * (radiusSq / maxRadiusSq); + result[row][col] += warp; + } + } + + // Normalize to zero mean offset + let sum = 0; + for (const row of result) { + for (const val of row) { + sum += val; + } + } + const mean = sum / (rows * cols); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + result[row][col] -= mean; + } + } + + return result; + } + + /** + * Format corner name for display. + */ + private formatCornerName(corner: BedCorner | string): string { + const mapping: Record = { + [BedCorner.FRONT_LEFT]: 'Front Left', + [BedCorner.FRONT_RIGHT]: 'Front Right', + [BedCorner.REAR_LEFT]: 'Rear Left', + [BedCorner.REAR_RIGHT]: 'Rear Right', + }; + return mapping[corner] || corner; + } + + /** + * Compute the complete calibration workflow. + */ + computeWorkflow(): WorkflowData { + if (!this.bed.meshData) { + throw new Error('No mesh data loaded'); + } + + const startTime = Date.now(); + const meshState = this.copyMesh(this.bed.meshData); + const stages = new Map(); + const completedStages: WorkflowStage[] = []; + + const flags: WorkflowFlags = { + enableBelt: this.settings.workflow.enableBelt, + enableScrews: this.settings.workflow.enableScrews, + enableTape: this.settings.workflow.enableTape, + }; + + // Initial stage + const initialResult = this.computeInitialStage(meshState); + const initialRange = initialResult.deviation; + + // Belt stage + const { stage: beltStage, meshAfter: meshAfterBelt } = this.buildBeltStage(meshState, flags.enableBelt); + + // Screw stage + const { stage: screwStage, meshAfter: meshAfterScrew } = this.buildScrewStage(meshAfterBelt, flags.enableScrews); + + // Tape stage + const { stage: tapeStage, meshAfter: meshAfterTape } = this.buildTapeStage(meshAfterScrew, flags.enableTape); + + // Temperature stage + const { stage: tempStage, meshAfter: meshAfterTemp } = this.buildTemperatureStage(meshAfterTape); + + // Build stage results + const beltSyncResult: BeltSyncResult = { + xAxisTilt: 0, // Calculated from belt stage + yAxisTilt: 0, + needsSync: beltStage.actions.length > 0, + recommendation: + beltStage.actions.length > 0 ? `Adjust ${beltStage.actions.length} belt position(s)` : 'Belts are synchronized', + }; + + const screwAdjustments = this.screwSolver.calculateAdjustments(this.settings.hardware.cornerAveraging); + + const tapeRecommendations = this.tapeCalculator.calculateLayers(this.settings.hardware.cornerAveraging); + + const thermalPrediction: ThermalPrediction = { + predictedMesh: meshAfterTemp, + rangeChange: tempStage.deviation - tapeStage.deviation, + tempDelta: this.settings.activeThermalPreset + ? (this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset)?.targetTemp || 60) - + (this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset)?.measurementTemp || + 25) + : 0, + expansionCoeff: this.settings.environment.thermalExpansionCoeff, + }; + + // Store stage results + stages.set(WorkflowStage.INITIAL, { + stage: WorkflowStage.INITIAL, + success: true, + data: initialResult, + metrics: { deviation: initialResult.deviation }, + nextStage: WorkflowStage.BELT_SYNC, + }); + completedStages.push(WorkflowStage.INITIAL); + + stages.set(WorkflowStage.BELT_SYNC, { + stage: WorkflowStage.BELT_SYNC, + success: true, + data: beltStage, + metrics: { deviation: beltStage.deviation }, + nextStage: WorkflowStage.SCREW_ADJUST, + }); + completedStages.push(WorkflowStage.BELT_SYNC); + + stages.set(WorkflowStage.SCREW_ADJUST, { + stage: WorkflowStage.SCREW_ADJUST, + success: true, + data: screwStage, + metrics: { deviation: screwStage.deviation }, + nextStage: WorkflowStage.TAPE_COMPENSATE, + }); + completedStages.push(WorkflowStage.SCREW_ADJUST); + + stages.set(WorkflowStage.TAPE_COMPENSATE, { + stage: WorkflowStage.TAPE_COMPENSATE, + success: true, + data: tapeStage, + metrics: { deviation: tapeStage.deviation }, + nextStage: WorkflowStage.THERMAL_PREDICT, + }); + completedStages.push(WorkflowStage.TAPE_COMPENSATE); + + stages.set(WorkflowStage.THERMAL_PREDICT, { + stage: WorkflowStage.THERMAL_PREDICT, + success: true, + data: tempStage, + metrics: { deviation: tempStage.deviation }, + nextStage: WorkflowStage.COMPLETE, + }); + completedStages.push(WorkflowStage.THERMAL_PREDICT); + + // Calculate final metrics + const finalRange = tempStage.deviation; + const improvementPercent = initialRange > 0 ? ((initialRange - finalRange) / initialRange) * 100 : 0; + + return { + currentStage: WorkflowStage.COMPLETE, + stages, + startTime, + completedStages, + beltSyncResult, + screwAdjustments, + tapeRecommendations, + thermalPrediction, + initialRange, + finalRange, + improvementPercent, + }; + } + + /** + * Get result for a specific stage. + */ + getStageResult(workflow: WorkflowData, stage: WorkflowStage): StageResult | undefined { + return workflow.stages.get(stage); + } + + /** + * Check if a stage can be skipped. + */ + canSkipStage(stage: WorkflowStage): boolean { + switch (stage) { + case WorkflowStage.BELT_SYNC: + return !this.settings.workflow.enableBelt; + case WorkflowStage.SCREW_ADJUST: + return !this.settings.workflow.enableScrews; + case WorkflowStage.TAPE_COMPENSATE: + return !this.settings.workflow.enableTape; + default: + return false; + } + } +} diff --git a/src/services/calibration/engine/index.ts b/src/services/calibration/engine/index.ts new file mode 100644 index 0000000..94c8188 --- /dev/null +++ b/src/services/calibration/engine/index.ts @@ -0,0 +1,18 @@ +/** + * @fileoverview Exports for the calibration engine module. + * Provides core calibration functionality including bed modeling, + * deviation analysis, screw/tape calculations, and workflow orchestration. + * + * @module services/calibration/engine + */ + +export type { MeshStats } from './Bed'; +export { Bed } from './Bed'; +export type { DeviationAnalyzerOptions, DeviationStats, LevelingStage } from './DeviationAnalyzer'; +export { DeviationAnalyzer } from './DeviationAnalyzer'; + +export { ScrewSolver } from './ScrewSolver'; +export type { TapeSpot } from './TapeCalculator'; +export { TapeCalculator } from './TapeCalculator'; +export type { StageAction, WorkflowStageResult } from './WorkflowEngine'; +export { WorkflowEngine } from './WorkflowEngine'; diff --git a/src/services/calibration/index.ts b/src/services/calibration/index.ts new file mode 100644 index 0000000..bf33809 --- /dev/null +++ b/src/services/calibration/index.ts @@ -0,0 +1,35 @@ +/** + * @fileoverview Main exports for the calibration module. + * Provides access to all calibration services including engine components, + * parsers, SSH connectivity, and input shaper analysis. + * + * @module services/calibration + */ + +// Engine exports +export * from './engine'; +export type { ParseResult } from './parsers/KlipperConfigParser'; +// Parser exports +export { KlipperConfigParser, klipperConfigParser } from './parsers/KlipperConfigParser'; +export type { FFTOptions } from './shaper'; +// Shaper exports +export { + DEFAULT_FFT_OPTIONS, + FFTProcessor, + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from './shaper'; +export type { + CommandResult, + SSHConnection, + SSHConnectionManagerEvents, +} from './ssh'; +// SSH exports +export { + DEFAULT_REMOTE_PATHS, + getSSHConnectionManager, + SCPFileTransfer, + SSHConnectionManager, +} from './ssh'; diff --git a/src/services/calibration/parsers/KlipperConfigParser.ts b/src/services/calibration/parsers/KlipperConfigParser.ts new file mode 100644 index 0000000..9d97dba --- /dev/null +++ b/src/services/calibration/parsers/KlipperConfigParser.ts @@ -0,0 +1,302 @@ +/** + * @fileoverview Parser for Klipper printer.cfg files to extract bed mesh data. + * Handles the #*# format used by Klipper to store bed mesh calibration data. + * + * The bed mesh section in printer.cfg looks like: + * ``` + * #*# [bed_mesh default] + * #*# version = 1 + * #*# points = + * #*# 0.025, 0.012, -0.003, ... + * #*# 0.018, 0.005, -0.010, ... + * #*# x_count = 7 + * #*# y_count = 7 + * #*# min_x = 15.0 + * #*# max_x = 205.0 + * #*# min_y = 15.0 + * #*# max_y = 205.0 + * ``` + * + * @module services/calibration/parsers/KlipperConfigParser + */ + +import type { BedConfig, MeshData } from '../../../types/calibration'; + +/** + * Result of a parse operation. + */ +export interface ParseResult { + success: boolean; + data?: MeshData; + error?: string; +} + +/** + * Parser for Klipper printer configuration files. + * Extracts bed mesh data from the #*# formatted sections. + */ +export class KlipperConfigParser { + /** + * Regex pattern to match mesh point lines. + * Matches lines like: #*# 0.025, 0.012, -0.003 + * Also supports scientific notation: #*# 1e-2, 0.0, -1e-2 + */ + private readonly pointPattern = /^#\*#\s+(-?\d+\.?\d*(?:[eE][+-]?\d+)?,\s*)*-?\d+\.?\d*(?:[eE][+-]?\d+)?\s*$/; + + /** + * Regex pattern to match parameter lines. + * Matches lines like: #*# x_count = 7 or #*# points = + */ + private readonly paramPattern = /^#\*#\s+(\w+)\s*=\s*(.*)$/; + + /** + * Regex pattern to match bed mesh section header. + * Matches lines like: #*# [bed_mesh default] + */ + private readonly sectionPattern = /^#\*#\s+\[bed_mesh\s+(\w+)\]$/; + + /** + * Required parameters for a valid mesh. + */ + private readonly requiredParams = ['x_count', 'y_count', 'min_x', 'max_x', 'min_y', 'max_y']; + + /** + * Parse a Klipper config file to extract bed mesh data. + * + * @param content - The full content of the printer.cfg file + * @param profileName - Optional specific profile name to extract (default: 'default') + * @returns ParseResult with mesh data or error + */ + parseConfigFile(content: string, profileName = 'default'): ParseResult { + try { + const lines = content.split('\n'); + const pointsData: number[][] = []; + const params: Record = {}; + let foundProfile = ''; + let inMeshSection = false; + let inPointsSection = false; + + for (const line of lines) { + // Check for mesh section header + const sectionMatch = this.sectionPattern.exec(line); + if (sectionMatch) { + foundProfile = sectionMatch[1]; + inMeshSection = foundProfile === profileName; + inPointsSection = false; + continue; + } + + // Skip if not in the target mesh section + if (!inMeshSection) { + continue; + } + + // Check for parameter lines + const paramMatch = this.paramPattern.exec(line); + if (paramMatch) { + const [, key, value] = paramMatch; + if (key === 'points') { + inPointsSection = true; + } else { + params[key] = value.trim(); + inPointsSection = false; + } + continue; + } + + // Check for point data lines (only when in points section) + if (inPointsSection && this.pointPattern.test(line)) { + const cleanLine = line.replace(/^#\*#\s*/, '').trim(); + if (cleanLine) { + const points = cleanLine.split(',').map((p) => { + const parsed = parseFloat(p.trim()); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid point value: ${p}`); + } + return parsed; + }); + pointsData.push(points); + } + continue; + } + + // If we hit a non-mesh line while in mesh section, we're done + if (line.startsWith('#*#')) { + // Still in #*# block, could be another section + if (line.match(/^#\*#\s+\[/)) { + inMeshSection = false; + inPointsSection = false; + } + } + } + + // Validate we found data + if (pointsData.length === 0) { + return { + success: false, + error: `No mesh data found for profile '${profileName}'`, + }; + } + + // Validate required parameters + for (const param of this.requiredParams) { + if (!(param in params)) { + return { + success: false, + error: `Missing required parameter: ${param}`, + }; + } + } + + // Parse parameters + const xCount = parseInt(params.x_count, 10); + const yCount = parseInt(params.y_count, 10); + const minX = parseFloat(params.min_x); + const maxX = parseFloat(params.max_x); + const minY = parseFloat(params.min_y); + const maxY = parseFloat(params.max_y); + + // Validate matrix dimensions + if (pointsData.length !== yCount) { + return { + success: false, + error: `Row count mismatch: expected ${yCount}, got ${pointsData.length}`, + }; + } + + for (let i = 0; i < pointsData.length; i++) { + if (pointsData[i].length !== xCount) { + return { + success: false, + error: `Column count mismatch in row ${i}: expected ${xCount}, got ${pointsData[i].length}`, + }; + } + } + + const meshData: MeshData = { + matrix: pointsData, + minX, + maxX, + minY, + maxY, + pointsX: xCount, + pointsY: yCount, + profileName: foundProfile || profileName, + }; + + return { + success: true, + data: meshData, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown parsing error', + }; + } + } + + /** + * Extract all available mesh profile names from a config file. + * + * @param content - The full content of the printer.cfg file + * @returns Array of profile names found + */ + getAvailableProfiles(content: string): string[] { + const profiles: string[] = []; + const lines = content.split('\n'); + + for (const line of lines) { + const match = this.sectionPattern.exec(line); + if (match) { + profiles.push(match[1]); + } + } + + return profiles; + } + + /** + * Validate mesh data for reasonable values. + * + * @param meshData - The mesh data to validate + * @returns Object with isValid flag and optional error message + */ + validateMeshData(meshData: MeshData): { isValid: boolean; error?: string } { + // Check matrix dimensions match declared size + if (meshData.matrix.length !== meshData.pointsY) { + return { + isValid: false, + error: `Matrix row count (${meshData.matrix.length}) doesn't match pointsY (${meshData.pointsY})`, + }; + } + + for (let i = 0; i < meshData.matrix.length; i++) { + if (meshData.matrix[i].length !== meshData.pointsX) { + return { + isValid: false, + error: `Row ${i} column count (${meshData.matrix[i].length}) doesn't match pointsX (${meshData.pointsX})`, + }; + } + } + + // Check for reasonable Z values (typical bed deviation range) + const maxReasonableDeviation = 10; // mm + for (let y = 0; y < meshData.matrix.length; y++) { + for (let x = 0; x < meshData.matrix[y].length; x++) { + const value = meshData.matrix[y][x]; + + if (Number.isNaN(value) || !Number.isFinite(value)) { + return { + isValid: false, + error: `Invalid value at position [${y}][${x}]: ${value}`, + }; + } + + if (Math.abs(value) > maxReasonableDeviation) { + return { + isValid: false, + error: `Unreasonable deviation at [${y}][${x}]: ${value}mm (max: ±${maxReasonableDeviation}mm)`, + }; + } + } + } + + // Check coordinate bounds make sense + if (meshData.minX >= meshData.maxX) { + return { + isValid: false, + error: `Invalid X bounds: minX (${meshData.minX}) >= maxX (${meshData.maxX})`, + }; + } + + if (meshData.minY >= meshData.maxY) { + return { + isValid: false, + error: `Invalid Y bounds: minY (${meshData.minY}) >= maxY (${meshData.maxY})`, + }; + } + + return { isValid: true }; + } + + /** + * Create a BedConfig from parsed mesh data. + * + * @param meshData - The parsed mesh data + * @returns BedConfig derived from mesh dimensions + */ + createBedConfigFromMesh(meshData: MeshData): BedConfig { + return { + sizeX: meshData.maxX - meshData.minX, + sizeY: meshData.maxY - meshData.minY, + meshPointsX: meshData.pointsX, + meshPointsY: meshData.pointsY, + }; + } +} + +/** + * Singleton instance for convenience. + */ +export const klipperConfigParser = new KlipperConfigParser(); diff --git a/src/services/calibration/report/ReportRenderer.ts b/src/services/calibration/report/ReportRenderer.ts new file mode 100644 index 0000000..08f1e3b --- /dev/null +++ b/src/services/calibration/report/ReportRenderer.ts @@ -0,0 +1,261 @@ +/** + * @fileoverview Utilities for rendering calibration reports to binary formats. + * Generates heatmap PNGs and PDF summaries from mesh data and analysis. + * + * @module services/calibration/report/ReportRenderer + */ + +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PNG } from 'pngjs'; +import type { AnalysisResult, CalibrationSettings, MeshData, WorkflowData } from '../../../types/calibration'; + +type ColorStop = { position: number; color: [number, number, number] }; + +const VIRIDIS_STOPS: ColorStop[] = [ + { position: 0.0, color: [68, 1, 84] }, + { position: 0.1, color: [72, 40, 120] }, + { position: 0.2, color: [62, 74, 137] }, + { position: 0.3, color: [49, 104, 142] }, + { position: 0.4, color: [38, 130, 142] }, + { position: 0.5, color: [31, 158, 137] }, + { position: 0.6, color: [53, 183, 121] }, + { position: 0.7, color: [109, 205, 89] }, + { position: 0.8, color: [180, 222, 44] }, + { position: 0.9, color: [223, 227, 24] }, + { position: 1.0, color: [253, 231, 37] }, +]; + +const PLASMA_STOPS: ColorStop[] = [ + { position: 0.0, color: [13, 8, 135] }, + { position: 0.1, color: [75, 3, 161] }, + { position: 0.2, color: [125, 3, 168] }, + { position: 0.3, color: [168, 34, 150] }, + { position: 0.4, color: [203, 70, 121] }, + { position: 0.5, color: [229, 107, 93] }, + { position: 0.6, color: [248, 148, 65] }, + { position: 0.7, color: [253, 195, 40] }, + { position: 0.8, color: [240, 249, 33] }, + { position: 1.0, color: [240, 249, 33] }, +]; + +const INFERNO_STOPS: ColorStop[] = [ + { position: 0.0, color: [0, 0, 4] }, + { position: 0.1, color: [40, 11, 84] }, + { position: 0.2, color: [89, 13, 115] }, + { position: 0.3, color: [137, 27, 100] }, + { position: 0.4, color: [181, 50, 64] }, + { position: 0.5, color: [219, 87, 26] }, + { position: 0.6, color: [244, 130, 7] }, + { position: 0.7, color: [252, 180, 31] }, + { position: 0.8, color: [250, 230, 102] }, + { position: 1.0, color: [252, 255, 164] }, +]; + +const COOLWARM_STOPS: ColorStop[] = [ + { position: 0.0, color: [59, 76, 192] }, + { position: 0.1, color: [98, 130, 234] }, + { position: 0.2, color: [141, 176, 254] }, + { position: 0.3, color: [184, 208, 249] }, + { position: 0.4, color: [221, 221, 221] }, + { position: 0.5, color: [245, 245, 245] }, + { position: 0.6, color: [249, 196, 178] }, + { position: 0.7, color: [244, 154, 123] }, + { position: 0.8, color: [221, 96, 73] }, + { position: 0.9, color: [192, 40, 40] }, + { position: 1.0, color: [180, 4, 38] }, +]; + +const COLOR_SCALES: Record = { + viridis: VIRIDIS_STOPS, + plasma: PLASMA_STOPS, + inferno: INFERNO_STOPS, + coolwarm: COOLWARM_STOPS, +}; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function getColor(scheme: string, t: number): [number, number, number] { + const stops = COLOR_SCALES[scheme] || COLOR_SCALES.viridis; + const normalized = clamp01(t); + let lower = stops[0]; + let upper = stops[stops.length - 1]; + + for (let i = 0; i < stops.length - 1; i++) { + if (normalized >= stops[i].position && normalized <= stops[i + 1].position) { + lower = stops[i]; + upper = stops[i + 1]; + break; + } + } + + const range = upper.position - lower.position; + const localT = range > 0 ? (normalized - lower.position) / range : 0; + + return [ + Math.round(lerp(lower.color[0], upper.color[0], localT)), + Math.round(lerp(lower.color[1], upper.color[1], localT)), + Math.round(lerp(lower.color[2], upper.color[2], localT)), + ]; +} + +function setPixel(data: Buffer, width: number, x: number, y: number, r: number, g: number, b: number, a = 255) { + const idx = (width * y + x) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; +} + +export interface HeatmapRenderOptions { + cellSize?: number; + padding?: number; + colorScheme?: CalibrationSettings['visualization']['colorScheme']; + showGrid?: boolean; +} + +export function renderHeatmapPNG(meshData: MeshData, options: HeatmapRenderOptions = {}): Buffer { + const matrix = meshData.matrix; + const rows = matrix.length; + const cols = matrix[0]?.length || 0; + if (rows === 0 || cols === 0) { + throw new Error('Mesh data is empty'); + } + + const cellSize = options.cellSize ?? 24; + const padding = options.padding ?? 20; + const showGrid = options.showGrid ?? true; + const scheme = options.colorScheme ?? 'viridis'; + + const width = cols * cellSize + padding * 2; + const height = rows * cellSize + padding * 2; + + const png = new PNG({ width, height }); + png.data.fill(255); + + const values = matrix.flat(); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const value = matrix[row][col]; + const t = range > 0 ? (value - min) / range : 0.5; + const [r, g, b] = getColor(scheme, t); + + const startX = padding + col * cellSize; + const startY = padding + row * cellSize; + + for (let y = startY; y < startY + cellSize; y++) { + for (let x = startX; x < startX + cellSize; x++) { + setPixel(png.data, width, x, y, r, g, b, 255); + } + } + } + } + + if (showGrid && cellSize >= 6) { + const gridColor = [32, 32, 32]; + for (let row = 0; row <= rows; row++) { + const y = padding + row * cellSize; + if (y >= 0 && y < height) { + for (let x = padding; x < padding + cols * cellSize; x++) { + setPixel(png.data, width, x, y, gridColor[0], gridColor[1], gridColor[2], 255); + } + } + } + for (let col = 0; col <= cols; col++) { + const x = padding + col * cellSize; + if (x >= 0 && x < width) { + for (let y = padding; y < padding + rows * cellSize; y++) { + setPixel(png.data, width, x, y, gridColor[0], gridColor[1], gridColor[2], 255); + } + } + } + } + + return PNG.sync.write(png); +} + +export interface ReportPDFOptions { + contextId: string; + meshData: MeshData; + analysis: AnalysisResult | null; + workflow: WorkflowData | null; + settings: CalibrationSettings; +} + +export async function renderReportPDF(options: ReportPDFOptions): Promise { + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([612, 792]); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const margin = 40; + let cursorY = page.getHeight() - margin; + + page.drawText('Calibration Report', { + x: margin, + y: cursorY, + size: 20, + font: fontBold, + color: rgb(0.12, 0.12, 0.12), + }); + cursorY -= 26; + + const timestamp = new Date().toISOString(); + page.drawText(`Context: ${options.contextId}`, { x: margin, y: cursorY, size: 11, font, color: rgb(0.2, 0.2, 0.2) }); + cursorY -= 16; + page.drawText(`Generated: ${timestamp}`, { x: margin, y: cursorY, size: 11, font, color: rgb(0.2, 0.2, 0.2) }); + cursorY -= 22; + + const summaryLines: string[] = []; + if (options.analysis) { + summaryLines.push(`Mesh range: ${options.analysis.meshRange.toFixed(4)} mm`); + summaryLines.push(`Max deviation: ${options.analysis.maxDeviation.toFixed(4)} mm`); + summaryLines.push(`Std dev: ${options.analysis.standardDeviation.toFixed(4)} mm`); + } + if (options.workflow) { + summaryLines.push(`Improvement: ${options.workflow.improvementPercent.toFixed(1)}%`); + } + + if (summaryLines.length > 0) { + page.drawText('Summary', { x: margin, y: cursorY, size: 13, font: fontBold, color: rgb(0.15, 0.15, 0.15) }); + cursorY -= 18; + for (const line of summaryLines) { + page.drawText(`• ${line}`, { x: margin + 4, y: cursorY, size: 11, font, color: rgb(0.25, 0.25, 0.25) }); + cursorY -= 14; + } + cursorY -= 6; + } + + const heatmapPng = renderHeatmapPNG(options.meshData, { + colorScheme: options.settings.visualization.colorScheme, + cellSize: 24, + padding: 16, + }); + const image = await pdfDoc.embedPng(heatmapPng); + const maxWidth = page.getWidth() - margin * 2; + const maxHeight = cursorY - margin; + const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1); + const imageWidth = image.width * scale; + const imageHeight = image.height * scale; + const imageX = margin; + const imageY = Math.max(margin, cursorY - imageHeight); + + page.drawImage(image, { + x: imageX, + y: imageY, + width: imageWidth, + height: imageHeight, + }); + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); +} diff --git a/src/services/calibration/shaper/FFTProcessor.ts b/src/services/calibration/shaper/FFTProcessor.ts new file mode 100644 index 0000000..477f867 --- /dev/null +++ b/src/services/calibration/shaper/FFTProcessor.ts @@ -0,0 +1,372 @@ +/** + * @fileoverview Fast Fourier Transform processor for accelerometer data. + * Provides frequency domain analysis of vibration data for input shaper calibration. + * Uses a pure TypeScript implementation suitable for Web Worker execution. + * + * @module services/calibration/shaper/FFTProcessor + */ + +import type { AccelerometerData, PowerSpectrum } from '../../../types/calibration'; + +/** + * FFT processing options. + */ +export interface FFTOptions { + /** Window function to apply ('hanning', 'hamming', 'blackman', 'none') */ + windowFunction: 'hanning' | 'hamming' | 'blackman' | 'none'; + /** Minimum frequency to include in output (Hz) */ + minFreq: number; + /** Maximum frequency to include in output (Hz) */ + maxFreq: number; + /** Whether to normalize the power spectrum */ + normalize: boolean; +} + +/** + * Default FFT options. + */ +export const DEFAULT_FFT_OPTIONS: FFTOptions = { + windowFunction: 'hanning', + minFreq: 5, + maxFreq: 200, + normalize: true, +}; + +/** + * Complex number representation. + */ +interface Complex { + re: number; + im: number; +} + +/** + * Multiply two complex numbers. + */ +function complexMultiply(a: Complex, b: Complex): Complex { + return { + re: a.re * b.re - a.im * b.im, + im: a.re * b.im + a.im * b.re, + }; +} + +/** + * Add two complex numbers. + */ +function complexAdd(a: Complex, b: Complex): Complex { + return { + re: a.re + b.re, + im: a.im + b.im, + }; +} + +/** + * Subtract two complex numbers. + */ +function complexSubtract(a: Complex, b: Complex): Complex { + return { + re: a.re - b.re, + im: a.im - b.im, + }; +} + +/** + * Compute the magnitude of a complex number. + */ +function complexMagnitude(c: Complex): number { + return Math.sqrt(c.re * c.re + c.im * c.im); +} + +/** + * Generate a Hanning window. + */ +function hanningWindow(n: number): number[] { + const window: number[] = []; + for (let i = 0; i < n; i++) { + window.push(0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)))); + } + return window; +} + +/** + * Generate a Hamming window. + */ +function hammingWindow(n: number): number[] { + const window: number[] = []; + for (let i = 0; i < n; i++) { + window.push(0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (n - 1))); + } + return window; +} + +/** + * Generate a Blackman window. + */ +function blackmanWindow(n: number): number[] { + const window: number[] = []; + const a0 = 0.42; + const a1 = 0.5; + const a2 = 0.08; + for (let i = 0; i < n; i++) { + window.push(a0 - a1 * Math.cos((2 * Math.PI * i) / (n - 1)) + a2 * Math.cos((4 * Math.PI * i) / (n - 1))); + } + return window; +} + +/** + * Apply a window function to data. + */ +function applyWindow(data: number[], windowType: FFTOptions['windowFunction']): number[] { + const n = data.length; + let window: number[]; + + switch (windowType) { + case 'hanning': + window = hanningWindow(n); + break; + case 'hamming': + window = hammingWindow(n); + break; + case 'blackman': + window = blackmanWindow(n); + break; + default: + return [...data]; + } + + return data.map((v, i) => v * window[i]); +} + +/** + * Pad array to next power of 2. + */ +function padToPowerOf2(data: number[]): number[] { + const n = data.length; + const nextPow2 = 2 ** Math.ceil(Math.log2(n)); + if (nextPow2 === n) return data; + + const padded = new Array(nextPow2).fill(0); + for (let i = 0; i < n; i++) { + padded[i] = data[i]; + } + return padded; +} + +/** + * Cooley-Tukey FFT implementation. + * Computes the discrete Fourier transform of the input array. + */ +function fft(input: Complex[]): Complex[] { + const n = input.length; + + // Base case + if (n <= 1) return input; + + // Ensure n is power of 2 + if (n & (n - 1)) { + throw new Error('FFT input length must be a power of 2'); + } + + // Split even and odd + const even: Complex[] = []; + const odd: Complex[] = []; + for (let i = 0; i < n; i++) { + if (i % 2 === 0) { + even.push(input[i]); + } else { + odd.push(input[i]); + } + } + + // Recursive FFT + const fftEven = fft(even); + const fftOdd = fft(odd); + + // Combine + const result: Complex[] = new Array(n); + for (let k = 0; k < n / 2; k++) { + const angle = (-2 * Math.PI * k) / n; + const w: Complex = { re: Math.cos(angle), im: Math.sin(angle) }; + const t = complexMultiply(w, fftOdd[k]); + result[k] = complexAdd(fftEven[k], t); + result[k + n / 2] = complexSubtract(fftEven[k], t); + } + + return result; +} + +/** + * Compute power spectral density from FFT result. + */ +function computePSD(fftResult: Complex[], sampleRate: number): { frequencies: number[]; power: number[] } { + const n = fftResult.length; + const halfN = Math.floor(n / 2); + + const frequencies: number[] = []; + const power: number[] = []; + + for (let i = 0; i < halfN; i++) { + frequencies.push((i * sampleRate) / n); + const mag = complexMagnitude(fftResult[i]); + power.push((mag * mag) / n); + } + + return { frequencies, power }; +} + +/** + * FFT processor for accelerometer data. + */ +export class FFTProcessor { + private options: FFTOptions; + + constructor(options: Partial = {}) { + this.options = { ...DEFAULT_FFT_OPTIONS, ...options }; + } + + /** + * Update processing options. + */ + setOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + } + + /** + * Parse accelerometer CSV data. + * Expected format: time,accel_x,accel_y,accel_z + */ + parseCSV(csvContent: string): AccelerometerData { + const lines = csvContent.trim().split('\n'); + const time: number[] = []; + const accelX: number[] = []; + const accelY: number[] = []; + const accelZ: number[] = []; + + // Skip header line if present + const startLine = lines[0].includes('time') || lines[0].includes('#') ? 1 : 0; + + for (let i = startLine; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(',').map((s) => parseFloat(s.trim())); + if (parts.length >= 4 && !Number.isNaN(parts[0])) { + time.push(parts[0]); + accelX.push(parts[1]); + accelY.push(parts[2]); + accelZ.push(parts[3]); + } + } + + // Calculate sample rate from time values + let sampleRate = 1000; // Default + if (time.length >= 2) { + const avgDelta = (time[time.length - 1] - time[0]) / (time.length - 1); + sampleRate = 1 / avgDelta; + } + + return { + time, + accelX, + accelY, + accelZ, + sampleRate, + }; + } + + /** + * Process accelerometer data for a single axis. + */ + processAxis(data: number[], sampleRate: number): PowerSpectrum { + // Apply window function + const windowed = applyWindow(data, this.options.windowFunction); + + // Pad to power of 2 + const padded = padToPowerOf2(windowed); + + // Convert to complex + const complex: Complex[] = padded.map((v) => ({ re: v, im: 0 })); + + // Compute FFT + const fftResult = fft(complex); + + // Compute power spectral density + const { frequencies, power } = computePSD(fftResult, sampleRate); + + // Filter to desired frequency range + const filteredFrequencies: number[] = []; + const filteredPower: number[] = []; + + for (let i = 0; i < frequencies.length; i++) { + if (frequencies[i] >= this.options.minFreq && frequencies[i] <= this.options.maxFreq) { + filteredFrequencies.push(frequencies[i]); + filteredPower.push(power[i]); + } + } + + // Normalize if requested + let normalizedPower = filteredPower; + if (this.options.normalize && filteredPower.length > 0) { + const maxPower = Math.max(...filteredPower); + if (maxPower > 0) { + normalizedPower = filteredPower.map((p) => p / maxPower); + } + } + + // Find peak + let peakIndex = 0; + let peakPower = normalizedPower[0] || 0; + for (let i = 1; i < normalizedPower.length; i++) { + if (normalizedPower[i] > peakPower) { + peakPower = normalizedPower[i]; + peakIndex = i; + } + } + + return { + frequencies: filteredFrequencies, + power: normalizedPower, + peakFrequency: filteredFrequencies[peakIndex] || 0, + peakPower, + }; + } + + /** + * Process full accelerometer data and return power spectrums for each axis. + */ + processData(data: AccelerometerData): { + x: PowerSpectrum; + y: PowerSpectrum; + z: PowerSpectrum; + } { + return { + x: this.processAxis(data.accelX, data.sampleRate), + y: this.processAxis(data.accelY, data.sampleRate), + z: this.processAxis(data.accelZ, data.sampleRate), + }; + } + + /** + * Find resonance peaks in power spectrum. + * Returns frequencies above the threshold relative to mean power. + */ + findPeaks(spectrum: PowerSpectrum, threshold = 0.3): number[] { + const peaks: number[] = []; + const { frequencies, power } = spectrum; + + if (power.length < 3) return peaks; + + // Calculate mean power + const meanPower = power.reduce((a, b) => a + b, 0) / power.length; + const peakThreshold = meanPower + threshold * (Math.max(...power) - meanPower); + + // Find local maxima above threshold + for (let i = 1; i < power.length - 1; i++) { + if (power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > peakThreshold) { + peaks.push(frequencies[i]); + } + } + + return peaks; + } +} diff --git a/src/services/calibration/shaper/ShaperAnalyzer.ts b/src/services/calibration/shaper/ShaperAnalyzer.ts new file mode 100644 index 0000000..469f816 --- /dev/null +++ b/src/services/calibration/shaper/ShaperAnalyzer.ts @@ -0,0 +1,378 @@ +/** + * @fileoverview Input shaper analyzer for Klipper resonance compensation. + * Evaluates different shaper types and recommends optimal configurations. + * Based on Klipper's input shaping algorithms. + * + * @module services/calibration/shaper/ShaperAnalyzer + */ + +import type { + AxisCalibration, + PowerSpectrum, + ShaperDefinition, + ShaperResult, + ShaperType, +} from '../../../types/calibration'; +import { type FFTOptions, FFTProcessor } from './FFTProcessor'; + +/** + * Shaper definitions with their mathematical properties. + * Based on Klipper's input_shaper.py + */ +export const SHAPER_DEFINITIONS: ShaperDefinition[] = [ + { + type: 'zv' as ShaperType, + name: 'ZV (Zero Vibration)', + minFreq: 10, + initFreq: 35, + maxFreq: 100, + }, + { + type: 'mzv' as ShaperType, + name: 'MZV (Modified ZV)', + minFreq: 15, + initFreq: 45, + maxFreq: 100, + }, + { + type: 'ei' as ShaperType, + name: 'EI (Extra Insensitive)', + minFreq: 10, + initFreq: 40, + maxFreq: 100, + }, + { + type: '2hump_ei' as ShaperType, + name: '2-Hump EI', + minFreq: 15, + initFreq: 50, + maxFreq: 100, + }, + { + type: '3hump_ei' as ShaperType, + name: '3-Hump EI', + minFreq: 20, + initFreq: 55, + maxFreq: 100, + }, +]; + +/** + * Shaper pulse coefficients. + * These define the impulse response for each shaper type. + */ +const SHAPER_PULSES: Record { a: number[]; t: number[] }> = { + zv: (freq: number) => { + const df = 1 / (2 * freq); + const K = Math.exp(-0.05 / (1 - 0.05 * 0.05)); + const a1 = 1 / (1 + K); + const a2 = K / (1 + K); + return { a: [a1, a2], t: [0, df] }; + }, + mzv: (freq: number) => { + const df = 1 / (2 * freq); + const K = Math.exp(-0.045 / Math.sqrt(1 - 0.045 * 0.045)); + const K2 = K * K; + const a1 = 1 / (1 + K + K2); + const a2 = K / (1 + K + K2); + const a3 = K2 / (1 + K + K2); + return { a: [a1, a2, a3], t: [0, df * 0.5, df] }; + }, + ei: (freq: number) => { + const df = 1 / (2 * freq); + const a1 = 0.25 * (1 + 1 / Math.E); + const a2 = 0.5 * (1 - 1 / Math.E); + const a3 = a1; + return { a: [a1, a2, a3], t: [0, df, 2 * df] }; + }, + '2hump_ei': (freq: number) => { + const df = 1 / (2 * freq); + const e = 1 / Math.E; + const e2 = e * e; + const denom = 1 + 3 * e + e2; + return { + a: [(1 + e) / denom, (2 * e) / denom, e2 / denom + e / denom, e2 / denom], + t: [0, df, 2 * df, 3 * df], + }; + }, + '3hump_ei': (freq: number) => { + const df = 1 / (2 * freq); + const e = 1 / Math.E; + const e2 = e * e; + const e3 = e2 * e; + const denom = 1 + 4 * e + 4 * e2 + e3; + return { + a: [ + (1 + 2 * e + e2) / denom, + (2 * (e + e2)) / denom, + (e2 + 2 * e + 1) / denom, + (e2 + 2 * e3) / denom, + e3 / denom, + ], + t: [0, df, 2 * df, 3 * df, 4 * df], + }; + }, +}; + +/** + * Get smoothing time for a shaper at a given frequency. + */ +function getSmoothingTime(shaperType: ShaperType, frequency: number): number { + const pulses = SHAPER_PULSES[shaperType](frequency); + if (!pulses || pulses.t.length === 0) return 0; + return pulses.t[pulses.t.length - 1] * 1000; // Convert to ms +} + +/** + * Estimate maximum recommended acceleration for a shaper. + * Based on Klipper's formula: max_accel = shaper_freq^2 * max_tolerable_vibration_ratio / smoothing + */ +function estimateMaxAcceleration(shaperType: ShaperType, frequency: number, vibrationReduction: number): number { + const smoothingMs = getSmoothingTime(shaperType, frequency); + if (smoothingMs <= 0) return 10000; + + // Base formula adjusted for typical use + const smoothingSeconds = smoothingMs / 1000; + const maxAccel = (frequency * frequency * vibrationReduction) / (100 * smoothingSeconds); + + // Clamp to reasonable range + return Math.min(20000, Math.max(500, Math.round(maxAccel / 100) * 100)); +} + +/** + * Calculate vibration reduction for a shaper at a given frequency. + * Estimates how well the shaper suppresses vibrations at the resonance frequency. + */ +function calculateVibrationReduction(shaperType: ShaperType, shaperFreq: number, resonanceFreq: number): number { + const pulses = SHAPER_PULSES[shaperType](shaperFreq); + if (!pulses) return 0; + + const { a, t } = pulses; + const omega = 2 * Math.PI * resonanceFreq; + + // Calculate transfer function magnitude at resonance frequency + let realSum = 0; + let imagSum = 0; + + for (let i = 0; i < a.length; i++) { + realSum += a[i] * Math.cos(omega * t[i]); + imagSum += a[i] * Math.sin(omega * t[i]); + } + + const magnitude = Math.sqrt(realSum * realSum + imagSum * imagSum); + + // Vibration reduction is 1 - magnitude (clamped to [0, 1]) + return Math.max(0, Math.min(1, 1 - magnitude)); +} + +/** + * Score a shaper configuration. + * Higher score is better. + */ +function scoreShaperConfig(vibrationReduction: number, smoothingTime: number, maxAccel: number): number { + // Prioritize vibration reduction, penalize excessive smoothing + const reductionScore = vibrationReduction * 100; + const smoothingPenalty = Math.min(smoothingTime / 100, 0.5); // Penalize high smoothing + const accelBonus = Math.min(maxAccel / 10000, 0.3); // Bonus for high max accel + + return reductionScore * (1 - smoothingPenalty) + accelBonus * 10; +} + +/** + * Input shaper analyzer. + */ +export class ShaperAnalyzer { + private readonly fftProcessor: FFTProcessor; + + constructor(fftOptions: Partial = {}) { + this.fftProcessor = new FFTProcessor(fftOptions); + } + + /** + * Analyze accelerometer data for a single axis. + */ + analyzeAxis(csvContent: string, axis: 'x' | 'y'): AxisCalibration { + // Parse CSV + const data = this.fftProcessor.parseCSV(csvContent); + + // Get appropriate acceleration data + const accelData = axis === 'x' ? data.accelX : data.accelY; + + // Process FFT + const spectrum = this.fftProcessor.processAxis(accelData, data.sampleRate); + + // Find resonance peaks + const peakFrequencies = this.fftProcessor.findPeaks(spectrum); + + // Use primary peak as resonance frequency + const primaryResonance = spectrum.peakFrequency; + + // Evaluate all shapers + const allResults = this.evaluateShapers(primaryResonance, spectrum); + + // Find best shaper + const recommendedShaper = this.findBestShaper(allResults); + + return { + axis, + frequencyBins: spectrum.frequencies, + powerSpectralDensity: spectrum.power, + peakFrequencies, + recommendedShaper, + allShaperResults: allResults, + }; + } + + /** + * Evaluate all shaper types for a given resonance frequency. + */ + evaluateShapers(resonanceFreq: number, spectrum: PowerSpectrum): ShaperResult[] { + const results: ShaperResult[] = []; + + for (const definition of SHAPER_DEFINITIONS) { + // Find optimal frequency for this shaper + const optimalFreq = this.findOptimalFrequency( + definition.type, + resonanceFreq, + spectrum, + definition.minFreq, + definition.maxFreq + ); + + const vibrationReduction = calculateVibrationReduction(definition.type, optimalFreq, resonanceFreq); + + const smoothingTime = getSmoothingTime(definition.type, optimalFreq); + const maxAcceleration = estimateMaxAcceleration(definition.type, optimalFreq, vibrationReduction); + + const score = scoreShaperConfig(vibrationReduction, smoothingTime, maxAcceleration); + + results.push({ + type: definition.type, + frequency: optimalFreq, + vibrationReduction, + smoothingTime, + maxAcceleration, + score, + }); + } + + // Sort by score (highest first) + return results.sort((a, b) => b.score - a.score); + } + + /** + * Find the optimal shaper frequency for a given shaper type. + */ + findOptimalFrequency( + shaperType: ShaperType, + resonanceFreq: number, + spectrum: PowerSpectrum, + minFreq: number, + maxFreq: number + ): number { + // Search for frequency that gives best vibration reduction + let bestFreq = resonanceFreq; + let bestReduction = 0; + + // Search in steps around the resonance frequency + const step = 0.5; + const searchMin = Math.max(minFreq, resonanceFreq * 0.5); + const searchMax = Math.min(maxFreq, resonanceFreq * 2); + + for (let freq = searchMin; freq <= searchMax; freq += step) { + // Calculate total vibration reduction across spectrum + let totalReduction = 0; + let totalWeight = 0; + + for (let i = 0; i < spectrum.frequencies.length; i++) { + const power = spectrum.power[i]; + const reduction = calculateVibrationReduction(shaperType, freq, spectrum.frequencies[i]); + totalReduction += reduction * power; + totalWeight += power; + } + + const avgReduction = totalWeight > 0 ? totalReduction / totalWeight : 0; + + if (avgReduction > bestReduction) { + bestReduction = avgReduction; + bestFreq = freq; + } + } + + return Math.round(bestFreq * 10) / 10; // Round to 0.1 Hz + } + + /** + * Find the best shaper from evaluation results. + */ + findBestShaper(results: ShaperResult[]): ShaperResult { + // Results are already sorted by score + return results[0]; + } + + /** + * Generate Klipper configuration lines for a shaper result. + */ + generateKlipperConfig(axis: 'x' | 'y', result: ShaperResult): string[] { + const lines: string[] = []; + lines.push(`[input_shaper]`); + lines.push(`shaper_freq_${axis}: ${result.frequency.toFixed(1)}`); + lines.push(`shaper_type_${axis}: ${result.type}`); + return lines; + } + + /** + * Generate a full report for axis calibration. + */ + generateReport(calibration: AxisCalibration): string { + const lines: string[] = []; + + lines.push(`Input Shaper Calibration Report - ${calibration.axis.toUpperCase()} Axis`); + lines.push('='.repeat(50)); + lines.push(''); + + lines.push(`Peak Resonance Frequency: ${calibration.peakFrequencies[0]?.toFixed(1) || 'N/A'} Hz`); + lines.push(''); + + lines.push('Recommended Shaper:'); + lines.push(` Type: ${calibration.recommendedShaper.type.toUpperCase()}`); + lines.push(` Frequency: ${calibration.recommendedShaper.frequency.toFixed(1)} Hz`); + lines.push(` Vibration Reduction: ${(calibration.recommendedShaper.vibrationReduction * 100).toFixed(1)}%`); + lines.push(` Smoothing: ${calibration.recommendedShaper.smoothingTime.toFixed(2)} ms`); + lines.push(` Max Accel: ${calibration.recommendedShaper.maxAcceleration} mm/s²`); + lines.push(''); + + lines.push('All Shapers (sorted by score):'); + for (const result of calibration.allShaperResults) { + lines.push(` ${result.type.padEnd(10)} @ ${result.frequency.toFixed(1).padStart(5)} Hz`); + lines.push( + ` - Vibration: ${(result.vibrationReduction * 100).toFixed(1)}%, ` + + `Smooth: ${result.smoothingTime.toFixed(2)}ms, ` + + `Score: ${result.score.toFixed(1)}` + ); + } + lines.push(''); + + lines.push('Klipper Configuration:'); + const config = this.generateKlipperConfig(calibration.axis, calibration.recommendedShaper); + for (const line of config) { + lines.push(` ${line}`); + } + + return lines.join('\n'); + } +} + +/** + * Get shaper definition by type. + */ +export function getShaperDefinition(type: ShaperType): ShaperDefinition | undefined { + return SHAPER_DEFINITIONS.find((d) => d.type === type); +} + +/** + * Get all shaper definitions. + */ +export function getAllShaperDefinitions(): ShaperDefinition[] { + return [...SHAPER_DEFINITIONS]; +} diff --git a/src/services/calibration/shaper/index.ts b/src/services/calibration/shaper/index.ts new file mode 100644 index 0000000..e7eb2e3 --- /dev/null +++ b/src/services/calibration/shaper/index.ts @@ -0,0 +1,16 @@ +/** + * @fileoverview Exports for the input shaper calibration module. + * Provides FFT processing and shaper analysis for resonance compensation. + * + * @module services/calibration/shaper + */ + +export type { FFTOptions } from './FFTProcessor'; +export { DEFAULT_FFT_OPTIONS, FFTProcessor } from './FFTProcessor'; + +export { + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from './ShaperAnalyzer'; diff --git a/src/services/calibration/ssh/SCPFileTransfer.ts b/src/services/calibration/ssh/SCPFileTransfer.ts new file mode 100644 index 0000000..5b54a86 --- /dev/null +++ b/src/services/calibration/ssh/SCPFileTransfer.ts @@ -0,0 +1,829 @@ +/** + * @fileoverview SCP file transfer for printer configuration and calibration files. + * Provides download and upload functionality using the SCP protocol over SSH exec channels. + * + * @module services/calibration/ssh/SCPFileTransfer + */ + +import { getEnvironmentService } from '../../EnvironmentService'; +import fs from 'fs/promises'; +import path from 'path'; +import type { TransferProgress, TransferResult } from '../../../types/calibration'; +import type { SSHConnectionManager } from './SSHConnectionManager'; + +const SCP_ACK = Buffer.from([0]); +const SCP_CHUNK_SIZE = 16 * 1024; + +interface SCPFileHeader { + mode: string; + size: number; + filename: string; +} + +interface SCPChannel { + write(data: string | Buffer): boolean; + end(): void; + on(event: 'data', listener: (chunk: Buffer | string) => void): this; + on(event: 'error', listener: (err: Error) => void): this; + on(event: 'end', listener: () => void): this; + on(event: 'close', listener: (code?: number | null) => void): this; + once(event: 'drain', listener: () => void): this; + once(event: 'error', listener: (err: Error) => void): this; + once(event: 'close', listener: (code?: number | null) => void): this; + off(event: 'drain', listener: () => void): this; + off(event: 'error', listener: (err: Error) => void): this; + off(event: 'close', listener: () => void): this; + stderr?: { + on(event: 'data', listener: (chunk: Buffer | string) => void): void; + }; +} + +interface SCPChannelState { + buffer: Buffer; + ended: boolean; + error: Error | null; + stderr: string; + waiters: Array<() => void>; + closePromise: Promise; +} + +/** + * Default remote paths for FlashForge printers. + * Based on Stone-Time SSH method and Klipper configuration. + */ +export const DEFAULT_REMOTE_PATHS = { + /** Primary printer configuration file path used by Flashforge Calibration Assistant v2 */ + printerConfig: '/opt/config/printer.cfg', + /** Alternative printer config location */ + printerConfigAlt: '/root/printer_data/config/printer.cfg', + /** Additional known printer config location */ + printerConfigAlt2: '/usr/data/config/printer.cfg', + /** Legacy Klipper location observed on some setups */ + printerConfigLegacy: '/home/klipper/printer_data/config/printer.cfg', + /** Input shaper X-axis calibration data */ + shaperX: '/tmp/calibration_data_x_*.csv', + /** Input shaper Y-axis calibration data */ + shaperY: '/tmp/calibration_data_y_*.csv', + /** Resonance test results directory */ + resonanceDir: '/tmp/resonances/', +}; + +/** + * File transfer service using SCP over SSH. + */ +export class SCPFileTransfer { + /** SSH connection manager reference */ + private readonly connectionManager: SSHConnectionManager; + + /** Local cache directory for downloaded files */ + private readonly cacheDir: string; + + constructor(connectionManager: SSHConnectionManager) { + this.connectionManager = connectionManager; + this.cacheDir = path.join(getEnvironmentService().getDataPath(), 'calibration', 'cache'); + } + + /** + * Ensure cache directory exists. + */ + private async ensureCacheDir(): Promise { + await fs.mkdir(this.cacheDir, { recursive: true }); + } + + /** + * Build ordered candidate paths for printer.cfg. + * Custom path is always tried first when provided. + */ + private getPrinterConfigCandidates(preferredPath?: string): string[] { + const ordered = [ + preferredPath?.trim() || '', + DEFAULT_REMOTE_PATHS.printerConfig, + DEFAULT_REMOTE_PATHS.printerConfigAlt, + DEFAULT_REMOTE_PATHS.printerConfigAlt2, + DEFAULT_REMOTE_PATHS.printerConfigLegacy, + ]; + + const seen = new Set(); + const result: string[] = []; + for (const candidate of ordered) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + result.push(candidate); + } + return result; + } + + /** + * Shell-quote a string for safe usage in remote commands. + */ + private shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; + } + + /** + * Open an exec channel and initialize SCP stream state. + */ + private async openScpChannel( + contextId: string, + command: string + ): Promise<{ stream: SCPChannel; state: SCPChannelState }> { + const connection = this.connectionManager.getConnection(contextId); + + if (!connection) { + throw new Error('Not connected'); + } + + if (connection.status !== 'connected') { + throw new Error(`Connection status: ${connection.status}`); + } + + this.connectionManager.touch(contextId); + + const stream = await new Promise((resolve, reject) => { + connection.client.exec(command, (err, channel) => { + if (err) { + reject(err); + return; + } + resolve(channel as SCPChannel); + }); + }); + + const state: SCPChannelState = { + buffer: Buffer.alloc(0), + ended: false, + error: null, + stderr: '', + waiters: [], + closePromise: Promise.resolve(), + }; + + const notify = (): void => { + const pending = state.waiters.splice(0, state.waiters.length); + for (const resolve of pending) { + resolve(); + } + }; + + state.closePromise = new Promise((resolve, reject) => { + stream.once('close', (code: number | null | undefined) => { + state.ended = true; + notify(); + + if (code === 0 || code == null) { + resolve(); + return; + } + + const stderrText = state.stderr.trim(); + if (stderrText.length > 0) { + reject(new Error(`Received exit code ${code} while executing SCP command: ${stderrText}`)); + } else { + reject(new Error(`Received exit code ${code} while executing SCP command`)); + } + }); + + stream.once('error', (err: Error) => { + state.error = err; + notify(); + reject(err); + }); + }); + + stream.on('data', (chunk: Buffer | string) => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + state.buffer = state.buffer.length === 0 ? data : Buffer.concat([state.buffer, data]); + notify(); + }); + + if (stream.stderr && typeof stream.stderr.on === 'function') { + stream.stderr.on('data', (chunk: Buffer | string) => { + state.stderr += Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : chunk; + }); + } + + stream.on('error', (err: Error) => { + state.error = err; + notify(); + }); + + stream.on('end', () => { + state.ended = true; + notify(); + }); + + return { stream, state }; + } + + /** + * Wait until new channel data or state is available. + */ + private waitForStateChange(state: SCPChannelState): Promise { + return new Promise((resolve) => { + state.waiters.push(resolve); + }); + } + + /** + * Build a consistent unexpected EOF error message. + */ + private unexpectedEOF(state: SCPChannelState, operation: string): Error { + const stderrText = state.stderr.trim(); + if (stderrText.length > 0) { + return new Error(`Unexpected end of SCP stream while ${operation}: ${stderrText}`); + } + return new Error(`Unexpected end of SCP stream while ${operation}`); + } + + /** + * Read a single byte from the SCP channel state buffer. + */ + private async readByte(state: SCPChannelState): Promise { + while (state.buffer.length === 0) { + if (state.error) { + throw state.error; + } + + if (state.ended) { + throw this.unexpectedEOF(state, 'reading control byte'); + } + + await this.waitForStateChange(state); + } + + const byte = state.buffer[0]; + state.buffer = state.buffer.subarray(1); + return byte; + } + + /** + * Read a newline-terminated control line from the SCP stream. + */ + private async readLine(state: SCPChannelState): Promise { + while (true) { + if (state.error) { + throw state.error; + } + + const newlineIndex = state.buffer.indexOf(0x0a); + if (newlineIndex >= 0) { + const lineBuffer = state.buffer.subarray(0, newlineIndex); + state.buffer = state.buffer.subarray(newlineIndex + 1); + return lineBuffer.toString('utf-8').replace(/\r$/, ''); + } + + if (state.ended) { + throw this.unexpectedEOF(state, 'reading control line'); + } + + await this.waitForStateChange(state); + } + } + + /** + * Read a fixed number of bytes from the SCP stream. + */ + private async readExact(state: SCPChannelState, length: number, onChunk?: (chunk: Buffer) => void): Promise { + const chunks: Buffer[] = []; + let remaining = length; + + while (remaining > 0) { + if (state.error) { + throw state.error; + } + + if (state.buffer.length === 0) { + if (state.ended) { + throw this.unexpectedEOF(state, `reading ${length} bytes of file data`); + } + + await this.waitForStateChange(state); + continue; + } + + const take = Math.min(remaining, state.buffer.length); + const chunk = Buffer.from(state.buffer.subarray(0, take)); + state.buffer = state.buffer.subarray(take); + remaining -= take; + + if (onChunk) { + onChunk(chunk); + } else { + chunks.push(chunk); + } + } + + if (onChunk) { + return Buffer.alloc(0); + } + + return Buffer.concat(chunks, length); + } + + /** + * Read and validate an SCP ACK/error response. + */ + private async readAck(state: SCPChannelState): Promise { + const response = await this.readByte(state); + + if (response === 0) { + return; + } + + if (response === 1 || response === 2) { + const message = (await this.readLine(state)).trim(); + throw new Error(message || (response === 1 ? 'SCP error' : 'SCP fatal error')); + } + + throw new Error(`Unexpected SCP response byte: ${response}`); + } + + /** + * Parse an SCP file header line (without leading "C"). + */ + private parseFileHeader(line: string): SCPFileHeader { + const match = line.match(/^([0-7]{4})\s+(\d+)\s+(.+)$/); + if (!match) { + throw new Error(`Invalid SCP file header: ${line}`); + } + + const size = Number.parseInt(match[2], 10); + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid SCP file size in header: ${line}`); + } + + return { + mode: match[1], + size, + filename: match[3], + }; + } + + /** + * Read the next file header from an SCP download stream. + * Handles optional timestamp records and end markers. + */ + private async readNextFileHeader(state: SCPChannelState, stream: SCPChannel): Promise { + while (true) { + const control = await this.readByte(state); + + // Timestamp record (T 0 0) + if (control === 0x54) { + await this.readLine(state); + stream.write(SCP_ACK); + continue; + } + + // File record (C ) + if (control === 0x43) { + const headerLine = await this.readLine(state); + return this.parseFileHeader(headerLine); + } + + // End-of-directory marker + if (control === 0x45) { + await this.readLine(state); + return null; + } + + // Remote reported error/fatal + if (control === 0x01 || control === 0x02) { + const message = (await this.readLine(state)).trim(); + throw new Error(message || (control === 0x01 ? 'SCP error' : 'SCP fatal error')); + } + + throw new Error(`Unexpected SCP control byte: ${control}`); + } + } + + /** + * Wait for channel drain, surfacing channel close/error as failures. + */ + private async waitForDrain(stream: SCPChannel, state: SCPChannelState): Promise { + if (state.error) { + throw state.error; + } + + await new Promise((resolve, reject) => { + const cleanup = (): void => { + stream.off('drain', onDrain); + stream.off('error', onError); + stream.off('close', onClose); + }; + + const onDrain = (): void => { + cleanup(); + resolve(); + }; + + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + + const onClose = (): void => { + cleanup(); + reject(this.unexpectedEOF(state, 'writing file data')); + }; + + stream.once('drain', onDrain); + stream.once('error', onError); + stream.once('close', onClose); + }); + } + + /** + * Download a file from the remote printer. + * + * @param contextId - Printer context ID + * @param remotePath - Path on the remote printer + * @param localPath - Local destination path (optional, uses cache if not provided) + * @param onProgress - Progress callback + * @returns Transfer result + */ + async downloadFile( + contextId: string, + remotePath: string, + localPath?: string, + onProgress?: (progress: TransferProgress) => void + ): Promise { + await this.ensureCacheDir(); + + const requestedFilename = path.posix.basename(remotePath) || path.basename(remotePath); + const destination = localPath || path.join(this.cacheDir, contextId, requestedFilename); + + // Ensure destination directory exists + await fs.mkdir(path.dirname(destination), { recursive: true }); + + let bytesTransferred = 0; + let stream: SCPChannel | null = null; + let state: SCPChannelState | null = null; + + try { + ({ stream, state } = await this.openScpChannel(contextId, `scp -f ${this.shellQuote(remotePath)}`)); + + // Start SCP transfer handshake + stream.write(SCP_ACK); + + const header = await this.readNextFileHeader(state, stream); + if (!header) { + throw new Error(`No file returned for remote path: ${remotePath}`); + } + + // Ack file header before content transfer + stream.write(SCP_ACK); + + const totalBytes = header.size; + const filename = header.filename || requestedFilename; + const chunks: Buffer[] = []; + + await this.readExact(state, totalBytes, (chunk) => { + chunks.push(chunk); + bytesTransferred += chunk.length; + + if (onProgress && totalBytes > 0) { + onProgress({ + filename, + bytesTransferred, + totalBytes, + percentage: Math.round((bytesTransferred / totalBytes) * 100), + }); + } + }); + + await fs.writeFile(destination, Buffer.concat(chunks, totalBytes)); + + // Read sender trailing ACK and finish transfer + await this.readAck(state); + stream.write(SCP_ACK); + stream.end(); + await state.closePromise; + + return { + success: true, + localPath: destination, + remotePath, + bytesTransferred, + }; + } catch (err) { + if (stream) { + try { + stream.end(); + } catch { + // Ignore cleanup errors + } + } + + if (state) { + try { + await state.closePromise; + } catch { + // Ignore close errors in failure path + } + } + + return { + success: false, + localPath: destination, + remotePath, + bytesTransferred, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + } + + /** + * Upload a file to the remote printer. + * + * @param contextId - Printer context ID + * @param localPath - Local source path + * @param remotePath - Destination path on printer + * @param onProgress - Progress callback + * @returns Transfer result + */ + async uploadFile( + contextId: string, + localPath: string, + remotePath: string, + onProgress?: (progress: TransferProgress) => void + ): Promise { + const normalizedRemotePath = remotePath.replace(/\\/g, '/'); + const remoteIsDirectory = normalizedRemotePath.endsWith('/'); + const targetDirectory = remoteIsDirectory + ? normalizedRemotePath.replace(/\/+$/, '') || '/' + : path.posix.dirname(normalizedRemotePath); + const targetFilename = remoteIsDirectory ? path.basename(localPath) : path.posix.basename(normalizedRemotePath); + + if (!targetFilename || targetFilename === '.' || targetFilename === '..') { + return { + success: false, + localPath, + remotePath, + bytesTransferred: 0, + error: `Invalid remote file path: ${remotePath}`, + }; + } + + let stream: SCPChannel | null = null; + let state: SCPChannelState | null = null; + + try { + const content = await fs.readFile(localPath); + const totalBytes = content.length; + let bytesTransferred = 0; + const progressFilename = path.basename(localPath); + + ({ stream, state } = await this.openScpChannel(contextId, `scp -t ${this.shellQuote(targetDirectory)}`)); + + // Wait for receiver ready ACK + await this.readAck(state); + + // Announce file metadata to receiver + stream.write(`C0644 ${totalBytes} ${targetFilename}\n`); + await this.readAck(state); + + // Send file data with progress updates + let offset = 0; + while (offset < totalBytes) { + const end = Math.min(offset + SCP_CHUNK_SIZE, totalBytes); + const chunk = content.subarray(offset, end); + const canContinue = stream.write(chunk); + + bytesTransferred = end; + if (onProgress) { + onProgress({ + filename: progressFilename, + bytesTransferred, + totalBytes, + percentage: Math.round((bytesTransferred / totalBytes) * 100), + }); + } + + offset = end; + + if (!canContinue) { + await this.waitForDrain(stream, state); + } + } + + // Signal end-of-file and wait for remote ACK + stream.write(SCP_ACK); + await this.readAck(state); + stream.end(); + await state.closePromise; + + return { + success: true, + localPath, + remotePath, + bytesTransferred: totalBytes, + }; + } catch (err) { + if (stream) { + try { + stream.end(); + } catch { + // Ignore cleanup errors + } + } + + if (state) { + try { + await state.closePromise; + } catch { + // Ignore close errors in failure path + } + } + + return { + success: false, + localPath, + remotePath, + bytesTransferred: 0, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + } + + /** + * Read a remote file and return its content as a string. + * + * @param contextId - Printer context ID + * @param remotePath - Path on the remote printer + * @returns File content as string + */ + async readRemoteFile(contextId: string, remotePath: string): Promise { + const result = await this.downloadFile(contextId, remotePath); + + if (!result.success) { + throw new Error(result.error || 'Download failed'); + } + + return fs.readFile(result.localPath, 'utf-8'); + } + + /** + * Fetch printer.cfg from the remote printer. + * Tries multiple known locations. + * + * @param contextId - Printer context ID + * @returns Config file content + */ + async fetchPrinterConfig(contextId: string, preferredPath?: string): Promise { + const candidates = this.getPrinterConfigCandidates(preferredPath); + let lastError: Error | null = null; + + for (const remotePath of candidates) { + try { + return await this.readRemoteFile(contextId, remotePath); + } catch (err) { + lastError = err instanceof Error ? err : new Error('Unknown error'); + } + } + + throw new Error( + `Could not find printer.cfg. Tried:\n` + + candidates.map((candidate) => ` - ${candidate}`).join('\n') + + `\nError: ${lastError?.message || 'Unknown error'}` + ); + } + + /** + * Fetch input shaper CSV file for an axis. + * + * @param contextId - Printer context ID + * @param axis - Which axis ('x' or 'y') + * @returns CSV file content + */ + async fetchShaperCSV(contextId: string, axis: 'x' | 'y'): Promise { + const locateResult = await this.connectionManager.executeCommand( + contextId, + `ls -1t /tmp/calibration_data_${axis}_*.csv 2>/dev/null | head -n 1` + ); + + if (locateResult.exitCode === -1) { + throw new Error(locateResult.error || locateResult.stderr.trim() || 'Failed to query calibration CSV files'); + } + + const latestPath = locateResult.stdout.trim().split(/\r?\n/)[0]; + + if (!latestPath) { + throw new Error(`No ${axis}-axis calibration data found in /tmp`); + } + + return this.readRemoteFile(contextId, latestPath); + } + + /** + * List files in a remote directory. + * + * @param contextId - Printer context ID + * @param remotePath - Directory path + * @returns Array of file names + */ + async listDirectory(contextId: string, remotePath: string): Promise { + const result = await this.connectionManager.executeCommand( + contextId, + `cd ${this.shellQuote(remotePath)} && ls -1A` + ); + + if (!result.success) { + throw new Error(result.stderr.trim() || result.error || `Could not list directory: ${remotePath}`); + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0); + } + + /** + * Check if a remote file exists. + * + * @param contextId - Printer context ID + * @param remotePath - File path to check + * @returns True if file exists + */ + async fileExists(contextId: string, remotePath: string): Promise { + const result = await this.connectionManager.executeCommand(contextId, `test -e ${this.shellQuote(remotePath)}`); + + return result.exitCode === 0; + } + + /** + * Upload config content to the printer. + * + * @param contextId - Printer context ID + * @param content - Config file content + * @param remotePath - Destination path (defaults to printer.cfg location) + * @returns Transfer result + */ + async uploadConfig(contextId: string, content: string, remotePath?: string): Promise { + await this.ensureCacheDir(); + + const configuredPath = remotePath?.trim(); + let targetRemotePath = configuredPath; + + if (targetRemotePath && !(await this.fileExists(contextId, targetRemotePath))) { + targetRemotePath = undefined; + } + + if (!targetRemotePath) { + const candidates = this.getPrinterConfigCandidates(); + for (const candidate of candidates) { + if (await this.fileExists(contextId, candidate)) { + targetRemotePath = candidate; + break; + } + } + if (!targetRemotePath) { + targetRemotePath = DEFAULT_REMOTE_PATHS.printerConfig; + } + } + + // Write content to temp file first + const tempPath = path.join(this.cacheDir, contextId, 'upload-temp.cfg'); + await fs.mkdir(path.dirname(tempPath), { recursive: true }); + await fs.writeFile(tempPath, content, 'utf-8'); + + // Upload the file + const result = await this.uploadFile(contextId, tempPath, targetRemotePath); + + // Clean up temp file + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + + return result; + } + + /** + * Clear the local cache for a context. + * + * @param contextId - Printer context ID + */ + async clearCache(contextId: string): Promise { + const contextCacheDir = path.join(this.cacheDir, contextId); + try { + await fs.rm(contextCacheDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + } + + /** + * Clear entire cache. + */ + async clearAllCache(): Promise { + try { + await fs.rm(this.cacheDir, { recursive: true, force: true }); + await this.ensureCacheDir(); + } catch { + // Ignore errors + } + } +} diff --git a/src/services/calibration/ssh/SSHConnectionManager.ts b/src/services/calibration/ssh/SSHConnectionManager.ts new file mode 100644 index 0000000..d618b34 --- /dev/null +++ b/src/services/calibration/ssh/SSHConnectionManager.ts @@ -0,0 +1,389 @@ +/** + * @fileoverview SSH connection management for remote printer access. + * Provides connection pooling, automatic reconnection, and timeout handling. + * Uses the ssh2 library for native SSH connectivity. + * + * @module services/calibration/ssh/SSHConnectionManager + */ + +import { EventEmitter } from 'events'; +import { Client, type ConnectConfig } from 'ssh2'; +import { type SSHConnectionConfig, SSHConnectionStatus } from '../../../types/calibration'; + +/** + * Active SSH connection state. + */ +export interface SSHConnection { + /** Unique connection ID (typically printer context ID) */ + id: string; + /** Connection configuration */ + config: SSHConnectionConfig; + /** SSH2 client instance */ + client: Client; + /** Current connection status */ + status: SSHConnectionStatus; + /** Timestamp of last activity */ + lastActivity: number; + /** Error message if status is 'error' */ + error?: string; +} + +/** + * Result of executing a command over SSH. + */ +export interface CommandResult { + /** Whether the command succeeded */ + success: boolean; + /** Exit code from the command */ + exitCode: number; + /** Standard output */ + stdout: string; + /** Standard error */ + stderr: string; + /** Error message if execution failed */ + error?: string; +} + +/** + * Events emitted by SSHConnectionManager. + */ +export interface SSHConnectionManagerEvents { + 'connection-status': (id: string, status: SSHConnectionStatus, error?: string) => void; + 'connection-error': (id: string, error: Error) => void; + 'connection-closed': (id: string) => void; +} + +/** + * Default SSH configuration values. + */ +const DEFAULT_CONFIG: Partial = { + port: 22, + timeout: 10000, + keepaliveInterval: 10000, + username: 'root', +}; + +/** + * Manager for SSH connections to printers. + * Maintains a pool of connections keyed by printer context ID. + */ +export class SSHConnectionManager extends EventEmitter { + /** Active connections */ + private readonly connections: Map = new Map(); + + /** Cleanup interval handle */ + private cleanupInterval: NodeJS.Timeout | null = null; + + /** Stale connection timeout (5 minutes) */ + private readonly staleTimeout = 5 * 60 * 1000; + + constructor() { + super(); + this.startCleanupInterval(); + } + + /** + * Start the periodic cleanup of stale connections. + */ + private startCleanupInterval(): void { + this.cleanupInterval = setInterval(() => { + this.cleanupStaleConnections(); + }, 60000); // Check every minute + } + + /** + * Stop the cleanup interval. + */ + private stopCleanupInterval(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Connect to a printer via SSH. + * + * @param contextId - Printer context ID + * @param config - SSH connection configuration + * @returns Promise that resolves when connected + */ + async connect(contextId: string, config: SSHConnectionConfig): Promise { + // Disconnect existing connection if any + if (this.connections.has(contextId)) { + await this.disconnect(contextId); + } + + const fullConfig: SSHConnectionConfig = { + ...DEFAULT_CONFIG, + ...config, + } as SSHConnectionConfig; + + const client = new Client(); + + const connection: SSHConnection = { + id: contextId, + config: fullConfig, + client, + status: SSHConnectionStatus.CONNECTING, + lastActivity: Date.now(), + }; + + this.connections.set(contextId, connection); + this.emit('connection-status', contextId, SSHConnectionStatus.CONNECTING); + + return new Promise((resolve, reject) => { + const connectConfig: ConnectConfig = { + host: fullConfig.host, + port: fullConfig.port, + username: fullConfig.username, + readyTimeout: fullConfig.timeout, + keepaliveInterval: fullConfig.keepaliveInterval, + }; + + // Add authentication + if (fullConfig.privateKey) { + connectConfig.privateKey = fullConfig.privateKey; + } else if (fullConfig.password) { + connectConfig.password = fullConfig.password; + } + + // Connection event handlers + client.on('ready', () => { + connection.status = SSHConnectionStatus.CONNECTED; + connection.lastActivity = Date.now(); + connection.error = undefined; + this.emit('connection-status', contextId, SSHConnectionStatus.CONNECTED); + resolve(); + }); + + client.on('error', (err: Error) => { + connection.status = SSHConnectionStatus.ERROR; + connection.error = err.message; + this.emit('connection-status', contextId, SSHConnectionStatus.ERROR, err.message); + this.emit('connection-error', contextId, err); + reject(err); + }); + + client.on('close', () => { + if (connection.status !== SSHConnectionStatus.DISCONNECTED) { + connection.status = SSHConnectionStatus.DISCONNECTED; + this.emit('connection-status', contextId, SSHConnectionStatus.DISCONNECTED); + this.emit('connection-closed', contextId); + } + }); + + client.on('end', () => { + connection.status = SSHConnectionStatus.DISCONNECTED; + }); + + // Initiate connection + client.connect(connectConfig); + }); + } + + /** + * Disconnect from a printer. + * + * @param contextId - Printer context ID + */ + async disconnect(contextId: string): Promise { + const connection = this.connections.get(contextId); + if (!connection) { + return; + } + + return new Promise((resolve) => { + connection.status = SSHConnectionStatus.DISCONNECTED; + connection.client.end(); + + // Give it a moment to close gracefully + setTimeout(() => { + this.connections.delete(contextId); + this.emit('connection-status', contextId, SSHConnectionStatus.DISCONNECTED); + resolve(); + }, 100); + }); + } + + /** + * Check if a connection is active. + * + * @param contextId - Printer context ID + * @returns True if connected + */ + isConnected(contextId: string): boolean { + const connection = this.connections.get(contextId); + return connection?.status === SSHConnectionStatus.CONNECTED; + } + + /** + * Get connection status. + * + * @param contextId - Printer context ID + * @returns Connection status or 'disconnected' if not found + */ + getStatus(contextId: string): SSHConnectionStatus { + const connection = this.connections.get(contextId); + return connection?.status || SSHConnectionStatus.DISCONNECTED; + } + + /** + * Get connection details. + * + * @param contextId - Printer context ID + * @returns Connection object or undefined + */ + getConnection(contextId: string): SSHConnection | undefined { + return this.connections.get(contextId); + } + + /** + * Execute a command on the remote printer. + * + * @param contextId - Printer context ID + * @param command - Command to execute + * @returns Command result + */ + async executeCommand(contextId: string, command: string): Promise { + const connection = this.connections.get(contextId); + + if (!connection) { + return { + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: 'Not connected', + }; + } + + if (connection.status !== SSHConnectionStatus.CONNECTED) { + return { + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: `Connection status: ${connection.status}`, + }; + } + + connection.lastActivity = Date.now(); + + return new Promise((resolve) => { + connection.client.exec(command, (err, stream) => { + if (err) { + resolve({ + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: err.message, + }); + return; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + stream.on('close', (code: number) => { + exitCode = code; + resolve({ + success: code === 0, + exitCode, + stdout, + stderr, + }); + }); + + stream.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + }); + }); + } + + /** + * Refresh a connection by reconnecting. + * + * @param contextId - Printer context ID + */ + async refreshConnection(contextId: string): Promise { + const connection = this.connections.get(contextId); + if (!connection) { + throw new Error('No connection to refresh'); + } + + await this.connect(contextId, connection.config); + } + + /** + * Clean up stale connections that haven't been used recently. + */ + cleanupStaleConnections(): void { + const now = Date.now(); + + for (const [contextId, connection] of this.connections) { + if (now - connection.lastActivity > this.staleTimeout) { + this.disconnect(contextId).catch(() => { + // Ignore errors during cleanup + }); + } + } + } + + /** + * Disconnect all connections and cleanup. + */ + async shutdown(): Promise { + this.stopCleanupInterval(); + + const disconnectPromises: Promise[] = []; + for (const contextId of this.connections.keys()) { + disconnectPromises.push(this.disconnect(contextId)); + } + + await Promise.all(disconnectPromises); + } + + /** + * Get all active connection IDs. + */ + getActiveConnectionIds(): string[] { + return Array.from(this.connections.keys()).filter( + (id) => this.connections.get(id)?.status === SSHConnectionStatus.CONNECTED + ); + } + + /** + * Update last activity timestamp for a connection. + * + * @param contextId - Printer context ID + */ + touch(contextId: string): void { + const connection = this.connections.get(contextId); + if (connection) { + connection.lastActivity = Date.now(); + } + } +} + +/** + * Singleton instance. + */ +let sshManagerInstance: SSHConnectionManager | null = null; + +/** + * Get the SSHConnectionManager singleton. + */ +export function getSSHConnectionManager(): SSHConnectionManager { + if (!sshManagerInstance) { + sshManagerInstance = new SSHConnectionManager(); + } + return sshManagerInstance; +} diff --git a/src/services/calibration/ssh/index.ts b/src/services/calibration/ssh/index.ts new file mode 100644 index 0000000..c270ace --- /dev/null +++ b/src/services/calibration/ssh/index.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Exports for the SSH module. + * Provides SSH connectivity and file transfer functionality for remote printer access. + * + * @module services/calibration/ssh + */ + +export { DEFAULT_REMOTE_PATHS, SCPFileTransfer } from './SCPFileTransfer'; +export type { CommandResult, SSHConnection, SSHConnectionManagerEvents } from './SSHConnectionManager'; +export { + getSSHConnectionManager, + SSHConnectionManager, +} from './SSHConnectionManager'; diff --git a/src/types/calibration.ts b/src/types/calibration.ts new file mode 100644 index 0000000..71502a0 --- /dev/null +++ b/src/types/calibration.ts @@ -0,0 +1,708 @@ +/** + * @fileoverview Shared type definitions for the Calibration Assistant module. + * These types are used across main process, renderer, and WebUI for bed leveling, + * input shaper analysis, and calibration workflow operations. + * + * @module shared/types/calibration + */ + +// ============================================================================ +// Enums +// ============================================================================ + +/** + * Direction of screw rotation for bed leveling adjustments. + */ +export enum RotationDirection { + CLOCKWISE = 'CW', + COUNTERCLOCKWISE = 'CCW', +} + +/** + * Corner positions on the print bed. + */ +export enum BedCorner { + FRONT_LEFT = 'frontLeft', + FRONT_RIGHT = 'frontRight', + REAR_LEFT = 'rearLeft', + REAR_RIGHT = 'rearRight', +} + +/** + * Stages in the calibration workflow. + */ +export enum WorkflowStage { + INITIAL = 'initial', + BELT_SYNC = 'belt_sync', + SCREW_ADJUST = 'screw_adjust', + TAPE_COMPENSATE = 'tape_compensate', + THERMAL_PREDICT = 'thermal_predict', + COMPLETE = 'complete', +} + +/** + * Input shaper types supported by Klipper. + */ +export enum ShaperType { + ZV = 'zv', + MZV = 'mzv', + EI = 'ei', + TWO_HUMP_EI = '2hump_ei', + THREE_HUMP_EI = '3hump_ei', +} + +/** + * SSH connection status states. + */ +export enum SSHConnectionStatus { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + ERROR = 'error', +} + +// ============================================================================ +// Bed & Mesh Types +// ============================================================================ + +/** + * Configuration for the print bed hardware. + */ +export interface BedConfig { + /** Bed width in mm */ + sizeX: number; + /** Bed depth in mm */ + sizeY: number; + /** Number of mesh probe points along X axis */ + meshPointsX: number; + /** Number of mesh probe points along Y axis */ + meshPointsY: number; +} + +/** + * Height values at the four corners of the bed. + */ +export interface BedCorners { + frontLeft: number; + frontRight: number; + rearLeft: number; + rearRight: number; +} + +/** + * Parsed mesh data from printer configuration. + */ +export interface MeshData { + /** 2D array of Z-height values [row][col] */ + matrix: number[][]; + /** Minimum X coordinate of mesh */ + minX: number; + /** Maximum X coordinate of mesh */ + maxX: number; + /** Minimum Y coordinate of mesh */ + minY: number; + /** Maximum Y coordinate of mesh */ + maxY: number; + /** Number of points along X axis */ + pointsX: number; + /** Number of points along Y axis */ + pointsY: number; + /** Name of the mesh profile */ + profileName: string; +} + +// ============================================================================ +// Screw Adjustment Types +// ============================================================================ + +/** + * Configuration for screw adjustment calculations. + */ +export interface ScrewConfig { + /** Thread pitch in mm per full rotation (default: 0.5) */ + pitch: number; + /** Minimum deviation threshold for adjustment (default: 0.01) */ + minAdjust: number; + /** Maximum allowed adjustment in mm (default: 2.0) */ + maxAdjust: number; +} + +/** + * Calculated adjustment for a single bed corner screw. + */ +export interface ScrewAdjustment { + /** Which corner this adjustment is for */ + corner: BedCorner; + /** Height deviation from reference in mm */ + deviation: number; + /** Direction to turn the screw */ + direction: RotationDirection; + /** Rotation amount in minutes (1/60 degree) */ + minutes: number; + /** Rotation amount in degrees */ + degrees: number; + /** Number of full rotations */ + turns: number; + /** Human-readable adjustment string (e.g., "15 minutes CW") */ + formattedAmount: string; + /** Whether this corner needs adjustment */ + requiresAdjustment: boolean; +} + +// ============================================================================ +// Tape Compensation Types +// ============================================================================ + +/** + * Configuration for tape compensation calculations. + */ +export interface TapeConfig { + /** Thickness of a single tape layer in mm (default: 0.05 for Kapton) */ + tapeThickness: number; + /** Minimum height difference to recommend tape */ + minHeightDiff: number; +} + +/** + * Tape layer recommendation for a single corner. + */ +export interface TapeRecommendation { + /** Which corner needs tape */ + corner: BedCorner; + /** Number of tape layers recommended */ + layers: number; + /** Total thickness of tape in mm */ + totalThickness: number; + /** Original deviation that this compensates */ + deviation: number; +} + +// ============================================================================ +// Analysis Types +// ============================================================================ + +/** + * Results from analyzing mesh deviations. + */ +export interface AnalysisResult { + /** Range between highest and lowest points */ + meshRange: number; + /** Maximum deviation from mean */ + maxDeviation: number; + /** Minimum deviation from mean */ + minDeviation: number; + /** Average deviation across mesh */ + averageDeviation: number; + /** Standard deviation of mesh values */ + standardDeviation: number; + /** Corner height values */ + cornerDeviations: BedCorners; + /** Which corner is used as reference (lowest) */ + referenceCorner: BedCorner; + /** Recommended actions based on analysis */ + recommendations: { + needsBeltSync: boolean; + needsScrewAdjust: boolean; + needsTapeCompensation: boolean; + }; +} + +// ============================================================================ +// Workflow Types +// ============================================================================ + +/** + * Result from processing a single workflow stage. + */ +export interface StageResult { + /** Which stage this result is for */ + stage: WorkflowStage; + /** Whether the stage completed successfully */ + success: boolean; + /** Stage-specific result data */ + data: unknown; + /** Metrics computed during this stage */ + metrics: Record; + /** Next stage to process, or null if complete */ + nextStage: WorkflowStage | null; +} + +/** + * Belt synchronization analysis result. + */ +export interface BeltSyncResult { + /** X-axis tilt in mm */ + xAxisTilt: number; + /** Y-axis tilt in mm */ + yAxisTilt: number; + /** Whether belt sync is needed */ + needsSync: boolean; + /** Recommended adjustment */ + recommendation: string; +} + +/** + * Thermal expansion prediction result. + */ +export interface ThermalPrediction { + /** Predicted mesh at target temperature */ + predictedMesh: number[][]; + /** Expected change in mesh range */ + rangeChange: number; + /** Temperature delta used */ + tempDelta: number; + /** Expansion coefficient applied */ + expansionCoeff: number; +} + +/** + * Complete workflow computation data. + */ +export interface WorkflowData { + /** Current stage being processed */ + currentStage: WorkflowStage; + /** Results for each completed stage */ + stages: Map; + /** Timestamp when workflow started */ + startTime: number; + /** List of completed stages */ + completedStages: WorkflowStage[]; + + /** Belt sync stage result */ + beltSyncResult?: BeltSyncResult; + /** Screw adjustment recommendations */ + screwAdjustments?: ScrewAdjustment[]; + /** Tape compensation recommendations */ + tapeRecommendations?: TapeRecommendation[]; + /** Thermal prediction result */ + thermalPrediction?: ThermalPrediction; + + /** Mesh range before calibration */ + initialRange: number; + /** Predicted mesh range after calibration */ + finalRange: number; + /** Improvement percentage */ + improvementPercent: number; +} + +// ============================================================================ +// Input Shaper Types +// ============================================================================ + +/** + * Definition of an input shaper algorithm. + */ +export interface ShaperDefinition { + /** Shaper type identifier */ + type: ShaperType; + /** Human-readable name */ + name: string; + /** Minimum supported frequency */ + minFreq: number; + /** Initial/default frequency */ + initFreq: number; + /** Maximum supported frequency */ + maxFreq: number; +} + +/** + * Result of evaluating a single shaper type. + */ +export interface ShaperResult { + /** Shaper type evaluated */ + type: ShaperType; + /** Optimal frequency in Hz */ + frequency: number; + /** Vibration reduction factor (0-1) */ + vibrationReduction: number; + /** Smoothing time in ms */ + smoothingTime: number; + /** Maximum recommended acceleration in mm/s² */ + maxAcceleration: number; + /** Overall quality score */ + score: number; +} + +/** + * Calibration results for a single axis. + */ +export interface AxisCalibration { + /** Which axis was calibrated */ + axis: 'x' | 'y'; + /** Frequency bins from FFT */ + frequencyBins: number[]; + /** Power spectral density values */ + powerSpectralDensity: number[]; + /** Detected resonance frequencies */ + peakFrequencies: number[]; + /** Best shaper recommendation */ + recommendedShaper: ShaperResult; + /** Results for all evaluated shapers */ + allShaperResults: ShaperResult[]; +} + +/** + * Accelerometer data parsed from CSV. + */ +export interface AccelerometerData { + /** Time values in seconds */ + time: number[]; + /** X-axis acceleration values */ + accelX: number[]; + /** Y-axis acceleration values */ + accelY: number[]; + /** Z-axis acceleration values */ + accelZ: number[]; + /** Sample rate in Hz */ + sampleRate: number; +} + +/** + * Power spectrum from FFT analysis. + */ +export interface PowerSpectrum { + /** Frequency bins in Hz */ + frequencies: number[]; + /** Power values at each frequency */ + power: number[]; + /** Peak frequency detected */ + peakFrequency: number; + /** Peak power value */ + peakPower: number; +} + +// ============================================================================ +// SSH Types +// ============================================================================ + +/** + * SSH connection configuration. + */ +export interface SSHConnectionConfig { + /** Hostname or IP address */ + host: string; + /** SSH port (default: 22) */ + port: number; + /** Username for authentication */ + username: string; + /** Password for authentication (optional if using key) */ + password?: string; + /** Path to private key file (optional) */ + privateKey?: string; + /** Connection timeout in ms */ + timeout: number; + /** Keepalive interval in ms */ + keepaliveInterval: number; +} + +/** + * Result of a file transfer operation. + */ +export interface TransferResult { + /** Whether transfer succeeded */ + success: boolean; + /** Local file path */ + localPath: string; + /** Remote file path */ + remotePath: string; + /** Number of bytes transferred */ + bytesTransferred: number; + /** Error message if failed */ + error?: string; +} + +/** + * Progress update during file transfer. + */ +export interface TransferProgress { + /** Name of file being transferred */ + filename: string; + /** Bytes transferred so far */ + bytesTransferred: number; + /** Total file size in bytes */ + totalBytes: number; + /** Percentage complete (0-100) */ + percentage: number; +} + +// ============================================================================ +// Settings Types +// ============================================================================ + +/** + * Hardware-specific calibration settings. + */ +export interface CalibrationHardwareSettings { + /** Tape thickness in mm (default: 0.05) */ + tapeThickness: number; + /** Belt tooth spacing in mm (default: 2.0) */ + beltToothMm: number; + /** Screw thread pitch in mm (default: 0.5) */ + screwPitch: number; + /** Minimum adjustment threshold in mm (default: 0.01) */ + minAdjustment: number; + /** Maximum adjustment limit in mm (default: 2.0) */ + maxAdjustment: number; + /** Number of grid points to average for corners (default: 2) */ + cornerAveraging: number; +} + +/** + * Threshold settings for calibration decisions. + */ +export interface CalibrationThresholds { + /** Belt sync threshold in mm (default: 0.1) */ + beltThreshold: number; + /** Screw adjustment threshold in mm (default: 0.02) */ + screwThreshold: number; + /** Tape compensation threshold in mm (default: 0.02) */ + tapeThreshold: number; +} + +/** + * Visualization preferences. + */ +export interface CalibrationVisualization { + /** Mesh interpolation factor (default: 3) */ + interpolationFactor: number; + /** Color scheme for heatmaps (default: 'viridis') */ + colorScheme: 'viridis' | 'plasma' | 'inferno' | 'coolwarm'; + /** Whether to show 3D surface (default: false, deferred) */ + show3D: boolean; +} + +/** + * Workflow stage enable/disable settings. + */ +export interface CalibrationWorkflowSettings { + /** Enable belt sync stage (default: true) */ + enableBelt: boolean; + /** Enable screw adjustment stage (default: true) */ + enableScrews: boolean; + /** Enable tape compensation stage (default: true) */ + enableTape: boolean; +} + +/** + * Thermal expansion preset for a bed type. + */ +export interface ThermalPreset { + /** Preset name (e.g., "PEI Spring Steel") */ + name: string; + /** Temperature at which mesh was measured */ + measurementTemp: number; + /** Target operating temperature */ + targetTemp: number; + /** Chamber heating factor (0-1) */ + chamberFactor: number; + /** PEI layer thickness in mm */ + peiThickness: number; + /** Steel sheet thickness in mm */ + steelThickness: number; + /** PEI thermal expansion coefficient */ + alphaPei: number; + /** Steel thermal expansion coefficient */ + alphaSteel: number; + /** Uniform expansion factor */ + betaUniform: number; +} + +/** + * Complete calibration settings object. + */ +export interface CalibrationSettings { + /** Hardware configuration */ + hardware: CalibrationHardwareSettings; + /** Decision thresholds */ + thresholds: CalibrationThresholds; + /** Visualization preferences */ + visualization: CalibrationVisualization; + /** Workflow stage settings */ + workflow: CalibrationWorkflowSettings; + /** Available thermal presets */ + thermalPresets: ThermalPreset[]; + /** Currently active thermal preset name */ + activeThermalPreset: string | null; + /** Environment/temperature settings */ + environment: { + measurementTemp: number; + targetTemp: number; + thermalExpansionCoeff: number; + }; + /** History retention settings */ + history: { + /** Maximum entries to retain (default: 5) */ + maxEntries: number; + }; +} + +// ============================================================================ +// Per-Printer Calibration Data +// ============================================================================ + +/** + * Calibration history entry. + */ +export interface CalibrationHistoryEntry { + /** Timestamp of calibration */ + timestamp: number; + /** Type of calibration performed */ + type: 'bed_level' | 'input_shaper'; + /** Brief summary of results */ + summary: string; + /** Full calibration data */ + data: WorkflowData | AxisCalibration; +} + +/** + * Per-printer calibration data stored with printer context. + */ +export interface PrinterCalibrationData { + /** + * Remote printer.cfg path override (calibration-specific). SSH credentials + * (host/port/username/password/keyPath) live in the centralized + * SSHSettingsService keyed by serial number, not here. + */ + sshConfigPath?: string; + + /** Last bed mesh calibration */ + lastBedMesh?: { + timestamp: number; + matrix: number[][]; + analysis: AnalysisResult; + }; + + /** X-axis input shaper configuration */ + inputShaperX?: ShaperResult; + /** Y-axis input shaper configuration */ + inputShaperY?: ShaperResult; + + /** Calibration history */ + calibrationHistory?: CalibrationHistoryEntry[]; +} + +// ============================================================================ +// Workspace Types +// ============================================================================ + +/** + * Active calibration workspace for a printer context. + */ +export interface BedWorkspace { + /** Printer context ID */ + contextId: string; + /** Current mesh data */ + meshData: MeshData | null; + /** Bed configuration */ + bedConfig: BedConfig; + /** Latest analysis result */ + analysis: AnalysisResult | null; + /** Current workflow data */ + workflow: WorkflowData | null; + /** Whether workspace has unsaved changes */ + isDirty: boolean; +} + +// ============================================================================ +// Default Values +// ============================================================================ + +/** + * Default calibration settings. + */ +export const DEFAULT_CALIBRATION_SETTINGS: CalibrationSettings = { + hardware: { + tapeThickness: 0.05, + beltToothMm: 2.0, + screwPitch: 0.5, + minAdjustment: 0.01, + maxAdjustment: 2.0, + cornerAveraging: 2, + }, + thresholds: { + beltThreshold: 0.1, + screwThreshold: 0.02, + tapeThreshold: 0.02, + }, + visualization: { + interpolationFactor: 3, + colorScheme: 'viridis', + show3D: false, + }, + workflow: { + enableBelt: true, + enableScrews: true, + enableTape: true, + }, + thermalPresets: [ + { + name: 'PEI Spring Steel', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.8, + peiThickness: 0.3, + steelThickness: 0.5, + alphaPei: 50e-6, + alphaSteel: 12e-6, + betaUniform: 0.7, + }, + { + name: 'Glass Bed', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.6, + peiThickness: 0, + steelThickness: 0, + alphaPei: 0, + alphaSteel: 9e-6, + betaUniform: 0.5, + }, + { + name: 'Aluminum Bed', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.9, + peiThickness: 0, + steelThickness: 0, + alphaPei: 0, + alphaSteel: 23e-6, + betaUniform: 0.85, + }, + ], + activeThermalPreset: null, + environment: { + measurementTemp: 25, + targetTemp: 60, + thermalExpansionCoeff: 12e-6, + }, + history: { + maxEntries: 5, + }, +}; + +/** + * Default screw configuration. + */ +export const DEFAULT_SCREW_CONFIG: ScrewConfig = { + pitch: 0.5, + minAdjust: 0.01, + maxAdjust: 2.0, +}; + +/** + * Default tape configuration. + */ +export const DEFAULT_TAPE_CONFIG: TapeConfig = { + tapeThickness: 0.05, + minHeightDiff: 0.02, +}; + +/** + * Default bed configuration for FlashForge AD5M. + */ +export const DEFAULT_BED_CONFIG: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 7, + meshPointsY: 7, +}; diff --git a/src/types/file-manager.ts b/src/types/file-manager.ts new file mode 100644 index 0000000..543291a --- /dev/null +++ b/src/types/file-manager.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Shared type definitions for the SFTP-based printer file manager. + * + * Describes the IPC payloads exchanged between the file manager dialog renderer, + * its preload bridge, and the main-process FileManagerService. Covers storage + * targets (internal flash vs plugged-in USB drives), directory listings, file + * entries, capability probing, and mutation results for delete/rename flows. + * + * Key exports: + * - FileManagerStorageKind: 'internal' | 'usb' storage discriminator + * - PrinterFileEntry / FileManagerListing: listing payloads + * - FileManagerCapabilities: per-printer support + USB presence probe result + * - FileManagerDeleteResult / FileManagerRenameResult: mutation outcomes + */ + +/** Storage target on the printer. */ +export type FileManagerStorageKind = 'internal' | 'usb'; + +/** Printable file extensions surfaced by the file manager. */ +export const PRINTABLE_FILE_EXTENSIONS = ['.3mf', '.gcode', '.gx', '.g'] as const; + +/** A single file or directory entry in a listing. */ +export interface PrinterFileEntry { + /** Base name including extension (display name). */ + readonly name: string; + /** Absolute path on the printer filesystem. */ + readonly path: string; + /** File size in bytes (0 for directories). */ + readonly size: number; + /** Modification time as unix seconds (0 when unknown). */ + readonly modifiedAt: number; + /** Whether the entry is a directory (USB navigation only). */ + readonly isDirectory: boolean; +} + +/** Result of listing a storage location. */ +export interface FileManagerListing { + readonly success: boolean; + readonly storage: FileManagerStorageKind; + /** Absolute directory that was listed. Empty string for the synthetic USB mount root. */ + readonly path: string; + /** Root directory of the current storage (navigation must stay under this). */ + readonly rootPath: string; + readonly entries: readonly PrinterFileEntry[]; + readonly error?: string; +} + +/** Per-printer file manager capabilities and USB probe result. */ +export interface FileManagerCapabilities { + /** Whether the connected printer model supports SFTP file management. */ + readonly supported: boolean; + /** Human-readable reason when unsupported. */ + readonly reason?: string; + /** Printer display name (for the dialog title). */ + readonly printerName?: string; + /** Whether at least one USB drive is currently mounted. */ + readonly usbPresent: boolean; + /** Absolute mount points of detected USB drives. */ + readonly usbMounts: readonly string[]; + /** Error message when the SSH/SFTP probe itself failed. */ + readonly error?: string; +} + +/** Outcome for a single file within a batch delete. */ +export interface FileManagerDeleteOutcome { + readonly path: string; + readonly success: boolean; + readonly error?: string; +} + +/** Result of a (batch) delete operation. */ +export interface FileManagerDeleteResult { + readonly success: boolean; + readonly outcomes: readonly FileManagerDeleteOutcome[]; + readonly error?: string; +} + +/** Result of a rename operation. */ +export interface FileManagerRenameResult { + readonly success: boolean; + /** New absolute path after a successful rename. */ + readonly newPath?: string; + readonly error?: string; +} + +/** Result of a thumbnail fetch. */ +export interface FileManagerThumbnailResult { + readonly success: boolean; + /** Base64-encoded PNG (no data-URL prefix) when available. */ + readonly thumbnail?: string; + readonly error?: string; +} diff --git a/src/types/printer-power.ts b/src/types/printer-power.ts new file mode 100644 index 0000000..ac669b4 --- /dev/null +++ b/src/types/printer-power.ts @@ -0,0 +1,40 @@ +/** + * @fileoverview Shared IPC types for the remote "Reboot Printer" feature. + * + * Consumed by the main-process handler (`printer-power-handlers.ts`), the + * preload bridge, the renderer's reboot overlay controller, and the WebUI + * (REBOOT_STATUS WebSocket broadcasts + the printer-power route). + * + * Key exports: + * - RebootPhase: lifecycle phase pushed over the 'printer:reboot-status' channel. + * - RebootStatusPayload: the event payload shape. + * - RebootResult: the 'printer:reboot' invoke return value. + */ + +/** + * Lifecycle phase of an in-flight reboot, pushed to the renderer. + * + * Phases progress: rebooting -> reconnecting -> reconnecting-services -> + * success (or terminal timeout / failed). The 'reconnecting-services' phase + * fires once polling resumes and waits for a streak of stable polls so the TCP + * command socket and camera stream can fully recover before declaring success. + */ +export type RebootPhase = + | 'rebooting' + | 'reconnecting' + | 'reconnecting-services' + | 'success' + | 'timeout' + | 'failed'; + +/** Payload pushed over the 'printer:reboot-status' channel. */ +export interface RebootStatusPayload { + readonly phase: RebootPhase; + readonly message?: string; + readonly printerName?: string; +} + +/** Return value of the 'printer:reboot' invoke handler. */ +export interface RebootResult { + readonly success: boolean; +} diff --git a/src/types/ssh-settings.ts b/src/types/ssh-settings.ts new file mode 100644 index 0000000..52fed1b --- /dev/null +++ b/src/types/ssh-settings.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview Shared type definitions for centralized per-printer SSH settings. + * + * SSH credentials are stored once per printer (keyed by serial number) and + * consumed by every SSH-based feature: the file manager, the calibration + * assistant, and the WebUI calibration routes. Defaults match the FlashForge + * easy-SSH provisioning script (root / flashforge on port 22), so features + * work out of the box; advanced users can override them in Settings -> SSH. + * + * Key exports: + * - SSH_DEFAULTS: default credentials provisioned by flashforge-easyssh + * - SSHSettings / SSHSettingsUpdate: resolved settings + settings-tab payloads + */ + +/** Default SSH credentials provisioned by the flashforge-easyssh script. */ +export const SSH_DEFAULTS = { + username: 'root', + password: 'flashforge', + port: 22, +} as const; + +/** Resolved SSH settings for a printer (defaults applied). */ +export interface SSHSettings { + readonly username: string; + readonly password: string; + readonly port: number; + /** Optional path to an SSH private key file (overrides password auth). */ + readonly keyPath?: string; + /** Whether any value differs from the easy-SSH defaults. */ + readonly isCustom: boolean; +} + +/** Partial update from the settings UI. Empty strings mean "use default". */ +export interface SSHSettingsUpdate { + readonly username?: string; + readonly password?: string; + readonly port?: number; + readonly keyPath?: string; +} + +/** IPC response for 'ssh-settings:get' (null when no printer is connected). */ +export interface SSHSettingsResponse { + readonly settings: SSHSettings | null; + readonly printerName?: string; + readonly error?: string; +} diff --git a/src/utils/PrinterUtils.ts b/src/utils/PrinterUtils.ts index a9a13b3..2efcbaf 100644 --- a/src/utils/PrinterUtils.ts +++ b/src/utils/PrinterUtils.ts @@ -101,6 +101,28 @@ const HTTP_ONLY_MODEL_TYPES: ReadonlySet = new Set(['creator-5 export const isHttpOnlyModel = (modelType: PrinterModelType): boolean => HTTP_ONLY_MODEL_TYPES.has(modelType); +/** + * Model types that support the remote "Reboot Printer" feature. + * + * Reboot is delivered over the flashforge-easyssh root SSH/SFTP surface, which + * is only provisioned on the Adventurer 5M / 5M Pro / AD5X. Creator 5 / 5 Pro + * use a different (Klipper) SSH method that is not yet wired up, and legacy + * printers have no SSH surface at all. + */ +const REBOOT_SUPPORTED_MODEL_TYPES: ReadonlySet = new Set([ + 'adventurer-5m', + 'adventurer-5m-pro', + 'ad5x', +]); + +/** + * Whether a printer model supports the remote reboot command. Defense-in-depth: + * the WebUI hides the button for unsupported models, but the printer-power + * route re-checks this before dispatching the SSH command. + */ +export const isRebootSupportedModel = (modelType: PrinterModelType | undefined): boolean => + !!modelType && REBOOT_SUPPORTED_MODEL_TYPES.has(modelType); + /** * USB product IDs for new-API (HTTP + check-code) printers. The firmware-set * product ID is authoritative for model selection; typeName is the fallback. diff --git a/src/utils/SecureStorage.ts b/src/utils/SecureStorage.ts new file mode 100644 index 0000000..20a89c6 --- /dev/null +++ b/src/utils/SecureStorage.ts @@ -0,0 +1,43 @@ +/** + * @fileoverview Lightweight helpers for encoding/decoding secrets at rest. + * + * Standalone port of the FlashForgeUI SecureStorage helpers. The desktop app + * uses Electron's safeStorage (OS keychain) when available; this Node-only + * build has no OS keychain access, so secrets are stored base64-encoded with + * the same `plain:` prefix the desktop uses as its fallback. The formats are + * interchangeable: `enc:` values written by the desktop cannot be decrypted + * here and resolve to undefined (callers fall back to defaults). + */ + +const ENCRYPT_PREFIX = 'enc:'; +const PLAIN_PREFIX = 'plain:'; + +export function encryptSecret(value: string): string { + if (!value) { + return value; + } + return `${PLAIN_PREFIX}${Buffer.from(value, 'utf-8').toString('base64')}`; +} + +export function decryptSecret(value?: string): string | undefined { + if (!value) { + return value; + } + + if (value.startsWith(ENCRYPT_PREFIX)) { + // Written by the Electron desktop app via OS-level encryption; not + // recoverable without that keychain. Treat as unset. + return undefined; + } + + if (value.startsWith(PLAIN_PREFIX)) { + const payload = value.slice(PLAIN_PREFIX.length); + try { + return Buffer.from(payload, 'base64').toString('utf-8'); + } catch { + return undefined; + } + } + + return value; +} diff --git a/src/webui/server/WebSocketManager.ts b/src/webui/server/WebSocketManager.ts index ed4f1b5..c15bd20 100644 --- a/src/webui/server/WebSocketManager.ts +++ b/src/webui/server/WebSocketManager.ts @@ -27,6 +27,7 @@ import { getPrinterContextManager } from '../../managers/PrinterContextManager'; import type { SpoolmanChangedEvent } from '../../services/SpoolmanIntegrationService'; import { getSpoolmanIntegrationService } from '../../services/SpoolmanIntegrationService'; import type { PollingData } from '../../types/polling'; +import type { RebootStatusPayload } from '../../types/printer-power'; import { AppError, ErrorCode, toAppError } from '../../utils/error.utils'; import { createValidationError, WebSocketCommandSchema } from '../schemas/web-api.schemas'; import type { PrinterStatusData, WebSocketCommand, WebSocketMessage } from '../types/web-api.types'; @@ -623,6 +624,24 @@ export class WebSocketManager extends EventEmitter { this.broadcast(spoolmanMessage); } + /** + * Broadcast a printer reboot lifecycle update to all connected clients. + * Called by the PrinterRebootService as the reboot progresses so the WebUI + * reboot overlay can follow along. + */ + public broadcastRebootStatus(contextId: string, payload: RebootStatusPayload): void { + if (!this.isRunning || this.clients.size === 0) { + return; + } + + this.broadcast({ + type: 'REBOOT_STATUS', + timestamp: new Date().toISOString(), + contextId, + reboot: payload, + }); + } + /** * Send message to specific client */ diff --git a/src/webui/server/api-routes.ts b/src/webui/server/api-routes.ts index e55ca05..e862f99 100644 --- a/src/webui/server/api-routes.ts +++ b/src/webui/server/api-routes.ts @@ -12,17 +12,21 @@ import { getPrinterConnectionManager } from '../../managers/ConnectionFlowManage import { getPrinterBackendManager } from '../../managers/PrinterBackendManager'; import { getPrinterContextManager } from '../../managers/PrinterContextManager'; import { getSpoolmanIntegrationService } from '../../services/SpoolmanIntegrationService'; +import { registerCalibrationRoutes } from './routes/calibration-routes'; import { registerCameraRoutes } from './routes/camera-routes'; import { registerContextRoutes } from './routes/context-routes'; import { registerDiscoveryRoutes } from './routes/discovery-routes'; +import { registerFileManagerRoutes } from './routes/file-manager-routes'; import { registerFiltrationRoutes } from './routes/filtration-routes'; import { registerJobRoutes } from './routes/job-routes'; +import { registerPrinterPowerRoutes } from './routes/printer-power-routes'; import { registerPrinterControlRoutes } from './routes/printer-control-routes'; import { registerPrinterDetectionRoutes } from './routes/printer-detection-routes'; import { registerPrinterManagementRoutes } from './routes/printer-management-routes'; import { registerPrinterStatusRoutes } from './routes/printer-status-routes'; import type { RouteDependencies } from './routes/route-helpers'; import { registerSpoolmanRoutes } from './routes/spoolman-routes'; +import { registerSSHSettingsRoutes } from './routes/ssh-settings-routes'; import { registerTemperatureRoutes } from './routes/temperature-routes'; import { registerThemeRoutes } from './routes/theme-routes'; @@ -51,6 +55,10 @@ export function createAPIRoutes(deps: RouteDependencies = buildRouteDependencies registerDiscoveryRoutes(router, deps); registerPrinterDetectionRoutes(router, deps); registerPrinterManagementRoutes(router, deps); + registerCalibrationRoutes(router, deps); + registerFileManagerRoutes(router, deps); + registerSSHSettingsRoutes(router, deps); + registerPrinterPowerRoutes(router, deps); return router; } diff --git a/src/webui/server/routes/calibration-routes.ts b/src/webui/server/routes/calibration-routes.ts new file mode 100644 index 0000000..df3d5a5 --- /dev/null +++ b/src/webui/server/routes/calibration-routes.ts @@ -0,0 +1,577 @@ +/** + * @fileoverview Calibration API route registrations for the WebUI server. + * + * Provides headless access to calibration workflows, analysis, history, reports, + * and SSH helpers so the WebUI can mirror the desktop dialog functionality. + */ + +import type { CalibrationSettings, ShaperResult } from '../../../types/calibration'; +import type { Response, Router } from 'express'; +import { getCalibrationManager } from '../../../managers/CalibrationManager'; +import { getPrinterContextManager } from '../../../managers/PrinterContextManager'; +import { getSSHConnectionManager, SCPFileTransfer } from '../../../services/calibration/ssh/index'; +import { getSSHSettingsService } from '../../../services/SSHSettingsService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +/** + * Resolve a context id to the printer's serial number and IP for SSH purposes. + */ +function resolveSshTarget(contextId: string): { serialNumber: string; host: string } | null { + const contextManager = getPrinterContextManager(); + const context = contextManager.getContext(contextId); + if (!context) { + return null; + } + return { + serialNumber: context.printerDetails.SerialNumber, + host: context.printerDetails.IPAddress, + }; +} + +const REPORT_FORMATS = new Set(['json', 'csv', 'png', 'pdf'] as const); +type ReportFormat = 'json' | 'csv' | 'png' | 'pdf'; + +export function registerCalibrationRoutes(router: Router, deps: RouteDependencies): void { + const manager = getCalibrationManager(); + void manager.initialize(); + + const sshManager = getSSHConnectionManager(); + const scpTransfer = new SCPFileTransfer(sshManager); + + router.get('/calibration/settings', async (_req: AuthenticatedRequest, res: Response) => { + try { + const settings = manager.getSettings(); + return res.json({ success: true, settings }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.patch('/calibration/settings', async (req: AuthenticatedRequest, res: Response) => { + try { + const payload = (req.body ?? {}) as Partial; + await manager.updateSettings(payload); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/workspace', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const workspace = manager.getWorkspace(contextResult.contextId) ?? null; + return res.json({ success: true, workspace }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workspace/load', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { configContent, profileName } = req.body as { configContent?: string; profileName?: string }; + if (!configContent) { + return sendErrorResponse(res, 400, 'configContent is required'); + } + + const workspace = manager.loadMeshFromConfig(contextResult.contextId, configContent, profileName); + if (!workspace) { + return sendErrorResponse(res, 422, 'Unable to parse mesh data from config'); + } + + return res.json({ success: true, workspace }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/profiles', async (req: AuthenticatedRequest, res: Response) => { + try { + const { configContent } = req.body as { configContent?: string }; + if (!configContent) { + return sendErrorResponse(res, 400, 'configContent is required'); + } + + const profiles = manager.getAvailableProfiles(configContent); + return res.json({ success: true, profiles }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workspace/clear', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + manager.clearWorkspace(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/analysis', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const analysis = manager.analyzeMesh(contextResult.contextId); + if (!analysis) { + return sendErrorResponse(res, 404, 'No mesh data loaded'); + } + + return res.json({ success: true, analysis }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workflow', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const workflow = manager.computeWorkflow(contextResult.contextId); + if (!workflow) { + return sendErrorResponse(res, 404, 'No mesh data loaded'); + } + + const workspace = manager.getWorkspace(contextResult.contextId); + if (workspace?.meshData && workspace.analysis) { + await manager.saveLastBedMesh(contextResult.contextId, workspace.meshData.matrix, workspace.analysis); + } + + return res.json({ success: true, workflow }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const history = await manager.getHistory(contextResult.contextId); + return res.json({ success: true, history }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { type, summary, data } = req.body as { + type?: 'bed_level' | 'input_shaper'; + summary?: string; + data?: unknown; + }; + if (!type || !summary) { + return sendErrorResponse(res, 400, 'type and summary are required'); + } + + await manager.addHistoryEntry(contextResult.contextId, type, summary, data); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.delete('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + await manager.clearHistory(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/report', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const formatRaw = typeof req.query?.format === 'string' ? req.query.format : 'json'; + const format = REPORT_FORMATS.has(formatRaw as ReportFormat) ? (formatRaw as ReportFormat) : 'json'; + + const report = await manager.exportReport(contextResult.contextId, format); + return sendReportPayload(res, report, format); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/report', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const formatRaw = (req.body as { format?: string }).format ?? 'json'; + const format = REPORT_FORMATS.has(formatRaw as ReportFormat) ? (formatRaw as ReportFormat) : 'json'; + + const report = await manager.exportReport(contextResult.contextId, format); + return sendReportPayload(res, report, format); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/analyze', async (req: AuthenticatedRequest, res: Response) => { + try { + const { csvContent, axis } = req.body as { csvContent?: string; axis?: 'x' | 'y' }; + if (!csvContent || (axis !== 'x' && axis !== 'y')) { + return sendErrorResponse(res, 400, 'csvContent and axis are required'); + } + + const { ShaperAnalyzer } = await import('../../../services/calibration/shaper/index'); + const analyzer = new ShaperAnalyzer(); + const calibration = analyzer.analyzeAxis(csvContent, axis); + + return res.json({ success: true, calibration }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/shaper/definitions', async (_req: AuthenticatedRequest, res: Response) => { + try { + const { getAllShaperDefinitions } = await import('../../../services/calibration/shaper/index'); + const definitions = getAllShaperDefinitions(); + return res.json({ success: true, definitions }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const { axis, result } = req.body as { axis?: 'x' | 'y'; result?: ShaperResult }; + if (!axis || !result) { + return sendErrorResponse(res, 400, 'axis and result are required'); + } + + const { ShaperAnalyzer } = await import('../../../services/calibration/shaper/index'); + const analyzer = new ShaperAnalyzer(); + const lines = analyzer.generateKlipperConfig(axis, result); + return res.json({ success: true, lines }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/save', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { axis, result } = req.body as { axis?: 'x' | 'y'; result?: ShaperResult }; + if (!axis || !result) { + return sendErrorResponse(res, 400, 'axis and result are required'); + } + + await manager.saveShaperResult(contextResult.contextId, axis, result); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const data = await manager.getPrinterData(contextResult.contextId); + const target = resolveSshTarget(contextResult.contextId); + if (!target) { + return res.json({ success: true, config: { configPath: data.sshConfigPath } }); + } + + const ssh = await getSSHSettingsService().getSettings(target.serialNumber); + return res.json({ + success: true, + config: { + host: target.host, + port: ssh.port, + username: ssh.username, + keyPath: ssh.keyPath, + isCustom: ssh.isCustom, + configPath: data.sshConfigPath, + }, + }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const config = (req.body ?? {}) as { + port?: number; + username?: string; + password?: string; + keyPath?: string; + configPath?: string; + }; + + const target = resolveSshTarget(contextResult.contextId); + if ( + target && + (config.username !== undefined || + config.password !== undefined || + config.port !== undefined || + config.keyPath !== undefined) + ) { + await getSSHSettingsService().updateSettings(target.serialNumber, { + username: config.username, + password: config.password, + port: config.port, + keyPath: config.keyPath, + }); + } + + if (config.configPath !== undefined) { + const data = await manager.getPrinterData(contextResult.contextId); + await manager.savePrinterData(contextResult.contextId, { + ...data, + sshConfigPath: config.configPath || undefined, + }); + } + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.delete('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const target = resolveSshTarget(contextResult.contextId); + if (target) { + await getSSHSettingsService().resetSettings(target.serialNumber); + } + + const data = await manager.getPrinterData(contextResult.contextId); + const updated = { + ...data, + sshConfigPath: undefined, + }; + await manager.savePrinterData(contextResult.contextId, updated); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/connect', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const target = resolveSshTarget(contextResult.contextId); + if (!target || !target.host) { + return sendErrorResponse(res, 409, 'No connected printer to resolve SSH settings for'); + } + + const config = await getSSHSettingsService().buildConnectionConfig(target.serialNumber, target.host); + await sshManager.connect(contextResult.contextId, config); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/disconnect', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + await sshManager.disconnect(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/ssh/status', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const status = sshManager.getStatus(contextResult.contextId); + return res.json({ success: true, status }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/execute', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { command } = req.body as { command?: string }; + if (!command) { + return sendErrorResponse(res, 400, 'command is required'); + } + + const result = await sshManager.executeCommand(contextResult.contextId, command); + return res.json({ success: true, result }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/fetch-config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { remotePath } = req.body as { remotePath?: string }; + const content = await scpTransfer.fetchPrinterConfig(contextResult.contextId, remotePath); + return res.json({ success: true, content }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/fetch-shaper', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { axis } = req.body as { axis?: 'x' | 'y' }; + if (!axis) { + return sendErrorResponse(res, 400, 'axis is required'); + } + + const content = await scpTransfer.fetchShaperCSV(contextResult.contextId, axis); + return res.json({ success: true, content }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/upload-config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { content, remotePath } = req.body as { content?: string; remotePath?: string }; + if (!content) { + return sendErrorResponse(res, 400, 'content is required'); + } + + const result = await scpTransfer.uploadConfig(contextResult.contextId, content, remotePath); + return res.json({ success: result.success, result }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} + +function sendReportPayload(res: Response, payload: string | Buffer, format: ReportFormat): Response { + if (format === 'png') { + res.type('image/png'); + return res.send(payload); + } + if (format === 'pdf') { + res.type('application/pdf'); + return res.send(payload); + } + + if (typeof payload === 'string') { + res.type(format === 'csv' ? 'text/csv' : 'application/json'); + return res.send(payload); + } + + return res.send(payload); +} diff --git a/src/webui/server/routes/file-manager-routes.ts b/src/webui/server/routes/file-manager-routes.ts new file mode 100644 index 0000000..24f0bb5 --- /dev/null +++ b/src/webui/server/routes/file-manager-routes.ts @@ -0,0 +1,187 @@ +/** + * @fileoverview SFTP file manager API routes for the WebUI server. + * + * Mirrors the desktop file manager IPC surface (file-manager-handlers.ts) over + * authenticated REST so browser clients can browse, delete, and rename printer + * files via the main-process FileManagerService. Unlike the desktop dialog + * (which pins the context at window-open time in the main process), REST + * clients pin the context themselves: the WebUI dialog captures the contextId + * when it opens and passes it explicitly on every request, so switching the + * active context mid-session never retargets operations. + * + * Routes: + * - GET /file-manager/support: cheap model-only support check (no SSH probe) + * - GET /file-manager/capabilities: model support + USB probe + * - GET /file-manager/files?storage=&path=: list a storage location + * - POST /file-manager/delete { storage, paths }: batch delete + * - POST /file-manager/rename { storage, path, newName }: rename in place + * - GET /file-manager/thumbnail?storage=&path=: cached/remote thumbnail fetch + */ + +import type { Response, Router } from 'express'; +import type { FileManagerStorageKind } from '../../../types/file-manager'; +import { + FileManagerService, + type FileManagerTarget, + getFileManagerService, +} from '../../../services/FileManagerService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { + type ResolvedContext, + type RouteDependencies, + resolveContext, + sendErrorResponse, +} from './route-helpers'; + +/** Convert a resolved printer context into a FileManagerService target. */ +function toFileManagerTarget(resolution: ResolvedContext): FileManagerTarget { + const details = resolution.context.printerDetails; + return { + contextId: resolution.contextId, + ipAddress: details.IPAddress, + serialNumber: details.SerialNumber, + printerName: details.Name, + modelType: details.modelType, + }; +} + +/** Validate the storage discriminator from query/body input. */ +function parseStorage(value: unknown): FileManagerStorageKind | null { + return value === 'internal' || value === 'usb' ? value : null; +} + +/** Read a single string value from an Express query parameter. */ +function readString(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0]; + } + return undefined; +} + +export function registerFileManagerRoutes(router: Router, deps: RouteDependencies): void { + const service = getFileManagerService(); + + // Model-only support probe: cheap enough to gate topbar buttons on every + // context switch (the full capabilities call opens an SSH session). + router.get('/file-manager/support', (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + const details = resolution.context.printerDetails; + return res.json({ + success: true, + supported: FileManagerService.isModelSupported(details.modelType), + contextId: resolution.contextId, + printerName: details.Name, + }); + }); + + router.get('/file-manager/capabilities', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse<{ success: boolean; error?: string; supported: boolean; usbPresent: boolean; usbMounts: string[] }>(res, resolution.statusCode, resolution.error, { + supported: false, + usbPresent: false, + usbMounts: [], + }); + } + + try { + const capabilities = await service.getCapabilities(toFileManagerTarget(resolution)); + return res.json({ success: true, contextId: resolution.contextId, ...capabilities }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/file-manager/files', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const storage = parseStorage(readString(req.query?.storage)); + if (!storage) { + return sendErrorResponse(res, 400, 'Invalid storage kind'); + } + const path = readString(req.query?.path) ?? ''; + + try { + const listing = await service.listFiles(toFileManagerTarget(resolution), storage, path); + return res.json(listing); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/file-manager/delete', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const body = (req.body ?? {}) as Record; + const storage = parseStorage(body.storage); + const paths = body.paths; + if (!storage || !Array.isArray(paths) || paths.some((path) => typeof path !== 'string')) { + return sendErrorResponse(res, 400, 'Invalid delete request'); + } + + try { + const result = await service.deleteFiles(toFileManagerTarget(resolution), storage, paths as string[]); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/file-manager/rename', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const body = (req.body ?? {}) as Record; + const storage = parseStorage(body.storage); + if (!storage || typeof body.path !== 'string' || typeof body.newName !== 'string') { + return sendErrorResponse(res, 400, 'Invalid rename request'); + } + + try { + const result = await service.renameFile(toFileManagerTarget(resolution), storage, body.path, body.newName); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/file-manager/thumbnail', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const storage = parseStorage(readString(req.query?.storage)); + const path = readString(req.query?.path); + if (!storage || !path) { + return sendErrorResponse(res, 400, 'Invalid thumbnail request'); + } + + try { + const result = await service.getThumbnail(toFileManagerTarget(resolution), storage, path); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/server/routes/printer-power-routes.ts b/src/webui/server/routes/printer-power-routes.ts new file mode 100644 index 0000000..688976c --- /dev/null +++ b/src/webui/server/routes/printer-power-routes.ts @@ -0,0 +1,52 @@ +/** + * @fileoverview Printer power API routes for the WebUI server. + * + * Exposes the remote "Reboot Printer" feature to WebUI clients. The route + * delegates to the shared startPrinterReboot() core (same code path as the + * desktop IPC handler), so validation, the SSH dispatch, and the reconnect + * monitor behave identically on both surfaces. Reboot lifecycle updates reach + * the browser over the REBOOT_STATUS WebSocket broadcast; this route only + * acknowledges that the reboot was dispatched. + * + * Routes: + * - POST /printer/reboot: dispatch a reboot for the resolved context + * - GET /printer/reboot/support: whether the resolved context supports reboot + */ + +import type { Response, Router } from 'express'; +import { startPrinterReboot } from '../../../services/PrinterRebootService'; +import { toAppError } from '../../../utils/error.utils'; +import { isRebootSupportedModel } from '../../../utils/PrinterUtils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +export function registerPrinterPowerRoutes(router: Router, deps: RouteDependencies): void { + router.get('/printer/reboot/support', (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + const modelType = resolution.context.printerDetails.modelType; + return res.json({ + success: true, + supported: isRebootSupportedModel(modelType), + contextId: resolution.contextId, + printerName: resolution.context.printerDetails.Name, + }); + }); + + router.post('/printer/reboot', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + await startPrinterReboot(resolution.contextId); + return res.json({ success: true, contextId: resolution.contextId }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/server/routes/ssh-settings-routes.ts b/src/webui/server/routes/ssh-settings-routes.ts new file mode 100644 index 0000000..64565a0 --- /dev/null +++ b/src/webui/server/routes/ssh-settings-routes.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Centralized per-printer SSH settings API routes for the WebUI server. + * + * Mirrors the desktop Settings -> SSH tab over authenticated REST so browser + * clients can view and edit the credentials consumed by every SSH feature + * (file manager, calibration assistant, reboot). Credentials are stored per + * printer serial via SSHSettingsService. + * + * SECURITY: the stored password is WRITE-ONLY over this surface. GET responses + * never include the password — only whether a custom (non easy-SSH default) + * password is set. Submitting a new password overwrites it; submitting an + * empty string resets it to the easy-SSH default; omitting the field keeps + * the current value. + * + * Routes: + * - GET /ssh-settings: resolved settings (password redacted) for the context + * - POST /ssh-settings: apply a partial update + * - POST /ssh-settings/reset: restore the easy-SSH defaults + */ + +import type { Response, Router } from 'express'; +import { SSH_DEFAULTS, type SSHSettingsUpdate } from '../../../types/ssh-settings'; +import { getSSHSettingsService } from '../../../services/SSHSettingsService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +/** SSH settings shape returned to WebUI clients (password redacted). */ +export interface WebUISSHSettings { + readonly username: string; + readonly port: number; + readonly keyPath?: string; + readonly isCustom: boolean; + /** True when a non-default password is stored (the value itself is never sent). */ + readonly passwordIsCustom: boolean; +} + +/** Coerce and validate the incoming update payload; null when malformed. */ +function parseUpdatePayload(body: unknown): SSHSettingsUpdate | null { + if (!body || typeof body !== 'object') { + return null; + } + const raw = body as Record; + const update: { + username?: string; + password?: string; + port?: number; + keyPath?: string; + } = {}; + + if (raw.username !== undefined) { + if (typeof raw.username !== 'string') return null; + update.username = raw.username; + } + if (raw.password !== undefined) { + if (typeof raw.password !== 'string') return null; + update.password = raw.password; + } + if (raw.port !== undefined) { + const port = typeof raw.port === 'number' ? raw.port : Number(raw.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) return null; + update.port = port; + } + if (raw.keyPath !== undefined) { + if (typeof raw.keyPath !== 'string') return null; + update.keyPath = raw.keyPath; + } + return update; +} + +export function registerSSHSettingsRoutes(router: Router, deps: RouteDependencies): void { + router.get('/ssh-settings', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + const details = resolution.context.printerDetails; + const settings = await getSSHSettingsService().getSettings(details.SerialNumber); + const redacted: WebUISSHSettings = { + username: settings.username, + port: settings.port, + keyPath: settings.keyPath, + isCustom: settings.isCustom, + passwordIsCustom: settings.password !== SSH_DEFAULTS.password, + }; + return res.json({ + success: true, + settings: redacted, + printerName: details.Name, + contextId: resolution.contextId, + }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/ssh-settings', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const update = parseUpdatePayload(req.body); + if (!update) { + return sendErrorResponse(res, 400, 'Invalid SSH settings payload'); + } + + try { + await getSSHSettingsService().updateSettings(resolution.context.printerDetails.SerialNumber, update); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/ssh-settings/reset', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + await getSSHSettingsService().resetSettings(resolution.context.printerDetails.SerialNumber); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/static/app.ts b/src/webui/static/app.ts index 5b41208..fb4b05d 100644 --- a/src/webui/static/app.ts +++ b/src/webui/static/app.ts @@ -62,7 +62,11 @@ import { confirmMaterialMatching, setupMaterialMatchingHandlers, } from './features/material-matching.js'; +import { refreshCalibrationButton, setupCalibration } from './features/calibration.js'; +import { refreshFileManagerButton, setupFileManager } from './features/file-manager.js'; import { refreshIfsStationCard, setupIfsStationCard } from './features/ifs-station.js'; +import { refreshRebootButton, setupReboot } from './features/reboot.js'; +import { refreshSSHSettings, setupSSHSettings } from './features/ssh.js'; import { initializeDiscovery } from './features/printer-discovery.js'; import { loadSpoolmanConfig, setupSpoolmanHandlers } from './features/spoolman.js'; import { $, hideElement, showElement } from './shared/dom.js'; @@ -92,7 +96,7 @@ export interface AuthStatusResponse { } export interface WebSocketMessage { - type: 'AUTH_SUCCESS' | 'STATUS_UPDATE' | 'ERROR' | 'COMMAND_RESULT' | 'PONG' | 'SPOOLMAN_UPDATE'; + type: 'AUTH_SUCCESS' | 'STATUS_UPDATE' | 'ERROR' | 'COMMAND_RESULT' | 'PONG' | 'SPOOLMAN_UPDATE' | 'REBOOT_STATUS'; timestamp: string; status?: PrinterStatus; error?: string; @@ -101,6 +105,23 @@ export interface WebSocketMessage { success?: boolean; contextId?: string; spool?: ActiveSpoolData | null; + reboot?: RebootStatusPayload; +} + +/** Lifecycle phase of an in-flight printer reboot (see printer-power routes). */ +export type RebootPhase = + | 'rebooting' + | 'reconnecting' + | 'reconnecting-services' + | 'success' + | 'timeout' + | 'failed'; + +/** Payload carried by REBOOT_STATUS WebSocket broadcasts. */ +export interface RebootStatusPayload { + readonly phase: RebootPhase; + readonly message?: string; + readonly printerName?: string; } export interface WebSocketCommand { @@ -405,13 +426,27 @@ async function initialize(): Promise { setupMaterialMatchingHandlers(); setupSpoolmanHandlers(); setupIfsStationCard(); + setupFileManager(); + setupCalibration(); + setupReboot(); + setupSSHSettings(); initializeDiscovery(); + // The SSH settings section reflects the ACTIVE printer; reload it whenever + // the settings modal is opened. + $('settings-button')?.addEventListener('click', () => { + void refreshSSHSettings(); + }); + const contextHandlers = { onContextSwitched: async () => { await loadPrinterFeatures(); await loadSpoolmanConfig(); initializeCamera(); + void refreshFileManagerButton(); + void refreshCalibrationButton(); + void refreshRebootButton(); + void refreshSSHSettings(); }, }; @@ -465,6 +500,9 @@ async function handlePostLoginTasks(): Promise { await loadSpoolmanConfig(); initializeCamera(); + void refreshFileManagerButton(); + void refreshCalibrationButton(); + void refreshRebootButton(); } catch (error) { console.error('Failed to load features:', error); } diff --git a/src/webui/static/core/Transport.ts b/src/webui/static/core/Transport.ts index e19a8b0..5a50426 100644 --- a/src/webui/static/core/Transport.ts +++ b/src/webui/static/core/Transport.ts @@ -6,7 +6,13 @@ * spoolman updates. Keeps transport concerns isolated from UI orchestration. */ -import type { ActiveSpoolData, PrinterStatus, WebSocketCommand, WebSocketMessage } from '../app.js'; +import type { + ActiveSpoolData, + PrinterStatus, + RebootStatusPayload, + WebSocketCommand, + WebSocketMessage, +} from '../app.js'; import { showToast } from '../shared/dom.js'; import { state } from './AppState.js'; @@ -94,10 +100,12 @@ export async function apiRequestWithMetadata( type StatusUpdateCallback = (status: PrinterStatus) => void; type SpoolmanUpdateCallback = (contextId: string, spool: ActiveSpoolData | null) => void; type ConnectionChangeCallback = (connected: boolean) => void; +type RebootStatusCallback = (contextId: string, payload: RebootStatusPayload) => void; const statusUpdateCallbacks: StatusUpdateCallback[] = []; const spoolmanUpdateCallbacks: SpoolmanUpdateCallback[] = []; const connectionCallbacks: ConnectionChangeCallback[] = []; +const rebootStatusCallbacks: RebootStatusCallback[] = []; export function onStatusUpdate(callback: StatusUpdateCallback): void { statusUpdateCallbacks.push(callback); @@ -111,6 +119,10 @@ export function onConnectionChange(callback: ConnectionChangeCallback): void { connectionCallbacks.push(callback); } +export function onRebootStatus(callback: RebootStatusCallback): void { + rebootStatusCallbacks.push(callback); +} + function notifyConnectionChange(connected: boolean): void { connectionCallbacks.forEach((callback) => { callback(connected); @@ -223,5 +235,15 @@ function handleWebSocketMessage(message: WebSocketMessage): void { }); } break; + + case 'REBOOT_STATUS': + if (message.contextId && message.reboot) { + const contextId = message.contextId; + const payload = message.reboot; + rebootStatusCallbacks.forEach((callback) => { + callback(contextId, payload); + }); + } + break; } } diff --git a/src/webui/static/features/calibration.ts b/src/webui/static/features/calibration.ts new file mode 100644 index 0000000..fb7ecfa --- /dev/null +++ b/src/webui/static/features/calibration.ts @@ -0,0 +1,1200 @@ +/** + * @fileoverview Calibration Assistant feature for the WebUI client. + * + * Browser port of the desktop calibration dialog: a topbar button (shown only + * for SSH-capable models: Adventurer 5M / 5M Pro / AD5X) opens a full overlay + * with the same four tabs — Bed Leveling (mesh heatmap + workflow analysis + + * animated screw/tape recommendations), Input Shaper (PSD plot + shaper + * comparison + Klipper config output), SSH connection controls, and History. + * Talks to the authenticated /api/calibration/* REST surface. + * + * Desktop-only affordances are replaced with browser equivalents: native file + * dialogs become pickers, save dialogs become Blob + * downloads, and report export streams the raw payload from + * GET /api/calibration/report with auth headers. + * + * Context pinning: the active context id is captured when the modal opens and + * sent with every request, so switching the printer selector mid-session never + * retargets calibration operations. + * + * Key exports: + * - setupCalibration(): wire the topbar button + modal event handlers + * - refreshCalibrationButton(): show/hide the topbar button per model support + */ + +import { contextById } from '../core/AppState.js'; +import { apiRequest, buildAuthHeaders } from '../core/Transport.js'; +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { AnimatedRecommendationVisualizer } from './calibration/AnimatedRecommendationVisualizer.js'; +import { BedMeshVisualizer } from './calibration/BedMeshVisualizer.js'; +import { ShaperPlotVisualizer } from './calibration/ShaperPlotVisualizer.js'; +import type { + AnalysisResult, + AxisCalibration, + CalibrationHistoryEntry, + CalibrationWorkspace, + MeshData, + ScrewAdjustment, + ShaperResult, + TapeRecommendation, + WorkflowData, +} from './calibration/types.js'; +import { getCurrentContextId } from './context-switching.js'; + +// --------------------------------------------------------------------------- +// API response shapes +// --------------------------------------------------------------------------- + +interface SupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly error?: string; +} + +interface WorkspaceResponse { + readonly success: boolean; + readonly workspace?: CalibrationWorkspace | null; + readonly error?: string; +} + +interface ProfilesResponse { + readonly success: boolean; + readonly profiles?: string[]; + readonly error?: string; +} + +interface WorkflowResponse { + readonly success: boolean; + readonly workflow?: WorkflowData; + readonly error?: string; +} + +interface HistoryResponse { + readonly success: boolean; + readonly history?: CalibrationHistoryEntry[]; + readonly error?: string; +} + +interface ShaperAnalyzeResponse { + readonly success: boolean; + readonly calibration?: AxisCalibration; + readonly error?: string; +} + +interface ShaperConfigResponse { + readonly success: boolean; + readonly lines?: string[]; + readonly error?: string; +} + +interface SSHConfigResponse { + readonly success: boolean; + readonly config?: { + readonly host?: string; + readonly port?: number; + readonly username?: string; + readonly keyPath?: string; + readonly isCustom?: boolean; + readonly configPath?: string; + }; + readonly error?: string; +} + +interface SSHStatusResponse { + readonly success: boolean; + readonly status?: unknown; + readonly error?: string; +} + +interface SSHExecuteResponse { + readonly success: boolean; + readonly result?: { + readonly success?: boolean; + readonly stdout?: string; + readonly stderr?: string; + readonly error?: string; + }; + readonly error?: string; +} + +interface SSHContentResponse { + readonly success: boolean; + readonly content?: string; + readonly error?: string; +} + +interface UploadConfigResponse { + readonly success: boolean; + readonly result?: { readonly success?: boolean; readonly error?: string }; + readonly error?: string; +} + +interface BasicResponse { + readonly success: boolean; + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// Constants + state +// --------------------------------------------------------------------------- + +/** Lucide icons used inside the calibration modal markup. */ +const CAL_ICONS = [ + 'grid-3x3', + 'activity', + 'terminal', + 'history', + 'file-up', + 'download', + 'circle', + 'play', + 'sparkles', + 'copy', + 'upload', + 'plug', + 'unplug', + 'check-circle', + 'trash-2', + 'x', +]; + +interface CalibrationState { + /** Context pinned when the modal opened; null when the modal is closed. */ + pinnedContextId: string | null; + meshData: MeshData | null; + analysisResult: AnalysisResult | null; + workflowData: WorkflowData | null; + configContent: string | null; + sshConnected: boolean; + activeTab: string; + shaperResults: { + x: AxisCalibration | null; + y: AxisCalibration | null; + activeAxis: 'x' | 'y'; + }; +} + +const calState: CalibrationState = { + pinnedContextId: null, + meshData: null, + analysisResult: null, + workflowData: null, + configContent: null, + sshConnected: false, + activeTab: 'bed', + shaperResults: { x: null, y: null, activeAxis: 'x' }, +}; + +let meshVisualizer: BedMeshVisualizer | null = null; +let shaperVisualizer: ShaperPlotVisualizer | null = null; +let recommendationVisualizer: AnimatedRecommendationVisualizer | null = null; +let iconsHydrated = false; + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[Calibration] Missing element #${id}`); + } + return element; +} + +function inputEl(id: string): HTMLInputElement { + return el(id) as HTMLInputElement; +} + +function buttonEl(id: string): HTMLButtonElement { + return el(id) as HTMLButtonElement; +} + +function selectEl(id: string): HTMLSelectElement { + return el(id) as HTMLSelectElement; +} + +function setStatus(message: string): void { + el('cal-status-message').textContent = message; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +/** Append the pinned context id to a query-string API path. */ +function calQuery(path: string, params: Record = {}): string { + const query = new URLSearchParams(params); + if (calState.pinnedContextId) { + query.set('contextId', calState.pinnedContextId); + } + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; +} + +/** Build a JSON body payload that carries the pinned context id. */ +function calBody(payload: Record = {}): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + calState.pinnedContextId ? { ...payload, contextId: calState.pinnedContextId } : payload + ), + }; +} + +/** Open a browser file picker and read the chosen file as text. */ +function pickTextFile(accept: string): Promise<{ name: string; content: string } | null> { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + file + .text() + .then((content) => resolve({ name: file.name, content })) + .catch(() => resolve(null)); + }); + input.click(); + }); +} + +/** Trigger a browser download for the given content. */ +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +// --------------------------------------------------------------------------- +// Topbar button gating +// --------------------------------------------------------------------------- + +/** + * Show or hide the topbar calibration button. Calibration shares the SSH model + * set with the file manager (5M / 5M Pro / AD5X), so it reuses the cheap + * model-only support endpoint. + */ +export async function refreshCalibrationButton(): Promise { + const button = document.getElementById('calibration-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/file-manager/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +// --------------------------------------------------------------------------- +// Modal lifecycle +// --------------------------------------------------------------------------- + +async function openCalibration(): Promise { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + calState.pinnedContextId = contextId; + calState.meshData = null; + calState.analysisResult = null; + calState.workflowData = null; + calState.configContent = null; + calState.sshConnected = false; + calState.shaperResults.x = null; + calState.shaperResults.y = null; + + const context = contextById.get(contextId); + el('cal-context-indicator').textContent = context ? `Printer: ${context.name}` : 'Printer: (active context)'; + + el('calibration-modal').classList.remove('hidden'); + if (!iconsHydrated) { + hydrateLucideIcons(CAL_ICONS, el('calibration-modal')); + iconsHydrated = true; + } + + if (!meshVisualizer) { + meshVisualizer = new BedMeshVisualizer(el('cal-mesh-canvas') as HTMLCanvasElement, { + width: 400, + height: 400, + colorScheme: 'viridis', + showGrid: true, + showLabels: true, + showCorners: true, + interpolationFactor: 1, + }); + meshVisualizer.setEventHandlers({ + onCellHover: (cell) => { + setStatus(cell ? `Cell [${cell.row}, ${cell.col}]: ${cell.value.toFixed(4)} mm` : ''); + }, + }); + } + if (!shaperVisualizer) { + shaperVisualizer = new ShaperPlotVisualizer(el('cal-shaper-canvas') as HTMLCanvasElement, { + width: 600, + height: 300, + backgroundColor: '#141414', + }); + } + if (!recommendationVisualizer) { + recommendationVisualizer = new AnimatedRecommendationVisualizer(el('cal-rec-canvas') as HTMLCanvasElement, { + width: 620, + height: 420, + }); + } + + switchTab('bed'); + setStatus(''); + updateMeshDisplay(); + updateAnalysisDisplay(); + updateShaperDisplay(null); + + await loadWorkspace(); + await loadSSHInfo(); + await updateSSHStatus(); + + // Auto-connect SSH so the Fetch-via-SSH workflows are immediately usable + // (mirrors the desktop dialog and file manager). + if (!calState.sshConnected) { + void handleSSHConnect(true); + } +} + +function closeCalibration(): void { + hideVisualRecommendations(); + el('calibration-modal').classList.add('hidden'); + calState.pinnedContextId = null; +} + +// --------------------------------------------------------------------------- +// Tab navigation +// --------------------------------------------------------------------------- + +function switchTab(tabId: string): void { + calState.activeTab = tabId; + + document.querySelectorAll('.cal-tab-button').forEach((button) => { + const isActive = button.dataset.tab === tabId; + button.setAttribute('aria-selected', String(isActive)); + button.tabIndex = isActive ? 0 : -1; + }); + + document.querySelectorAll('.cal-tab-panel').forEach((panel) => { + panel.hidden = panel.id !== `cal-panel-${tabId}`; + }); + + if (tabId === 'history' && calState.pinnedContextId) { + void loadHistory(); + } +} + +// --------------------------------------------------------------------------- +// Workspace + mesh display +// --------------------------------------------------------------------------- + +async function loadWorkspace(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/workspace')); + if (result.success && result.workspace?.meshData) { + calState.meshData = result.workspace.meshData; + calState.analysisResult = result.workspace.analysis; + calState.workflowData = result.workspace.workflow; + updateMeshDisplay(); + updateAnalysisDisplay(); + } + } catch (error) { + console.error('[Calibration] Failed to load workspace:', error); + } +} + +function updateMeshDisplay(): void { + meshVisualizer?.setMeshData(calState.meshData, calState.analysisResult); + + if (calState.meshData) { + const values = calState.meshData.matrix.flat(); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const stdDev = Math.sqrt(values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length); + + el('cal-stat-range').textContent = `${(max - min).toFixed(4)} mm`; + el('cal-stat-max-dev').textContent = `${Math.max(Math.abs(min), Math.abs(max)).toFixed(4)} mm`; + el('cal-stat-std-dev').textContent = `${stdDev.toFixed(4)} mm`; + buttonEl('cal-btn-run-workflow').disabled = false; + } else { + el('cal-stat-range').textContent = '--'; + el('cal-stat-max-dev').textContent = '--'; + el('cal-stat-std-dev').textContent = '--'; + buttonEl('cal-btn-run-workflow').disabled = true; + } +} + +function formatCornerName(corner: string): string { + const names: Record = { + frontLeft: 'Front Left', + frontRight: 'Front Right', + rearLeft: 'Rear Left', + rearRight: 'Rear Right', + }; + return names[corner] || corner; +} + +function updateAnalysisDisplay(): void { + const resultsSection = el('cal-results-section'); + const visualButton = buttonEl('cal-btn-visual-rec'); + + if (!calState.workflowData) { + resultsSection.hidden = true; + visualButton.disabled = true; + updateWorkflowStages(); + return; + } + + resultsSection.hidden = false; + visualButton.disabled = false; + + const screwAdjustments = (calState.workflowData.screwAdjustments || []).filter( + (adj: ScrewAdjustment) => adj.requiresAdjustment + ); + el('cal-screw-list').innerHTML = + screwAdjustments.length > 0 + ? screwAdjustments + .map( + (adj: ScrewAdjustment) => ` +
+ ${formatCornerName(adj.corner)} + ${adj.formattedAmount} +
` + ) + .join('') + : '
No adjustments needed
'; + + const tapeRecs = calState.workflowData.tapeRecommendations || []; + el('cal-tape-list').innerHTML = + tapeRecs.length > 0 + ? tapeRecs + .map( + (rec: TapeRecommendation) => ` +
+ ${formatCornerName(rec.corner)} + ${rec.layers} layer${rec.layers !== 1 ? 's' : ''} +
` + ) + .join('') + : '
No tape needed
'; + + el('cal-improvement-value').textContent = `${calState.workflowData.improvementPercent.toFixed(1)}%`; + updateWorkflowStages(); +} + +function updateWorkflowStages(): void { + el('cal-workflow-stages') + .querySelectorAll('.cal-workflow-stage') + .forEach((stageEl) => { + const stage = stageEl.dataset.stage; + stageEl.classList.remove('active', 'completed'); + if (stage && calState.workflowData?.completedStages?.includes(stage as never)) { + stageEl.classList.add('completed'); + } + if (stage && calState.workflowData?.currentStage === stage) { + stageEl.classList.add('active'); + } + }); +} + +// --------------------------------------------------------------------------- +// Config file handling (bed leveling) +// --------------------------------------------------------------------------- + +async function handleLoadConfigFile(): Promise { + const picked = await pickTextFile('.cfg,.conf,.txt,text/plain'); + if (!picked) { + setStatus('File selection cancelled'); + return; + } + + calState.configContent = picked.content; + setStatus(`Loaded: ${picked.name}`); + await loadProfilesAndFirstMesh(picked.content); +} + +async function handleFetchConfigSSH(): Promise { + try { + setStatus('Fetching config via SSH...'); + const overridePath = inputEl('cal-ssh-config-path').value.trim() || undefined; + const result = await apiRequest( + '/api/calibration/ssh/fetch-config', + calBody(overridePath ? { remotePath: overridePath } : {}) + ); + if (!result.success || typeof result.content !== 'string') { + setStatus(`SSH Error: ${result.error || 'Failed to fetch config'}`); + return; + } + calState.configContent = result.content; + await loadProfilesAndFirstMesh(result.content); + setStatus('Config fetched successfully'); + } catch (error) { + setStatus(`SSH Error: ${errorText(error)}`); + } +} + +async function loadProfilesAndFirstMesh(configContent: string): Promise { + try { + const result = await apiRequest('/api/calibration/profiles', calBody({ configContent })); + const profiles = result.success && result.profiles ? result.profiles : []; + populateProfileSelect(profiles); + if (profiles.length > 0) { + await loadMeshProfile(profiles[0]); + } else { + setStatus('No mesh profiles found in config'); + } + } catch (error) { + setStatus(`Error: ${errorText(error)}`); + } +} + +function populateProfileSelect(profiles: string[]): void { + const select = selectEl('cal-profile-select'); + select.innerHTML = ''; + profiles.forEach((profile) => { + const option = document.createElement('option'); + option.value = profile; + option.textContent = profile; + select.appendChild(option); + }); + select.disabled = profiles.length === 0; +} + +async function loadMeshProfile(profileName: string): Promise { + if (!calState.configContent) return; + + try { + const result = await apiRequest( + '/api/calibration/workspace/load', + calBody({ configContent: calState.configContent, profileName }) + ); + if (result.success && result.workspace?.meshData) { + calState.meshData = result.workspace.meshData; + calState.analysisResult = result.workspace.analysis; + updateMeshDisplay(); + setStatus(`Loaded profile: ${profileName}`); + } else { + setStatus(`Error loading profile: ${result.error || 'Unable to parse mesh data'}`); + } + } catch (error) { + setStatus(`Error loading profile: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Workflow +// --------------------------------------------------------------------------- + +async function handleRunWorkflow(): Promise { + const runButton = buttonEl('cal-btn-run-workflow'); + try { + setStatus('Running calibration workflow...'); + runButton.disabled = true; + + const result = await apiRequest('/api/calibration/workflow', calBody()); + if (result.success && result.workflow) { + calState.workflowData = result.workflow; + updateAnalysisDisplay(); + setStatus('Workflow complete'); + + await apiRequest( + '/api/calibration/history', + calBody({ + type: 'bed_level', + summary: `Range: ${result.workflow.initialRange.toFixed(3)}mm -> ${result.workflow.finalRange.toFixed(3)}mm`, + data: result.workflow, + }) + ); + } else { + setStatus(`Workflow error: ${result.error || 'No mesh data loaded'}`); + } + } catch (error) { + setStatus(`Workflow error: ${errorText(error)}`); + } finally { + runButton.disabled = false; + } +} + +function handleShowVisualRecommendations(): void { + if (!calState.workflowData) { + setStatus('Run analysis to generate recommendations'); + return; + } + + el('cal-visual-rec-overlay').classList.remove('hidden'); + recommendationVisualizer?.setRecommendations( + calState.workflowData.screwAdjustments || [], + calState.workflowData.tapeRecommendations || [] + ); + recommendationVisualizer?.start(); +} + +function hideVisualRecommendations(): void { + el('cal-visual-rec-overlay').classList.add('hidden'); + recommendationVisualizer?.stop(); +} + +// --------------------------------------------------------------------------- +// Input shaper +// --------------------------------------------------------------------------- + +function switchAxis(axis: 'x' | 'y'): void { + buttonEl('cal-btn-axis-x').classList.toggle('active', axis === 'x'); + buttonEl('cal-btn-axis-y').classList.toggle('active', axis === 'y'); + calState.shaperResults.activeAxis = axis; + updateShaperDisplay(calState.shaperResults[axis]); +} + +async function handleLoadShaperCSV(): Promise { + const picked = await pickTextFile('.csv,text/csv'); + if (!picked) { + setStatus('File selection cancelled'); + return; + } + + const axis = calState.shaperResults.activeAxis; + await analyzeShaperContent(picked.content, axis); + setStatus(`Loaded shaper CSV (${axis.toUpperCase()} axis)`); +} + +async function handleFetchShaperSSH(): Promise { + const axis = calState.shaperResults.activeAxis; + try { + setStatus(`Fetching ${axis.toUpperCase()} shaper data via SSH...`); + const result = await apiRequest('/api/calibration/ssh/fetch-shaper', calBody({ axis })); + if (!result.success || typeof result.content !== 'string') { + setStatus(`SSH Error: ${result.error || 'Failed to fetch shaper data'}`); + return; + } + await analyzeShaperContent(result.content, axis); + setStatus(`Fetched shaper data (${axis.toUpperCase()} axis)`); + } catch (error) { + setStatus(`SSH Error: ${errorText(error)}`); + } +} + +async function analyzeShaperContent(csvContent: string, axis: 'x' | 'y'): Promise { + try { + setStatus('Analyzing input shaper data...'); + const result = await apiRequest( + '/api/calibration/shaper/analyze', + calBody({ csvContent, axis }) + ); + if (!result.success || !result.calibration) { + setStatus(`Shaper analysis error: ${result.error || 'Analysis failed'}`); + return; + } + await setShaperCalibration(axis, result.calibration); + } catch (error) { + setStatus(`Shaper analysis error: ${errorText(error)}`); + } +} + +async function setShaperCalibration(axis: 'x' | 'y', calibration: AxisCalibration): Promise { + calState.shaperResults[axis] = calibration; + if (axis === calState.shaperResults.activeAxis) { + updateShaperDisplay(calibration); + } + + const summary = `${axis.toUpperCase()}: ${calibration.recommendedShaper.type.toUpperCase()} @ ${calibration.recommendedShaper.frequency.toFixed(1)} Hz`; + try { + await apiRequest( + '/api/calibration/history', + calBody({ type: 'input_shaper', summary, data: calibration }) + ); + await apiRequest( + '/api/calibration/shaper/save', + calBody({ axis, result: calibration.recommendedShaper }) + ); + } catch (error) { + console.error('[Calibration] Failed to persist shaper result:', error); + } +} + +function updateShaperDisplay(calibration: AxisCalibration | null): void { + shaperVisualizer?.setCalibration(calibration); + + if (!calibration) { + el('cal-shaper-recommendation').innerHTML = + '
Load accelerometer data to see recommendations
'; + el('cal-shaper-comparison').innerHTML = '
No data loaded
'; + void updateShaperConfigOutput(null); + return; + } + + const rec = calibration.recommendedShaper; + el('cal-shaper-recommendation').innerHTML = ` + +
+
Vibration reduction: ${(rec.vibrationReduction * 100).toFixed(1)}%
+
Smoothing: ${rec.smoothingTime.toFixed(2)} ms
+
Max accel: ${rec.maxAcceleration} mm/s²
+
+ `; + + el('cal-shaper-comparison').innerHTML = calibration.allShaperResults + .map( + (result: ShaperResult) => ` +
+
${result.type.toUpperCase()}
+
${result.frequency.toFixed(1)} Hz
+
` + ) + .join(''); + + void updateShaperConfigOutput(calibration); +} + +async function updateShaperConfigOutput(calibration: AxisCalibration | null): Promise { + const output = el('cal-shaper-config-output'); + const copyButton = buttonEl('cal-btn-copy-config'); + const saveButton = buttonEl('cal-btn-save-config'); + const uploadButton = buttonEl('cal-btn-upload-config'); + + if (!calibration) { + output.textContent = 'Load data to generate config'; + copyButton.disabled = true; + saveButton.disabled = true; + uploadButton.disabled = true; + return; + } + + const lines = await generateShaperConfigLines(calibration.axis, calibration.recommendedShaper); + output.textContent = lines.join('\n'); + copyButton.disabled = false; + saveButton.disabled = false; + uploadButton.disabled = !calState.sshConnected; +} + +async function generateShaperConfigLines(axis: 'x' | 'y', result: ShaperResult): Promise { + const response = await apiRequest( + '/api/calibration/shaper/config', + calBody({ axis, result }) + ); + return response.success && response.lines ? response.lines : []; +} + +async function handleCopyShaperConfig(): Promise { + const text = el('cal-shaper-config-output').textContent || ''; + if (!text || text.includes('Load data')) { + setStatus('No shaper config available'); + return; + } + + try { + await navigator.clipboard.writeText(text); + setStatus('Shaper config copied to clipboard'); + } catch (error) { + setStatus(`Copy failed: ${errorText(error)}`); + } +} + +async function handleSaveShaperConfig(): Promise { + const calibration = calState.shaperResults[calState.shaperResults.activeAxis]; + if (!calibration) { + setStatus('No shaper data to export'); + return; + } + + try { + const content = await buildShaperConfigContent(calibration.axis, calibration); + downloadBlob(new Blob([content], { type: 'text/plain' }), `input_shaper_${calibration.axis}.cfg`); + setStatus(`Config downloaded: input_shaper_${calibration.axis}.cfg`); + } catch (error) { + setStatus(`Export error: ${errorText(error)}`); + } +} + +async function handleUploadShaperConfig(): Promise { + const calibration = calState.shaperResults[calState.shaperResults.activeAxis]; + if (!calibration) { + setStatus('No shaper data to upload'); + return; + } + + if (!calState.configContent) { + setStatus('Load or fetch printer.cfg before uploading'); + return; + } + + try { + const content = await buildShaperConfigContent(calibration.axis, calibration); + const remotePath = inputEl('cal-ssh-config-path').value.trim() || undefined; + const result = await apiRequest( + '/api/calibration/ssh/upload-config', + calBody(remotePath ? { content, remotePath } : { content }) + ); + if (result.success) { + calState.configContent = content; + setStatus('Config uploaded via SSH'); + } else { + setStatus(`Upload failed: ${result.error || result.result?.error || 'Unknown error'}`); + } + } catch (error) { + setStatus(`Upload error: ${errorText(error)}`); + } +} + +async function buildShaperConfigContent(axis: 'x' | 'y', calibration: AxisCalibration): Promise { + const lines = await generateShaperConfigLines(axis, calibration.recommendedShaper); + if (!calState.configContent) { + return lines.join('\n'); + } + return applyShaperConfigToPrinterConfig(calState.configContent, axis, lines); +} + +/** + * Merge generated [input_shaper] lines into an existing printer.cfg, replacing + * only the target axis keys. Mirrors the desktop implementation verbatim. + */ +function applyShaperConfigToPrinterConfig(configContent: string, axis: 'x' | 'y', configLines: string[]): string { + const lines = configContent.split(/\r?\n/); + const axisKeys = [`shaper_freq_${axis}`, `shaper_type_${axis}`]; + + let sectionStart = -1; + let sectionEnd = lines.length; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim().toLowerCase(); + if (trimmed.startsWith('[')) { + if (sectionStart !== -1) { + sectionEnd = i; + break; + } + if (trimmed === '[input_shaper]') { + sectionStart = i; + } + } + } + + const axisLines = configLines.filter((line) => line.trim().startsWith('shaper_')); + + if (sectionStart === -1) { + const suffix = configContent.trim().length > 0 ? '\n\n' : ''; + return `${configContent.trimEnd()}${suffix}${configLines.join('\n')}\n`; + } + + const bodyLines = lines.slice(sectionStart + 1, sectionEnd); + const filtered = bodyLines.filter((line) => { + const trimmed = line.trim().toLowerCase(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) { + return true; + } + return !axisKeys.some((key) => trimmed.startsWith(`${key}:`) || trimmed.startsWith(`${key} `)); + }); + + const newSection = [lines[sectionStart], ...filtered, ...axisLines]; + return [...lines.slice(0, sectionStart), ...newSection, ...lines.slice(sectionEnd)].join('\n'); +} + +// --------------------------------------------------------------------------- +// SSH +// --------------------------------------------------------------------------- + +async function loadSSHInfo(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/ssh/config')); + if (!result.success || !result.config) { + return; + } + + const config = result.config; + const context = calState.pinnedContextId ? contextById.get(calState.pinnedContextId) : undefined; + const host = context?.ipAddress?.trim() || config.host?.trim() || ''; + const hostInput = inputEl('cal-ssh-host'); + hostInput.value = host; + if (!host) { + hostInput.placeholder = 'Printer IP unavailable (connect printer first)'; + } + + const username = config.username || 'root'; + const port = config.port || 22; + const auth = config.keyPath ? 'private key' : 'password'; + const source = config.isCustom ? 'custom' : 'easy-SSH defaults'; + inputEl('cal-ssh-summary').value = `${username} (port ${port}, ${auth} auth, ${source})`; + + if (config.configPath) { + inputEl('cal-ssh-config-path').value = config.configPath; + } + } catch (error) { + console.error('[Calibration] Failed to load SSH settings:', error); + } +} + +/** Defensively derive "connected" from the SSH status payload. */ +function isConnectedStatus(status: unknown): boolean { + if (typeof status === 'string') { + return status === 'connected'; + } + if (status && typeof status === 'object') { + const record = status as Record; + return record.connected === true || record.status === 'connected' || record.state === 'connected'; + } + return false; +} + +async function updateSSHStatus(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/ssh/status')); + calState.sshConnected = result.success && isConnectedStatus(result.status); + } catch { + calState.sshConnected = false; + } + + const connected = calState.sshConnected; + const indicator = el('cal-ssh-indicator'); + indicator.querySelector('.cal-ssh-dot')?.classList.toggle('connected', connected); + const statusText = indicator.querySelector('.cal-ssh-text'); + if (statusText) { + statusText.textContent = connected ? 'SSH: Connected' : 'SSH: Disconnected'; + } + + buttonEl('cal-btn-ssh-connect').disabled = connected; + buttonEl('cal-btn-ssh-disconnect').disabled = !connected; + buttonEl('cal-btn-fetch-ssh').disabled = !connected; + buttonEl('cal-btn-fetch-shaper').disabled = !connected; + buttonEl('cal-btn-upload-config').disabled = + !connected || !calState.shaperResults[calState.shaperResults.activeAxis]; +} + +async function handleSSHConnect(silent = false): Promise { + try { + buttonEl('cal-btn-ssh-connect').disabled = true; + if (!silent) { + setSSHResult('Connecting...', 'info'); + } + + const result = await apiRequest('/api/calibration/ssh/connect', calBody()); + if (!result.success) { + throw new Error(result.error || 'Connection failed'); + } + + if (!silent) { + setSSHResult('Connected successfully!', 'success'); + } + await updateSSHStatus(); + } catch (error) { + if (!silent) { + setSSHResult(`Connection failed: ${errorText(error)}`, 'error'); + } + buttonEl('cal-btn-ssh-connect').disabled = false; + } +} + +async function handleSSHDisconnect(): Promise { + try { + await apiRequest('/api/calibration/ssh/disconnect', calBody()); + setSSHResult('Disconnected', 'info'); + await updateSSHStatus(); + } catch (error) { + setSSHResult(`Disconnect error: ${errorText(error)}`, 'error'); + } +} + +async function handleSSHTest(): Promise { + const testButton = buttonEl('cal-btn-ssh-test'); + try { + testButton.disabled = true; + setSSHResult('Testing connection...', 'info'); + + const connect = await apiRequest('/api/calibration/ssh/connect', calBody()); + if (!connect.success) { + throw new Error(connect.error || 'Connection failed'); + } + + const result = await apiRequest( + '/api/calibration/ssh/execute', + calBody({ command: 'echo "Connection test"' }) + ); + if (result.success && result.result?.success !== false) { + setSSHResult('Connection test successful!', 'success'); + } else { + setSSHResult(`Command failed: ${result.error || result.result?.error || result.result?.stderr || 'Unknown error'}`, 'error'); + } + + await updateSSHStatus(); + } catch (error) { + setSSHResult(`Test failed: ${errorText(error)}`, 'error'); + } finally { + testButton.disabled = false; + } +} + +function setSSHResult(message: string, type: 'success' | 'error' | 'info'): void { + const resultEl = el('cal-ssh-test-result'); + resultEl.textContent = message; + resultEl.className = 'cal-ssh-test-result'; + if (type !== 'info') { + resultEl.classList.add(type); + } +} + +/** Persist the calibration-specific remote config path override. */ +async function persistConfigPath(): Promise { + try { + await apiRequest( + '/api/calibration/ssh/config', + calBody({ configPath: inputEl('cal-ssh-config-path').value.trim() }) + ); + } catch (error) { + console.error('[Calibration] Failed to save config path:', error); + } +} + +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + +async function loadHistory(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/history')); + const history = result.success && result.history ? result.history : []; + + if (history.length === 0) { + el('cal-history-list').innerHTML = + '
No calibration history for this printer
'; + return; + } + + el('cal-history-list').innerHTML = history + .map( + (entry: CalibrationHistoryEntry) => ` +
+
+ ${entry.type === 'bed_level' ? 'Bed Leveling' : 'Input Shaper'} + ${entry.summary} + ${new Date(entry.timestamp).toLocaleString()} +
+
` + ) + .join(''); + } catch (error) { + console.error('[Calibration] Failed to load history:', error); + } +} + +async function handleClearHistory(): Promise { + if (!window.confirm('Are you sure you want to clear calibration history?')) { + return; + } + + try { + await apiRequest(calQuery('/api/calibration/history'), { method: 'DELETE' }); + await loadHistory(); + setStatus('History cleared'); + } catch (error) { + setStatus(`Error clearing history: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Report export +// --------------------------------------------------------------------------- + +async function handleExport(): Promise { + if (!calState.meshData) { + setStatus('Load mesh data before exporting a report'); + return; + } + + const format = selectEl('cal-export-format').value || 'json'; + try { + setStatus(`Exporting ${format.toUpperCase()} report...`); + // The report endpoint streams the raw payload (JSON/CSV text or PNG/PDF + // binary) rather than a JSON envelope, so it needs a direct fetch. + const response = await fetch(calQuery('/api/calibration/report', { format }), { + headers: buildAuthHeaders(), + }); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Non-JSON error body; keep the HTTP status message. + } + setStatus(`Export error: ${message}`); + return; + } + + const blob = await response.blob(); + downloadBlob(blob, `calibration-report.${format}`); + setStatus(`Report downloaded (calibration-report.${format})`); + } catch (error) { + setStatus(`Export error: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +/** Wire the topbar button and all modal event handlers. Call once at startup. */ +export function setupCalibration(): void { + document.getElementById('calibration-button')?.addEventListener('click', () => void openCalibration()); + + el('cal-close').addEventListener('click', closeCalibration); + el('cal-close-footer').addEventListener('click', closeCalibration); + + document.querySelectorAll('.cal-tab-button').forEach((button) => { + button.addEventListener('click', () => switchTab(button.dataset.tab || 'bed')); + }); + + el('cal-btn-load-file').addEventListener('click', () => void handleLoadConfigFile()); + el('cal-btn-fetch-ssh').addEventListener('click', () => void handleFetchConfigSSH()); + selectEl('cal-profile-select').addEventListener('change', () => { + const profile = selectEl('cal-profile-select').value; + if (profile) { + void loadMeshProfile(profile); + } + }); + el('cal-btn-run-workflow').addEventListener('click', () => void handleRunWorkflow()); + + el('cal-btn-axis-x').addEventListener('click', () => switchAxis('x')); + el('cal-btn-axis-y').addEventListener('click', () => switchAxis('y')); + el('cal-btn-load-csv').addEventListener('click', () => void handleLoadShaperCSV()); + el('cal-btn-fetch-shaper').addEventListener('click', () => void handleFetchShaperSSH()); + el('cal-btn-copy-config').addEventListener('click', () => void handleCopyShaperConfig()); + el('cal-btn-save-config').addEventListener('click', () => void handleSaveShaperConfig()); + el('cal-btn-upload-config').addEventListener('click', () => void handleUploadShaperConfig()); + + el('cal-btn-ssh-connect').addEventListener('click', () => void handleSSHConnect()); + el('cal-btn-ssh-disconnect').addEventListener('click', () => void handleSSHDisconnect()); + el('cal-btn-ssh-test').addEventListener('click', () => void handleSSHTest()); + inputEl('cal-ssh-config-path').addEventListener('change', () => void persistConfigPath()); + + el('cal-btn-clear-history').addEventListener('click', () => void handleClearHistory()); + + el('cal-btn-export').addEventListener('click', () => void handleExport()); + + el('cal-btn-visual-rec').addEventListener('click', handleShowVisualRecommendations); + el('cal-visual-rec-close').addEventListener('click', hideVisualRecommendations); + el('cal-visual-rec-overlay').addEventListener('click', (event) => { + if (event.target === el('cal-visual-rec-overlay')) { + hideVisualRecommendations(); + } + }); +} diff --git a/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts b/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts new file mode 100644 index 0000000..20cfb7c --- /dev/null +++ b/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts @@ -0,0 +1,233 @@ +/** + * @fileoverview Animated visualization for screw adjustments and tape + * recommendations (WebUI copy). Browser-pure port of the desktop + * AnimatedRecommendationVisualizer: draws the bed outline with pulsing + * rotation arrows per corner and tape-layer markers. + */ + +import type { ScrewAdjustment, TapeRecommendation } from './types.js'; + +export interface RecommendationVisualizerOptions { + width: number; + height: number; + padding: number; + backgroundColor: string; + bedColor: string; + gridColor: string; + textColor: string; + cwColor: string; + ccwColor: string; + tapeColor: string; + fontSize: number; +} + +const DEFAULT_OPTIONS: RecommendationVisualizerOptions = { + width: 620, + height: 420, + padding: 40, + backgroundColor: '#141414', + bedColor: '#1f1f1f', + gridColor: 'rgba(255,255,255,0.1)', + textColor: '#e0e0e0', + cwColor: '#4caf50', + ccwColor: '#ff9800', + tapeColor: '#5cc8ff', + fontSize: 12, +}; + +export class AnimatedRecommendationVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly dpr: number; + private options: RecommendationVisualizerOptions; + private screwAdjustments: ScrewAdjustment[] = []; + private tapeRecommendations: TapeRecommendation[] = []; + private animationFrame: number | null = null; + private startTime = 0; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + this.dpr = window.devicePixelRatio || 1; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.setupCanvas(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.setupCanvas(); + this.render(0); + } + + setRecommendations(adjustments: ScrewAdjustment[], tape: TapeRecommendation[]): void { + this.screwAdjustments = adjustments; + this.tapeRecommendations = tape; + this.render(0); + } + + start(): void { + if (this.animationFrame !== null) return; + this.startTime = performance.now(); + const loop = (timestamp: number) => { + this.render(timestamp); + this.animationFrame = requestAnimationFrame(loop); + }; + this.animationFrame = requestAnimationFrame(loop); + } + + stop(): void { + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + private getCornerPositions(): Record { + const { width, height, padding } = this.options; + const bedWidth = width - padding * 2; + const bedHeight = height - padding * 2; + const left = padding; + const right = padding + bedWidth; + const top = padding; + const bottom = padding + bedHeight; + + return { + frontLeft: { x: left, y: bottom }, + frontRight: { x: right, y: bottom }, + rearLeft: { x: left, y: top }, + rearRight: { x: right, y: top }, + }; + } + + private drawBed(): void { + const { width, height, padding, bedColor, gridColor } = this.options; + const bedWidth = width - padding * 2; + const bedHeight = height - padding * 2; + + this.ctx.fillStyle = bedColor; + this.ctx.fillRect(padding, padding, bedWidth, bedHeight); + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(padding, padding, bedWidth, bedHeight); + + const gridCount = 4; + for (let i = 1; i < gridCount; i++) { + const x = padding + (i / gridCount) * bedWidth; + const y = padding + (i / gridCount) * bedHeight; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + bedHeight); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.moveTo(padding, y); + this.ctx.lineTo(padding + bedWidth, y); + this.ctx.stroke(); + } + } + + private drawScrewAdjustment(corner: { x: number; y: number }, adjustment: ScrewAdjustment, pulse: number): void { + const { cwColor, ccwColor, textColor, fontSize } = this.options; + const color = adjustment.direction === 'CW' ? cwColor : ccwColor; + const radius = 18 + pulse * 4; + + this.ctx.strokeStyle = color; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + const startAngle = adjustment.direction === 'CW' ? Math.PI * 1.1 : Math.PI * 0.1; + const endAngle = adjustment.direction === 'CW' ? Math.PI * 0.1 : Math.PI * 1.1; + this.ctx.arc(corner.x, corner.y, radius, startAngle, endAngle, adjustment.direction !== 'CW'); + this.ctx.stroke(); + + const arrowAngle = adjustment.direction === 'CW' ? endAngle : startAngle; + const arrowX = corner.x + radius * Math.cos(arrowAngle); + const arrowY = corner.y + radius * Math.sin(arrowAngle); + const arrowSize = 6; + this.ctx.fillStyle = color; + this.ctx.beginPath(); + this.ctx.moveTo(arrowX, arrowY); + this.ctx.lineTo( + arrowX - arrowSize * Math.cos(arrowAngle - Math.PI / 6), + arrowY - arrowSize * Math.sin(arrowAngle - Math.PI / 6) + ); + this.ctx.lineTo( + arrowX - arrowSize * Math.cos(arrowAngle + Math.PI / 6), + arrowY - arrowSize * Math.sin(arrowAngle + Math.PI / 6) + ); + this.ctx.closePath(); + this.ctx.fill(); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'bottom'; + this.ctx.fillText(adjustment.formattedAmount, corner.x, corner.y - radius - 6); + } + + private drawTapeRecommendation(corner: { x: number; y: number }, recommendation: TapeRecommendation): void { + const { tapeColor, textColor, fontSize } = this.options; + const size = 16; + const offset = 10; + + this.ctx.fillStyle = tapeColor; + this.ctx.fillRect(corner.x - size / 2, corner.y - offset - size / 2, size, size); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText( + `${recommendation.layers} layer${recommendation.layers === 1 ? '' : 's'}`, + corner.x, + corner.y + 8 + ); + } + + private render(timestamp: number): void { + const { width, height, backgroundColor, textColor, fontSize } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + this.drawBed(); + + const cornerPositions = this.getCornerPositions(); + const pulse = 0.5 + 0.5 * Math.sin((timestamp - this.startTime) / 400); + + const activeAdjustments = this.screwAdjustments.filter((adj) => adj.requiresAdjustment); + activeAdjustments.forEach((adj) => { + const corner = cornerPositions[adj.corner]; + if (corner) { + this.drawScrewAdjustment(corner, adj, pulse); + } + }); + + this.tapeRecommendations.forEach((rec) => { + const corner = cornerPositions[rec.corner]; + if (corner) { + this.drawTapeRecommendation(corner, rec); + } + }); + + if (activeAdjustments.length === 0 && this.tapeRecommendations.length === 0) { + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize + 2}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No visual adjustments needed', width / 2, height / 2); + } + } +} diff --git a/src/webui/static/features/calibration/BedMeshVisualizer.ts b/src/webui/static/features/calibration/BedMeshVisualizer.ts new file mode 100644 index 0000000..6ece382 --- /dev/null +++ b/src/webui/static/features/calibration/BedMeshVisualizer.ts @@ -0,0 +1,453 @@ +/** + * @fileoverview Canvas-based bed mesh visualization for the WebUI client. + * Browser-pure port of the desktop BedMeshVisualizer: renders a 2D heatmap of + * bed mesh data with color-coded height values, hover tooltips, grid lines, + * value labels, and a color bar legend. + */ + +import { type ColorScheme, createColorMapper, getColor, rgbToCSS } from './ColorScales.js'; +import type { AnalysisResult, MeshData } from './types.js'; + +/** Configuration options for the visualizer. */ +export interface VisualizerOptions { + width: number; + height: number; + padding: number; + colorScheme: ColorScheme; + showGrid: boolean; + showLabels: boolean; + showCorners: boolean; + interpolationFactor: number; + fontSize: number; + gridColor: string; + labelColor: string; + backgroundColor: string; +} + +/** Default visualizer options. */ +export const DEFAULT_VISUALIZER_OPTIONS: VisualizerOptions = { + width: 400, + height: 400, + padding: 40, + colorScheme: 'viridis', + showGrid: true, + showLabels: true, + showCorners: true, + interpolationFactor: 1, + fontSize: 10, + gridColor: 'rgba(255, 255, 255, 0.3)', + labelColor: '#ffffff', + backgroundColor: '#1a1a1a', +}; + +/** Cell information for hover/click events. */ +export interface CellInfo { + row: number; + col: number; + value: number; + x: number; + y: number; +} + +/** Event handlers for the visualizer. */ +export interface VisualizerEventHandlers { + onCellHover?: (cell: CellInfo | null) => void; + onCellClick?: (cell: CellInfo) => void; +} + +/** Canvas-based bed mesh visualizer. */ +export class BedMeshVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private meshData: MeshData | null = null; + private analysisResult: AnalysisResult | null = null; + private options: VisualizerOptions; + private handlers: VisualizerEventHandlers = {}; + private hoveredCell: CellInfo | null = null; + private interpolatedMesh: number[][] | null = null; + private readonly dpr: number; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + this.options = { ...DEFAULT_VISUALIZER_OPTIONS, ...options }; + this.dpr = window.devicePixelRatio || 1; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + + this.setupCanvas(); + this.setupEventListeners(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + private setupEventListeners(): void { + this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); + this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); + this.canvas.addEventListener('click', this.handleClick.bind(this)); + } + + private handleMouseMove(event: MouseEvent): void { + const cell = this.getCellAtPosition(event.offsetX, event.offsetY); + if (cell !== this.hoveredCell) { + this.hoveredCell = cell; + this.handlers.onCellHover?.(cell); + this.render(); + } + } + + private handleMouseLeave(): void { + if (this.hoveredCell) { + this.hoveredCell = null; + this.handlers.onCellHover?.(null); + this.render(); + } + } + + private handleClick(event: MouseEvent): void { + const cell = this.getCellAtPosition(event.offsetX, event.offsetY); + if (cell) { + this.handlers.onCellClick?.(cell); + } + } + + private getCellAtPosition(x: number, y: number): CellInfo | null { + if (!this.meshData) return null; + + const { width, height, padding } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + + if (x < padding || x > width - padding || y < padding || y > height - padding) { + return null; + } + + const { pointsX, pointsY, matrix } = this.meshData; + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + const col = Math.floor((x - padding) / cellWidth); + const row = Math.floor((y - padding) / cellHeight); + + if (row >= 0 && row < pointsY && col >= 0 && col < pointsX) { + return { + row, + col, + value: matrix[row][col], + x: padding + col * cellWidth + cellWidth / 2, + y: padding + row * cellHeight + cellHeight / 2, + }; + } + + return null; + } + + setEventHandlers(handlers: VisualizerEventHandlers): void { + this.handlers = handlers; + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.interpolatedMesh = null; + this.setupCanvas(); + this.render(); + } + + setMeshData(meshData: MeshData | null, analysisResult?: AnalysisResult | null): void { + this.meshData = meshData; + this.analysisResult = analysisResult ?? null; + this.interpolatedMesh = null; + this.render(); + } + + /** Latest analysis result associated with the current mesh (may be null). */ + getAnalysisResult(): AnalysisResult | null { + return this.analysisResult; + } + + private interpolateMesh(): number[][] { + if (!this.meshData) return []; + + const factor = this.options.interpolationFactor; + if (factor <= 1) return this.meshData.matrix; + + const { matrix, pointsX, pointsY } = this.meshData; + const newWidth = (pointsX - 1) * factor + 1; + const newHeight = (pointsY - 1) * factor + 1; + const result: number[][] = []; + + for (let y = 0; y < newHeight; y++) { + const row: number[] = []; + for (let x = 0; x < newWidth; x++) { + const srcX = x / factor; + const srcY = y / factor; + const x0 = Math.floor(srcX); + const y0 = Math.floor(srcY); + const x1 = Math.min(x0 + 1, pointsX - 1); + const y1 = Math.min(y0 + 1, pointsY - 1); + const tx = srcX - x0; + const ty = srcY - y0; + + const v00 = matrix[y0][x0]; + const v01 = matrix[y0][x1]; + const v10 = matrix[y1][x0]; + const v11 = matrix[y1][x1]; + + const v0 = v00 * (1 - tx) + v01 * tx; + const v1 = v10 * (1 - tx) + v11 * tx; + row.push(v0 * (1 - ty) + v1 * ty); + } + result.push(row); + } + + return result; + } + + private getMeshBounds(): { min: number; max: number } { + if (!this.meshData) { + return { min: 0, max: 1 }; + } + + let min = Infinity; + let max = -Infinity; + for (const row of this.meshData.matrix) { + for (const value of row) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + return { min, max }; + } + + render(): void { + const { width, height, backgroundColor } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + if (!this.meshData) { + this.renderEmptyState(); + return; + } + + if (!this.interpolatedMesh) { + this.interpolatedMesh = this.interpolateMesh(); + } + + this.renderHeatmap(); + this.renderGrid(); + this.renderLabels(); + this.renderCorners(); + this.renderHover(); + this.renderColorBar(); + } + + private renderEmptyState(): void { + const { width, height, labelColor } = this.options; + this.ctx.fillStyle = labelColor; + this.ctx.font = '14px sans-serif'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No mesh data loaded', width / 2, height / 2); + } + + private renderHeatmap(): void { + if (!this.interpolatedMesh || this.interpolatedMesh.length === 0) return; + + const { width, height, padding, colorScheme } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + + const rows = this.interpolatedMesh.length; + const cols = this.interpolatedMesh[0].length; + const cellWidth = meshWidth / cols; + const cellHeight = meshHeight / rows; + + const { min, max } = this.getMeshBounds(); + const colorMapper = createColorMapper(colorScheme, min, max); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const value = this.interpolatedMesh[row][col]; + const x = padding + col * cellWidth; + const y = padding + row * cellHeight; + + this.ctx.fillStyle = colorMapper(value); + this.ctx.fillRect(x, y, cellWidth + 0.5, cellHeight + 0.5); + } + } + } + + private renderGrid(): void { + if (!this.options.showGrid || !this.meshData) return; + + const { width, height, padding, gridColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY } = this.meshData; + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + + for (let i = 0; i <= pointsX; i++) { + const x = padding + (i / pointsX) * meshWidth; + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, height - padding); + } + + for (let i = 0; i <= pointsY; i++) { + const y = padding + (i / pointsY) * meshHeight; + this.ctx.moveTo(padding, y); + this.ctx.lineTo(width - padding, y); + } + + this.ctx.stroke(); + } + + private renderLabels(): void { + if (!this.options.showLabels || !this.meshData) return; + + const { width, height, padding, fontSize, labelColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY, matrix } = this.meshData; + + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize}px monospace`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + + for (let row = 0; row < pointsY; row++) { + for (let col = 0; col < pointsX; col++) { + const value = matrix[row][col]; + const x = padding + col * cellWidth + cellWidth / 2; + const y = padding + row * cellHeight + cellHeight / 2; + + this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + this.ctx.shadowBlur = 2; + this.ctx.fillText(value.toFixed(3), x, y); + this.ctx.shadowBlur = 0; + } + } + } + + private renderCorners(): void { + if (!this.options.showCorners || !this.meshData) return; + + const { width, height, padding, fontSize, labelColor } = this.options; + + const corners = [ + { x: padding, y: height - padding }, + { x: width - padding, y: height - padding }, + { x: padding, y: padding }, + { x: width - padding, y: padding }, + ]; + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize}px sans-serif`; + + for (const corner of corners) { + this.ctx.beginPath(); + this.ctx.arc(corner.x, corner.y, 4, 0, Math.PI * 2); + this.ctx.fill(); + } + } + + private renderHover(): void { + if (!this.hoveredCell || !this.meshData) return; + + const { width, height, padding, labelColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY } = this.meshData; + + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + const { row, col, value } = this.hoveredCell; + const x = padding + col * cellWidth; + const y = padding + row * cellHeight; + + this.ctx.strokeStyle = '#ffffff'; + this.ctx.lineWidth = 2; + this.ctx.strokeRect(x, y, cellWidth, cellHeight); + + const tooltipText = `[${row}, ${col}]: ${value.toFixed(4)} mm`; + const tooltipPadding = 5; + this.ctx.font = '12px monospace'; + const textWidth = this.ctx.measureText(tooltipText).width; + + let tooltipX = x + cellWidth / 2 - textWidth / 2 - tooltipPadding; + let tooltipY = y - 25; + + tooltipX = Math.max(5, Math.min(width - textWidth - tooltipPadding * 2 - 5, tooltipX)); + if (tooltipY < 5) tooltipY = y + cellHeight + 5; + + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + this.ctx.fillRect(tooltipX, tooltipY, textWidth + tooltipPadding * 2, 20); + + this.ctx.fillStyle = labelColor; + this.ctx.textAlign = 'left'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(tooltipText, tooltipX + tooltipPadding, tooltipY + 10); + } + + private renderColorBar(): void { + const { width, height, padding, colorScheme, labelColor, fontSize } = this.options; + const { min, max } = this.getMeshBounds(); + + const barWidth = 15; + const barHeight = height - padding * 2; + const barX = width - padding / 2 - barWidth / 2; + const barY = padding; + + const gradient = this.ctx.createLinearGradient(barX, barY + barHeight, barX, barY); + const steps = 10; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + gradient.addColorStop(t, rgbToCSS(getColor(colorScheme, t))); + } + + this.ctx.fillStyle = gradient; + this.ctx.fillRect(barX, barY, barWidth, barHeight); + + this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(barX, barY, barWidth, barHeight); + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize - 1}px monospace`; + + const ticks = [0, 0.25, 0.5, 0.75, 1]; + for (const t of ticks) { + const y = barY + barHeight * (1 - t); + this.ctx.beginPath(); + this.ctx.moveTo(barX + barWidth, y); + this.ctx.lineTo(barX + barWidth + 3, y); + this.ctx.stroke(); + } + + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(max.toFixed(3), barX + barWidth / 2, barY - 8); + this.ctx.fillText(min.toFixed(3), barX + barWidth / 2, barY + barHeight + 8); + } + + toDataURL(format: 'png' | 'jpeg' = 'png'): string { + return this.canvas.toDataURL(`image/${format}`); + } +} diff --git a/src/webui/static/features/calibration/ColorScales.ts b/src/webui/static/features/calibration/ColorScales.ts new file mode 100644 index 0000000..f3fadb1 --- /dev/null +++ b/src/webui/static/features/calibration/ColorScales.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Color scale utilities for heatmap visualization (WebUI copy). + * Browser-pure port of src/renderer/src/ui/calibration/visualization/ColorScales.ts + * for the static WebUI client (which cannot import renderer modules). + * Supports multiple color schemes: viridis, plasma, inferno, coolwarm. + */ + +/** RGB color tuple. */ +export type RGB = [number, number, number]; + +/** Available color scheme names. */ +export type ColorScheme = 'viridis' | 'plasma' | 'inferno' | 'coolwarm'; + +/** Color stop definition for gradient. */ +export interface ColorStop { + position: number; + color: RGB; +} + +const VIRIDIS_STOPS: ColorStop[] = [ + { position: 0.0, color: [68, 1, 84] }, + { position: 0.1, color: [72, 40, 120] }, + { position: 0.2, color: [62, 74, 137] }, + { position: 0.3, color: [49, 104, 142] }, + { position: 0.4, color: [38, 130, 142] }, + { position: 0.5, color: [31, 158, 137] }, + { position: 0.6, color: [53, 183, 121] }, + { position: 0.7, color: [109, 205, 89] }, + { position: 0.8, color: [180, 222, 44] }, + { position: 0.9, color: [223, 227, 24] }, + { position: 1.0, color: [253, 231, 37] }, +]; + +const PLASMA_STOPS: ColorStop[] = [ + { position: 0.0, color: [13, 8, 135] }, + { position: 0.1, color: [75, 3, 161] }, + { position: 0.2, color: [125, 3, 168] }, + { position: 0.3, color: [168, 34, 150] }, + { position: 0.4, color: [203, 70, 121] }, + { position: 0.5, color: [229, 107, 93] }, + { position: 0.6, color: [248, 148, 65] }, + { position: 0.7, color: [253, 195, 40] }, + { position: 0.8, color: [240, 249, 33] }, + { position: 1.0, color: [240, 249, 33] }, +]; + +const INFERNO_STOPS: ColorStop[] = [ + { position: 0.0, color: [0, 0, 4] }, + { position: 0.1, color: [40, 11, 84] }, + { position: 0.2, color: [89, 13, 115] }, + { position: 0.3, color: [137, 27, 100] }, + { position: 0.4, color: [181, 50, 64] }, + { position: 0.5, color: [219, 87, 26] }, + { position: 0.6, color: [244, 130, 7] }, + { position: 0.7, color: [252, 180, 31] }, + { position: 0.8, color: [250, 230, 102] }, + { position: 1.0, color: [252, 255, 164] }, +]; + +const COOLWARM_STOPS: ColorStop[] = [ + { position: 0.0, color: [59, 76, 192] }, + { position: 0.1, color: [98, 130, 234] }, + { position: 0.2, color: [141, 176, 254] }, + { position: 0.3, color: [184, 208, 249] }, + { position: 0.4, color: [221, 221, 221] }, + { position: 0.5, color: [245, 245, 245] }, + { position: 0.6, color: [249, 196, 178] }, + { position: 0.7, color: [244, 154, 123] }, + { position: 0.8, color: [221, 96, 73] }, + { position: 0.9, color: [192, 40, 40] }, + { position: 1.0, color: [180, 4, 38] }, +]; + +const COLOR_SCALES: Record = { + viridis: VIRIDIS_STOPS, + plasma: PLASMA_STOPS, + inferno: INFERNO_STOPS, + coolwarm: COOLWARM_STOPS, +}; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +/** Get color from a color scale at a normalized position (0-1). */ +export function getColor(scheme: ColorScheme, t: number): RGB { + const stops = COLOR_SCALES[scheme]; + const normalizedT = clamp01(t); + + let lowerStop = stops[0]; + let upperStop = stops[stops.length - 1]; + + for (let i = 0; i < stops.length - 1; i++) { + if (normalizedT >= stops[i].position && normalizedT <= stops[i + 1].position) { + lowerStop = stops[i]; + upperStop = stops[i + 1]; + break; + } + } + + const range = upperStop.position - lowerStop.position; + const localT = range > 0 ? (normalizedT - lowerStop.position) / range : 0; + + return [ + Math.round(lerp(lowerStop.color[0], upperStop.color[0], localT)), + Math.round(lerp(lowerStop.color[1], upperStop.color[1], localT)), + Math.round(lerp(lowerStop.color[2], upperStop.color[2], localT)), + ]; +} + +/** Convert RGB to CSS color string. */ +export function rgbToCSS(rgb: RGB, alpha?: number): string { + if (alpha !== undefined) { + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; + } + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; +} + +/** Get CSS color from color scale at normalized position. */ +export function getCSSColor(scheme: ColorScheme, t: number, alpha?: number): string { + return rgbToCSS(getColor(scheme, t), alpha); +} + +/** Create a color mapper for a given value range. */ +export function createColorMapper(scheme: ColorScheme, min: number, max: number): (value: number) => string { + const range = max - min; + return (value: number): string => { + const t = range > 0 ? (value - min) / range : 0.5; + return getCSSColor(scheme, t); + }; +} diff --git a/src/webui/static/features/calibration/ShaperPlotVisualizer.ts b/src/webui/static/features/calibration/ShaperPlotVisualizer.ts new file mode 100644 index 0000000..47c7818 --- /dev/null +++ b/src/webui/static/features/calibration/ShaperPlotVisualizer.ts @@ -0,0 +1,272 @@ +/** + * @fileoverview Canvas visualization for input shaper frequency analysis (WebUI copy). + * Browser-pure port of the desktop ShaperPlotVisualizer: renders power spectral + * density plots with peak markers and the recommended shaper frequency. + */ + +import type { AxisCalibration } from './types.js'; + +export interface ShaperPlotOptions { + width: number; + height: number; + padding: number; + backgroundColor: string; + axisColor: string; + gridColor: string; + lineColor: string; + peakColor: string; + recommendationColor: string; + textColor: string; + fontSize: number; +} + +const DEFAULT_OPTIONS: ShaperPlotOptions = { + width: 600, + height: 300, + padding: 40, + backgroundColor: '#1a1a1a', + axisColor: 'rgba(255, 255, 255, 0.7)', + gridColor: 'rgba(255, 255, 255, 0.08)', + lineColor: '#5cc8ff', + peakColor: '#ffb74d', + recommendationColor: '#4caf50', + textColor: '#e0e0e0', + fontSize: 11, +}; + +export class ShaperPlotVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly dpr: number; + private options: ShaperPlotOptions; + private calibration: AxisCalibration | null = null; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + this.dpr = window.devicePixelRatio || 1; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.setupCanvas(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.setupCanvas(); + this.render(); + } + + setCalibration(calibration: AxisCalibration | null): void { + this.calibration = calibration; + this.render(); + } + + private renderEmptyState(): void { + const { width, height, textColor } = this.options; + this.ctx.fillStyle = textColor; + this.ctx.font = '14px sans-serif'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No input shaper data loaded', width / 2, height / 2); + } + + private getPlotBounds(): { minFreq: number; maxFreq: number; maxPower: number } { + if (!this.calibration) { + return { minFreq: 0, maxFreq: 1, maxPower: 1 }; + } + const freqs = this.calibration.frequencyBins; + const power = this.calibration.powerSpectralDensity; + const minFreq = freqs[0] ?? 0; + const maxFreq = freqs[freqs.length - 1] ?? 1; + const maxPower = Math.max(1e-6, ...power); + return { minFreq, maxFreq, maxPower }; + } + + private drawGrid(): void { + const { width, height, padding, gridColor } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + + const verticalLines = 6; + for (let i = 0; i <= verticalLines; i++) { + const x = padding + (i / verticalLines) * plotWidth; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, height - padding); + this.ctx.stroke(); + } + + const horizontalLines = 4; + for (let i = 0; i <= horizontalLines; i++) { + const y = padding + (i / horizontalLines) * plotHeight; + this.ctx.beginPath(); + this.ctx.moveTo(padding, y); + this.ctx.lineTo(width - padding, y); + this.ctx.stroke(); + } + } + + private drawAxes(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + const { width, height, padding, axisColor, textColor, fontSize } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + this.ctx.strokeStyle = axisColor; + this.ctx.lineWidth = 1.5; + + this.ctx.beginPath(); + this.ctx.moveTo(padding, height - padding); + this.ctx.lineTo(width - padding, height - padding); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(padding, padding); + this.ctx.lineTo(padding, height - padding); + this.ctx.stroke(); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText('Frequency (Hz)', padding + plotWidth / 2, height - padding + 10); + + this.ctx.save(); + this.ctx.translate(padding - 28, padding + plotHeight / 2); + this.ctx.rotate(-Math.PI / 2); + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText('Normalized Power', 0, 0); + this.ctx.restore(); + + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + const freqTicks = 4; + for (let i = 0; i <= freqTicks; i++) { + const value = bounds.minFreq + (i / freqTicks) * (bounds.maxFreq - bounds.minFreq); + const x = padding + (i / freqTicks) * plotWidth; + this.ctx.fillText(value.toFixed(0), x, height - padding + 2); + } + + this.ctx.textAlign = 'right'; + this.ctx.textBaseline = 'middle'; + const powerTicks = 3; + for (let i = 0; i <= powerTicks; i++) { + const value = (1 - i / powerTicks) * bounds.maxPower; + const y = padding + (i / powerTicks) * plotHeight; + this.ctx.fillText(value.toFixed(2), padding - 6, y); + } + } + + private drawSpectrum(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + if (!this.calibration) return; + + const { width, height, padding, lineColor } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + const freqs = this.calibration.frequencyBins; + const power = this.calibration.powerSpectralDensity; + + if (freqs.length === 0 || power.length === 0) return; + + const scaleX = plotWidth / (bounds.maxFreq - bounds.minFreq || 1); + const scaleY = plotHeight / (bounds.maxPower || 1); + + this.ctx.strokeStyle = lineColor; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + + freqs.forEach((freq, index) => { + const x = padding + (freq - bounds.minFreq) * scaleX; + const y = padding + plotHeight - power[index] * scaleY; + if (index === 0) { + this.ctx.moveTo(x, y); + } else { + this.ctx.lineTo(x, y); + } + }); + + this.ctx.stroke(); + } + + private drawMarkers(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + if (!this.calibration) return; + + const { width, height, padding, peakColor, recommendationColor, textColor, fontSize } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + const scaleX = plotWidth / (bounds.maxFreq - bounds.minFreq || 1); + + this.calibration.peakFrequencies.forEach((freq) => { + const x = padding + (freq - bounds.minFreq) * scaleX; + this.ctx.strokeStyle = peakColor; + this.ctx.lineWidth = 1.5; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + plotHeight); + this.ctx.stroke(); + + this.ctx.fillStyle = peakColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'bottom'; + this.ctx.fillText(`${freq.toFixed(1)} Hz`, x, padding - 4); + }); + + const recommendedFreq = this.calibration.recommendedShaper.frequency; + if (recommendedFreq > 0) { + const x = padding + (recommendedFreq - bounds.minFreq) * scaleX; + this.ctx.strokeStyle = recommendationColor; + this.ctx.lineWidth = 2; + this.ctx.setLineDash([4, 4]); + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + plotHeight); + this.ctx.stroke(); + this.ctx.setLineDash([]); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText(`Recommended ${recommendedFreq.toFixed(1)} Hz`, x, padding + plotHeight + 6); + } + } + + render(): void { + const { width, height, backgroundColor } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + if (!this.calibration) { + this.renderEmptyState(); + return; + } + + const bounds = this.getPlotBounds(); + this.drawGrid(); + this.drawAxes(bounds); + this.drawSpectrum(bounds); + this.drawMarkers(bounds); + } + + toDataURL(format: 'png' | 'jpeg' = 'png'): string { + return this.canvas.toDataURL(`image/${format}`); + } +} diff --git a/src/webui/static/features/calibration/types.ts b/src/webui/static/features/calibration/types.ts new file mode 100644 index 0000000..0022f9f --- /dev/null +++ b/src/webui/static/features/calibration/types.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview Local calibration type definitions for the WebUI static client. + * + * JSON-shaped mirrors of the calibration types in + * src/shared/types/calibration.ts. The static client compiles with its own + * tsconfig and cannot import @shared modules, so the REST payload shapes are + * duplicated here. Enums are represented as string-literal unions because the + * values arrive as plain JSON strings, and WorkflowData omits the `stages` + * Map (it does not survive JSON serialization; the client only reads the + * scalar/array fields). + */ + +/** Direction of screw rotation for bed leveling adjustments. */ +export type RotationDirection = 'CW' | 'CCW'; + +/** Corner positions on the print bed. */ +export type BedCorner = 'frontLeft' | 'frontRight' | 'rearLeft' | 'rearRight'; + +/** Stages in the calibration workflow. */ +export type WorkflowStage = + | 'initial' + | 'belt_sync' + | 'screw_adjust' + | 'tape_compensate' + | 'thermal_predict' + | 'complete'; + +/** Height values at the four corners of the bed. */ +export interface BedCorners { + frontLeft: number; + frontRight: number; + rearLeft: number; + rearRight: number; +} + +/** Parsed mesh data from printer configuration. */ +export interface MeshData { + matrix: number[][]; + minX: number; + maxX: number; + minY: number; + maxY: number; + pointsX: number; + pointsY: number; + profileName: string; +} + +/** Calculated adjustment for a single bed corner screw. */ +export interface ScrewAdjustment { + corner: BedCorner; + deviation: number; + direction: RotationDirection; + minutes: number; + degrees: number; + turns: number; + formattedAmount: string; + requiresAdjustment: boolean; +} + +/** Tape layer recommendation for a single corner. */ +export interface TapeRecommendation { + corner: BedCorner; + layers: number; + totalThickness: number; + deviation: number; +} + +/** Results from analyzing mesh deviations. */ +export interface AnalysisResult { + meshRange: number; + maxDeviation: number; + minDeviation: number; + averageDeviation: number; + standardDeviation: number; + cornerDeviations: BedCorners; + referenceCorner: BedCorner; + recommendations: { + needsBeltSync: boolean; + needsScrewAdjust: boolean; + needsTapeCompensation: boolean; + }; +} + +/** + * Workflow computation data as it arrives over REST (the desktop type's + * `stages` Map is dropped by JSON serialization and never read here). + */ +export interface WorkflowData { + currentStage: WorkflowStage; + startTime: number; + completedStages: WorkflowStage[]; + screwAdjustments?: ScrewAdjustment[]; + tapeRecommendations?: TapeRecommendation[]; + initialRange: number; + finalRange: number; + improvementPercent: number; +} + +/** Result of evaluating a single input shaper type. */ +export interface ShaperResult { + type: string; + frequency: number; + vibrationReduction: number; + smoothingTime: number; + maxAcceleration: number; + score: number; +} + +/** Input shaper calibration results for a single axis. */ +export interface AxisCalibration { + axis: 'x' | 'y'; + frequencyBins: number[]; + powerSpectralDensity: number[]; + peakFrequencies: number[]; + recommendedShaper: ShaperResult; + allShaperResults: ShaperResult[]; +} + +/** Calibration history entry. */ +export interface CalibrationHistoryEntry { + timestamp: number; + type: 'bed_level' | 'input_shaper'; + summary: string; + data: unknown; +} + +/** Active calibration workspace for a printer context (REST shape). */ +export interface CalibrationWorkspace { + contextId: string; + meshData: MeshData | null; + analysis: AnalysisResult | null; + workflow: WorkflowData | null; +} diff --git a/src/webui/static/features/file-manager.ts b/src/webui/static/features/file-manager.ts new file mode 100644 index 0000000..b7454e1 --- /dev/null +++ b/src/webui/static/features/file-manager.ts @@ -0,0 +1,918 @@ +/** + * @fileoverview SFTP printer file manager feature for the WebUI client. + * + * Browser port of the desktop file manager dialog: a thumbnail grid over the + * printer's internal storage and plugged-in USB drives with multi-select + * batch delete, rename, and USB folder navigation with breadcrumbs. Talks to + * the authenticated /api/file-manager/* REST surface (backed by the + * main-process FileManagerService over SSH/SFTP). + * + * Context pinning: the desktop dialog pins its printer context in the main + * process at window-open time; the WebUI equivalent captures the current + * context id when the modal opens and sends it explicitly with every request, + * so switching the printer selector while the modal is open never retargets + * operations onto a different printer. + * + * Key exports: + * - setupFileManager(): wire the topbar button + modal event handlers + * - refreshFileManagerButton(): show/hide the topbar button per model support + */ + +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { apiRequest } from '../core/Transport.js'; +import { getCurrentContextId } from './context-switching.js'; + +// --------------------------------------------------------------------------- +// API payload types (REST mirror of @shared/types/file-manager.ts) +// --------------------------------------------------------------------------- + +type FileManagerStorageKind = 'internal' | 'usb'; + +interface PrinterFileEntry { + readonly name: string; + readonly path: string; + readonly size: number; + readonly modifiedAt: number; + readonly isDirectory: boolean; +} + +interface FileManagerListing { + readonly success: boolean; + readonly storage: FileManagerStorageKind; + readonly path: string; + readonly rootPath: string; + readonly entries: readonly PrinterFileEntry[]; + readonly error?: string; +} + +interface FileManagerCapabilities { + readonly success?: boolean; + readonly supported: boolean; + readonly reason?: string; + readonly printerName?: string; + readonly usbPresent: boolean; + readonly usbMounts: readonly string[]; + readonly error?: string; +} + +interface FileManagerDeleteResult { + readonly success: boolean; + readonly outcomes: readonly { readonly path: string; readonly success: boolean; readonly error?: string }[]; + readonly error?: string; +} + +interface FileManagerRenameResult { + readonly success: boolean; + readonly newPath?: string; + readonly error?: string; +} + +interface FileManagerThumbnailResult { + readonly success: boolean; + readonly thumbnail?: string; + readonly error?: string; +} + +interface FileManagerSupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// Constants + state +// --------------------------------------------------------------------------- + +/** Lucide icons used by the static markup and dynamically rendered tiles. */ +const FM_ICONS = [ + 'folder', + 'hard-drive', + 'usb', + 'refresh-cw', + 'check-square', + 'square', + 'trash-2', + 'pencil', + 'alert-triangle', + 'file', + 'check', + 'chevron-right', +]; + +/** Maximum concurrent thumbnail requests in flight. */ +const THUMBNAIL_CONCURRENCY = 4; + +interface FileManagerState { + /** Context pinned when the modal opened; null when the modal is closed. */ + pinnedContextId: string | null; + capabilities: FileManagerCapabilities | null; + storage: FileManagerStorageKind; + path: string; + rootPath: string; + entries: readonly PrinterFileEntry[]; + selection: Set; + /** Monotonic token: bumps invalidate in-flight listings + thumbnail runs. */ + runId: number; + busy: boolean; +} + +const fmState: FileManagerState = { + pinnedContextId: null, + capabilities: null, + storage: 'internal', + path: '', + rootPath: '', + entries: [], + selection: new Set(), + runId: 0, + busy: false, +}; + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[FileManager] Missing element #${id}`); + } + return element; +} + +function show(element: HTMLElement): void { + element.classList.remove('hidden'); +} + +function hide(element: HTMLElement): void { + element.classList.add('hidden'); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ''; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes; + let unit = ''; + for (const candidate of units) { + value /= 1024; + unit = candidate; + if (value < 1024) break; + } + return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${unit}`; +} + +function fileExtension(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.slice(idx) : ''; +} + +function baseName(path: string): string { + const idx = path.lastIndexOf('/'); + return idx >= 0 ? path.slice(idx + 1) : path; +} + +/** Build a query string carrying the pinned context id. */ +function fmQuery(params: Record): string { + const search = new URLSearchParams(params); + if (fmState.pinnedContextId) { + search.set('contextId', fmState.pinnedContextId); + } + return search.toString(); +} + +/** JSON body carrying the pinned context id. */ +function fmBody(payload: Record): string { + return JSON.stringify( + fmState.pinnedContextId ? { ...payload, contextId: fmState.pinnedContextId } : payload + ); +} + +// --------------------------------------------------------------------------- +// Topbar button gating +// --------------------------------------------------------------------------- + +/** + * Show or hide the topbar file manager button based on the active printer's + * model (cheap model-only probe; no SSH session is opened). + */ +export async function refreshFileManagerButton(): Promise { + const button = document.getElementById('file-manager-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/file-manager/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +// --------------------------------------------------------------------------- +// Thumbnail queue (limited concurrency, cancellable via runId) +// --------------------------------------------------------------------------- + +interface ThumbnailJob { + readonly entry: PrinterFileEntry; + readonly runId: number; +} + +const thumbnailQueue: ThumbnailJob[] = []; +let activeThumbnailRequests = 0; + +function enqueueThumbnail(entry: PrinterFileEntry): void { + thumbnailQueue.push({ entry, runId: fmState.runId }); + pumpThumbnailQueue(); +} + +function pumpThumbnailQueue(): void { + while (activeThumbnailRequests < THUMBNAIL_CONCURRENCY && thumbnailQueue.length > 0) { + const job = thumbnailQueue.shift(); + if (!job || job.runId !== fmState.runId) { + continue; + } + activeThumbnailRequests++; + void fetchThumbnail(job).finally(() => { + activeThumbnailRequests--; + pumpThumbnailQueue(); + }); + } +} + +async function fetchThumbnail(job: ThumbnailJob): Promise { + try { + const query = fmQuery({ storage: fmState.storage, path: job.entry.path }); + const result = await apiRequest(`/api/file-manager/thumbnail?${query}`); + if (job.runId !== fmState.runId) { + return; + } + applyThumbnail(job.entry.path, result.success && result.thumbnail ? result.thumbnail : null); + } catch { + if (job.runId === fmState.runId) { + applyThumbnail(job.entry.path, null); + } + } +} + +function applyThumbnail(path: string, base64: string | null): void { + const tile = document.querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`); + const thumbnail = tile?.querySelector('.fm-thumbnail'); + if (!thumbnail) { + return; + } + + if (base64) { + thumbnail.innerHTML = ''; + const img = document.createElement('img'); + img.src = `data:image/png;base64,${base64}`; + img.alt = baseName(path); + thumbnail.appendChild(img); + } else { + thumbnail.innerHTML = ''; + hydrateLucideIcons(['file'], thumbnail as Element); + } +} + +// --------------------------------------------------------------------------- +// Views: loading / message / grid +// --------------------------------------------------------------------------- + +function showLoading(text: string): void { + const loading = el('fm-loading'); + const textElement = loading.querySelector('.fm-state-text'); + if (textElement) { + textElement.textContent = text; + } + show(loading); + hide(el('fm-message')); + hide(el('fm-grid')); +} + +function showMessage(text: string, allowRetry: boolean = true): void { + el('fm-message-text').textContent = text; + el('fm-retry').classList.toggle('hidden', !allowRetry); + show(el('fm-message')); + hide(el('fm-loading')); + hide(el('fm-grid')); +} + +function showGrid(): void { + show(el('fm-grid')); + hide(el('fm-loading')); + hide(el('fm-message')); +} + +// --------------------------------------------------------------------------- +// Capabilities + tabs +// --------------------------------------------------------------------------- + +async function loadCapabilities(): Promise { + fmState.runId++; + showLoading('Connecting to printer…'); + updateToolbar(); + + let capabilities: FileManagerCapabilities; + try { + capabilities = await apiRequest(`/api/file-manager/capabilities?${fmQuery({})}`); + } catch (error) { + showMessage(`Failed to reach the printer: ${error instanceof Error ? error.message : String(error)}`); + return; + } + + fmState.capabilities = capabilities; + + const title = el('file-manager-title'); + title.textContent = capabilities.printerName ? `File Manager — ${capabilities.printerName}` : 'File Manager'; + + if (!capabilities.supported) { + hide(el('fm-tab-usb')); + showMessage(capabilities.reason || capabilities.error || 'File management is not supported for this printer.', false); + return; + } + + if (capabilities.error) { + showMessage(capabilities.error); + return; + } + + el('fm-tab-usb').classList.toggle('hidden', !capabilities.usbPresent); + + // If the current tab is USB but the drive vanished, fall back to internal + if (fmState.storage === 'usb' && !capabilities.usbPresent) { + fmState.storage = 'internal'; + } + + updateTabSelection(); + await loadListing(fmState.storage, fmState.storage === 'usb' ? fmState.path : ''); +} + +function updateTabSelection(): void { + el('fm-tab-internal').classList.toggle('active', fmState.storage === 'internal'); + el('fm-tab-usb').classList.toggle('active', fmState.storage === 'usb'); +} + +async function switchStorage(storage: FileManagerStorageKind): Promise { + if (fmState.busy || fmState.storage === storage) { + return; + } + fmState.storage = storage; + fmState.path = ''; + updateTabSelection(); + await loadListing(storage, ''); +} + +// --------------------------------------------------------------------------- +// Listing +// --------------------------------------------------------------------------- + +async function loadListing(storage: FileManagerStorageKind, path: string): Promise { + const runId = ++fmState.runId; + fmState.selection.clear(); + showLoading('Loading files…'); + updateToolbar(); + + let listing: FileManagerListing; + try { + listing = await apiRequest(`/api/file-manager/files?${fmQuery({ storage, path })}`); + } catch (error) { + if (runId === fmState.runId) { + showMessage(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); + } + return; + } + + if (runId !== fmState.runId) { + return; // A newer navigation superseded this request + } + + if (!listing.success) { + showMessage(listing.error || 'Failed to list files'); + return; + } + + fmState.entries = listing.entries; + fmState.path = listing.path; + fmState.rootPath = listing.rootPath; + + renderBreadcrumb(); + renderGrid(); + updateToolbar(); + updateSummary(); +} + +function renderGrid(): void { + const grid = el('fm-grid'); + grid.innerHTML = ''; + + if (fmState.entries.length === 0) { + showMessage('No files found', true); + return; + } + + const fragment = document.createDocumentFragment(); + for (const entry of fmState.entries) { + fragment.appendChild(entry.isDirectory ? createDirectoryTile(entry) : createFileTile(entry)); + } + grid.appendChild(fragment); + hydrateLucideIcons(FM_ICONS, grid); + showGrid(); + + // Queue thumbnails for files after the grid is visible + for (const entry of fmState.entries) { + if (!entry.isDirectory) { + enqueueThumbnail(entry); + } + } +} + +function createFileTile(entry: PrinterFileEntry): HTMLElement { + const tile = document.createElement('div'); + tile.className = 'fm-item'; + tile.dataset.path = entry.path; + + const check = document.createElement('button'); + check.className = 'fm-item-check'; + check.title = 'Select file'; + check.innerHTML = ''; + check.addEventListener('click', (event) => { + event.stopPropagation(); + toggleSelection(entry.path); + }); + + const controls = document.createElement('div'); + controls.className = 'fm-item-controls'; + + const renameBtn = document.createElement('button'); + renameBtn.className = 'fm-item-btn'; + renameBtn.title = 'Rename'; + renameBtn.innerHTML = ''; + renameBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openRenameOverlay(entry); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'fm-item-btn delete'; + deleteBtn.title = 'Delete'; + deleteBtn.innerHTML = ''; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openDeleteConfirmation([entry.path]); + }); + + controls.appendChild(renameBtn); + controls.appendChild(deleteBtn); + + const thumbnail = document.createElement('div'); + thumbnail.className = 'fm-thumbnail'; + thumbnail.innerHTML = '
'; + + const name = document.createElement('div'); + name.className = 'fm-name'; + name.textContent = entry.name; + name.title = entry.name; + + const size = document.createElement('div'); + size.className = 'fm-size'; + size.textContent = formatBytes(entry.size); + if (entry.modifiedAt > 1_000_000_000) { + size.title = new Date(entry.modifiedAt * 1000).toLocaleString(); + } + + tile.appendChild(check); + tile.appendChild(controls); + tile.appendChild(thumbnail); + tile.appendChild(name); + tile.appendChild(size); + + tile.addEventListener('click', () => toggleSelection(entry.path)); + + return tile; +} + +function createDirectoryTile(entry: PrinterFileEntry): HTMLElement { + const tile = document.createElement('div'); + tile.className = 'fm-item directory'; + tile.dataset.path = entry.path; + + const thumbnail = document.createElement('div'); + thumbnail.className = 'fm-thumbnail'; + thumbnail.innerHTML = ''; + + const name = document.createElement('div'); + name.className = 'fm-name'; + name.textContent = entry.name; + name.title = entry.name; + + const size = document.createElement('div'); + size.className = 'fm-size'; + size.textContent = 'Folder'; + + tile.appendChild(thumbnail); + tile.appendChild(name); + tile.appendChild(size); + + tile.addEventListener('click', () => { + void loadListing(fmState.storage, entry.path); + }); + + return tile; +} + +// --------------------------------------------------------------------------- +// Breadcrumb (USB navigation) +// --------------------------------------------------------------------------- + +interface Crumb { + readonly label: string; + readonly path: string; +} + +function buildCrumbs(): Crumb[] { + const crumbs: Crumb[] = [{ label: 'USB', path: '' }]; + if (!fmState.path) { + return crumbs; + } + + const mounts = fmState.capabilities?.usbMounts ?? []; + const mount = mounts.find( + (candidate) => fmState.path === candidate || fmState.path.startsWith(`${candidate}/`) + ); + if (!mount) { + return crumbs; + } + + // With multiple drives the mount itself is a navigable crumb; with a single + // drive the 'USB' crumb already points at the mount root. + if (mounts.length > 1) { + crumbs.push({ label: baseName(mount) || mount, path: mount }); + } + + const relative = fmState.path.slice(mount.length).replace(/^\//, ''); + if (relative) { + let accumulated = mount; + for (const segment of relative.split('/')) { + accumulated = `${accumulated}/${segment}`; + crumbs.push({ label: segment, path: accumulated }); + } + } + + return crumbs; +} + +function renderBreadcrumb(): void { + const breadcrumb = el('fm-breadcrumb'); + + if (fmState.storage !== 'usb') { + hide(breadcrumb); + breadcrumb.innerHTML = ''; + return; + } + + const crumbs = buildCrumbs(); + breadcrumb.innerHTML = ''; + + crumbs.forEach((crumb, index) => { + const isLast = index === crumbs.length - 1; + if (index > 0) { + const separator = document.createElement('i'); + separator.setAttribute('data-lucide', 'chevron-right'); + separator.setAttribute('aria-hidden', 'true'); + breadcrumb.appendChild(separator); + } + + if (isLast) { + const current = document.createElement('span'); + current.className = 'crumb-current'; + current.textContent = crumb.label; + breadcrumb.appendChild(current); + } else { + const link = document.createElement('button'); + link.textContent = crumb.label; + link.addEventListener('click', () => { + void loadListing('usb', crumb.path); + }); + breadcrumb.appendChild(link); + } + }); + + hydrateLucideIcons(['chevron-right'], breadcrumb); + show(breadcrumb); +} + +// --------------------------------------------------------------------------- +// Selection + toolbar +// --------------------------------------------------------------------------- + +function toggleSelection(path: string): void { + if (fmState.selection.has(path)) { + fmState.selection.delete(path); + } else { + fmState.selection.add(path); + } + + const tile = document.querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`); + tile?.classList.toggle('selected', fmState.selection.has(path)); + + updateToolbar(); + updateSummary(); +} + +function clearSelection(): void { + fmState.selection.clear(); + document.querySelectorAll('#fm-grid .fm-item.selected').forEach((tile) => { + tile.classList.remove('selected'); + }); + updateToolbar(); + updateSummary(); +} + +function selectAllFiles(): void { + for (const entry of fmState.entries) { + if (!entry.isDirectory) { + fmState.selection.add(entry.path); + } + } + document.querySelectorAll('#fm-grid .fm-item:not(.directory)').forEach((tile) => { + tile.classList.add('selected'); + }); + updateToolbar(); + updateSummary(); +} + +function updateToolbar(): void { + const fileCount = fmState.entries.filter((entry) => !entry.isDirectory).length; + const selectedCount = fmState.selection.size; + + el('fm-select-all').classList.toggle('hidden', fileCount === 0 || selectedCount >= fileCount); + el('fm-clear-selection').classList.toggle('hidden', selectedCount === 0); + el('fm-delete-selected').classList.toggle('hidden', selectedCount === 0); + el('fm-delete-selected-label').textContent = + selectedCount > 0 ? `Delete Selected (${selectedCount})` : 'Delete Selected'; +} + +function updateSummary(extra?: string): void { + const files = fmState.entries.filter((entry) => !entry.isDirectory); + const totalSize = files.reduce((sum, entry) => sum + entry.size, 0); + const parts: string[] = []; + + if (files.length > 0) { + parts.push(`${files.length} file${files.length === 1 ? '' : 's'}`); + parts.push(formatBytes(totalSize)); + } + if (fmState.selection.size > 0) { + parts.push(`${fmState.selection.size} selected`); + } + if (extra) { + parts.push(extra); + } + + el('fm-summary').textContent = parts.join(' • '); +} + +// --------------------------------------------------------------------------- +// Delete flow +// --------------------------------------------------------------------------- + +let pendingDeletePaths: string[] = []; + +function openDeleteConfirmation(paths: string[]): void { + if (paths.length === 0) { + return; + } + pendingDeletePaths = paths; + + el('fm-confirm-title').textContent = paths.length === 1 ? 'Delete file?' : `Delete ${paths.length} files?`; + + const body = el('fm-confirm-body'); + body.innerHTML = ''; + const intro = document.createElement('div'); + intro.textContent = + paths.length === 1 + ? 'This will permanently delete the file from the printer:' + : 'This will permanently delete these files from the printer:'; + body.appendChild(intro); + + const list = document.createElement('ul'); + const shown = paths.slice(0, 8); + for (const path of shown) { + const item = document.createElement('li'); + item.textContent = baseName(path); + list.appendChild(item); + } + if (paths.length > shown.length) { + const item = document.createElement('li'); + item.textContent = `…and ${paths.length - shown.length} more`; + list.appendChild(item); + } + body.appendChild(list); + + show(el('fm-confirm-overlay')); +} + +function closeDeleteConfirmation(): void { + pendingDeletePaths = []; + hide(el('fm-confirm-overlay')); +} + +async function executeDelete(): Promise { + const paths = pendingDeletePaths; + closeDeleteConfirmation(); + if (paths.length === 0 || fmState.busy) { + return; + } + + fmState.busy = true; + for (const path of paths) { + document + .querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`) + ?.classList.add('pending'); + } + updateSummary('Deleting…'); + + try { + const result = await apiRequest('/api/file-manager/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fmBody({ storage: fmState.storage, paths }), + }); + const failures = (result.outcomes ?? []).filter((outcome) => !outcome.success); + if (result.error) { + showMessage(`Delete failed: ${result.error}`); + return; + } + + await loadListing(fmState.storage, fmState.path); + + if (failures.length > 0) { + const first = failures[0]; + updateSummary( + `Failed to delete ${failures.length} file${failures.length === 1 ? '' : 's'} (${first.error || 'unknown error'})` + ); + } + } catch (error) { + showMessage(`Delete failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + fmState.busy = false; + // Clear the per-tile "pending" visual state on every exit path (the + // success path rebuilds the grid, but error paths return early). + for (const path of paths) { + document + .querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`) + ?.classList.remove('pending'); + } + } +} + +// --------------------------------------------------------------------------- +// Rename flow +// --------------------------------------------------------------------------- + +let renameTarget: PrinterFileEntry | null = null; + +function openRenameOverlay(entry: PrinterFileEntry): void { + renameTarget = entry; + + const extension = fileExtension(entry.name); + const stem = extension ? entry.name.slice(0, -extension.length) : entry.name; + + const input = el('fm-rename-input') as HTMLInputElement; + input.value = stem; + el('fm-rename-ext').textContent = extension; + hide(el('fm-rename-error')); + + show(el('fm-rename-overlay')); + input.focus(); + input.select(); +} + +function closeRenameOverlay(): void { + renameTarget = null; + hide(el('fm-rename-overlay')); +} + +function showRenameError(message: string): void { + const errorElement = el('fm-rename-error'); + errorElement.textContent = message; + show(errorElement); +} + +async function executeRename(): Promise { + const entry = renameTarget; + if (!entry || fmState.busy) { + return; + } + + const input = el('fm-rename-input') as HTMLInputElement; + const extension = fileExtension(entry.name); + const stem = input.value.trim(); + + if (!stem) { + showRenameError('Enter a file name'); + return; + } + if (stem.includes('/') || stem.includes('\\')) { + showRenameError('File names cannot contain slashes'); + return; + } + + const newName = `${stem}${extension}`; + if (newName === entry.name) { + closeRenameOverlay(); + return; + } + + fmState.busy = true; + try { + const result = await apiRequest('/api/file-manager/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fmBody({ storage: fmState.storage, path: entry.path, newName }), + }); + if (!result.success) { + showRenameError(result.error || 'Rename failed'); + return; + } + closeRenameOverlay(); + await loadListing(fmState.storage, fmState.path); + } catch (error) { + showRenameError(error instanceof Error ? error.message : String(error)); + } finally { + fmState.busy = false; + } +} + +// --------------------------------------------------------------------------- +// Modal open/close + wiring +// --------------------------------------------------------------------------- + +function openFileManager(): void { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + // Pin the context for the modal's lifetime (survives printer-selector + // switches while open; released on close). + fmState.pinnedContextId = contextId; + fmState.storage = 'internal'; + fmState.path = ''; + fmState.selection.clear(); + + show(el('file-manager-modal')); + hydrateLucideIcons(FM_ICONS, el('file-manager-modal')); + void loadCapabilities(); +} + +function closeFileManager(): void { + fmState.pinnedContextId = null; + fmState.runId++; // Cancel in-flight listings/thumbnails + hide(el('file-manager-modal')); +} + +/** Wire the topbar button and every modal control. Call once at startup. */ +export function setupFileManager(): void { + document.getElementById('file-manager-button')?.addEventListener('click', openFileManager); + + el('fm-close').addEventListener('click', closeFileManager); + el('fm-close-footer').addEventListener('click', closeFileManager); + + el('fm-tab-internal').addEventListener('click', () => void switchStorage('internal')); + el('fm-tab-usb').addEventListener('click', () => void switchStorage('usb')); + + el('fm-refresh').addEventListener('click', () => void loadCapabilities()); + el('fm-retry').addEventListener('click', () => void loadCapabilities()); + + el('fm-select-all').addEventListener('click', selectAllFiles); + el('fm-clear-selection').addEventListener('click', clearSelection); + el('fm-delete-selected').addEventListener('click', () => { + openDeleteConfirmation(Array.from(fmState.selection)); + }); + + el('fm-confirm-cancel').addEventListener('click', closeDeleteConfirmation); + el('fm-confirm-delete').addEventListener('click', () => void executeDelete()); + + el('fm-rename-cancel').addEventListener('click', closeRenameOverlay); + el('fm-rename-confirm').addEventListener('click', () => void executeRename()); + (el('fm-rename-input') as HTMLInputElement).addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + void executeRename(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || el('file-manager-modal').classList.contains('hidden')) { + return; + } + if (!el('fm-rename-overlay').classList.contains('hidden')) { + closeRenameOverlay(); + } else if (!el('fm-confirm-overlay').classList.contains('hidden')) { + closeDeleteConfirmation(); + } else { + closeFileManager(); + } + }); +} diff --git a/src/webui/static/features/reboot.ts b/src/webui/static/features/reboot.ts new file mode 100644 index 0000000..b013503 --- /dev/null +++ b/src/webui/static/features/reboot.ts @@ -0,0 +1,225 @@ +/** + * @fileoverview Remote "Reboot Printer" feature for the WebUI client. + * + * Browser port of the desktop reboot flow: a topbar button (shown only for + * SSH-capable models: Adventurer 5M / 5M Pro / AD5X) opens a confirmation + * modal, then POSTs /api/printer/reboot. Reboot lifecycle updates arrive over + * REBOOT_STATUS WebSocket broadcasts and drive a full-screen overlay through + * the phases rebooting -> reconnecting -> reconnecting-services -> success + * (or terminal timeout / failed with a Retry option). + * + * Key exports: + * - setupReboot(): wire the topbar button, modal, overlay, and WS listener + * - refreshRebootButton(): show/hide the topbar button per model support + */ + +import type { RebootStatusPayload } from '../app.js'; +import { apiRequest, onRebootStatus } from '../core/Transport.js'; +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { getCurrentContextId } from './context-switching.js'; + +interface RebootSupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly printerName?: string; + readonly error?: string; +} + +interface RebootResponse { + readonly success: boolean; + readonly error?: string; +} + +/** Context of the reboot this client initiated; null when idle. */ +let rebootContextId: string | null = null; + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[Reboot] Missing element #${id}`); + } + return element; +} + +function show(element: HTMLElement): void { + element.classList.remove('hidden'); +} + +function hide(element: HTMLElement): void { + element.classList.add('hidden'); +} + +/** + * Show or hide the topbar reboot button based on the active printer's model. + */ +export async function refreshRebootButton(): Promise { + const button = document.getElementById('reboot-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/printer/reboot/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +// --------------------------------------------------------------------------- +// Overlay phases +// --------------------------------------------------------------------------- + +function setOverlay(options: { + title: string; + message: string; + spinning: boolean; + result?: 'success' | 'error'; + showRetry?: boolean; + showDismiss?: boolean; +}): void { + el('reboot-overlay-title').textContent = options.title; + el('reboot-overlay-message').textContent = options.message; + + el('reboot-spinner').classList.toggle('hidden', !options.spinning); + el('reboot-success-icon').classList.toggle('hidden', options.result !== 'success'); + el('reboot-error-icon').classList.toggle('hidden', options.result !== 'error'); + el('reboot-overlay-retry').classList.toggle('hidden', !options.showRetry); + el('reboot-overlay-dismiss').classList.toggle('hidden', !options.showDismiss); + + hydrateLucideIcons(['check-circle', 'alert-triangle'], el('reboot-overlay')); + show(el('reboot-overlay')); +} + +function closeOverlay(): void { + rebootContextId = null; + hide(el('reboot-overlay')); +} + +function handleRebootStatus(contextId: string, payload: RebootStatusPayload): void { + // Only surface updates for the reboot this client is watching. (A reboot + // triggered from the desktop is intentionally ignored here to avoid taking + // over every connected browser; parity with the desktop overlay, which is + // likewise scoped to its own window.) + if (!rebootContextId || contextId !== rebootContextId) { + return; + } + + const printerName = payload.printerName || 'Printer'; + + switch (payload.phase) { + case 'rebooting': + setOverlay({ + title: `Rebooting ${printerName}…`, + message: payload.message || 'Sending reboot command over SSH.', + spinning: true, + }); + break; + case 'reconnecting': + setOverlay({ + title: 'Waiting for printer…', + message: payload.message || `Waiting for ${printerName} to come back online.`, + spinning: true, + }); + break; + case 'reconnecting-services': + setOverlay({ + title: 'Reconnecting services…', + message: payload.message || 'Printer is back; waiting for services to stabilize.', + spinning: true, + }); + break; + case 'success': + setOverlay({ + title: 'Reboot complete', + message: payload.message || `${printerName} is back online.`, + spinning: false, + result: 'success', + showDismiss: true, + }); + break; + case 'timeout': + setOverlay({ + title: 'Reboot timed out', + message: + payload.message || `${printerName} didn't come back online. Check that it's powered on and reachable.`, + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + break; + case 'failed': + setOverlay({ + title: 'Reboot failed', + message: payload.message || 'The printer was disconnected during the reboot.', + spinning: false, + result: 'error', + showDismiss: true, + }); + break; + } +} + +// --------------------------------------------------------------------------- +// Trigger flow +// --------------------------------------------------------------------------- + +async function executeReboot(): Promise { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + hide(el('reboot-confirm-modal')); + rebootContextId = contextId; + setOverlay({ + title: 'Rebooting printer…', + message: 'Dispatching reboot command over SSH.', + spinning: true, + }); + + try { + const result = await apiRequest('/api/printer/reboot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contextId }), + }); + if (!result.success) { + setOverlay({ + title: 'Reboot failed', + message: result.error || 'The reboot could not be dispatched.', + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + } + } catch (error) { + setOverlay({ + title: 'Reboot failed', + message: error instanceof Error ? error.message : 'The reboot could not be dispatched.', + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + } +} + +/** Wire the topbar button, confirmation modal, overlay, and WS listener. */ +export function setupReboot(): void { + document.getElementById('reboot-button')?.addEventListener('click', () => { + show(el('reboot-confirm-modal')); + }); + + el('reboot-confirm-close').addEventListener('click', () => hide(el('reboot-confirm-modal'))); + el('reboot-cancel').addEventListener('click', () => hide(el('reboot-confirm-modal'))); + el('reboot-confirm').addEventListener('click', () => void executeReboot()); + + el('reboot-overlay-dismiss').addEventListener('click', closeOverlay); + el('reboot-overlay-retry').addEventListener('click', () => void executeReboot()); + + onRebootStatus(handleRebootStatus); +} diff --git a/src/webui/static/features/ssh.ts b/src/webui/static/features/ssh.ts new file mode 100644 index 0000000..dc69421 --- /dev/null +++ b/src/webui/static/features/ssh.ts @@ -0,0 +1,177 @@ +/** + * @fileoverview Per-printer SSH settings section for the WebUI settings modal. + * + * Mirrors the desktop Settings -> SSH tab against the /api/ssh-settings REST + * surface. Settings apply to the ACTIVE printer context and are stored by + * serial number, so they follow the printer across IP changes. + * + * Passwords are write-only over the WebUI: the server never returns the + * stored value. The password field stays blank on load; a placeholder hints + * whether a custom password is set. Typing a new value overwrites it, + * clearing the field (submitting an empty string) resets it to the easy-SSH + * default, and leaving it untouched keeps the current one. + * + * Key exports: + * - setupSSHSettings(): wire the save/reset buttons + * - refreshSSHSettings(): (re)load the section for the active printer + */ + +import { showToast } from '../shared/dom.js'; +import { apiRequest } from '../core/Transport.js'; + +interface WebUISSHSettings { + readonly username: string; + readonly port: number; + readonly keyPath?: string; + readonly isCustom: boolean; + readonly passwordIsCustom: boolean; +} + +interface SSHSettingsGetResponse { + readonly success: boolean; + readonly settings?: WebUISSHSettings; + readonly printerName?: string; + readonly error?: string; +} + +interface SSHSettingsSaveResponse { + readonly success: boolean; + readonly error?: string; +} + +const DEFAULT_HINT = 'Using the easy-SSH default password (flashforge). Leave blank to keep it.'; +const CUSTOM_HINT = 'A custom password is saved (not shown). Leave blank to keep it, or type a new one to replace it. Clear and save resets to the default.'; + +/** Tracks whether the password field was intentionally edited this session. */ +let passwordDirty = false; + +function input(id: string): HTMLInputElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLInputElement)) { + throw new Error(`[SSHSettings] Missing input #${id}`); + } + return element; +} + +function setUnavailable(unavailable: boolean, reason?: string): void { + const form = document.getElementById('ssh-settings-form'); + const message = document.getElementById('ssh-settings-unavailable'); + const buttons = [document.getElementById('ssh-save-btn'), document.getElementById('ssh-reset-btn')]; + + form?.classList.toggle('hidden', unavailable); + if (message) { + message.classList.toggle('hidden', !unavailable); + message.textContent = reason || 'No printer is connected.'; + } + buttons.forEach((button) => { + if (button instanceof HTMLButtonElement) { + button.disabled = unavailable; + } + }); +} + +/** + * (Re)load the SSH section for the active printer. Call when the settings + * modal opens and after context switches. + */ +export async function refreshSSHSettings(): Promise { + passwordDirty = false; + + let response: SSHSettingsGetResponse; + try { + response = await apiRequest('/api/ssh-settings'); + } catch { + setUnavailable(true, 'Failed to load SSH settings.'); + return; + } + + if (!response.success || !response.settings) { + setUnavailable(true, response.error || 'No printer is connected.'); + return; + } + + setUnavailable(false); + + const settings = response.settings; + input('ssh-username').value = settings.username; + input('ssh-port').value = String(settings.port); + input('ssh-keypath').value = settings.keyPath ?? ''; + + const password = input('ssh-password'); + password.value = ''; + password.placeholder = settings.passwordIsCustom ? '•••••••• (custom saved)' : 'flashforge (default)'; + + const hint = document.getElementById('ssh-password-hint'); + if (hint) { + hint.textContent = settings.passwordIsCustom ? CUSTOM_HINT : DEFAULT_HINT; + } + + const heading = document.querySelector('#ssh-settings-section h3'); + if (heading) { + heading.textContent = response.printerName ? `SSH — ${response.printerName}` : 'SSH (Current Printer)'; + } +} + +async function saveSSHSettings(): Promise { + const port = Number(input('ssh-port').value); + const payload: Record = { + username: input('ssh-username').value, + keyPath: input('ssh-keypath').value, + }; + if (Number.isInteger(port) && port > 0) { + payload.port = port; + } else if (input('ssh-port').value.trim() === '') { + payload.port = 0; // 0 = reset to default on the server + } else { + showToast('Port must be a number between 1 and 65535', 'error'); + return; + } + + // Write-only password: only send it when the user actually touched the field. + if (passwordDirty) { + payload.password = input('ssh-password').value; + } + + try { + const result = await apiRequest('/api/ssh-settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!result.success) { + showToast(result.error || 'Failed to save SSH settings', 'error'); + return; + } + showToast('SSH settings saved', 'success'); + await refreshSSHSettings(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to save SSH settings', 'error'); + } +} + +async function resetSSHSettings(): Promise { + try { + const result = await apiRequest('/api/ssh-settings/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!result.success) { + showToast(result.error || 'Failed to reset SSH settings', 'error'); + return; + } + showToast('SSH settings reset to defaults', 'success'); + await refreshSSHSettings(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to reset SSH settings', 'error'); + } +} + +/** Wire the SSH section buttons. Call once at startup. */ +export function setupSSHSettings(): void { + input('ssh-password').addEventListener('input', () => { + passwordDirty = true; + }); + document.getElementById('ssh-save-btn')?.addEventListener('click', () => void saveSSHSettings()); + document.getElementById('ssh-reset-btn')?.addEventListener('click', () => void resetSSHSettings()); +} diff --git a/src/webui/static/index.html b/src/webui/static/index.html index 48bcf67..4a55464 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -1,4 +1,4 @@ - + @@ -48,6 +48,34 @@

FlashForge Web UI

+ + +
+
+

SSH (Current Printer)

+

+ Credentials used by the file manager, calibration assistant, and reboot. + Defaults match the FlashForge-EasySSH provisioning tool. +

+ +
+ + + + +
+

+
+ + +
+

Reset

@@ -249,7 +308,7 @@

Set Temperature

+ + + + + + + + + + + + diff --git a/src/webui/static/shared/icons.ts b/src/webui/static/shared/icons.ts index ec18d95..82bf8be 100644 --- a/src/webui/static/shared/icons.ts +++ b/src/webui/static/shared/icons.ts @@ -1,4 +1,4 @@ -/** +/** * @fileoverview Lucide icon utilities for the WebUI static client. * * Handles converting icon names to PascalCase, hydrating Lucide icons inside @@ -75,5 +75,5 @@ export function hydrateLucideIcons( } export function initializeLucideIcons(): void { - hydrateLucideIcons(['settings', 'lock', 'package', 'search', 'circle'], document); + hydrateLucideIcons(['settings', 'lock', 'package', 'search', 'circle', 'folder', 'gauge', 'power'], document); } diff --git a/src/webui/static/webui.css b/src/webui/static/webui.css index c7e5fd6..59249d0 100644 --- a/src/webui/static/webui.css +++ b/src/webui/static/webui.css @@ -1,4 +1,4 @@ -/** +/** * Web UI Stylesheet * Theme colors are set dynamically via JavaScript from --theme-* CSS variables */ @@ -384,7 +384,7 @@ body { } .grid-stack.edit-mode .panel-header::before { - content: "⋮⋮"; + content: "⋮⋮"; margin-right: 8px; opacity: 0.5; font-weight: 400; @@ -2126,3 +2126,1371 @@ body { .grid-item-hidden { display: none; } + + +/* ============================================================================ + SSH FEATURES (file manager / calibration / reboot) - ported from FlashForgeUI + ============================================================================ */ + +:root { + --primary-alpha-10: color-mix(in srgb, var(--theme-primary) 10%, transparent); + --primary-alpha-15: color-mix(in srgb, var(--theme-primary) 15%, transparent); + --primary-alpha-20: color-mix(in srgb, var(--theme-primary) 20%, transparent); + --primary-alpha-35: color-mix(in srgb, var(--theme-primary) 35%, transparent); + --primary-alpha-40: color-mix(in srgb, var(--theme-primary) 40%, transparent); + --primary-alpha-60: color-mix(in srgb, var(--theme-primary) 60%, transparent); + --overlay-bg: rgba(0, 0, 0, 0.6); + --surface-muted: color-mix(in srgb, var(--theme-text) 10%, var(--theme-surface)); + --text-on-primary: #ffffff; +} +/* FILE MANAGER (SFTP printer files) */ + +.fm-modal-content { + position: relative; + display: flex; + flex-direction: column; + height: min(720px, 90vh); +} + +.fm-modal-body { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.fm-tabs { + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; + flex-shrink: 0; +} + +.fm-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 0.95rem; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-tab:hover { + background: var(--card-bg-hover); + color: var(--text-color); +} + +.fm-tab.active { + background: var(--primary-alpha-20); + border-color: var(--theme-primary); + color: var(--text-color); +} + +.fm-tab .lucide-icon { + width: 16px; + height: 16px; +} + +.fm-tabs-spacer { + flex: 1; +} + +.fm-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + flex-shrink: 0; + min-height: 48px; +} + +.fm-toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.fm-toolbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.85rem; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-toolbar-btn:hover:not(:disabled) { + background: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-toolbar-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.fm-toolbar-btn .lucide-icon { + width: 15px; + height: 15px; +} + +.fm-toolbar-btn.danger { + background: color-mix(in srgb, var(--error-color) 18%, var(--theme-surface)); + border-color: color-mix(in srgb, var(--error-color) 60%, var(--border-color)); +} + +.fm-toolbar-btn.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--error-color) 32%, var(--theme-surface)); +} + +.fm-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--text-color-secondary); + min-width: 0; +} + +.fm-breadcrumb button { + background: none; + border: none; + color: var(--theme-primary); + cursor: pointer; + font-size: 0.85rem; + padding: 4px 6px; + border-radius: 4px; +} + +.fm-breadcrumb button:hover { + background: var(--card-bg-hover); +} + +.fm-breadcrumb .crumb-current { + padding: 4px 6px; + color: var(--text-color); + font-weight: 500; +} + +.fm-breadcrumb .lucide-icon { + width: 13px; + height: 13px; + opacity: 0.6; +} + +.fm-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 12px; +} + +.fm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 14px; + padding: 4px 4px 12px 4px; + overflow-y: auto; + flex: 1; + align-content: start; +} + +.fm-item { + background-color: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 10px 10px 10px; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-item:hover { + background-color: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-item.selected { + background-color: var(--primary-alpha-15); + border-color: var(--theme-primary); +} + +.fm-item.directory { + cursor: pointer; +} + +.fm-item.pending { + opacity: 0.45; + pointer-events: none; +} + +.fm-item-controls { + position: absolute; + top: 6px; + right: 6px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: 5; +} + +.fm-item:hover .fm-item-controls, +.fm-item.selected .fm-item-controls { + opacity: 1; +} + +.fm-item-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--theme-background) 80%, transparent); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); + padding: 0; +} + +.fm-item-btn:hover { + background: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-item-btn.delete:hover { + background: color-mix(in srgb, var(--error-color) 30%, var(--theme-surface)); + border-color: var(--error-color); +} + +.fm-item-btn .lucide-icon { + width: 14px; + height: 14px; +} + +.fm-item-check { + position: absolute; + top: 6px; + left: 6px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--theme-background) 80%, transparent); + border: 1px solid var(--border-color); + border-radius: 6px; + color: transparent; + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-fast), background-color var(--transition-fast); + z-index: 5; + padding: 0; +} + +.fm-item:hover .fm-item-check, +.fm-item.selected .fm-item-check { + opacity: 1; +} + +.fm-item.selected .fm-item-check { + background: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.fm-item-check .lucide-icon { + width: 13px; + height: 13px; +} + +.fm-thumbnail { + width: 110px; + height: 110px; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--surface-muted); + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.fm-thumbnail img { + max-width: 100%; + max-height: 100%; +} + +.fm-thumbnail .lucide-icon { + width: 42px; + height: 42px; + opacity: 0.35; +} + +.fm-thumbnail .thumb-spinner { + width: 22px; + height: 22px; + border: 2px solid color-mix(in srgb, var(--text-color) 20%, transparent); + border-top-color: var(--theme-primary); + border-radius: 50%; + animation: fm-spin 0.9s linear infinite; +} + +.fm-name { + font-size: 0.82rem; + color: var(--text-color); + text-align: center; + word-break: break-word; + line-height: 1.3; + max-height: 2.6em; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.fm-size { + font-size: 0.72rem; + color: var(--text-color-muted); + margin-top: 4px; +} + +.fm-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 24px; + text-align: center; +} + +.fm-state-text { + color: var(--text-color-secondary); + font-size: 0.95rem; + max-width: 480px; + line-height: 1.5; +} + +.fm-state-icon.lucide-icon, +.fm-state .fm-state-icon { + width: 40px; + height: 40px; + color: var(--text-color-muted); +} + +.fm-state .loading-spinner, +.reboot-overlay-panel .loading-spinner { + width: 36px; + height: 36px; + border: 3px solid color-mix(in srgb, var(--text-color) 15%, transparent); + border-top-color: var(--theme-primary); + border-radius: 50%; + animation: fm-spin 0.9s linear infinite; +} + +@keyframes fm-spin { + to { + transform: rotate(360deg); + } +} + +.fm-footer { + justify-content: space-between; +} + +.fm-summary { + font-size: 0.85rem; + color: var(--text-color-secondary); +} + +.fm-overlay { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--theme-background) 65%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: inherit; +} + +.fm-overlay-panel { + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: var(--shadow-lg); + width: min(440px, calc(100% - 48px)); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.fm-overlay-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-color); +} + +.fm-overlay-title .lucide-icon { + width: 18px; + height: 18px; +} + +.fm-overlay-body { + color: var(--text-color-secondary); + font-size: 0.9rem; + line-height: 1.5; + max-height: 220px; + overflow-y: auto; +} + +.fm-overlay-body ul { + margin: 8px 0 0 0; + padding-left: 18px; +} + +.fm-overlay-body li { + word-break: break-word; +} + +.fm-overlay-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.fm-rename-row { + display: flex; + align-items: center; + gap: 6px; +} + +.fm-rename-row input { + flex: 1; + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.9rem; + outline: none; +} + +.fm-rename-row input:focus { + border-color: var(--border-color-focus); +} + +.fm-rename-ext { + color: var(--text-color-muted); + font-size: 0.9rem; + white-space: nowrap; +} + +.fm-rename-error { + margin-top: 8px; + color: var(--error-color); + font-size: 0.82rem; +} + +/* Destructive variant of the shared primary button (delete / reboot). */ +.primary-btn.danger { + background-color: var(--error-color); + border-color: var(--error-color); + color: var(--text-on-primary); +} + +.primary-btn.danger:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--error-color) 85%, black); +} + +/* ============================================================================ + SSH SETTINGS SECTION (settings modal) + ============================================================================ */ + +.ssh-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin: 12px 0; +} + +.ssh-settings-grid label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.88rem; + color: var(--text-color-secondary); +} + +.ssh-settings-grid input { + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.9rem; + outline: none; +} + +.ssh-settings-grid input:focus { + border-color: var(--border-color-focus); +} + +/* ============================================================================ + REBOOT OVERLAY + ============================================================================ */ + +.reboot-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1200; +} + +.reboot-overlay-panel { + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: var(--shadow-lg); + width: min(420px, calc(100% - 48px)); + padding: 28px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; +} + +.reboot-overlay-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-color); +} + +.reboot-overlay-message { + font-size: 0.9rem; + color: var(--text-color-secondary); + line-height: 1.5; + min-height: 1.4em; +} + +.reboot-result-icon.lucide-icon, +.reboot-result-icon { + width: 40px; + height: 40px; +} + +.reboot-result-icon.success { + color: var(--success-color); +} + +.reboot-result-icon.error { + color: var(--error-color); +} + +.reboot-overlay-actions { + display: flex; + gap: 10px; + justify-content: center; +} + + +/* ============================================================================ + CALIBRATION ASSISTANT + ============================================================================ */ + +.cal-modal-content { + position: relative; + display: flex; + flex-direction: column; + width: min(1100px, 96vw); + max-width: min(1100px, 96vw); + height: min(820px, 94vh); + max-height: 94vh; +} + +.cal-modal-body { + display: flex; + flex-direction: column; + flex: 1; + gap: 12px; + overflow: hidden; +} + +/* Header indicators */ +.cal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--surface-muted); + border-radius: 8px; + flex-shrink: 0; +} + +.cal-context-indicator { + font-size: 14px; + color: var(--text-color-secondary); +} + +.cal-ssh-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-color-secondary); +} + +.cal-ssh-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--error-color); +} + +.cal-ssh-dot.connected { + background-color: var(--success-color); +} + +/* Tabs */ +.cal-tabs { + display: flex; + gap: 4px; + background-color: var(--surface-muted); + padding: 4px; + border-radius: 8px; + flex-shrink: 0; +} + +.cal-tab-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.cal-tab-button svg { + width: 16px; + height: 16px; +} + +.cal-tab-button:hover { + background-color: var(--card-bg-hover); + color: var(--text-color); +} + +.cal-tab-button[aria-selected="true"] { + background-color: var(--theme-primary); + color: var(--text-on-primary); +} + +/* Panels */ +.cal-tab-panels { + flex: 1; + overflow: hidden; +} + +.cal-tab-panel { + height: 100%; + overflow: auto; +} + +.cal-tab-panel[hidden] { + display: none; +} + +.cal-panel-layout { + display: grid; + grid-template-columns: 1fr 300px; + gap: 12px; + min-height: 100%; +} + +.cal-panel-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.cal-panel-sidebar { + display: flex; + flex-direction: column; + gap: 16px; + background-color: var(--surface-muted); + border-radius: 8px; + padding: 14px; + overflow-y: auto; +} + +@media (max-width: 900px) { + .cal-panel-layout { + grid-template-columns: 1fr; + } +} + +/* Mesh + shaper visualization containers */ +.cal-mesh-container, +.cal-shaper-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--surface-muted); + border-radius: 8px; + padding: 14px; +} + +#cal-mesh-canvas, +#cal-shaper-canvas { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +.cal-mesh-stats { + display: flex; + gap: 24px; + margin-top: 12px; + padding: 8px 16px; + background-color: var(--card-bg); + border-radius: 6px; +} + +.cal-stat-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.cal-stat-label { + font-size: 11px; + color: var(--text-color-secondary); + text-transform: uppercase; +} + +.cal-stat-value { + font-size: 15px; + font-weight: 600; + color: var(--text-color); + font-family: monospace; +} + +.cal-controls-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.cal-profile-select, +.cal-export-select { + padding: 8px 12px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; +} + +.cal-profile-select { + flex: 1; + max-width: 220px; +} + +.cal-profile-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Buttons */ +.cal-control-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.cal-control-button svg { + width: 16px; + height: 16px; +} + +.cal-control-button:hover:not(:disabled) { + background-color: var(--card-bg-hover); +} + +.cal-control-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cal-control-button.primary { + background-color: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.cal-control-button.primary:hover:not(:disabled) { + background-color: var(--theme-primary-hover); +} + +.cal-control-button.danger { + background-color: var(--error-color); + border-color: var(--error-color); + color: var(--text-on-primary); +} + +.cal-control-button.danger:hover:not(:disabled) { + filter: brightness(0.9); +} + +/* Section titles */ +.cal-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.cal-subsection-title { + font-size: 13px; + font-weight: 500; + color: var(--text-color-secondary); + margin: 0 0 8px 0; +} + +/* Workflow stages */ +.cal-workflow-section { + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.cal-workflow-stages { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} + +.cal-workflow-stage { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid transparent; + border-radius: 6px; + transition: background-color var(--transition-fast); +} + +.cal-workflow-stage.active { + background-color: var(--primary-alpha-20); + border-color: var(--theme-primary); +} + +.cal-workflow-stage.completed .cal-stage-icon svg { + stroke: var(--success-color); +} + +.cal-stage-icon svg { + width: 16px; + height: 16px; + stroke: var(--text-color-secondary); +} + +.cal-stage-label { + font-size: 13px; + color: var(--text-color); +} + +/* Results */ +.cal-results-section[hidden] { + display: none; +} + +.cal-adjustments-container { + margin-bottom: 14px; +} + +.cal-adjustments-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cal-adjustment-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 9px 12px; + background-color: var(--card-bg); + border-radius: 6px; + font-size: 13px; +} + +.cal-adjustment-corner { + font-weight: 500; + color: var(--text-color); +} + +.cal-adjustment-value { + font-family: monospace; + color: var(--theme-primary); +} + +.cal-adjustment-value.cw { + color: var(--success-color); +} + +.cal-adjustment-value.ccw { + color: var(--warning-color); +} + +.cal-improvement-display { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + background-color: color-mix(in srgb, var(--success-color) 10%, transparent); + border: 1px solid var(--success-color); + border-radius: 8px; + margin: 14px 0; +} + +.cal-improvement-label { + font-size: 14px; + color: var(--text-color); +} + +.cal-improvement-value { + font-size: 22px; + font-weight: 700; + color: var(--success-color); +} + +/* Input shaper sidebar */ +.cal-axis-selector { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.cal-axis-button { + padding: 8px 24px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.cal-axis-button.active { + background-color: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.cal-axis-button:hover:not(.active) { + background-color: var(--card-bg-hover); + color: var(--text-color); +} + +.cal-shaper-results-section, +.cal-shaper-comparison-section { + padding-bottom: 14px; + border-bottom: 1px solid var(--border-color); +} + +.cal-shaper-recommendation { + padding: 12px; + background-color: var(--card-bg); + border-radius: 8px; +} + +.cal-shaper-details { + margin-top: 10px; + font-size: 12px; + color: var(--text-color-secondary); + display: grid; + gap: 4px; +} + +.cal-empty-state { + color: var(--text-color-secondary); + font-size: 13px; + text-align: center; + padding: 20px; +} + +.cal-shaper-comparison { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cal-shaper-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid transparent; + border-radius: 6px; +} + +.cal-shaper-item.recommended { + border-color: var(--success-color); +} + +.cal-shaper-name { + font-size: 13px; + font-weight: 500; + color: var(--text-color); +} + +.cal-shaper-freq { + font-size: 12px; + font-family: monospace; + color: var(--text-color-secondary); +} + +.cal-shaper-config-output { + margin-top: 8px; + padding: 12px; + background-color: var(--darker-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 12px; + color: var(--text-color); + white-space: pre-wrap; + word-break: break-word; + min-height: 100px; +} + +.cal-shaper-config-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +/* SSH tab */ +.cal-ssh-panel { + max-width: 620px; + padding: 4px; +} + +.cal-info-text { + font-size: 13px; + color: var(--text-color-secondary); + line-height: 1.5; + margin-bottom: 12px; +} + +.cal-ssh-fields { + margin-top: 14px; + padding: 14px; + background-color: var(--surface-muted); + border-radius: 8px; +} + +.cal-input-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.cal-input-group:last-child { + margin-bottom: 0; +} + +.cal-input-group label { + font-size: 13px; + font-weight: 500; + color: var(--text-color); +} + +.cal-input { + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; + transition: border-color var(--transition-fast); +} + +.cal-input:focus { + outline: none; + border-color: var(--border-color-focus); +} + +.cal-ssh-buttons { + display: flex; + gap: 10px; + margin-top: 14px; + flex-wrap: wrap; +} + +.cal-ssh-test-result { + margin-top: 12px; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + min-height: 20px; + color: var(--text-color-secondary); +} + +.cal-ssh-test-result.success { + background-color: color-mix(in srgb, var(--success-color) 10%, transparent); + color: var(--success-color); +} + +.cal-ssh-test-result.error { + background-color: color-mix(in srgb, var(--error-color) 10%, transparent); + color: var(--error-color); +} + +/* History tab */ +.cal-history-panel { + padding: 4px; +} + +.cal-history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.cal-history-header .cal-section-title { + margin-bottom: 0; +} + +.cal-history-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cal-history-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 14px; + background-color: var(--surface-muted); + border-radius: 8px; +} + +.cal-history-item-main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cal-history-item-type { + font-size: 14px; + font-weight: 600; + color: var(--text-color); +} + +.cal-history-item-summary { + font-size: 13px; + color: var(--text-color-secondary); +} + +.cal-history-item-date { + font-size: 12px; + color: var(--text-color-muted); +} + +/* Footer */ +.cal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cal-status-message { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-color-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cal-footer-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.cal-footer-actions .secondary-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.cal-footer-actions .lucide-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cal-export-format { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Visual recommendations overlay (nested inside the modal content) */ +.cal-overlay { + position: absolute; + inset: 0; + background-color: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + z-index: 10; +} + +.cal-overlay.hidden { + display: none; +} + +.cal-overlay-panel { + width: min(92%, 720px); + max-height: 92%; + overflow: auto; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: var(--shadow-lg); +} + +.cal-overlay-title { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: var(--surface-muted); + font-weight: 600; + color: var(--text-color); +} + +.cal-overlay-title svg { + width: 18px; + height: 18px; + color: var(--theme-primary); +} + +.cal-overlay-title .close-btn { + margin-left: auto; +} + +.cal-overlay-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +#cal-rec-canvas { + width: 100%; + height: auto; + border-radius: 8px; +} + +.cal-rec-legend { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-color-secondary); +} + +.cal-legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.cal-legend-swatch { + width: 12px; + height: 12px; + border-radius: 3px; +} + +.cal-legend-swatch.cw { + background-color: var(--success-color); +} + +.cal-legend-swatch.ccw { + background-color: var(--warning-color); +} + +.cal-legend-swatch.tape { + background-color: var(--theme-secondary); +} + + diff --git a/src/webui/types/web-api.types.ts b/src/webui/types/web-api.types.ts index 6585aa9..3f78489 100644 --- a/src/webui/types/web-api.types.ts +++ b/src/webui/types/web-api.types.ts @@ -18,6 +18,8 @@ * - Errors: WebUIError, WEB_UI_ERROR_CODES constant object, WebUIErrorCode type */ +import type { RebootStatusPayload } from '../../types/printer-power'; + // ============================================================================ // AUTHENTICATION TYPES // ============================================================================ @@ -66,7 +68,8 @@ export type WebSocketMessageType = | 'ERROR' | 'COMMAND_RESULT' | 'PONG' - | 'SPOOLMAN_UPDATE'; + | 'SPOOLMAN_UPDATE' + | 'REBOOT_STATUS'; /** * Per-tool temperature reading for multi-tool printers (Creator 5 series). @@ -165,6 +168,8 @@ export interface WebSocketMessage { readonly remainingLength: number; readonly lastUpdated: string; } | null; + // Reboot lifecycle fields (when type === 'REBOOT_STATUS') + readonly reboot?: RebootStatusPayload; } // ============================================================================ From 502bd8bec2578e186ba0851a2fe196d6a75877c2 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:27:22 -0400 Subject: [PATCH 07/59] feat(webui): fix manually adding HTTP-only (Creator 5) printers Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WacnY1aZDjPC1VTFoDuJxf --- CLAUDE.md | 2 +- README.md | 2 +- src/managers/ConnectionFlowManager.ts | 44 ++++- .../ConnectionEstablishmentService.test.ts | 65 ++++++++ src/utils/HeadlessArguments.test.ts | 121 ++++++++++++++ src/utils/HeadlessArguments.ts | 42 ++++- src/utils/PrinterUtils.test.ts | 99 +++++++++++ src/utils/PrinterUtils.ts | 6 +- .../server/routes/printer-detection-routes.ts | 11 +- .../routes/printer-management-routes.ts | 27 ++- .../static/features/printer-discovery.ts | 155 ++++++++++++++---- src/webui/static/index.html | 14 +- 12 files changed, 538 insertions(+), 50 deletions(-) create mode 100644 src/services/ConnectionEstablishmentService.test.ts create mode 100644 src/utils/HeadlessArguments.test.ts create mode 100644 src/utils/PrinterUtils.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 61785fc..b6d2c24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ node dist/index.js --no-printers node dist/index.js --last-used --webui-port=3001 --webui-password=mypassword ``` -Printer spec format: `IP:TYPE:CHECKCODE` where TYPE is `new` or `legacy`. +Printer spec format: `IP:TYPE[:CHECKCODE[:SERIAL]]` where TYPE is `new`, `legacy`, `creator-5`, or `creator-5-pro`. The Creator tokens are required for the Creator 5 series because those printers are HTTP-only — the generic `new` token triggers a legacy TCP probe they cannot answer. SERIAL is required for the Creator series (their serial cannot be recovered by probing) and optional for dual-API models, which fall back to the serial reported by the TCP probe. ## Architecture diff --git a/README.md b/README.md index b2be1d1..77b0ec2 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ http://:3000 | --- | --- | | **--last-used** | Connect to the last used printer on startup | | **--all-saved-printers** | Connect to all saved printers on startup | -| **--printers="IP:TYPE:CODE,..."** | Connect to specific printers (TYPE: "new" or "legacy") | +| **--printers="IP:TYPE[:CODE[:SERIAL]],..."** | Connect to specific printers (TYPE: "new", "legacy", "creator-5", or "creator-5-pro"). SERIAL is required for the Creator 5 series | | **--no-printers** | Start WebUI only, without connecting to any printer | | **--webui-port=PORT** | Override the WebUI port (default: 3000) | | **--webui-password=PASS** | Override the WebUI password | diff --git a/src/managers/ConnectionFlowManager.ts b/src/managers/ConnectionFlowManager.ts index aa09230..18076ca 100644 --- a/src/managers/ConnectionFlowManager.ts +++ b/src/managers/ConnectionFlowManager.ts @@ -1205,6 +1205,8 @@ export class ConnectionFlowManager extends EventEmitter { checkCode?: string; commandPort?: number; httpPort?: number; + productId?: number; + serialNumber?: string; }> ): Promise<{ contextId: string; ip: string }[]> { const connectedContexts: { contextId: string; ip: string }[] = []; @@ -1215,14 +1217,20 @@ export class ConnectionFlowManager extends EventEmitter { const flowId = this.startFlow(); - // Create mock discovered printer + // Create mock discovered printer. The productId hint (from UDP discovery + // or the manual "Creator 5" selection) lets the temporary connection skip + // the legacy TCP probe for HTTP-only models. const mockDiscoveredPrinter: DiscoveredPrinter = { name: `Printer at ${spec.ip}`, ipAddress: spec.ip, - serialNumber: '', // Will be determined during connection + // HTTP-only models can't be probed for their serial, so the caller + // must supply it — modern printers authenticate with serial + check + // code. The TCP probe refines it for dual-API printers. + serialNumber: spec.serialNumber ?? '', model: undefined, commandPort: spec.commandPort, eventPort: spec.httpPort, + productId: spec.productId, }; // Determine if this is a 5M family printer @@ -1243,11 +1251,13 @@ export class ConnectionFlowManager extends EventEmitter { ? tempResult.printerInfo.Name : `Printer at ${spec.ip}`; - const serialNumber = + const probedSerial = tempResult.printerInfo?.SerialNumber && - typeof tempResult.printerInfo.SerialNumber === 'string' + typeof tempResult.printerInfo.SerialNumber === 'string' && + tempResult.printerInfo.SerialNumber.trim() !== '' ? tempResult.printerInfo.SerialNumber - : `Unknown-${Date.now()}`; + : undefined; + const serialNumber = probedSerial ?? spec.serialNumber ?? `Unknown-${Date.now()}`; const modelType = detectPrinterModelType(tempResult.typeName); @@ -1265,6 +1275,7 @@ export class ConnectionFlowManager extends EventEmitter { model: tempResult.typeName, commandPort: spec.commandPort, eventPort: spec.httpPort, + productId: spec.productId, }; const forceLegacyMode = existingPrinter?.forceLegacyMode ?? false; @@ -1284,6 +1295,25 @@ export class ConnectionFlowManager extends EventEmitter { continue; } + // When the productId hint short-circuited the TCP probe, the temp result + // only carries a synthesized generic model name (a manual "Creator 5" + // selection cannot distinguish the 5 Pro). Prefer the immutable factory + // model name reported by the authenticated HTTP client — it also corrects + // a wrong manual selection. + let resolvedTypeName = tempResult.typeName; + if (is5MFamily) { + const factoryModel = (connectionResult.primaryClient as Partial).model; + if (typeof factoryModel === 'string' && factoryModel.trim() !== '') { + resolvedTypeName = factoryModel; + } + } + const resolvedModelType = detectPrinterModelType(resolvedTypeName); + if (resolvedModelType !== modelType) { + console.log( + `[Headless] Refined model from "${tempResult.typeName}" to "${resolvedTypeName}" via HTTP client` + ); + } + // Save printer details const printerDetails: PrinterDetails = applyPerPrinterDefaults({ Name: formatPrinterName(printerName, serialNumber), @@ -1291,8 +1321,8 @@ export class ConnectionFlowManager extends EventEmitter { SerialNumber: serialNumber, CheckCode: checkCode, ClientType: forceLegacyMode ? 'legacy' : spec.type, - printerModel: tempResult.typeName, - modelType, + printerModel: resolvedTypeName, + modelType: resolvedModelType, commandPort: spec.commandPort, httpPort: spec.httpPort, // Preserve previously configured per-printer overrides when present diff --git a/src/services/ConnectionEstablishmentService.test.ts b/src/services/ConnectionEstablishmentService.test.ts new file mode 100644 index 0000000..75c3594 --- /dev/null +++ b/src/services/ConnectionEstablishmentService.test.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Tests for the HTTP-only short-circuit in + * ConnectionEstablishmentService.createTemporaryConnection (issue #17). + * When a Creator 5 series product ID is supplied, the service must synthesize + * the type info and return without ever opening a legacy TCP probe — the + * Creator 5 series runs no TCP server on port 8899. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import type { DiscoveredPrinter } from '../types/printer'; +import { getConnectionEstablishmentService } from './ConnectionEstablishmentService'; + +describe('ConnectionEstablishmentService.createTemporaryConnection', () => { + const service = getConnectionEstablishmentService(); + + const basePrinter: DiscoveredPrinter = { + name: 'Printer at 192.168.1.184', + ipAddress: '192.168.1.184', + serialNumber: 'SNCRE5PRO001', + model: undefined, + }; + + it('short-circuits the TCP probe for the Creator 5 (productId 40)', async () => { + const result = await service.createTemporaryConnection({ ...basePrinter, productId: 40 }); + + expect(result.success).toBe(true); + expect(result.typeName).toBe('Creator 5'); + expect(result.printerInfo?.SerialNumber).toBe('SNCRE5PRO001'); + }); + + it('short-circuits the TCP probe for the Creator 5 Pro (productId 41)', async () => { + const result = await service.createTemporaryConnection({ ...basePrinter, productId: 41 }); + + expect(result.success).toBe(true); + expect(result.typeName).toBe('Creator 5 Pro'); + }); + + it('does not short-circuit for dual-API product IDs', async () => { + // 5M family printers run a real TCP server, so the probe must actually run. + // Stub the legacy client factory so no real socket is opened. + const fakeClient = { + initControl: jest.fn(async () => false), + dispose: jest.fn(async () => undefined), + }; + const spy = jest + .spyOn( + service as unknown as { createLegacyClient: () => unknown }, + 'createLegacyClient' + ) + .mockReturnValue(fakeClient); + + try { + const result = await service.createTemporaryConnection( + { ...basePrinter, productId: 35 }, + 50, + 1 + ); + + expect(spy).toHaveBeenCalled(); + expect(result.success).toBe(false); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/utils/HeadlessArguments.test.ts b/src/utils/HeadlessArguments.test.ts new file mode 100644 index 0000000..16a04f0 --- /dev/null +++ b/src/utils/HeadlessArguments.test.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview Tests for CLI printer-spec parsing and validation. + * Covers the Creator 5 series TYPE tokens and the serial-number requirement + * for HTTP-only models, which cannot be probed for their serial (issue #17). + */ + +import { afterEach, describe, expect, it } from '@jest/globals'; +import { + type HeadlessConfig, + parseHeadlessArguments, + validateHeadlessConfig, +} from './HeadlessArguments'; + +const originalArgv = process.argv; + +function parseWith(printersArg: string): HeadlessConfig { + process.argv = ['node', 'index.js', `--printers=${printersArg}`]; + return parseHeadlessArguments(); +} + +afterEach(() => { + process.argv = originalArgv; +}); + +describe('parseHeadlessArguments - printer specs', () => { + it('parses the legacy IP:TYPE:CHECKCODE format unchanged', () => { + const config = parseWith('192.168.1.100:new:12345678,192.168.1.101:legacy'); + + expect(config.mode).toBe('explicit-printers'); + expect(config.printers).toHaveLength(2); + expect(config.printers?.[0]).toMatchObject({ + ip: '192.168.1.100', + type: 'new', + checkCode: '12345678', + }); + expect(config.printers?.[0].productId).toBeUndefined(); + expect(config.printers?.[1]).toMatchObject({ ip: '192.168.1.101', type: 'legacy' }); + }); + + it('parses the optional serial number as a fourth field', () => { + const config = parseWith('192.168.1.100:new:12345678:SNMOMC1234567'); + + expect(config.printers?.[0]).toMatchObject({ + type: 'new', + checkCode: '12345678', + serialNumber: 'SNMOMC1234567', + }); + }); + + it('maps Creator tokens to the new client type with an HTTP-only product ID', () => { + const config = parseWith( + '192.168.1.184:creator-5:12345678:SN1,192.168.1.185:creator-5-pro:87654321:SN2' + ); + + expect(config.printers?.[0]).toMatchObject({ + type: 'new', + productId: 40, + serialNumber: 'SN1', + }); + expect(config.printers?.[1]).toMatchObject({ + type: 'new', + productId: 41, + serialNumber: 'SN2', + }); + }); + + it('falls back to legacy for unknown type tokens', () => { + const config = parseWith('192.168.1.100:bogus'); + expect(config.printers?.[0].type).toBe('legacy'); + }); +}); + +describe('validateHeadlessConfig', () => { + const base: HeadlessConfig = { mode: 'explicit-printers' }; + + it('requires a serial number for Creator 5 series printers', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.184', type: 'new', checkCode: '12345678', productId: 40 }], + }); + + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/serial number/i); + }); + + it('accepts a Creator 5 spec that includes a serial number', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [ + { + ip: '192.168.1.184', + type: 'new', + checkCode: '12345678', + productId: 41, + serialNumber: 'SNCRE51234567', + }, + ], + }); + + expect(result.valid).toBe(true); + }); + + it('keeps the serial optional for dual-API printers (TCP probe supplies it)', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.100', type: 'new', checkCode: '12345678' }], + }); + + expect(result.valid).toBe(true); + }); + + it('still requires a check code for modern printers', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.100', type: 'new' }], + }); + + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/check code/i); + }); +}); diff --git a/src/utils/HeadlessArguments.ts b/src/utils/HeadlessArguments.ts index 4fb645b..41de4b6 100644 --- a/src/utils/HeadlessArguments.ts +++ b/src/utils/HeadlessArguments.ts @@ -7,7 +7,8 @@ * Examples: * node dist/index.js --last-used * node dist/index.js --all-saved-printers - * node dist/index.js --printers="192.168.1.100:new:12345678,192.168.1.101:legacy" + * node dist/index.js --printers="192.168.1.100:new:12345678:SNMOMC1234567,192.168.1.101:legacy" + * node dist/index.js --printers="192.168.1.184:creator-5-pro:12345678:SNCRE51234567" * node dist/index.js --webui-port=3001 --webui-password=mypassword */ @@ -20,6 +21,18 @@ export interface PrinterSpec { ip: string; type: PrinterClientType; checkCode?: string; + /** + * USB product ID hint for HTTP-only models (Creator 5 series). Set when the + * TYPE token is `creator-5` / `creator-5-pro` so the connection flow skips + * the legacy TCP probe these printers cannot answer. + */ + productId?: number; + /** + * Printer serial number. Required for modern printers (5M family and Creator + * 5 series), which authenticate with serial + check code. Dual-API models can + * fall back to the serial reported by the TCP probe; HTTP-only models cannot. + */ + serialNumber?: string; } /** @@ -87,7 +100,11 @@ export function parseHeadlessArguments(): HeadlessConfig { /** * Parse --printers argument into array of PrinterSpec * - * Format: --printers="192.168.1.100:new:12345678,192.168.1.101:legacy" + * Format: --printers="IP:TYPE[:CHECKCODE[:SERIAL]],..." + * e.g. --printers="192.168.1.100:new:12345678:SNMOMC1234567,192.168.1.101:legacy" + * + * TYPE is `new`, `legacy`, `creator-5`, or `creator-5-pro`. The Creator tokens + * mark HTTP-only printers so the connection flow skips the legacy TCP probe. * * @param arg The --printers= argument string * @returns Array of PrinterSpec objects @@ -112,13 +129,21 @@ function parsePrintersArgument(arg: string): PrinterSpec[] { continue; } - const [ip, typeStr, checkCode] = parts; - const type: PrinterClientType = typeStr === 'new' ? 'new' : 'legacy'; + const [ip, typeStr, checkCode, serialNumber] = parts; + // The Creator 5 series is HTTP-only (no legacy TCP server), so the type + // token also carries the model for those printers. All other non-"new" + // tokens fall back to legacy, matching historical behavior. + const normalizedType = typeStr.trim().toLowerCase(); + const isCreator = normalizedType === 'creator-5' || normalizedType === 'creator-5-pro'; + const type: PrinterClientType = typeStr === 'new' || isCreator ? 'new' : 'legacy'; + const productId = isCreator ? (normalizedType === 'creator-5' ? 40 : 41) : undefined; specs.push({ ip: ip.trim(), type, checkCode: checkCode?.trim(), + productId, + serialNumber: serialNumber?.trim() || undefined, }); } @@ -187,6 +212,15 @@ export function validateHeadlessConfig(config: HeadlessConfig): ValidationResult if (printer.type === 'new' && !printer.checkCode) { errors.push(`Printer ${index + 1}: New printer type requires check code`); } + // HTTP-only models (Creator 5 series) can't be probed over TCP for their + // serial, so it must be supplied. Dual-API models fall back to the + // probe's serial, so it stays optional there for compatibility. + if (printer.productId !== undefined && !printer.serialNumber) { + errors.push( + `Printer ${index + 1}: Creator 5 series requires a serial number ` + + `(format IP:TYPE:CHECKCODE:SERIAL)` + ); + } }); } } diff --git a/src/utils/PrinterUtils.test.ts b/src/utils/PrinterUtils.test.ts new file mode 100644 index 0000000..830fe44 --- /dev/null +++ b/src/utils/PrinterUtils.test.ts @@ -0,0 +1,99 @@ +/** + * @fileoverview Unit tests for printer model detection and classification utilities. + * Focuses on the PID-based detection paths and HTTP-only (Creator 5 series) + * classification that drive connection-flow routing (issue #17). + */ + +import { describe, expect, it } from '@jest/globals'; +import { + detectPrinterFamily, + detectPrinterFamilyFromId, + detectPrinterModelType, + detectPrinterModelTypeFromId, + isHttpOnlyModel, + supportsDualAPI, +} from './PrinterUtils'; + +describe('detectPrinterModelType', () => { + it('detects 5M family models from typeName', () => { + expect(detectPrinterModelType('FlashForge Adventurer 5M Pro')).toBe('adventurer-5m-pro'); + expect(detectPrinterModelType('FlashForge Adventurer 5M')).toBe('adventurer-5m'); + expect(detectPrinterModelType('FlashForge AD5X')).toBe('ad5x'); + }); + + it('detects Creator 5 series models from typeName', () => { + expect(detectPrinterModelType('Creator 5 Pro')).toBe('creator-5-pro'); + expect(detectPrinterModelType('Creator 5')).toBe('creator-5'); + }); + + it('falls back to generic-legacy for unknown or empty typeName', () => { + expect(detectPrinterModelType('Adventurer 4')).toBe('generic-legacy'); + expect(detectPrinterModelType('')).toBe('generic-legacy'); + }); +}); + +describe('detectPrinterModelTypeFromId', () => { + it('prefers the USB product ID when known', () => { + expect(detectPrinterModelTypeFromId(35, '')).toBe('adventurer-5m'); + expect(detectPrinterModelTypeFromId(36, '')).toBe('adventurer-5m-pro'); + expect(detectPrinterModelTypeFromId(38, '')).toBe('ad5x'); + expect(detectPrinterModelTypeFromId(40, '')).toBe('creator-5'); + expect(detectPrinterModelTypeFromId(41, '')).toBe('creator-5-pro'); + }); + + it('falls back to typeName when the product ID is missing or unknown', () => { + expect(detectPrinterModelTypeFromId(undefined, 'Creator 5 Pro')).toBe('creator-5-pro'); + expect(detectPrinterModelTypeFromId(9999, 'FlashForge Adventurer 5M')).toBe('adventurer-5m'); + expect(detectPrinterModelTypeFromId(undefined, '')).toBe('generic-legacy'); + }); +}); + +describe('detectPrinterFamilyFromId', () => { + it('classifies known product IDs as new-API printers requiring a check code', () => { + const info = detectPrinterFamilyFromId(40, ''); + expect(info.is5MFamily).toBe(true); + expect(info.requiresCheckCode).toBe(true); + expect(info.familyName).toBe('Creator 5'); + }); + + it('falls back to typeName detection without a product ID', () => { + const legacy = detectPrinterFamilyFromId(undefined, 'Adventurer 4'); + expect(legacy.is5MFamily).toBe(false); + expect(legacy.requiresCheckCode).toBe(false); + }); +}); + +describe('detectPrinterFamily', () => { + it('treats the Creator 5 series as part of the modern check-code family', () => { + expect(detectPrinterFamily('Creator 5').is5MFamily).toBe(true); + expect(detectPrinterFamily('Creator 5 Pro').requiresCheckCode).toBe(true); + }); +}); + +describe('isHttpOnlyModel', () => { + it('marks only the Creator 5 series as HTTP-only', () => { + expect(isHttpOnlyModel('creator-5')).toBe(true); + expect(isHttpOnlyModel('creator-5-pro')).toBe(true); + expect(isHttpOnlyModel('adventurer-5m')).toBe(false); + expect(isHttpOnlyModel('adventurer-5m-pro')).toBe(false); + expect(isHttpOnlyModel('ad5x')).toBe(false); + expect(isHttpOnlyModel('generic-legacy')).toBe(false); + }); +}); + +describe('supportsDualAPI', () => { + it('is true only for modern printers that also run the legacy TCP server', () => { + expect(supportsDualAPI('adventurer-5m')).toBe(true); + expect(supportsDualAPI('adventurer-5m-pro')).toBe(true); + expect(supportsDualAPI('ad5x')).toBe(true); + }); + + it('is false for HTTP-only models (Creator 5 series has no TCP server)', () => { + expect(supportsDualAPI('creator-5')).toBe(false); + expect(supportsDualAPI('creator-5-pro')).toBe(false); + }); + + it('is false for legacy printers', () => { + expect(supportsDualAPI('generic-legacy')).toBe(false); + }); +}); diff --git a/src/utils/PrinterUtils.ts b/src/utils/PrinterUtils.ts index 2efcbaf..352265f 100644 --- a/src/utils/PrinterUtils.ts +++ b/src/utils/PrinterUtils.ts @@ -253,10 +253,12 @@ export const getPrinterModelInfo = (typeName: string): EnhancedPrinterFamilyInfo /** * Check if printer supports dual API usage - * Modern printers (5M family) can use both new and legacy APIs + * 5M / 5M Pro / AD5X can use both the new HTTP API and the legacy TCP API. + * HTTP-only models (Creator 5 series) have no legacy TCP server, so they are + * modern but NOT dual-API. */ export const supportsDualAPI = (modelType: PrinterModelType): boolean => { - return modelType !== 'generic-legacy'; + return modelType !== 'generic-legacy' && !isHttpOnlyModel(modelType); }; /** diff --git a/src/webui/server/routes/printer-detection-routes.ts b/src/webui/server/routes/printer-detection-routes.ts index 837adff..7b54c2a 100644 --- a/src/webui/server/routes/printer-detection-routes.ts +++ b/src/webui/server/routes/printer-detection-routes.ts @@ -30,6 +30,8 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen ipAddress?: string; commandPort?: number; httpPort?: number; + productId?: number; + serialNumber?: string; }; const ipAddress = body.ipAddress; @@ -47,14 +49,19 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen console.log(`[Detection] Probing printer at ${ipAddress}...`); - // Create mock discovered printer for temporary connection + // Create mock discovered printer for temporary connection. The optional + // productId (from UDP discovery or the manual "Creator 5" selection) lets + // createTemporaryConnection short-circuit the legacy TCP probe for + // HTTP-only models — the Creator 5 series has no TCP server on 8899, so + // probing it would only time out. const mockPrinter: DiscoveredPrinter = { name: `Printer at ${ipAddress}`, ipAddress, - serialNumber: '', // Will be determined during connection + serialNumber: typeof body.serialNumber === 'string' ? body.serialNumber.trim() : '', model: undefined, commandPort: typeof body.commandPort === 'number' ? body.commandPort : undefined, eventPort: typeof body.httpPort === 'number' ? body.httpPort : undefined, + productId: typeof body.productId === 'number' ? body.productId : undefined, }; // Create temporary connection to probe the printer diff --git a/src/webui/server/routes/printer-management-routes.ts b/src/webui/server/routes/printer-management-routes.ts index 55c566e..b496f1f 100644 --- a/src/webui/server/routes/printer-management-routes.ts +++ b/src/webui/server/routes/printer-management-routes.ts @@ -7,6 +7,7 @@ import type { Response, Router } from 'express'; import { getSavedPrinterService } from '../../../services/SavedPrinterService'; import type { PrinterClientType } from '../../../types/printer'; import { toAppError } from '../../../utils/error.utils'; +import { detectPrinterModelTypeFromId, isHttpOnlyModel } from '../../../utils/PrinterUtils'; import type { StandardAPIResponse } from '../../types/web-api.types'; import type { AuthenticatedRequest } from '../auth-middleware'; import type { RouteDependencies } from './route-helpers'; @@ -30,6 +31,7 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen checkCode?: string; commandPort?: number; httpPort?: number; + productId?: number; }; const ipAddress = body.ipAddress; @@ -37,6 +39,7 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen const checkCode = body.checkCode; const commandPort = body.commandPort; const httpPort = body.httpPort; + const productId = body.productId; // Validate required fields if (!ipAddress || typeof ipAddress !== 'string') { @@ -49,7 +52,27 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen // Validate check code for new printers if (type === 'new' && (!checkCode || typeof checkCode !== 'string')) { - return sendErrorResponse(res, 400, 'Check code is required for 5M/Pro printers'); + return sendErrorResponse(res, 400, 'Check code is required for modern printers'); + } + + const serialNumber = + typeof body.serialNumber === 'string' && body.serialNumber.trim() !== '' + ? body.serialNumber.trim() + : undefined; + + // HTTP-only models (Creator 5 series) run no legacy TCP server, so their + // serial can never be recovered by probing — it must be supplied. Modern + // printers authenticate with serial + check code. + if ( + typeof productId === 'number' && + isHttpOnlyModel(detectPrinterModelTypeFromId(productId, '')) && + !serialNumber + ) { + return sendErrorResponse( + res, + 400, + 'Serial number is required for Creator 5 series printers' + ); } // Build printer spec @@ -59,6 +82,8 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen checkCode: type === 'new' ? checkCode : undefined, commandPort: typeof commandPort === 'number' ? commandPort : undefined, httpPort: typeof httpPort === 'number' ? httpPort : undefined, + productId: typeof productId === 'number' ? productId : undefined, + serialNumber, }; // Connect via ConnectionFlowManager diff --git a/src/webui/static/features/printer-discovery.ts b/src/webui/static/features/printer-discovery.ts index b3729e6..afe2d7e 100644 --- a/src/webui/static/features/printer-discovery.ts +++ b/src/webui/static/features/printer-discovery.ts @@ -13,6 +13,42 @@ interface DiscoveredPrinter { model?: string; commandPort?: number; eventPort?: number; + productId?: number; +} + +/** + * Internal USB product IDs sent for manual connections to HTTP-only models + * (Creator 5 series). These printers run no legacy TCP server on port 8899, so + * the server needs the ID up front to skip the TCP probe that would otherwise + * time out. Dual-API models (5M / 5M Pro / AD5X) are deliberately absent: they + * answer the TCP probe with their firmware TypeName, and the printer's own + * answer should always win over the user's dropdown selection. + */ +const MANUAL_PRODUCT_ID_HINTS: Readonly> = { + 'creator-5': 40, + 'creator-5-pro': 41, +}; + +/** + * Parse a manual host entry that may carry an optional `:port` suffix + * (e.g. `192.168.1.184:80`). Returns null when the IPv4 part is invalid. + */ +function parseHostInput(raw: string): { ipAddress: string; port?: number } | null { + const match = raw.match(/^([0-9.]+)(?::(\d{1,5}))?$/); + if (!match) return null; + + const ipAddress = match[1]; + const ipv4Regex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (!ipv4Regex.test(ipAddress)) return null; + + if (match[2] !== undefined) { + const port = Number(match[2]); + if (port < 1 || port > 65535) return null; + return { ipAddress, port }; + } + + return { ipAddress }; } interface SavedPrinterMatch { @@ -108,14 +144,19 @@ function setupDiscoveryModal(): void { const manualBtn = $('discovery-manual-connect'); manualBtn?.addEventListener('click', () => void connectManually()); - // Printer type change handler + // Printer type change handler. All modern printers (5M family and Creator 5 + // series) authenticate with a check code + serial number; only legacy + // printers connect without them. const typeSelect = $('discovery-printer-type') as HTMLSelectElement | null; typeSelect?.addEventListener('change', () => { const checkCodeGroup = $('discovery-check-code-group'); - if (typeSelect.value === 'new') { - checkCodeGroup?.classList.remove('hidden'); - } else { + const serialGroup = $('discovery-serial-group'); + if (typeSelect.value === 'legacy') { checkCodeGroup?.classList.add('hidden'); + serialGroup?.classList.add('hidden'); + } else { + checkCodeGroup?.classList.remove('hidden'); + serialGroup?.classList.remove('hidden'); } }); @@ -212,7 +253,8 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { data-name="${printer.name}" data-model="${printer.model || ''}" data-command-port="${printer.commandPort ?? ''}" - data-event-port="${printer.eventPort ?? ''}"> + data-event-port="${printer.eventPort ?? ''}" + data-product-id="${printer.productId ?? ''}"> Connect @@ -231,6 +273,7 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { const model = btn.getAttribute('data-model'); const commandPort = btn.getAttribute('data-command-port'); const eventPort = btn.getAttribute('data-event-port'); + const productId = btn.getAttribute('data-product-id'); if (ip) { void connectToDiscoveredPrinter( @@ -239,7 +282,8 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { name || '', model || '', commandPort ? Number(commandPort) : undefined, - eventPort ? Number(eventPort) : undefined + eventPort ? Number(eventPort) : undefined, + productId ? Number(productId) : undefined ); } }); @@ -295,7 +339,8 @@ async function connectToDiscoveredPrinter( name: string, _model: string, commandPort?: number, - httpPort?: number + httpPort?: number, + productId?: number ): Promise { try { showToast('Detecting printer type...', 'info'); @@ -317,6 +362,7 @@ async function connectToDiscoveredPrinter( ipAddress: ip, commandPort, httpPort, + productId, }), }); @@ -370,6 +416,7 @@ async function connectToDiscoveredPrinter( checkCode, commandPort, httpPort, + productId, }), }); @@ -398,19 +445,49 @@ async function connectManually(): Promise { const ipInput = $('discovery-manual-ip') as HTMLInputElement | null; const typeSelect = $('discovery-printer-type') as HTMLSelectElement | null; const checkCodeInput = $('discovery-check-code') as HTMLInputElement | null; + const serialInput = $('discovery-serial-number') as HTMLInputElement | null; if (!ipInput || !typeSelect) return; - const ip = ipInput.value.trim(); + const rawHost = ipInput.value.trim(); const userSelectedType = typeSelect.value; const userCheckCode = checkCodeInput?.value.trim(); + const userSerialNumber = serialInput?.value.trim() ?? ''; - // Validate IP - if (!ip) { + // Validate IP (an optional :port suffix is accepted, e.g. 192.168.1.184:80) + if (!rawHost) { showToast('Please enter an IP address', 'error'); return; } + const parsedHost = parseHostInput(rawHost); + if (!parsedHost) { + showToast('Invalid IP address - use e.g. 192.168.1.100 or 192.168.1.100:80', 'error'); + return; + } + + const ip = parsedHost.ipAddress; + // A :port suffix on a legacy printer overrides the TCP command port; on a + // modern printer it overrides the HTTP API port. + const commandPort = userSelectedType === 'legacy' ? parsedHost.port : undefined; + const httpPort = userSelectedType === 'legacy' ? undefined : parsedHost.port; + // The Creator 5 series is HTTP-only (no TCP server), so the server must skip + // the legacy TCP probe. The internal product ID triggers that short-circuit. + const productId = MANUAL_PRODUCT_ID_HINTS[userSelectedType]; + + // All modern printers authenticate with serial number + check code, so both + // are required up front for manual connections. + if (userSelectedType !== 'legacy') { + if (!/^[A-Za-z0-9\-_]{3,}$/.test(userSerialNumber)) { + showToast('Please enter the printer serial number (found in printer settings)', 'error'); + return; + } + if (!userCheckCode || userCheckCode.length !== 8) { + showToast('Please enter the 8-digit check code', 'error'); + return; + } + } + try { showToast('Detecting printer type...', 'info'); @@ -427,7 +504,13 @@ async function connectManually(): Promise { }>('/api/printers/detect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ipAddress: ip }), + body: JSON.stringify({ + ipAddress: ip, + commandPort, + httpPort, + productId, + serialNumber: userSerialNumber || undefined, + }), }); if (!detectResponse.success || !detectResponse.typeName) { @@ -436,16 +519,23 @@ async function connectManually(): Promise { const { typeName, serialNumber, is5MFamily, requiresCheckCode, clientType } = detectResponse; const detectedName = detectResponse.name || `Printer at ${ip}`; - const detectedSerial = serialNumber || ''; + // The TCP probe's serial (firmware-reported) wins when available; the + // user-entered serial covers HTTP-only models the probe cannot reach. + const detectedSerial = serialNumber || userSerialNumber; const detectedType = clientType || (is5MFamily ? 'new' : 'legacy'); console.log(`[Manual] Detected: ${typeName} (${is5MFamily ? '5M family' : 'legacy'})`); - // STEP 2: Validate user selection against detection (warn if mismatch) - if (userSelectedType !== detectedType) { + // STEP 2: Validate user selection against detection (warn if mismatch). + // Every modern model resolves to the "new" client type on the wire, so only + // a modern-vs-legacy disagreement is worth warning about. + const userClientType = userSelectedType === 'legacy' ? 'legacy' : 'new'; + if (userClientType !== detectedType) { + const selectedLabel = typeSelect.options[typeSelect.selectedIndex]?.text ?? userSelectedType; + const detectedLabel = detectedType === 'legacy' ? 'a legacy printer' : `a modern printer`; const proceed = confirm( - `Warning: You selected "${userSelectedType}" but the printer was detected as "${detectedType}".\n\n` + - `Using detected type: ${detectedType}\n\nContinue?` + `Warning: You selected "${selectedLabel}" but the printer reports itself as ` + + `${detectedLabel} (${typeName}).\n\nConnect using the detected type instead?` ); if (!proceed) { return; @@ -455,22 +545,24 @@ async function connectManually(): Promise { // STEP 3: Get check code if needed let checkCode: string | undefined; if (requiresCheckCode && is5MFamily) { - // Check if printer is already saved with a check code - const savedPrinter = await getSavedPrinterBySerial(detectedSerial); - - if (savedPrinter?.CheckCode && savedPrinter.CheckCode !== '123') { - // Use saved check code - checkCode = savedPrinter.CheckCode; - console.log('[Manual] Using saved check code'); - } else if (userCheckCode && userCheckCode.length === 8) { - // Use user-provided check code from form + // The form's check code is required for modern printers, so it wins over + // any saved value — otherwise a stale saved code would silently override + // a freshly re-paired printer's new one. + if (userCheckCode && userCheckCode.length === 8) { checkCode = userCheckCode; } else { - // Prompt user for check code - checkCode = prompt(`Enter the check code for ${detectedName} (8 digits):`) || undefined; - if (!checkCode || checkCode.length !== 8) { - showToast('Invalid check code - must be 8 digits', 'error'); - return; + const savedPrinter = await getSavedPrinterBySerial(detectedSerial); + + if (savedPrinter?.CheckCode && savedPrinter.CheckCode !== '123') { + checkCode = savedPrinter.CheckCode; + console.log('[Manual] Using saved check code'); + } else { + // Prompt user for check code + checkCode = prompt(`Enter the check code for ${detectedName} (8 digits):`) || undefined; + if (!checkCode || checkCode.length !== 8) { + showToast('Invalid check code - must be 8 digits', 'error'); + return; + } } } } @@ -492,6 +584,9 @@ async function connectManually(): Promise { model: typeName, type: detectedType, checkCode, + commandPort, + httpPort, + productId, }), }); diff --git a/src/webui/static/index.html b/src/webui/static/index.html index 4a55464..ac89919 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -374,7 +374,11 @@

Discovered Printers

@@ -382,7 +386,13 @@

Discovered Printers

- Required for 5M/Pro printers. Found in printer settings. + Required for modern printers. Found in printer settings. +
+ +
+ + + Required for modern printers. Found in printer settings or on the machine label.
+
+ +
`, diff --git a/src/webui/static/index.html b/src/webui/static/index.html index ac89919..1eea8e2 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -237,6 +237,122 @@ + + + diff --git a/src/webui/static/webui.css b/src/webui/static/webui.css index eb3051a..0a8e981 100644 --- a/src/webui/static/webui.css +++ b/src/webui/static/webui.css @@ -802,6 +802,38 @@ body { color: var(--warning-color); } +.material-matching-warning-card { + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid var(--warning-alpha-40, var(--border-color)); + border-radius: 10px; + background-color: var(--surface-muted); +} + +.material-matching-warning-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--warning-color, var(--text-color-secondary)); +} + +.material-matching-warning-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 0; + font-size: 14px; + color: var(--text-color); +} + +.material-matching-warning-item::before { + content: "⚠"; + color: var(--warning-color, var(--text-color-secondary)); + flex-shrink: 0; +} + .material-matching-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -978,6 +1010,21 @@ body { background-color: rgba(255, 152, 0, 0.1); } +.material-mapping-content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.material-mapping-swatch { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + .material-mapping-text { font-size: 14px; color: var(--text-color); @@ -3555,14 +3602,66 @@ body { border-radius: 10px; } +.job-upload-mappings { + margin-bottom: 20px; + padding: 12px 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-mappings-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-secondary); +} + +.job-upload-mappings-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.job-upload-mapping-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background-color: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 999px; + font-size: 14px; + color: var(--theme-text); +} + +.job-upload-mapping-swatch { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.job-upload-mapping-arrow, +.job-upload-mapping-material { + color: var(--text-color-secondary); +} + .job-upload-content { display: grid; + /* Named areas: auto-placement would otherwise push the metadata card onto a + second row, leaving an empty cell beside the preview. */ grid-template-columns: 1fr 180px; + grid-template-areas: "metadata preview"; + align-items: start; gap: 20px; } .job-upload-preview { - grid-column: 2; + grid-area: preview; display: flex; align-items: flex-start; justify-content: center; @@ -3598,10 +3697,10 @@ body { } .job-upload-metadata { - grid-column: 1; + grid-area: metadata; display: grid; grid-template-columns: 1fr 1fr; - gap: 16px 24px; + gap: 14px 20px; padding: 20px; background-color: var(--surface-muted); border: 1px solid var(--border-color); @@ -3629,18 +3728,35 @@ body { } .job-upload-meta-group.print-time-highlight { - align-items: center; + /* A full-width banner at the top of the card: as a normal cell its taller box + forced a deep gap into the neighbouring column. */ + grid-column: 1 / -1; + order: -1; + flex-direction: row; + align-items: baseline; justify-content: center; - min-height: 60px; - padding: 16px; - text-align: center; + gap: 10px; + padding: 10px 16px; border: 1px solid var(--primary-alpha-40); border-radius: 8px; background-color: var(--primary-alpha-15); } -.job-upload-meta-group.print-time-highlight .job-upload-meta-title { - margin-bottom: 8px; +.job-upload-warnings-card { + margin-bottom: 20px; + padding: 12px 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-warnings-card-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-secondary); } .job-upload-meta-value.print-time-value { @@ -3754,25 +3870,28 @@ body { @media (max-width: 768px) { .job-upload-content { grid-template-columns: 1fr; - } - - .job-upload-preview, - .job-upload-metadata { - grid-column: 1; + grid-template-areas: + "preview" + "metadata"; } .job-upload-preview { - order: -1; + justify-self: stretch; } .job-upload-metadata { grid-template-columns: 1fr; } - .job-upload-file-section, - .job-upload-options { + .job-upload-file-section { flex-direction: column; align-items: stretch; gap: 12px; } + + /* The options stay on one line - two short labels fit even on a phone. */ + .job-upload-options { + flex-wrap: wrap; + gap: 20px; + } } From da6a0959b3f85a859195e782aefa95a1cdd8a3f7 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:11:24 -0400 Subject: [PATCH 35/59] chore: remove ui-shot screenshot harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the ui-shot headless screenshot harness that commit 62ddd44 added. The harness served the built WebUI over a throwaway HTTP server, applied a fixture that filled the dialog DOM with representative data, and captured PNGs in headless Chromium. It no longer earns its keep, so it goes. Deleted: - scripts/ui-shot.mjs, the runner - scripts/ui-shot-fixtures/upload.mjs and matching.mjs, the two dialog fixtures - the "ui:shot" npm script - the .ui-shots/ ignore block from .gitignore No CHANGELOG changes needed — the harness was never mentioned in the changelog entries. The .pi/settings.json deletion in the working tree remains unrelated local noise, not part of this commit. --- .gitignore | 2 - package.json | 1 - scripts/ui-shot-fixtures/matching.mjs | 133 --------------------- scripts/ui-shot-fixtures/upload.mjs | 95 --------------- scripts/ui-shot.mjs | 162 -------------------------- 5 files changed, 393 deletions(-) delete mode 100644 scripts/ui-shot-fixtures/matching.mjs delete mode 100644 scripts/ui-shot-fixtures/upload.mjs delete mode 100644 scripts/ui-shot.mjs diff --git a/.gitignore b/.gitignore index b404a75..9c96c01 100644 --- a/.gitignore +++ b/.gitignore @@ -54,5 +54,3 @@ playwright-report/ data/* !data/*.example.json -# Headless UI screenshots (scripts/ui-shot.mjs) -.ui-shots/ diff --git a/package.json b/package.json index b968622..1aacb5a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "build:all:wrapped": "npm run build:win:wrapped && npm run build:linux:wrapped && npm run build:linux-arm:wrapped && npm run build:linux-armv7:wrapped && npm run build:mac:wrapped && npm run build:mac-arm:wrapped", "clean": "rimraf dist", "download:go2rtc": "node scripts/download-go2rtc.cjs", - "ui:shot": "node scripts/ui-shot.mjs", "lint": "biome lint .", "lint:fix": "biome lint --write .", "format": "biome format .", diff --git a/scripts/ui-shot-fixtures/matching.mjs b/scripts/ui-shot-fixtures/matching.mjs deleted file mode 100644 index 80cc60e..0000000 --- a/scripts/ui-shot-fixtures/matching.mjs +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @fileoverview ui-shot fixture: the material matching modal mid-mapping. - * - * Two tools mapped to two slots, one of them with a colour mismatch, so the - * warning card at the top of the dialog is populated. - */ - -export default async function apply(page) { - await page.evaluate(() => { - const byId = (id) => document.getElementById(id); - byId('material-matching-modal').classList.remove('hidden'); - byId('material-matching-title').textContent = 'Match Materials – 3DBenchy_multicolor.3mf'; - - const tools = [ - { id: 0, material: 'PLA', color: '#26A69A', slot: 2 }, - { id: 1, material: 'PETG', color: '#E8C62D', slot: 4 }, - ]; - const slots = [ - { id: 1, material: 'PLA', color: '#2D6EE8', empty: false, assigned: false }, - { id: 2, material: 'PLA', color: '#F72224', empty: false, assigned: true }, - { id: 3, material: null, color: null, empty: true, assigned: false }, - { id: 4, material: 'PETG', color: '#E8C62D', empty: false, assigned: true }, - ]; - - const requirements = byId('material-job-requirements'); - requirements.textContent = ''; - tools.forEach((tool) => { - const item = document.createElement('div'); - item.className = 'material-tool-item mapped'; - item.dataset.toolId = String(tool.id); - - const header = document.createElement('div'); - header.className = 'material-tool-header'; - const label = document.createElement('span'); - label.className = 'material-tool-label'; - label.textContent = `Tool ${tool.id + 1}`; - const color = document.createElement('span'); - color.className = 'material-tool-color'; - color.style.backgroundColor = tool.color; - header.append(label, color); - - const details = document.createElement('div'); - details.className = 'material-tool-details'; - details.textContent = tool.material; - const mapped = document.createElement('div'); - mapped.className = 'material-tool-mapping'; - mapped.textContent = `Mapped to Slot ${tool.slot}`; - details.append(mapped); - - item.append(header, details); - requirements.append(item); - }); - - const slotList = byId('material-slot-list'); - slotList.textContent = ''; - slots.forEach((slot) => { - const item = document.createElement('div'); - item.className = 'material-slot-item'; - if (slot.empty) { - item.classList.add('empty', 'disabled'); - } - if (slot.assigned) { - item.classList.add('assigned', 'disabled'); - } - - const swatch = document.createElement('span'); - swatch.className = 'material-slot-swatch'; - if (slot.color) { - swatch.style.backgroundColor = slot.color; - } - - const info = document.createElement('div'); - info.className = 'material-slot-info'; - const label = document.createElement('div'); - label.className = 'material-slot-label'; - label.textContent = `Slot ${slot.id}`; - const material = document.createElement('div'); - material.className = 'material-slot-material'; - material.textContent = slot.empty ? 'Empty' : slot.material; - info.append(label, material); - - item.append(swatch, info); - slotList.append(item); - }); - - const mappings = byId('material-mappings'); - mappings.textContent = ''; - tools.forEach((tool, index) => { - const item = document.createElement('div'); - item.className = index === 0 ? 'material-mapping-item warning' : 'material-mapping-item'; - - const swatch = (color) => { - const element = document.createElement('span'); - element.className = 'material-mapping-swatch'; - element.style.backgroundColor = color; - return element; - }; - - const text = document.createElement('span'); - text.className = 'material-mapping-text'; - const arrow = document.createElement('span'); - arrow.className = 'material-mapping-arrow'; - arrow.textContent = '→'; - text.append(`Tool ${tool.id + 1} `, arrow, ` Slot ${tool.slot}`); - - const content = document.createElement('span'); - content.className = 'material-mapping-content'; - content.append( - swatch(tool.color), - text, - swatch(slots.find((slot) => slot.id === tool.slot).color) - ); - - const remove = document.createElement('button'); - remove.className = 'material-mapping-remove'; - remove.type = 'button'; - remove.textContent = '×'; - - item.append(content, remove); - mappings.append(item); - }); - - const warningList = byId('material-matching-warning-list'); - warningList.textContent = ''; - const warning = document.createElement('div'); - warning.className = 'material-matching-warning-item'; - warning.textContent = 'Tool 1 expects #26A69A but Slot 2 has #F72224.'; - warningList.append(warning); - byId('material-matching-warning').classList.remove('hidden'); - - byId('material-matching-confirm').disabled = false; - }); -} diff --git a/scripts/ui-shot-fixtures/upload.mjs b/scripts/ui-shot-fixtures/upload.mjs deleted file mode 100644 index 6d003c8..0000000 --- a/scripts/ui-shot-fixtures/upload.mjs +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @fileoverview ui-shot fixture: the Upload Job dialog with a fully parsed 3MF. - * - * Fills in a file selection, confirmed material mappings, slicer warnings and - * metadata, so the dialog is in the busiest state it can reach. - */ - -export default async function apply(page) { - await page.evaluate(() => { - const byId = (id) => document.getElementById(id); - byId('job-upload-modal').classList.remove('hidden'); - byId('job-upload-file-path').textContent = 'OrcaSlicer-2.4.0-alpha-3DBenchy_PLA_37m22s.gcode.3mf'; - - const swatch = (color) => { - const element = document.createElement('span'); - element.className = 'job-upload-mapping-swatch'; - element.style.backgroundColor = color; - return element; - }; - const text = (className, value) => { - const element = document.createElement('span'); - element.className = className; - element.textContent = value; - return element; - }; - const chip = (tool, slot, toolColor, slotColor, material) => { - const element = document.createElement('div'); - element.className = 'job-upload-mapping-chip'; - element.append( - swatch(toolColor), - text('job-upload-mapping-label', `Tool ${tool}`), - text('job-upload-mapping-arrow', '→'), - swatch(slotColor), - text('job-upload-mapping-label', `Slot ${slot}`), - text('job-upload-mapping-material', material) - ); - return element; - }; - - byId('job-upload-mappings-list').append( - chip(1, 2, '#26A69A', '#F72224', 'PLA'), - chip(2, 4, '#E8C62D', '#2D6EE8', 'PETG') - ); - byId('job-upload-mappings').classList.remove('hidden'); - - const warnings = byId('job-upload-meta-warnings'); - warnings.textContent = ''; - [ - 'The current hot bed temperature is relatively high. The nozzle may be clogged.', - 'Supports are disabled but overhangs were detected.', - ].forEach((message) => { - const item = document.createElement('div'); - item.className = 'job-upload-warning-item'; - const icon = document.createElement('span'); - icon.className = 'job-upload-warning-icon level-warning'; - icon.textContent = '⚠'; - const body = document.createElement('span'); - body.className = 'job-upload-warning-msg'; - body.textContent = message; - item.append(icon, body); - warnings.append(item); - }); - byId('job-upload-warnings-container').classList.remove('hidden'); - - const metadata = { - 'job-upload-meta-printer': 'Flashforge Adventurer 5M Pro', - 'job-upload-meta-filament-type': 'PLA', - 'job-upload-meta-filament-len': '3.77 m • 11.23 g', - 'job-upload-meta-slicer-name': 'OrcaSlicer', - 'job-upload-meta-slicer-ver': '2.4.0-alpha', - 'job-upload-meta-slice-date': '2026-05-30', - 'job-upload-meta-slice-time': '3:58 PM', - 'job-upload-meta-eta': '37m22s', - 'job-upload-meta-first-layer-time': '1m 1s', - 'job-upload-meta-layer-height': '0.2 mm', - 'job-upload-meta-infill': '15%', - 'job-upload-meta-layers': '240', - 'job-upload-meta-support': 'No', - }; - Object.entries(metadata).forEach(([id, value]) => { - const element = byId(id); - if (element) { - element.textContent = value; - } - }); - - const thumbnail = byId('job-upload-thumbnail'); - thumbnail.textContent = ''; - const preview = document.createElement('div'); - preview.style.cssText = 'width:100%;height:100%;background:linear-gradient(135deg,#0f6b5c,#26A69A)'; - thumbnail.append(preview); - - byId('job-upload-ok').disabled = false; - }); -} diff --git a/scripts/ui-shot.mjs b/scripts/ui-shot.mjs deleted file mode 100644 index 762c509..0000000 --- a/scripts/ui-shot.mjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @fileoverview Screenshot a WebUI screen headlessly, without running the app. - * - * Serves the built static WebUI over a throwaway HTTP server, opens one page in - * headless Chromium, runs a fixture that fills the DOM with representative data, - * and writes a PNG. No printer, no backend, no visible window - the point is to - * see a layout change in seconds instead of building, launching and clicking - * through the app to reach the screen. - * - * Usage: - * npm run build:webui # fixtures render the built output - * node scripts/ui-shot.mjs upload - * node scripts/ui-shot.mjs matching --width 420 --height 900 - * node scripts/ui-shot.mjs --all - * - * Add a screen by dropping a fixture in scripts/ui-shot-fixtures/ and listing it - * in TARGETS below. - */ - -import fs from 'node:fs'; -import http from 'node:http'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { chromium } from '@playwright/test'; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, '..'); -const staticRoot = path.join(repoRoot, 'dist', 'webui', 'static'); - -/** Screens this harness knows how to render. */ -const TARGETS = { - upload: { - root: staticRoot, - page: 'index.html', - fixture: 'upload.mjs', - clip: '#job-upload-modal .modal-content', - }, - matching: { - root: staticRoot, - page: 'index.html', - fixture: 'matching.mjs', - clip: '#material-matching-modal .modal-content', - }, -}; - -const MIME = { - '.html': 'text/html', - '.css': 'text/css', - '.js': 'text/javascript', - '.mjs': 'text/javascript', - '.json': 'application/json', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.svg': 'image/svg+xml', - '.woff': 'font/woff', - '.woff2': 'font/woff2', -}; - -function parseArgs(argv) { - const options = { targets: [], out: null, width: 1200, height: 1000 }; - - let index = 0; - - while (index < argv.length) { - const arg = argv[index]; - index += 1; - - if (arg === '--all') { - options.targets = Object.keys(TARGETS); - } else if (arg === '--out') { - options.out = argv[index]; - index += 1; - } else if (arg === '--width') { - options.width = Number(argv[index]); - index += 1; - } else if (arg === '--height') { - options.height = Number(argv[index]); - index += 1; - } else if (!arg.startsWith('-')) { - options.targets.push(arg); - } - } - - return options; -} - -/** Static file server, scoped to one directory. */ -function serve(root) { - const server = http.createServer((req, res) => { - const relative = decodeURIComponent((req.url || '/').split('?')[0]); - const file = path.join(root, relative); - - if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { - res.writeHead(404); - res.end(); - return; - } - - res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); - fs.createReadStream(file).pipe(res); - }); - - return new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve(server)); - }); -} - -async function shoot(name, options, browser) { - const target = TARGETS[name]; - if (!target) { - throw new Error(`Unknown target "${name}". Known: ${Object.keys(TARGETS).join(', ')}`); - } - if (!fs.existsSync(path.join(target.root, target.page))) { - throw new Error(`${target.page} not found in ${target.root} - run "npm run build:webui" first.`); - } - - const server = await serve(target.root); - const { port } = server.address(); - const page = await browser.newPage({ - viewport: { width: options.width, height: options.height }, - }); - - // The page's modules expect a live backend; their failures are irrelevant here - // because the fixture puts the DOM into the state we want to look at. - page.on('pageerror', () => {}); - - try { - await page.goto(`http://127.0.0.1:${port}/${target.page}`, { waitUntil: 'domcontentloaded' }); - - const fixture = await import( - pathToFileURL(path.join(scriptDir, 'ui-shot-fixtures', target.fixture)).href - ); - await fixture.default(page); - - const outPath = options.out || path.join(repoRoot, '.ui-shots', `${name}.png`); - fs.mkdirSync(path.dirname(outPath), { recursive: true }); - - const clip = target.clip ? page.locator(target.clip) : page; - await clip.screenshot({ path: outPath }); - - console.log(`${name} -> ${outPath}`); - } finally { - await page.close(); - server.close(); - } -} - -const options = parseArgs(process.argv.slice(2)); -if (options.targets.length === 0) { - console.log(`Usage: node scripts/ui-shot.mjs [--out file] [--width n] [--height n] -Targets: ${Object.keys(TARGETS).join(', ')}`); - process.exit(1); -} - -const browser = await chromium.launch(); -try { - for (const name of options.targets) { - await shoot(name, options, browser); - } -} finally { - await browser.close(); -} From fedc2a9a0d0610bbe80deac3686fbcc982fd35ed Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:23:57 -0400 Subject: [PATCH 36/59] chore(release): 1.2.0-alpha.7 --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4aeb7..8fcfb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0-alpha.7] - 2026-08-21 + ### Added - **Upload Job, ported from the desktop app.** The WebUI can now upload a `.gcode`, `.gx`, or `.3mf` file from the browser and start it. Before this, only the desktop app could do that, so a phone or a remote browser could only start files already on the printer. The dialog is a 1:1 port: file picker, **Start Now** and **Auto Level**, the plate thumbnail, and the full metadata grid — printer model, filament type and use, slicer name and version, slice date, print time, first layer time, layer height, infill, layer count, support, and slice warnings. The **Upload Job** button sits with the other job controls and turns on whenever the printer is ready for a new job. @@ -296,7 +298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional password authentication - Configuration persistence in `data/config.json` -[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.6...HEAD +[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.7...HEAD +[1.2.0-alpha.7]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.6...v1.2.0-alpha.7 [1.2.0-alpha.6]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.5...v1.2.0-alpha.6 [1.2.0-alpha.5]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.2...v1.2.0-alpha.5 [1.2.0-alpha.4]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.3...v1.2.0-alpha.4 diff --git a/package-lock.json b/package-lock.json index 0745e05..0894412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flashforge-webui", - "version": "1.2.0-alpha.6", + "version": "1.2.0-alpha.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flashforge-webui", - "version": "1.2.0-alpha.6", + "version": "1.2.0-alpha.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1aacb5a..19d6263 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flashforge-webui", - "version": "1.2.0-alpha.6", + "version": "1.2.0-alpha.7", "description": "Standalone WebUI for FlashForge 3D Printers", "main": "dist/index.js", "bin": "dist/index.js", From 8f007fff6a9e0c1e8673d4c3df2261f542f6b40c Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:05:22 -0400 Subject: [PATCH 37/59] fix(webui): route bed/extruder heater commands through HTTP on TCP-less printers The four single-extruder temperature routes sent raw ~M140/~M104 G-code through executeGCodeCommand, which hard-fails on HTTP-only backends -- so bed and extruder temps could not be set on the Creator 5 series at all (real firmware runs no TCP service on those printers). Bed/extruder set/cancel are now proper backend methods that branch on legacy client presence: dual-API printers keep the legacy TCP wire command (identical bytes to before), HTTP-only printers (Creator 5 / 5 Pro) use the ff-api tempControl endpoints with no TCP attempt, and GenericLegacyBackend (AD3/AD4) keeps its G-code path unchanged. Verified against live emulator instances: Creator 5 and AD5M Pro, all four routes per printer, asserting each emulator's /detail target temps (and the C5 four-nozzle array) reflect the commands. --- src/managers/PrinterBackendManager.ts | 84 ++++++ src/printer-backends/BasePrinterBackend.ts | 55 ++++ src/printer-backends/DualAPIBackend.ts | 57 ++++ src/printer-backends/GenericLegacyBackend.ts | 32 ++ .../temperature-control.test.ts | 278 ++++++++++++++++++ .../server/routes/temperature-routes.test.ts | 247 ++++++++++++++++ src/webui/server/routes/temperature-routes.ts | 179 +++++------ .../browser/webui-temperature-heaters.spec.ts | 194 ++++++++++++ 8 files changed, 1037 insertions(+), 89 deletions(-) create mode 100644 src/printer-backends/temperature-control.test.ts create mode 100644 src/webui/server/routes/temperature-routes.test.ts create mode 100644 tests/e2e/browser/webui-temperature-heaters.spec.ts diff --git a/src/managers/PrinterBackendManager.ts b/src/managers/PrinterBackendManager.ts index e24e233..00bdae0 100644 --- a/src/managers/PrinterBackendManager.ts +++ b/src/managers/PrinterBackendManager.ts @@ -475,6 +475,90 @@ export class PrinterBackendManager extends EventEmitter { return await backend.executeGCodeCommand(command); } + /** + * Set the print bed target temperature for a context + * + * Routes through the context backend, which prefers the legacy TCP channel on + * dual-API printers and the HTTP temperature-control API on HTTP-only models + * (Creator 5 series). + * + * @param contextId - Context ID + * @param temperature - Target temperature in Celsius + * @returns Command result + */ + public async setBedTemperature(contextId: string, temperature: number): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.setBedTemperature(temperature); + } + + /** + * Cancel print bed heating for a context + * + * @param contextId - Context ID + * @returns Command result + */ + public async cancelBedTemperature(contextId: string): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.cancelBedTemperature(); + } + + /** + * Set the extruder target temperature for a context + * + * Routes through the context backend, which prefers the legacy TCP channel on + * dual-API printers and the HTTP temperature-control API on HTTP-only models + * (Creator 5 series). + * + * @param contextId - Context ID + * @param temperature - Target temperature in Celsius + * @returns Command result + */ + public async setExtruderTemperature( + contextId: string, + temperature: number + ): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.setExtruderTemperature(temperature); + } + + /** + * Cancel extruder heating for a context + * + * @param contextId - Context ID + * @returns Command result + */ + public async cancelExtruderTemperature(contextId: string): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.cancelExtruderTemperature(); + } + + /** + * Shared failure result for temperature commands with no initialized backend + */ + private noBackendTemperatureResult(): CommandResult { + return { + success: false, + error: 'No backend initialized', + timestamp: new Date(), + }; + } + /** * Get current printer status * diff --git a/src/printer-backends/BasePrinterBackend.ts b/src/printer-backends/BasePrinterBackend.ts index 1ef77fd..02bc3d5 100644 --- a/src/printer-backends/BasePrinterBackend.ts +++ b/src/printer-backends/BasePrinterBackend.ts @@ -569,6 +569,61 @@ export abstract class BasePrinterBackend extends EventEmitter { */ public abstract setLedEnabled(enabled: boolean): Promise; + /** + * Set the print bed target temperature + * @param temperature - Target temperature in Celsius + * @returns Command result with success/failure + */ + public abstract setBedTemperature(temperature: number): Promise; + + /** + * Cancel print bed heating (target back to 0) + * @returns Command result with success/failure + */ + public abstract cancelBedTemperature(): Promise; + + /** + * Set the extruder target temperature + * @param temperature - Target temperature in Celsius + * @returns Command result with success/failure + */ + public abstract setExtruderTemperature(temperature: number): Promise; + + /** + * Cancel extruder heating (target back to 0) + * @returns Command result with success/failure + */ + public abstract cancelExtruderTemperature(): Promise; + + /** + * Wrap a boolean temperature-control action into a CommandResult. + * + * Shared by the backend heater implementations so the only backend-specific + * logic is the client routing (legacy TCP vs HTTP temperature control). + * + * @param action - Heater action resolving to the transport's success flag + * @returns Command result with success/failure + */ + protected async runTemperatureControlCommand( + action: () => Promise + ): Promise { + try { + const success = await action(); + + return { + success, + error: success ? undefined : 'Temperature command failed', + timestamp: new Date(), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }; + } + } + // Helper methods for feature detection protected abstract supportsNewAPI(): boolean; diff --git a/src/printer-backends/DualAPIBackend.ts b/src/printer-backends/DualAPIBackend.ts index 00732cd..5e41bd2 100644 --- a/src/printer-backends/DualAPIBackend.ts +++ b/src/printer-backends/DualAPIBackend.ts @@ -253,6 +253,63 @@ export abstract class DualAPIBackend extends BasePrinterBackend { } } + // ----- Bed / extruder heater control --------------------------------------- + + /** + * Set the bed target temperature. + * + * Dual-API printers keep preferring the legacy TCP channel (the same `~M140` + * wire command the raw G-code path sends); HTTP-only backends (Creator 5 + * series, no TCP channel) route through the FiveMClient temperature-control + * API. The branch is on client presence, so an HTTP-only printer never + * attempts a TCP connection. + */ + public async setBedTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.setBedTemp(temperature) + : this.fiveMClient.tempControl.setBedTemp(temperature) + ); + } + + /** + * Cancel bed heating. Legacy channel sends `~M140 S0`; the HTTP-only path + * uses the temperature-control cancel command. + */ + public async cancelBedTemperature(): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.cancelBedTemp() + : this.fiveMClient.tempControl.cancelBedTemp() + ); + } + + /** + * Set the extruder target temperature. Legacy channel sends `~M104`; the + * HTTP-only path (Creator 5 series) drives the primary tool via the + * temperature-control API. + */ + public async setExtruderTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.setExtruderTemp(temperature) + : this.fiveMClient.tempControl.setExtruderTemp(temperature) + ); + } + + /** + * Cancel extruder heating. Legacy channel sends `~M104 S0`; the HTTP-only + * path (Creator 5 series) turns the primary tool off via the + * temperature-control API. + */ + public async cancelExtruderTemperature(): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.cancelExtruderTemp() + : this.fiveMClient.tempControl.cancelExtruderTemp() + ); + } + /** * Get current printer status using new API with legacy fallback * Common implementation with hooks for backend-specific fields diff --git a/src/printer-backends/GenericLegacyBackend.ts b/src/printer-backends/GenericLegacyBackend.ts index 2dae594..954638e 100644 --- a/src/printer-backends/GenericLegacyBackend.ts +++ b/src/printer-backends/GenericLegacyBackend.ts @@ -148,6 +148,38 @@ export class GenericLegacyBackend extends BasePrinterBackend { } } + // ----- Bed / extruder heater control (legacy TCP G-code channel) ----------- + + /** + * Set the bed target temperature via the legacy TCP G-code channel + * (`~M140 S`), matching the raw G-code path this backend has always used. + */ + public async setBedTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.setBedTemp(temperature)); + } + + /** + * Cancel bed heating via the legacy TCP G-code channel (`~M140 S0`). + */ + public async cancelBedTemperature(): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.cancelBedTemp()); + } + + /** + * Set the extruder target temperature via the legacy TCP G-code channel + * (`~M104 S`). + */ + public async setExtruderTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.setExtruderTemp(temperature)); + } + + /** + * Cancel extruder heating via the legacy TCP G-code channel (`~M104 S0`). + */ + public async cancelExtruderTemperature(): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.cancelExtruderTemp()); + } + /** * Get current printer status using legacy API */ diff --git a/src/printer-backends/temperature-control.test.ts b/src/printer-backends/temperature-control.test.ts new file mode 100644 index 0000000..f0ff8bd --- /dev/null +++ b/src/printer-backends/temperature-control.test.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview Jest coverage for backend bed/extruder heater routing. + * + * Covers the client-presence routing introduced for the Creator 5 series: + * - HTTP-only backends (Creator 5 / 5 Pro, no legacy TCP client) must use the + * FiveMClient temperature-control API and never touch raw G-code. + * - Legacy TCP-only backends (GenericLegacyBackend) must keep using the legacy + * G-code channel (`~M140` / `~M104` via the FlashForgeClient temp methods). + * - Dual-API backends with both clients must prefer the legacy channel. + * + * The `@ghosttypes/ff-api` classes are replaced with in-memory doubles so the + * `instanceof` routing inside the backends is exercised without sockets. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { BackendInitOptions, CommandResult } from '../types/printer-backend'; + +jest.mock('@ghosttypes/ff-api', () => { + const actual = jest.requireActual>('@ghosttypes/ff-api'); + return { + ...actual, + FiveMClient: class { + public readonly tempControl = { + setBedTemp: jest.fn<(temp: number) => Promise>(async () => true), + cancelBedTemp: jest.fn<() => Promise>(async () => true), + setExtruderTemp: jest.fn<(temp: number) => Promise>(async () => true), + cancelExtruderTemp: jest.fn<() => Promise>(async () => true), + }; + }, + FlashForgeClient: class { + public readonly setBedTemp = jest.fn<(temp: number) => Promise>(async () => true); + public readonly cancelBedTemp = jest.fn<() => Promise>(async () => true); + public readonly setExtruderTemp = jest.fn<(temp: number) => Promise>( + async () => true + ); + public readonly cancelExtruderTemp = jest.fn<() => Promise>(async () => true); + }, + }; +}); + +import { FiveMClient, FlashForgeClient } from '@ghosttypes/ff-api'; +import { Adventurer5MBackend } from './Adventurer5MBackend'; +import { Creator5Backend } from './Creator5Backend'; +import { GenericLegacyBackend } from './GenericLegacyBackend'; + +interface TempControlDouble { + readonly setBedTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelBedTemp: jest.Mock<() => Promise>; + readonly setExtruderTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelExtruderTemp: jest.Mock<() => Promise>; +} + +interface LegacyClientDouble { + readonly setBedTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelBedTemp: jest.Mock<() => Promise>; + readonly setExtruderTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelExtruderTemp: jest.Mock<() => Promise>; +} + +function createFiveMClient(): FiveMClient { + return new FiveMClient('127.0.0.1', 'FFSN01', 'CODE01'); +} + +function httpTempControl(client: FiveMClient): TempControlDouble { + return (client as unknown as { tempControl: TempControlDouble }).tempControl; +} + +function createLegacyClient(): FlashForgeClient { + return new FlashForgeClient('127.0.0.1'); +} + +function legacyDouble(client: FlashForgeClient): LegacyClientDouble { + return client as unknown as LegacyClientDouble; +} + +function backendOptions( + printerModel: BackendInitOptions['printerModel'], + primaryClient: FiveMClient | FlashForgeClient, + secondaryClient?: FlashForgeClient +): BackendInitOptions { + return { + printerModel, + primaryClient, + secondaryClient, + printerDetails: { + name: 'Test Printer', + ipAddress: '127.0.0.1', + serialNumber: 'FFSN01', + typeName: 'FlashForge Test', + }, + }; +} + +function createCreator5Backend(): { backend: Creator5Backend; tempControl: TempControlDouble } { + const fiveMClient = createFiveMClient(); + const backend = new Creator5Backend(backendOptions('creator-5', fiveMClient)); + return { backend, tempControl: httpTempControl(fiveMClient) }; +} + +function createDualBackend(): { + backend: Adventurer5MBackend; + tempControl: TempControlDouble; + legacy: LegacyClientDouble; +} { + const fiveMClient = createFiveMClient(); + const legacyClient = createLegacyClient(); + const backend = new Adventurer5MBackend( + backendOptions('adventurer-5m', fiveMClient, legacyClient) + ); + return { + backend, + tempControl: httpTempControl(fiveMClient), + legacy: legacyDouble(legacyClient), + }; +} + +function createLegacyBackend(): { backend: GenericLegacyBackend; legacy: LegacyClientDouble } { + const legacyClient = createLegacyClient(); + const backend = new GenericLegacyBackend(backendOptions('generic-legacy', legacyClient)); + return { backend, legacy: legacyDouble(legacyClient) }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Creator5Backend heater routing (HTTP-only, no legacy client)', () => { + it('sets the bed temperature through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + const gcodeSpy = jest.spyOn(backend, 'executeGCodeCommand'); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(tempControl.setBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.setBedTemp).toHaveBeenCalledWith(60); + expect(gcodeSpy).not.toHaveBeenCalled(); + }); + + it('cancels bed heating through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(tempControl.cancelBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.setBedTemp).not.toHaveBeenCalled(); + }); + + it('sets and cancels the extruder temperature through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + + const setResult = await backend.setExtruderTemperature(210); + expect(setResult.success).toBe(true); + expect(tempControl.setExtruderTemp).toHaveBeenCalledWith(210); + + const cancelResult = await backend.cancelExtruderTemperature(); + expect(cancelResult.success).toBe(true); + expect(tempControl.cancelExtruderTemp).toHaveBeenCalledTimes(1); + }); + + it('maps HTTP temperature-control failures to an unsuccessful CommandResult', async () => { + const { backend, tempControl } = createCreator5Backend(); + tempControl.setBedTemp.mockRejectedValueOnce(new Error('HTTP request failed')); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP request failed'); + }); + + it('maps a rejected HTTP temperature-control command to an unsuccessful CommandResult', async () => { + const { backend, tempControl } = createCreator5Backend(); + tempControl.cancelExtruderTemp.mockResolvedValueOnce(false); + + const result = await backend.cancelExtruderTemperature(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Temperature command failed'); + }); +}); + +describe('DualAPIBackend heater routing (legacy TCP preferred)', () => { + it('prefers the legacy TCP client for the bed heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(legacy.setBedTemp).toHaveBeenCalledTimes(1); + expect(legacy.setBedTemp).toHaveBeenCalledWith(60); + expect(tempControl.setBedTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for canceling the bed heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.cancelBedTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for the extruder heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.setExtruderTemperature(210); + + expect(result.success).toBe(true); + expect(legacy.setExtruderTemp).toHaveBeenCalledTimes(1); + expect(legacy.setExtruderTemp).toHaveBeenCalledWith(210); + expect(tempControl.setExtruderTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for canceling the extruder heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.cancelExtruderTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelExtruderTemp).toHaveBeenCalledTimes(1); + expect(tempControl.cancelExtruderTemp).not.toHaveBeenCalled(); + }); + + it('maps legacy client failures to an unsuccessful CommandResult', async () => { + const { backend, legacy } = createDualBackend(); + legacy.setExtruderTemp.mockRejectedValueOnce(new Error('socket hang up')); + + const result = await backend.setExtruderTemperature(210); + + expect(result.success).toBe(false); + expect(result.error).toBe('socket hang up'); + }); +}); + +describe('GenericLegacyBackend heater routing (TCP G-code only)', () => { + it('sets the bed temperature through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(legacy.setBedTemp).toHaveBeenCalledTimes(1); + expect(legacy.setBedTemp).toHaveBeenCalledWith(60); + }); + + it('cancels bed heating through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelBedTemp).toHaveBeenCalledTimes(1); + }); + + it('sets and cancels the extruder temperature through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const setResult = await backend.setExtruderTemperature(210); + expect(setResult.success).toBe(true); + expect(legacy.setExtruderTemp).toHaveBeenCalledWith(210); + + const cancelResult = await backend.cancelExtruderTemperature(); + expect(cancelResult.success).toBe(true); + expect(legacy.cancelExtruderTemp).toHaveBeenCalledTimes(1); + }); + + it('maps legacy G-code failures to an unsuccessful CommandResult', async () => { + const { backend, legacy } = createLegacyBackend(); + legacy.setBedTemp.mockRejectedValueOnce(new Error('write EPIPE')); + + const result: CommandResult = await backend.setBedTemperature(60); + + expect(result.success).toBe(false); + expect(result.error).toBe('write EPIPE'); + }); +}); diff --git a/src/webui/server/routes/temperature-routes.test.ts b/src/webui/server/routes/temperature-routes.test.ts new file mode 100644 index 0000000..3fd50d7 --- /dev/null +++ b/src/webui/server/routes/temperature-routes.test.ts @@ -0,0 +1,247 @@ +/** + * @fileoverview Jest coverage for WebUI bed/extruder temperature route handlers. + * + * Asserts the single-tool heater routes delegate to backend-manager temperature + * commands (which route HTTP-only printers such as the Creator 5 series onto + * the HTTP temperature-control API) instead of raw G-code, along with request + * validation, multi-context routing, and error responses. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import express from 'express'; +import type { CommandResult } from '../../../types/printer-backend'; +import { startTestServer } from './test-server'; +import { registerTemperatureRoutes } from './temperature-routes'; + +function okResult(): CommandResult { + return { success: true, timestamp: new Date() }; +} + +function createDependencies() { + return { + backendManager: { + isBackendReady: jest.fn<(contextId: string) => boolean>(() => true), + setBedTemperature: jest.fn< + (contextId: string, temperature: number) => Promise + >(async () => okResult()), + cancelBedTemperature: jest.fn<(contextId: string) => Promise>(async () => + okResult() + ), + setExtruderTemperature: jest.fn< + (contextId: string, temperature: number) => Promise + >(async () => okResult()), + cancelExtruderTemperature: jest.fn<(contextId: string) => Promise>(async () => + okResult() + ), + executeGCodeCommand: jest.fn<(contextId: string, command: string) => Promise>(), + }, + contextManager: { + getActiveContextId: jest.fn<() => string | null>(() => 'context-1'), + getContext: jest.fn<(contextId: string) => unknown>((contextId) => + contextId === 'context-1' || contextId === 'context-2' + ? { id: contextId, printerDetails: {} } + : undefined + ), + }, + connectionManager: {}, + configManager: {}, + spoolmanService: {}, + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + } as any; +} + +interface TestDeps { + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + backendManager: any; + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + contextManager: any; +} + +async function startTemperatureServer(deps: TestDeps) { + return await startTestServer((app) => { + const router = express.Router(); + registerTemperatureRoutes(router, deps as never); + app.use('/api', router); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('temperature-routes (bed/extruder heaters)', () => { + it('sets the bed temperature through the backend manager, never raw G-code', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 60 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Setting bed temperature to 60°C', + error: undefined, + }); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledTimes(1); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledWith('context-1', 60); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('turns the bed heater off through the backend manager cancel command', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed/off`, { + method: 'POST', + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Bed heating turned off', + error: undefined, + }); + expect(deps.backendManager.cancelBedTemperature).toHaveBeenCalledWith('context-1'); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('sets the extruder temperature (rounded) through the backend manager', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 210.4 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Setting extruder temperature to 210°C', + error: undefined, + }); + expect(deps.backendManager.setExtruderTemperature).toHaveBeenCalledWith('context-1', 210); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('turns the extruder heater off through the backend manager cancel command', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder/off`, { + method: 'POST', + }); + await server.close(); + + expect(response.status).toBe(200); + expect(deps.backendManager.cancelExtruderTemperature).toHaveBeenCalledWith('context-1'); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('routes heater commands to an explicit contextId override', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch( + `${server.baseUrl}/api/printer/temperature/bed?contextId=context-2`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 55 }), + } + ); + await server.close(); + + expect(response.status).toBe(200); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledWith('context-2', 55); + }); + + it('rejects out-of-range temperatures with a 400 validation error', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 500 }), + }); + const body = (await response.json()) as { success: boolean; error: string }; + await server.close(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(typeof body.error).toBe('string'); + expect(deps.backendManager.setBedTemperature).not.toHaveBeenCalled(); + }); + + it('returns 500 when the backend manager reports a failed command', async () => { + const deps = createDependencies(); + deps.backendManager.setBedTemperature.mockResolvedValueOnce({ + success: false, + error: 'Temperature command failed', + timestamp: new Date(), + }); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 60 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + success: false, + message: undefined, + error: 'Temperature command failed', + }); + }); + + it('returns 503 when no printer context is active', async () => { + const deps = createDependencies(); + deps.contextManager.getActiveContextId.mockReturnValue(null); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed/off`, { + method: 'POST', + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(503); + expect(body).toEqual({ + success: false, + error: 'No active printer context', + }); + expect(deps.backendManager.cancelBedTemperature).not.toHaveBeenCalled(); + }); + + it('returns 503 when the printer backend is not ready', async () => { + const deps = createDependencies(); + deps.backendManager.isBackendReady.mockReturnValue(false); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 200 }), + }); + await server.close(); + + expect(response.status).toBe(503); + expect(deps.backendManager.setExtruderTemperature).not.toHaveBeenCalled(); + }); +}); diff --git a/src/webui/server/routes/temperature-routes.ts b/src/webui/server/routes/temperature-routes.ts index f2c6bf8..0d31f9c 100644 --- a/src/webui/server/routes/temperature-routes.ts +++ b/src/webui/server/routes/temperature-routes.ts @@ -4,6 +4,7 @@ import { FiveMClient } from '@ghosttypes/ff-api'; import type { Response, Router } from 'express'; +import type { CommandResult } from '../../../types/printer-backend'; import { toAppError } from '../../../utils/error.utils'; import { createValidationError, TemperatureSetRequestSchema } from '../../schemas/web-api.schemas'; import type { StandardAPIResponse } from '../../types/web-api.types'; @@ -17,88 +18,42 @@ const CHAMBER_MAX_TEMP = 80; const MAX_TOOL_COUNT = 4; export function registerTemperatureRoutes(router: Router, deps: RouteDependencies): void { - router.post('/printer/temperature/bed', async (req: AuthenticatedRequest, res: Response) => { - try { - const contextResult = resolveContext(req, deps, { requireBackendReady: true }); - if (!contextResult.success) { - return sendErrorResponse( - res, - contextResult.statusCode, - contextResult.error - ); - } - - const validation = TemperatureSetRequestSchema.safeParse(req.body); - if (!validation.success) { - const validationError = createValidationError(validation.error); - return sendErrorResponse(res, 400, validationError.error); - } - - const temperature = Math.round(validation.data.temperature); - const result = await deps.backendManager.executeGCodeCommand( - contextResult.contextId, - `~M140 S${temperature}` - ); + // ----- Single-tool bed / extruder heaters ----------------------------------- + // These route through backend-manager temperature commands rather than raw + // G-code: dual-API printers keep using the legacy TCP channel, while + // HTTP-only printers (Creator 5 series, which have no TCP channel) use the + // HTTP temperature-control API. - const response: StandardAPIResponse = { - success: result.success, - message: result.success ? `Setting bed temperature to ${temperature}°C` : undefined, - error: result.error, - }; - return res.status(result.success ? 200 : 500).json(response); - } catch (error) { - const appError = toAppError(error); - return sendErrorResponse(res, 500, appError.message); - } + router.post('/printer/temperature/bed', async (req: AuthenticatedRequest, res: Response) => { + return handleSetTemperature(req, res, deps, 'bed', (contextId, temperature) => + deps.backendManager.setBedTemperature(contextId, temperature) + ); }); router.post('/printer/temperature/bed/off', async (req: AuthenticatedRequest, res: Response) => { - await handleSimpleTemperatureCommand(req, res, deps, '~M140 S0', 'Bed heating turned off'); + return handleTemperatureCommand( + req, + res, + deps, + (contextId) => deps.backendManager.cancelBedTemperature(contextId), + 'Bed heating turned off' + ); }); router.post('/printer/temperature/extruder', async (req: AuthenticatedRequest, res: Response) => { - try { - const contextResult = resolveContext(req, deps, { requireBackendReady: true }); - if (!contextResult.success) { - return sendErrorResponse( - res, - contextResult.statusCode, - contextResult.error - ); - } - - const validation = TemperatureSetRequestSchema.safeParse(req.body); - if (!validation.success) { - const validationError = createValidationError(validation.error); - return sendErrorResponse(res, 400, validationError.error); - } - - const temperature = Math.round(validation.data.temperature); - const result = await deps.backendManager.executeGCodeCommand( - contextResult.contextId, - `~M104 S${temperature}` - ); - - const response: StandardAPIResponse = { - success: result.success, - message: result.success ? `Setting extruder temperature to ${temperature}°C` : undefined, - error: result.error, - }; - return res.status(result.success ? 200 : 500).json(response); - } catch (error) { - const appError = toAppError(error); - return sendErrorResponse(res, 500, appError.message); - } + return handleSetTemperature(req, res, deps, 'extruder', (contextId, temperature) => + deps.backendManager.setExtruderTemperature(contextId, temperature) + ); }); router.post( '/printer/temperature/extruder/off', async (req: AuthenticatedRequest, res: Response) => { - await handleSimpleTemperatureCommand( + return handleTemperatureCommand( req, res, deps, - '~M104 S0', + (contextId) => deps.backendManager.cancelExtruderTemperature(contextId), 'Extruder heating turned off' ); } @@ -144,49 +99,56 @@ export function registerTemperatureRoutes(router: Router, deps: RouteDependencie } /** - * Run a FiveMClient temperature-control action for the resolved context. Used by the - * Creator 5 per-tool and chamber heaters, which have no G-code passthrough. + * Validate and run a backend-manager set-temperature command for the resolved + * context. Used by the single-tool bed and extruder heaters. */ -async function handleTempControl( +async function handleSetTemperature( req: AuthenticatedRequest, res: Response, deps: RouteDependencies, - action: (client: FiveMClient) => Promise, - successMessage: string + heaterLabel: string, + run: (contextId: string, temperature: number) => Promise ): Promise { try { - const contextResult = resolveContext(req, deps, { - requireBackendReady: true, - requireBackendInstance: true, - }); + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); if (!contextResult.success) { - return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + return sendErrorResponse( + res, + contextResult.statusCode, + contextResult.error + ); } - const { backend } = contextResult; - const primaryClient = backend?.getPrimaryClient(); - if (!(primaryClient instanceof FiveMClient)) { - return sendErrorResponse(res, 400, 'Temperature control requires new API client'); + const validation = TemperatureSetRequestSchema.safeParse(req.body); + if (!validation.success) { + const validationError = createValidationError(validation.error); + return sendErrorResponse(res, 400, validationError.error); } - const result = await action(primaryClient); + const temperature = Math.round(validation.data.temperature); + const result = await run(contextResult.contextId, temperature); + const response: StandardAPIResponse = { - success: result, - message: result ? successMessage : undefined, - error: result ? undefined : 'Temperature command failed', + success: result.success, + message: result.success ? `Setting ${heaterLabel} temperature to ${temperature}°C` : undefined, + error: result.error, }; - return res.status(result ? 200 : 500).json(response); + return res.status(result.success ? 200 : 500).json(response); } catch (error) { const appError = toAppError(error); return sendErrorResponse(res, 500, appError.message); } } -async function handleSimpleTemperatureCommand( +/** + * Run a backend-manager temperature command for the resolved context. Used by + * the bed/extruder "off" endpoints, which carry no request body. + */ +async function handleTemperatureCommand( req: AuthenticatedRequest, res: Response, deps: RouteDependencies, - command: string, + action: (contextId: string) => Promise, successMessage: string ): Promise { try { @@ -199,7 +161,7 @@ async function handleSimpleTemperatureCommand( ); } - const result = await deps.backendManager.executeGCodeCommand(contextResult.contextId, command); + const result = await action(contextResult.contextId); const response: StandardAPIResponse = { success: result.success, @@ -212,3 +174,42 @@ async function handleSimpleTemperatureCommand( return sendErrorResponse(res, 500, appError.message); } } + +/** + * Run a FiveMClient temperature-control action for the resolved context. Used by the + * Creator 5 per-tool and chamber heaters, which have no G-code passthrough. + */ +async function handleTempControl( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies, + action: (client: FiveMClient) => Promise, + successMessage: string +): Promise { + try { + const contextResult = resolveContext(req, deps, { + requireBackendReady: true, + requireBackendInstance: true, + }); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { backend } = contextResult; + const primaryClient = backend?.getPrimaryClient(); + if (!(primaryClient instanceof FiveMClient)) { + return sendErrorResponse(res, 400, 'Temperature control requires new API client'); + } + + const result = await action(primaryClient); + const response: StandardAPIResponse = { + success: result, + message: result ? successMessage : undefined, + error: result ? undefined : 'Temperature command failed', + }; + return res.status(result ? 200 : 500).json(response); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} diff --git a/tests/e2e/browser/webui-temperature-heaters.spec.ts b/tests/e2e/browser/webui-temperature-heaters.spec.ts new file mode 100644 index 0000000..7e72d06 --- /dev/null +++ b/tests/e2e/browser/webui-temperature-heaters.spec.ts @@ -0,0 +1,194 @@ +/** + * @fileoverview End-to-end coverage for the single-tool bed/extruder heater routes. + * + * The four `/api/printer/temperature/{bed,extruder}[/off]` routes used to send + * raw G-code (`~M140` / `~M104`) over TCP, which hard-fails on the HTTP-only + * Creator 5 series (no TCP channel). They now route through backend-manager + * temperature commands that branch on client presence: dual-API printers keep + * the legacy TCP G-code channel, HTTP-only printers use the HTTP + * temperature-control API. + * + * This spec pins both halves of that contract against real emulated printers + * driven through the real server: a Creator 5 (HTTP-only path) and an + * Adventurer 5M Pro (legacy TCP path), asserting the heater targets actually + * change in each emulator's own /detail state. + */ + +import { expect, test } from '@playwright/test'; +import { + type StandalonePrinter, + type StandaloneWebUI, + startStandaloneWebUI, + WEBUI_TEST_PASSWORD, +} from './helpers/standalone-server'; + +const FIVE_M_PRO_PRINTER: StandalonePrinter = { + label: 'Adventurer 5M Pro (emulated)', + model: 'adventurer-5m-pro', + serial: 'E2E-WEBUI-HEAT-5MPRO', + checkCode: '123', + machineName: 'Heat-5MPro', + tcpPort: 8899, + httpPort: 8898, +}; + +const CREATOR5_PRINTER: StandalonePrinter = { + label: 'Creator 5 (emulated)', + model: 'creator-5', + serial: 'E2E-WEBUI-HEAT-C5', + checkCode: '123', + machineName: 'Heat-Creator5', + tcpPort: 8999, + httpPort: 8998, +}; + +const HEATER_PRINTERS: readonly StandalonePrinter[] = [FIVE_M_PRO_PRINTER, CREATOR5_PRINTER]; + +interface ContextsPayload { + contexts: Array<{ id: string; name: string; isActive: boolean }>; +} + +interface EmulatorDetail { + platTargetTemp?: number; + rightTargetTemp?: number; + nozzleTargetTemps?: number[]; +} + +const apiToken = async (webui: StandaloneWebUI): Promise => { + const response = await fetch(`${webui.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: WEBUI_TEST_PASSWORD }), + }); + const payload = (await response.json()) as { token?: string }; + if (!payload.token) { + throw new Error('Could not obtain an API token for heater assertions'); + } + return payload.token; +}; + +const resolveContextId = async ( + webui: StandaloneWebUI, + token: string, + machineName: string +): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const { contexts } = (await response.json()) as ContextsPayload; + const target = contexts.find((context) => context.name === machineName); + if (!target) { + throw new Error(`expected a context for ${machineName}`); + } + return target.id; +}; + +const postHeaterCommand = async ( + webui: StandaloneWebUI, + token: string, + contextId: string, + endpoint: string, + body?: Record +): Promise<{ status: number; payload: { success?: boolean; error?: string } }> => { + const response = await fetch( + `${webui.baseUrl}/api/printer/temperature/${endpoint}?contextId=${contextId}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + } + ); + return { + status: response.status, + payload: (await response.json()) as { success?: boolean; error?: string }, + }; +}; + +const readEmulatorDetail = async (printer: StandalonePrinter): Promise => { + const response = await fetch(`http://127.0.0.1:${printer.httpPort}/detail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serialNumber: printer.serial, checkCode: printer.checkCode }), + }); + const payload = (await response.json()) as { detail?: EmulatorDetail }; + return payload.detail ?? {}; +}; + +test.describe('WebUI bed/extruder heater routes', () => { + let webui: StandaloneWebUI; + /** Fetched once per suite; the login rate limiter budgets 5 attempts / 15 min. */ + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: HEATER_PRINTERS }); + token = await apiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const printer of HEATER_PRINTERS) { + test(`sets and cancels bed and extruder targets on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Bed on: the emulator's own /detail must report the new target. + const bedResult = await postHeaterCommand(webui, token, contextId, 'bed', { + temperature: 60, + }); + expect(bedResult.status, `bed set failed: ${bedResult.payload.error}`).toBe(200); + expect(bedResult.payload.success).toBe(true); + await expect + .poll(async () => (await readEmulatorDetail(printer)).platTargetTemp) + .toBe(60); + + // Bed off: target returns to 0. + const bedOffResult = await postHeaterCommand(webui, token, contextId, 'bed/off'); + expect(bedOffResult.status, `bed off failed: ${bedOffResult.payload.error}`).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).platTargetTemp) + .toBe(0); + + // Extruder on: /detail's rightTemp alias mirrors tool 0 on every model. + const extruderResult = await postHeaterCommand(webui, token, contextId, 'extruder', { + temperature: 215, + }); + expect(extruderResult.status, `extruder set failed: ${extruderResult.payload.error}`).toBe( + 200 + ); + expect(extruderResult.payload.success).toBe(true); + await expect + .poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp) + .toBe(215); + + // Extruder off: target returns to 0. + const extruderOffResult = await postHeaterCommand(webui, token, contextId, 'extruder/off'); + expect( + extruderOffResult.status, + `extruder off failed: ${extruderOffResult.payload.error}` + ).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp) + .toBe(0); + }); + } + + test('drives the Creator 5 primary tool through the nozzles array', async () => { + const printer = CREATOR5_PRINTER; + const contextId = await resolveContextId(webui, token, printer.machineName); + + const result = await postHeaterCommand(webui, token, contextId, 'extruder', { + temperature: 205, + }); + expect(result.status).toBe(200); + + const detail = await readEmulatorDetail(printer); + // The Creator 5 firmware only honors the 4-entry nozzles array for tool + // control; the HTTP path must land there, not in a legacy scalar field. + expect(detail.nozzleTargetTemps?.[0]).toBe(205); + expect(detail.nozzleTargetTemps?.slice(1)).toEqual([0, 0, 0]); + }); +}); From 3984d67f033ec0d9a8bdeb747324fe40998d8dcd Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:52:52 -0400 Subject: [PATCH 38/59] ci: add GitHub Actions CI with emulator E2E track Add .github/workflows/ci.yml for FlashForgeWebUI with two jobs. verify: type-check, lint, build, and unit tests on Node 20. Sets up GitHub Packages auth for the @ghosttypes and @parallel-7 scopes so npm ci can install the scoped deps. e2e-emulator: browser E2E with Playwright chromium against flashforge-emulator-v2, checked out at pinned tag v0.2.0 (overridable via the emulator-ref workflow_dispatch input). The emulator supplies the printers, so no real hardware is needed. Its install skips the Electron binary because only the headless Node runtime is used; this track drives dist/index.js, not an Electron window. Playwright test-results and report upload as artifacts on failure. Triggers on push and pull_request to main/alpha, ignores doc-only paths, and cancels in-progress runs per ref. --- .github/workflows/ci.yml | 144 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b9eece --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,144 @@ +name: CI + +on: + push: + branches: [main, alpha] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - '.editorconfig' + pull_request: + branches: [main, alpha] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - '.editorconfig' + workflow_dispatch: + inputs: + emulator-ref: + description: 'flashforge-emulator-v2 git ref to run the E2E track against' + required: false + default: 'v0.2.0' + type: string + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Type-check, lint, build, test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout FlashForgeWebUI + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Configure GitHub Packages Authentication + shell: bash + run: | + echo "@ghosttypes:registry=https://npm.pkg.github.com" >> .npmrc + echo "@parallel-7:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + shell: bash + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Type check + run: npm run type-check + + - name: Lint + run: npm run lint + + - name: Build (backend + webui) + run: npm run build + + - name: Unit tests + run: npm test + + e2e-emulator: + name: E2E (emulator + browser) + runs-on: ubuntu-latest + needs: verify + timeout-minutes: 40 + steps: + - name: Checkout FlashForgeWebUI + uses: actions/checkout@v4 + + # The emulator lives in a separate public repo and provides the printers this + # track drives, so no real hardware is needed. Pinned to a release tag so an + # emulator change cannot silently break or silently change this CI; bump the + # tag (or dispatch with emulator-ref) to pick up a new emulator release. + - name: Checkout flashforge-emulator-v2 + uses: actions/checkout@v4 + with: + repository: GhostTypes/flashforge-emulator-v2 + ref: ${{ inputs.emulator-ref || 'v0.2.0' }} + path: emulator + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Configure GitHub Packages Authentication + shell: bash + run: | + echo "@ghosttypes:registry=https://npm.pkg.github.com" >> .npmrc + echo "@parallel-7:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies + shell: bash + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install emulator dependencies + working-directory: emulator + run: npm ci + env: + # Only the emulator's headless runtime is used here; skip the large + # Electron binary download this repo's e2e-tests track never launches. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + + # The browser track needs the chromium binary itself, not just its system libraries. + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium + + # Unlike FlashForgeUI-Electron's CI there is no xvfb-run and no chrome-sandbox + # fixup here: this track drives the plain Node server (dist/index.js) with + # headless Chromium, never a real Electron window. + - name: Run browser E2E against the emulator + run: npm run test:e2e + env: + FF_EMULATOR_ROOT: ${{ github.workspace }}/emulator + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-e2e-artifacts + path: | + test-results/ + playwright-report/ + retention-days: 7 + if-no-files-found: ignore From 19cb81c90bf91961bd79694349593e90d3a26d15 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:06:35 -0400 Subject: [PATCH 39/59] fix: mark bundled go2rtc binaries as executable Change the file mode of all five bundled go2rtc binaries (darwin-arm64, darwin-x64, linux-arm, linux-arm64, linux-x64) from 100644 to 100755. Contents are unchanged; only permissions differ. The binaries are committed to the repo, so a fresh Linux/macOS checkout restores them without the executable bit, and the postinstall downloader skips chmod because the files already exist ("Already exists" early return). Spawning go2rtc then fails with EACCES and server startup aborts with "[Fatal] go2rtc process exited during startup". This broke the new CI e2e-emulator job (dist/index.js on ubuntu-latest) while Windows local runs stayed green, since Windows has no exec bit. The pkg track never hit it because extractBundledBinary() already chmods after unpacking. Fixes the mode at the source so every checkout, git archive, and clone gets executable binaries on Unix platforms. --- resources/bin/darwin-arm64/go2rtc | Bin resources/bin/darwin-x64/go2rtc | Bin resources/bin/linux-arm/go2rtc | Bin resources/bin/linux-arm64/go2rtc | Bin resources/bin/linux-x64/go2rtc | Bin 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 resources/bin/darwin-arm64/go2rtc mode change 100644 => 100755 resources/bin/darwin-x64/go2rtc mode change 100644 => 100755 resources/bin/linux-arm/go2rtc mode change 100644 => 100755 resources/bin/linux-arm64/go2rtc mode change 100644 => 100755 resources/bin/linux-x64/go2rtc diff --git a/resources/bin/darwin-arm64/go2rtc b/resources/bin/darwin-arm64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/darwin-x64/go2rtc b/resources/bin/darwin-x64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-arm/go2rtc b/resources/bin/linux-arm/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-arm64/go2rtc b/resources/bin/linux-arm64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-x64/go2rtc b/resources/bin/linux-x64/go2rtc old mode 100644 new mode 100755 From f8c41f10d0b396dcb9f1b4b757f45b49a6c33273 Mon Sep 17 00:00:00 2001 From: GhostTypes <106415648+GhostTypes@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:57:44 -0400 Subject: [PATCH 40/59] test: add per-model connect, LED, upload E2E matrix Expand the standalone WebUI browser E2E suite from 14 to 38 tests, mirroring FlashForgeUI-Electron's per-model emulator track, and refactor it onto shared infrastructure. - helpers/targets.ts: four-model emulator matrix (Adventurer 5M Pro, AD5X, Creator 5, Creator 5 Pro) with capability flags (isCreator5Series, hasMaterialStation, productId, printerModel). Specs loop this list so every model is covered everywhere by construction, exactly like FFUI-Electron's support/track.ts. The matrix, never the printer's /detail, is the source of truth - stock Creator 5 firmware no longer reports the material station in /detail but still has one and still requires mappings on a fresh upload. - New upload spec (mirrors FFUI-E's upload.spec.ts): browser-driven upload through the real dialog per model, material matching dialog must appear for every 3MF on station printers (AD5X, Creator 5, Creator 5 Pro, single tool included) and never on 5M-series (bounded 10s wait), tool-to-slot assignment then confirm, Start Now unchecked, file-landed assertion via the recent-jobs list (the printer's /gcodeList), and an assertNotPrinting safety gate reading the emulator's own /detail. API-level three-call upload for all models pins the gcode-refused-on-station gate and the mapping payload. - New connect spec: auto-connect of the saved printer on boot, per-model manual connect via POST /printers/connect (productId for HTTP-only models), wrong check-code rejection leaving no context, UDP discovery finding every instance with the saved one flagged known. - New LED spec: on/off cycle per model asserted in the emulator's /detail lightStatus. - helpers/api.ts: shared token fetch, context resolution, raw/JSON POST, remembered-token page open, printer switch. Tests open pages via the remembered-token restore path to stay inside the server's 5-logins-per-15-minutes rate limit. - standalone-server harness: seedPrinters option to boot emulators without auto-connecting them (used by the connect spec). - Gating and heater specs refactored onto the shared matrix: gating now covers AD5X too, heaters cover all four models plus the nozzles-array contract on both Creator 5s. - Fixtures copied from FlashForgeUI-Electron: ad5x-single-tool.3mf and adventurer5m-single-color.gcode. Full suite: 38 passed in 2.0m locally against emulator v0.2.0. --- tests/e2e/browser/helpers/api.ts | 145 + .../e2e/browser/helpers/standalone-server.ts | 13 +- tests/e2e/browser/helpers/targets.ts | 116 + tests/e2e/browser/webui-connect.spec.ts | 175 + .../e2e/browser/webui-creator5-gating.spec.ts | 116 +- tests/e2e/browser/webui-led.spec.ts | 74 + .../browser/webui-temperature-heaters.spec.ts | 165 +- tests/e2e/browser/webui-upload.spec.ts | 287 + .../fixtures/print-files/ad5x-single-tool.3mf | Bin 0 -> 664764 bytes .../adventurer5m-single-color.gcode | 21227 ++++++++++++++++ 10 files changed, 22097 insertions(+), 221 deletions(-) create mode 100644 tests/e2e/browser/helpers/api.ts create mode 100644 tests/e2e/browser/helpers/targets.ts create mode 100644 tests/e2e/browser/webui-connect.spec.ts create mode 100644 tests/e2e/browser/webui-led.spec.ts create mode 100644 tests/e2e/browser/webui-upload.spec.ts create mode 100644 tests/fixtures/print-files/ad5x-single-tool.3mf create mode 100644 tests/fixtures/print-files/adventurer5m-single-color.gcode diff --git a/tests/e2e/browser/helpers/api.ts b/tests/e2e/browser/helpers/api.ts new file mode 100644 index 0000000..12bc8b3 --- /dev/null +++ b/tests/e2e/browser/helpers/api.ts @@ -0,0 +1,145 @@ +/** + * @fileoverview Shared API/browser helpers for browser E2E specs. + * + * Extracted from the per-spec duplicates (gating and heaters both carried private + * copies of the token fetch and context resolution): every helper here talks to + * the REAL server or the REAL emulator and asserts nothing itself. + * + * Login budget discipline: the server rate-limits /api/auth/login to 5 attempts + * per 15 minutes per IP and counts successful logins. A suite that uses these + * helpers spends exactly two (harness readiness login + one fetchApiToken) if + * every UI test opens pages through openWithRememberedToken instead of the login + * form — the token restore path is the app's own "remember me" behaviour. + */ + +import { expect, type Page } from '@playwright/test'; +import type { StandalonePrinter, StandaloneWebUI } from './standalone-server'; +import { WEBUI_TEST_PASSWORD } from './standalone-server'; + +interface ContextsPayload { + contexts: Array<{ id: string; name: string; isActive: boolean }>; +} + +export interface EmulatorDetail { + /** Machine status string (e.g. "Idle"); the not-printing safety gate reads it. */ + status?: string; + printFileName?: string; + platTargetTemp?: number; + rightTargetTemp?: number; + nozzleTargetTemps?: number[]; + lightStatus?: string; +} + +/** Logs in through the real auth route to obtain a bearer token for API-driven assertions. */ +export const fetchApiToken = async (webui: StandaloneWebUI): Promise => { + const response = await fetch(`${webui.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: webui.password ?? WEBUI_TEST_PASSWORD }), + }); + + const payload = (await response.json()) as { token?: string; message?: string }; + if (!payload.token) { + throw new Error(`Could not obtain an API token: ${payload.message ?? response.status}`); + } + + return payload.token; +}; + +/** Resolves a printer's context id from the server by machine name; never assumes which context is active. */ +export const resolveContextId = async ( + webui: StandaloneWebUI, + token: string, + machineName: string +): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const { contexts } = (await response.json()) as ContextsPayload; + const target = contexts.find((context) => context.name === machineName); + if (!target) { + throw new Error(`expected a context for ${machineName}`); + } + return target.id; +}; + +/** Reads the emulator printer's own /detail state - the ground truth assertions poll. */ +export const readEmulatorDetail = async (printer: StandalonePrinter): Promise => { + const response = await fetch(`http://127.0.0.1:${printer.httpPort}/detail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serialNumber: printer.serial, checkCode: printer.checkCode }), + }); + const payload = (await response.json()) as { detail?: EmulatorDetail }; + return payload.detail ?? {}; +}; + +/** Authenticated JSON POST against the server, returning status plus parsed body. */ +export const postJson = async ( + webui: StandaloneWebUI, + token: string, + path: string, + body?: Record +): Promise<{ status: number; payload: T }> => { + const response = await fetch(`${webui.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: response.status, payload: (await response.json()) as T }; +}; + +/** Authenticated raw-bytes POST (e.g. job file staging), returning status plus parsed body. */ +export const postRaw = async ( + webui: StandaloneWebUI, + token: string, + path: string, + body: Buffer +): Promise<{ status: number; payload: T }> => { + const response = await fetch(`${webui.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + }, + body: new Uint8Array(body), + }); + return { status: response.status, payload: (await response.json()) as T }; +}; + +/** + * Opens the WebUI already authenticated via the remembered token. + * + * Seeds localStorage through addInitScript, which runs before any app script on + * every navigation - the same code path a returning "remember me" user takes, so + * it costs zero logins against the rate limiter. Asserts the dashboard came up + * and the websocket passed the real auth gate ("Connected"). + */ +export const openWithRememberedToken = async ( + page: Page, + webui: StandaloneWebUI, + token: string +): Promise => { + await page.addInitScript((value) => localStorage.setItem('webui-token', value), token); + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + await expect(page.locator('#main-ui')).toBeVisible(); + await expect(page.locator('#login-screen')).toBeHidden(); + await expect(page.locator('#connection-text')).toHaveText('Connected'); +}; + +/** Switches the active printer context through the built WebUI's printer selector. */ +export const switchPrinterInUi = async ( + page: Page, + webui: StandaloneWebUI, + token: string, + machineName: string +): Promise => { + const contextId = await resolveContextId(webui, token, machineName); + const selector = page.locator('#printer-select'); + await expect(selector).toBeVisible(); + await selector.selectOption(contextId); + await expect(page.locator('#connection-text')).toHaveText('Connected'); +}; diff --git a/tests/e2e/browser/helpers/standalone-server.ts b/tests/e2e/browser/helpers/standalone-server.ts index b46c7d4..7b9f25e 100644 --- a/tests/e2e/browser/helpers/standalone-server.ts +++ b/tests/e2e/browser/helpers/standalone-server.ts @@ -86,6 +86,12 @@ export interface StandaloneWebUI { export interface StartStandaloneWebUIOptions { printers?: readonly StandalonePrinter[]; + /** + * Printers seeded into the saved profile; defaults to `printers`. Pass a subset + * (or []) to boot emulators the server does NOT auto-connect, e.g. so specs can + * exercise the manual connect flow against them. + */ + seedPrinters?: readonly StandalonePrinter[]; password?: string; /** Number of printers the server must have connected before tests start. */ requireConnectedPrinters?: number; @@ -338,8 +344,9 @@ export const startStandaloneWebUI = async ( options: StartStandaloneWebUIOptions = {} ): Promise => { const printers = options.printers ?? DEFAULT_STANDALONE_PRINTERS; + const seedPrinters = options.seedPrinters ?? printers; const password = options.password ?? WEBUI_TEST_PASSWORD; - const requiredPrinters = options.requireConnectedPrinters ?? printers.length; + const requiredPrinters = options.requireConnectedPrinters ?? seedPrinters.length; const emulators: Array<{ stop: () => Promise }> = []; let dataRoot: string | null = null; @@ -389,7 +396,7 @@ export const startStandaloneWebUI = async ( const port = await findFreePort(); const baseUrl = `http://127.0.0.1:${port}`; - await seedDataDir(dataDir, printers, printerIp, port, password); + await seedDataDir(dataDir, seedPrinters, printerIp, port, password); server = spawnServer({ dataDir, port, password }); await waitForServer(baseUrl, server, SERVER_READY_TIMEOUT_MS); @@ -401,7 +408,7 @@ export const startStandaloneWebUI = async ( return { baseUrl, password, - printers, + printers: seedPrinters, logTail: () => formatLogTail(startedServer.logLines), stop: cleanup, }; diff --git a/tests/e2e/browser/helpers/targets.ts b/tests/e2e/browser/helpers/targets.ts new file mode 100644 index 0000000..a430af4 --- /dev/null +++ b/tests/e2e/browser/helpers/targets.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview Per-model emulator matrix for browser E2E suites. + * + * Ported from FlashForgeUI-Electron's `support/track.ts` pattern: one source of + * truth listing every emulated model the suite must exercise, each carrying the + * capability flags assertions derive from. Specs loop this list so a new model is + * covered everywhere by construction instead of by whichever models a spec author + * remembered to hand-pick. + * + * The matrix matches FlashForgeUI-Electron's emulator track (four models). All + * four sit on distinct ports so a single server instance can hold them at once; + * suites run sequentially (playwright workers: 1), so reusing the same ports + * across suites is fine. + */ + +import type { EmulatorModel } from './emulator-harness'; +import type { StandalonePrinter } from './standalone-server'; + +export type ModelToken = Exclude; + +/** + * USB product ID every real unit of this model carries; the discovery packet and + * the manual-connect model selector both use it, and HTTP-only models cannot be + * connected without it (it is what skips the legacy TCP probe). + */ +export const MODEL_PRODUCT_IDS: Readonly> = { + 'adventurer-5m': 35, + 'adventurer-5m-pro': 36, + 'adventurer-5x': 38, + 'creator-5': 40, + 'creator-5-pro': 41, +}; + +export interface ModelTarget { + readonly printer: StandalonePrinter; + /** USB product ID the manual connect API needs (see MODEL_PRODUCT_IDS). */ + readonly productId: number; + /** printerModel display string the app's detection reports for this model. */ + readonly printerModel: string; + /** + * Creator 5 series: HTTP-only firmware with no TCP/G-code passthrough. Local and + * recent job entry points are gated off and raw G-code controls (Home Axes) stay + * disabled. + */ + readonly isCreator5Series: boolean; + /** Models carrying an inventory/material feed station (IFS). */ + readonly hasMaterialStation: boolean; +} + +export const MODEL_TARGETS: readonly ModelTarget[] = [ + { + printer: { + label: 'Adventurer 5M Pro (emulated)', + model: 'adventurer-5m-pro', + serial: 'E2E-WEBUI-M-5MPRO', + checkCode: '123', + machineName: 'Matrix-5MPro', + tcpPort: 8899, + httpPort: 8898, + }, + isCreator5Series: false, + hasMaterialStation: false, + productId: MODEL_PRODUCT_IDS['adventurer-5m-pro'], + printerModel: 'Adventurer 5M Pro', + }, + { + printer: { + label: 'AD5X (emulated)', + model: 'adventurer-5x', + serial: 'E2E-WEBUI-M-AD5X', + checkCode: '123', + machineName: 'Matrix-AD5X', + tcpPort: 8999, + httpPort: 8998, + }, + isCreator5Series: false, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['adventurer-5x'], + printerModel: 'AD5X', + }, + { + printer: { + label: 'Creator 5 (emulated)', + model: 'creator-5', + serial: 'E2E-WEBUI-M-C5', + checkCode: '123', + machineName: 'Matrix-Creator5', + tcpPort: 9099, + httpPort: 9098, + }, + isCreator5Series: true, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['creator-5'], + printerModel: 'Creator 5', + }, + { + printer: { + label: 'Creator 5 Pro (emulated)', + model: 'creator-5-pro', + serial: 'E2E-WEBUI-M-C5PRO', + checkCode: '123', + machineName: 'Matrix-Creator5Pro', + tcpPort: 9199, + httpPort: 9198, + }, + isCreator5Series: true, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['creator-5-pro'], + printerModel: 'Creator 5 Pro', + }, +]; + +/** The matrix as a plain printer list, for startStandaloneWebUI({ printers }). */ +export const MATRIX_PRINTERS: readonly StandalonePrinter[] = MODEL_TARGETS.map( + (target) => target.printer +); diff --git a/tests/e2e/browser/webui-connect.spec.ts b/tests/e2e/browser/webui-connect.spec.ts new file mode 100644 index 0000000..6a77d82 --- /dev/null +++ b/tests/e2e/browser/webui-connect.spec.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Printer connection flow coverage, mirroring FlashForgeUI-Electron's + * connect spec against the standalone server's API. + * + * One suite boot starts the full model matrix of emulators but seeds only the + * Adventurer 5M Pro as a saved printer, so the remaining models must be reached + * through the real connect paths the webui offers: + * + * - auto-connect of saved printers on boot (--all-saved-printers) + * - manual connect per model via POST /printers/connect, including the HTTP-only + * Creator 5 series where a serial number is mandatory + * - rejection of a wrong check code without leaving a half-created context + * - network discovery (UDP broadcast) seeing every emulated instance and marking + * the saved one as known + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface ContextsPayload { + contexts: Array<{ + id: string; + name: string; + model?: string; + serialNumber?: string; + isActive: boolean; + }>; +} + +interface ConnectPayload { + success?: boolean; + contextId?: string; + error?: string; + printer?: { SerialNumber?: string }; +} + +interface DiscoveryPayload { + success?: boolean; + error?: string; + printers?: Array<{ serialNumber?: string }>; + savedMatches?: Array<{ isKnown?: boolean; ipAddressChanged?: boolean }>; +} + +/** The matrix's first entry: a plain dual-API printer, seeded as the saved profile. */ +const SAVED_TARGET = MODEL_TARGETS[0]; +if (!SAVED_TARGET) { + throw new Error('MODEL_TARGETS must not be empty'); +} + +const fetchContexts = async ( + webui: StandaloneWebUI, + token: string +): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const payload = (await response.json()) as ContextsPayload; + return payload.contexts ?? []; +}; + +test.describe('WebUI printer connect flow', () => { + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ + printers: MATRIX_PRINTERS, + seedPrinters: [SAVED_TARGET.printer], + }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + test('auto-connects the saved printer on boot', async () => { + const contexts = await fetchContexts(webui, token); + expect(contexts, 'only the seeded printer may be auto-connected').toHaveLength(1); + expect(contexts[0]?.name).toBe(SAVED_TARGET.printer.machineName); + expect(contexts[0]?.isActive).toBe(true); + }); + + for (const target of MODEL_TARGETS.slice(1)) { + const printer = target.printer; + + test(`connects to ${printer.machineName} through the manual connect API`, async () => { + const { status, payload } = await postJson( + webui, + token, + '/api/printers/connect', + { + ipAddress: '127.0.0.1', + type: 'new', + checkCode: printer.checkCode, + serialNumber: printer.serial, + name: printer.machineName, + productId: target.productId, + commandPort: printer.tcpPort, + httpPort: printer.httpPort, + } + ); + + expect(status, `connect failed: ${payload.error}`).toBe(200); + expect(payload.success).toBe(true); + expect(payload.contextId, 'a context must exist for the new printer').toBeTruthy(); + expect(payload.printer?.SerialNumber).toBe(printer.serial); + + const contexts = await fetchContexts(webui, token); + const match = contexts.find((context) => context.serialNumber === printer.serial); + expect( + match, + `expected a context for serial ${printer.serial} (${printer.machineName})` + ).toBeTruthy(); + expect(match?.model, `model detection must identify ${printer.machineName}`).toBe( + target.printerModel + ); + }); + } + + test('rejects a wrong check code without creating a context', async () => { + const printer = MODEL_TARGETS[1]?.printer; + if (!printer) { + throw new Error('MODEL_TARGETS must contain a second model'); + } + + const contextsBefore = await fetchContexts(webui, token); + + const { status, payload } = await postJson( + webui, + token, + '/api/printers/connect', + { + ipAddress: '127.0.0.1', + type: 'new', + checkCode: '000', + serialNumber: `${printer.serial}-BADCODE`, + name: 'Bad-CheckCode', + commandPort: printer.tcpPort, + httpPort: printer.httpPort, + } + ); + + expect(status, 'a wrong check code must not succeed').toBeGreaterThanOrEqual(400); + expect(payload.success).not.toBe(true); + + const contextsAfter = await fetchContexts(webui, token); + expect(contextsAfter).toHaveLength(contextsBefore.length); + }); + + test('finds every emulated printer through network discovery', async () => { + const { status, payload } = await postJson( + webui, + token, + '/api/discovery/scan', + { timeout: 8000, interval: 1000, retries: 3 } + ); + + expect(status, `discovery scan failed: ${payload.error}`).toBe(200); + expect(payload.success).toBe(true); + + const found = new Set((payload.printers ?? []).map((found) => found.serialNumber)); + for (const target of MODEL_TARGETS) { + expect( + found.has(target.printer.serial), + `discovery must report ${target.printer.machineName}` + ).toBe(true); + } + + const savedMatch = payload.savedMatches?.find((match) => match.isKnown === true); + expect(savedMatch, 'the seeded saved printer must be flagged as known').toBeTruthy(); + }); +}); diff --git a/tests/e2e/browser/webui-creator5-gating.spec.ts b/tests/e2e/browser/webui-creator5-gating.spec.ts index 2865a8f..9519399 100644 --- a/tests/e2e/browser/webui-creator5-gating.spec.ts +++ b/tests/e2e/browser/webui-creator5-gating.spec.ts @@ -11,126 +11,52 @@ * It also pins the HTTP-only side effect: Home Axes is raw G-code (~G28), and the * series exposes no TCP/G-code passthrough, so the button must stay disabled for * those models and enabled on a 5M-family printer. + * + * Every model in the matrix is exercised (see helpers/targets.ts); the expected + * state derives from each target's isCreator5Series flag. */ -import { expect, type Page, test } from '@playwright/test'; -import { - type StandalonePrinter, - type StandaloneWebUI, - startStandaloneWebUI, - WEBUI_TEST_PASSWORD, -} from './helpers/standalone-server'; +import { expect, test } from '@playwright/test'; +import { fetchApiToken, openWithRememberedToken, switchPrinterInUi } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; const LOCAL_JOB_UNAVAILABLE_MESSAGE = 'Local job management is not available on this printer.'; -const GATING_PRINTERS: readonly StandalonePrinter[] = [ - { - label: 'Adventurer 5M Pro (emulated)', - model: 'adventurer-5m-pro', - serial: 'E2E-WEBUI-GATE-5MPRO', - checkCode: '123', - machineName: 'Gate-5MPro', - tcpPort: 8899, - httpPort: 8898, - }, - { - label: 'Creator 5 (emulated)', - model: 'creator-5', - serial: 'E2E-WEBUI-GATE-C5', - checkCode: '123', - machineName: 'Gate-Creator5', - tcpPort: 8999, - httpPort: 8998, - }, - { - label: 'Creator 5 Pro (emulated)', - model: 'creator-5-pro', - serial: 'E2E-WEBUI-GATE-C5PRO', - checkCode: '123', - machineName: 'Gate-Creator5Pro', - tcpPort: 9099, - httpPort: 9098, - }, -]; - -interface ContextsPayload { - contexts: Array<{ id: string; name: string; isActive: boolean }>; -} - -const CREATOR5_SERIALS = new Set(['E2E-WEBUI-GATE-C5', 'E2E-WEBUI-GATE-C5PRO']); - -const apiToken = async (webui: StandaloneWebUI): Promise => { - const response = await fetch(`${webui.baseUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: WEBUI_TEST_PASSWORD }), - }); - const payload = (await response.json()) as { token?: string }; - if (!payload.token) { - throw new Error('Could not obtain an API token for out-of-band assertions'); - } - return payload.token; -}; - -const signIn = async (page: Page, webui: StandaloneWebUI): Promise => { - await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); - await page.fill('#password-input', WEBUI_TEST_PASSWORD); - await page.click('#login-button'); - await expect(page.locator('#main-ui')).toBeVisible(); -}; - test.describe('WebUI Creator 5 capability gating', () => { let webui: StandaloneWebUI; /** * Shared API token, fetched once in beforeAll. * * The server's login rate limiter allows 5 attempts per 15 minutes per IP and - * counts successful logins too, so the file budget is: harness login (1), this - * token (2), and one UI login per test (3-5). Fetching a token per test would - * trip the limiter - same constraint the FlashForgeUI-Electron browser suite - * is budgeted around. + * counts successful logins too. This suite spends two (harness readiness + + * this token); every test then opens its page through the remembered-token + * restore path, which is not a login. Same constraint the + * FlashForgeUI-Electron browser suite is budgeted around. */ let token: string; test.beforeAll(async () => { - webui = await startStandaloneWebUI({ printers: GATING_PRINTERS }); - token = await apiToken(webui); + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); }); test.afterAll(async () => { await webui?.stop(); }); - for (const printer of GATING_PRINTERS) { - test(`gates local job entry points per model on ${printer.machineName}`, async ({ page }) => { - const isCreator5 = CREATOR5_SERIALS.has(printer.serial); - - await signIn(page, webui); - await expect(page.locator('#connection-text')).toHaveText('Connected'); - - // Resolve this printer's context id from the server (never assume which - // context is active) and switch to it through the built UI. - const contextsResponse = await fetch(`${webui.baseUrl}/api/contexts`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const { contexts } = (await contextsResponse.json()) as ContextsPayload; - - const target = contexts.find((context) => context.name === printer.machineName); - expect(target, `expected a context for ${printer.machineName}`).toBeTruthy(); - - const selector = page.locator('#printer-select'); - await expect(selector).toBeVisible(); - await selector.selectOption(target!.id); - - // The webui must settle on the switched printer's dashboard before the button - // states mean anything. - await expect(page.locator('#connection-text')).toHaveText('Connected'); + for (const target of MODEL_TARGETS) { + test(`gates local job entry points per model on ${target.printer.machineName}`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, target.printer.machineName); const recentButton = page.locator('#btn-start-recent'); const localButton = page.locator('#btn-start-local'); const homeAxesButton = page.locator('#btn-home-axes'); - if (isCreator5) { + if (target.isCreator5Series) { await expect( recentButton, 'recent job start must be disabled on the Creator 5 series' @@ -158,7 +84,7 @@ test.describe('WebUI Creator 5 capability gating', () => { ).toBeEnabled(); await expect( homeAxesButton, - 'Home Axes must be available on 5M-family printers' + 'Home Axes must be available on printers with a G-code channel' ).toBeEnabled(); } }); diff --git a/tests/e2e/browser/webui-led.spec.ts b/tests/e2e/browser/webui-led.spec.ts new file mode 100644 index 0000000..070af61 --- /dev/null +++ b/tests/e2e/browser/webui-led.spec.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview LED control coverage across every model, mirroring FlashForgeUI-Electron's + * emulator-track LED spec. + * + * Drives the real server's led-on/led-off routes for each model in the matrix and + * asserts the change in the emulator printer's own /detail state (lightStatus + * open/close), never in rendered UI: a button that toggles its own styling while + * the command never reaches the printer is precisely the failure this catches. + * + * The cycle ends with the LED back on, which is the state a user leaves a printer in. + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson, readEmulatorDetail, resolveContextId } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface CommandPayload { + success?: boolean; + error?: string; +} + +const postLedCommand = async ( + webui: StandaloneWebUI, + token: string, + contextId: string, + enabled: boolean +): Promise<{ status: number; payload: CommandPayload }> => + await postJson( + webui, + token, + `/api/printer/control/led-${enabled ? 'on' : 'off'}?contextId=${contextId}` + ); + +test.describe('WebUI LED control', () => { + let webui: StandaloneWebUI; + /** Fetched once per suite; the login rate limiter budgets 5 attempts / 15 min. */ + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const target of MODEL_TARGETS) { + const printer = target.printer; + + test(`cycles the LED on, off, and back on on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Start from a known state so the first assertion cannot pass by accident. + const initialOff = await postLedCommand(webui, token, contextId, false); + expect(initialOff.status, `initial LED off failed: ${initialOff.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('close'); + + const onResult = await postLedCommand(webui, token, contextId, true); + expect(onResult.status, `LED on failed: ${onResult.payload.error}`).toBe(200); + expect(onResult.payload.success).toBe(true); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('open'); + + const offResult = await postLedCommand(webui, token, contextId, false); + expect(offResult.status, `LED off failed: ${offResult.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('close'); + + const onAgain = await postLedCommand(webui, token, contextId, true); + expect(onAgain.status, `LED on again failed: ${onAgain.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('open'); + }); + } +}); diff --git a/tests/e2e/browser/webui-temperature-heaters.spec.ts b/tests/e2e/browser/webui-temperature-heaters.spec.ts index 7e72d06..ed96792 100644 --- a/tests/e2e/browser/webui-temperature-heaters.spec.ts +++ b/tests/e2e/browser/webui-temperature-heaters.spec.ts @@ -1,5 +1,5 @@ /** - * @fileoverview End-to-end coverage for the single-tool bed/extruder heater routes. + * @fileoverview End-to-end coverage for the bed/extruder heater routes across every model. * * The four `/api/printer/temperature/{bed,extruder}[/off]` routes used to send * raw G-code (`~M140` / `~M104`) over TCP, which hard-fails on the HTTP-only @@ -9,113 +9,35 @@ * temperature-control API. * * This spec pins both halves of that contract against real emulated printers - * driven through the real server: a Creator 5 (HTTP-only path) and an - * Adventurer 5M Pro (legacy TCP path), asserting the heater targets actually - * change in each emulator's own /detail state. + * driven through the real server, looping the full model matrix so every + * printer's heater path is exercised: a set/cancel cycle per model asserting the + * targets actually change in each emulator's own /detail state, plus the + * Creator 5 series nozzles-array contract. */ import { expect, test } from '@playwright/test'; -import { - type StandalonePrinter, - type StandaloneWebUI, - startStandaloneWebUI, - WEBUI_TEST_PASSWORD, -} from './helpers/standalone-server'; - -const FIVE_M_PRO_PRINTER: StandalonePrinter = { - label: 'Adventurer 5M Pro (emulated)', - model: 'adventurer-5m-pro', - serial: 'E2E-WEBUI-HEAT-5MPRO', - checkCode: '123', - machineName: 'Heat-5MPro', - tcpPort: 8899, - httpPort: 8898, -}; - -const CREATOR5_PRINTER: StandalonePrinter = { - label: 'Creator 5 (emulated)', - model: 'creator-5', - serial: 'E2E-WEBUI-HEAT-C5', - checkCode: '123', - machineName: 'Heat-Creator5', - tcpPort: 8999, - httpPort: 8998, -}; - -const HEATER_PRINTERS: readonly StandalonePrinter[] = [FIVE_M_PRO_PRINTER, CREATOR5_PRINTER]; - -interface ContextsPayload { - contexts: Array<{ id: string; name: string; isActive: boolean }>; -} +import { fetchApiToken, postJson, readEmulatorDetail, resolveContextId } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; -interface EmulatorDetail { - platTargetTemp?: number; - rightTargetTemp?: number; - nozzleTargetTemps?: number[]; +interface CommandPayload { + success?: boolean; + error?: string; } -const apiToken = async (webui: StandaloneWebUI): Promise => { - const response = await fetch(`${webui.baseUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: WEBUI_TEST_PASSWORD }), - }); - const payload = (await response.json()) as { token?: string }; - if (!payload.token) { - throw new Error('Could not obtain an API token for heater assertions'); - } - return payload.token; -}; - -const resolveContextId = async ( - webui: StandaloneWebUI, - token: string, - machineName: string -): Promise => { - const response = await fetch(`${webui.baseUrl}/api/contexts`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const { contexts } = (await response.json()) as ContextsPayload; - const target = contexts.find((context) => context.name === machineName); - if (!target) { - throw new Error(`expected a context for ${machineName}`); - } - return target.id; -}; - const postHeaterCommand = async ( webui: StandaloneWebUI, token: string, contextId: string, endpoint: string, body?: Record -): Promise<{ status: number; payload: { success?: boolean; error?: string } }> => { - const response = await fetch( - `${webui.baseUrl}/api/printer/temperature/${endpoint}?contextId=${contextId}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: body === undefined ? undefined : JSON.stringify(body), - } +): Promise<{ status: number; payload: CommandPayload }> => + await postJson( + webui, + token, + `/api/printer/temperature/${endpoint}?contextId=${contextId}`, + body ); - return { - status: response.status, - payload: (await response.json()) as { success?: boolean; error?: string }, - }; -}; - -const readEmulatorDetail = async (printer: StandalonePrinter): Promise => { - const response = await fetch(`http://127.0.0.1:${printer.httpPort}/detail`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serialNumber: printer.serial, checkCode: printer.checkCode }), - }); - const payload = (await response.json()) as { detail?: EmulatorDetail }; - return payload.detail ?? {}; -}; test.describe('WebUI bed/extruder heater routes', () => { let webui: StandaloneWebUI; @@ -123,15 +45,17 @@ test.describe('WebUI bed/extruder heater routes', () => { let token: string; test.beforeAll(async () => { - webui = await startStandaloneWebUI({ printers: HEATER_PRINTERS }); - token = await apiToken(webui); + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); }); test.afterAll(async () => { await webui?.stop(); }); - for (const printer of HEATER_PRINTERS) { + for (const target of MODEL_TARGETS) { + const printer = target.printer; + test(`sets and cancels bed and extruder targets on ${printer.machineName}`, async () => { const contextId = await resolveContextId(webui, token, printer.machineName); @@ -141,16 +65,12 @@ test.describe('WebUI bed/extruder heater routes', () => { }); expect(bedResult.status, `bed set failed: ${bedResult.payload.error}`).toBe(200); expect(bedResult.payload.success).toBe(true); - await expect - .poll(async () => (await readEmulatorDetail(printer)).platTargetTemp) - .toBe(60); + await expect.poll(async () => (await readEmulatorDetail(printer)).platTargetTemp).toBe(60); // Bed off: target returns to 0. const bedOffResult = await postHeaterCommand(webui, token, contextId, 'bed/off'); expect(bedOffResult.status, `bed off failed: ${bedOffResult.payload.error}`).toBe(200); - await expect - .poll(async () => (await readEmulatorDetail(printer)).platTargetTemp) - .toBe(0); + await expect.poll(async () => (await readEmulatorDetail(printer)).platTargetTemp).toBe(0); // Extruder on: /detail's rightTemp alias mirrors tool 0 on every model. const extruderResult = await postHeaterCommand(webui, token, contextId, 'extruder', { @@ -160,9 +80,7 @@ test.describe('WebUI bed/extruder heater routes', () => { 200 ); expect(extruderResult.payload.success).toBe(true); - await expect - .poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp) - .toBe(215); + await expect.poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp).toBe(215); // Extruder off: target returns to 0. const extruderOffResult = await postHeaterCommand(webui, token, contextId, 'extruder/off'); @@ -170,25 +88,26 @@ test.describe('WebUI bed/extruder heater routes', () => { extruderOffResult.status, `extruder off failed: ${extruderOffResult.payload.error}` ).toBe(200); - await expect - .poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp) - .toBe(0); + await expect.poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp).toBe(0); }); } - test('drives the Creator 5 primary tool through the nozzles array', async () => { - const printer = CREATOR5_PRINTER; - const contextId = await resolveContextId(webui, token, printer.machineName); + for (const target of MODEL_TARGETS.filter((candidate) => candidate.isCreator5Series)) { + const printer = target.printer; - const result = await postHeaterCommand(webui, token, contextId, 'extruder', { - temperature: 205, - }); - expect(result.status).toBe(200); + test(`drives the ${printer.machineName} primary tool through the nozzles array`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); - const detail = await readEmulatorDetail(printer); - // The Creator 5 firmware only honors the 4-entry nozzles array for tool - // control; the HTTP path must land there, not in a legacy scalar field. - expect(detail.nozzleTargetTemps?.[0]).toBe(205); - expect(detail.nozzleTargetTemps?.slice(1)).toEqual([0, 0, 0]); - }); + const result = await postHeaterCommand(webui, token, contextId, 'extruder', { + temperature: 205, + }); + expect(result.status).toBe(200); + + const detail = await readEmulatorDetail(printer); + // The Creator 5 series firmware only honors the 4-entry nozzles array for + // tool control; the HTTP path must land there, not in a legacy scalar field. + expect(detail.nozzleTargetTemps?.[0]).toBe(205); + expect(detail.nozzleTargetTemps?.slice(1)).toEqual([0, 0, 0]); + }); + } }); diff --git a/tests/e2e/browser/webui-upload.spec.ts b/tests/e2e/browser/webui-upload.spec.ts new file mode 100644 index 0000000..6f601a6 --- /dev/null +++ b/tests/e2e/browser/webui-upload.spec.ts @@ -0,0 +1,287 @@ +/** + * @fileoverview Job upload coverage across every model, mirroring FlashForgeUI-Electron's + * upload spec: an API-level three-call upload per model plus the browser-driven + * dialog behaviour (material matching raised only where the printer has a station). + * + * This is the regression net for the alpha.10 hotfix class of bug: a confirm + * handler that throws or silently skips the upload still leaves the dialog looking + * successful - so every upload test confirms the file actually landed on the + * printer (recent-jobs list = the printer's /gcodeList underneath) and that no + * print started despite Start Now being unchecked. + * + * Material matching rules under test, identical to FlashForgeUI-Electron's: + * - any 3MF + material-station printer (AD5X, Creator 5, Creator 5 Pro) -> dialog + * appears, mapping required, single tool included + * - multi-filament file + 5M-series printer -> dialog must NOT appear + * + * Note the material-station capability comes from the test matrix, never from the + * printer: stock Creator 5 firmware no longer reports the station in /detail, but + * it still has one and still requires mappings on a fresh upload. + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { + fetchApiToken, + openWithRememberedToken, + postJson, + postRaw, + readEmulatorDetail, + resolveContextId, + switchPrinterInUi, +} from './helpers/api'; +import { + type StandalonePrinter, + type StandaloneWebUI, + startStandaloneWebUI, +} from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface StagePayload { + success?: boolean; + uploadId?: string; + error?: string; + requiresMaterialMatching?: boolean; +} + +interface StartPayload { + success?: boolean; + started?: boolean; + fileName?: string; + error?: string; +} + +interface FilesPayload { + success?: boolean; + files?: Array<{ fileName?: string; name?: string }>; + error?: string; +} + +const FIXTURES_DIR = path.resolve('tests/fixtures/print-files'); +const GCODE_FIXTURE = 'adventurer5m-single-color.gcode'; +const THREE_MF_FIXTURE = 'ad5x-single-tool.3mf'; + +/** Machine statuses that mean material is actively being laid down or prepared. */ +const ACTIVE_PRINT_STATUSES: readonly string[] = [ + 'printing', + 'heating', + 'calibrating', + 'pausing', + 'paused', +]; + +/** + * Post-upload safety gate, mirroring FlashForgeUI-Electron's assertNotPrinting: + * give the printer a moment to react (an auto-start would have begun by now), + * then read the emulator's own /detail and fail loudly if a print ever started. + * The emulator instance is torn down by the suite, so no recovery path is needed. + */ +const assertNotPrinting = async (printer: StandalonePrinter): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const detail = await readEmulatorDetail(printer); + const status = (detail.status ?? '').toLowerCase(); + const active = ACTIVE_PRINT_STATUSES.find((candidate) => status.includes(candidate)); + expect( + active, + `printer entered "${detail.status}" after an upload with Start Now unchecked` + ).toBeUndefined(); +}; + +test.describe('WebUI job upload', () => { + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + const stageFile = async (contextId: string, fileName: string) => { + const bytes = await readFile(path.join(FIXTURES_DIR, fileName)); + return await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent(fileName)}`, + bytes + ); + }; + + const fetchRecentJobs = async (contextId: string): Promise => { + const response = await fetch(`${webui.baseUrl}/api/jobs/recent?contextId=${contextId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return (await response.json()) as FilesPayload; + }; + + const expectFileOnPrinter = async ( + contextId: string, + printer: StandalonePrinter, + fixture: string + ) => { + await expect + .poll( + async () => { + const recent = await fetchRecentJobs(contextId); + return recent.files?.some((file) => (file.fileName ?? file.name) === fixture) ?? false; + }, + { timeout: 15_000 } + ) + .toBe(true); + await assertNotPrinting(printer); + }; + + for (const target of MODEL_TARGETS) { + const printer = target.printer; + const fixture = target.hasMaterialStation ? THREE_MF_FIXTURE : GCODE_FIXTURE; + + test(`uploads a job file through the dialog on ${printer.machineName} without starting a print`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + const modal = page.locator('#job-upload-modal'); + await expect(modal).toBeVisible(); + + await page.locator('#job-upload-file-input').setInputFiles(path.join(FIXTURES_DIR, fixture)); + + if (target.hasMaterialStation) { + const matching = page.locator('#material-matching-modal'); + await expect( + matching, + 'material-station printers must raise the matching dialog' + ).toBeVisible(); + + // One requirement row per filament the slicer recorded. + await expect(matching.locator('.material-tool-item')).toHaveCount(1); + + // Assign the single tool to the first available loaded slot and confirm. + await matching.locator('.material-tool-item[data-tool-id="0"]').click(); + await matching.locator('.material-slot-item:not(.empty):not(.disabled)').first().click(); + await expect(matching.locator('#material-matching-confirm')).toBeEnabled(); + await matching.locator('#material-matching-confirm').click(); + } + + await page.locator('#job-upload-start-now').uncheck(); + const okButton = page.locator('#job-upload-ok'); + await expect(okButton, 'OK should be enabled once the job is ready').toBeEnabled(); + await okButton.click(); + + await expect(modal, 'the uploader must close once the upload finishes').toBeHidden({ + timeout: 30_000, + }); + + const contextId = await resolveContextId(webui, token, printer.machineName); + await expectFileOnPrinter(contextId, printer, fixture); + }); + + test(`raises the material matching dialog on ${printer.machineName} only for a station printer`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + const modal = page.locator('#job-upload-modal'); + await expect(modal).toBeVisible(); + + // Same 3MF on every model: only station printers may raise matching. + await page + .locator('#job-upload-file-input') + .setInputFiles(path.join(FIXTURES_DIR, THREE_MF_FIXTURE)); + + const matching = page.locator('#material-matching-modal'); + if (target.hasMaterialStation) { + await expect( + matching, + 'AD5X / Creator 5 should raise the material matching dialog' + ).toBeVisible(); + await expect(matching.locator('.material-tool-item')).toHaveCount(1); + await matching.locator('#material-matching-cancel').click(); + } else { + // 5M-series printers have no material station, so mapping tools to slots + // is meaningless and the dialog must never appear. Bounded wait mirrors + // FlashForgeUI-Electron's findOptional(10s). + await page.waitForTimeout(10_000); + await expect( + matching, + '5M-series printers should not raise the material matching dialog' + ).toBeHidden(); + } + + await modal.locator('#job-upload-cancel').click(); + await expect(modal).toBeHidden(); + }); + } + + test('uploads through the API without starting a print (all models)', async () => { + for (const target of MODEL_TARGETS) { + const printer = target.printer; + const fixture = target.hasMaterialStation ? THREE_MF_FIXTURE : GCODE_FIXTURE; + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Material-station firmware only accepts 3MF; the gate must refuse gcode. + if (target.hasMaterialStation) { + const gcodeStage = await stageFile(contextId, GCODE_FIXTURE); + expect(gcodeStage.status, 'gcode must be refused on material-station models').toBe(400); + } + + const stage = await stageFile(contextId, fixture); + expect(stage.status, `staging failed: ${stage.payload.error}`).toBe(200); + expect(stage.payload.success).toBe(true); + expect(stage.payload.uploadId, 'staging must return an upload handle').toBeTruthy(); + expect(stage.payload.requiresMaterialMatching).toBe(target.hasMaterialStation); + + // Material-station uploads carry a mapping for the single tool, exactly as + // the matching dialog would (the AD5X ff-api path hard-rejects empty lists). + const materialMappings = target.hasMaterialStation + ? [ + { + toolId: 0, + slotId: 1, + materialName: 'PLA', + toolMaterialColor: '#FFFFFF', + slotMaterialColor: '#FFFFFF', + }, + ] + : undefined; + + const start = await postJson( + webui, + token, + `/api/jobs/upload/start?contextId=${contextId}`, + { uploadId: stage.payload.uploadId, startNow: false, materialMappings } + ); + expect(start.status, `upload failed: ${start.payload.error}`).toBe(200); + expect(start.payload.success).toBe(true); + expect(start.payload.started, 'startNow: false must not start the print').toBe(false); + + await expectFileOnPrinter(contextId, printer, fixture); + } + }); + + test('refuses a staged file name that is not a bare job file name', async () => { + const target = MODEL_TARGETS[0]; + if (!target) { + throw new Error('MODEL_TARGETS must not be empty'); + } + const contextId = await resolveContextId(webui, token, target.printer.machineName); + + // Deliberately no fixture read: the filename must be rejected before anything + // touches the filesystem, so any bytes prove the point. + const stage = await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent('../escape.gcode')}`, + Buffer.from('placeholder bytes') + ); + expect(stage.status).toBe(400); + }); +}); diff --git a/tests/fixtures/print-files/ad5x-single-tool.3mf b/tests/fixtures/print-files/ad5x-single-tool.3mf new file mode 100644 index 0000000000000000000000000000000000000000..3c3d62acc87d6fadc55cd9efbdfa418be5f46512 GIT binary patch literal 664764 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 z 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

RkC8Bd+y(wkinBF3mV6IdVh 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