summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Benjamin Barenblat <bbarenblat@gmail.com>2022-09-08 14:40:10 -0400
committerGravatar Benjamin Barenblat <bbarenblat@gmail.com>2022-09-08 14:40:10 -0400
commit64f73905537e4b1d083afe4c446279a78f9808c0 (patch)
treef7f22289cb0613dc7a29ac5ee5e87ca5ef61ec8c
parentd48c52c1298777cba68b6e78dc5cd83dda4f12de (diff)
Basic, read-only audiotrondHEADmain
Audiotrond exists and properly displays music being played through MPD. The display includes title, artist, album, and progress bar, and text scrolls if too long to fit on the screen. Backlight support exists; the display backlight fades smoothly on and off when the music starts and stops. After being stopped for a little while, audiotrond switches to a clock. Currently, audiotrond lacks user interaction support, so it keeps the keypad backlight off.
-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
+}