@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.
- package/CHANGELOG.md +11 -0
- package/README.md +366 -2
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/internal/errors.d.ts +15 -0
- package/dist/cjs/internal/errors.js +44 -0
- package/dist/cjs/internal/gateway.d.ts +41 -0
- package/dist/cjs/internal/gateway.js +586 -0
- package/dist/cjs/internal/link.d.ts +13 -0
- package/dist/cjs/internal/link.js +111 -0
- package/dist/cjs/internal/protocol.d.ts +29 -0
- package/dist/cjs/internal/protocol.js +38 -0
- package/dist/cjs/internal/simulator.d.ts +15 -0
- package/dist/cjs/internal/simulator.js +117 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/types.d.ts +182 -0
- package/dist/cjs/types.js +2 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +22 -0
- package/dist/esm/internal/errors.d.ts +15 -0
- package/dist/esm/internal/errors.js +37 -0
- package/dist/esm/internal/gateway.d.ts +41 -0
- package/dist/esm/internal/gateway.js +582 -0
- package/dist/esm/internal/link.d.ts +13 -0
- package/dist/esm/internal/link.js +106 -0
- package/dist/esm/internal/protocol.d.ts +29 -0
- package/dist/esm/internal/protocol.js +32 -0
- package/dist/esm/internal/simulator.d.ts +15 -0
- package/dist/esm/internal/simulator.js +113 -0
- package/dist/esm/types.d.ts +182 -0
- package/dist/esm/types.js +1 -0
- package/docs/API.md +106 -0
- package/docs/COMPATIBILITY.md +11 -0
- package/docs/SECURITY.md +17 -0
- package/docs/index.html +30 -0
- package/examples/browser/app.js +104 -0
- package/examples/browser/index.html +37 -0
- package/examples/react/GatewayPanel.tsx +66 -0
- package/package.json +45 -4
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SDKError, asError } from './errors.js';
|
|
2
|
+
const provider = () => globalThis.navigator?.serial;
|
|
3
|
+
export function getSupport() {
|
|
4
|
+
const secureContext = globalThis.isSecureContext === true;
|
|
5
|
+
const webSerial = !!provider();
|
|
6
|
+
return { supported: secureContext && webSerial, secureContext, webSerial,
|
|
7
|
+
reason: !secureContext ? 'INSECURE_CONTEXT' : !webSerial ? 'UNSUPPORTED_BROWSER' : null };
|
|
8
|
+
}
|
|
9
|
+
export class BrowserLink {
|
|
10
|
+
#port = null;
|
|
11
|
+
#reader = null;
|
|
12
|
+
#writer = null;
|
|
13
|
+
#reading = null;
|
|
14
|
+
#closing = false;
|
|
15
|
+
#openedByUs = false;
|
|
16
|
+
async open(line, lost) {
|
|
17
|
+
const support = getSupport();
|
|
18
|
+
if (!support.supported)
|
|
19
|
+
throw new SDKError(support.reason);
|
|
20
|
+
this.#closing = false;
|
|
21
|
+
try {
|
|
22
|
+
this.#port = await provider().requestPort();
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new SDKError(e?.name === 'NotFoundError' ? 'USER_CANCELLED' : 'PORT_UNAVAILABLE');
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
await this.#port.open({ baudRate: 115200 });
|
|
29
|
+
this.#openedByUs = true;
|
|
30
|
+
if (!this.#port.readable || !this.#port.writable)
|
|
31
|
+
throw new SDKError('PORT_UNAVAILABLE');
|
|
32
|
+
this.#reader = this.#port.readable.getReader();
|
|
33
|
+
this.#writer = this.#port.writable.getWriter();
|
|
34
|
+
this.#reading = this.#read(line, lost);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
await this.close().catch(() => { });
|
|
38
|
+
throw new SDKError('PORT_UNAVAILABLE');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async #read(line, lost) {
|
|
42
|
+
const reader = this.#reader;
|
|
43
|
+
const decoder = new TextDecoder();
|
|
44
|
+
let buffer = '';
|
|
45
|
+
let discarding = false;
|
|
46
|
+
try {
|
|
47
|
+
while (!this.#closing) {
|
|
48
|
+
const { value, done } = await reader.read();
|
|
49
|
+
if (done)
|
|
50
|
+
break;
|
|
51
|
+
const text = decoder.decode(value, { stream: true });
|
|
52
|
+
for (const ch of text) {
|
|
53
|
+
if (ch === '\n') {
|
|
54
|
+
if (!discarding)
|
|
55
|
+
line(buffer.replace(/\r$/, ''));
|
|
56
|
+
buffer = '';
|
|
57
|
+
discarding = false;
|
|
58
|
+
}
|
|
59
|
+
else if (!discarding) {
|
|
60
|
+
buffer += ch;
|
|
61
|
+
if (buffer.length > 4096) {
|
|
62
|
+
buffer = '';
|
|
63
|
+
discarding = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch { /* The public error deliberately omits OS and raw device details. */ }
|
|
70
|
+
finally {
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
this.#reader = null;
|
|
73
|
+
if (!this.#closing)
|
|
74
|
+
lost();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async write(command) {
|
|
78
|
+
if (!this.#writer || this.#closing)
|
|
79
|
+
throw new SDKError('CONNECTION_LOST');
|
|
80
|
+
try {
|
|
81
|
+
await this.#writer.write(new TextEncoder().encode(`${command}\n`));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new SDKError('CONNECTION_LOST');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async close() {
|
|
88
|
+
this.#closing = true;
|
|
89
|
+
try {
|
|
90
|
+
await this.#reader?.cancel().catch(() => { });
|
|
91
|
+
await this.#reading;
|
|
92
|
+
if (this.#writer) {
|
|
93
|
+
await this.#writer.abort().catch(() => { });
|
|
94
|
+
this.#writer.releaseLock();
|
|
95
|
+
this.#writer = null;
|
|
96
|
+
}
|
|
97
|
+
if (this.#port && this.#openedByUs)
|
|
98
|
+
await this.#port.close();
|
|
99
|
+
this.#port = null;
|
|
100
|
+
this.#openedByUs = false;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw asError(error, 'PORT_UNAVAILABLE');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Frame = {
|
|
2
|
+
kind: 'help';
|
|
3
|
+
text: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'list';
|
|
6
|
+
phase: 'BEGIN' | 'END';
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'device';
|
|
9
|
+
number: number;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
battery: number;
|
|
12
|
+
version: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'tx';
|
|
15
|
+
target: string;
|
|
16
|
+
endpoint: string;
|
|
17
|
+
bytes: number[];
|
|
18
|
+
ok: boolean;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'rx';
|
|
21
|
+
number: number;
|
|
22
|
+
endpoint: string;
|
|
23
|
+
bytes: number[];
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'error';
|
|
26
|
+
};
|
|
27
|
+
export declare function bytes(hex: string): number[] | null;
|
|
28
|
+
export declare function parse(line: string): Frame | null;
|
|
29
|
+
export declare const u16: (data: number[], offset: number) => number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function bytes(hex) {
|
|
2
|
+
if (!/^(?:[0-9a-f]{2}){1,48}$/i.test(hex))
|
|
3
|
+
return null;
|
|
4
|
+
return hex.match(/../g).map(v => Number.parseInt(v, 16));
|
|
5
|
+
}
|
|
6
|
+
const number = (s, max = 255) => s !== undefined && /^\d+$/.test(s) && Number(s) <= max ? Number(s) : null;
|
|
7
|
+
const endpoint = (s) => !!s && /^[a-z0-9:.%-]{1,63}$/i.test(s);
|
|
8
|
+
export function parse(line) {
|
|
9
|
+
const p = line.trim().split(',');
|
|
10
|
+
if (line.startsWith('HELP'))
|
|
11
|
+
return { kind: 'help', text: line };
|
|
12
|
+
if (p[0] === 'LIST' && (p[1] === 'BEGIN' || p[1] === 'END'))
|
|
13
|
+
return { kind: 'list', phase: p[1] };
|
|
14
|
+
if (p[0] === 'DEVICE' && p.length === 10 && number(p[1], 40) !== null && number(p[2]) !== null && endpoint(p[3]) && /^\d+\.\d+\.\d+$/.test(p[8]) && /^-?\d+$/.test(p[5])) {
|
|
15
|
+
return { kind: 'device', number: Number(p[2]), endpoint: p[3], battery: Number(p[5]), version: p[8] };
|
|
16
|
+
}
|
|
17
|
+
if (p[0] === 'TXB' && p.length === 5 && endpoint(p[2]) && p[1] && (p[4] === 'OK' || p[4] === 'FAIL')) {
|
|
18
|
+
const b = bytes(p[3]);
|
|
19
|
+
if (b)
|
|
20
|
+
return { kind: 'tx', target: p[1], endpoint: p[2], bytes: b, ok: p[4] === 'OK' };
|
|
21
|
+
}
|
|
22
|
+
if (p[0] === 'RXB' && p.length === 4 && number(p[1]) !== null && endpoint(p[2])) {
|
|
23
|
+
const b = bytes(p[3]);
|
|
24
|
+
if (b && b.length >= 2 && b[1] === Number(p[1]))
|
|
25
|
+
return { kind: 'rx', number: Number(p[1]), endpoint: p[2], bytes: b };
|
|
26
|
+
}
|
|
27
|
+
// Normalized measurement lines have no unambiguous endpoint. Use the addressed response above only.
|
|
28
|
+
if (p[0] === 'ERR' && p[1] !== 'DEVICE')
|
|
29
|
+
return { kind: 'error' };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export const u16 = (data, offset) => data[offset] | (data[offset + 1] << 8);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SimulationOptions } from '../types.js';
|
|
2
|
+
import type { Link } from './link.js';
|
|
3
|
+
export declare class SimulationLink implements Link {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(options: SimulationOptions);
|
|
6
|
+
open(line: (line: string) => void, lost: () => void): Promise<void>;
|
|
7
|
+
write(command: string): Promise<void>;
|
|
8
|
+
controls(): Readonly<{
|
|
9
|
+
disconnectDevice: (displayNumber: number) => void;
|
|
10
|
+
reconnectDevice: (displayNumber: number) => void;
|
|
11
|
+
setStartFailure: (displayNumber: number, fail: boolean) => void;
|
|
12
|
+
dropConnection: () => void;
|
|
13
|
+
}>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { SDKError, integer } from './errors.js';
|
|
2
|
+
const codes = { free: 16, count: 17, time: 18, start: 19, stop: 20, getData: 21, getBattery: 6, vibrate: 49, buzzer: 48, showId: 51 };
|
|
3
|
+
const hex = (values) => values.map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
4
|
+
const word = (n) => [n & 255, (n >> 8) & 255];
|
|
5
|
+
export class SimulationLink {
|
|
6
|
+
#line = null;
|
|
7
|
+
#lost = null;
|
|
8
|
+
#units;
|
|
9
|
+
#delay;
|
|
10
|
+
#failures;
|
|
11
|
+
#seed;
|
|
12
|
+
#timers = new Set();
|
|
13
|
+
constructor(options) {
|
|
14
|
+
if (!options || typeof options !== 'object' || Array.isArray(options))
|
|
15
|
+
throw new SDKError('INVALID_ARGUMENT');
|
|
16
|
+
const count = integer(options.deviceCount ?? 3, 1, 40);
|
|
17
|
+
this.#delay = integer(options.responseDelayMs ?? 10, 0, 30000);
|
|
18
|
+
this.#seed = integer(options.seed ?? 1, 0, 0xffffffff);
|
|
19
|
+
if (options.startFailures && !Array.isArray(options.startFailures))
|
|
20
|
+
throw new SDKError('INVALID_ARGUMENT');
|
|
21
|
+
this.#failures = new Set((options.startFailures ?? []).map(n => integer(n, 1, count)));
|
|
22
|
+
this.#units = Array.from({ length: count }, (_, i) => ({ number: i + 1, online: true, mode: 0, running: false, count: 0, ticks: 0, target: 0 }));
|
|
23
|
+
}
|
|
24
|
+
async open(line, lost) { this.#line = line; this.#lost = lost; }
|
|
25
|
+
#send(lines) {
|
|
26
|
+
const timer = setTimeout(() => { this.#timers.delete(timer); for (const line of lines)
|
|
27
|
+
this.#line?.(line); }, this.#delay);
|
|
28
|
+
this.#timers.add(timer);
|
|
29
|
+
}
|
|
30
|
+
async write(command) {
|
|
31
|
+
if (!this.#line)
|
|
32
|
+
throw new SDKError('CONNECTION_LOST');
|
|
33
|
+
if (command === 'help') {
|
|
34
|
+
this.#send(['HELP: target=all|0|id|#index|!addr', 'HELP: getData|status|fw', 'HELP: getList|clearList|scanList|refreshList']);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (command === 'getList') {
|
|
38
|
+
this.#send(['LIST,BEGIN', ...this.#units.filter(u => u.online).map(u => `DEVICE,${u.number},${u.number},unit-${u.number},-,85,0,0,1.0.0,-`), 'LIST,END']);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (command === 'discover all') {
|
|
42
|
+
this.#send(['TXB,all,group,01,OK', ...this.#units.filter(u => u.online).map(u => `RXB,${u.number},unit-${u.number},${hex([1, u.number])}`)]);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const [name, target, parameter] = command.split(' ');
|
|
46
|
+
const code = codes[name];
|
|
47
|
+
const unit = this.#units.find(u => `!unit-${u.number}` === target);
|
|
48
|
+
if (code === undefined || !unit) {
|
|
49
|
+
this.#send(['ERR,BAD_TARGET']);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const request = hex([code, ...(parameter === undefined ? [] : word(Number(parameter)))]);
|
|
53
|
+
const tx = `TXB,${target},unit-${unit.number},${request},OK`;
|
|
54
|
+
if (!unit.online) {
|
|
55
|
+
this.#send([tx]);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (code === 19 && this.#failures.has(unit.number)) {
|
|
59
|
+
this.#send([tx, `RXB,${unit.number},unit-${unit.number},${hex([127, unit.number, 1, code])}`]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let payload = [code, unit.number];
|
|
63
|
+
if ([16, 17, 18].includes(code)) {
|
|
64
|
+
unit.mode = code - 16;
|
|
65
|
+
unit.target = Number(parameter ?? 0);
|
|
66
|
+
}
|
|
67
|
+
if (code === 19) {
|
|
68
|
+
unit.running = true;
|
|
69
|
+
unit.count = 0;
|
|
70
|
+
unit.ticks = 0;
|
|
71
|
+
}
|
|
72
|
+
if (code === 20)
|
|
73
|
+
unit.running = false;
|
|
74
|
+
if (code === 21) {
|
|
75
|
+
if (unit.running) {
|
|
76
|
+
this.#seed = (Math.imul(this.#seed, 1664525) + 1013904223) >>> 0;
|
|
77
|
+
unit.count += 1 + this.#seed % 3;
|
|
78
|
+
unit.ticks += 5;
|
|
79
|
+
if (unit.mode === 1 && unit.count >= unit.target) {
|
|
80
|
+
unit.count = unit.target;
|
|
81
|
+
unit.running = false;
|
|
82
|
+
}
|
|
83
|
+
if (unit.mode === 2 && unit.ticks >= unit.target * 10) {
|
|
84
|
+
unit.ticks = unit.target * 10;
|
|
85
|
+
unit.running = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
payload = [...payload, unit.mode, ...word(unit.count % 65536), ...word(unit.ticks % 65536), 0, 0];
|
|
89
|
+
}
|
|
90
|
+
if (code === 6)
|
|
91
|
+
payload.push(85);
|
|
92
|
+
this.#send([tx, `RXB,${unit.number},unit-${unit.number},${hex(payload)}`]);
|
|
93
|
+
}
|
|
94
|
+
#unit(displayNumber) { integer(displayNumber, 1, this.#units.length); return this.#units[displayNumber - 1]; }
|
|
95
|
+
controls() {
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
disconnectDevice: (displayNumber) => { this.#unit(displayNumber).online = false; },
|
|
98
|
+
reconnectDevice: (displayNumber) => { this.#unit(displayNumber).online = true; },
|
|
99
|
+
setStartFailure: (displayNumber, fail) => {
|
|
100
|
+
this.#unit(displayNumber);
|
|
101
|
+
if (typeof fail !== 'boolean')
|
|
102
|
+
throw new SDKError('INVALID_ARGUMENT');
|
|
103
|
+
if (fail)
|
|
104
|
+
this.#failures.add(displayNumber);
|
|
105
|
+
else
|
|
106
|
+
this.#failures.delete(displayNumber);
|
|
107
|
+
},
|
|
108
|
+
dropConnection: () => { const lost = this.#lost; void this.close(); lost?.(); }
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async close() { for (const timer of this.#timers)
|
|
112
|
+
clearTimeout(timer); this.#timers.clear(); this.#line = null; this.#lost = null; }
|
|
113
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export type ErrorCode = 'UNSUPPORTED_BROWSER' | 'INSECURE_CONTEXT' | 'USER_CANCELLED' | 'PORT_UNAVAILABLE' | 'NOT_CONNECTED' | 'UNSUPPORTED_GATEWAY' | 'UNSUPPORTED_FEATURE' | 'INVALID_ARGUMENT' | 'INVALID_STATE' | 'DEVICE_OFFLINE' | 'AMBIGUOUS_DEVICE' | 'BUSY' | 'TIMEOUT' | 'DEVICE_REJECTED' | 'CONNECTION_LOST' | 'PROTOCOL_ERROR';
|
|
2
|
+
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'disconnecting' | 'lost';
|
|
3
|
+
export type DeviceState = 'available' | 'running' | 'unknown' | 'offline';
|
|
4
|
+
export type Mode = {
|
|
5
|
+
type: 'free';
|
|
6
|
+
} | {
|
|
7
|
+
type: 'count';
|
|
8
|
+
targetCount: number;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'time';
|
|
11
|
+
durationSeconds: number;
|
|
12
|
+
};
|
|
13
|
+
export type IdentifySignal = 'vibrate' | 'buzzer' | 'displayId';
|
|
14
|
+
export type Battery = {
|
|
15
|
+
kind: 'percent';
|
|
16
|
+
value: number;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'unknown';
|
|
19
|
+
};
|
|
20
|
+
export interface Capabilities {
|
|
21
|
+
modes: Array<Mode['type']>;
|
|
22
|
+
maxTargetCount: number;
|
|
23
|
+
maxDurationSeconds: number;
|
|
24
|
+
identifySignals: IdentifySignal[];
|
|
25
|
+
/** Actual hardware combinations must still pass the release acceptance checks. */
|
|
26
|
+
hardwareVerified: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface Device {
|
|
29
|
+
deviceId: string;
|
|
30
|
+
displayNumber: number | null;
|
|
31
|
+
state: DeviceState;
|
|
32
|
+
firmwareVersion: string | null;
|
|
33
|
+
battery: Battery;
|
|
34
|
+
capabilities: Capabilities;
|
|
35
|
+
simulated: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface Measurement {
|
|
38
|
+
deviceId: string;
|
|
39
|
+
displayNumber: number | null;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
segmentId: string;
|
|
42
|
+
count: number;
|
|
43
|
+
elapsedMs: number;
|
|
44
|
+
pauseCount: number | null;
|
|
45
|
+
receivedAt: number;
|
|
46
|
+
source: 'live' | 'cache';
|
|
47
|
+
stale: boolean;
|
|
48
|
+
simulated: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface ErrorInfo {
|
|
51
|
+
code: ErrorCode;
|
|
52
|
+
message: string;
|
|
53
|
+
recoverable: boolean;
|
|
54
|
+
deviceId?: string;
|
|
55
|
+
requestId?: string;
|
|
56
|
+
}
|
|
57
|
+
export type ResultStatus = 'acknowledged' | 'failed' | 'unknown';
|
|
58
|
+
export interface DeviceResult {
|
|
59
|
+
deviceId: string;
|
|
60
|
+
status: ResultStatus;
|
|
61
|
+
errorCode?: ErrorCode;
|
|
62
|
+
recovery?: {
|
|
63
|
+
action: 'stop';
|
|
64
|
+
status: ResultStatus;
|
|
65
|
+
};
|
|
66
|
+
finalMeasurement?: {
|
|
67
|
+
available: boolean;
|
|
68
|
+
measurement: Measurement | null;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface CommandResult {
|
|
72
|
+
requestId: string;
|
|
73
|
+
ok: boolean;
|
|
74
|
+
results: DeviceResult[];
|
|
75
|
+
}
|
|
76
|
+
export interface DisconnectResult {
|
|
77
|
+
closed: boolean;
|
|
78
|
+
stopResults: CommandResult | null;
|
|
79
|
+
}
|
|
80
|
+
export interface ScanResult {
|
|
81
|
+
devices: Device[];
|
|
82
|
+
status: 'complete';
|
|
83
|
+
windowMs: number;
|
|
84
|
+
}
|
|
85
|
+
export interface Support {
|
|
86
|
+
supported: boolean;
|
|
87
|
+
secureContext: boolean;
|
|
88
|
+
webSerial: boolean;
|
|
89
|
+
reason: ErrorCode | null;
|
|
90
|
+
}
|
|
91
|
+
export interface GatewayInfo {
|
|
92
|
+
connection: ConnectionState;
|
|
93
|
+
sdkVersion: string;
|
|
94
|
+
interfaceVersion: string;
|
|
95
|
+
firmwareVersion: string | null;
|
|
96
|
+
simulated: boolean;
|
|
97
|
+
features: string[];
|
|
98
|
+
}
|
|
99
|
+
export interface Diagnostics {
|
|
100
|
+
sdkVersion: string;
|
|
101
|
+
interfaceVersion: string;
|
|
102
|
+
connection: ConnectionState;
|
|
103
|
+
simulated: boolean;
|
|
104
|
+
deviceCount: number;
|
|
105
|
+
blockedOperationCount: number;
|
|
106
|
+
recentRequests: Array<{
|
|
107
|
+
requestId: string;
|
|
108
|
+
durationMs: number;
|
|
109
|
+
errorCode: ErrorCode | null;
|
|
110
|
+
}>;
|
|
111
|
+
}
|
|
112
|
+
export interface Events {
|
|
113
|
+
measurement: Measurement;
|
|
114
|
+
deviceChanged: {
|
|
115
|
+
device: Device;
|
|
116
|
+
previousState: DeviceState | null;
|
|
117
|
+
};
|
|
118
|
+
connectionChanged: {
|
|
119
|
+
state: ConnectionState;
|
|
120
|
+
previousState: ConnectionState;
|
|
121
|
+
};
|
|
122
|
+
error: ErrorInfo;
|
|
123
|
+
}
|
|
124
|
+
export interface GatewayOptions {
|
|
125
|
+
requestTimeoutMs?: number;
|
|
126
|
+
scanWindowMs?: number;
|
|
127
|
+
pollIntervalMs?: number;
|
|
128
|
+
staleAfterMs?: number;
|
|
129
|
+
/** Empty by default; enable only signals validated for the actual product. */
|
|
130
|
+
identifySignals?: IdentifySignal[];
|
|
131
|
+
}
|
|
132
|
+
export interface Gateway {
|
|
133
|
+
connect(): Promise<GatewayInfo>;
|
|
134
|
+
disconnect(): Promise<DisconnectResult>;
|
|
135
|
+
getInfo(): GatewayInfo;
|
|
136
|
+
scan(options?: {
|
|
137
|
+
timeoutMs?: number;
|
|
138
|
+
}): Promise<ScanResult>;
|
|
139
|
+
getDevices(): Device[];
|
|
140
|
+
configure(options: {
|
|
141
|
+
deviceIds: string[];
|
|
142
|
+
mode: Mode;
|
|
143
|
+
}): Promise<CommandResult>;
|
|
144
|
+
start(options: {
|
|
145
|
+
deviceIds: string[];
|
|
146
|
+
}): Promise<CommandResult>;
|
|
147
|
+
stop(options: {
|
|
148
|
+
deviceIds: string[];
|
|
149
|
+
}): Promise<CommandResult>;
|
|
150
|
+
readMeasurement(options: {
|
|
151
|
+
deviceId: string;
|
|
152
|
+
timeoutMs?: number;
|
|
153
|
+
}): Promise<Measurement>;
|
|
154
|
+
getLastMeasurement(options: {
|
|
155
|
+
deviceId: string;
|
|
156
|
+
}): Measurement | null;
|
|
157
|
+
readBattery(options: {
|
|
158
|
+
deviceId: string;
|
|
159
|
+
timeoutMs?: number;
|
|
160
|
+
}): Promise<Battery>;
|
|
161
|
+
identify(options: {
|
|
162
|
+
deviceId: string;
|
|
163
|
+
signal: IdentifySignal;
|
|
164
|
+
}): Promise<CommandResult>;
|
|
165
|
+
on<K extends keyof Events>(event: K, handler: (value: Events[K]) => void): () => void;
|
|
166
|
+
getDiagnostics(): Diagnostics;
|
|
167
|
+
}
|
|
168
|
+
export interface SimulationOptions extends GatewayOptions {
|
|
169
|
+
deviceCount?: number;
|
|
170
|
+
seed?: number;
|
|
171
|
+
responseDelayMs?: number;
|
|
172
|
+
/** Display numbers that reject start, for testing partial failure recovery. */
|
|
173
|
+
startFailures?: number[];
|
|
174
|
+
}
|
|
175
|
+
export interface SimulatedGateway extends Gateway {
|
|
176
|
+
simulation: {
|
|
177
|
+
disconnectDevice(displayNumber: number): void;
|
|
178
|
+
reconnectDevice(displayNumber: number): void;
|
|
179
|
+
setStartFailure(displayNumber: number, fail: boolean): void;
|
|
180
|
+
dropConnection(): void;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/docs/API.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Jumpitnow SDK · 함수 사용법
|
|
2
|
+
|
|
3
|
+
교사 앱에서 사용하는 JavaScript 함수와 입력값, 결과값을 설명합니다. 현재 버전은 `0.1.0-alpha.2`입니다. 설치 방법은 [사용 안내](../README.md)를 참고하세요.
|
|
4
|
+
|
|
5
|
+
## 연결하고 장비 선택하기
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
await gateway.connect();
|
|
9
|
+
const { devices } = await gateway.scan();
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`connect()`는 연결 버튼을 눌렀을 때 호출합니다. 검색 결과에서 수업에 사용할 장비를 선택하세요. 각 장비의 `deviceId`는 이후 함수에 전달하는 키이고, `displayNumber`는 장비 표시 번호입니다. 학생 이름이나 학생 번호를 `deviceId`로 보내지 않습니다.
|
|
13
|
+
|
|
14
|
+
| 함수 | 결과 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `getSupport()` | `supported`가 true이면 실행 환경에서 연결 기능을 사용할 수 있음 |
|
|
17
|
+
| `connect()` | 연결 완료 또는 오류 |
|
|
18
|
+
| `scan()` | `{ devices, status }` |
|
|
19
|
+
| `getDevices()` | 현재 장비 목록 |
|
|
20
|
+
| `disconnect()` | `{ closed, stopResults }` |
|
|
21
|
+
|
|
22
|
+
검색한 장비가 모두 수업 대상이 되는 것은 아닙니다. 선생님이 선택한 장비만 명령에 지정하세요. `closed`는 연결 종료 여부이고 `stopResults`는 장비 정지 결과입니다.
|
|
23
|
+
|
|
24
|
+
## 운동 설정하기
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
await gateway.configure({ deviceIds, mode: { type: 'free' } });
|
|
28
|
+
await gateway.configure({ deviceIds, mode: { type: 'count', targetCount: 100 } });
|
|
29
|
+
await gateway.configure({ deviceIds, mode: { type: 'time', durationSeconds: 60 } });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `deviceIds`: 선택한 장비의 키 목록. 빈 목록이나 같은 장비의 중복 선택은 허용하지 않습니다.
|
|
33
|
+
- `targetCount`: 목표 횟수. 양의 정수입니다.
|
|
34
|
+
- `durationSeconds`: 목표 시간. 초 단위의 양의 정수입니다.
|
|
35
|
+
|
|
36
|
+
설정 결과가 성공인지 확인한 후 운동을 시작하세요. 가능한 목표값은 장비의 지원 범위를 따릅니다.
|
|
37
|
+
|
|
38
|
+
## 시작과 정지
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const started = await gateway.start({ deviceIds });
|
|
42
|
+
const stopped = await gateway.stop({ deviceIds });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
정지에는 시작할 때 선택했던 장비 목록을 사용합니다. 운동 중 선택 화면을 바꾸더라도 시작한 장비 목록을 잊지 않도록 보관하세요.
|
|
46
|
+
|
|
47
|
+
| 결과 필드 | 선생님 앱에서의 의미 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `ok` | 선택한 모든 장비의 요청이 확인되면 true |
|
|
50
|
+
| `results` | 장비별 처리 결과 |
|
|
51
|
+
| `status: 'acknowledged'` | 요청이 확인됨 |
|
|
52
|
+
| `status: 'failed'` | 요청이 처리되지 않음 |
|
|
53
|
+
| `status: 'unknown'` | 결과를 확인할 수 없음. 장비 상태 확인 필요 |
|
|
54
|
+
| `recovery` | 일부 시작 실패 후 정지 시도 결과 |
|
|
55
|
+
| `finalMeasurement` | 정지할 때 마지막 운동 값을 확보했는지와 해당 값 |
|
|
56
|
+
|
|
57
|
+
`unknown`을 성공으로 표시하지 마세요. 결과 확인 없이 시작 버튼을 반복 실행하지 않습니다. 정지 확인이 되지 않으면 장비를 현장에서 확인합니다.
|
|
58
|
+
|
|
59
|
+
## 운동 데이터 받기
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const unsubscribe = gateway.on('measurement', data => {
|
|
63
|
+
renderCount(data.deviceId, data.count);
|
|
64
|
+
renderTime(data.deviceId, data.elapsedMs);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const current = await gateway.readMeasurement({ deviceId });
|
|
68
|
+
const previous = gateway.getLastMeasurement({ deviceId });
|
|
69
|
+
const battery = await gateway.readBattery({ deviceId });
|
|
70
|
+
|
|
71
|
+
// 더 이상 화면을 사용하지 않을 때
|
|
72
|
+
unsubscribe();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| 데이터 | 의미 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `deviceId` | 어느 장비의 값인지 구분하는 키 |
|
|
78
|
+
| `displayNumber` | 장비 표시 번호. 미확인 시 null |
|
|
79
|
+
| `count` | 현재 운동 구간의 누적 횟수 |
|
|
80
|
+
| `elapsedMs` | 경과 시간, 밀리초 단위 |
|
|
81
|
+
| `pauseCount` | 멈춤 횟수. 알 수 없으면 null |
|
|
82
|
+
| `receivedAt` | 앱이 값을 받은 시각 |
|
|
83
|
+
| `source` | `live`는 이번에 받은 값, `cache`는 저장된 마지막 값 |
|
|
84
|
+
| `stale` | 값이 오래되었거나 현재 상태 확인이 필요한 경우 true |
|
|
85
|
+
| `simulated` | 연습용 가상 장비이면 true |
|
|
86
|
+
|
|
87
|
+
횟수는 기존 값에 더하지 않고 새 값으로 바꿉니다. `sessionId`와 `segmentId`가 달라지면 다른 운동 또는 구간으로 구분하고 임의로 합산하지 않습니다. 마지막 값이 없으면 `getLastMeasurement()`는 null을 반환합니다. 배터리 결과의 `kind: 'percent'`는 퍼센트이며 `value`가 잔량입니다.
|
|
88
|
+
|
|
89
|
+
## 상태 변경과 오류
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
gateway.on('deviceChanged', ({ device }) => updateDevice(device));
|
|
93
|
+
gateway.on('connectionChanged', ({ state }) => updateConnection(state));
|
|
94
|
+
gateway.on('error', error => showError(error.message));
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
연결 해제나 확인 필요 상태를 화면에 보여주세요. 함수 호출 오류는 try/catch로 처리합니다. 입력값 오류는 값을 고치고, 연결 오류는 장비 상태를 확인합니다. 운동 종료 여부가 불확실하면 시작을 반복하지 않습니다.
|
|
98
|
+
|
|
99
|
+
## 장비 없이 연습하기
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
import { createSimulatedGateway } from '@makeitnow/jumpitnow';
|
|
103
|
+
const gateway = createSimulatedGateway({ deviceCount: 3 });
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
같은 함수로 연결·선택·시작·수신·정지를 연습할 수 있습니다. 가상 데이터는 실제 수업 기록과 구분하세요.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# 사용 환경
|
|
2
|
+
|
|
3
|
+
- 컴퓨터의 Chrome 또는 Edge에서 사용합니다.
|
|
4
|
+
- 보안 연결로 제공되는 웹페이지 또는 컴퓨터의 로컬 개발 환경에서 실행합니다.
|
|
5
|
+
- 호환되는 Jumpitnow 게이트웨이와 스마트 줄넘기를 준비합니다.
|
|
6
|
+
- 하나의 수업 화면에서 하나의 게이트웨이를 사용합니다.
|
|
7
|
+
- 연결 버튼을 눌러 사용할 게이트웨이를 선택합니다.
|
|
8
|
+
|
|
9
|
+
현재 설치 파일은 개발 확인용 alpha 버전입니다. 지원하는 장비 조합은 실제 장비 확인 후 안내할 예정입니다. 가상 장비로 화면을 확인한 것은 실제 장비 검증을 대신하지 않습니다.
|
|
10
|
+
|
|
11
|
+
연결이 끊기면 운동 상태를 먼저 확인하세요. 사용한 장비의 정지를 확인한 뒤 다시 연결합니다. 자동으로 운동이 다시 시작되었다고 가정하지 않습니다.
|
package/docs/SECURITY.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# 수업 운영과 기록 관리
|
|
2
|
+
|
|
3
|
+
## 장비 선택
|
|
4
|
+
|
|
5
|
+
검색 결과에서 이번 수업에 사용할 줄넘기를 직접 선택하세요. 표시 번호가 겹치거나 장비를 구분하기 어렵다면 먼저 장비를 확인합니다.
|
|
6
|
+
|
|
7
|
+
## 운동 시작과 종료
|
|
8
|
+
|
|
9
|
+
설정 결과를 확인한 뒤 시작하고, 수업이 끝나면 사용한 장비를 정지합니다. 장비가 정지했는지 확인한 후 연결을 종료하세요. 연결이 끊기거나 화면을 닫았다는 이유만으로 장비가 정지했다고 판단하지 않습니다.
|
|
10
|
+
|
|
11
|
+
## 기록 관리
|
|
12
|
+
|
|
13
|
+
학생 명단과 수업 기록의 저장 여부·위치는 선생님의 앱에서 정합니다. SDK는 학생 정보를 자동으로 수집하거나 수업 기록을 외부로 전송하지 않습니다. 장비 키를 학생의 영구 식별자로 사용하지 마세요.
|
|
14
|
+
|
|
15
|
+
## 연습 데이터
|
|
16
|
+
|
|
17
|
+
가상 장비의 값은 연습용입니다. 실제 학생의 수업 기록에 합치지 않습니다.
|