summaryrefslogtreecommitdiff
path: root/cfa635
diff options
context:
space:
mode:
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
+}