Skip to content

Instantly share code, notes, and snippets.

@fritschy
Last active August 29, 2015 14:17
Show Gist options
  • Save fritschy/93c6a2eac854544bc59e to your computer and use it in GitHub Desktop.
Save fritschy/93c6a2eac854544bc59e to your computer and use it in GitHub Desktop.
Talking to a Denon AVR via telnet and with readline support (Tested on an AVR X-4000)
// This was hacked up by Marcus Fritzsch, @znephf
//
// Please make sure this does what you think it does, as I am not
// responsible for any damage it might cause.
//
// TODO:
// - Profiles: check what is played and fix settings accordingly
// - Setting Profiles from file
// - Browser interface through HTTP
package main
import (
"bytes"
"flag"
"fmt"
"github.com/bobappleyard/readline"
"io"
"net"
"os"
"strings"
"time"
)
type waitReader interface {
io.Reader
Wait()
}
type rlReader struct {
io.Reader
}
func (self *rlReader) Wait() {
time.Sleep(200 * time.Millisecond)
}
type netReader struct {
net.Conn
}
func (self *netReader) Wait() {
}
func reader(r waitReader, out chan []byte, quit chan int) {
var n int
var err error
var buf []byte
for {
if buf == nil {
buf = make([]byte, 1024)
}
n, err = r.Read(buf)
if err != nil {
quit <- 1
return
}
if n == 0 {
continue
}
out <- buf[:n]
r.Wait()
buf = nil
}
}
const (
PrefixCharsCount = 26 + 1 + 1 + 1 + 10 // A-Z, space, colon, question, 0-9
NumberIndexBase = 26
SpaceIndex = 36
QuestionIndex = 37
ColonIndex = 38
)
// do not store actual chars
type radixNode struct {
end bool /// a string finishes here, too
next [PrefixCharsCount]*radixNode
}
const index2char_map = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ?:"
func char2index(r byte) int {
switch {
case r == '?':
return QuestionIndex
case r == ':':
return ColonIndex
case r == ' ':
return SpaceIndex
case r >= 'A' && r <= 'Z':
return int(r - 'A')
case r >= '0' && r <= '9':
return NumberIndexBase + int(r-'0')
default:
return -1
}
}
func index2char(i int) byte {
switch {
case i < len(index2char_map):
return byte(index2char_map[i])
default:
return byte(0xa)
}
}
func (self *radixNode) insert(str string) {
if len(str) == 0 {
self.end = true
return
}
r := byte(str[0])
idx := char2index(r)
if idx == -1 {
fmt.Errorf("Cannot insert rune '%v' into radixNode\n", r)
return
}
if self.next[idx] == nil {
self.next[idx] = &radixNode{}
}
self.next[idx].insert(str[1:])
}
// char 0x0 denotes root-node
func (self *radixNode) getwords(char byte, cur string, ret *[]string) {
if char != 0 {
cur = fmt.Sprintf("%s%c", cur, char)
}
if self.end {
*ret = append(*ret, cur)
}
for i, n := range self.next {
if n != nil {
n.getwords(index2char(i), cur, ret)
}
}
}
// Char 0x0 denotes root-node
func (self *radixNode) query(char byte, str string, cur string, ret *[]string) {
if len(str) == 0 {
// empty query, return all next words...
self.getwords(char, cur, ret)
return
}
r := byte(str[0])
idx := char2index(r)
if idx == -1 { // could not map character to index
fmt.Errorf("Cannot map character '%v' to index\n", r)
return
}
if char != 0 {
cur = fmt.Sprintf("%s%c", cur, char)
}
if self.end {
*ret = append(*ret, cur)
}
if self.next[idx] != nil {
self.next[idx].query(index2char(idx), str[1:], cur, ret)
}
}
type command [2]string
type commandCategory struct {
prefix string
name string
suffixes []command
}
type denonCommands struct {
commands []commandCategory
tree *radixNode
count uint
}
func make_commands(args ...string) []command {
c := make([]command, 0, len(args)/2)
for i := 0; i < len(args); i += 2 {
c = append(c, command{args[i], args[i+1]})
}
return c
}
// Oh this sucks so hard, I don't even...
func init_denon_commands() *denonCommands {
q_on_off := make_commands("?", "", "ON", "", "OFF", "")
denon_commands := &denonCommands{
[]commandCategory{
commandCategory{"PW", "Main Power", make_commands("?", "", "ON", "", "STANDBY", "")},
commandCategory{"ZM", "Main Zone", append(q_on_off, make_commands("FAVORITE1", "", "FAVORITE2", "", "FAVORITE3", "", "FAVORITE4", "")...)},
// commandCategory{"Z2", "Zone 2", q_on_off},
// commandCategory{"Z3", "Zone 3", q_on_off},
commandCategory{"MV", "Master Volume", make_commands("?", "", "UP", "", "DOWN", "")},
commandCategory{"MU", "Mute", q_on_off},
commandCategory{"SI", "Select Input", make_commands("?", "", "BD", "", "DVD", "", "TV", "", "MPLAY", "", "NET", "", "GAME", "")},
commandCategory{"SV", "Select Video", make_commands("?", "", "BD", "", "DVD", "", "TV", "", "MPLAY", "")}, // not more?!
commandCategory{"MS", "Mode Select", make_commands("?", "", "STEREO", "", "MOVIE", "", "MUSIC", "", "DIRECT", "", "PURE DIRECT", "")},
commandCategory{"PSMULTEQ:", "MultEQ Settings", make_commands(" ?", "", "AUDYSSEY", "", "FLAT", "", "MANUAL", "", "OFF", "")},
commandCategory{"PSDYNEQ ", "Dynamic EQ Settings", q_on_off},
commandCategory{"PSREFLEV ", "DynEQ Reference Level", make_commands("?", "", "0", "", "5", "", "10", "", "15", "")},
commandCategory{"PSDYNVOL ", "Dynamic Volume Settings", make_commands("?", "", "LIT", "", "MED", "", "HEV", "", "OFF", "")},
commandCategory{"PSLFC ", "Low Frequency Containment", q_on_off},
commandCategory{"PSCNTAMT ", "LFC Amount", make_commands("UP", "", "DOWN", "", "?", "")},
commandCategory{"NS", "Player Control", make_commands("E", "Report Display Text (UTF-8)", "A", "Report Display Text (ASCII)", "RND", "Toggle Random", "RPT", "Toggle Repeat")},
commandCategory{"MN", "Menu Control", make_commands("MEN?", "", "CUP", "up", "CDN", "down", "CRT", "right", "CLT", "left", "ENT", "enter", "OPT", "option", "INF", "info", "RTN", "return")},
},
nil,
0,
}
denon_commands.tree = &radixNode{}
for _, cc := range denon_commands.commands {
for _, c := range cc.suffixes {
denon_commands.tree.insert(strings.Join([]string{cc.prefix, c[0]}, ""))
denon_commands.count++
}
}
return denon_commands
}
func make_denon_completer(commands *denonCommands) func(query, ctx string) []string {
words := make([]string, 0, commands.count)
return func(query, ctx string) []string {
words = words[0:0] // clear
commands.tree.query(0, strings.ToUpper(query), "", &words)
return words
}
}
func show_commands(commands *denonCommands) {
fmt.Println("I know the following commands:\n")
for _, cc := range commands.commands {
fmt.Printf("%-20s%s\n", cc.prefix, cc.name)
for _, c := range cc.suffixes {
w := strings.Join([]string{cc.prefix, c[0]}, "")
fmt.Printf(" %-16s", w)
if len(c[1]) != 0 {
fmt.Print(" ", c[1])
} else if c[0][len(c[0])-1] == '?' {
fmt.Print(" Query status")
}
fmt.Println("")
}
fmt.Println("")
}
fmt.Println("")
}
func handle_nse_event(ev []byte) []byte {
// This message is made up of 101 bytes:
//
// NSE[0-8]TEXT<CR>
//
// Where the first byte of TEXT is a special bitfield for NSE[1-6] and
// should be handled as such. (it is a normal char for NSE0, NSE7 and NSE8)
//
// The bits have the following meaning (Cursor Position):
// 0: Is Playable Music?
// 1: Is Directory?
// 3: Is Selected by Cursor?
// 6: Is (Has?) a Picture?
// All non-specified bits should be ignored, that is: 2,4,5 and 7
//
// In general TEXT is 96 bytes long, all after and including a NUL byte
// should be ignored.
// The event is terminated with a <CR> (0x0d) byte.
//
// Additionally, NSE0 seems to be a general satus or title.
if len(ev) > 4 && ev[0] == 'N' && ev[1] == 'S' && (ev[2] == 'E' || ev[2] == 'A') {
n := int(ev[3]) - int('0')
var flags uint8
if n >= 1 && n <= 6 {
flags = uint8(ev[4])
// copy(ev[4:], ev[5:])
copy(ev[1:], ev[:4])
ev = ev[1:]
}
// disregard everything after the first NUL byte
nulidx := bytes.IndexByte(ev, 0) // be safe...?
if nulidx != -1 {
ev = ev[:bytes.IndexByte(ev, 0)]
}
if flags&0x2b != 0 { // 0b101011
// I feel I should be doing this a little more high-level...
ev = append(ev, byte(' '))
ev = append(ev, byte('['))
if flags&(1<<0) != 0 {
ev = append(ev, byte('F'))
} else if flags&(1<<1) != 0 {
ev = append(ev, byte('D'))
}
if flags&(1<<6) != 0 {
ev = append(ev, byte('P'))
}
if flags&(1<<3) != 0 { // want cursor last
ev = append(ev, byte('C'))
}
ev = append(ev, byte(']'))
}
}
return ev
}
type pipe_step func(input, output chan []byte)
func event_assembler(raw_in, event_out chan []byte) {
scratch := make([]byte, 0)
sep := []byte{0xd}
for {
input := <-raw_in
scratch = append(scratch, input...)
for {
ev_rest := bytes.SplitN(scratch, sep, 2)
if len(ev_rest) != 2 {
break
}
event_out <- handle_nse_event(ev_rest[0])
if len(ev_rest) == 2 {
scratch = ev_rest[1]
} else {
// no remaining bytes, clear scratch
scratch = scratch[0:0]
}
}
}
}
func producer(f func(out chan []byte)) chan []byte {
out := make(chan []byte)
go f(out)
return out
}
// create a pipe-segment using func f
func pipe(f pipe_step, in chan []byte) chan []byte {
out := make(chan []byte)
go f(in, out)
return out
}
func run() {
var conn string
if strings.Contains(*avr_host, ":") {
conn = *avr_host
} else {
conn = fmt.Sprintf("%s:23", *avr_host)
}
c, e := net.Dial("tcp", conn)
if e != nil {
fmt.Printf("%v\n", e)
return
}
commands := init_denon_commands()
show_commands(commands)
// except/quit signal channel for producers
quit := make(chan int)
command_in := producer(func(out chan []byte) {
// start readline reader
readline.SetWordBreaks("")
readline.Prompt = "Denon> "
readline.Continue = readline.Prompt
readline.Completer = make_denon_completer(commands)
reader(&rlReader{readline.NewReader()}, out, quit)
})
// setup event pipe
event_in := pipe(event_assembler,
producer(func(out chan []byte) {
reader(&netReader{c}, out, quit)
}))
// refresh can be a time.After timeout channel, but most of the time
// it is not, to not poll all the time
no_refresh := make(<-chan time.Time)
refresh := no_refresh
for {
select {
case ev := <-event_in:
if refresh == no_refresh { // print line to skip prompt
fmt.Println("")
}
os.Stdout.Write(append(ev, '\n'))
refresh = time.After(200 * time.Millisecond)
case cmd := <-command_in:
c.Write(append(cmd, '\r'))
readline.AddHistory(string(cmd))
refresh = time.After(200 * time.Millisecond)
case _ = <-refresh:
readline.RefreshLine()
refresh = no_refresh
case _ = <-quit:
if refresh == no_refresh { // print line to skipt prompt
fmt.Println("")
}
return
}
}
}
var avr_host = flag.String("host", "avr", "AVR host[:port] to talk to (default: avr[:23])")
func main() {
flag.Parse()
defer readline.Cleanup()
run()
fmt.Print("\tkthxbai.\n")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment