diff options
-rw-r--r-- | .gitignore | 15 | ||||
-rw-r--r-- | audiotrond.go | 270 | ||||
-rw-r--r-- | cfa635/cfa635.go | 226 | ||||
-rw-r--r-- | cfa635/charset.go | 397 | ||||
-rw-r--r-- | cfa635/crc.go | 39 | ||||
-rw-r--r-- | cfa635/decode.go | 128 | ||||
-rw-r--r-- | cfa635/report.go | 109 | ||||
-rw-r--r-- | cfa635/state.go | 52 | ||||
-rw-r--r-- | clockDisplay.go | 308 | ||||
-rw-r--r-- | go.mod | 26 | ||||
-rw-r--r-- | go.sum | 10 | ||||
-rw-r--r-- | mpdDisplay.go | 249 | ||||
-rw-r--r-- | view.go | 47 |
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 +} @@ -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 @@ -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 +} @@ -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 +} |