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:
@@ -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' });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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='상태 조회 실패';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 부품 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user