summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore15
-rw-r--r--audiotrond.go270
-rw-r--r--cfa635/cfa635.go226
-rw-r--r--cfa635/charset.go397
-rw-r--r--cfa635/crc.go39
-rw-r--r--cfa635/decode.go128
-rw-r--r--cfa635/report.go109
-rw-r--r--cfa635/state.go52
-rw-r--r--clockDisplay.go308
-rw-r--r--go.mod26
-rw-r--r--go.sum10
-rw-r--r--mpdDisplay.go249
-rw-r--r--view.go47
13 files changed, 1876 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6c6511b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# Copyright 2022 Benjamin Barenblat
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+audiotrond
diff --git a/audiotrond.go b/audiotrond.go
new file mode 100644
index 0000000..f59a4db
--- /dev/null
+++ b/audiotrond.go
@@ -0,0 +1,270 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "time"
+
+ "benjamin.barenblat.name/audiotrond/cfa635"
+ "github.com/fhs/gompd/v2/mpd"
+ "github.com/tarm/serial"
+)
+
+func putWrapped(lcd *cfa635.Module, col, row int, data []byte) error {
+ for row < 4 && len(data) > 0 {
+ if err := lcd.Put(col, row, data); err != nil {
+ return err
+ }
+ if len(data) <= 20-col {
+ data = nil
+ } else {
+ data = data[20-col:]
+ }
+ col = 0
+ row++
+ }
+ return nil
+}
+
+func update[T comparable](dst *T, src T, mtime *time.Time, now time.Time) {
+ if *dst == src {
+ return
+ }
+ *dst = src
+ *mtime = now
+}
+
+type playbackState byte
+
+const (
+ _ playbackState = 1 + iota
+ stopped
+ playing
+ paused
+)
+
+type foreground byte
+
+const (
+ _ foreground = iota
+ mpdForeground
+ clockForeground
+)
+
+type model struct {
+ State playbackState
+ LastStateChange time.Time
+
+ Duration time.Duration
+ Elapsed time.Duration
+
+ Track string
+ Artist string
+ Album string
+ LastTrackInfoUpdate time.Time
+
+ Foreground foreground
+}
+
+func connectToCFA635() *cfa635.Module {
+ s, err := serial.OpenPort(&serial.Config{Name: "/dev/lcd", Baud: 115200})
+ if err != nil {
+ panic(err)
+ }
+ m := cfa635.Connect(s)
+
+ go func() {
+ for r := m.ReadReport(); r != nil; r = m.ReadReport() {
+ log.Println("report:", r)
+ }
+ }()
+
+ return m
+}
+
+func reportPanicOrClear(lcd *cfa635.Module) {
+ if v := recover(); v != nil {
+ putWrapped(lcd, 0, 0, encode(fmt.Sprint("panic: ", v)))
+ panic(v)
+ }
+ lcd.Clear()
+}
+
+func poll(mpd *mpd.Client, now time.Time, model *model) error {
+ cmds := mpd.BeginCommandList()
+ statusP := cmds.Status()
+ currentP := cmds.CurrentSong()
+ if err := cmds.End(); err != nil {
+ return err
+ }
+
+ status, err := statusP.Value()
+ if err != nil {
+ return err
+ }
+
+ switch status["state"] {
+ case "stop":
+ update(&model.State, stopped, &model.LastStateChange, now)
+ case "play":
+ update(&model.State, playing, &model.LastStateChange, now)
+ case "pause":
+ update(&model.State, paused, &model.LastStateChange, now)
+ }
+
+ if status["duration"] == "" {
+ model.Duration = 0
+ model.Elapsed = 0
+ } else {
+ if model.Duration, err = time.ParseDuration(status["duration"] + "s"); err != nil {
+ panic(err)
+ }
+ if model.Elapsed, err = time.ParseDuration(status["elapsed"] + "s"); err != nil {
+ panic(err)
+ }
+ }
+
+ current, err := currentP.Value()
+ if err != nil {
+ return err
+ }
+
+ update(&model.Track, current["Title"], &model.LastTrackInfoUpdate, now)
+ update(&model.Artist, current["Artist"], &model.LastTrackInfoUpdate, now)
+ update(&model.Album, current["Album"], &model.LastTrackInfoUpdate, now)
+ return nil
+}
+
+func ellipsize(src []byte, ellipsis byte, dst []byte) {
+ if len(src) <= len(dst) {
+ copy(dst, src)
+ } else {
+ dots := len(dst) - 1
+ copy(dst, src[:dots])
+ dst[dots] = ellipsis
+ }
+}
+
+func fmtTime(t, max time.Duration) string {
+ f := func(neg string, h, m, s int) string {
+ _ = h
+ return fmt.Sprintf("%s%d:%02d", neg, m, s)
+ }
+ if max/time.Hour > 0 {
+ f = func(neg string, h, m, s int) string {
+ return fmt.Sprintf("%s%d:%02d:%02d", neg, h, m, s)
+ }
+ } else if int(max/time.Minute)%60 >= 10 {
+ f = func(neg string, h, m, s int) string {
+ _ = h
+ return fmt.Sprintf("%s%02d:%02d", neg, m, s)
+ }
+ }
+
+ var neg string
+ if t < 0 {
+ t *= -1
+ neg = "-"
+ }
+ return f(neg, int(t/time.Hour), int(t/time.Minute)%60, int(t/time.Second)%60)
+}
+
+func main() {
+ lcd := connectToCFA635()
+ defer reportPanicOrClear(lcd)
+ defer lcd.SetBacklight(0, 0)
+ if err := lcd.SetLED(0, false, 0); err != nil {
+ panic(err)
+ }
+ if err := lcd.SetLED(0, true, 0); err != nil {
+ panic(err)
+ }
+
+ if err := lcd.Clear(); err != nil {
+ panic(err)
+ }
+
+ mpd, err := mpd.Dial("unix", "/run/mpd/socket")
+ if err != nil {
+ panic(err)
+ }
+
+ var model model
+
+ view1 := new(view)
+ view1.LCD = cfa635.ClearedLCDState()
+ view1.Mtime = time.Now()
+
+ // Create an idle timer and put it in a drained state so the event loop
+ // can set it.
+ idle := time.NewTimer(0)
+ <-idle.C
+
+ sigterm := make(chan os.Signal, 1)
+ signal.Notify(sigterm, os.Interrupt)
+
+EventLoop:
+ for {
+ now := time.Now()
+
+ if err := poll(mpd, now, &model); err != nil {
+ panic(err)
+ }
+
+ var foreground2 foreground
+ if model.State == playing || now.Sub(model.LastStateChange).Seconds() < 17 {
+ foreground2 = mpdForeground
+ } else {
+ foreground2 = clockForeground
+ }
+
+ if foreground2 != model.Foreground {
+ switch foreground2 {
+ case mpdForeground:
+ if err := initializeMPDDisplay(lcd); err != nil {
+ panic(err)
+ }
+ case clockForeground:
+ if err := initializeClockDisplay(lcd); err != nil {
+ panic(err)
+ }
+ }
+ }
+ model.Foreground = foreground2
+
+ var view2 *view
+ switch model.Foreground {
+ case mpdForeground:
+ view2 = mpdView(&model, now, view1)
+ case clockForeground:
+ view2 = clockView(now)
+ }
+ if err := updateView(lcd, view1, view2); err != nil {
+ panic(err)
+ }
+ view1 = view2
+
+ idle.Reset(10 * time.Millisecond)
+ select {
+ case <-sigterm:
+ break EventLoop
+ case <-idle.C:
+ }
+ }
+}
diff --git a/cfa635/cfa635.go b/cfa635/cfa635.go
new file mode 100644
index 0000000..296ab21
--- /dev/null
+++ b/cfa635/cfa635.go
@@ -0,0 +1,226 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+// Package cfa635 interfaces with Crystalfontz CFA635-xxx-KU LCD modules.
+//
+// The Connect function adopts an existing serial port:
+//
+// s, err := serial.OpenPort(&serial.Config{Name: "/dev/ttyUSB0", Baud: 115200})
+// if err != nil {
+// panic(err)
+// }
+// m := cfa635.Connect(s)
+// defer m.Close()
+//
+// Having connected, you can then issue commands to the module:
+//
+// msg, _, err := transform.String(cfa635.NewEncoder(), "Hello, world!")
+// if err != nil {
+// panic(err)
+// }
+// if err := m.Put(0, 0, []byte(msg)); err != nil {
+// panic(err)
+// }
+//
+// You can also use the State type and Update function to send the optimal
+// sequence of commands to the module to transform its state:
+//
+// if err := m.Reset(); err != nil {
+// panic(err)
+// }
+// s := new(cfa635.State)
+// t := new(cfa635.State)
+// copy(t.LCD[0][0:19], msg)
+// if err := cfa635.Update(m, s, t); err != nil {
+// panic(err)
+// }
+// s = t
+package cfa635
+
+import (
+ "bufio"
+ "errors"
+ "io"
+ "reflect"
+ "sync"
+ "time"
+)
+
+var (
+ ErrPayloadTooLarge = errors.New("payload too large")
+ ErrCGRAM = errors.New("CGRAM index out of range")
+ ErrSprite = errors.New("invalid sprite")
+ ErrPosition = errors.New("position out of range")
+ ErrBacklight = errors.New("backlight brightness out of range")
+ ErrLEDIndex = errors.New("LED index out of range")
+ ErrLEDDuty = errors.New("LED duty cycle out of range")
+
+ ErrTimeout = errors.New("timed out")
+
+ ErrFailed = errors.New("command failed")
+)
+
+// Module is a handle to a CFA635 module.
+type Module struct {
+ w io.WriteCloser
+ reports chan any // Messages initiated by the CFA635
+ responses chan []byte // Responses to messages initiated by the host
+
+ // Ensures that only one request is in flight to the CFA635 at once
+ mu sync.Mutex
+}
+
+// Connect constructs a Module from a serial connection to a CFA635.
+func Connect(cfa635 io.ReadWriteCloser) *Module {
+ m := Module{w: cfa635, reports: make(chan any), responses: make(chan []byte)}
+
+ bytes := make(chan byte)
+ go buffer(bufio.NewReader(cfa635), bytes)
+ packets := make(chan []byte)
+ go decode(bytes, packets)
+ go route(packets, m.reports, m.responses)
+
+ return &m
+}
+
+// Close releases the CFA635 and closes the underlying connection.
+func (m *Module) Close() { m.w.Close() }
+
+// ReadReport blocks until the CFA635 sends a report to the host and then
+// returns it. The returned report will be a *KeyActivity, *FanSpeed, or
+// *Temperature.
+func (m *Module) ReadReport() any { return <-m.reports }
+
+// RawCommand sends an arbitrary command (request and payload) to the CFA635,
+// returning the response (and payload) or error.
+func (m *Module) RawCommand(req byte, reqP []byte) (resp byte, respP []byte, err error) {
+ p := []byte{req, byte(len(reqP))}
+ p = append(p, reqP...)
+ p = pushCRC(p)
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, err := m.w.Write(p); err != nil {
+ return 0, nil, err
+ }
+
+ var q []byte
+ select {
+ case q = <-m.responses:
+ break
+ case <-time.After(timeout):
+ return 0, nil, ErrTimeout
+ }
+
+ if len(q) < 2 || len(q) != 2+int(q[1]) {
+ return 0, nil, ErrFailed
+ }
+
+ return q[0], q[2:], nil
+}
+
+func (m *Module) simple(req byte, reqP []byte, wantC byte, wantP []byte) error {
+ gotC, gotP, err := m.RawCommand(req, reqP)
+ if err != nil {
+ return err
+ }
+
+ // DeepEqual treats nil and empty slices as non-equal, so check for
+ // emptiness explicitly.
+ if gotC != wantC || (len(wantP) != 0 || len(gotP) != 0) && !reflect.DeepEqual(gotP, wantP) {
+ return ErrFailed
+ }
+ return nil
+}
+
+// Ping pings the CFA635 with a payload of up to 16 bytes.
+func (m *Module) Ping(payload []byte) error {
+ if len(payload) > 16 {
+ return ErrPayloadTooLarge
+ }
+ return m.simple(0x00, payload, 0x40, payload)
+}
+
+// Clear clears the CFA635 LCD. After Clear returns successfully, all LCD cells
+// hold 0x20 (space).
+func (m *Module) Clear() error { return m.simple(0x06, nil, 0x46, nil) }
+
+// SetCharacter sets a sprite (six columns by eight rows) in character generator
+// RAM. The index of the sprite must be between 0 and 7, inclusive.
+//
+// The sprite itself is specified as an 8-element byte array, each byte
+// representing a row. In each byte, the upper two bits must be 0; the lower six
+// determine the six pixels in the row, with 1 bits corresponding to active
+// pixels and 0 bits corresponding to inactive ones.
+func (m *Module) SetCharacter(i int, data *[8]byte) error {
+ if i < 0 || i > 7 {
+ return ErrCGRAM
+ }
+ for _, b := range data {
+ if b&0b11_000000 != 0 {
+ return ErrSprite
+ }
+ }
+
+ payload := []byte{byte(i)}
+ payload = append(payload, data[:]...)
+ return m.simple(0x09, payload, 0x49, nil)
+}
+
+// SetBacklight controls the LEDs backing the LCD and keypad. Each LED value can
+// range from 0 to 100, inclusive, with 0 turning off the light and 100 turning
+// it on to its maximum brightness.
+func (m *Module) SetBacklight(lcd, keypad int) error {
+ if lcd < 0 || lcd > 100 || keypad < 0 || keypad > 100 {
+ return ErrBacklight
+ }
+
+ return m.simple(0x0e, []byte{byte(lcd), byte(keypad)}, 0x4e, nil)
+}
+
+// Put writes data to the LCD at a row and column. No wrapping occurs; if the
+// data are too large, they are truncated. Data are interpreted in the CFA635
+// character set; see NewEncoder.
+func (m *Module) Put(col, row int, data []byte) error {
+ if col < 0 || col >= 20 || row < 0 || row >= 4 {
+ return ErrPosition
+ }
+ width := 20 - col
+ if len(data) > width {
+ data = data[:width]
+ }
+
+ payload := []byte{byte(col), byte(row)}
+ payload = append(payload, data...)
+ return m.simple(0x1f, payload, 0x5f, nil)
+}
+
+// SetLED controls the four red/green LEDs to the left of the LCD. The LEDs are
+// numbered 0 through 3 from top to bottom; for each, the red and green
+// components can be set separately to a value from 0 (off) to 100 (full duty
+// cycle).
+func (m *Module) SetLED(led int, green bool, duty int) error {
+ if led < 0 || led > 3 {
+ return ErrLEDIndex
+ }
+ if duty < 0 || duty > 100 {
+ return ErrLEDDuty
+ }
+
+ i := byte(11 - 2*led)
+ if !green {
+ i++
+ }
+ return m.simple(0x22, []byte{i, byte(duty)}, 0x62, nil)
+}
diff --git a/cfa635/charset.go b/cfa635/charset.go
new file mode 100644
index 0000000..5521b3b
--- /dev/null
+++ b/cfa635/charset.go
@@ -0,0 +1,397 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package cfa635
+
+import (
+ "unicode"
+ "unicode/utf8"
+
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+)
+
+// NewEncoder returns a Transformer that converts UTF-8 to the CFA635 display
+// character set. The Transformer uses ¿ as the replacement character.
+//
+// The returned Transformer is lossy, converting various Unicode code points to
+// the same byte. For example, U+DF LATIN SMALL LETTER SHARP S (ß) and U+03B2
+// GREEK SMALL LETTER BETA (β) are both converted to 0xbe.
+//
+// The returned Transformer will never map anything to bytes in the range 0x00,
+// …, 0x0f.
+func NewEncoder() transform.Transformer {
+ identityMapped := unicode.RangeTable{
+ R16: []unicode.Range16{
+ {0x20, 0x23, 1},
+ {0x25, 0x3f, 1},
+ {0x41, 0x5a, 1},
+ {0x61, 0x7a, 1}},
+ R32: nil,
+ LatinOffset: 83,
+ }
+ return runes.If(runes.In(&identityMapped), nil, encode{})
+}
+
+type encode struct{}
+
+func (_ encode) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
+ for nDst < len(dst) && nSrc < len(src) {
+ r, rLen := utf8.DecodeRune(src[nSrc:])
+ if r == utf8.RuneError {
+ err = transform.ErrShortSrc
+ break
+ }
+ dst[nDst] = encode1(r)
+ nDst++
+ nSrc += rLen
+ }
+ return
+}
+
+func (_ encode) Reset() {}
+
+func encode1(c rune) byte {
+ switch c {
+ case '⏵', '▶', '▸', '►', '⯈':
+ return 0x10
+ case '⏴', '◀', '⯇':
+ return 0x11
+ case '⏫':
+ return 0x12
+ case '⏬':
+ return 0x13
+ case '«', '≪', '《':
+ return 0x14
+ case '»', '≫', '》':
+ return 0x15
+ case '↖', '⬉', '⭦':
+ return 0x16
+ case '↗', '⬈', '⭧':
+ return 0x17
+ case '↙', '⬋', '⭩':
+ return 0x18
+ case '↘', '⬊', '⭨':
+ return 0x19
+ case '⏶', '▲', '▴':
+ return 0x1a
+ case '⏷', '▼', '▾':
+ return 0x1b
+ case '↲', '↵', '⏎', '⮐':
+ return 0x1c
+ case '^', '˄', 'ˆ', '⌃':
+ return 0x1d
+ case 'ᵛ':
+ return 0x1e
+ case 0xa0, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x202f, 0x2060, 0x3000:
+ return 0x20
+ case 0x01c3:
+ return 0x21
+ case 'ʺ', '˝', '״', '″', '〃':
+ return 0x22
+ case '℔', '⌗', '♯', '⧣':
+ return 0x23
+ case '¤':
+ return 0x24
+ case '٪', '⁒':
+ return 0x25
+ case 'ʹ', 'ʼ', 'ˈ', '׳', '‘', '’', '′', 'ꞌ':
+ return 0x27
+ case '٭', '∗', '⚹':
+ return 0x2a
+ case '˖':
+ return 0x2b
+ case '‚':
+ return 0x2c
+ case '˗', '‐', '‑', '‒', '–', '−', '𐆑':
+ return 0x2d
+ case '․':
+ return 0x2e
+ case '⁄', '∕', '⟋':
+ return 0x2f
+ case '։', '׃', '፡', '∶', '꞉':
+ return 0x3a
+ case ';':
+ return 0x3b
+ case '˂', '‹', '〈', '⟨', '〈':
+ return 0x3c
+ case '᐀', '⹀', '゠', '꞊', '𐆐', '🟰':
+ return 0x3d
+ case '˃', '›', '〉', '⟩', '〉':
+ return 0x3e
+ case '¡':
+ return 0x40
+ case 'Ä':
+ return 0x5b
+ case 'Ö':
+ return 0x5c
+ case 'Ñ':
+ return 0x5d
+ case 'Ü':
+ return 0x5e
+ case '§':
+ return 0x5d
+ case '¿':
+ return 0x60
+ case 'ä':
+ return 0x7b
+ case 'ö':
+ return 0x7c
+ case 'ñ':
+ return 0x7d
+ case 'ü':
+ return 0x7e
+ case 'à':
+ return 0x7f
+ case '°', '˚', 'ᴼ', 'ᵒ', '⁰':
+ return 0x80
+ case '¹':
+ return 0x81
+ case '²':
+ return 0x82
+ case '³':
+ return 0x83
+ case '⁴':
+ return 0x84
+ case '⁵':
+ return 0x85
+ case '⁶':
+ return 0x86
+ case '⁷':
+ return 0x87
+ case '⁸':
+ return 0x88
+ case '⁹':
+ return 0x89
+ case '½':
+ return 0x8a
+ case '¼':
+ return 0x8b
+ case '±':
+ return 0x8c
+ case '≥':
+ return 0x8d
+ case '≤':
+ return 0x8e
+ case 'µ', 'μ':
+ return 0x8f
+ case '♪', '𝅘𝅥𝅮':
+ return 0x90
+ case '♬':
+ return 0x91
+ case '🔔', '🕭':
+ return 0x92
+ case '♥', '❤', '💙', '💚', '💛', '💜', '🖤', '🤎', '🧡':
+ return 0x93
+ case '◆', '♦':
+ return 0x94
+ case '𐎂':
+ return 0x95
+ case '「':
+ return 0x96
+ case '」':
+ return 0x97
+ case '“', '❝':
+ return 0x98
+ case '”', '❞':
+ return 0x99
+ case 'ɑ', 'α':
+ return 0x9c
+ case 'ɛ', 'ε':
+ return 0x9d
+ case 'δ':
+ return 0x9e
+ case '∞':
+ return 0x9f
+ case '@':
+ return 0xa0
+ case '£':
+ return 0xa1
+ case '$':
+ return 0xa2
+ case '¥':
+ return 0xa3
+ case 'è':
+ return 0xa4
+ case 'é':
+ return 0xa5
+ case 'ù':
+ return 0xa6
+ case 'ì':
+ return 0xa7
+ case 'ò':
+ return 0xa8
+ case 'Ç':
+ return 0xa9
+ case 'ᵖ':
+ return 0xaa
+ case 'Ø':
+ return 0xab
+ case 'ø':
+ return 0xac
+ case 'ʳ':
+ return 0xad
+ case 'Å', 'Å':
+ return 0xae
+ case 'å':
+ return 0xaf
+ case 'Δ', '∆', '⌂':
+ return 0xb0
+ case '¢', 'ȼ', '₵':
+ return 0xb1
+ case 'Φ':
+ return 0xb2
+ case 'τ':
+ return 0xb3
+ case 'λ':
+ return 0xb4
+ case 'Ω', 'Ω':
+ return 0xb5
+ case 'π':
+ return 0xb6
+ case 'Ψ':
+ return 0xb7
+ case 'Ʃ', 'Σ', '∑':
+ return 0xb8
+ case 'Θ', 'ϴ', 'θ':
+ return 0xb9
+ case 'Ξ':
+ return 0xba
+ case '⏺', '⚫', '⬤', '🔴':
+ return 0xbb
+ case 'Æ':
+ return 0xbc
+ case 'æ', 'ӕ':
+ return 0xbd
+ case 'ß', 'β':
+ return 0xbe
+ case 'É':
+ return 0xbf
+ case 'Γ':
+ return 0xc0
+ case 'Λ':
+ return 0xc1
+ case 'Π', '∏':
+ return 0xc2
+ case 'Υ', 'ϓ':
+ return 0xc3
+ case '_', 'ˍ':
+ return 0xc4
+ case 'È':
+ return 0xc5
+ case 'Ê':
+ return 0xc6
+ case 'ê':
+ return 0xc7
+ case 'ç':
+ return 0xc8
+ case 'ğ', 'ǧ':
+ return 0xc9
+ case 'Ş':
+ return 0xca
+ case 'ş', 'ș':
+ return 0xcb
+ case 'İ':
+ return 0xcc
+ case 'ı':
+ return 0xcd
+ case '~', '˜', '⁓', '∼', '〜', '~':
+ return 0xce
+ case '◇', '◊', '♢':
+ return 0xcf
+ case 'ƒ':
+ return 0xd5
+ case 0x2588:
+ return 0xd6
+ case 0x2589, 0x258a:
+ return 0xd7
+ case 0x258b, 0x258c:
+ return 0xd8
+ case 0x258d:
+ return 0xd9
+ case 0x258e, 0x258f:
+ return 0xda
+ case '₧':
+ return 0xdb
+ case '◦':
+ return 0xdc
+ case '•', '⋅':
+ return 0xdd
+ case '↑', '⬆', '⭡':
+ return 0xde
+ case '→', '⮕', '⭢':
+ return 0xdf
+ case '↓', '⬇', '⭣':
+ return 0xe0
+ case '←', '⬅', '⭠':
+ return 0xe1
+ case 'Á':
+ return 0xe2
+ case 'Í':
+ return 0xe3
+ case 'Ó':
+ return 0xe4
+ case 'Ú':
+ return 0xe5
+ case 'Ý':
+ return 0xe6
+ case 'á':
+ return 0xe7
+ case 'í':
+ return 0xe8
+ case 'ó':
+ return 0xe9
+ case 'ú':
+ return 0xea
+ case 'ý':
+ return 0xeb
+ case 'Ô':
+ return 0xec
+ case 'ô':
+ return 0xed
+ case 'Č':
+ return 0xf0
+ case 'Ě':
+ return 0xf1
+ case 'Ř':
+ return 0xf2
+ case 'Š':
+ return 0xf3
+ case 'Ž':
+ return 0xf4
+ case 'č':
+ return 0xf5
+ case 'ě':
+ return 0xf6
+ case 'ř':
+ return 0xf7
+ case 'š':
+ return 0xf8
+ case 'ž':
+ return 0xf9
+ case '[':
+ return 0xfa
+ case '\\':
+ return 0xfb
+ case ']':
+ return 0xfc
+ case '{':
+ return 0xfd
+ case '|':
+ return 0xfe
+ case '}':
+ return 0xff
+ }
+ return 0x60 // ¿
+}
diff --git a/cfa635/crc.go b/cfa635/crc.go
new file mode 100644
index 0000000..33a0757
--- /dev/null
+++ b/cfa635/crc.go
@@ -0,0 +1,39 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package cfa635
+
+import (
+ "encoding/binary"
+
+ "github.com/sigurn/crc16"
+)
+
+var crcTable = crc16.MakeTable(crc16.CRC16_X_25)
+
+func pushCRC(b []byte) []byte {
+ end := len(b)
+ b = append(b, 0, 0)
+ binary.LittleEndian.PutUint16(b[end:len(b)], crc16.Checksum(b[:end], crcTable))
+ return b
+}
+
+func popCRC(b []byte) ([]byte, bool) {
+ if len(b) < 2 {
+ return b, false
+ }
+
+ end := len(b) - 2
+ return b[:end], crc16.Checksum(b[:end], crcTable) == binary.LittleEndian.Uint16(b[end:len(b)])
+}
diff --git a/cfa635/decode.go b/cfa635/decode.go
new file mode 100644
index 0000000..dc77e03
--- /dev/null
+++ b/cfa635/decode.go
@@ -0,0 +1,128 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package cfa635
+
+import (
+ "io"
+ "log"
+ "time"
+)
+
+const (
+ maxPacketBytes = 26
+
+ msgPacketFailed = "failed to read packet from CFA635:"
+ msgTimedOut = "timed out"
+)
+
+var (
+ timeout = 250 * time.Millisecond // maximum response latency
+)
+
+// buffer copies bytes from an io.Reader into a channel, logging any errors as
+// it goes. It closes the channel when no more bytes are left or when it
+// receives on the done channel.
+func buffer(r io.ByteReader, w chan<- byte) {
+ defer close(w)
+ for {
+ b, err := r.ReadByte()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ log.Print("failed to read byte from CFA635: ", err)
+ continue
+ }
+ w <- b
+ }
+}
+
+// decode reassembles bytes into packets, logging any errors as it goes.
+func decode(bytes <-chan byte, packets chan<- []byte) {
+ defer close(packets)
+Outer:
+ for {
+ p := make([]byte, 0, maxPacketBytes)
+
+ // Read packet type.
+ typ, ok := <-bytes
+ if !ok {
+ break
+ }
+ p = append(p, typ)
+
+ // The rest of the packet should come in fairly quickly.
+ timedout := time.After(timeout)
+
+ // Read packet length.
+ var length byte
+ select {
+ case <-timedout:
+ log.Print(msgPacketFailed, ' ', msgTimedOut)
+ continue
+
+ case length, ok = <-bytes:
+ if !ok {
+ break Outer
+ }
+ if length > 22 {
+ log.Print(msgPacketFailed, " got too-long data_length ", length)
+ continue
+ }
+ p = append(p, length)
+ }
+
+ // Read the data and CRC.
+ for i := 0; i < int(length)+2; i++ {
+ select {
+ case <-timedout:
+ log.Print(msgPacketFailed, ' ', msgTimedOut)
+ continue
+ case b, ok := <-bytes:
+ if !ok {
+ break Outer
+ }
+ p = append(p, b)
+ }
+ }
+
+ if p, ok = popCRC(p); !ok {
+ log.Printf("%s CRC failure\n", msgPacketFailed)
+ continue
+ }
+
+ // Save the packet.
+ packets <- p
+ }
+}
+
+// route splits a channel of packets into channels of reports and responses.
+func route(packets <-chan []byte, reports chan<- any, responses chan<- []byte) {
+ defer close(reports)
+ defer close(responses)
+ for p := range packets {
+ switch p[0] & 0b1100_0000 >> 6 {
+ case 0b10:
+ r, err := decodeReport(p)
+ if err != nil {
+ log.Print(err.Error())
+ continue
+ }
+ reports <- r
+ default:
+ responses <- p
+ }
+ }
+}
diff --git a/cfa635/report.go b/cfa635/report.go
new file mode 100644
index 0000000..84541bc
--- /dev/null
+++ b/cfa635/report.go
@@ -0,0 +1,109 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package cfa635
+
+import (
+ "encoding/binary"
+ "errors"
+)
+
+var (
+ errUnknownReportType = errors.New("failed to read report: unknown type")
+ errUnknownKey = errors.New("failed to read key activity report: unknown key")
+ errFBSCAB = errors.New("failed to read fan speed report: FBSCAB module disconnected")
+ errDOW = errors.New("failed to read temperature report: DOW sensor error")
+)
+
+// KeyActivity is a report that a key has been pressed or released.
+type KeyActivity struct {
+ K Key
+ Pressed bool
+}
+
+// Key represents a button on the CFA635.
+type Key int
+
+const (
+ _ Key = iota
+ UpButton
+ DownButton
+ LeftButton
+ RightButton
+ EnterButton
+ ExitButton
+)
+
+// FanSpeed is a report on the speed of a system fan.
+type FanSpeed struct {
+ N int
+ TachCycles int
+ TimerTicks int
+}
+
+// Temperature is a report on the system temperature.
+type Temperature struct {
+ N int // Sensor number.
+ Celsius float64
+}
+
+// decodeReport converts a packet into either a *KeyActivity, a *FanSpeed, or a
+// *Temperature, depending on the packet's type tag.
+func decodeReport(p []byte) (any, error) {
+ switch p[0] {
+ case 0x80:
+ return decodeKeyActivity(p[2])
+ case 0x81:
+ return decodeFanSpeed(p[2:6])
+ case 0x82:
+ return decodeTemperature(p[2:6])
+ default:
+ return nil, errUnknownReportType
+ }
+}
+
+// decodeKeyActivity converts the data byte from a key activity report to a
+// KeyActivity.
+func decodeKeyActivity(b byte) (*KeyActivity, error) {
+ if b == 0 || b > 12 {
+ return nil, errUnknownKey
+ }
+
+ var a KeyActivity
+ if b < 7 {
+ a.Pressed = true
+ } else {
+ b -= 6
+ }
+ a.K = Key(b)
+ return &a, nil
+}
+
+// decodeFanSpeed converts the data part of a fan speed report packet to a
+// FanSpeed.
+func decodeFanSpeed(b []byte) (*FanSpeed, error) {
+ if b[1] == 0 && b[2] == 0 && b[3] == 0 {
+ return nil, errFBSCAB
+ }
+ return &FanSpeed{int(b[0]), int(b[1]), int(binary.BigEndian.Uint16(b[2:4]))}, nil
+}
+
+// decodeTemperature converts the data part of a temperature report packet to a
+// Temperature.
+func decodeTemperature(b []byte) (*Temperature, error) {
+ if b[3] == 0 {
+ return nil, errDOW
+ }
+ return &Temperature{int(b[0]), float64(binary.BigEndian.Uint16(b[1:3])) / 16}, nil
+}
diff --git a/cfa635/state.go b/cfa635/state.go
new file mode 100644
index 0000000..fc324d5
--- /dev/null
+++ b/cfa635/state.go
@@ -0,0 +1,52 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package cfa635
+
+// State represents the state of the CFA635 LCD.
+type LCDState [4][20]byte
+
+func ClearedLCDState() *LCDState {
+ var r LCDState
+ for y := range r {
+ for x := range r[y] {
+ r[y][x] = 0x20
+ }
+ }
+ return &r
+}
+
+func Update(m *Module, old, new *LCDState) error {
+ if *old == *new {
+ return nil
+ }
+
+ for y := range old {
+ var first, last int
+
+ for ; first < len(old[y]) && new[y][first] == old[y][first]; first++ {
+ }
+ if first == len(old[y]) {
+ continue
+ }
+
+ for last = len(old[y]) - 1; last > first && new[y][last] == old[y][last]; last-- {
+ }
+ if err := m.Put(first, y, new[y][first:last+1]); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/clockDisplay.go b/clockDisplay.go
new file mode 100644
index 0000000..cc20de3
--- /dev/null
+++ b/clockDisplay.go
@@ -0,0 +1,308 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package main
+
+import (
+ "fmt"
+ "time"
+
+ "benjamin.barenblat.name/audiotrond/cfa635"
+)
+
+const (
+ lowerHalfSprite = iota
+ upperHalfSprite
+ lowerHalfEdgeSprite
+ upperHalfEdgeSprite
+ fullBlockSprite
+ fullBlockEdgeSprite
+ lowerRightSprite
+ rightLowerEdgeSprite
+)
+
+func initializeClockDisplay(lcd *cfa635.Module) error {
+ // Load sprites.
+
+ if err := lcd.SetCharacter(lowerHalfSprite, &[...]byte{
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(upperHalfSprite, &[...]byte{
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(lowerHalfEdgeSprite, &[...]byte{
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(upperHalfEdgeSprite, &[...]byte{
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(fullBlockSprite, &[...]byte{
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(fullBlockEdgeSprite, &[...]byte{
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(lowerRightSprite, &[...]byte{
+ 0b00_000001,
+ 0b00_000011,
+ 0b00_000111,
+ 0b00_001111,
+ 0b00_011111,
+ 0b00_111111,
+ 0b00_111111,
+ 0b00_111111,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(rightLowerEdgeSprite, &[...]byte{
+ 0b00_000001,
+ 0b00_000011,
+ 0b00_000111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ 0b00_001111,
+ }); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func blitClockDigit(n int, lcd *cfa635.LCDState, x int) {
+ switch n {
+ case 0:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = fullBlockEdgeSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 1:
+ lcd[0][x+2] = lowerRightSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 2:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = lowerHalfEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = fullBlockEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = upperHalfSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 3:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = lowerHalfEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = upperHalfEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 4:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = upperHalfEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 5:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = lowerHalfSprite
+ lcd[2][x] = upperHalfEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 6:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = lowerHalfSprite
+ lcd[2][x] = fullBlockEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 7:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 8:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = fullBlockEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x] = fullBlockEdgeSprite
+ lcd[3][x+1] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ case 9:
+ lcd[0][x] = rightLowerEdgeSprite
+ lcd[0][x+1] = fullBlockSprite
+ lcd[0][x+2] = fullBlockSprite
+ lcd[1][x] = fullBlockEdgeSprite
+ lcd[1][x+1] = lowerHalfSprite
+ lcd[1][x+2] = fullBlockSprite
+ lcd[2][x] = upperHalfEdgeSprite
+ lcd[2][x+1] = upperHalfSprite
+ lcd[2][x+2] = fullBlockSprite
+ lcd[3][x+2] = fullBlockSprite
+
+ default:
+ panic(fmt.Sprint("unknown digit ", n))
+ }
+}
+
+func clockView(now time.Time) *view {
+ var new view
+ new.LCD = cfa635.ClearedLCDState()
+
+ now = now.Local()
+ h := now.Hour() % 12
+ if h == 0 {
+ h = 12
+ }
+ if h >= 10 {
+ blitClockDigit(1, new.LCD, -2)
+ h -= 10
+ }
+ blitClockDigit(h, new.LCD, 1)
+
+ new.LCD[1][4] = 0xbb
+ new.LCD[2][4] = 0xbb
+
+ m := now.Minute()
+ blitClockDigit(m/10, new.LCD, 5)
+ blitClockDigit(m%10, new.LCD, 8)
+
+ new.LCD[1][11] = 0xbb
+ new.LCD[2][11] = 0xbb
+
+ s := now.Second()
+ blitClockDigit(s/10, new.LCD, 12)
+ blitClockDigit(s%10, new.LCD, 15)
+
+ if now.Hour() < 12 {
+ new.LCD[2][19] = 'a'
+ } else {
+ new.LCD[2][19] = 'p'
+ }
+ new.LCD[3][19] = 'm'
+
+ return &new
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..82a9645
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,26 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+module benjamin.barenblat.name/audiotrond
+
+go 1.18
+
+require (
+ github.com/fhs/gompd/v2 v2.2.0
+ github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3
+ github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
+ golang.org/x/text v0.3.7
+)
+
+require golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d22b81d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/fhs/gompd/v2 v2.2.0 h1:zdSYAAOzQ5cCCgYa5CoXkL0Vr0Cqb/b5JmTobirLc90=
+github.com/fhs/gompd/v2 v2.2.0/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4=
+github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 h1:aQKxg3+2p+IFXXg97McgDGT5zcMrQoi0EICZs8Pgchs=
+github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
+github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
+github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
diff --git a/mpdDisplay.go b/mpdDisplay.go
new file mode 100644
index 0000000..93fb552
--- /dev/null
+++ b/mpdDisplay.go
@@ -0,0 +1,249 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package main
+
+import (
+ "math"
+ "time"
+
+ "golang.org/x/text/transform"
+
+ "benjamin.barenblat.name/audiotrond/cfa635"
+)
+
+var (
+ encoder = cfa635.NewEncoder()
+)
+
+func encode(s string) []byte {
+ r, _, err := transform.String(encoder, s)
+ if err != nil {
+ panic(err)
+ }
+ return []byte(r)
+}
+
+func setPlaybackIcon(model *model, lcdState *cfa635.LCDState) {
+ switch model.State {
+ case stopped:
+ lcdState[0][19] = 0xd0
+ case playing:
+ lcdState[0][19] = 0x10
+ case paused:
+ lcdState[0][19] = pauseIconSprite
+ }
+}
+
+func rotate(s []byte, width int, start, now time.Time) []byte {
+ const delay int = 10
+ const rotationRate float64 = 1 / float64(333*time.Millisecond)
+
+ if len(s) <= width {
+ return s
+ }
+
+ ticks := int(float64(now.Sub(start).Nanoseconds()) * rotationRate)
+
+ if len(s) < 2*width {
+ // Just scroll back and forth.
+ period := len(s) - width + delay
+ ticks %= 2 * period
+
+ i := 0
+ if ticks <= delay {
+ } else if ticks <= period {
+ i = ticks - delay
+ } else if ticks <= period+delay {
+ i = len(s) - width
+ } else {
+ i = 2*period - ticks
+ }
+ return s[i : i+width]
+ }
+
+ // Actually rotate.
+ for i := 0; i < 7; i++ {
+ s = append(s, byte(' '))
+ }
+
+ period := len(s) + delay
+ ticks %= period
+
+ i := 0
+ if ticks > delay {
+ i = ticks - delay
+ }
+ return append(s[i:], s[:i]...)[:width]
+}
+
+func setTrackInfo(model *model, now time.Time, lcdState *cfa635.LCDState) {
+ // Cut off the track one character short so we don't overwrite the
+ // playback icon.
+ copy(lcdState[0][:], rotate(encode(model.Track), 19, model.LastTrackInfoUpdate, now))
+ copy(lcdState[1][:], rotate(encode(model.Artist), 20, model.LastTrackInfoUpdate, now))
+ copy(lcdState[2][:], rotate(encode(model.Album), 20, model.LastTrackInfoUpdate, now))
+}
+
+func setTimeElapsed(model *model, lcdState *cfa635.LCDState) int {
+ elapsed := fmtTime(model.Elapsed, model.Duration)
+ copy(lcdState[3][:], elapsed)
+ return len(elapsed)
+}
+
+func setTimeRemaining(model *model, lcdState *cfa635.LCDState) int {
+ remaining := fmtTime(model.Elapsed-model.Duration, model.Duration)
+ copy(lcdState[3][20-len(remaining):], remaining)
+ return len(remaining)
+}
+
+func setProgressBar(model *model, barStart, barEnd int, lcdState *cfa635.LCDState) {
+ fraction := float64(model.Elapsed) / float64(model.Duration)
+
+ // Convert the fraction played to the number of columns that should be
+ // colored in the bar. Each cell has 6 columns; leave one extra column
+ // free at the left of the bar so it doesn't crash into the time
+ // elapsed.
+ c := int(math.Round(fraction * (6*float64(barEnd-barStart) - 1)))
+ if c == 0 {
+ return
+ }
+
+ // There are precomposed block glyphs of widths 1-5 that leave a blank
+ // column at the beginning. Use one of those in the first cell.
+ w := c
+ if w > 5 {
+ w = 5
+ }
+ lcdState[3][barStart] = byte(0xdb - w)
+ c -= w
+ if c == 0 {
+ return
+ }
+
+ // Fill in the full cells in the bar.
+ x := barStart + 1
+ for ; c >= 6; x, c = x+1, c-6 {
+ lcdState[3][x] = progressBarSpriteFull
+ }
+ if c == 0 {
+ return
+ }
+
+ // Fill in the last, partial bar.
+ lcdState[3][x] = byte(progressBarSprite1 - 1 + c)
+}
+
+const (
+ ellipsisSprite = iota
+ pauseIconSprite
+ progressBarSprite1
+ progressBarSprite2
+ progressBarSprite3
+ progressBarSprite4
+ progressBarSprite5
+ progressBarSpriteFull
+)
+
+func initializeMPDDisplay(lcd *cfa635.Module) error {
+ // Load sprites.
+
+ if err := lcd.SetCharacter(ellipsisSprite, &[...]byte{
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_000000,
+ 0b00_010101,
+ 0b00_000000,
+ }); err != nil {
+ return err
+ }
+
+ if err := lcd.SetCharacter(pauseIconSprite, &[...]byte{
+ 0b00_000000,
+ 0b00_011011,
+ 0b00_011011,
+ 0b00_011011,
+ 0b00_011011,
+ 0b00_011011,
+ 0b00_000000,
+ 0b00_000000,
+ }); err != nil {
+ return err
+ }
+
+ for w := 1; w <= 6; w++ {
+ r := byte((1<<w - 1) << (6 - w))
+ if err := lcd.SetCharacter(progressBarSprite1-1+w, &[...]byte{
+ r,
+ r,
+ r,
+ r,
+ r,
+ r,
+ r,
+ 0b00_000000,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func brightnessStep(pm float64, then, now time.Time, old float64) float64 {
+ const brightnessRampRate float64 = 20.0 / float64(500*time.Millisecond)
+
+ dt := float64(now.Sub(then).Nanoseconds())
+ return old + pm*dt*brightnessRampRate
+}
+
+func setBrightness(model *model, now time.Time, old *view) float64 {
+ var z time.Time
+ if old.Mtime == z {
+ return old.DisplayBrightness
+ }
+
+ if model.State == playing || now.Sub(model.LastStateChange).Seconds() < 15 {
+ if old.DisplayBrightness >= 20 {
+ return 20
+ }
+ return brightnessStep(+1, old.Mtime, now, old.DisplayBrightness)
+ } else {
+ if old.DisplayBrightness == 0 {
+ return 0
+ }
+ return math.Max(0, brightnessStep(-1, old.Mtime, now, old.DisplayBrightness))
+ }
+}
+
+func mpdView(model *model, now time.Time, old *view) *view {
+ var new view
+ new.LCD = cfa635.ClearedLCDState()
+ setPlaybackIcon(model, new.LCD)
+ setTrackInfo(model, now, new.LCD)
+ if model.Duration > 0 {
+ barStart := setTimeElapsed(model, new.LCD)
+ barEnd := 20 - setTimeRemaining(model, new.LCD)
+ setProgressBar(model, barStart, barEnd, new.LCD)
+ }
+
+ new.DisplayBrightness = setBrightness(model, now, old)
+
+ new.Mtime = now
+
+ return &new
+}
diff --git a/view.go b/view.go
new file mode 100644
index 0000000..3282f0a
--- /dev/null
+++ b/view.go
@@ -0,0 +1,47 @@
+// Copyright 2022 Benjamin Barenblat
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+package main
+
+import "time"
+
+import (
+ "math"
+
+ "benjamin.barenblat.name/audiotrond/cfa635"
+)
+
+type view struct {
+ LCD *cfa635.LCDState
+ DisplayBrightness float64
+ Mtime time.Time
+}
+
+func updateView(lcd *cfa635.Module, old, new *view) error {
+ if err := cfa635.Update(lcd, old.LCD, new.LCD); err != nil {
+ return err
+ }
+
+ if new.DisplayBrightness != old.DisplayBrightness {
+ bright := new.DisplayBrightness
+ if bright < 0 {
+ bright = 0
+ }
+ if err := lcd.SetBacklight(int(math.Round(new.DisplayBrightness)), 0); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}