@makeitnow/jumpitnow 0.0.0-stage → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +366 -2
  3. package/dist/cjs/index.d.ts +6 -0
  4. package/dist/cjs/index.js +29 -0
  5. package/dist/cjs/internal/errors.d.ts +15 -0
  6. package/dist/cjs/internal/errors.js +44 -0
  7. package/dist/cjs/internal/gateway.d.ts +41 -0
  8. package/dist/cjs/internal/gateway.js +586 -0
  9. package/dist/cjs/internal/link.d.ts +13 -0
  10. package/dist/cjs/internal/link.js +111 -0
  11. package/dist/cjs/internal/protocol.d.ts +29 -0
  12. package/dist/cjs/internal/protocol.js +38 -0
  13. package/dist/cjs/internal/simulator.d.ts +15 -0
  14. package/dist/cjs/internal/simulator.js +117 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/types.d.ts +182 -0
  17. package/dist/cjs/types.js +2 -0
  18. package/dist/esm/index.d.ts +6 -0
  19. package/dist/esm/index.js +22 -0
  20. package/dist/esm/internal/errors.d.ts +15 -0
  21. package/dist/esm/internal/errors.js +37 -0
  22. package/dist/esm/internal/gateway.d.ts +41 -0
  23. package/dist/esm/internal/gateway.js +582 -0
  24. package/dist/esm/internal/link.d.ts +13 -0
  25. package/dist/esm/internal/link.js +106 -0
  26. package/dist/esm/internal/protocol.d.ts +29 -0
  27. package/dist/esm/internal/protocol.js +32 -0
  28. package/dist/esm/internal/simulator.d.ts +15 -0
  29. package/dist/esm/internal/simulator.js +113 -0
  30. package/dist/esm/types.d.ts +182 -0
  31. package/dist/esm/types.js +1 -0
  32. package/docs/API.md +106 -0
  33. package/docs/COMPATIBILITY.md +11 -0
  34. package/docs/SECURITY.md +17 -0
  35. package/docs/index.html +30 -0
  36. package/examples/browser/app.js +104 -0
  37. package/examples/browser/index.html +37 -0
  38. package/examples/react/GatewayPanel.tsx +66 -0
  39. package/package.json +45 -4
