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