summaryrefslogtreecommitdiff
path: root/cfa635
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 /cfa635
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.
Diffstat (limited to 'cfa635')
-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
6 files changed, 951 insertions, 0 deletions
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
+}