Files
homeclaw/src/tools/print3d.ts
T
kimandClaude Sonnet 5 68c1ae9b21 fix: K2 원격 출력 시작 시 필라멘트 삽입 확인 안내 추가
K2 콤보 CFS 시스템은 출력 시작할 때 터치스크린에서 "필라멘트 삽입"을 직접
눌러 확인해야 실제 급지가 되는데, API로 원격 시작하면 아무도 그 확인을
못 눌러줘서 압출이 안 되고 Klipper 안전장치로 히터가 자동으로 꺼지는 걸
오늘 실제로 겪음(2026-09-20). 웹 UI 슬라이싱 모달과 print3d_model 채팅도구
둘 다 출력 시작 성공 메시지에 "터치스크린 확인하라"는 안내를 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:55:19 +09:00

149 lines
9.3 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import {
K2_HOST, K2_MOONRAKER_PORT, PRUSA_SLICER_BIN, MATERIAL_PRESETS,
buildBaseArgs, runPrusaSlicer, uploadToMoonraker, parsePrusaTime, fmtDuration,
} from './k2-slicer-core.js';
// K2 콤보(Creality, Klipper+Moonraker)로 STL을 찾아서 바로 슬라이싱→업로드까지 자동화.
// 2026-09-20: STL URL을 받아 슬라이싱하고 K2의 Moonraker(192.168.0.114:7125)에 업로드한다.
// 실제 출력 시작은 반드시 confirm:true 재호출을 거쳐야 함(schedule_job의 확인 패턴과 동일)
// — 재료/시간 낭비 위험이 있는 물리적 동작이라 자동시작 금지.
//
// 슬라이서 선택 이유·프린터 프로파일 값 출처·PrusaSlicer CLI 세부사항은 k2-slicer-core.ts
// 참고(웹 UI 슬라이싱 라우트 routes-k2.ts와 이 파일이 그 모듈을 공유한다).
async function downloadStl(url: string, outPath: string): Promise<{ ok: true } | { ok: false; detail: string }> {
let u: URL;
try { u = new URL(url); } catch { return { ok: false, detail: 'URL 형식이 잘못됐습니다.' }; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, detail: 'http/https URL만 지원합니다.' };
let res: Response;
try {
res = await fetch(url, { signal: AbortSignal.timeout(30_000), redirect: 'follow' });
} catch (e: any) {
return { ok: false, detail: `다운로드 실패: ${String(e?.message || e)}` };
}
if (!res.ok || !res.body) return { ok: false, detail: `다운로드 실패: HTTP ${res.status}` };
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 100) return { ok: false, detail: '파일이 너무 작습니다 — STL이 아닌 것 같습니다.' };
if (buf.length > 100 * 1024 * 1024) return { ok: false, detail: '파일이 100MB를 넘어 너무 큽니다.' };
// Thingiverse 401 응답처럼 인증벽에 막혀 HTML 에러페이지가 대신 내려오는 경우를 거른다.
const head = buf.subarray(0, 200).toString('utf8').toLowerCase();
if (head.includes('<html') || head.includes('<!doctype')) {
return { ok: false, detail: '내려받은 내용이 STL이 아니라 HTML입니다 (로그인/인증이 필요한 다운로드 링크일 수 있습니다). 다른 출처(Printables, GitHub raw 등)를 시도하세요.' };
}
fs.writeFileSync(outPath, buf);
return { ok: true };
}
export const print3dModelTool = {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
schema: { action: 'string', stl_url: 'string' },
jsonSchema: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
stl_url: { type: 'string', description: '(prepare 필수) STL 파일 직접 다운로드 URL(http/https). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — Printables, GitHub raw 등 직접 링크를 쓸 것.' },
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
material: { type: 'string', enum: ['PLA', 'PETG'], description: '(prepare, 선택) 소재. 기본 PLA(K2 실측 온도값 검증됨). PETG는 일반적인 추정 온도라 품질 이슈 있으면 조정 필요.' },
filename: { type: 'string', description: '(start 필수) prepare가 반환한 파일명' },
confirm: { type: 'boolean', description: '(start 필수) 반드시 true — 사용자가 출력 시작을 명시적으로 승인했을 때만 true로 호출' },
},
additionalProperties: false,
},
execute: async (args: any): Promise<ToolResult> => {
const action = String(args?.action || '');
if (action === 'start') {
const filename = String(args?.filename || '').trim();
if (!filename) return { success: false, error: 'filename이 필요합니다 (prepare 결과에서 받은 파일명).' };
if (args?.confirm !== true) {
return {
success: false,
error: '출력 시작에는 사용자 확인이 필요합니다. 예상 출력시간/필라멘트 사용량을 사용자에게 보여주고 "출력 시작할까요?"라고 물어본 뒤, 사용자가 승인하면 confirm=true로 다시 호출하세요.',
};
}
try {
const res = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/printer/print/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return { success: false, error: `출력 시작 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
return {
success: true,
stdout: `K2 콤보에서 "${filename}" 출력을 시작했습니다.\n\n⚠️ 원격으로 시작하면 K2 터치스크린에 뜨는 "필라멘트 삽입" 확인 프롬프트를 아무도 못 눌러줘서 실제 급지가 안 되고 히터가 자동으로 꺼질 수 있습니다 — 사용자에게 지금 프린터 터치스크린을 확인해서 그 프롬프트가 뜨면 직접 눌러달라고 안내하세요.`,
};
} catch (e: any) {
return { success: false, error: `출력 시작 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
}
}
if (action !== 'prepare') {
return { success: false, error: 'action은 "prepare" 또는 "start"여야 합니다.' };
}
const stlUrl = String(args?.stl_url || '').trim();
if (!stlUrl) return { success: false, error: 'stl_url이 필요합니다.' };
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return { success: false, error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}). "sudo apt-get install -y prusa-slicer" 필요.` };
}
const materialKey = String(args?.material || 'PLA').toUpperCase();
const material = MATERIAL_PRESETS[materialKey];
if (!material) return { success: false, error: `지원하지 않는 소재: ${materialKey}. PLA 또는 PETG.` };
const layerHeight = Number.isFinite(Number(args?.layer_height)) ? Number(args.layer_height) : 0.2;
const infillPercent = Number.isFinite(Number(args?.infill_percent)) ? Number(args.infill_percent) : 15;
const wallCount = Number.isFinite(Number(args?.wall_count)) ? Math.max(1, Math.round(Number(args.wall_count))) : 2;
const workspacePath = getWorkspacePath(args);
const outDir = path.join(workspacePath, 'print3d');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `model_${stamp}.stl`);
const dl = await downloadStl(stlUrl, stlPath);
if (!dl.ok) return { success: false, error: dl.detail };
const gcodeFilename = `print3d_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
const psArgs = [
...buildBaseArgs({ material, layerHeight, infillPercent, wallCount }),
'-o', gcodePath,
stlPath,
];
const slice = await runPrusaSlicer(psArgs, 5 * 60_000);
if (!slice.ok) return { success: false, error: `슬라이싱 실패: ${slice.detail}` };
if (!fs.existsSync(gcodePath) || fs.statSync(gcodePath).size < 100) {
return { success: false, error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' };
}
const printTimeSec = parsePrusaTime(fs.readFileSync(gcodePath, 'utf8'));
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return { success: false, error: `${up.detail} (슬라이싱은 성공, G-code는 ${gcodePath}에 있음)` };
const timeStr = printTimeSec != null ? fmtDuration(printTimeSec) : '알 수 없음';
const materialNote = material.verified ? '' : ' (⚠️ 온도값은 검증되지 않은 일반 추정치입니다)';
return {
success: true,
stdout: `K2 콤보에 업로드 완료: **${gcodeFilename}**\n` +
`- 소재: ${materialKey}${materialNote}\n` +
`- 예상 출력시간: ${timeStr}\n` +
`- 설정: 레이어 ${layerHeight}mm, 벽 ${wallCount}겹, 인필 ${infillPercent}%\n\n` +
`사용자에게 위 정보를 보여주고 출력을 시작할지 확인하세요. 승인하면 print3d_model(action="start", filename="${gcodeFilename}", confirm=true)로 출력을 시작하세요.`,
};
},
};