package main import ( "bytes" "context" "fmt" "math" "strconv" "strings" "sync" "time" log "github.com/sirupsen/logrus" ) // Lutron integration protocol constants (not user-configurable). const ( qseTerminator = "\r\n" qseActionZoneLevel = 14 // Action number for a zone's light level (component 1-24). qseBtnAction = 3 // "Press" action carried by the enable/disable signals. qseActionRelease = 4 // "Release" action for phantom buttons (shades react on release). qseSceneController = 141 // Component number of the GRAFIK Eye QS scene controller. qseActionScene = 7 // Action number for the current scene (set/get). qseMaxScene = 16 // Highest scene number the GRAFIK Eye QS supports. // Zone movement actions (component 1-24). Raise/lower start motion and run // until a stop; the panel reports the new level via zone monitoring. qseActionStartRaising = 18 qseActionStartLowering = 19 qseActionStopMoving = 20 // Scene controller (141) actions (QS Standalone). qseActionZoneLock = 15 // Zone lock: 0 off, 1 on. qseActionSceneLock = 16 // Scene lock: 0 off, 1 on. qseActionSequence = 17 // Sequence: 0 off, 1 scenes 1-4, 2 scenes 5-16. // qseSceneOffButton is the Scene Off phantom-button component. qseSceneOffButton = 83 // zoneStopGrace is how long zone writes stay suppressed after a raise/lower // stop so the panel's final level report can update the target before the // writer resumes, preventing it from yanking the zone back to its pre-move // level. zoneStopGrace = 2 * time.Second // #MONITORING types (protocol p.12). qseMonitorZone = 5 // Zone-level monitoring. qseMonitorScene = 8 // Scene monitoring. qseMonitorReply = 11 // Reply state (responses cease if disabled). // writeInterval is the zone-writer tick. writeInterval = 100 * time.Millisecond // Minimum inter-message delays mandated by the integration protocol: a command // ("#") requires at least 100 ms before the next message, a query ("?") at // least 1500 ms. The protocol also advises relying on monitoring rather than // polling, which is why queries are only used for the connect-time sync and // the single-query watchdog probe. commandInterMessageDelay = 100 * time.Millisecond queryInterMessageDelay = 1500 * time.Millisecond // queryReplyWindow bounds how long after we query a zone its reply is treated // as solicited. A solicited reply (notably the watchdog liveness probe) is // used for liveness only and must not override the level we maintain; an // unsolicited report past this window is a genuine external change to follow. queryReplyWindow = 5 * time.Second // zoneEchoWindow bounds how long after we write a zone its report is treated // as an echo of that write rather than a genuine external change. The panel // quantizes levels to its own scale, so its echo rarely byte-matches what we // sent; adopting it back into the target would fight our own writes. zoneEchoWindow = 5 * time.Second ) // qseErrorDescriptions maps ~ERROR, codes from the Lutron integration // protocol (doc 040249) to human-readable text. var qseErrorDescriptions = map[string]string{ "1": "parameter count mismatch", "2": "object does not exist (check integration ID)", "3": "invalid action number", "4": "parameter data out of range", "5": "parameter data malformed", "6": "unsupported command", } // shadeColumn holds the GRAFIK Eye QS phantom-button component numbers for one // shade column. Open, close, and preset react on release (a press then release); // raise and lower start motion on press and are halted by releasing the held // component. type shadeColumn struct{ open, preset, close, lower, raise int } // qseShadeColumns maps a 1-indexed shade column to its component numbers. var qseShadeColumns = map[int]shadeColumn{ 1: {open: 38, preset: 39, close: 40, lower: 41, raise: 47}, 2: {open: 44, preset: 45, close: 46, lower: 52, raise: 53}, 3: {open: 50, preset: 51, close: 56, lower: 57, raise: 58}, } // sourceBinding tracks a control source's arbitration state on a device. Zone // and scene activity are tracked separately so the device knows whether zones // are being actively driven. type sourceBinding struct { name string priority int hold time.Duration fade string // Zone fade time this source applies; empty uses the device default. lastZoneActive time.Time lastSceneActive time.Time } // activeAt reports whether the source has applied zone or scene control within // its hold window as of now. func (b *sourceBinding) activeAt(now time.Time) bool { return now.Sub(b.lastZoneActive) < b.hold || now.Sub(b.lastSceneActive) < b.hold } // zoneFeedbackFn is called when the device reports a zone's current level. type zoneFeedbackFn func(zone int, level byte) // sceneFeedbackFn is called when the device reports the active scene. type sceneFeedbackFn func(scene int) // MonitorEvent is a parsed monitoring message reported by the panel. It carries // every "~" feedback line generically so a source can relay arbitrary monitoring // (zone, scene, button, occupancy, etc.) without the device modelling each type. type MonitorEvent struct { Family string // The "~KEYWORD": DEVICE, OUTPUT, GROUP, MONITORING, SYSTEM, ERROR. Fields []string // Comma-split fields after the keyword (integration id, component, action, params). Raw string // The original line, for passthrough consumers. } // monitorFn is called for every monitoring message the panel reports. type monitorFn func(ev MonitorEvent) // Device supervises a single Lutron interface: it owns the connection, reads // integration responses, writes changed zone levels, and arbitrates between the // control sources that want to drive its zones. type Device struct { cfg *DeviceConfig zones int // Connection ownership. connMu guards conn and serializes writes. connMu sync.Mutex conn transport // Outbound pacing. paceMu serializes all outbound messages and enforces the // protocol's minimum inter-message delays; nextSendAt is the earliest time // the next message may be sent. lastWasZoneOp records whether the previous // send was a zone-value operation so a run of them can burst together. paceMu sync.Mutex nextSendAt time.Time lastWasZoneOp bool // Zone and arbitration state. dataMu sync.Mutex target []byte // Desired zone levels (0-255). sent []byte // Last levels written to the panel. reported []byte // Last levels reported by the panel. zoneFade []string // Per-zone fade time set by the last source to drive each zone. zoneRamping []bool // Per-zone raise/lower in progress; writes are suppressed until stop. zoneSuppress []time.Time // Per-zone write-suppression deadline (raise/lower stop grace). shadeHeld map[int]int // Per-column held shade component, released on stop. lastZoneQuery []time.Time // When each zone was last queried, to spot solicited replies. lastZoneWrite []time.Time // When each zone was last written, to spot reports echoing our own writes. sendAll bool // Force a full resend on the next write pass. controlDisabled bool // Panel signalled integration control is disabled. resyncing bool // Connect-time level sync in progress; suppress zone writes. pendingResync int // Zone reports still awaited before the resync is complete. bindings []*sourceBinding feedbackFns []zoneFeedbackFn // Generic monitoring fan-out. monitorFns receive every "~" feedback line; // requestedMonitoring is the union of #MONITORING types sources have asked the // panel to report, enabled on connect alongside the built-in defaults. monitorFns []monitorFn requestedMonitoring map[int]bool // Scene state. sceneFeedbackFns []sceneFeedbackFn sceneMonitoring bool // A scene source is attached; enable scene monitoring. sceneSuppress time.Time // Suppress zone writes until this time (a scene is holding). // Watchdog timestamps (Unix nanoseconds). lastResponse atomicTime lastWrite atomicTime lastReset atomicTime // Lifecycle. ctx context.Context reconnect chan string wg sync.WaitGroup } // NewDevice constructs a device from its configuration. func NewDevice(cfg *DeviceConfig) *Device { d := &Device{ cfg: cfg, zones: cfg.Zones, target: make([]byte, cfg.Zones), sent: make([]byte, cfg.Zones), reported: make([]byte, cfg.Zones), zoneFade: make([]string, cfg.Zones), zoneRamping: make([]bool, cfg.Zones), zoneSuppress: make([]time.Time, cfg.Zones), shadeHeld: make(map[int]int), lastZoneQuery: make([]time.Time, cfg.Zones), lastZoneWrite: make([]time.Time, cfg.Zones), sendAll: true, reconnect: make(chan string, 1), requestedMonitoring: make(map[int]bool), } return d } // Name returns the device's configured name. func (d *Device) Name() string { return d.cfg.Name } // Zones returns the number of controllable zones. func (d *Device) Zones() int { return d.zones } // RegisterSource registers a control source for arbitration and returns its // binding handle. It must be called before Start. func (d *Device) RegisterSource(name string, priority int, hold time.Duration, fade string) *sourceBinding { b := &sourceBinding{name: name, priority: priority, hold: hold, fade: fade} d.dataMu.Lock() d.bindings = append(d.bindings, b) d.dataMu.Unlock() return b } // OnZoneFeedback registers a callback invoked when the panel reports a zone's // level. It must be called before Start. func (d *Device) OnZoneFeedback(fn zoneFeedbackFn) { d.dataMu.Lock() d.feedbackFns = append(d.feedbackFns, fn) d.dataMu.Unlock() } // OnSceneFeedback registers a callback invoked when the panel reports the active // scene. It must be called before Start. func (d *Device) OnSceneFeedback(fn sceneFeedbackFn) { d.dataMu.Lock() d.sceneFeedbackFns = append(d.sceneFeedbackFns, fn) d.dataMu.Unlock() } // NoteSceneControl marks that a scene source is attached so scene monitoring is // enabled on connect. It must be called before Start. func (d *Device) NoteSceneControl() { d.dataMu.Lock() d.sceneMonitoring = true d.dataMu.Unlock() } // OnMonitor registers a callback invoked for every monitoring message the panel // reports. It must be called before Start. func (d *Device) OnMonitor(fn monitorFn) { d.dataMu.Lock() d.monitorFns = append(d.monitorFns, fn) d.dataMu.Unlock() } // NoteMonitoring records #MONITORING types a source wants the panel to report so // the union is enabled on connect. Type 255 requests all monitoring. It must be // called before Start. func (d *Device) NoteMonitoring(types ...int) { d.dataMu.Lock() for _, t := range types { if t > 0 { d.requestedMonitoring[t] = true } } d.dataMu.Unlock() } // lockedOut reports whether a higher-priority source is currently active, // blocking this binding from taking control. Caller must hold dataMu. func (d *Device) lockedOut(b *sourceBinding, now time.Time) bool { for _, other := range d.bindings { if other == b { continue } if other.priority > b.priority && other.activeAt(now) { return true } } return false } // ApplyZoneLevels sets the target level for a set of 1-indexed zones on behalf of // a source, leaving every other zone untouched (a source only ever drives the // zones it owns). It returns false when the caller can't take control — either a // higher-priority source is currently active, or the panel has signalled that // integration control is disabled — in which case the levels are ignored and the // caller should reflect the device's real state instead of its request. func (d *Device) ApplyZoneLevels(b *sourceBinding, levels map[int]byte) bool { d.dataMu.Lock() defer d.dataMu.Unlock() now := time.Now() if d.controlDisabled || d.lockedOut(b, now) { return false } b.lastZoneActive = now // Zone control takes over from any active scene. d.sceneSuppress = time.Time{} for zone, level := range levels { if zone >= 1 && zone <= d.zones { d.target[zone-1] = level d.zoneFade[zone-1] = b.fade // An absolute level cancels any raise/lower in progress for the zone. d.zoneRamping[zone-1] = false d.zoneSuppress[zone-1] = time.Time{} } } return true } // RefreshZoneActivity re-asserts a source's zone activity without changing any // levels, keeping its arbitration lock alive between value changes. A // continuously streaming source (e.g. sACN holding a static look) needs this // because its receiver only reports changed frames, so a steady stream would // otherwise look idle once the hold window elapsed and let a lower-priority // source take over. It is a no-op while integration control is disabled or a // higher-priority source holds control, matching ApplyZoneLevels. func (d *Device) RefreshZoneActivity(b *sourceBinding) { d.dataMu.Lock() defer d.dataMu.Unlock() now := time.Now() if d.controlDisabled || d.lockedOut(b, now) { return } b.lastZoneActive = now } // ApplyScene activates a GRAFIK Eye scene on behalf of a source. It returns false // when a higher-priority source is active. While the scene holds, zone writes are // suppressed so the scene isn't immediately overwritten by a resend. func (d *Device) ApplyScene(b *sourceBinding, scene int) bool { d.dataMu.Lock() now := time.Now() if d.controlDisabled || d.lockedOut(b, now) { d.dataMu.Unlock() return false } b.lastSceneActive = now d.sceneSuppress = now.Add(b.hold) d.dataMu.Unlock() // Activate the scene via the Scene Controller component (141), action 7. cmd := fmt.Sprintf("#DEVICE,%d,%d,%d,%d", d.cfg.IntegrationID, qseSceneController, qseActionScene, scene) log.Infof("[%s] Activating scene %d", d.cfg.Name, scene) log.Debugf("[%s] TX %s", d.cfg.Name, cmd) d.write(cmd + qseTerminator) return true } // deviceCommand formats a "#DEVICE,,,[,...]" line, // terminated for transmission. func (d *Device) deviceCommand(component, action int, params ...string) string { cmd := fmt.Sprintf("#DEVICE,%d,%d,%d", d.cfg.IntegrationID, component, action) if len(params) > 0 { cmd += "," + strings.Join(params, ",") } return cmd + qseTerminator } // RaiseZone starts raising a zone's level on behalf of a source and holds until a // matching StopZone. Zone writes for the zone are suppressed while it moves so the // resend loop can't interrupt the ramp; the new level is followed via zone // monitoring. It returns false when the caller can't take control (a higher- // priority source is active or integration control is disabled). func (d *Device) RaiseZone(b *sourceBinding, zone int) bool { return d.moveZone(b, zone, qseActionStartRaising) } // LowerZone starts lowering a zone's level; see RaiseZone. func (d *Device) LowerZone(b *sourceBinding, zone int) bool { return d.moveZone(b, zone, qseActionStartLowering) } // moveZone begins a raise or lower on a zone, marking it ramping so the writer // leaves it alone until StopZone. func (d *Device) moveZone(b *sourceBinding, zone, action int) bool { if zone < 1 || zone > d.zones { return false } d.dataMu.Lock() now := time.Now() if d.controlDisabled || d.lockedOut(b, now) { d.dataMu.Unlock() return false } b.lastZoneActive = now d.sceneSuppress = time.Time{} d.zoneRamping[zone-1] = true d.dataMu.Unlock() d.write(d.deviceCommand(zone, action)) return true } // StopZone halts a zone started by RaiseZone or LowerZone. Zone writes stay // suppressed for a short grace period so the panel's final level report updates // the target before the writer resumes, keeping the zone at its resting level. // Stop is always sent (even when locked out) so a moving zone can be halted. func (d *Device) StopZone(b *sourceBinding, zone int) bool { if zone < 1 || zone > d.zones { return false } d.dataMu.Lock() now := time.Now() b.lastZoneActive = now d.zoneRamping[zone-1] = false d.zoneSuppress[zone-1] = now.Add(zoneStopGrace) d.dataMu.Unlock() d.write(d.deviceCommand(zone, qseActionStopMoving)) return true } // ApplyShade drives a shade column on behalf of a source. The action is one of // "open", "close", "preset", "raise", "lower", or "stop". Open, close, and preset // are momentary (press then release); raise and lower start motion and are halted // by a subsequent "stop". It returns false for an unknown column or action. func (d *Device) ApplyShade(b *sourceBinding, column int, action string) bool { col, ok := qseShadeColumns[column] if !ok { return false } switch action { case "open": d.pressRelease(col.open) case "close": d.pressRelease(col.close) case "preset": d.pressRelease(col.preset) case "raise": d.shadeStart(column, col.raise) case "lower": d.shadeStart(column, col.lower) case "stop": d.shadeStop(column) default: return false } return true } // pressRelease issues a press then release on a phantom-button component, which // the panel acts on at the release. func (d *Device) pressRelease(component int) { d.write(d.deviceCommand(component, qseBtnAction)) d.write(d.deviceCommand(component, qseActionRelease)) } // shadeStart presses a shade raise/lower component to begin motion, recording the // held component so a later stop releases it. A held opposite direction is // released first. func (d *Device) shadeStart(column, component int) { d.dataMu.Lock() prev := d.shadeHeld[column] d.shadeHeld[column] = component d.dataMu.Unlock() if prev != 0 && prev != component { d.write(d.deviceCommand(prev, qseActionRelease)) } d.write(d.deviceCommand(component, qseBtnAction)) } // shadeStop releases the held shade component for a column, halting motion. func (d *Device) shadeStop(column int) { d.dataMu.Lock() component := d.shadeHeld[column] delete(d.shadeHeld, column) d.dataMu.Unlock() if component != 0 { d.write(d.deviceCommand(component, qseActionRelease)) } } // SetZoneLock enables or disables the zone lock (QS Standalone). func (d *Device) SetZoneLock(on bool) { d.write(d.deviceCommand(qseSceneController, qseActionZoneLock, boolParam(on))) } // SetSceneLock enables or disables the scene lock (QS Standalone). func (d *Device) SetSceneLock(on bool) { d.write(d.deviceCommand(qseSceneController, qseActionSceneLock, boolParam(on))) } // SetSequence sets the scene sequence state: 0 off, 1 scenes 1-4, 2 scenes 5-16. func (d *Device) SetSequence(mode int) bool { if mode < 0 || mode > 2 { return false } d.write(d.deviceCommand(qseSceneController, qseActionSequence, strconv.Itoa(mode))) return true } // SceneOff activates the Scene Off button on behalf of a source, turning all // zones off. Like ApplyScene it suppresses zone writes while the off look holds // so the writer doesn't immediately re-light the zones. func (d *Device) SceneOff(b *sourceBinding) bool { d.dataMu.Lock() now := time.Now() if d.controlDisabled || d.lockedOut(b, now) { d.dataMu.Unlock() return false } b.lastSceneActive = now d.sceneSuppress = now.Add(b.hold) d.dataMu.Unlock() d.pressRelease(qseSceneOffButton) return true } // boolParam renders a Lutron 0/1 on/off parameter. func boolParam(on bool) string { if on { return "1" } return "0" } // ZoneTargets returns a snapshot of the current target zone levels. func (d *Device) ZoneTargets() []byte { d.dataMu.Lock() defer d.dataMu.Unlock() out := make([]byte, d.zones) copy(out, d.target) return out } // Start launches the supervisor and worker loops. func (d *Device) Start(ctx context.Context) { d.ctx = ctx d.wg.Add(1) go d.supervisor(ctx) d.wg.Add(1) go d.writeLoop(ctx) d.wg.Add(1) go d.resendLoop(ctx) d.wg.Add(1) go d.watchdog(ctx) } // Stop closes the connection and waits for the loops to exit. func (d *Device) Stop() { d.closeConn() d.wg.Wait() } // supervisor owns the (re)connect path. Other loops request a reconnect and the // supervisor reopens the link, then resyncs zone levels from the panel. func (d *Device) supervisor(ctx context.Context) { defer d.wg.Done() for { if ctx.Err() != nil { return } // Reset per-connection state before the connection is exposed. Writes are // suppressed until the connect-time query sync populates the current zone // levels, so the writer can't push its zero-initialized targets and // momentarily drive every zone to off. Setting the gate before connect // closes the window where the writer could see the new connection but not // yet the resync flag. d.dataMu.Lock() d.sendAll = true d.resyncing = true d.pendingResync = d.zones d.dataMu.Unlock() conn := d.connect(ctx) if conn == nil { return // Shutting down. } d.lastResponse.set(time.Now()) // Configure monitoring and resync zone levels concurrently with the // reader. The zone queries are paced 1500 ms apart, so running them inline // would block reading for the whole burst and leave the panel's replies // sitting unread in the port buffer; spawning them lets responses be // processed as they arrive. syncDone := make(chan struct{}) go func() { defer close(syncDone) d.setupMonitoring() d.queryZoneLevels() // Failsafe: the resync gate normally clears as each zone reports its // level. If a zone never replies, give the last query's response time // to arrive, then lift the gate so writes aren't suppressed forever. select { case <-ctx.Done(): case <-time.After(queryInterMessageDelay): } d.dataMu.Lock() d.resyncing = false d.dataMu.Unlock() }() // Read until the connection fails or a reconnect is requested. d.readLoop(ctx, conn) // Let the sync goroutine unwind before reconnecting so it can't write to // the next connection. Once the link is down its paced writes fail fast. <-syncDone select { case <-ctx.Done(): return default: } } } // connect opens the transport, retrying with exponential backoff until it // succeeds or the context is cancelled (in which case it returns nil). func (d *Device) connect(ctx context.Context) transport { backoff := time.Duration(d.cfg.Reliability.ReconnectBackoffMinSec) * time.Second maxBackoff := time.Duration(d.cfg.Reliability.ReconnectBackoffMaxSec) * time.Second // Guard against a zero/negative configured backoff, which would spin the retry // loop and never grow (0*2 stays 0). if backoff <= 0 { backoff = time.Second } if maxBackoff < backoff { maxBackoff = backoff } for { if ctx.Err() != nil { return nil } log.Infof("[%s] Opening %s connection", d.cfg.Name, d.cfg.Transport) conn, err := openTransport(d.cfg) if err == nil { d.connMu.Lock() d.conn = conn d.connMu.Unlock() // Drain any stale reconnect request from a prior connection. select { case <-d.reconnect: default: } log.Infof("[%s] Connection established", d.cfg.Name) return conn } log.Errorf("[%s] Connect failed: %s; retry in %s", d.cfg.Name, err, backoff) select { case <-ctx.Done(): return nil case <-time.After(backoff): } backoff = min(backoff*2, maxBackoff) } } // requestReconnect closes the current connection and wakes the supervisor. func (d *Device) requestReconnect(reason string) { log.Warnf("[%s] Reconnect requested: %s", d.cfg.Name, reason) d.closeConn() select { case d.reconnect <- reason: default: } } // closeConn closes the current connection if any. func (d *Device) closeConn() { d.connMu.Lock() if d.conn != nil { d.conn.Close() d.conn = nil } d.connMu.Unlock() } // write sends a payload to the panel, serialized and paced to honor the // protocol's minimum inter-message delays, and requests a reconnect on failure. // It returns true on success. func (d *Device) write(payload string) bool { // Queries ("?") and commands ("#") carry different post-send delays. isQuery := len(payload) > 0 && payload[0] == '?' zoneOp := isZoneValueOp(payload) // Serialize all outbound messages. A run of zone-value operations bursts so // every zone changes at once instead of cascading; the inter-message delay is // only owed when this message differs in kind from the previous send. d.paceMu.Lock() defer d.paceMu.Unlock() if !(zoneOp && d.lastWasZoneOp) { d.paceWait() } d.connMu.Lock() conn := d.conn if conn == nil { d.connMu.Unlock() return false } _, err := conn.Write([]byte(payload)) d.connMu.Unlock() // Schedule the earliest time a differing next message may be sent. delay := commandInterMessageDelay if isQuery { delay = queryInterMessageDelay } d.nextSendAt = time.Now().Add(delay) d.lastWasZoneOp = zoneOp if err != nil { log.Errorf("[%s] Write failed: %s", d.cfg.Name, err) d.requestReconnect("write error") return false } d.lastWrite.set(time.Now()) return true } // isZoneValueOp reports whether a payload is a zone-level set ("#DEVICE,...,14,..."). // Consecutive zone-level sets burst without the inter-message delay so all zones // change together rather than cascading. Zone-level queries ("?DEVICE,...,14") are // deliberately excluded: the protocol mandates a 1500 ms gap between queries, so // they must pace normally rather than burst. func isZoneValueOp(payload string) bool { body := strings.TrimRight(payload, qseTerminator) if !strings.HasPrefix(body, "#DEVICE,") { return false } fields := strings.Split(body, ",") return len(fields) >= 4 && fields[3] == strconv.Itoa(qseActionZoneLevel) } // paceWait blocks until the minimum inter-message delay owed from the previous // send has elapsed, returning early on shutdown. It must be called with paceMu // held. func (d *Device) paceWait() { wait := time.Until(d.nextSendAt) if wait <= 0 { return } timer := time.NewTimer(wait) defer timer.Stop() select { case <-d.ctx.Done(): case <-timer.C: } } // readLoop reads CRLF-terminated integration responses from conn until it // fails, dispatching each line. It returns so the supervisor can reconnect. func (d *Device) readLoop(ctx context.Context, conn transport) { buf := make([]byte, 0, 256) tmp := make([]byte, 256) for { n, err := conn.Read(tmp) if err != nil { if ctx.Err() == nil { log.Errorf("[%s] Read failed: %s", d.cfg.Name, err) d.requestReconnect("read error") } return } if n == 0 { continue } buf = append(buf, tmp[:n]...) // Extract and handle each complete line. for { i := bytes.IndexByte(buf, '\n') if i < 0 { break } line := strings.TrimRight(string(buf[:i]), "\r") buf = buf[i+1:] d.handleLine(line) } // Guard against an unterminated flood growing the buffer without bound. if len(buf) > 4096 { buf = buf[:0] } } } // handleLine parses one integration response and updates state. func (d *Device) handleLine(line string) { // The QSE echoes a "QSE>" prompt; strip it and trim whitespace. line = strings.TrimSpace(strings.ReplaceAll(line, "QSE>", "")) if line == "" { return } d.lastResponse.set(time.Now()) log.Debugf("[%s] RX %s", d.cfg.Name, line) upper := strings.ToUpper(line) switch { case strings.HasPrefix(upper, "~ERROR"): d.handleError(line) case d.cfg.DisableComponent != 0 && line == d.disableSignal(): log.Infof("[%s] Received disable signal", d.cfg.Name) d.dataMu.Lock() d.controlDisabled = true d.dataMu.Unlock() case d.cfg.EnableComponent != 0 && line == d.enableSignal(): log.Infof("[%s] Received enable signal", d.cfg.Name) d.dataMu.Lock() d.controlDisabled = false d.sendAll = true d.dataMu.Unlock() case strings.HasPrefix(line, d.devicePrefix()): d.handleDeviceFeedback(line) } // Relay every "~" report to generic monitor subscribers (OSC/MQTT), in // addition to the typed handling above which drives arbitration and HA state. d.dispatchMonitor(line) } // dispatchMonitor parses a "~" feedback line into a MonitorEvent and fans it out // to monitor subscribers. Non-"~" lines (echoes, prompts) are ignored. func (d *Device) dispatchMonitor(line string) { if !strings.HasPrefix(line, "~") { return } d.dataMu.Lock() if len(d.monitorFns) == 0 { d.dataMu.Unlock() return } fns := d.monitorFns d.dataMu.Unlock() fields := strings.Split(line, ",") ev := MonitorEvent{ Family: strings.ToUpper(strings.TrimPrefix(fields[0], "~")), Fields: fields[1:], Raw: line, } for _, fn := range fns { fn(ev) } } // handleDeviceFeedback dispatches a "~DEVICE,,..." response to the zone-level // or scene handler based on its component and action. func (d *Device) handleDeviceFeedback(line string) { fields := strings.Split(line, ",") if len(fields) < 4 || fields[1] != strconv.Itoa(d.cfg.IntegrationID) { return } switch { case fields[3] == strconv.Itoa(qseActionZoneLevel): d.handleZoneFeedback(fields) case fields[2] == strconv.Itoa(qseSceneController) && fields[3] == strconv.Itoa(qseActionScene): d.handleSceneFeedback(fields) } } // handleZoneFeedback parses a "~DEVICE,,,14," response, records // the reported level, and mirrors it to feedback subscribers. The target is // synced to the reported level during the connect-time resync and when an // unsolicited report reflects a genuine external change (a keypad or scene), so // the writer follows it instead of fighting it. A report that merely echoes one // of our own recent writes or query probes is not adopted: the panel quantizes // levels to its own scale, so its echo rarely byte-matches what we sent, and // adopting it back would create a self-sustaining write loop (and re-populate // zones mid-fade that a release is trying to drive to 0). func (d *Device) handleZoneFeedback(fields []string) { if len(fields) < 5 { return } zone, err := strconv.Atoi(fields[2]) if err != nil || zone < 1 || zone > d.zones { return } pct, err := strconv.ParseFloat(fields[4], 64) if err != nil { return } level := byte(math.Round(pct / 100.0 * 255.0)) d.dataMu.Lock() d.reported[zone-1] = level // Classify the report: a solicited reply follows our own query, an echo follows // our own write. Neither is an external change, so neither should move the // target. Adopt only a resync reply or a genuine unsolicited report. solicited := time.Since(d.lastZoneQuery[zone-1]) < queryReplyWindow echo := time.Since(d.lastZoneWrite[zone-1]) < zoneEchoWindow resyncing := d.resyncing adopt := resyncing || (!solicited && !echo) if adopt { d.target[zone-1] = level d.sent[zone-1] = level } // Lift the connect-time resync gate once every zone has reported, so the // writer resumes with targets that mirror the panel rather than zeros. if resyncing && d.pendingResync > 0 { d.pendingResync-- if d.pendingResync == 0 { d.resyncing = false } } // Mirror genuine reports to subscribers (e.g. Home Assistant), but not a // solicited probe reply, which is for liveness only and must not flip // subscribers to a value that disagrees with the level we maintain. var fns []zoneFeedbackFn if resyncing || !solicited { fns = d.feedbackFns } d.dataMu.Unlock() for _, fn := range fns { fn(zone, level) } } // handleSceneFeedback parses a "~DEVICE,,141,7," response and notifies // scene subscribers. func (d *Device) handleSceneFeedback(fields []string) { if len(fields) < 5 { return } scene, err := strconv.Atoi(fields[4]) if err != nil { return } d.dataMu.Lock() fns := d.sceneFeedbackFns d.dataMu.Unlock() for _, fn := range fns { fn(scene) } } // handleError logs a ~ERROR response and, for the NWK-lockup error, resets the // interface. The reset is rate-limited because a wedged NWK floods the error. func (d *Device) handleError(line string) { code := "" if _, after, ok := strings.Cut(line, ","); ok { code = strings.TrimSpace(after) } desc := qseErrorDescriptions[code] if desc == "" { desc = "unknown error" } // Error 6 ("unsupported command") is also the symptom of the NWK lockup that // only #RESET,0 clears, so we recover from it; other errors are logged only. if code == "6" { cooldown := time.Duration(d.cfg.Reliability.ResetCooldownSec) * time.Second if time.Since(d.lastReset.get()) >= cooldown { d.lastReset.set(time.Now()) log.Warnf("[%s] NWK returned %s (%s); sending #RESET,0", d.cfg.Name, line, desc) d.write("#RESET,0" + qseTerminator) } return } log.Warnf("[%s] NWK returned %s (%s)", d.cfg.Name, line, desc) } // writeLoop periodically pushes changed zone levels out to the panel. func (d *Device) writeLoop(ctx context.Context) { defer d.wg.Done() ticker := time.NewTicker(writeInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: d.flushZones() } } } // flushZones writes any zone levels that changed since the last pass. func (d *Device) flushZones() { d.dataMu.Lock() // Don't push zone levels while the connect-time resync is still in progress, // while integration control is disabled, or while a scene is holding (so the // scene isn't immediately overwritten). if d.resyncing || d.controlDisabled || time.Now().Before(d.sceneSuppress) { d.dataMu.Unlock() return } now := time.Now() targets := make([]byte, d.zones) copy(targets, d.target) fades := make([]string, d.zones) copy(fades, d.zoneFade) ramping := make([]bool, d.zones) copy(ramping, d.zoneRamping) suppress := make([]time.Time, d.zones) copy(suppress, d.zoneSuppress) // Snapshot the last-sent levels under the lock too; the read loop writes them // from handleZoneFeedback, so comparing against d.sent directly would race. sent := make([]byte, d.zones) copy(sent, d.sent) resendAll := d.sendAll d.sendAll = false d.dataMu.Unlock() // Skip writing entirely if the link is down; the supervisor is reconnecting. d.connMu.Lock() connected := d.conn != nil d.connMu.Unlock() if !connected { return } for zone := range d.zones { // Skip zones being raised/lowered and those still in their post-stop grace // so the writer can't interrupt a ramp or yank a zone back to its // pre-move level before the panel reports the resting level. if ramping[zone] || now.Before(suppress[zone]) { continue } if !resendAll && targets[zone] == sent[zone] { continue } if !d.sendZoneLevel(zone+1, targets[zone], fades[zone]) { // Write failed: mark a full resend for next pass and let the supervisor // reopen the port. d.dataMu.Lock() d.sendAll = true d.dataMu.Unlock() return } // Record the write so the panel's echo of it isn't mistaken for an // external change in handleZoneFeedback. d.dataMu.Lock() d.sent[zone] = targets[zone] d.lastZoneWrite[zone] = time.Now() d.dataMu.Unlock() } } // sendZoneLevel writes a single zone's level as a percentage with the given fade // time, falling back to the device default when the source set none. func (d *Device) sendZoneLevel(zone int, value byte, fade string) bool { if fade == "" { fade = d.cfg.Fade } pct := math.Round(float64(value)/255.0*100.0*100.0) / 100.0 cmd := fmt.Sprintf("#DEVICE,%d,%d,%d,%.2f,%s", d.cfg.IntegrationID, zone, qseActionZoneLevel, pct, fade) log.Debugf("[%s] TX %s", d.cfg.Name, cmd) return d.write(cmd + qseTerminator) } // setupMonitoring configures which integration messages the panel reports. The // protocol advises enabling only what is needed; we ensure zone-level and reply // monitoring are on (so feedback works regardless of the unit's programmed // defaults) plus scene monitoring when a scene source is attached, then apply any // configured overrides. func (d *Device) setupMonitoring() { m := d.cfg.Monitoring if m.Manage == nil || *m.Manage { d.setMonitoring(qseMonitorZone, true) d.setMonitoring(qseMonitorReply, true) d.dataMu.Lock() wantScene := d.sceneMonitoring // Snapshot the source-requested types, skipping the built-ins already sent. requested := make([]int, 0, len(d.requestedMonitoring)) for t := range d.requestedMonitoring { if t != qseMonitorZone && t != qseMonitorReply { requested = append(requested, t) } } d.dataMu.Unlock() if wantScene { d.setMonitoring(qseMonitorScene, true) } // Enable the union of monitoring types sources asked to relay. for _, t := range requested { if t == qseMonitorScene && wantScene { continue // Already enabled above. } d.setMonitoring(t, true) } } for _, t := range m.Enable { d.setMonitoring(t, true) } for _, t := range m.Disable { d.setMonitoring(t, false) } } // setMonitoring enables or disables one monitoring type via #MONITORING. func (d *Device) setMonitoring(monitorType int, enable bool) { action := 2 // Disable. if enable { action = 1 // Enable. } cmd := fmt.Sprintf("#MONITORING,%d,%d", monitorType, action) log.Debugf("[%s] TX %s", d.cfg.Name, cmd) d.write(cmd + qseTerminator) } // queryZoneLevels asks the panel for each zone's current level so our view // matches reality after a (re)connect. Replies are handled in handleLine. The // queries are paced (1500 ms apart) per the protocol, so this is used only for // the one-time connect sync; ongoing updates arrive via zone monitoring. func (d *Device) queryZoneLevels() { for zone := 1; zone <= d.zones; zone++ { d.queryZone(zone) } } // queryZone asks the panel for one zone's current level. It records the query // time so the solicited reply is used for liveness only and does not override the // level we maintain (see handleZoneFeedback). func (d *Device) queryZone(zone int) { query := fmt.Sprintf("?DEVICE,%d,%d,%d", d.cfg.IntegrationID, zone, qseActionZoneLevel) d.dataMu.Lock() d.lastZoneQuery[zone-1] = time.Now() d.dataMu.Unlock() log.Debugf("[%s] TX %s", d.cfg.Name, query) d.write(query + qseTerminator) } // resendLoop forces a periodic full resend so the panel can't drift out of sync. // A zero interval disables the periodic refresh: zones are then sent only when a // target changes (reconnect and recovery resyncs still re-assert full state). func (d *Device) resendLoop(ctx context.Context) { defer d.wg.Done() interval := time.Duration(d.cfg.Reliability.SendAllIntervalSec) * time.Second if interval <= 0 { return } rxTimeout := time.Duration(d.cfg.Reliability.RxTimeoutSec) * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: // Skip resends when the link looks unhealthy; the watchdog handles it. if time.Since(d.lastResponse.get()) > rxTimeout { continue } d.dataMu.Lock() d.sendAll = true d.dataMu.Unlock() } } } // watchdog detects a stale link and reconnects it. The panel only // replies to a #DEVICE write when a level actually changes, so a quiet RX is not // itself a fault: we first probe the link (which gets a reply on a healthy link) // and only reconnect if that probe also goes unanswered. func (d *Device) watchdog(ctx context.Context) { defer d.wg.Done() interval := time.Duration(d.cfg.Reliability.WatchdogIntervalSec) * time.Second rxTimeout := time.Duration(d.cfg.Reliability.RxTimeoutSec) * time.Second ticker := time.NewTicker(interval) defer ticker.Stop() probed := false for { select { case <-ctx.Done(): return case <-ticker.C: } now := time.Now() wroteRecently := now.Sub(d.lastWrite.get()) < rxTimeout staleFor := now.Sub(d.lastResponse.get()) if wroteRecently && staleFor > rxTimeout { if !probed { d.probeLink(staleFor) probed = true } else { d.requestReconnect(fmt.Sprintf("no reply to probe; silent for %.0fs", staleFor.Seconds())) probed = false } } else { probed = false } } } // probeLink elicits a reply to confirm the link is alive and re-asserts state on // recovery. It prefers re-asserting monitoring: the panel acks with ~MONITORING // (proving liveness) and re-enabling the reply path recovers a NWK whose poll // state has gone stale after idle — a bare ?DEVICE poll returns a stale level // (e.g. 0.01) on such a link while monitoring stays accurate, so we lean on // monitoring rather than polling. When monitoring is left to the unit's // programming, fall back to a zone query whose reply (value ignored) still proves // the link is alive. staleFor is how long the link has been silent, for logging. func (d *Device) probeLink(staleFor time.Duration) { // Re-drive every zone's target on recovery. The probe itself touches only one // channel, but the resend loop pauses while RX is stale, so all zones — not // just the probed one — must be re-asserted once the link is back. d.dataMu.Lock() d.sendAll = true d.dataMu.Unlock() m := d.cfg.Monitoring if m.Manage == nil || *m.Manage { log.Infof("[%s] No reply for %.0fs while sending; re-asserting monitoring to test the link and restore the reply path", d.cfg.Name, staleFor.Seconds()) d.setMonitoring(qseMonitorReply, true) d.setMonitoring(qseMonitorZone, true) // Re-assert source-requested monitoring too so the relay resumes after a // NWK reply-path reset, not just the built-in zone/reply path. d.dataMu.Lock() requested := make([]int, 0, len(d.requestedMonitoring)) for t := range d.requestedMonitoring { if t != qseMonitorZone && t != qseMonitorReply { requested = append(requested, t) } } d.dataMu.Unlock() for _, t := range requested { d.setMonitoring(t, true) } return } log.Infof("[%s] No reply for %.0fs while sending; querying zone 1 to test the link", d.cfg.Name, staleFor.Seconds()) d.queryZone(1) } // devicePrefix is the "~DEVICE," prefix for this device's feedback lines. func (d *Device) devicePrefix() string { return fmt.Sprintf("~DEVICE,%d", d.cfg.IntegrationID) } // disableSignal is the integration-control-disabled signal line. func (d *Device) disableSignal() string { return fmt.Sprintf("~DEVICE,%d,%d,%d", d.cfg.IntegrationID, d.cfg.DisableComponent, qseBtnAction) } // enableSignal is the integration-control-enabled signal line. func (d *Device) enableSignal() string { return fmt.Sprintf("~DEVICE,%d,%d,%d", d.cfg.IntegrationID, d.cfg.EnableComponent, qseBtnAction) } // atomicTime is a small mutex-guarded time value shared between loops. type atomicTime struct { mu sync.Mutex t time.Time } func (a *atomicTime) set(t time.Time) { a.mu.Lock() a.t = t a.mu.Unlock() } func (a *atomicTime) get() time.Time { a.mu.Lock() defer a.mu.Unlock() return a.t }