feat: robot-app에 K2/밀링 장비 연동 위젯(장비 탭) 추가

로봇 프로젝트 대시보드에 실제 공작 장비(K2 3D프린터, CNC 밀링 프린터실
PTZ카메라) 상태를 보는 "장비" 탭을 추가. 우선 가벼운 주기적 스냅샷 방식으로
구현(실시간 WebRTC 상시연결은 자원 부담 커서 보류, 추후 업그레이드 검토).

- routes-k2.ts: GET /api/k2/status — Moonraker print_stats/virtual_sdcard를
  요약해서 반환(온라인여부/상태/파일명/진행률/경과시간)
- nvr.ts: GET /api/nvr/frame/:channelId — go2rtc(127.0.0.1:1985)의
  frame.jpeg를 프록시(밀링 PTZ카메라, channelId=1000 "프린터실"), go2rtc
  포트를 브라우저에 직접 노출하지 않음
- robot-app.html: "장비" 탭 — K2는 20초 간격으로 짧게 WebRTC 연결→ontrack에서
  캔버스 캡처→즉시 종료(상시 연결 안 함), 밀링은 위 프록시로 img 새로고침.
  "장비" 탭을 보고 있을 때만 폴링, 다른 탭으로 나가면 타이머 정지

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 14:07:54 +09:00
co-authored by Claude Sonnet 5
parent 9641f689b8
commit b358cbc968
3 changed files with 217 additions and 0 deletions
+23
View File
@@ -251,6 +251,29 @@ export function registerNvrRoutes(app: Express): void {
}
});
// go2rtc(127.0.0.1:1985)에 릴레이 등록된 카메라(go2rtcSrc 설정된 채널)의 정지 프레임 1장을
// 프록시한다 — go2rtc 자체 프로토콜을 몰라도 되는 가벼운 대시보드 위젯용(2026-09-20,
// robot-app 장비연동 위젯). go2rtc 포트를 브라우저에 직접 노출하지 않기 위해 서버에서 중계.
app.get('/api/nvr/frame/:channelId', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const channelId = Number(req.params.channelId);
const cam = getExtraCameras().find((c) => c.id === channelId);
if (!cam || !cam.go2rtcSrc) return res.status(404).json({ error: 'go2rtc 릴레이가 없는 카메라입니다.' });
try {
const r = await fetch(`http://${GO2RTC_HOST}:${GO2RTC_PORT}/api/frame.jpeg?src=${encodeURIComponent(cam.go2rtcSrc)}`, {
signal: AbortSignal.timeout(8_000),
});
if (!r.ok || !r.body) return res.status(502).json({ error: `go2rtc frame ${r.status}` });
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
const buf = Buffer.from(await r.arrayBuffer());
res.send(buf);
} catch (e: any) {
res.status(502).json({ error: String(e?.message || e) });
}
});
app.get('/api/nvr/channels', async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
+28
View File
@@ -8,8 +8,36 @@ import express, { Express, Request, Response } from 'express';
// 대신 호출해주는 얇은 프록시로 우회한다 — 서버-서버 호출은 스킴 제약이 없다.
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;
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) => {
+166
View File
@@ -57,6 +57,21 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.rb-task-add,.rb-phase-add{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;margin-top:4px;}
.rb-task-add:hover,.rb-phase-add:hover{border-color:#f59e0b;color:#f59e0b;}
/* ── 장비 위젯 ── */
.rb-equip-grid{display:flex;gap:14px;flex-wrap:wrap;}
.rb-equip-card{flex:1;min-width:300px;max-width:480px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel);}
.rb-equip-hdr{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;}
.rb-equip-dot{width:7px;height:7px;border-radius:50%;background:#6b7280;flex-shrink:0;}
.rb-equip-dot.on{background:#22c55e;}
.rb-equip-dot.off{background:#ef4444;}
.rb-equip-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);}
.rb-equip-refresh:hover{color:#f59e0b;}
.rb-equip-frame-wrap{width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.rb-equip-frame-wrap img{width:100%;height:100%;object-fit:contain;}
.rb-equip-frame-wrap .rb-equip-placeholder{color:#666;font-size:11px;}
.rb-equip-status{padding:8px 12px;font-size:11px;color:var(--muted);line-height:1.7;}
.rb-equip-status b{color:var(--text);}
/* ── 메모 ── */
.rb-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;}
.rb-notes:focus{border-color:#f59e0b;}
@@ -82,6 +97,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="rb-tabs">
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
</div>
@@ -102,6 +118,29 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
</div>
<div class="rb-pane" id="pane-equip">
<div class="rb-equip-grid">
<div class="rb-equip-card">
<div class="rb-equip-hdr">
<span class="rb-equip-dot" id="k2-dot"></span>
<span>🖨️ K2 콤보</span>
<button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button>
</div>
<div class="rb-equip-frame-wrap"><img id="k2-frame" alt="" style="display:none"><span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span></div>
<div class="rb-equip-status" id="k2-status-text">-</div>
</div>
<div class="rb-equip-card">
<div class="rb-equip-hdr">
<span class="rb-equip-dot" id="mill-dot"></span>
<span>⚙️ CNC 밀링(프린터실)</span>
<button class="rb-equip-refresh" onclick="refreshMillWidget()">↺</button>
</div>
<div class="rb-equip-frame-wrap"><img id="mill-frame" alt="" style="display:none" onerror="millFrameError()" onload="millFrameLoaded()"><span class="rb-equip-placeholder" id="mill-placeholder">불러오는 중...</span></div>
<div class="rb-equip-status">20초마다 자동 새로고침(스냅샷) — <a href="/html/milling-app.html" target="_blank" style="color:#f59e0b">밀링 앱에서 라이브로 보기 ↗</a></div>
</div>
</div>
</div>
<div class="rb-pane" id="pane-notes">
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
</div>
@@ -172,6 +211,133 @@ function switchTab(tab){
document.querySelectorAll('.rb-tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
document.getElementById('pane-'+tab).classList.add('active');
if(tab==='equip')startEquipTimers();else stopEquipTimers();
}
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
const MILL_CHANNEL_ID=1000;
let equipTimer=null;
function startEquipTimers(){
if(equipTimer)return;
refreshK2Widget();
refreshMillWidget();
equipTimer=setInterval(()=>{refreshK2Widget();refreshMillWidget();},20000);
}
function stopEquipTimers(){
clearInterval(equipTimer);
equipTimer=null;
}
function refreshMillWidget(){
const img=document.getElementById('mill-frame');
img.src='/api/nvr/frame/'+MILL_CHANNEL_ID+'?t='+Date.now();
}
function millFrameLoaded(){
document.getElementById('mill-frame').style.display='block';
document.getElementById('mill-placeholder').style.display='none';
document.getElementById('mill-dot').className='rb-equip-dot on';
}
function millFrameError(){
document.getElementById('mill-frame').style.display='none';
document.getElementById('mill-placeholder').style.display='block';
document.getElementById('mill-placeholder').textContent='카메라를 불러올 수 없습니다';
document.getElementById('mill-dot').className='rb-equip-dot off';
}
// K2 카메라는 go2rtc 없이 프린터 자체 WebRTC(:8000)로만 나오므로 정지 프레임을 얻으려면
// 짧게 연결→ontrack에서 캔버스로 한 프레임 캡처→즉시 연결 종료한다(k2-app.html의 상시
// 라이브 연결과 달리 "장비" 탭을 볼 때만, 20초 간격으로만 짧게 붙었다 끊는 방식).
function grabK2Frame(){
return new Promise((resolve)=>{
let settled=false;
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
const video=document.createElement('video');
video.autoplay=true;video.muted=true;video.playsInline=true;
const finish=(dataUrl)=>{
if(settled)return;
settled=true;
try{pc.close();}catch{}
try{video.remove();}catch{}
resolve(dataUrl);
};
// 비디오 엘리먼트를 DOM에 붙이지 않으면 일부 브라우저에서 프레임 디코딩이 실제로
// 시작되지 않아(재생 안 함) videoWidth/Height가 계속 0으로 남고 캔버스가 새까맣게
// 찍힌다 — 화면 밖(offscreen)에 실제로 붙이고 명시적으로 play()까지 호출해야 한다.
video.style.cssText='position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;';
document.body.appendChild(video);
const captureWhenReady=(triesLeft)=>{
if(settled)return;
if(video.videoWidth>0&&video.videoHeight>0){
try{
const c=document.createElement('canvas');
c.width=video.videoWidth;c.height=video.videoHeight;
c.getContext('2d').drawImage(video,0,0,c.width,c.height);
finish(c.toDataURL('image/jpeg',0.7));
}catch{finish(null);}
return;
}
if(triesLeft<=0){finish(null);return;}
setTimeout(()=>captureWhenReady(triesLeft-1),200);
};
pc.ontrack=(ev)=>{
video.srcObject=ev.streams[0];
video.play().catch(()=>{});
video.onloadeddata=()=>captureWhenReady(15);
};
pc.addTransceiver('video',{direction:'sendrecv'});
let offerSent=false;
const sendOfferOnce=()=>{
if(offerSent||settled)return;
offerSent=true;
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
.then(r=>r.text()).then(txt=>{
const res=JSON.parse(atob(txt));
if(res.type==='answer'&&!settled)pc.setRemoteDescription(new RTCSessionDescription(res));
}).catch(()=>finish(null));
};
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>finish(null));
setTimeout(()=>finish(null),8000);
});
}
function fmtK2Duration(sec){
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
if(h>0)return h+'시간 '+m+'분';
return m+'분';
}
const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정지',complete:'완료',cancelled:'취소됨',error:'오류'};
async function refreshK2Widget(){
const img=document.getElementById('k2-frame');
const placeholder=document.getElementById('k2-placeholder');
grabK2Frame().then(dataUrl=>{
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
});
try{
const r=await fetch('/api/k2/status',{headers:authH()});
const d=await r.json();
const dot=document.getElementById('k2-dot');
const statusEl=document.getElementById('k2-status-text');
if(!d.online){
dot.className='rb-equip-dot off';
statusEl.innerHTML='<b>오프라인</b> — 프린터가 꺼져있거나 네트워크에 없음';
return;
}
dot.className='rb-equip-dot '+(d.state==='printing'?'on':'');
const stateLabel=K2_STATE_LABEL[d.state]||d.state;
let html='상태: <b>'+escAttr(stateLabel)+'</b>';
if(d.filename)html+=' · 파일: <b>'+escAttr(d.filename)+'</b>';
if(d.state==='printing'){
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
}
statusEl.innerHTML=html;
}catch{
document.getElementById('k2-status-text').textContent='상태 조회 실패';
}
}
// ── 부품 ──────────────────────────────────────────────────────────────────────