Files
homeclaw/src/gateway/routes/routes-k2.ts
T
kimandClaude Sonnet 5 ba193eec3c feat: 작업실 파일 폴더 규칙 통일 — STL을 .scad와 같은 폴더에, 프로젝트별 제각각 구조 정돈
규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유):
  workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/
  print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/
인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지)

- 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고),
  --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등
- 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성),
  model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거
- 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내
- 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬
- 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획)

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

354 lines
18 KiB
TypeScript

import express, { Express, Request, Response } from 'express';
import { PRINT3D_DIRS } from './workshop-layout';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getUserWorkspace } from '../../config/config';
import { isPathInsideDir } from '../../tools/image';
import {
PRUSA_SLICER_BIN, runPrusaSlicer, uploadToMoonraker,
parsePrusaTime, parsePrusaFilament,
K2_BASE_INI, parseIni, buildProfileIni, profileSliceArgs,
} from '../../tools/k2-slicer-core';
// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만
// 서빙한다(:8000/call/webrtc_local). 홈클로 자체는 https://ai.applecherry.net로
// 서빙되므로, 그 HTTPS 페이지에서 이 HTTP 엔드포인트로 직접 fetch(POST)하면
// 브라우저의 mixed-content 정책에 막혀 요청 자체가 안 나간다(주소창 "주의 요함" +
// 카메라 화면 검은 채로 멈춤, 2026-09-18 발견). 그래서 이 엔드포인트를 우리 서버가
// 대신 호출해주는 얇은 프록시로 우회한다 — 서버-서버 호출은 스킴 제약이 없다.
const K2_CAM_HOST = '192.168.0.114';
const K2_CAM_PORT = 8000;
const K2_MOONRAKER_HOST = '192.168.0.114';
const K2_MOONRAKER_PORT = 7125;
// 저장 프로파일 디렉토리(<workspace>/print3d/profiles/)와 이름 검증. `__current`는
// 에디터가 임시로 현재 편집값을 저장하는 세션 프로파일.
const PROFILE_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
function profilesDir(workspacePath: string): string {
return path.join(workspacePath, 'print3d', 'profiles');
}
function profilePath(workspacePath: string, name: string): string {
const p = path.join(profilesDir(workspacePath), `${name}.ini`);
if (!isPathInsideDir(profilesDir(workspacePath), p)) throw new Error('경로가 프로파일 디렉토리 밖입니다.');
return p;
}
function validProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name) && name !== '__current';
}
// 슬라이싱 결과 gcode 파일명 검증 — print3d/ 디렉토리 안의 단일 파일명만 허용(슬래시/.. 거부).
const GCODE_NAME_RE = /^[A-Za-z0-9_\-]+\.gcode$/;
function gcodePath(workspacePath: string, filename: string): string {
if (!GCODE_NAME_RE.test(filename)) throw new Error('잘못된 gcode 파일명.');
const p = path.join(workspacePath, 'print3d', filename);
const dir = path.join(workspacePath, 'print3d');
if (!isPathInsideDir(dir, p)) throw new Error('경로가 print3d 디렉토리 밖입니다.');
return p;
}
export function registerK2Routes(app: Express): void {
// Moonraker 상태(print_stats/virtual_sdcard)를 대시보드 위젯용으로 요약해서 반환 —
// 브라우저가 K2 LAN IP를 직접 못 치니(HTTPS 페이지에서 mixed-content) 서버가 대신 조회.
app.get('/api/k2/status', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
try {
const r = await fetch(
`http://${K2_MOONRAKER_HOST}:${K2_MOONRAKER_PORT}/printer/objects/query?print_stats&virtual_sdcard`,
{ signal: AbortSignal.timeout(8_000) },
);
if (!r.ok) return res.status(502).json({ error: `Moonraker ${r.status}` });
const d: any = await r.json();
const ps = d?.result?.status?.print_stats || {};
const sd = d?.result?.status?.virtual_sdcard || {};
res.json({
online: true,
state: ps.state || 'unknown',
filename: ps.filename || '',
progress: Math.round((sd.progress || 0) * 100),
printDurationSec: Math.round(ps.print_duration || 0),
});
} catch (e: any) {
res.json({ online: false, error: String(e?.message || e) });
}
});
// 클라이언트가 'text/plain'으로 보내는데 서버 전역엔 json/urlencoded 파서만 등록돼
// 있어(server.ts) req.body가 안 채워짐 — 이 라우트에서만 원본 바디를 텍스트로 받는다.
app.post('/api/k2/webrtc-signal', express.text({ type: () => true, limit: '2mb' }), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
try {
const body = typeof req.body === 'string' ? req.body : String(req.body ?? '');
const r = await fetch(`http://${K2_CAM_HOST}:${K2_CAM_PORT}/call/webrtc_local`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body,
signal: AbortSignal.timeout(10_000),
});
const text = await r.text();
res.status(r.status).type('text/plain').send(text);
} catch (e: any) {
res.status(502).json({ error: String(e?.message || e) });
}
});
// ── 슬라이싱 프로파일 라우트(풀 에디터용) ──────────────────────────────────
// 베이스 프로파일값(에디터 초기 채움용). start_gcode/end_gcode는 canonical 강제값이라
// 클라이언트가 받아도 편집에 노출하지 않는다.
app.get('/api/k2/profile/base', (req: Request, res: Response) => {
if (!(req as any).user) return res.status(401).json({ error: 'Unauthorized' });
res.json({ values: parseIni(K2_BASE_INI) });
});
// 저장 프로파일 목록.
app.get('/api/k2/profiles', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
try {
const dir = profilesDir(getUserWorkspace(user.username));
const names: string[] = [];
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.ini')) names.push(f.slice(0, -4));
}
}
res.json({ profiles: names.filter((n) => n !== '__current').sort() });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 저장 프로파일 불러오기(머지된 ini 파싱값).
app.get('/api/k2/profiles/:name', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름.' });
try {
const p = profilePath(getUserWorkspace(user.username), name);
if (!fs.existsSync(p)) return res.status(404).json({ error: '프로파일이 없습니다.' });
res.json({ values: parseIni(fs.readFileSync(p, 'utf8')) });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 프로파일 저장(오버라이드 맵 → 머지 ini 파일). __current도 허용.
app.post('/api/k2/profiles/:name', express.json({ limit: '256kb' }), (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름(영문/숫자/밑줄/하이픈, 최대 64자).' });
const overrides = (req.body && typeof req.body === 'object' && req.body.overrides && typeof req.body.overrides === 'object') ? req.body.overrides : null;
if (!overrides) return res.status(400).json({ error: 'overrides 객체가 필요합니다.' });
try {
const dir = profilesDir(getUserWorkspace(user.username));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(profilePath(getUserWorkspace(user.username), name), buildProfileIni(overrides), 'utf8');
res.json({ success: true, name });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
app.delete('/api/k2/profiles/:name', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name)) return res.status(400).json({ error: '잘못된 프로파일 이름.' });
try {
const p = profilePath(getUserWorkspace(user.username), name);
if (fs.existsSync(p)) fs.unlinkSync(p);
res.json({ success: true });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 웹 UI(k2-app.html) 풀 프로파일 에디터의 슬라이싱 — 저장된 프로파일(--load <ini>)로
// 슬라이싱+K2 업로드. 바디는 STL 원본 바이트, 설정은 ?profile=<name>&qty=<N> 쿼리로.
// (print3d_model 채팅도구는 buildBaseArgs 하드코딩 경로를 그대로 쓴다 — 동작 변경 회피.)
app.post('/api/k2/slice', express.raw({ type: () => true, limit: '100mb' }), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 100) {
return res.status(400).json({ error: '모델 파일이 비어있거나 너무 작습니다.' });
}
// 2026-09-21: 3MF 지원 추가 — PrusaSlicer CLI가 3MF를 네이티브로 지원해서(확장자로 자동판별)
// 그냥 .3mf로 저장해서 넘기면 됨. 3MF는 클라이언트에서 미리보기/배치 편집 없이 원본 그대로
// 통째로 보내는 방식(Three.js STLLoader로는 3MF를 못 읽어서 바로 편집기에 못 올림).
const format = String(req.query.format || 'stl').toLowerCase();
if (format !== 'stl' && format !== '3mf') {
return res.status(400).json({ error: `지원하지 않는 형식: ${format} (stl 또는 3mf)` });
}
const modelExt = format === '3mf' ? '.3mf' : '.stl';
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
}
const profileName = String(req.query.profile || '__current');
if (!validProfileName(profileName) && profileName !== '__current') {
return res.status(400).json({ error: '잘못된 프로파일 이름.' });
}
// 소재는 프로파일 ini의 filament_type/temperature 값에서 읽는다(에디터에서 설정).
// 응답 표시용 라벨만 여기서 뽑는다.
// 여러 파츠를 한 판에 합치는 --merge는 이 헤드리스 환경에서 세그폴트가 나서(2026-09-20
// 확인) 지원 안 함. 대신 웹 UI의 Cura식 배치 도구가 인스턴스들을 단일 STL로 구워(bake)
// 보내면, arrange=0 일 때 --dont-arrange 로 원본 XY 좌표를 그대로 보존해 슬라이싱한다
// (2026-09-20 실측: 큐브 STL 0..20 → --dont-arrange 시 베드 0..20 유지, 재중앙화 안 됨).
// arrange=0 이면 인스턴스가 이미 복제까지 구워져 들어오므로 --duplicate는 끈다.
const arrange = String(req.query.arrange || '1');
const dontArrange = arrange === '0' || arrange === 'false';
const qty = dontArrange ? 1 : Math.max(1, Math.min(20, Number.isFinite(Number(req.query.qty)) ? Math.round(Number(req.query.qty)) : 1));
const workspacePath = getUserWorkspace(user.username);
const profPath = profilePath(workspacePath, profileName);
if (!fs.existsSync(profPath)) {
return res.status(400).json({ error: `프로파일 '${profileName}'이 없습니다. 먼저 저장하거나 기본값으로 슬라이싱하세요.` });
}
const profValues = parseIni(fs.readFileSync(profPath, 'utf8'));
const materialKey = String(profValues.filament_type || 'PLA').toUpperCase();
// 웹 UI 슬라이싱 임시 파일은 print3d/K2웹슬라이서/ (프로젝트에 안 묶인 파일 — workshop-layout.ts)
const outDir = path.join(workspacePath, PRINT3D_DIRS.k2Web);
fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `k2ui_${stamp}${modelExt}`);
fs.writeFileSync(stlPath, buf);
const gcodeFilename = `k2ui_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
const psArgs = [
...profileSliceArgs(profPath, gcodePath, stlPath),
...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []),
...(dontArrange ? ['--dont-arrange'] : []),
];
const slice = await runPrusaSlicer(psArgs, 5 * 60_000);
if (!slice.ok) return res.status(500).json({ error: `슬라이싱 실패: ${slice.detail}` });
if (!fs.existsSync(gcodePath) || fs.statSync(gcodePath).size < 100) {
return res.status(500).json({ error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' });
}
const gcodeText = fs.readFileSync(gcodePath, 'utf8');
const printTimeSec = parsePrusaTime(gcodeText);
const filament = parsePrusaFilament(gcodeText);
// upload=1일 때만 Moonraker에 업로드(하위호환). 기본은 슬라이스만 — Cura 스타일 GUI가
// 툴패스 미리보기를 보여준 뒤 사용자가 명시적으로 업로드 버튼을 누르도록 흐름 분리.
const doUpload = req.query.upload === '1' || req.query.upload === 'true';
if (doUpload) {
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return res.status(502).json({ error: `${up.detail} (슬라이싱은 성공, G-code는 서버에 있음)` });
}
res.json({
success: true,
gcodeFilename,
printTimeSec,
filamentMm: filament.mm,
filamentCm3: filament.cm3,
uploaded: doUpload,
material: materialKey,
qty,
});
});
// 3MF → STL 변환(PrusaSlicer --export-stl, 네이티브 지원이라 파싱 직접 안 함). 슬라이서 웹UI가
// 3MF 선택 시 이걸 먼저 태워서 기존 STL 미리보기/배치편집 파이프라인을 그대로 쓸 수 있게 한다
// (Three.js STLLoader가 3MF를 못 읽어서 별도 패스스루가 아니라 변환 경로로 감, 2026-09-21).
app.post('/api/k2/convert-3mf-to-stl', express.raw({ type: () => true, limit: '100mb' }), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
}
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 100) {
return res.status(400).json({ error: '3MF 파일이 비어있거나 너무 작습니다.' });
}
const stamp = Date.now();
const tmpDir = os.tmpdir();
const inPath = path.join(tmpDir, `k2_3mf_${stamp}.3mf`);
const outPath = path.join(tmpDir, `k2_3mf_${stamp}.stl`);
try { fs.writeFileSync(inPath, buf); }
catch (e: any) { return res.status(500).json({ error: '임시 3MF 쓰기 실패: ' + String(e?.message || e) }); }
const conv = await runPrusaSlicer(['--export-stl', '-o', outPath, inPath], 2 * 60_000);
cleanupTemp(inPath);
if (!conv.ok) { cleanupTemp(outPath); return res.status(500).json({ error: `3MF 변환 실패: ${conv.detail}` }); }
try {
const outBuf = fs.readFileSync(outPath);
cleanupTemp(outPath);
res.type('application/octet-stream').send(outBuf);
} catch (e: any) {
cleanupTemp(outPath);
res.status(500).json({ error: '변환된 STL 읽기 실패: ' + String(e?.message || e) });
}
});
// 생성된 gcode를 Moonraker에 업로드(슬라이스와 분리된 명시적 전송 단계).
app.post('/api/k2/upload', express.json(), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const filename = String(req.body?.filename || '').trim();
if (!filename) return res.status(400).json({ error: 'filename이 필요합니다.' });
try {
const p = gcodePath(getUserWorkspace(user.username), filename);
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
const up = await uploadToMoonraker(p, filename);
if (!up.ok) return res.status(502).json({ error: up.detail });
res.json({ success: true, filename });
} catch (e: any) {
res.status(400).json({ error: String(e?.message || e) });
}
});
// 슬라이싱 결과 gcode를 브라우저로 스트리밍 — Cura 스타일 툴패스 미리보기용.
app.get('/api/k2/gcode/:filename', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const filename = String(req.params.filename || '');
try {
const p = gcodePath(getUserWorkspace(user.username), filename);
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
// 워크스페이스 경로가 /srv/homeclaw/.smallclaw/... 인데, send 라이브러리(res.sendFile
// 베이스)의 dotfiles 기본값이 'ignore'라 .smallclaw 같은 도트파일 디렉토리 아래 파일을
// 404 "Not Found"로 씹어버림(2026-09-20 확인). 명시적으로 'allow'로 풀어야 스트리밍됨.
res.type('text/plain').sendFile(p, { dotfiles: 'allow' });
} catch (e: any) {
res.status(400).json({ error: String(e?.message || e) });
}
});
// 웹 UI에서 출력 시작 — Moonraker가 LAN 평문 HTTP라 브라우저가 직접 못 치므로(mixed-content) 프록시.
app.post('/api/k2/print-start', express.json(), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const filename = String(req.body?.filename || '').trim();
if (!filename) return res.status(400).json({ error: 'filename이 필요합니다.' });
try {
const r = await fetch(`http://${K2_MOONRAKER_HOST}:${K2_MOONRAKER_PORT}/printer/print/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
signal: AbortSignal.timeout(15_000),
});
if (!r.ok) return res.status(502).json({ error: `출력 시작 실패: HTTP ${r.status} ${await r.text().catch(() => '')}` });
res.json({ success: true });
} catch (e: any) {
res.status(502).json({ error: `출력 시작 실패: ${String(e?.message || e)}` });
}
});
}
function cleanupTemp(p: string): void {
try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* noop */ }
}