lutron-control/transport.go
2026-06-28 08:32:22 -05:00

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})
}