214 lines
6 KiB
Go
214 lines
6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
// transport is a bidirectional byte stream to a Lutron interface. Both the
|
|
// serial and telnet implementations satisfy it.
|
|
type transport io.ReadWriteCloser
|
|
|
|
// openTransport dials a device using its configured transport.
|
|
func openTransport(cfg *DeviceConfig) (transport, error) {
|
|
switch cfg.Transport {
|
|
case "serial":
|
|
return openSerial(cfg.Serial)
|
|
case "telnet":
|
|
return openTelnet(cfg.Telnet)
|
|
default:
|
|
return nil, fmt.Errorf("unknown transport %q", cfg.Transport)
|
|
}
|
|
}
|
|
|
|
// openSerial opens the serial port at the configured baud rate.
|
|
func openSerial(cfg SerialConfig) (transport, error) {
|
|
port, err := serial.Open(cfg.Device, &serial.Mode{BaudRate: cfg.Baud})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return port, nil
|
|
}
|
|
|
|
// telnetLoginTimeout bounds the login handshake so a wrong host can't wedge the
|
|
// connect path indefinitely.
|
|
const telnetLoginTimeout = 10 * time.Second
|
|
|
|
// openTelnet dials the device over TCP and performs the Lutron integration
|
|
// login handshake, returning a stream with telnet IAC negotiation filtered out.
|
|
func openTelnet(cfg TelnetConfig) (transport, error) {
|
|
addr := cfg.Address
|
|
if _, _, err := net.SplitHostPort(addr); err != nil {
|
|
// No port given; default to the telnet port.
|
|
addr = net.JoinHostPort(addr, "23")
|
|
}
|
|
conn, err := net.DialTimeout("tcp", addr, telnetLoginTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Keep the TCP connection alive. A QSE-CI-NWK-E telnet link can sit idle for
|
|
// long stretches (we only write on changes), and a router/NAT may drop an
|
|
// idle connection without keepalives (per Lutron App Note 048618).
|
|
if tcp, ok := conn.(*net.TCPConn); ok {
|
|
tcp.SetKeepAlive(true)
|
|
tcp.SetKeepAlivePeriod(30 * time.Second)
|
|
}
|
|
tc := &telnetConn{Conn: conn}
|
|
|
|
// Perform the login handshake. The QSE-CI-NWK-E prompts "login:" and, on some
|
|
// units, "password:"; we answer each as it appears.
|
|
if err := tc.login(cfg.Username, cfg.Password); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
return tc, nil
|
|
}
|
|
|
|
// telnetConn wraps a net.Conn and strips telnet IAC command sequences from the
|
|
// inbound stream, auto-refusing any option negotiation so the server proceeds.
|
|
type telnetConn struct {
|
|
net.Conn
|
|
pending []byte // Bytes held back mid-IAC-sequence across Read calls.
|
|
}
|
|
|
|
// Telnet command bytes (RFC 854) used by the IAC filter.
|
|
const (
|
|
iac = 255
|
|
iacD = 254 // DONT
|
|
iacC = 253 // DO
|
|
iacW = 252 // WONT
|
|
iacL = 251 // WILL
|
|
iacS = 250 // SB (subnegotiation begin)
|
|
iacE = 240 // SE (subnegotiation end)
|
|
)
|
|
|
|
// login reads prompts and sends the credentials as they are requested. It
|
|
// returns once credentials are handled and the integration prompt is seen.
|
|
func (t *telnetConn) login(username, password string) error {
|
|
t.SetReadDeadline(time.Now().Add(telnetLoginTimeout))
|
|
defer t.SetReadDeadline(time.Time{})
|
|
|
|
var acc []byte
|
|
buf := make([]byte, 256)
|
|
sentUser, sentPass := false, false
|
|
for {
|
|
n, err := t.Read(buf)
|
|
if n > 0 {
|
|
acc = append(acc, buf[:n]...)
|
|
lower := strings.ToLower(string(acc))
|
|
if !sentUser && username != "" && strings.Contains(lower, "login:") {
|
|
if _, err := t.Write([]byte(username + "\r\n")); err != nil {
|
|
return err
|
|
}
|
|
sentUser = true
|
|
acc = acc[:0]
|
|
continue
|
|
}
|
|
// The QSE-CI-NWK-E prompts "passphrase:" when a login passphrase is set;
|
|
// older/other units use "password:". Answer either.
|
|
if !sentPass && password != "" && (strings.Contains(lower, "passphrase:") || strings.Contains(lower, "password:")) {
|
|
if _, err := t.Write([]byte(password + "\r\n")); err != nil {
|
|
return err
|
|
}
|
|
sentPass = true
|
|
acc = acc[:0]
|
|
continue
|
|
}
|
|
// Once credentials are handled and the unit echoes a prompt, the link
|
|
// is ready for integration commands.
|
|
if strings.Contains(lower, "qse>") || strings.Contains(lower, "gnet>") || strings.Contains(lower, "qnet>") {
|
|
return nil
|
|
}
|
|
if (sentUser || username == "") && (sentPass || password == "") && len(acc) > 0 {
|
|
// No recognizable prompt, but credentials are handled; assume ready.
|
|
return nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
if username == "" && password == "" {
|
|
// Some units open straight into the command stream with no prompt.
|
|
return nil
|
|
}
|
|
return fmt.Errorf("telnet login: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read returns inbound data with telnet IAC sequences removed.
|
|
func (t *telnetConn) Read(p []byte) (int, error) {
|
|
n, err := t.Conn.Read(p)
|
|
if n == 0 {
|
|
return 0, err
|
|
}
|
|
data := append(t.pending, p[:n]...)
|
|
t.pending = nil
|
|
clean, leftover := t.filterIAC(data)
|
|
t.pending = leftover
|
|
copy(p, clean)
|
|
return len(clean), err
|
|
}
|
|
|
|
// filterIAC removes IAC command sequences from data, replying to DO/WILL with
|
|
// WONT/DONT. Bytes belonging to an incomplete trailing sequence are returned as
|
|
// leftover to be prepended to the next read.
|
|
func (t *telnetConn) filterIAC(data []byte) (clean, leftover []byte) {
|
|
out := make([]byte, 0, len(data))
|
|
for i := 0; i < len(data); {
|
|
b := data[i]
|
|
if b != iac {
|
|
out = append(out, b)
|
|
i++
|
|
continue
|
|
}
|
|
// Need at least the command byte to proceed.
|
|
if i+1 >= len(data) {
|
|
return out, data[i:]
|
|
}
|
|
cmd := data[i+1]
|
|
switch cmd {
|
|
case iac:
|
|
// Escaped 0xFF is a literal data byte.
|
|
out = append(out, iac)
|
|
i += 2
|
|
case iacC, iacL, iacD, iacW:
|
|
// Option negotiation: the option byte is required too.
|
|
if i+2 >= len(data) {
|
|
return out, data[i:]
|
|
}
|
|
t.refuse(cmd, data[i+2])
|
|
i += 3
|
|
case iacS:
|
|
// Subnegotiation: skip until IAC SE.
|
|
end := bytes.Index(data[i:], []byte{iac, iacE})
|
|
if end < 0 {
|
|
return out, data[i:]
|
|
}
|
|
i += end + 2
|
|
default:
|
|
// Two-byte command without an option.
|
|
i += 2
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// refuse answers an option request by declining it, keeping the server from
|
|
// waiting on a negotiation we don't implement.
|
|
func (t *telnetConn) refuse(cmd, option byte) {
|
|
var reply byte
|
|
switch cmd {
|
|
case iacC: // Server asks us to DO -> we WONT.
|
|
reply = iacW
|
|
case iacL: // Server WILL -> we DONT.
|
|
reply = iacD
|
|
default:
|
|
return
|
|
}
|
|
t.Conn.Write([]byte{iac, reply, option})
|
|
}
|