forked from omriharel/deej
-
Notifications
You must be signed in to change notification settings - Fork 0
/
serial.go
307 lines (243 loc) · 8.53 KB
/
serial.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
package deej
import (
"bufio"
"errors"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"time"
"github.com/jacobsa/go-serial/serial"
"go.uber.org/zap"
"github.com/omriharel/deej/util"
)
// SerialIO provides a deej-aware abstraction layer to managing serial I/O
type SerialIO struct {
comPort string
baudRate uint
deej *Deej
logger *zap.SugaredLogger
stopChannel chan bool
connected bool
connOptions serial.OpenOptions
conn io.ReadWriteCloser
lastKnownNumSliders int
currentSliderPercentValues []float32
sliderMoveConsumers []chan SliderMoveEvent
}
// SliderMoveEvent represents a single slider move captured by deej
type SliderMoveEvent struct {
SliderID int
PercentValue float32
}
var expectedLinePattern = regexp.MustCompile(`^\d{1,4}(\|\d{1,4})*\r\n$`)
// NewSerialIO creates a SerialIO instance that uses the provided deej
// instance's connection info to establish communications with the arduino chip
func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) {
logger = logger.Named("serial")
sio := &SerialIO{
deej: deej,
logger: logger,
stopChannel: make(chan bool),
connected: false,
conn: nil,
sliderMoveConsumers: []chan SliderMoveEvent{},
}
logger.Debug("Created serial i/o instance")
// respond to config changes
sio.setupOnConfigReload()
return sio, nil
}
// Start attempts to connect to our arduino chip
func (sio *SerialIO) Start() error {
// don't allow multiple concurrent connections
if sio.connected {
sio.logger.Warn("Already connected, can't start another without closing first")
return errors.New("serial: connection already active")
}
// set minimum read size according to platform (0 for windows, 1 for linux)
// this prevents a rare bug on windows where serial reads get congested,
// resulting in significant lag
minimumReadSize := 0
if util.Linux() {
minimumReadSize = 1
}
sio.connOptions = serial.OpenOptions{
PortName: sio.deej.config.ConnectionInfo.COMPort,
BaudRate: uint(sio.deej.config.ConnectionInfo.BaudRate),
DataBits: 8,
StopBits: 1,
MinimumReadSize: uint(minimumReadSize),
}
sio.logger.Debugw("Attempting serial connection",
"comPort", sio.connOptions.PortName,
"baudRate", sio.connOptions.BaudRate,
"minReadSize", minimumReadSize)
var err error
sio.conn, err = serial.Open(sio.connOptions)
if err != nil {
// might need a user notification here, TBD
sio.logger.Warnw("Failed to open serial connection", "error", err)
return fmt.Errorf("open serial connection: %w", err)
}
namedLogger := sio.logger.Named(strings.ToLower(sio.connOptions.PortName))
namedLogger.Infow("Connected", "conn", sio.conn)
sio.connected = true
// read lines or await a stop
go func() {
connReader := bufio.NewReader(sio.conn)
lineChannel := sio.readLine(namedLogger, connReader)
for {
select {
case <-sio.stopChannel:
sio.close(namedLogger)
case line := <-lineChannel:
sio.handleLine(namedLogger, line)
}
}
}()
return nil
}
// Stop signals us to shut down our serial connection, if one is active
func (sio *SerialIO) Stop() {
if sio.connected {
sio.logger.Debug("Shutting down serial connection")
sio.stopChannel <- true
} else {
sio.logger.Debug("Not currently connected, nothing to stop")
}
}
// SubscribeToSliderMoveEvents returns an unbuffered channel that receives
// a sliderMoveEvent struct every time a slider moves
func (sio *SerialIO) SubscribeToSliderMoveEvents() chan SliderMoveEvent {
ch := make(chan SliderMoveEvent)
sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, ch)
return ch
}
func (sio *SerialIO) setupOnConfigReload() {
configReloadedChannel := sio.deej.config.SubscribeToChanges()
const stopDelay = 50 * time.Millisecond
go func() {
for {
select {
case <-configReloadedChannel:
// make any config reload unset our slider number to ensure process volumes are being re-set
// (the next read line will emit SliderMoveEvent instances for all sliders)\
// this needs to happen after a small delay, because the session map will also re-acquire sessions
// whenever the config file is reloaded, and we don't want it to receive these move events while the map
// is still cleared. this is kind of ugly, but shouldn't cause any issues
go func() {
<-time.After(stopDelay)
sio.lastKnownNumSliders = 0
}()
// if connection params have changed, attempt to stop and start the connection
if sio.deej.config.ConnectionInfo.COMPort != sio.connOptions.PortName ||
uint(sio.deej.config.ConnectionInfo.BaudRate) != sio.connOptions.BaudRate {
sio.logger.Info("Detected change in connection parameters, attempting to renew connection")
sio.Stop()
// let the connection close
<-time.After(stopDelay)
if err := sio.Start(); err != nil {
sio.logger.Warnw("Failed to renew connection after parameter change", "error", err)
} else {
sio.logger.Debug("Renewed connection successfully")
}
}
}
}
}()
}
func (sio *SerialIO) close(logger *zap.SugaredLogger) {
if err := sio.conn.Close(); err != nil {
logger.Warnw("Failed to close serial connection", "error", err)
} else {
logger.Debug("Serial connection closed")
}
sio.conn = nil
sio.connected = false
}
func (sio *SerialIO) readLine(logger *zap.SugaredLogger, reader *bufio.Reader) chan string {
ch := make(chan string)
go func() {
for {
line, err := reader.ReadString('\n')
if err != nil {
if sio.deej.Verbose() {
logger.Warnw("Failed to read line from serial", "error", err, "line", line)
}
// just ignore the line, the read loop will stop after this
return
}
if sio.deej.Verbose() {
logger.Debugw("Read new line", "line", line)
}
// deliver the line to the channel
ch <- line
}
}()
return ch
}
func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) {
// this function receives an unsanitized line which is guaranteed to end with LF,
// but most lines will end with CRLF. it may also have garbage instead of
// deej-formatted values, so we must check for that! just ignore bad ones
if !expectedLinePattern.MatchString(line) {
return
}
// trim the suffix
line = strings.TrimSuffix(line, "\r\n")
// split on pipe (|), this gives a slice of numerical strings between "0" and "1023"
splitLine := strings.Split(line, "|")
numSliders := len(splitLine)
// update our slider count, if needed - this will send slider move events for all
if numSliders != sio.lastKnownNumSliders {
logger.Infow("Detected sliders", "amount", numSliders)
sio.lastKnownNumSliders = numSliders
sio.currentSliderPercentValues = make([]float32, numSliders)
// reset everything to be an impossible value to force the slider move event later
for idx := range sio.currentSliderPercentValues {
sio.currentSliderPercentValues[idx] = -1.0
}
}
// for each slider:
moveEvents := []SliderMoveEvent{}
for sliderIdx, stringValue := range splitLine {
// convert string values to integers ("1023" -> 1023)
number, _ := strconv.Atoi(stringValue)
// turns out the first line could come out dirty sometimes (i.e. "4558|925|41|643|220")
// so let's check the first number for correctness just in case
if sliderIdx == 0 && number > 1023 {
sio.logger.Debugw("Got malformed line from serial, ignoring", "line", line)
return
}
// map the value from raw to a "dirty" float between 0 and 1 (e.g. 0.15451...)
dirtyFloat := float32(number) / 1023.0
// normalize it to an actual volume scalar between 0.0 and 1.0 with 2 points of precision
normalizedScalar := util.NormalizeScalar(dirtyFloat)
// if sliders are inverted, take the complement of 1.0
if sio.deej.config.InvertSliders {
normalizedScalar = 1 - normalizedScalar
}
// check if it changes the desired state (could just be a jumpy raw slider value)
if util.SignificantlyDifferent(sio.currentSliderPercentValues[sliderIdx], normalizedScalar, sio.deej.config.NoiseReductionLevel) {
// if it does, update the saved value and create a move event
sio.currentSliderPercentValues[sliderIdx] = normalizedScalar
moveEvents = append(moveEvents, SliderMoveEvent{
SliderID: sliderIdx,
PercentValue: normalizedScalar,
})
if sio.deej.Verbose() {
logger.Debugw("Slider moved", "event", moveEvents[len(moveEvents)-1])
}
}
}
// deliver move events if there are any, towards all potential consumers
if len(moveEvents) > 0 {
for _, consumer := range sio.sliderMoveConsumers {
for _, moveEvent := range moveEvents {
consumer <- moveEvent
}
}
}
}