• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

atomic14 / web-serial-plotter / 18927554430

30 Oct 2025 02:04AM UTC coverage: 60.213% (-1.0%) from 61.184%
18927554430

Pull #22

github

web-flow
Merge 6f890ce49 into ae73642b7
Pull Request #22: Feature: Add COBS-encoded float32 support

421 of 537 branches covered (78.4%)

Branch coverage included in aggregate %.

18 of 128 new or added lines in 4 files covered. (14.06%)

1 existing line in 1 file now uncovered.

2064 of 3590 relevant lines covered (57.49%)

33.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

1.66
/src/hooks/useSerial.ts
1
import { useCallback, useRef, useState } from 'react'
1✔
2

3
import type { SerialConfig } from './useDataConnection'
4

5
import { decodeCOBS, COBSDecoderError } from '../utils/cobsDecoder.ts'
1✔
6

7

8
export type BaudRate = 300 | 600 | 1200 | 2400 | 4800 | 9600 | 19200 | 38400 | 57600 | 115200 | 230400 | 460800 | 921600
9

10
export interface SerialState {
11
  isSupported: boolean
12
  isConnecting: boolean
13
  isConnected: boolean
14
  port: SerialPort | null
15
  readerLocked: boolean
16
  error: string | null
17
}
18

19
export interface UseSerial {
20
  state: SerialState
21
  connect: (baudRate: number) => Promise<void>
22
  disconnect: () => Promise<void>
23
  onLine: (handler: (line: string) => void) => void
24
  write: (data: string) => Promise<void>
25
}
26

27
// Minimal serial types from lib.dom (guarded by any where unavailable)
28
// eslint-disable-next-line @typescript-eslint/no-explicit-any
29
type SerialPort = any
30

