-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgotogen.go
670 lines (589 loc) · 18 KB
/
gotogen.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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
package gotogen
import (
"errors"
"image/color"
"runtime"
"strconv"
"time"
"github.com/ajanata/textbuf"
"github.com/ajanata/gotogen/internal/animation"
"github.com/ajanata/gotogen/internal/animation/face"
"github.com/ajanata/gotogen/internal/animation/peek"
"github.com/ajanata/gotogen/internal/animation/slide"
"github.com/ajanata/gotogen/internal/animation/static"
"github.com/ajanata/gotogen/internal/media"
"github.com/ajanata/gotogen/internal/mirror"
)
const menuTimeout = 10 * time.Second
type SensorStatus uint8
const (
// SensorStatusUnavailable indicates that the sensor is never available (not implemented in hardware).
SensorStatusUnavailable = iota
// SensorStatusAvailable indicates that the returned value(s) is/are accurate.
SensorStatusAvailable
// SensorStatusBusy indicates that the sensor is temporarily unavailable e.g. due to bus contention.
SensorStatusBusy
)
type Gotogen struct {
framerate uint
frameTime time.Duration
blinker Blinker
boopDist uint8
aX, aY, aZ int32 // accelerometer
faceDisplay Display
faceMirror Display
faceState faceState
activeAnim animation.Animation
statusDisplay Display
statusMirror Display
statusFrameSkip uint8
statusDownmixChannel colorChannel
statusDownmixCutoff uint8
statusText *textbuf.Buffer // TODO interface
statusState statusState
statusStateChange time.Time
rootMenu Menu
activeMenu Menuable
statusForceUpdate bool
driver Driver
init bool
start time.Time
tick uint32
lastSec time.Time
lastTicks uint32
lastFPS uint32
// storing this once could be inaccurate on OS-based implementations, but you also don't really care in that case
totalRAM string
}
type Driver interface {
// EarlyInit initializes secondary devices after the primary menu display has been initialized for boot
// messages. Hardware drivers shall configure any buses (SPI, etc.) that are required to communicate with these
// devices at this point, and should only configure the bare minimum to call New.
EarlyInit() (faceDisplay Display, err error)
// LateInit performs any late initialization (e.g. connecting to wifi to set the clock). The failure of anything in
// LateInit should not cause the failure of the entire process. Boot messages may be freely logged.
//
// TODO interface
LateInit(buffer *textbuf.Buffer)
// PressedButton returns the currently-pressed menu button. The implementation is responsible for prioritizing
// multiple buttons being pressed at the same time however it sees fit (or implement some buttons as a chord of
// multiple physical buttons), as well as handling debouncing (if needed) and button repeating. Basically, this
// should only return a value when that value should be acted upon.
//
// This function should expect to be called at the main loop framerate.
PressedButton() MenuButton
// MenuItems is invoked every time the menu is displayed to retrieve the current menu items for the driver.
// The driver may return different menu items depending on current state.
MenuItems() []Item
// BoopDistance is a normalized value for the closeness of a boop. TODO define the normalization
// The second return value indicates the status of the boop sensor: does not exist, valid data, or busy.
BoopDistance() (uint8, SensorStatus)
// Accelerometer is a normalized value for accelerometer values. TODO define the scale of the normalized values
// When not in motion, all values should be approximately zero. TODO actually implement that
// Drivers should provide a calibration option to zero out the sensor.
// The second return value indicates the status of the accelerometer: does not exist, valid data, or busy.
Accelerometer() (x, y, z int32, status SensorStatus)
// Talking indicates if the driver has detected speech and the face should animate talking.
Talking() bool
// StatusLine returns a textual status indicator that the driver may use for whatever it wishes.
//
// For the current hardware implementation of a 128x64 OLED display with the 6x8 font, this cannot be more than 21
// characters. Other hardware implementations may have different limits, but since the hardware implementation is
// what is returning this line, it should know better.
StatusLine() string
}
type Blinker interface {
Low()
High()
}
func New(framerate uint, status Display, blinker Blinker, driver Driver) (*Gotogen, error) {
if framerate == 0 {
return nil, errors.New("must run at least one frame per second")
}
if status == nil {
return nil, errors.New("must provide status display")
}
if driver == nil {
return nil, errors.New("must provide driver")
}
return &Gotogen{
framerate: framerate,
frameTime: time.Second / time.Duration(framerate),
statusDisplay: status,
statusMirror: mirror.New(status),
blinker: blinker,
driver: driver,
start: time.Now(),
}, nil
}
func (g *Gotogen) Init() error {
if g.init {
return errors.New("already initialized")
}
println("starting init")
g.blink()
var err error
// TODO font size configurable
g.statusText, err = textbuf.New(g.statusDisplay, textbuf.FontSize6x8)
if err != nil {
return errors.New("init status: " + err.Error())
}
g.statusText.AutoFlush = true
w, h := g.statusDisplay.Size()
tw, th := g.statusText.Size()
// TODO make this more graceful
if tw < 20 || th < 8 || w < 128 || h < 64 {
return errors.New("unusably small status display")
}
err = g.statusText.SetLineInverse(0, "GOTOGEN BOOTING")
if err != nil {
return errors.New("boot msg: " + err.Error())
}
// we already validated it has at least 4 lines
_ = g.statusText.SetY(1)
// we already know it was possible to print text so don't bother checking every time
_ = g.statusText.Print("Initialize devices")
faceDisplay, err := g.driver.EarlyInit()
if err != nil {
_ = g.statusText.PrintlnInverse(err.Error())
return errors.New("early init: " + err.Error())
}
if faceDisplay == nil {
return errors.New("init did not provide face")
}
g.faceDisplay = faceDisplay
g.faceMirror = mirror.New(faceDisplay)
_ = g.statusText.Println(".")
// now that we have the face panels set up, we can put a loading image on them while LateInit runs
err = g.busy()
if err != nil {
_ = g.statusText.PrintlnInverse("load busy: " + err.Error())
return errors.New("load busy: " + err.Error())
}
_ = g.statusText.Println("CPUs: " + strconv.Itoa(runtime.NumCPU()))
mem := runtime.MemStats{}
runtime.ReadMemStats(&mem)
g.totalRAM = strconv.Itoa(int(mem.HeapSys / 1024))
_ = g.statusText.Println(strconv.Itoa(int(mem.HeapSys/1024)) + "k RAM, " + strconv.Itoa(int(mem.HeapIdle/1024)) + "k free")
g.driver.LateInit(g.statusText)
g.initMainMenu()
_ = g.statusText.Print("Loading face")
f, err = face.New(g)
if err != nil {
_ = g.statusText.PrintlnInverse(": " + err.Error())
return errors.New("load face: " + err.Error())
}
_ = g.statusText.Println(".\nThe time is now")
_ = g.statusText.Println(time.Now().Format(time.Stamp))
_ = g.statusText.Println("Booted in " + time.Now().Sub(g.start).Round(100*time.Millisecond).String())
_ = g.statusText.Println("Gotogen online.")
// TODO load from settings storage; these is also defined in initMainMenu
g.statusDownmixChannel = colorChannelRed
g.statusDownmixCutoff = 0xA0
g.statusFrameSkip = 0
g.statusText.AutoFlush = false
g.statusStateChange = time.Now()
g.blink()
g.init = true
println("init complete in", time.Now().Sub(g.start).Round(100*time.Millisecond).String())
return nil
}
// Run does not return. It attempts to run the main loop at the framerate specified in New.
func (g *Gotogen) Run() {
for range time.Tick(g.frameTime) {
err := g.RunTick()
if err != nil {
g.panic(err)
}
}
}
var s animation.Animation
var f *face.Anim
// RunTick runs a single iteration of the main loop.
func (g *Gotogen) RunTick() error {
if !g.init {
return errors.New("not initialized")
}
g.blinkerOff()
g.tick++
g.statusForceUpdate = false
// busy states clear when we get back to the run loop
if g.faceState == faceStateBusy {
g.faceState = faceStateDefault
f.Activate(g)
g.activeAnim = f
}
if time.Since(g.lastSec) >= time.Second {
g.lastFPS = g.tick - g.lastTicks
g.lastSec = time.Now()
g.lastTicks = g.tick
}
// read sensors
d, st := g.driver.BoopDistance()
if st == SensorStatusAvailable {
g.boopDist = d
}
x, y, z, st := g.driver.Accelerometer()
if st == SensorStatusAvailable {
g.aX, g.aY, g.aZ = x, y, z
}
// TODO better way to framerate limit the status screen
canRedrawStatus := g.statusDisplay.CanUpdateNow()
if g.statusFrameSkip > 0 {
canRedrawStatus = canRedrawStatus && uint8(g.tick)%g.statusFrameSkip == 0
}
// we always need to call this tho since the menu handling code is in here
g.updateStatus(canRedrawStatus)
cont := g.activeAnim.DrawFrame(g, g.tick)
if !cont {
g.faceState = faceStateDefault
g.statusForceUpdate = true
f.Activate(g)
g.activeAnim = f
}
err := g.faceDisplay.Display()
if err != nil {
g.panic(err)
}
if g.statusState != statusStateBlank && canRedrawStatus {
err = g.statusText.Display()
if err != nil {
g.panic(err)
}
}
g.blinkerOn()
return nil
}
func (g *Gotogen) drawIdleStatus() {
mem := runtime.MemStats{}
runtime.ReadMemStats(&mem)
// TODO switch which line this is on every minute or so for burn-in protection
_ = g.statusText.SetLine(0, time.Now().Format("03:04"), " ", strconv.Itoa(int(g.lastFPS)), "Hz ", strconv.Itoa(int(mem.HeapIdle/1024)), "k/", g.totalRAM, "k")
// TODO temp hack
_ = g.statusText.SetLine(1, strconv.Itoa(int(g.boopDist)), " ", strconv.Itoa(int(g.aX)), " ", strconv.Itoa(int(g.aY)), " ", strconv.Itoa(int(g.aZ)))
_ = g.statusText.SetLine(3, g.driver.StatusLine())
// println(time.Now().Format("03:04"), g.lastFPS, "Hz", mem.HeapIdle/1024, "k/", g.totalRAM)
}
func (g *Gotogen) updateStatus(updateIdleStatus bool) {
switch g.statusState {
case statusStateBoot:
if time.Now().After(g.statusStateChange.Add(menuTimeout)) {
g.changeStatusState(statusStateIdle)
break
}
// any button press clears the boot log
if g.driver.PressedButton() != MenuButtonNone {
g.changeStatusState(statusStateIdle)
}
case statusStateIdle:
but := g.driver.PressedButton()
switch but {
case MenuButtonBack:
if g.faceState != faceStateDefault {
g.faceState = faceStateDefault
g.statusForceUpdate = true
f.Activate(g)
g.activeAnim = f
}
case MenuButtonMenu:
g.changeStatusState(statusStateMenu)
default:
if updateIdleStatus {
g.drawIdleStatus()
}
}
case statusStateMenu:
if time.Now().After(g.statusStateChange.Add(menuTimeout)) {
g.changeStatusState(statusStateIdle)
break
}
switch g.driver.PressedButton() {
case MenuButtonBack:
g.statusStateChange = time.Now()
if g.activeMenu.Prev() == nil {
// at top level menu
g.changeStatusState(statusStateIdle)
} else {
m := g.activeMenu
g.activeMenu = g.activeMenu.Prev()
m.SetPrev(nil)
g.activeMenu.Render(g.statusText)
}
case MenuButtonMenu:
g.statusStateChange = time.Now()
switch active := g.activeMenu.(type) {
case *Menu:
// in case a menu is empty for some reason
if len(active.Items) == 0 || int(active.selected) > len(active.Items) {
break
}
switch item := active.Items[active.selected].(type) {
case *Menu:
item.prev, g.activeMenu = g.activeMenu, item
item.Render(g.statusText)
case *ActionItem:
item.Invoke()
case *SettingItem:
item.prev, g.activeMenu = g.activeMenu, item
item.selected = item.Active
_, h := g.statusText.Size()
if item.selected > item.top+uint8(h)-2 {
// TODO avoid empty lines at the bottom?
item.top = item.selected
}
item.Render(g.statusText)
}
case *SettingItem:
active.Active = active.selected
active.Apply(active.selected)
g.activeMenu, active.prev = active.prev, nil
g.activeMenu.Render(g.statusText)
}
case MenuButtonUp:
g.statusStateChange = time.Now()
if g.activeMenu.Selected() > 0 {
g.activeMenu.SetSelected(g.activeMenu.Selected() - 1)
}
if g.activeMenu.Selected() < g.activeMenu.Top() {
g.activeMenu.SetTop(g.activeMenu.Selected())
}
g.activeMenu.Render(g.statusText)
case MenuButtonDown:
g.statusStateChange = time.Now()
g.activeMenu.SetSelected(g.activeMenu.Selected() + 1)
if g.activeMenu.Selected() > g.activeMenu.Len()-1 {
g.activeMenu.SetSelected(g.activeMenu.Len() - 1)
}
_, h := g.statusText.Size()
if g.activeMenu.Selected() > g.activeMenu.Top()+uint8(h)-2 {
g.activeMenu.SetTop(g.activeMenu.Top() + 1)
}
g.activeMenu.Render(g.statusText)
}
case statusStateBlank:
if g.driver.PressedButton() != MenuButtonNone {
g.changeStatusState(statusStateIdle)
}
}
}
func (g *Gotogen) clearStatusScreen() {
// clear text buffer
g.statusText.Clear()
// but make sure we clear the *entire* screen, including pixels outside the coverage of the text buffer
w, h := g.statusDisplay.Size()
for x := int16(0); x < w; x++ {
for y := int16(0); y < h; y++ {
g.statusDisplay.SetPixel(x, y, color.RGBA{})
}
}
_ = g.statusDisplay.Display()
}
func (g *Gotogen) changeStatusState(state statusState) {
println("changing to status state", state.String())
g.activeMenu = nil
g.statusState = state
g.statusStateChange = time.Now()
g.clearStatusScreen()
switch state {
case statusStateIdle:
g.drawIdleStatus()
case statusStateBlank:
// nothing special to do
case statusStateMenu:
// hardware submenu is required to be the first item in the menu
m := g.rootMenu.Items[0].(*Menu)
m.Items = g.driver.MenuItems()
g.activeMenu = &g.rootMenu
g.rootMenu.Render(g.statusText)
}
}
func (g *Gotogen) startAnimation(a animation.Animation) {
g.faceState = faceStateAnimation
a.Activate(g)
g.activeAnim = a
}
// unfortunately you can't recover runtime panics in tinygo, so this is just going to be used for things we detect
// that are fatal
func (g *Gotogen) panic(v any) {
println(v)
for {
println(v)
g.blink()
}
}
func (g *Gotogen) blink() {
g.blinkerOn()
time.Sleep(100 * time.Millisecond)
g.blinkerOff()
time.Sleep(100 * time.Millisecond)
}
func (g *Gotogen) blinkerOn() {
if g.blinker != nil {
g.blinker.High()
}
}
func (g *Gotogen) blinkerOff() {
if g.blinker != nil {
g.blinker.Low()
}
}
func (g *Gotogen) newAnimation(file string, f func(string) (animation.Animation, error)) {
a, err := f(file)
if err != nil {
g.panic(err)
}
g.startAnimation(a)
// TODO exit the menu?
}
func (g *Gotogen) initMainMenu() {
imgs, err := media.Enumerate(media.TypeFull)
if err != nil {
g.panic("enumerating images for animations: " + err.Error())
}
var anims []Item
for _, i := range imgs {
f := i
anims = append(anims, &Menu{
Name: i,
Items: []Item{
&ActionItem{
Name: "Static",
Invoke: func() { g.newAnimation(f, static.New) },
},
&ActionItem{
Name: "Slide",
Invoke: func() { g.newAnimation(f, slide.New) },
},
&ActionItem{
Name: "Peek",
Invoke: func() { g.newAnimation(f, peek.New) },
},
},
})
}
g.rootMenu = Menu{
Name: "GOTOGEN MENU",
Items: []Item{
// This must be first, and will be filled in by the driver's menu items every time the menu is displayed.
&Menu{
Name: "Hardware Settings",
},
&Menu{
Name: "Full-screen anims.",
Items: anims,
},
&Menu{
Name: "Internal screen",
Items: []Item{
&ActionItem{
Name: "Blank screen",
Invoke: func() { g.changeStatusState(statusStateBlank) },
},
&SettingItem{
Name: "Frame skip",
Options: []string{"0", "1", "2", "4", "8", "16"},
Active: 0, // TODO load from setting storage
Apply: g.setStatusFrameSkip,
},
&SettingItem{
Name: "Face dupl. color",
Options: []string{"full", "red", "green", "blue"},
Active: 1,
Apply: g.setStatusDuplicateColor,
},
&SettingItem{
Name: "Face dupl. cutoff",
Options: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "D", "E", "F"},
Active: 9,
Apply: g.setStatusDuplicateCutoff,
},
},
},
},
}
}
func (g *Gotogen) setStatusDuplicateCutoff(selected uint8) {
g.statusDownmixCutoff = (selected + 1) << 4
}
func (g *Gotogen) setStatusDuplicateColor(selected uint8) {
g.statusDownmixChannel = colorChannel(selected)
}
func (g *Gotogen) setStatusFrameSkip(selected uint8) {
if selected == 0 {
g.statusFrameSkip = 0
} else {
g.statusFrameSkip = 1 << (selected - 1)
}
}
func (g *Gotogen) Busy(f func(buffer *textbuf.Buffer)) {
g.statusText.AutoFlush = true
g.statusText.Clear()
err := g.busy()
if err != nil {
print("unable to load busy", err)
_ = g.statusText.PrintlnInverse("loading busy: " + err.Error())
}
f(g.statusText)
s := time.Now()
for time.Now().Before(s.Add(5 * time.Second)) {
if g.driver.PressedButton() != MenuButtonNone {
break
}
}
g.statusText.AutoFlush = false
g.changeStatusState(statusStateIdle)
}
func (g *Gotogen) busy() error {
g.faceState = faceStateBusy
busy, err := static.New("wait")
if err != nil {
return errors.New("load busy: " + err.Error())
}
busy.Activate(g.faceMirror)
_ = g.faceDisplay.Display()
g.activeAnim = busy
return nil
}
func (g *Gotogen) Size() (x, y int16) {
return g.faceMirror.Size()
}
func (g *Gotogen) Display() error {
// nothing to do here, refreshing the real displays is managed elsewhere
return nil
}
func (g *Gotogen) SetPixel(x, y int16, c color.RGBA) {
g.faceMirror.SetPixel(x, y, c)
if g.statusForceUpdate || (g.statusState == statusStateIdle && (g.statusFrameSkip == 0 || uint8(g.tick)%g.statusFrameSkip == 0 && g.statusDisplay.CanUpdateNow())) {
switch g.statusDownmixChannel {
case colorChannelRed:
if c.R < g.statusDownmixCutoff {
c.R = 0
} else {
c.R = 0xFF
}
c.G = 0
c.B = 0
case colorChannelGreen:
if c.G < g.statusDownmixCutoff {
c.G = 0
} else {
c.G = 0xFF
}
c.R = 0
c.B = 0
case colorChannelBlue:
if c.B < g.statusDownmixCutoff {
c.B = 0
} else {
c.B = 0xFF
}
c.R = 0
c.G = 0
}
// TODO remove hardcoded offset
g.statusMirror.SetPixel(x, y+32, c)
}
}
func (g *Gotogen) Talking() bool {
return g.driver.Talking()
}