Last active
August 29, 2015 14:17
-
-
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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