371 lines
11 KiB
Go
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
|
|
}
|
|
}
|