31
export function useSerial(): UseSerial {
1✔
32
  const [state, setState] = useState<SerialState>({
×
33
    isSupported: typeof navigator !== 'undefined' && !!(navigator as Navigator).serial,
×
34
    isConnecting: false,
×
35
    isConnected: false,
×
36
    port: null,
×
37
    readerLocked: false,
×
38
    error: null,
×
39
  })
×
40

41

42
  const lineHandlerRef = useRef<((line: string) => void) | null>(null)
×
43
  const abortControllerRef = useRef<AbortController | null>(null)
×
44
  const readerRef = useRef<ReadableStreamDefaultReader<string> | null>(null)
×
45
  const portRef = useRef<SerialPort | null>(null)
×
46
  const writerRef = useRef<WritableStreamDefaultWriter<Uint8Array> | null>(null)
×
47

48
  const onLine = useCallback((handler: (line: string) => void) => {
×
49
    lineHandlerRef.current = handler
×
50
  }, [])
×
51

52
  const disconnect = useCallback(async () => {
×
53
    try {
×
54
      abortControllerRef.current?.abort()
×
55
      abortControllerRef.current = null
×
56
      
57
      if (readerRef.current) {
×
58
        try {
×
59
          await readerRef.current.cancel()
×
60
        } catch {
×
61
          // ignore cancel errors
62
        }
×
63
      }
×
64
      readerRef.current = null
×
65
      
66
      if (writerRef.current) {
×
67
        try {
×
68
          await writerRef.current.close()
×
69
        } catch {
×
70
          // ignore close errors
71
        }
×
72
      }
×
73
      writerRef.current = null
×
74
      
75
      if (portRef.current && typeof portRef.current.close === 'function') {
×
76
        await portRef.current.close()
×
77
      }
×
78
      portRef.current = null
×
79
      
80
    } catch {
×
81
      // swallow
82
    } finally {
×
83
      setState((s) => ({ ...s, isConnected: false, port: null, readerLocked: false }))
×
84
    }
×
85
  }, []) // No dependencies - use refs for everything
×
86

NEW
87
  const connect = useCallback(async (config: SerialConfig) => {
×
88
    if (!state.isSupported) {
×
89
      setState((s) => ({ ...s, error: 'Web Serial not supported in this browser.' }))
×
90
      return
×
91
    }
×
92
    
93
    // Make sure we're fully disconnected first
94
    if (portRef.current) {
×
95
      await disconnect()
×
96
    }
×
97
    
98
    setState((s) => ({ ...s, isConnecting: true, error: null }))
×
99
    
100
    try {
×
101
      // Always request a fresh port - don't reuse existing ones
102
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
      const port: any = await (navigator as Navigator).serial!.requestPort()
×
104
      
105
      // Check if port is already open before attempting to open it
106
      if (port.readable) {
×
107
        try {
×
108
          await port.close()
×
109
          // Give it a moment to fully close
110
          await new Promise(resolve => setTimeout(resolve, 100))
×
111
        } catch {
×
112
          // ignore
113
        }
×
114
      }
×
115
      
NEW
116
      await port.open({ baudRate: config.baudRate })
×
117

NEW
118
      if (config.encoding === 'ascii') {
×
NEW
119
        const textDecoder = new TextDecoderStream()
×
NEW
120
        const readableClosed = port.readable.pipeTo(textDecoder.writable)
×
NEW
121
        const reader = textDecoder.readable.getReader()
×
NEW
122
        readerRef.current = reader
×
123

NEW
124
        const writer = port.writable.getWriter()
×
NEW
125
        writerRef.current = writer
×
NEW
126
        portRef.current = port
×
127

NEW
128
        setState((s) => ({ ...s, port, isConnected: true }))
×
129

NEW
130
        const abort = new AbortController()
×
NEW
131
        abortControllerRef.current = abort
×
132

NEW
133
        let buffer = ''
×
NEW
134
        ;(async () => {
×
NEW
135
          try {
×
NEW
136
            while (true) {
×
NEW
137
              const { value, done } = await reader.read()
×
NEW
138
              if (done) break
×
NEW
139
              if (value) {
×
NEW
140
                buffer += value
×
NEW
141
                let index
×
NEW
142
                while ((index = buffer.indexOf('\n')) >= 0) {
×
NEW
143
                  const line = buffer.slice(0, index).replace(/\r$/, '')
×
NEW
144
                  buffer = buffer.slice(index + 1)
×
NEW
145
                  lineHandlerRef.current?.(line)
×
NEW
146
                }
×
147
              }
×
148
            }
×
149
          } catch {
×
150
            // ignore if aborted
NEW
151
          } finally {
×
NEW
152
            try {
×
NEW
153
              reader.releaseLock()
×
NEW
154
            } catch {
×
155
              // ignore
NEW
156
            }
×
NEW
157
            try {
×
NEW
158
              await readableClosed.catch(() => {})
×
NEW
159
            } catch {
×
160
              // ignore
NEW
161
            }
×
NEW
162
            setState((s) => ({ ...s, readerLocked: false }))
×
163
          }
×
NEW
164
        })()
×
NEW
165
      } else if (config.encoding === 'cobs-f32') {
×
NEW
166
        const reader = port.readable.getReader()
×
NEW
167
        readerRef.current = reader
×
168

NEW
169
        const writer = port.writable.getWriter()
×
NEW
170
        writerRef.current = writer
×
NEW
171
        portRef.current = port
×
172

NEW
173
        setState((s) => ({ ...s, port, isConnected: true }))
×
174

NEW
175
        const abort = new AbortController()
×
NEW
176
        abortControllerRef.current = abort
×
177
        
NEW
178
        let buffer = [];
×
NEW
179
        (async () => {
×
180
          try {
×
NEW
181
            while (true) {
×
NEW
182
              const { value, done } = await reader.read()
×
NEW
183
              if (done) break
×
NEW
184
              if (value) {
×
NEW
185
                for (const byte of value) {
×
NEW
186
                  if (byte === 0x00) {
×
187
                    // End of COBS packet
NEW
188
                    if (buffer.length > 0) {
×
NEW
189
                      try {
×
NEW
190
                        const decoded = decodeCOBS(buffer)
×
NEW
191
                        const converted = new Float32Array(decoded.buffer, 0, Math.floor(decoded.byteLength/4));
×
192

193
                        // disgusting hack because I don't feel like refactoring stuff right now
NEW
194
                        const line = converted.map((x: number)=>x.toString()).join(' ');
×
NEW
195
                        lineHandlerRef.current?.(line);
×
196

NEW
197
                      } catch (err) { // catch COBS decoding errors
×
NEW
198
                        if (err instanceof COBSDecoderError) {
×
NEW
199
                          console.log(err);
×
NEW
200
                        } else {
×
NEW
201
                          throw err;
×
NEW
202
                        }
×
NEW
203
                      }
×
204

NEW
205
                      buffer = [] // reset for next packet
×
NEW
206
                    }
×
NEW
207
                  } else {
×
NEW
208
                    buffer.push(byte)
×
NEW
209
                  }
×
NEW
210
                }
×
NEW
211
              }
×
NEW
212
            }
×
NEW
213
          } catch (e) {
×
NEW
214
            if (e instanceof Error && e.name === "AbortError") {
×
215
                // ignore if aborted
NEW
216
            } else {
×
NEW
217
              throw e;
×
NEW
218
            }
×
NEW
219
          } finally {
×
NEW
220
            try {
×
NEW
221
              reader.releaseLock()
×
NEW
222
            } catch {
×
223
              // ignore
NEW
224
            }
×
NEW
225
            setState((s) => ({ ...s, readerLocked: false }))
×
226
          }
×
NEW
227
        })()
×
NEW
228
      }
×
229
    } catch (err) {
×
230
      const message = err instanceof Error ? err.message : 'Failed to connect.'
×
231
      setState((s) => ({ ...s, error: message }))
×
232
    } finally {
×
233
      setState((s) => ({ ...s, isConnecting: false }))
×
234
    }
×
235
  }, [state.isSupported, disconnect])
×
236

237
  const write = useCallback(async (data: string) => {
×
238
    if (!writerRef.current) {
×
239
      throw new Error('Serial port not connected')
×
240
    }
×
241
    
242
    try {
×
243
      const encoder = new TextEncoder()
×
244
      const encoded = encoder.encode(data)
×
245
      await writerRef.current.write(encoded)
×
246
    } catch (err) {
×
247
      const message = err instanceof Error ? err.message : 'Failed to write to serial port'
×
248
      setState((s) => ({ ...s, error: message }))
×
249
      throw err
×
250
    }
×
251
  }, [])
×
252

253
  return { state, connect, disconnect, onLine, write }
×
254
}
×
255

256

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc