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

371 lines
11 KiB
Go

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:
//
// <prefix>/zone/<n>/level f|i set zone level (float 0-1, or int 0-255)
// <prefix>/zone/<n>/raise start raising zone <n>
// <prefix>/zone/<n>/lower start lowering zone <n>
// <prefix>/zone/<n>/stop stop raising/lowering zone <n>
// <prefix>/scene i activate scene <i>
// <prefix>/scene/off activate the scene-off look
// <prefix>/shade/<c>/<act> drive shade column <c> (open|close|preset|raise|lower|stop)
// <prefix>/lock/zone i zone lock (0 off, 1 on)
// <prefix>/lock/scene i scene lock (0 off, 1 on)
// <prefix>/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/<column>/<action>".
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/<n>/..." 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,<id>,<component>,<action>,<params...>
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,<id>,1,<level%>
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,<id>,3,<state>
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:
// <prefix>/monitor/<family>/<fields-except-last> 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
}
}