@jikko/ai-camera 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MAKEITALL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @jikko/ai-camera
2
+
3
+ JIKKO AI Camera SDK입니다. 카메라 시리얼 프로토콜을 파싱하는 `core`와 브라우저 Web Serial API로 카메라에 연결하는 `web` 어댑터를 제공합니다.
4
+
5
+ 이 패키지는 게임 규칙을 포함하지 않습니다. `CLEAR`, `BATTERY`, `ANTENA`, `COOLANT` 같은 카드 의미는 각 프로젝트에서 `classId`를 받아 매핑해야 합니다.
6
+
7
+ ## 설치
8
+
9
+ ```bash
10
+ npm install @jikko/ai-camera
11
+ ```
12
+
13
+ ## WebSerial 사용
14
+
15
+ ```js
16
+ import { createWebSerialCamera } from '@jikko/ai-camera/web'
17
+
18
+ const camera = createWebSerialCamera({
19
+ baudRate: 9600,
20
+ emitRawChunks: true
21
+ })
22
+
23
+ camera.on('classification', (event) => {
24
+ console.log(event.classId, event.score)
25
+ })
26
+
27
+ camera.on('detection', (event) => {
28
+ console.log(event.classId, event.centerX, event.centerY)
29
+ })
30
+
31
+ camera.on('packet', (packet) => {
32
+ console.log(packet.command, packet.length, packet.data)
33
+ })
34
+
35
+ camera.on('error', (event) => {
36
+ console.error(event.code, event.message)
37
+ })
38
+
39
+ await camera.connect()
40
+ ```
41
+
42
+ Web Serial API는 Chrome/Edge 계열 브라우저와 HTTPS 또는 localhost 환경이 필요합니다.
43
+
44
+ ## Parser만 사용
45
+
46
+ ```js
47
+ import { JikkoProtocolParser, parsePacketResults } from '@jikko/ai-camera/core'
48
+
49
+ const parser = new JikkoProtocolParser()
50
+ const packets = parser.feedChunk(bytes)
51
+
52
+ for (const packet of packets) {
53
+ const parsed = parsePacketResults(packet)
54
+ console.log(parsed.classifications, parsed.detections)
55
+ }
56
+ ```
57
+
58
+ ## 패킷 구조
59
+
60
+ ```txt
61
+ START CMD LEN_L LEN_H DATA... CRC_0 CRC_1 CRC_2 CRC_3 END
62
+ 0xFD 1B 1B 1B LEN 4B little-endian 0xED
63
+ ```
64
+
65
+ 명령:
66
+
67
+ ```txt
68
+ 0x00 KEYPOINT_BOX_DETECTION
69
+ 0x01 CLASSIFICATION
70
+ 0x02 DETECTION
71
+ ```
72
+
73
+ 현재 v0.1은 CRC 값을 읽어서 `packet.crc`로 제공합니다. CRC 검증은 아직 수행하지 않습니다.
74
+
75
+ ## 개발
76
+
77
+ ```bash
78
+ npm install
79
+ npm test
80
+ ```
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@jikko/ai-camera",
3
+ "version": "0.1.0",
4
+ "description": "JIKKO AI Camera protocol parser and WebSerial adapter.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js"
12
+ },
13
+ "./core": {
14
+ "types": "./src/core/index.d.ts",
15
+ "import": "./src/core/index.js"
16
+ },
17
+ "./web": {
18
+ "types": "./src/web/index.d.ts",
19
+ "import": "./src/web/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "test": "node --test"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK.git"
33
+ },
34
+ "keywords": [
35
+ "jikko",
36
+ "ai-camera",
37
+ "webserial",
38
+ "serial"
39
+ ],
40
+ "license": "MIT",
41
+ "bugs": {
42
+ "url": "https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK/issues"
43
+ },
44
+ "homepage": "https://github.com/makeitall-dev/JIKKO_AI_CAMERA_SDK#readme",
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,33 @@
1
+ import { getCommandName } from './protocol.js'
2
+
3
+ export function formatByteHex(byte) {
4
+ return Number(byte).toString(16).padStart(2, '0').toUpperCase()
5
+ }
6
+
7
+ export function formatBytesAsHex(bytes) {
8
+ if (!bytes || bytes.length === 0) return ''
9
+ return Array.from(bytes, formatByteHex).join(' ')
10
+ }
11
+
12
+ export function formatBytesAsAscii(bytes) {
13
+ if (!bytes || bytes.length === 0) return ''
14
+
15
+ return Array.from(bytes, (byte) => {
16
+ if (byte >= 32 && byte <= 126) return String.fromCharCode(byte)
17
+ if (byte === 10) return '\\n'
18
+ if (byte === 13) return '\\r'
19
+ return '.'
20
+ }).join('')
21
+ }
22
+
23
+ export function formatCrc(value) {
24
+ return `0x${(Number(value) >>> 0).toString(16).padStart(8, '0').toUpperCase()}`
25
+ }
26
+
27
+ export function formatPacket(packet) {
28
+ if (!packet) return ''
29
+
30
+ const command = packet.command || getCommandName(packet.cmd)
31
+ return `${command} / len ${packet.length} / data ${formatBytesAsHex(packet.data)} / crc ${formatCrc(packet.crc)}`
32
+ }
33
+
@@ -0,0 +1,66 @@
1
+ export const JIKKO_PACKET_START: 0xFD
2
+ export const JIKKO_PACKET_END: 0xED
3
+
4
+ export const JikkoCameraCommand: {
5
+ readonly KEYPOINT_BOX_DETECTION: 0x00
6
+ readonly CLASSIFICATION: 0x01
7
+ readonly DETECTION: 0x02
8
+ }
9
+
10
+ export type JikkoCommandName =
11
+ | 'KEYPOINT_BOX_DETECTION'
12
+ | 'CLASSIFICATION'
13
+ | 'DETECTION'
14
+ | 'UNKNOWN'
15
+
16
+ export type JikkoPacket = {
17
+ start: 0xFD
18
+ cmd: number
19
+ command: JikkoCommandName
20
+ length: number
21
+ data: Uint8Array
22
+ crc: number
23
+ end: 0xED
24
+ receivedAt: number
25
+ }
26
+
27
+ export type ClassificationResult = {
28
+ type: number
29
+ classId: number
30
+ score: number
31
+ index: number
32
+ }
33
+
34
+ export type DetectionResult = {
35
+ type: number
36
+ classId: number
37
+ centerX: number
38
+ centerY: number
39
+ width: number
40
+ height: number
41
+ score: number
42
+ index: number
43
+ }
44
+
45
+ export type ParsedPacketResults = {
46
+ packet: JikkoPacket
47
+ classifications: ClassificationResult[]
48
+ detections: DetectionResult[]
49
+ }
50
+
51
+ export class JikkoProtocolParser {
52
+ feed(byte: number): JikkoPacket | null
53
+ feedChunk(bytes: Iterable<number>): JikkoPacket[]
54
+ reset(): void
55
+ }
56
+
57
+ export function getCommandName(cmd: number): JikkoCommandName
58
+ export function parseClassificationResults(packet: JikkoPacket): ClassificationResult[]
59
+ export function parseDetectionResults(packet: JikkoPacket): DetectionResult[]
60
+ export function parsePacketResults(packet: JikkoPacket): ParsedPacketResults
61
+ export function formatByteHex(byte: number): string
62
+ export function formatBytesAsAscii(bytes: Iterable<number>): string
63
+ export function formatBytesAsHex(bytes: Iterable<number>): string
64
+ export function formatCrc(value: number): string
65
+ export function formatPacket(packet: JikkoPacket): string
66
+
@@ -0,0 +1,20 @@
1
+ export {
2
+ JIKKO_PACKET_END,
3
+ JIKKO_PACKET_START,
4
+ JikkoCameraCommand,
5
+ JikkoProtocolParser,
6
+ getCommandName
7
+ } from './protocol.js'
8
+ export {
9
+ parseClassificationResults,
10
+ parseDetectionResults,
11
+ parsePacketResults
12
+ } from './results.js'
13
+ export {
14
+ formatByteHex,
15
+ formatBytesAsAscii,
16
+ formatBytesAsHex,
17
+ formatCrc,
18
+ formatPacket
19
+ } from './format.js'
20
+
@@ -0,0 +1,132 @@
1
+ export const JIKKO_PACKET_START = 0xFD
2
+ export const JIKKO_PACKET_END = 0xED
3
+
4
+ export const JikkoCameraCommand = Object.freeze({
5
+ KEYPOINT_BOX_DETECTION: 0x00,
6
+ CLASSIFICATION: 0x01,
7
+ DETECTION: 0x02
8
+ })
9
+
10
+ const commandNames = Object.freeze({
11
+ [JikkoCameraCommand.KEYPOINT_BOX_DETECTION]: 'KEYPOINT_BOX_DETECTION',
12
+ [JikkoCameraCommand.CLASSIFICATION]: 'CLASSIFICATION',
13
+ [JikkoCameraCommand.DETECTION]: 'DETECTION'
14
+ })
15
+
16
+ const ReceiveState = Object.freeze({
17
+ WAIT_START: 0,
18
+ HEAD: 1,
19
+ DATA: 2,
20
+ CRC: 3,
21
+ END: 4
22
+ })
23
+
24
+ export function getCommandName(cmd) {
25
+ return commandNames[cmd] || 'UNKNOWN'
26
+ }
27
+
28
+ function readUint32LE(bytes) {
29
+ return (
30
+ (bytes[0] |
31
+ (bytes[1] << 8) |
32
+ (bytes[2] << 16) |
33
+ (bytes[3] << 24)) >>> 0
34
+ )
35
+ }
36
+
37
+ export class JikkoProtocolParser {
38
+ constructor() {
39
+ this.reset()
40
+ }
41
+
42
+ feed(byte) {
43
+ const normalizedByte = Number(byte) & 0xFF
44
+
45
+ switch (this.state) {
46
+ case ReceiveState.WAIT_START:
47
+ if (normalizedByte === JIKKO_PACKET_START) {
48
+ this.currentPacket = {
49
+ start: normalizedByte,
50
+ cmd: 0,
51
+ command: 'UNKNOWN',
52
+ length: 0,
53
+ data: new Uint8Array(),
54
+ crc: 0,
55
+ end: 0,
56
+ receivedAt: Date.now()
57
+ }
58
+ this.receiveBuffer = []
59
+ this.state = ReceiveState.HEAD
60
+ }
61
+ break
62
+
63
+ case ReceiveState.HEAD:
64
+ this.receiveBuffer.push(normalizedByte)
65
+ if (this.receiveBuffer.length === 3) {
66
+ const cmd = this.receiveBuffer[0]
67
+ this.currentPacket.cmd = cmd
68
+ this.currentPacket.command = getCommandName(cmd)
69
+ this.currentPacket.length = this.receiveBuffer[1] | (this.receiveBuffer[2] << 8)
70
+ this.receiveBuffer = []
71
+ this.state = this.currentPacket.length === 0 ? ReceiveState.CRC : ReceiveState.DATA
72
+ }
73
+ break
74
+
75
+ case ReceiveState.DATA:
76
+ this.receiveBuffer.push(normalizedByte)
77
+ if (this.receiveBuffer.length === this.currentPacket.length) {
78
+ this.currentPacket.data = Uint8Array.from(this.receiveBuffer)
79
+ this.receiveBuffer = []
80
+ this.state = ReceiveState.CRC
81
+ }
82
+ break
83
+
84
+ case ReceiveState.CRC:
85
+ this.receiveBuffer.push(normalizedByte)
86
+ if (this.receiveBuffer.length === 4) {
87
+ this.currentPacket.crc = readUint32LE(this.receiveBuffer)
88
+ this.receiveBuffer = []
89
+ this.state = ReceiveState.END
90
+ }
91
+ break
92
+
93
+ case ReceiveState.END:
94
+ if (normalizedByte === JIKKO_PACKET_END) {
95
+ const packet = {
96
+ ...this.currentPacket,
97
+ end: normalizedByte,
98
+ receivedAt: Date.now()
99
+ }
100
+ this.reset()
101
+ return packet
102
+ }
103
+ this.reset()
104
+ break
105
+
106
+ default:
107
+ this.reset()
108
+ break
109
+ }
110
+
111
+ return null
112
+ }
113
+
114
+ feedChunk(bytes) {
115
+ const packets = []
116
+ if (!bytes) return packets
117
+
118
+ for (const byte of bytes) {
119
+ const packet = this.feed(byte)
120
+ if (packet) packets.push(packet)
121
+ }
122
+
123
+ return packets
124
+ }
125
+
126
+ reset() {
127
+ this.state = ReceiveState.WAIT_START
128
+ this.currentPacket = null
129
+ this.receiveBuffer = []
130
+ }
131
+ }
132
+
@@ -0,0 +1,112 @@
1
+ import { JikkoCameraCommand } from './protocol.js'
2
+
3
+ function toByteArray(data) {
4
+ if (!data) return []
5
+ return Array.from(data, (byte) => Number(byte) & 0xFF)
6
+ }
7
+
8
+ function readUint16LE(data, offset) {
9
+ return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8)
10
+ }
11
+
12
+ export function parseClassificationResults(packet) {
13
+ if (!packet || packet.cmd !== JikkoCameraCommand.CLASSIFICATION) return []
14
+
15
+ const data = toByteArray(packet.data)
16
+ const results = []
17
+
18
+ if (data.length >= 3 && data.length % 3 === 0) {
19
+ for (let offset = 0; offset + 2 < data.length; offset += 3) {
20
+ results.push({
21
+ type: data[offset],
22
+ classId: data[offset + 1],
23
+ score: data[offset + 2],
24
+ index: results.length
25
+ })
26
+ }
27
+ return results
28
+ }
29
+
30
+ if (typeof data[1] !== 'undefined') {
31
+ results.push({
32
+ type: data[0] ?? 0,
33
+ classId: data[1],
34
+ score: data[2] ?? 0,
35
+ index: 0
36
+ })
37
+ }
38
+
39
+ for (let offset = 3; offset < data.length; offset += 2) {
40
+ results.push({
41
+ type: data[offset - 1] ?? 0,
42
+ classId: data[offset],
43
+ score: data[offset + 1] ?? 0,
44
+ index: results.length
45
+ })
46
+ }
47
+
48
+ return results
49
+ }
50
+
51
+ export function parseDetectionResults(packet) {
52
+ if (!packet || packet.cmd !== JikkoCameraCommand.DETECTION) return []
53
+
54
+ const data = toByteArray(packet.data)
55
+ const results = []
56
+
57
+ if (data.length >= 11 && data.length % 11 === 0) {
58
+ for (let offset = 0; offset + 10 < data.length; offset += 11) {
59
+ results.push({
60
+ type: data[offset],
61
+ classId: data[offset + 1],
62
+ centerX: readUint16LE(data, offset + 2),
63
+ centerY: readUint16LE(data, offset + 4),
64
+ width: readUint16LE(data, offset + 6),
65
+ height: readUint16LE(data, offset + 8),
66
+ score: data[offset + 10],
67
+ index: results.length
68
+ })
69
+ }
70
+ return results
71
+ }
72
+
73
+ if (data.length >= 11 && (data.length - 1) % 10 === 0) {
74
+ for (let offset = 1; offset + 9 < data.length; offset += 10) {
75
+ results.push({
76
+ type: data[0],
77
+ classId: data[offset],
78
+ centerX: readUint16LE(data, offset + 1),
79
+ centerY: readUint16LE(data, offset + 3),
80
+ width: readUint16LE(data, offset + 5),
81
+ height: readUint16LE(data, offset + 7),
82
+ score: data[offset + 9],
83
+ index: results.length
84
+ })
85
+ }
86
+ return results
87
+ }
88
+
89
+ for (let offset = 1; offset + 5 < data.length; offset += 6) {
90
+ results.push({
91
+ type: data[offset - 1] ?? 0,
92
+ classId: data[offset],
93
+ centerX: data[offset + 1],
94
+ centerY: data[offset + 2],
95
+ width: data[offset + 3],
96
+ height: data[offset + 4],
97
+ score: data[offset + 5],
98
+ index: results.length
99
+ })
100
+ }
101
+
102
+ return results
103
+ }
104
+
105
+ export function parsePacketResults(packet) {
106
+ return {
107
+ packet,
108
+ classifications: parseClassificationResults(packet),
109
+ detections: parseDetectionResults(packet)
110
+ }
111
+ }
112
+
package/src/index.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ export * from './core/index.js'
2
+ export {
3
+ WebSerialCamera,
4
+ createWebSerialCamera,
5
+ isWebSerialSupported
6
+ } from './web/index.js'
7
+ export type {
8
+ CameraErrorCode,
9
+ CameraErrorEvent,
10
+ CameraEventMap,
11
+ CameraStatus,
12
+ CameraStatusEvent,
13
+ ClassificationEvent,
14
+ ClassificationsEvent,
15
+ DetectionEvent,
16
+ DetectionsEvent,
17
+ RawChunkEvent,
18
+ WebSerialCameraOptions
19
+ } from './web/index.js'
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './core/index.js'
2
+ export {
3
+ WebSerialCamera,
4
+ createWebSerialCamera,
5
+ isWebSerialSupported
6
+ } from './web/index.js'
@@ -0,0 +1,95 @@
1
+ import type {
2
+ ClassificationResult,
3
+ DetectionResult,
4
+ JikkoPacket
5
+ } from '../core/index.js'
6
+
7
+ export type CameraStatus =
8
+ | 'idle'
9
+ | 'unsupported'
10
+ | 'connecting'
11
+ | 'connected'
12
+ | 'disconnecting'
13
+ | 'disconnected'
14
+ | 'error'
15
+
16
+ export type CameraErrorCode =
17
+ | 'UNSUPPORTED'
18
+ | 'PERMISSION_DENIED'
19
+ | 'PORT_OPEN_FAILED'
20
+ | 'READ_FAILED'
21
+
22
+ export type CameraStatusEvent = {
23
+ status: CameraStatus
24
+ isConnected: boolean
25
+ receivedAt: number
26
+ }
27
+
28
+ export type CameraErrorEvent = {
29
+ code: CameraErrorCode
30
+ message: string
31
+ cause?: unknown
32
+ receivedAt: number
33
+ }
34
+
35
+ export type RawChunkEvent = {
36
+ bytes: Uint8Array
37
+ receivedAt: number
38
+ }
39
+
40
+ export type ClassificationEvent = ClassificationResult & {
41
+ packet: JikkoPacket
42
+ receivedAt: number
43
+ }
44
+
45
+ export type ClassificationsEvent = {
46
+ results: ClassificationResult[]
47
+ packet: JikkoPacket
48
+ receivedAt: number
49
+ }
50
+
51
+ export type DetectionEvent = DetectionResult & {
52
+ packet: JikkoPacket
53
+ receivedAt: number
54
+ }
55
+
56
+ export type DetectionsEvent = {
57
+ results: DetectionResult[]
58
+ packet: JikkoPacket
59
+ receivedAt: number
60
+ }
61
+
62
+ export type CameraEventMap = {
63
+ status: CameraStatusEvent
64
+ error: CameraErrorEvent
65
+ raw: RawChunkEvent
66
+ packet: JikkoPacket
67
+ classification: ClassificationEvent
68
+ classifications: ClassificationsEvent
69
+ detection: DetectionEvent
70
+ detections: DetectionsEvent
71
+ }
72
+
73
+ export type WebSerialCameraOptions = {
74
+ baudRate?: number
75
+ emitRawChunks?: boolean
76
+ filters?: SerialPortFilter[]
77
+ serial?: Serial
78
+ }
79
+
80
+ export class WebSerialCamera {
81
+ constructor(options?: WebSerialCameraOptions)
82
+ connect(): Promise<void>
83
+ disconnect(): Promise<void>
84
+ getStatus(): CameraStatus
85
+ isConnected(): boolean
86
+ getLastPacket(): JikkoPacket | null
87
+ on<EventName extends keyof CameraEventMap>(
88
+ eventName: EventName,
89
+ handler: (event: CameraEventMap[EventName]) => void
90
+ ): () => void
91
+ }
92
+
93
+ export function createWebSerialCamera(options?: WebSerialCameraOptions): WebSerialCamera
94
+ export function isWebSerialSupported(serial?: Serial | null): boolean
95
+
@@ -0,0 +1,6 @@
1
+ export {
2
+ WebSerialCamera,
3
+ createWebSerialCamera,
4
+ isWebSerialSupported
5
+ } from './web-serial.js'
6
+
@@ -0,0 +1,282 @@
1
+ import { JikkoProtocolParser } from '../core/protocol.js'
2
+ import { parsePacketResults } from '../core/results.js'
3
+
4
+ const DEFAULT_BAUD_RATE = 9600
5
+
6
+ class EventEmitter {
7
+ constructor() {
8
+ this.listeners = new Map()
9
+ }
10
+
11
+ on(eventName, handler) {
12
+ if (!this.listeners.has(eventName)) {
13
+ this.listeners.set(eventName, new Set())
14
+ }
15
+
16
+ this.listeners.get(eventName).add(handler)
17
+
18
+ return () => {
19
+ this.listeners.get(eventName)?.delete(handler)
20
+ }
21
+ }
22
+
23
+ emit(eventName, event) {
24
+ const handlers = this.listeners.get(eventName)
25
+ if (!handlers) return
26
+
27
+ for (const handler of handlers) {
28
+ try {
29
+ handler(event)
30
+ } catch (error) {
31
+ setTimeout(() => {
32
+ throw error
33
+ }, 0)
34
+ }
35
+ }
36
+ }
37
+
38
+ clear() {
39
+ this.listeners.clear()
40
+ }
41
+ }
42
+
43
+ function getDefaultSerial() {
44
+ return globalThis.navigator?.serial ?? null
45
+ }
46
+
47
+ function cloneBytes(bytes) {
48
+ return Uint8Array.from(bytes)
49
+ }
50
+
51
+ export function isWebSerialSupported(serial = getDefaultSerial()) {
52
+ return !!serial
53
+ }
54
+
55
+ export function createWebSerialCamera(options = {}) {
56
+ return new WebSerialCamera(options)
57
+ }
58
+
59
+ export class WebSerialCamera {
60
+ constructor(options = {}) {
61
+ this.options = {
62
+ baudRate: DEFAULT_BAUD_RATE,
63
+ emitRawChunks: false,
64
+ filters: undefined,
65
+ serial: undefined,
66
+ ...options
67
+ }
68
+ this.serial = Object.prototype.hasOwnProperty.call(options, 'serial')
69
+ ? options.serial
70
+ : getDefaultSerial()
71
+ this.parser = new JikkoProtocolParser()
72
+ this.emitter = new EventEmitter()
73
+ this.port = null
74
+ this.reader = null
75
+ this.status = isWebSerialSupported(this.serial) ? 'idle' : 'unsupported'
76
+ this.lastPacket = null
77
+ this.readLoopPromise = null
78
+ this.disconnecting = false
79
+ }
80
+
81
+ on(eventName, handler) {
82
+ return this.emitter.on(eventName, handler)
83
+ }
84
+
85
+ getStatus() {
86
+ return this.status
87
+ }
88
+
89
+ isConnected() {
90
+ return this.status === 'connected'
91
+ }
92
+
93
+ getLastPacket() {
94
+ return this.lastPacket
95
+ }
96
+
97
+ async connect() {
98
+ if (!isWebSerialSupported(this.serial)) {
99
+ const error = new Error('Web Serial API is not supported.')
100
+ this.setStatus('unsupported')
101
+ this.emitError('UNSUPPORTED', error.message, error)
102
+ throw error
103
+ }
104
+
105
+ if (this.isConnected()) return
106
+
107
+ this.disconnecting = false
108
+ this.setStatus('connecting')
109
+
110
+ try {
111
+ const requestOptions = this.options.filters
112
+ ? { filters: this.options.filters }
113
+ : undefined
114
+ const port = await this.serial.requestPort(requestOptions)
115
+ await port.open({ baudRate: this.options.baudRate })
116
+
117
+ this.port = port
118
+ this.parser.reset()
119
+ this.setStatus('connected')
120
+ this.readLoopPromise = this.startReadLoop(port)
121
+ } catch (error) {
122
+ this.setStatus('error')
123
+ this.emitError(
124
+ error?.name === 'NotFoundError' ? 'PERMISSION_DENIED' : 'PORT_OPEN_FAILED',
125
+ error?.message || 'Failed to open serial port.',
126
+ error
127
+ )
128
+ throw error
129
+ }
130
+ }
131
+
132
+ async disconnect() {
133
+ this.disconnecting = true
134
+ this.setStatus('disconnecting')
135
+
136
+ const reader = this.reader
137
+ this.reader = null
138
+ if (reader) {
139
+ try {
140
+ await reader.cancel()
141
+ } catch {
142
+ // Some browsers reject cancel after the stream has already closed.
143
+ }
144
+ this.releaseReader(reader)
145
+ }
146
+
147
+ if (this.readLoopPromise) {
148
+ try {
149
+ await this.readLoopPromise
150
+ } catch {
151
+ // Read loop errors are already emitted through the error event.
152
+ }
153
+ this.readLoopPromise = null
154
+ }
155
+
156
+ if (this.port) {
157
+ try {
158
+ await this.port.close()
159
+ } catch {
160
+ // The OS or browser can close the port first.
161
+ }
162
+ this.port = null
163
+ }
164
+
165
+ this.parser.reset()
166
+ this.setStatus('disconnected')
167
+ }
168
+
169
+ async startReadLoop(port) {
170
+ if (!port?.readable) return
171
+
172
+ let reader = null
173
+
174
+ try {
175
+ reader = port.readable.getReader()
176
+ this.reader = reader
177
+
178
+ while (true) {
179
+ const { value, done } = await reader.read()
180
+ if (done) break
181
+ if (!value) continue
182
+
183
+ this.handleChunk(value)
184
+ }
185
+ } catch (error) {
186
+ if (!this.disconnecting && error?.name !== 'AbortError') {
187
+ this.setStatus('error')
188
+ this.emitError('READ_FAILED', error?.message || 'Failed to read serial data.', error)
189
+ }
190
+ } finally {
191
+ if (this.reader === reader) {
192
+ this.reader = null
193
+ }
194
+ this.releaseReader(reader)
195
+
196
+ if (!this.disconnecting && this.status === 'connected') {
197
+ this.setStatus('disconnected')
198
+ }
199
+ }
200
+ }
201
+
202
+ handleChunk(chunk) {
203
+ const bytes = cloneBytes(chunk)
204
+
205
+ if (this.options.emitRawChunks) {
206
+ this.emitter.emit('raw', {
207
+ bytes,
208
+ receivedAt: Date.now()
209
+ })
210
+ }
211
+
212
+ const packets = this.parser.feedChunk(bytes)
213
+
214
+ for (const packet of packets) {
215
+ this.lastPacket = packet
216
+ this.emitter.emit('packet', packet)
217
+
218
+ const parsed = parsePacketResults(packet)
219
+
220
+ if (parsed.classifications.length > 0) {
221
+ const event = {
222
+ results: parsed.classifications,
223
+ packet,
224
+ receivedAt: packet.receivedAt
225
+ }
226
+ this.emitter.emit('classifications', event)
227
+ for (const result of parsed.classifications) {
228
+ this.emitter.emit('classification', {
229
+ ...result,
230
+ packet,
231
+ receivedAt: packet.receivedAt
232
+ })
233
+ }
234
+ }
235
+
236
+ if (parsed.detections.length > 0) {
237
+ const event = {
238
+ results: parsed.detections,
239
+ packet,
240
+ receivedAt: packet.receivedAt
241
+ }
242
+ this.emitter.emit('detections', event)
243
+ for (const result of parsed.detections) {
244
+ this.emitter.emit('detection', {
245
+ ...result,
246
+ packet,
247
+ receivedAt: packet.receivedAt
248
+ })
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ setStatus(status) {
255
+ this.status = status
256
+ this.emitter.emit('status', {
257
+ status,
258
+ isConnected: this.isConnected(),
259
+ receivedAt: Date.now()
260
+ })
261
+ }
262
+
263
+ emitError(code, message, cause) {
264
+ this.emitter.emit('error', {
265
+ code,
266
+ message,
267
+ cause,
268
+ receivedAt: Date.now()
269
+ })
270
+ }
271
+
272
+ releaseReader(reader) {
273
+ if (!reader) return
274
+
275
+ try {
276
+ reader.releaseLock()
277
+ } catch {
278
+ // Ignore if the stream lock has already been released.
279
+ }
280
+ }
281
+ }
282
+