package main import ( "context" "fmt" "math" "net" "strconv" "strings" "github.com/hypebeast/go-osc/osc" log "github.com/sirupsen/logrus" ) // OSCSource exposes a device's controls over OSC (Open Sound Control). Incoming // UDP messages select an operation by address under the configured prefix: // // /zone//level f|i set zone level (float 0-1, or int 0-255) // /zone//raise start raising zone // /zone//lower start lowering zone // /zone//stop stop raising/lowering zone // /scene i activate scene // /scene/off activate the scene-off look // /shade// drive shade column (open|close|preset|raise|lower|stop) // /lock/zone i zone lock (0 off, 1 on) // /lock/scene i scene lock (0 off, 1 on) // /sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16) // // Movement and trigger addresses (raise/lower/stop, scene/off) act on receipt and // ignore their arguments. type OSCSource struct { cfg *SourceConfig dev *Device binding *sourceBinding server *osc.Server conn net.PacketConn // Monitoring feedback streaming. dests are the resolved destinations the // panel's reports are sent to; families filters which "~" families to forward // (empty forwards all); levelFloat picks the zone-level encoding. dests []*net.UDPAddr families map[string]bool levelFloat bool } // newOSCSource constructs an OSC control source, resolving any monitoring stream // destinations up front. func newOSCSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *OSCSource { s := &OSCSource{ cfg: cfg, dev: dev, binding: binding, levelFloat: cfg.OSC.LevelAsFloat == nil || *cfg.OSC.LevelAsFloat, } // Resolve stream destinations; skip (and log) any that don't resolve. for _, addr := range cfg.OSC.StreamTo { ua, err := net.ResolveUDPAddr("udp", addr) if err != nil { log.Errorf("[%s] Bad OSC stream_to %q: %s", cfg.Name, addr, err) continue } s.dests = append(s.dests, ua) } // Build the family filter (uppercased); empty means forward everything. if len(cfg.OSC.Monitor.Families) > 0 { s.families = make(map[string]bool, len(cfg.OSC.Monitor.Families)) for _, f := range cfg.OSC.Monitor.Families { s.families[strings.ToUpper(f)] = true } } return s } // Name returns the source's configured name. func (s *OSCSource) Name() string { return s.cfg.Name } // Start binds the UDP socket and serves OSC messages until the context is done. func (s *OSCSource) Start(ctx context.Context) error { // go-osc rejects pattern characters in registered addresses, so route every // message through a single default handler that parses the address. d := osc.NewStandardDispatcher() if err := d.AddMsgHandler("*", s.route); err != nil { return err } conn, err := net.ListenPacket("udp", s.cfg.OSC.Listen) if err != nil { return err } s.conn = conn s.server = &osc.Server{Dispatcher: d} // Stream the panel's monitoring out to the configured destinations, requesting // the desired monitoring types from the panel first. Registered after the // socket is bound so the stream callback always has a connection to send on. if len(s.dests) > 0 { s.dev.NoteMonitoring(s.cfg.OSC.Monitor.Enable...) s.dev.OnMonitor(s.streamEvent) log.Infof("[%s] Streaming OSC monitoring to %d destination(s)", s.cfg.Name, len(s.dests)) } // Serve in the background; Serve returns once the connection is closed on // shutdown, which is not an error worth surfacing. go func() { if err := s.server.Serve(conn); err != nil { log.Debugf("[%s] OSC server stopped: %s", s.cfg.Name, err) } }() log.Infof("[%s] Listening for OSC on %s (prefix %s)", s.cfg.Name, s.cfg.OSC.Listen, s.cfg.OSC.Prefix) go func() { <-ctx.Done() s.Stop() }() return nil } // Stop closes the UDP socket, unblocking Serve. func (s *OSCSource) Stop() { if s.conn != nil { s.conn.Close() } } // route parses an OSC address under the prefix and dispatches it to the device. func (s *OSCSource) route(msg *osc.Message) { rest, ok := strings.CutPrefix(msg.Address, s.cfg.OSC.Prefix) if !ok { return } parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 0 || parts[0] == "" { return } log.Debugf("[%s] OSC RX %s %v", s.cfg.Name, msg.Address, msg.Arguments) switch parts[0] { case "zone": s.routeZone(parts, msg) case "scene": // "/scene" sets a scene; "/scene/off" clears it. if len(parts) >= 2 && parts[1] == "off" { s.dev.SceneOff(s.binding) return } if scene, ok := oscInt(msg); ok && scene >= 1 { s.dev.ApplyScene(s.binding, scene) } case "shade": // "/shade//". if len(parts) >= 3 { if col, err := strconv.Atoi(parts[1]); err == nil { s.dev.ApplyShade(s.binding, col, parts[2]) } } case "lock": if len(parts) >= 2 { on, _ := oscBool(msg) switch parts[1] { case "zone": s.dev.SetZoneLock(on) case "scene": s.dev.SetSceneLock(on) } } case "sequence": if mode, ok := oscInt(msg); ok { s.dev.SetSequence(mode) } } } // routeZone handles the "/zone//..." addresses. func (s *OSCSource) routeZone(parts []string, msg *osc.Message) { if len(parts) < 3 { return } zone, err := strconv.Atoi(parts[1]) if err != nil || zone < 1 { return } switch parts[2] { case "level": if level, ok := oscLevel(msg); ok { s.dev.ApplyZoneLevels(s.binding, map[int]byte{zone: level}) } case "raise": s.dev.RaiseZone(s.binding, zone) case "lower": s.dev.LowerZone(s.binding, zone) case "stop": s.dev.StopZone(s.binding, zone) } } // streamEvent forwards one monitoring message to the configured destinations, // after applying the family filter. Known reports (zone level, scene, occupancy) // map to symmetric addresses; anything else falls back to a generic address so // every monitored message is relayed. func (s *OSCSource) streamEvent(ev MonitorEvent) { if s.families != nil && !s.families[ev.Family] { return } addr, args := s.monitorAddress(ev) s.send(addr, args...) } // monitorAddress maps a MonitorEvent to an OSC address and arguments. The common // reports get clean, symmetric addresses; all others use the generic fallback. func (s *OSCSource) monitorAddress(ev MonitorEvent) (string, []any) { prefix := strings.TrimRight(s.cfg.OSC.Prefix, "/") f := ev.Fields switch ev.Family { case "DEVICE": // ~DEVICE,,,, if len(f) >= 4 { component, action := f[1], f[2] switch { case action == strconv.Itoa(qseActionZoneLevel): // Zone level: the component is the zone number, the param a percent. if pct, err := strconv.ParseFloat(f[3], 64); err == nil { return fmt.Sprintf("%s/zone/%s/level", prefix, component), []any{s.levelArg(pct)} } case component == strconv.Itoa(qseSceneController) && action == strconv.Itoa(qseActionScene): if scene, err := strconv.Atoi(f[3]); err == nil { return prefix + "/scene", []any{int32(scene)} } } } case "OUTPUT": // ~OUTPUT,,1, if len(f) >= 3 && f[1] == "1" { if pct, err := strconv.ParseFloat(f[2], 64); err == nil { return fmt.Sprintf("%s/zone/%s/level", prefix, f[0]), []any{s.levelArg(pct)} } } case "GROUP": // ~GROUP,,3, if len(f) >= 3 { if state, err := strconv.Atoi(f[2]); err == nil { return fmt.Sprintf("%s/group/%s/occupancy", prefix, f[0]), []any{int32(state)} } } } return s.genericAddress(prefix, ev) } // genericAddress builds the fallback address for an unmodeled report: // /monitor// with the trailing field as the // value argument (Lutron reports put the value last), so nothing is lost. func (s *OSCSource) genericAddress(prefix string, ev MonitorEvent) (string, []any) { segs := []string{"monitor", strings.ToLower(ev.Family)} var args []any if n := len(ev.Fields); n > 0 { segs = append(segs, ev.Fields[:n-1]...) args = []any{oscArg(ev.Fields[n-1])} } return prefix + "/" + strings.Join(segs, "/"), args } // levelArg encodes a zone level percent (0-100) as a 0-1 float or a 0-255 int, // matching the source's configured input convention. func (s *OSCSource) levelArg(pct float64) any { if s.levelFloat { return float32(pct / 100.0) } return int32(math.Round(pct / 100.0 * 255.0)) } // send marshals an OSC message and writes it to every stream destination over the // listening socket. Failures are logged at debug; streaming is best-effort. func (s *OSCSource) send(addr string, args ...any) { if s.conn == nil { return } b, err := osc.NewMessage(addr, args...).MarshalBinary() if err != nil { log.Debugf("[%s] OSC marshal failed for %s: %s", s.cfg.Name, addr, err) return } for _, dst := range s.dests { if _, err := s.conn.WriteTo(b, dst); err != nil { log.Debugf("[%s] OSC stream to %s failed: %s", s.cfg.Name, dst, err) } } log.Debugf("[%s] OSC TX %s %v", s.cfg.Name, addr, args) } // oscArg parses a Lutron field into the tightest OSC argument type: an integer, a // float, or the raw string when it is neither. func oscArg(v string) any { if i, err := strconv.Atoi(v); err == nil { return int32(i) } if f, err := strconv.ParseFloat(v, 32); err == nil { return float32(f) } return v } // oscLevel maps the first argument to a 0-255 zone level: a float is treated as a // 0-1 fraction, an integer as already on the 0-255 scale. func oscLevel(msg *osc.Message) (byte, bool) { if len(msg.Arguments) == 0 { return 0, false } switch v := msg.Arguments[0].(type) { case float32: return fractionByte(float64(v)), true case float64: return fractionByte(v), true case int32: return byte(clampByte(int(v))), true case int64: return byte(clampByte(int(v))), true default: return 0, false } } // fractionByte clamps a 0-1 fraction and scales it to 0-255. func fractionByte(f float64) byte { if f < 0 { f = 0 } if f > 1 { f = 1 } return byte(math.Round(f * 255)) } // oscInt extracts an integer from the first argument (float arguments are // truncated). func oscInt(msg *osc.Message) (int, bool) { if len(msg.Arguments) == 0 { return 0, false } switch v := msg.Arguments[0].(type) { case int32: return int(v), true case int64: return int(v), true case float32: return int(v), true case float64: return int(v), true default: return 0, false } } // oscBool reads a 0/1 (or boolean) on/off argument; a missing argument is off. func oscBool(msg *osc.Message) (bool, bool) { if len(msg.Arguments) == 0 { return false, false } switch v := msg.Arguments[0].(type) { case bool: return v, true case int32: return v != 0, true case int64: return v != 0, true case float32: return v != 0, true case float64: return v != 0, true default: return false, false } }