@@ -0,0 +1,30 @@
1
+ <!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Jumpitnow SDK · 사용 가이드</title>
2
+ <style>
3
+ :root{--ink:#183341;--muted:#5c7181;--blue:#245bda;--teal:#087f80;--line:#dce5ed;--paper:#f4f7fb}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:15px/1.8 -apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Malgun Gothic",sans-serif}a{color:var(--blue);text-underline-offset:4px}code,pre,.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}header{background:white;border-bottom:1px solid var(--line);padding:20px 5%;display:flex;justify-content:space-between;align-items:center;gap:20px}.brand{font-size:23px;font-weight:900;letter-spacing:-1px}.brand span{color:var(--blue)}header nav{display:flex;gap:24px;font-size:13px}.wrap{max-width:1180px;margin:auto;padding:65px 30px}.hero{display:grid;grid-template-columns:1.5fr 1fr;gap:70px;align-items:center;padding-bottom:60px}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.13em;color:var(--blue);font-weight:700}h1{font-size:clamp(34px,4vw,54px);line-height:1.28;letter-spacing:-2px;margin:20px 0}h1 em{font-style:normal;color:var(--blue)}p{color:var(--muted)}.tag{display:inline-block;background:#eaf0ff;color:var(--blue);padding:5px 9px;font-size:12px;border-radius:5px}.actions{display:flex;gap:12px;margin-top:26px;flex-wrap:wrap}.button{display:inline-block;text-decoration:none;border:1px solid var(--line);padding:10px 18px;border-radius:7px;background:white;font-size:14px;font-weight:700}.button.primary{background:var(--blue);color:white;border-color:var(--blue)}.sample{background:#163444;color:#d8e7f5;padding:24px;border-radius:10px;box-shadow:0 16px 32px #1537450d}.sample .mono{color:#8dcbda;font-size:11px}.sample pre{margin:20px 0;font-size:12px;line-height:2;white-space:pre-wrap;overflow-wrap:anywhere}.sample small{color:#a5becd}section{padding:42px 0;border-top:1px solid var(--line);scroll-margin-top:25px}section h2{font-size:26px;letter-spacing:-1px;margin:8px 0 22px}.flow{display:flex;align-items:center;gap:0;margin:26px 0}.node{flex:1;background:white;border:1px solid var(--line);border-radius:8px;padding:24px 15px;text-align:center}.node strong{display:block;font-size:16px}.node small{display:block;color:var(--muted);font-size:11px;margin-top:7px}.node.sdk{background:var(--blue);color:white;border-color:var(--blue)}.node.sdk small{color:#dfe8ff}.arrow{color:var(--teal);padding:0 13px;font-size:22px}.steps{display:grid;grid-template-columns:repeat(4,1fr);gap:24px}.step{border-top:2px solid var(--blue);padding-top:15px}.step .mono{color:var(--blue);font-size:12px}.step h3{font-size:16px;margin:10px 0 5px}.step p{font-size:13px;margin:0}.code{background:white;border:1px solid var(--line);border-radius:9px;padding:22px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}.table-wrap{overflow:auto;background:white;border:1px solid var(--line);border-radius:9px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:15px 20px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}th{background:#edf3fa;font-size:12px}td code{color:var(--blue);font-size:12px}tr:last-child td{border-bottom:0}.results{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.result{padding:23px;background:white;border:1px solid var(--line);border-top:3px solid var(--teal);border-radius:7px}.result:nth-child(2){border-top-color:#b84b40}.result:nth-child(3){border-top-color:#b88625}.result code{font-weight:700}.result p{font-size:13px;margin-bottom:0}.note{border-left:3px solid var(--blue);padding:10px 18px;background:#ebf1fc;margin-top:24px;font-size:13px}.docs{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.doc{background:white;border:1px solid var(--line);border-radius:9px;padding:22px;text-decoration:none;color:var(--ink)}.doc:hover{border-color:var(--blue)}.doc strong{display:block}.doc p{font-size:13px;margin:7px 0 12px}.doc span{font-size:12px;color:var(--blue)}footer{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:24px}a:focus-visible{outline:3px solid #91b3ff;outline-offset:4px}@media(max-width:850px){.hero{grid-template-columns:1fr;gap:25px}.steps{grid-template-columns:repeat(2,1fr)}.wrap{padding-top:36px}.docs{grid-template-columns:1fr}}@media(max-width:600px){.wrap{padding:30px 20px}.flow{flex-direction:column}.node{width:100%}.arrow{transform:rotate(90deg);padding:8px}.results{grid-template-columns:1fr}header nav a:first-child{display:none}.steps{gap:22px 16px}th,td{padding:13px;min-width:135px}.hero{padding-bottom:35px}}@media print{header nav,.actions{display:none}.wrap{max-width:none;padding:20px}body{background:white}.hero{gap:30px}section{break-inside:avoid}a{color:inherit}.sample{box-shadow:none}}
4
+ </style></head><body>
5
+ <header><div class="brand">jumpitnow<span> .</span></div><nav><a href="#api">사용 기능</a><a href="../examples/browser/index.html">데모 열기 ↗</a></nav></header>
6
+ <main class="wrap">
7
+ <div class="hero"><div><div class="eyebrow">JAVASCRIPT SDK / TEACHER GUIDE</div><h1>줄넘기의 데이터를,<br><em>선생님의 수업으로.</em></h1><p>연결·설정·데이터 수신은 SDK에 맡기세요.<br>그 위에 자신만의 게임과 수업 화면을 만들 수 있습니다.</p><span class="tag">0.1.0-alpha.2 · 개발 확인용</span><div class="actions"><a class="button primary" href="../examples/browser/index.html">가상 장비로 실행하기</a><a class="button" href="API.md">함수 사용법</a></div></div><div class="sample"><div class="mono">YOUR FIRST CONNECTION</div><pre>import { createGateway }
8
+ from '@makeitnow/jumpitnow';
9
+
10
+ const gateway = createGateway();
11
+
12
+ gateway.on('measurement', data =&gt; {
13
+ renderCount(data.deviceId, data.count);
14
+ });
15
+
16
+ // 연결 버튼에서 호출
17
+ await gateway.connect();
18
+ const { devices } = await gateway.scan();</pre><small>JavaScript · TypeScript · 프레임워크 독립</small></div></div>
19
+ <section id="architecture"><div class="eyebrow">ARCHITECTURE</div><h2>수업 화면과 줄넘기를 연결합니다.</h2><p>장비를 선택하고 운동을 시작하면, 수업 화면에서 횟수와 시간을 확인할 수 있습니다.</p><div class="flow"><div class="node"><strong>교사의 웹앱</strong><small>게임 · 수업 화면 · 기록</small></div><div class="arrow">↔</div><div class="node sdk"><strong>Jumpitnow SDK</strong><small>연결 · 운동 시작 · 데이터 받기</small></div><div class="arrow">↔</div><div class="node"><strong>게이트웨이</strong><small>장비 연결</small></div><div class="arrow">↔</div><div class="node"><strong>스마트 줄넘기</strong><small>측정값 · 장비 응답</small></div></div><div class="note">연결부터 운동 종료까지 같은 함수로 사용할 수 있습니다. 학생 배정과 수업 화면은 선생님의 앱에서 만듭니다.</div></section>
20
+ <section id="start"><div class="eyebrow">QUICK START</div><h2>설치하고, 선택하고, 시작합니다.</h2><pre class="code">npm install ./makeitnow-jumpitnow-0.1.0-alpha.2.tgz</pre><div class="steps"><div class="step"><span class="mono">01 / CONNECT</span><h3>게이트웨이 연결</h3><p>연결 버튼에서 connect를 호출하고 장비를 검색합니다.</p></div><div class="step"><span class="mono">02 / SELECT</span><h3>수업 대상 선택</h3><p>발견된 목록에서 사용할 장비를 선택합니다. 자동 전체 시작은 없습니다.</p></div><div class="step"><span class="mono">03 / RUN</span><h3>모드 설정과 시작</h3><p>설정 수락을 확인한 뒤 시작합니다. 측정값은 이벤트로 받습니다.</p></div><div class="step"><span class="mono">04 / FINISH</span><h3>정지와 연결 종료</h3><p>시작한 장비를 정지하고 결과를 확인한 뒤 포트를 해제합니다.</p></div></div></section>
21
+ <section id="api"><div class="eyebrow">PUBLIC API</div><h2>수업을 만드는 데 필요한 기능</h2><div class="table-wrap"><table><thead><tr><th>기능</th><th>API</th><th>받는 것</th></tr></thead><tbody><tr><td>환경과 연결</td><td><code>getSupport / connect / disconnect</code></td><td>연결 가능 여부와 종료 결과</td></tr><tr><td>장비 확인</td><td><code>scan / getDevices</code></td><td>지원 기능과 사용 가능한 장비 목록</td></tr><tr><td>운동 모드</td><td><code>configure</code></td><td>자유 · 목표 횟수 · 목표 시간 설정 결과</td></tr><tr><td>시작과 정지</td><td><code>start / stop</code></td><td>장비별 수락·실패·불확실 결과</td></tr><tr><td>측정값 수신</td><td><code>on('measurement') / readMeasurement</code></td><td>누적 횟수 · 경과 시간 · 멈춤 수</td></tr><tr><td>캐시와 배터리</td><td><code>getLastMeasurement / readBattery</code></td><td>마지막 값과 신선도 · 배터리 잔량</td></tr><tr><td>상태 알림</td><td><code>deviceChanged / connectionChanged / error</code></td><td>상태 변경과 오류 정보</td></tr><tr><td>개발 지원</td><td><code>identify</code></td><td>지원하는 장비 찾기 기능</td></tr><tr><td>가상 장비</td><td><code>createSimulatedGateway</code></td><td>같은 API로 개발하는 테스트 환경</td></tr></tbody></table></div></section>
22
+ <section><div class="eyebrow">COMMAND RESULTS</div><h2>장비별 처리 결과를 확인합니다.</h2><div class="results"><div class="result"><code>acknowledged</code><p>대상 장비의 요청을 확인했습니다.</p></div><div class="result"><code>failed</code><p>요청이 처리되지 않았습니다. 안내 메시지를 확인합니다.</p></div><div class="result"><code>unknown</code><p>결과를 확인할 수 없습니다. 장비 상태를 확인하세요.</p></div></div><div class="note">일부 장비가 시작되지 않았다면 장비별 결과를 확인하세요. 정지할 때는 시작에 사용한 장비 목록을 사용합니다.</div></section>
23
+ <section><div class="eyebrow">DEVELOP WITHOUT HARDWARE</div><h2>가상 장비부터 실제 교실까지.</h2><pre class="code">const gateway = createSimulatedGateway({ deviceCount: 3, seed: 1 });
24
+
25
+ // 장비 단절·부분 시작 실패·연결 소실을 재현할 수 있습니다.
26
+ gateway.simulation.setStartFailure(2, true);
27
+ gateway.simulation.disconnectDevice(3);</pre><p>가상 데이터에는 항상 <code>simulated: true</code>가 표시됩니다. 시뮬레이터 통과는 실제 장비의 성능이나 호환성을 인증하지 않습니다.</p></section>
28
+ <section><div class="eyebrow">REFERENCE</div><h2>구현할 때 곁에 둘 문서</h2><div class="docs"><a class="doc" href="API.md"><strong>함수 사용법</strong><p>입력값, 반환값, 단위, 상태와 오류를 확인합니다.</p><span>API.md →</span></a><a class="doc" href="COMPATIBILITY.md"><strong>지원 범위와 제한</strong><p>준비할 장비와 사용할 수 있는 환경을 안내합니다.</p><span>COMPATIBILITY.md →</span></a><a class="doc" href="SECURITY.md"><strong>수업 운영과 기록 관리</strong><p>장비 선택, 수업 종료와 학생 기록 관리 방법을 확인합니다.</p><span>SECURITY.md →</span></a></div></section>
29
+ <footer>Jumpitnow Teacher SDK · 2026.10.10 · 교사용 사용 안내<br>컴퓨터용 Chrome/Edge 사용 · 0.1.0-alpha.2 · 개발 확인용</footer>
30
+ </main></body></html>
@@ -0,0 +1,104 @@
1
+ // For a bundled application use: import { ... } from '@makeitnow/jumpitnow';
2
+ import { createGateway, createSimulatedGateway } from '../../dist/esm/index.js';
3
+ const $ = id => document.getElementById(id);
4
+ let gateway, subscriptions = [], busy = false, selected = new Set(), activeTargets = [], lines = [];
5
+ const labels = { disconnected: '미연결', connecting: '연결 중', connected: '연결됨', disconnecting: '종료 중', lost: '연결 소실' };
6
+ const deviceLabels = { available: '준비됨', running: '운동 중', unknown: '확인 필요', offline: '응답 없음' };
7
+ function log(title, result) {
8
+ const time = new Date().toLocaleTimeString('ko-KR', { hour12: false });
9
+ let detail = '';
10
+ if (result?.results) {
11
+ const status = { acknowledged: '요청 확인됨', failed: '처리하지 못함', unknown: '장비 확인 필요' };
12
+ detail = result.results.map(item => {
13
+ const device = gateway.getDevices().find(d => d.deviceId === item.deviceId);
14
+ return `줄넘기 ${device?.displayNumber ?? '미확인'}: ${status[item.status] ?? '확인 필요'}`;
15
+ }).join('\n');
16
+ } else if (typeof result?.devices === 'number') detail = `${result.devices}대 확인`;
17
+ else if (typeof result?.closed === 'boolean') detail = result.closed ? '연결 종료됨' : '연결 상태 확인 필요';
18
+ else if (result?.message) detail = result.message;
19
+ lines.unshift(`${time} ${title}${detail ? '\n' + detail : ''}`);
20
+ lines = lines.slice(0, 20); $('log').textContent = lines.join('\n\n');
21
+ }
22
+ function refresh() {
23
+ const state = gateway?.getInfo().connection ?? 'disconnected'; const connected = state === 'connected';
24
+ $('connection').textContent = labels[state]; $('dot').classList.toggle('online', connected);
25
+ $('source').disabled = busy || state !== 'disconnected' || !!activeTargets.length; $('connect').disabled = busy || state !== 'disconnected';
26
+ $('scan').disabled = busy || !connected || activeTargets.length > 0;
27
+ $('disconnect').disabled = busy || state === 'disconnected';
28
+ $('start').disabled = busy || !connected || !selected.size || !!activeTargets.length;
29
+ $('stop').disabled = busy || !connected || !activeTargets.length;
30
+ $('selectAll').disabled = busy || !connected || !gateway?.getDevices().length || !!activeTargets.length;
31
+ $('exercise').disabled = busy || !!activeTargets.length; $('target').disabled = busy || !!activeTargets.length;
32
+ for (const input of $('devices').querySelectorAll('input')) input.disabled = busy || !!activeTargets.length || input.dataset.offline === 'true';
33
+ }
34
+ function renderDevices() {
35
+ const devices = gateway.getDevices(); $('empty').hidden = devices.length > 0; $('devices').hidden = !devices.length;
36
+ const known = new Set(devices.map(d => d.deviceId)); selected = new Set([...selected].filter(id => known.has(id)));
37
+ $('devices').replaceChildren();
38
+ for (const device of devices) {
39
+ const card = document.createElement('article'); card.className = 'rope'; card.dataset.deviceId = device.deviceId;
40
+ const label = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = selected.has(device.deviceId);
41
+ input.dataset.offline = String(device.state === 'offline' || device.displayNumber === null);
42
+ input.addEventListener('change', () => { if (input.checked) selected.add(device.deviceId); else selected.delete(device.deviceId); card.classList.toggle('selected', input.checked); refresh(); });
43
+ label.append(input, document.createTextNode(`줄넘기 ${device.displayNumber ?? '미배정'}`));
44
+ const count = document.createElement('div'); count.className = 'count'; count.textContent = gateway.getLastMeasurement({ deviceId: device.deviceId })?.count ?? '—';
45
+ const unit = document.createElement('div'); unit.className = 'unit'; unit.textContent = '누적 횟수';
46
+ const footer = document.createElement('div'); footer.className = 'devicefooter';
47
+ const state = document.createElement('span'); state.className = 'device-state'; state.textContent = deviceLabels[device.state];
48
+ const time = document.createElement('span'); time.className = 'elapsed'; time.textContent = '수신 대기'; footer.append(state, time);
49
+ card.append(label, count, unit, footer); card.classList.toggle('selected', input.checked); $('devices').append(card);
50
+ }
51
+ refresh();
52
+ }
53
+ function makeGateway() {
54
+ subscriptions.forEach(off => off()); subscriptions = []; selected.clear(); activeTargets = [];
55
+ const simulated = $('source').value === 'simulation';
56
+ gateway = simulated ? createSimulatedGateway({ deviceCount: 3, seed: 12, scanWindowMs: 300 }) : createGateway();
57
+ $('modeBadge').textContent = simulated ? '가상 장비 · 실제 장비 제어 없음' : '게이트웨이 · 실제 장비 연결';
58
+ subscriptions.push(gateway.on('connectionChanged', refresh));
59
+ subscriptions.push(gateway.on('deviceChanged', ({ device }) => {
60
+ const card = [...$('devices').children].find(el => el.dataset.deviceId === device.deviceId);
61
+ if (card) card.querySelector('.device-state').textContent = deviceLabels[device.state];
62
+ }));
63
+ subscriptions.push(gateway.on('measurement', measurement => {
64
+ const card = [...$('devices').children].find(el => el.dataset.deviceId === measurement.deviceId);
65
+ if (card) { card.querySelector('.count').textContent = measurement.count; card.querySelector('.elapsed').textContent = `${(measurement.elapsedMs / 1000).toFixed(1)}초`; }
66
+ }));
67
+ subscriptions.push(gateway.on('error', error => { $('notice').textContent = error.message; log('통신 상태 확인', error); refresh(); }));
68
+ renderDevices();
69
+ }
70
+ async function run(action) {
71
+ busy = true; refresh();
72
+ try { await action(); }
73
+ catch (error) { $('notice').textContent = error.message; log('요청 확인 필요', { code: error.code, message: error.message }); }
74
+ finally { busy = false; refresh(); }
75
+ }
76
+ $('source').addEventListener('change', makeGateway);
77
+ $('connect').addEventListener('click', () => run(async () => {
78
+ await gateway.connect(); const result = await gateway.scan(); renderDevices();
79
+ $('notice').textContent = `${result.devices.length}대가 목록에 있습니다. 사용할 장비를 선택하세요.`; log('연결 및 검색 완료', { devices: result.devices.length, simulated: gateway.getInfo().simulated });
80
+ }));
81
+ $('scan').addEventListener('click', () => run(async () => { await gateway.scan(); selected.clear(); renderDevices(); $('notice').textContent = '검색을 마쳤습니다. 사용할 장비를 다시 선택하세요.'; }));
82
+ $('selectAll').addEventListener('click', () => { selected = new Set(gateway.getDevices().filter(d => d.state === 'available' && d.displayNumber !== null).map(d => d.deviceId)); renderDevices(); });
83
+ $('exercise').addEventListener('change', () => { $('targetField').hidden = $('exercise').value === 'free'; $('targetLabel').textContent = $('exercise').value === 'count' ? '목표 횟수' : '목표 시간 (초)'; $('target').value = $('exercise').value === 'count' ? 100 : 60; });
84
+ $('start').addEventListener('click', () => run(async () => {
85
+ const deviceIds = [...selected]; const type = $('exercise').value;
86
+ const mode = type === 'count' ? { type, targetCount: Number($('target').value) } : type === 'time' ? { type, durationSeconds: Number($('target').value) } : { type };
87
+ const configured = await gateway.configure({ deviceIds, mode }); log('모드 설정', configured);
88
+ if (!configured.ok) { $('notice').textContent = '설정 응답을 확인하지 못한 장비가 있습니다.'; return; }
89
+ const result = await gateway.start({ deviceIds });
90
+ activeTargets = result.results.filter(r => r.status !== 'failed' && r.recovery?.status !== 'acknowledged').map(r => r.deviceId);
91
+ log('시작 결과', result); $('notice').textContent = result.ok ? '운동 데이터를 받고 있습니다. 종료할 때 정지 버튼을 누르세요.' : '일부 시작을 확인하지 못했습니다. 복구 결과를 확인하세요.';
92
+ }));
93
+ $('stop').addEventListener('click', () => run(async () => {
94
+ const result = await gateway.stop({ deviceIds: [...activeTargets] });
95
+ activeTargets = result.results.filter(r => r.status !== 'acknowledged').map(r => r.deviceId);
96
+ log('정지 결과', result); $('notice').textContent = result.ok ? '모든 대상의 정지 수락을 확인했습니다.' : '정지를 확인하지 못한 장비가 있습니다. 현장에서 상태를 확인하세요.';
97
+ }));
98
+ $('disconnect').addEventListener('click', () => run(async () => {
99
+ const result = await gateway.disconnect(); log('연결 종료', result);
100
+ $('notice').textContent = result.stopResults && !result.stopResults.ok ? '정지가 불확실한 장비가 있습니다. 현장에서 확인하세요.' : result.closed ? '연결을 종료했습니다.' : '연결 종료를 확인하지 못했습니다.';
101
+ // Preserve the same SDK instance and unresolved targets across reconnects.
102
+ if (result.stopResults?.ok) activeTargets = [];
103
+ }));
104
+ makeGateway();
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>Jumpitnow SDK · 클래스룸 콘솔</title>
6
+ <style>
7
+ :root{--paper:#f4f7fb;--white:#fff;--ink:#183341;--muted:#597180;--line:#dce5ed;--blue:#245bda;--teal:#087f80;--red:#b43e35;--soft:#eaf0ff}
8
+ *{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;line-height:1.6}button,input,select{font:inherit}button,select,input{border:1px solid var(--line);border-radius:8px;padding:10px 14px;background:white;color:var(--ink)}button{cursor:pointer;font-weight:700}button:hover:not(:disabled){border-color:var(--blue);color:var(--blue)}button:disabled{opacity:.4;cursor:not-allowed}button.primary{background:var(--blue);color:#fff;border-color:var(--blue)}button.stop{color:var(--red)}:focus-visible{outline:3px solid #92b5ff;outline-offset:3px}a{color:var(--blue);text-underline-offset:4px}header{background:#fff;border-bottom:1px solid var(--line)}.bar{max-width:1240px;margin:auto;display:flex;align-items:center;justify-content:space-between;padding:22px 30px;gap:18px}.brand{font-size:24px;letter-spacing:-1px;font-weight:900}.brand span{color:var(--blue)}.version{font:11px ui-monospace,monospace;color:var(--muted);letter-spacing:.07em}.toplinks{display:flex;gap:22px;font-size:13px}main{max-width:1240px;margin:0 auto;padding:48px 30px 64px}.intro{display:flex;justify-content:space-between;align-items:flex-end;gap:30px;margin-bottom:34px}.eyebrow{font:11px ui-monospace,monospace;letter-spacing:.14em;color:var(--blue);font-weight:700}h1{font-size:clamp(30px,4vw,44px);line-height:1.25;letter-spacing:-2px;margin:13px 0}p{color:var(--muted);margin:10px 0}.badge{padding:7px 11px;background:var(--soft);color:var(--blue);border-radius:5px;font-size:12px;white-space:nowrap}.workspace{display:grid;grid-template-columns:290px minmax(0,1fr);gap:24px}.panel{background:var(--white);border:1px solid var(--line);border-radius:12px;overflow:hidden}.panel h2{font-size:16px;margin:0}.panelhead{padding:20px 22px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:12px}.settings{padding:22px}.settings label{display:block;font-size:13px;font-weight:700;margin:18px 0 7px}.settings label:first-child{margin-top:0}.settings select,.settings input{width:100%}.settings button{width:100%;margin-top:12px}.separator{border:0;border-top:1px solid var(--line);margin:23px 0}.small{font-size:12px;color:var(--muted)}.connection{font-size:12px;display:flex;align-items:center;gap:7px}.dot{height:7px;width:7px;background:#8e9eaa;border-radius:100%}.dot.online{background:var(--teal)}.board{min-height:340px}.empty{padding:76px 30px;text-align:center;color:var(--muted)}.empty strong{display:block;color:var(--ink);font-size:18px;margin-bottom:10px}.ropes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding:22px}.rope{border:1px solid var(--line);border-radius:10px;position:relative;padding:20px;min-width:0}.rope.selected{border-color:var(--blue);box-shadow:inset 0 0 0 1px var(--blue);background:#f9fbff}.rope label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:700}.rope input{accent-color:var(--blue);width:16px;height:16px;margin:0}.count{font:700 clamp(38px,4vw,56px) ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-3px;line-height:1.3;margin-top:30px;color:var(--blue);font-variant-numeric:tabular-nums}.unit{font-size:12px;color:var(--muted);margin-bottom:22px}.devicefooter{border-top:1px solid var(--line);padding-top:12px;display:flex;justify-content:space-between;gap:4px;font-size:11px;color:var(--muted)}.statusnote{padding:14px 22px;background:#f7fafc;border-top:1px solid var(--line);font-size:12px;color:var(--muted);min-height:49px}.details{margin-top:24px}.eventlog{margin:0;padding:20px 22px;max-height:240px;overflow:auto;font:12px/1.8 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.api-strip{margin-top:28px;display:grid;grid-template-columns:1fr 1fr;gap:24px}.api-strip section{border-top:2px solid var(--line);padding-top:15px}.api-strip h3{font-size:14px;margin:0 0 8px}.api-strip code{font:12px/1.8 ui-monospace,monospace;color:var(--teal)}footer{margin-top:42px;padding-top:20px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}[hidden]{display:none!important}@media(max-width:850px){.workspace{grid-template-columns:1fr}.settings{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}.settings .separator{grid-column:1/-1}.settings label{margin-top:0}.settings .small{grid-column:1/-1}.intro{align-items:flex-start}.ropes{grid-template-columns:repeat(3,minmax(0,1fr))}.rope{padding:15px}}@media(max-width:540px){main{padding:32px 18px}.bar{padding:18px}.toplinks a:last-child{display:none}.intro{display:block}.badge{display:inline-block;margin-top:12px}.ropes{grid-template-columns:1fr}.rope{display:grid;grid-template-columns:1fr 1fr}.rope label{grid-column:1/-1}.count{margin-top:12px}.unit{align-self:center;margin:0;text-align:right}.devicefooter{grid-column:1/-1;margin-top:12px}.api-strip{grid-template-columns:1fr}.panelhead{padding:16px}.settings{display:block}.settings label{margin-top:16px}.count{font-size:44px}}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <header><div class="bar"><div><div class="brand">jumpitnow<span> .</span></div><div class="version">TEACHER SDK / 0.1.0-alpha.2</div></div><nav class="toplinks"><a href="../../docs/index.html">사용 가이드</a><a href="../../docs/API.md">함수 사용법</a></nav></div></header>
13
+ <main>
14
+ <div class="intro"><div><div class="eyebrow">INTERACTIVE EXAMPLE</div><h1>연결에서 첫 번째 점프까지.</h1><p>장비를 선택하고 운동을 시작해 보세요.<br>수신되는 누적 횟수를 SDK가 화면에 전달합니다.</p></div><span class="badge" id="modeBadge">가상 장비 · 실제 장비 제어 없음</span></div>
15
+ <div class="workspace">
16
+ <section class="panel"><div class="panelhead"><h2>수업 준비</h2><div class="connection"><span class="dot" id="dot"></span><span id="connection">미연결</span></div></div>
17
+ <div class="settings">
18
+ <div><label for="source">연결 대상</label><select id="source"><option value="simulation">가상 장비 3대</option value="hardware">게이트웨이</option></select></div>
19
+ <button class="primary" id="connect">연결하고 검색</button>
20
+ <button id="scan" disabled>다시 검색</button><button id="disconnect" disabled>연결 종료</button>
21
+ <hr class="separator">
22
+ <div><label for="exercise">운동 모드</label><select id="exercise"><option value="free">자유 운동</option><option value="count">목표 횟수</option><option value="time">목표 시간</option></select></div>
23
+ <div id="targetField" hidden><label for="target" id="targetLabel">목표 횟수</label><input id="target" type="number" min="1" max="65535" step="1" value="100"></div>
24
+ <button class="primary" id="start" disabled>선택 장비 운동 시작</button><button class="stop" id="stop" disabled>운동 정지</button>
25
+ <p class="small">시작한 장비 목록을 보관해 정지합니다. 선택하지 않은 장비에는 운동 명령을 보내지 않습니다.</p>
26
+ </div>
27
+ </section>
28
+ <div>
29
+ <section class="panel board"><div class="panelhead"><h2>장비별 측정</h2><button id="selectAll" disabled>전체 선택</button></div><div class="empty" id="empty"><strong>수업에 사용할 장비를 연결하세요.</strong>가상 장비로 연결 흐름과 화면을 먼저 확인할 수 있습니다.</div><div class="ropes" id="devices" hidden></div><div class="statusnote" id="notice" role="status" aria-live="polite">연결 → 장비 선택 → 시작 → 데이터 수신 → 정지</div></section>
30
+ <section class="panel details"><div class="panelhead"><h2>명령 결과</h2><span class="small">최근 실행 결과</span></div><pre class="eventlog" id="log">아직 실행한 명령이 없습니다.</pre></section>
31
+ </div>
32
+ </div>
33
+ <div class="api-strip"><section><h3>수업 진행 순서</h3><code>연결 → 장비 선택 → 운동 시작<br>데이터 확인 → 정지 → 연결 종료</code></section><section><h3>데이터를 읽는 방법</h3><p class="small">횟수는 현재 구간의 누적값입니다. 새 값을 기존 값에 더하지 않습니다. 가상 모드의 값은 실제 수업 기록과 구분하세요.</p></section></div>
34
+ <footer>개발 확인용 alpha · 가상 장비 사용 예제 · 학생 정보와 데이터는 서버로 전송하지 않습니다.</footer>
35
+ </main>
36
+ <script type="module" src="./app.js"></script>
37
+ </body></html>
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createGateway, type Device, type Measurement, type CommandResult } from '@makeitnow/jumpitnow';
3
+
4
+ /** Install React in the consuming app. Connect must run from the user's button click. */
5
+ export function GatewayPanel() {
6
+ const [gateway] = useState(() => createGateway());
7
+ const [devices, setDevices] = useState<Device[]>([]);
8
+ const [selected, setSelected] = useState<string[]>([]);
9
+ const [values, setValues] = useState<Record<string, Measurement>>({});
10
+ const [message, setMessage] = useState('게이트웨이를 연결하세요.');
11
+ const [busy, setBusy] = useState(false);
12
+ const started = useRef<string[]>([]);
13
+ const mounted = useRef(false);
14
+ const pendingConnect = useRef<Promise<unknown> | null>(null);
15
+ useEffect(() => {
16
+ mounted.current = true;
17
+ const offData = gateway.on('measurement', m => setValues(old => ({ ...old, [m.deviceId]: m })));
18
+ const offError = gateway.on('error', e => setMessage(`${e.code}: ${e.message}`));
19
+ return () => {
20
+ mounted.current = false;
21
+ offData(); offError();
22
+ // Best-effort only; the user should explicitly stop/disconnect before leaving.
23
+ const pending = pendingConnect.current;
24
+ void (pending ? pending.catch(() => {}).then(() => gateway.disconnect()) : gateway.disconnect()).catch(() => {});
25
+ };
26
+ }, [gateway]);
27
+ async function run(action: () => Promise<void>) {
28
+ setBusy(true);
29
+ try { await action(); }
30
+ catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '요청 실패'); }
31
+ finally { if (mounted.current) setBusy(false); }
32
+ }
33
+ function report(result: CommandResult) { setMessage(JSON.stringify(result, null, 2)); }
34
+ return <section>
35
+ <button disabled={busy} onClick={() => run(async () => {
36
+ const connecting = gateway.connect(); pendingConnect.current = connecting;
37
+ try { await connecting; } finally { pendingConnect.current = null; }
38
+ if (!mounted.current) return;
39
+ const discovered = await gateway.scan(); if (mounted.current) setDevices(discovered.devices);
40
+ })}>게이트웨이 연결</button>
41
+ {devices.map(d => <label key={d.deviceId} style={{ display: 'block' }}>
42
+ <input type="checkbox" disabled={busy || d.state === 'offline'} checked={selected.includes(d.deviceId)}
43
+ onChange={e => setSelected(old => e.target.checked ? [...old, d.deviceId] : old.filter(id => id !== d.deviceId))} />
44
+ 줄넘기 {d.displayNumber ?? '미배정'} · {values[d.deviceId]?.count ?? '—'}회
45
+ </label>)}
46
+ <button disabled={busy || !selected.length} onClick={() => run(async () => {
47
+ const deviceIds = [...selected];
48
+ const configured = await gateway.configure({ deviceIds, mode: { type: 'free' } });
49
+ if (!configured.ok) return report(configured);
50
+ const result = await gateway.start({ deviceIds });
51
+ started.current = [...new Set([...started.current, ...result.results
52
+ .filter(r => r.status !== 'failed' && r.recovery?.status !== 'acknowledged').map(r => r.deviceId)])];
53
+ report(result);
54
+ })}>선택 장비 시작</button>
55
+ <button disabled={busy} onClick={() => run(async () => {
56
+ if (!started.current.length) return;
57
+ const result = await gateway.stop({ deviceIds: [...started.current] });
58
+ started.current = result.results.filter(r => r.status !== 'acknowledged').map(r => r.deviceId); report(result);
59
+ })}>시작한 장비 정지</button>
60
+ <button disabled={busy} onClick={() => run(async () => {
61
+ const result = await gateway.disconnect(); setMessage(JSON.stringify(result, null, 2));
62
+ if (result.stopResults?.ok) started.current = [];
63
+ })}>연결 종료</button>
64
+ <pre role="status" style={{ whiteSpace: 'pre-wrap' }}>{message}</pre>
65
+ </section>;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@makeitnow/jumpitnow",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.2",
4
+ "description": "JavaScript SDK for Jumpitnow classroom gateways and smart jump ropes",
5
+ "type": "module",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/esm/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/esm/index.d.ts",
13
+ "default": "./dist/esm/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/cjs/index.d.ts",
17
+ "default": "./dist/cjs/index.js"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "docs/index.html",
24
+ "docs/API.md",
25
+ "docs/COMPATIBILITY.md",
26
+ "docs/SECURITY.md",
27
+ "examples",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "sideEffects": false,
31
+ "license": "UNLICENSED",
32
+ "scripts": {
33
+ "build": "node scripts/build.mjs",
34
+ "test": "npm run build && node --test test/*.test.mjs",
35
+ "typecheck": "tsc --noEmit && tsc -p test/tsconfig.json",
36
+ "check:package": "node scripts/check-package.mjs",
37
+ "verify": "npm test && npm run typecheck && npm run check:package",
38
+ "prepack": "npm run build",
39
+ "demo": "npm run build && node scripts/serve.mjs"
40
+ },
41
+ "devDependencies": {
42
+ "typescript": "5.8.3"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ }
47
+ }