diff --git a/buttplug-generated.go b/buttplug-generated.go index aa429f2..85102fe 100644 --- a/buttplug-generated.go +++ b/buttplug-generated.go @@ -586,48 +586,48 @@ type StepCount []int // Attributes for device messages. type GenericMessageAttributes struct { // Number of features on device. - FeatureCount FeatureCount `json:"FeatureCount,omitempty"` + FeatureCount *FeatureCount `json:"FeatureCount,omitempty"` // Specifies granularity of each feature on the device. - StepCount []int `json:"StepCount,omitempty"` + StepCount *[]int `json:"StepCount,omitempty"` } // Attributes for raw device messages. type RawMessageAttributes struct { - Endpoints []string `json:"Endpoints,omitempty"` + Endpoints *[]string `json:"Endpoints,omitempty"` } // A list of the messages a device will accept on this server implementation. type DeviceMessagesEx struct { // Attributes for device message that have no attributes. - StopDeviceCmd NullMessageAttributes `json:"StopDeviceCmd,omitempty"` + StopDeviceCmd *NullMessageAttributes `json:"StopDeviceCmd,omitempty"` // Attributes for device messages. - VibrateCmd GenericMessageAttributes `json:"VibrateCmd,omitempty"` + VibrateCmd *GenericMessageAttributes `json:"VibrateCmd,omitempty"` // Attributes for device messages. - LinearCmd GenericMessageAttributes `json:"LinearCmd,omitempty"` + LinearCmd *GenericMessageAttributes `json:"LinearCmd,omitempty"` // Attributes for device messages. - RotateCmd GenericMessageAttributes `json:"RotateCmd,omitempty"` + RotateCmd *GenericMessageAttributes `json:"RotateCmd,omitempty"` // Attributes for device message that have no attributes. - LovenseCmd NullMessageAttributes `json:"LovenseCmd,omitempty"` + LovenseCmd *NullMessageAttributes `json:"LovenseCmd,omitempty"` // Attributes for device message that have no attributes. - VorzeA10CycloneCmd NullMessageAttributes `json:"VorzeA10CycloneCmd,omitempty"` + VorzeA10CycloneCmd *NullMessageAttributes `json:"VorzeA10CycloneCmd,omitempty"` // Attributes for device message that have no attributes. - KiirooCmd NullMessageAttributes `json:"KiirooCmd,omitempty"` + KiirooCmd *NullMessageAttributes `json:"KiirooCmd,omitempty"` // Attributes for device message that have no attributes. - SingleMotorVibrateCmd NullMessageAttributes `json:"SingleMotorVibrateCmd,omitempty"` + SingleMotorVibrateCmd *NullMessageAttributes `json:"SingleMotorVibrateCmd,omitempty"` // Attributes for device message that have no attributes. - FleshlightLaunchFW12Cmd NullMessageAttributes `json:"FleshlightLaunchFW12Cmd,omitempty"` + FleshlightLaunchFW12Cmd *NullMessageAttributes `json:"FleshlightLaunchFW12Cmd,omitempty"` // Attributes for device message that have no attributes. - BatteryLevelCmd NullMessageAttributes `json:"BatteryLevelCmd,omitempty"` + BatteryLevelCmd *NullMessageAttributes `json:"BatteryLevelCmd,omitempty"` // Attributes for device message that have no attributes. - RSSILevelCmd NullMessageAttributes `json:"RSSILevelCmd,omitempty"` + RSSILevelCmd *NullMessageAttributes `json:"RSSILevelCmd,omitempty"` // Attributes for raw device messages. - RawReadCmd RawMessageAttributes `json:"RawReadCmd,omitempty"` + RawReadCmd *RawMessageAttributes `json:"RawReadCmd,omitempty"` // Attributes for raw device messages. - RawWriteCmd RawMessageAttributes `json:"RawWriteCmd,omitempty"` + RawWriteCmd *RawMessageAttributes `json:"RawWriteCmd,omitempty"` // Attributes for raw device messages. - RawSubscribeCmd RawMessageAttributes `json:"RawSubscribeCmd,omitempty"` + RawSubscribeCmd *RawMessageAttributes `json:"RawSubscribeCmd,omitempty"` // Attributes for raw device messages. - RawUnsubscribeCmd RawMessageAttributes `json:"RawUnsubscribeCmd,omitempty"` + RawUnsubscribeCmd *RawMessageAttributes `json:"RawUnsubscribeCmd,omitempty"` } // List of all available devices known to the system. @@ -749,7 +749,7 @@ type RequestServerInfo struct { // Name of the client software. ClientName string `json:"ClientName"` // Message template version of the client software. - MessageVersion int `json:"MessageVersion,omitempty"` + MessageVersion *int `json:"MessageVersion,omitempty"` } // Server version information, in Major.Minor.Build format. @@ -761,11 +761,11 @@ type ServerInfo struct { // Message template version of the server software. MessageVersion int `json:"MessageVersion"` // Major version of server. - MajorVersion int `json:"MajorVersion,omitempty"` + MajorVersion *int `json:"MajorVersion,omitempty"` // Minor version of server. - MinorVersion int `json:"MinorVersion,omitempty"` + MinorVersion *int `json:"MinorVersion,omitempty"` // Build version of server. - BuildVersion int `json:"BuildVersion,omitempty"` + BuildVersion *int `json:"BuildVersion,omitempty"` // Maximum time (in milliseconds) the server will wait between ping messages // from client before shutting down. MaxPingTime int `json:"MaxPingTime"` diff --git a/buttplug.go b/buttplug.go index c45ed8f..fc16ada 100644 --- a/buttplug.go +++ b/buttplug.go @@ -39,12 +39,76 @@ const Version = 2 // NewRequestServerInfo creates a new RequestServerInfo with the current client // information. func NewRequestServerInfo() *RequestServerInfo { + v := new(int) + *v = Version return &RequestServerInfo{ ClientName: "go-buttplug", - MessageVersion: Version, + MessageVersion: v, } } +// Broadcaster is used for creating multiple event loops on the same Buttplug +// server. +type Broadcaster struct { + dst map[chan<- Message]struct{} + mut sync.Mutex + void bool +} + +// NewBroadcaster creates a new broadcaster. +func NewBroadcaster() *Broadcaster { + return &Broadcaster{ + dst: make(map[chan<- Message]struct{}), + } +} + +// Start starts the broadcaster. +func (b *Broadcaster) Start(src <-chan Message) { + b.mut.Lock() + if b.void { + panic("Start called on voided Broadcaster") + } + b.mut.Unlock() + + go func() { + for op := range src { + b.mut.Lock() + + for ch := range b.dst { + ch <- op + } + + b.mut.Unlock() + } + + b.mut.Lock() + b.void = true + + for ch := range b.dst { + close(ch) + } + + b.mut.Unlock() + }() +} + +// Subscribe subscribes the given channel +func (b *Broadcaster) Subscribe(ch chan<- Message) { + b.mut.Lock() + if b.void { + panic("Subscribe called on voided Broadcaster") + } + b.dst[ch] = struct{}{} + b.mut.Unlock() +} + +// Listen returns a newly subscribed Op channel. +func (b *Broadcaster) Listen() <-chan Message { + ch := make(chan Message, 1) + b.Subscribe(ch) + return ch +} + type command struct { msg Message reply chan Message @@ -422,8 +486,8 @@ func (w *Websocket) Send(ctx context.Context, msgs ...Message) { // Command sends a message over the websocket and waits for a reply. If the // caller calls this method after the websocket is closed, the function will // block forever, since a websocket cannot be started back up. The returned -// message is never nil, but it may be of type InternalError, which the function -// will unbox into the return type. +// message is never nil, but it may be of type InternalError or Error, which the +// function will unbox into the return error type. func (w *Websocket) Command(ctx context.Context, msg Message) (Message, error) { msg.SetMessageID(w.id.Next()) cmd := command{ @@ -444,10 +508,14 @@ func (w *Websocket) Command(ctx context.Context, msg Message) (Message, error) { err := errors.Wrap(ctx.Err(), "timed out waiting") return &InternalError{Err: err}, err case reply := <-cmd.reply: - if err, ok := reply.(*InternalError); ok { - return reply, err.Err + switch reply := reply.(type) { + case *InternalError: + return reply, reply.Err + case *Error: + return reply, reply + default: + return reply, nil } - return reply, nil } } diff --git a/cmd/buttplughttp/README.md b/cmd/buttplughttp/README.md new file mode 100644 index 0000000..dbf44dc --- /dev/null +++ b/cmd/buttplughttp/README.md @@ -0,0 +1,4 @@ +# buttplughttp + +An example application using the buttplug API that exposes a REST API to +control devices. diff --git a/cmd/buttplughttp/go.mod b/cmd/buttplughttp/go.mod new file mode 100644 index 0000000..9b43ea1 --- /dev/null +++ b/cmd/buttplughttp/go.mod @@ -0,0 +1,14 @@ +module github.com/diamondburned/go-buttplug/cmd/buttplughttp + +go 1.17 + +replace github.com/diamondburned/go-buttplug => ../../ + +require github.com/diamondburned/go-buttplug v0.0.0-00010101000000-000000000000 + +require ( + github.com/go-chi/chi v1.5.4 // indirect + github.com/gorilla/schema v1.2.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/pkg/errors v0.9.1 // indirect +) diff --git a/cmd/buttplughttp/go.sum b/cmd/buttplughttp/go.sum new file mode 100644 index 0000000..a34f18a --- /dev/null +++ b/cmd/buttplughttp/go.sum @@ -0,0 +1,15 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64= +github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/cmd/buttplughttp/main.go b/cmd/buttplughttp/main.go new file mode 100644 index 0000000..32a2970 --- /dev/null +++ b/cmd/buttplughttp/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + + "github.com/diamondburned/go-buttplug" + "github.com/diamondburned/go-buttplug/device" + "github.com/diamondburned/go-buttplug/intiface" + "github.com/pkg/errors" +) + +var ( + wsPort = 20000 + httpAddr = "localhost:8080" + intifaceCLI = "intiface-cli" +) + +func main() { + flag.IntVar(&wsPort, "ws-port", wsPort, "websocket port to start from") + flag.StringVar(&httpAddr, "http-addr", httpAddr, "http address to listen to") + flag.StringVar(&intifaceCLI, "exec", intifaceCLI, "command to execute if no --ws-addr") + flag.Parse() + + if err := run(); err != nil { + log.Fatalln(err) + } +} + +func run() error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + ws := intiface.NewWebsocket(wsPort, intifaceCLI) + broadcaster := buttplug.NewBroadcaster() + + manager := device.NewManager() + manager.Listen(broadcaster.Listen()) + + msgCh := broadcaster.Listen() + + // Start connecting and broadcasting messages at the same time. + broadcaster.Start(ws.Open(ctx)) + + httpErr := make(chan error) + go func() { + server := newServer(ws.Websocket, manager) + httpErr <- serveHTTP(ctx, httpAddr, server) + }() + + for { + select { + case <-ctx.Done(): + return nil + case msg := <-msgCh: + switch msg := msg.(type) { + case *buttplug.ServerInfo: + // Server is ready. Start scanning and ask for the list of + // devices. The device manager will pick up the device messages + // for us. + ws.Send(ctx, + &buttplug.StartScanning{}, + &buttplug.RequestDeviceList{}, + ) + case *buttplug.DeviceList: + for _, device := range msg.Devices { + log.Printf("listed device %s (index %d)", device.DeviceName, device.DeviceIndex) + } + case *buttplug.DeviceAdded: + log.Printf("added device %s (index %d)", msg.DeviceName, msg.DeviceIndex) + case *buttplug.DeviceRemoved: + log.Println("removed device", msg.DeviceIndex) + case error: + log.Println("buttplug error:", msg) + } + case err := <-httpErr: + return errors.Wrap(err, "HTTP error") + } + } +} + +func serveHTTP(ctx context.Context, addr string, h http.Handler) error { + server := http.Server{ + Addr: addr, + Handler: h, + } + + go func() { + <-ctx.Done() + server.Shutdown(ctx) + }() + + log.Print("starting HTTP server at http://", addr) + + if err := server.ListenAndServe(); err != nil { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } + return nil +} diff --git a/cmd/buttplughttp/server.go b/cmd/buttplughttp/server.go new file mode 100644 index 0000000..d1fd1f1 --- /dev/null +++ b/cmd/buttplughttp/server.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/diamondburned/go-buttplug" + "github.com/diamondburned/go-buttplug/device" + "github.com/go-chi/chi" + "github.com/gorilla/schema" +) + +type server struct { + *chi.Mux + conn *buttplug.Websocket + manager *device.Manager +} + +func newServer(w *buttplug.Websocket, m *device.Manager) http.Handler { + s := &server{ + Mux: chi.NewMux(), + conn: w, + manager: m, + } + + s.Get("/devices", s.listDevices) + + s.Route("/device/{id:\\d+}", func(r chi.Router) { + r.Get("/", s.device) + r.Get("/battery", s.deviceBattery) + r.Get("/rssi", s.deviceRSSI) + + r.Group(func(r chi.Router) { + r.Use(needForm) + r.Post("/vibrate", s.deviceVibrate) + }) + }) + + return s +} + +// controllerFromRequest gets the device's controller from the request. It +// writes the error directly into the given response writer and returns nil if +// the device cannot be found. +func (s *server) controllerFromRequest(w http.ResponseWriter, r *http.Request) *device.Controller { + id, err := parseDeviceID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return nil + } + + ctrl := s.manager.Controller(s.conn, id) + if ctrl == nil { + writeError(w, http.StatusNotFound, errors.New("device not found")) + return nil + } + + return ctrl +} + +func (s *server) listDevices(w http.ResponseWriter, r *http.Request) { + devices := s.manager.Devices() + json.NewEncoder(w).Encode(devices) +} + +func (s *server) device(w http.ResponseWriter, r *http.Request) { + ctrl := s.controllerFromRequest(w, r) + if ctrl == nil { + return + } + + json.NewEncoder(w).Encode(ctrl.Device) +} + +func (s *server) deviceBattery(w http.ResponseWriter, r *http.Request) { + ctrl := s.controllerFromRequest(w, r) + if ctrl == nil { + return + } + + b, err := ctrl.Battery() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + type response struct { + BatteryLevel float64 + } + + json.NewEncoder(w).Encode(response{ + BatteryLevel: b, + }) +} + +func (s *server) deviceRSSI(w http.ResponseWriter, r *http.Request) { + ctrl := s.controllerFromRequest(w, r) + if ctrl == nil { + return + } + + f, err := ctrl.RSSILevel() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + type response struct { + RSSILevel float64 + } + + json.NewEncoder(w).Encode(response{ + RSSILevel: f, + }) +} + +var formDecoder = schema.NewDecoder() + +func (s *server) deviceVibrate(w http.ResponseWriter, r *http.Request) { + ctrl := s.controllerFromRequest(w, r) + if ctrl == nil { + return + } + + var form struct { + Motor int `json:"motor"` // default 0 + Strength float64 `json:"strength,required"` + } + + if err := formDecoder.Decode(&form, r.Form); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + err := ctrl.Vibrate(map[int]float64{ + form.Motor: form.Strength, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } +} + +func parseDeviceID(r *http.Request) (buttplug.DeviceIndex, error) { + str := chi.URLParam(r, "id") + i, err := strconv.Atoi(str) + return buttplug.DeviceIndex(i), err +} + +func needForm(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + next.ServeHTTP(w, r) + }) +} + +type jsonError struct { + Error string +} + +func writeError(w http.ResponseWriter, code int, err error) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(jsonError{ + Error: err.Error(), + }) +} diff --git a/device/device.go b/device/device.go new file mode 100644 index 0000000..f8b1666 --- /dev/null +++ b/device/device.go @@ -0,0 +1,266 @@ +// Package device contains abstractions to handle devices. +package device + +import ( + "context" + "fmt" + "time" + + "github.com/diamondburned/go-buttplug" +) + +// DeviceMessages is a type that holds the supported message types for a device. +type DeviceMessages map[buttplug.MessageType]buttplug.GenericMessageAttributes + +func convertDeviceMessagesEx(ex *buttplug.DeviceMessagesEx) DeviceMessages { + if ex == nil { + return nil + } + + msgs := DeviceMessages{} + + if ex.VibrateCmd != nil { + msgs[buttplug.VibrateCmdMessage] = *ex.VibrateCmd + } + if ex.LinearCmd != nil { + msgs[buttplug.LinearCmdMessage] = *ex.LinearCmd + } + if ex.RotateCmd != nil { + msgs[buttplug.RotateCmdMessage] = *ex.RotateCmd + } + + return msgs +} + +// Device describes a single device. +type Device struct { + // Name is the device name. + Name buttplug.DeviceName + // Index identifies the device. + Index buttplug.DeviceIndex + // Messages holds the message types that the device will accept and the + // features that it supports. + Messages DeviceMessages +} + +// ButtplugConnection describes a connection to the Buttplug (Intiface) +// controller. A *buttplug.Websocket will satisfy this. +type ButtplugConnection interface { + Command(ctx context.Context, msg buttplug.Message) (buttplug.Message, error) + Send(ctx context.Context, msgs ...buttplug.Message) +} + +var _ ButtplugConnection = (*buttplug.Websocket)(nil) + +// Controller binds a device and a websocket together to provide +// abstraction methods for controlling it. +type Controller struct { + // Device is the device that this controller belongs to. + Device + + conn ButtplugConnection + ctx context.Context + + async bool +} + +// NewController creates a new device controller. +func NewController(conn ButtplugConnection, device Device) *Controller { + return &Controller{ + Device: device, + conn: conn, + ctx: context.Background(), + } +} + +// WithAsync returns a new Controller that is fully asynchronous. When that's +// the case, Controller will send commands asynchronously whenever possible. The +// return types for command methods will always be a zero-value. Methods that +// behave differently when async will be explicitly documented. +func (c *Controller) WithAsync() *Controller { + copy := *c + copy.async = true + return © +} + +// WithContext returns a new Controller that internally uses the given +// context. +func (c *Controller) WithContext(ctx context.Context) *Controller { + copy := *c + copy.ctx = ctx + return © +} + +// Battery queries for the battery level. The returned number is between 0 and +// 1. +func (c *Controller) Battery() (float64, error) { + m, err := c.conn.Command(c.ctx, &buttplug.BatteryLevelCmd{DeviceIndex: c.Index}) + if err != nil { + return 0, err + } + + ev, ok := m.(*buttplug.BatteryLevelReading) + if !ok { + return 0, fmt.Errorf("unexpected message type %T", m) + } + + return ev.BatteryLevel, nil +} + +// RSSILevel gets the received signal strength indication level, expressed as dB +// gain, typically like [-100:0], where -100 is the number returned. +func (c *Controller) RSSILevel() (float64, error) { + m, err := c.conn.Command(c.ctx, &buttplug.RSSILevelCmd{DeviceIndex: c.Index}) + if err != nil { + return 0, err + } + + ev, ok := m.(*buttplug.RSSILevelReading) + if !ok { + return 0, fmt.Errorf("unexpected message type %T", m) + } + + return ev.RSSILevel, nil +} + +// Stop asks the server to stop the device. Stop ca be asynchronous. +func (c *Controller) Stop() error { + return c.sendAsyncable(&buttplug.StopDeviceCmd{DeviceIndex: c.Index}) +} + +// deviceSpeed is kept equal to VibrateCmd's. +type deviceSpeed = struct { + Index int `json:"Index"` + Speed float64 `json:"Speed"` +} + +func newDeviceSpeeds(speeds map[int]float64) []deviceSpeed { + deviceSpeeds := make([]deviceSpeed, 0, len(speeds)) + for i, speed := range speeds { + deviceSpeeds = append(deviceSpeeds, deviceSpeed{ + Index: i, + Speed: speed, + }) + } + return deviceSpeeds +} + +// VibrationMotors returns the number of vibration motors for the device. 0 is +// returned if the device doesn't support vibration. +func (c *Controller) VibrationMotors() int { + attrs, ok := c.Messages[buttplug.VibrateCmdMessage] + if !ok { + return 0 + } + + if attrs.FeatureCount != nil { + return int(*attrs.FeatureCount) + } + + return 0 +} + +// VibrationSteps returns the number of vibration steps that the device can +// support. The returned list has the length of the number of motors, with each +// item being the step count of each motor. Nil is returned if the device +// doesn't support vibration or if the server doesn't have this information. +func (c *Controller) VibrationSteps() []int { + attrs, ok := c.Messages[buttplug.VibrateCmdMessage] + if !ok { + return nil + } + + if attrs.StepCount != nil { + return *attrs.StepCount + } + + return nil +} + +// Vibrate asks the server to start vibrating. Vibrate can be asynchronous. +func (c *Controller) Vibrate(motorSpeeds map[int]float64) error { + return c.sendAsyncable(&buttplug.VibrateCmd{ + DeviceIndex: c.Index, + Speeds: newDeviceSpeeds(motorSpeeds), + }) +} + +// Vector describes the linear movement of a motor. +type Vector struct { + // Duration is the movement time. + Duration time.Duration + // Position is the target position ranging from 0.0 to 1.0. + Position float64 +} + +type linearVector = struct { + Index int `json:"Index"` + Duration float64 `json:"Duration"` + Position float64 `json:"Position"` +} + +func newLinearVectors(vectors map[int]Vector) []linearVector { + linearVectors := make([]linearVector, 0, len(vectors)) + for i, vector := range vectors { + linearVectors = append(linearVectors, linearVector{ + Index: i, + Duration: float64(vector.Duration) / float64(time.Millisecond), + Position: vector.Position, + }) + } + return linearVectors +} + +// Linear asks the server to linearly move the device over a certain amount of +// time. Linear can be asynchronous. +func (c *Controller) Linear(vectors map[int]Vector) error { + return c.sendAsyncable(&buttplug.LinearCmd{ + DeviceIndex: c.Index, + Vectors: newLinearVectors(vectors), + }) +} + +// Rotation describes a rotation that rotating motor does. +type Rotation struct { + // Speed is the rotation speed. + Speed float64 + // Clockwise is the direction of rotation. + Clockwise bool +} + +type rotation = struct { + Index int `json:"Index"` + Speed float64 `json:"Speed"` + Clockwise bool `json:"Clockwise"` +} + +func newRotations(rotations map[int]Rotation) []rotation { + rots := make([]rotation, 0, len(rotations)) + for i, rot := range rotations { + rots = append(rots, rotation{ + Index: i, + Speed: rot.Speed, + Clockwise: rot.Clockwise, + }) + } + return rots +} + +// Rotate asks the server to rotate some or all of the device's motors. Rotate +// can be asynchronous. +func (c *Controller) Rotate(rotations map[int]Rotation) error { + return c.sendAsyncable(&buttplug.RotateCmd{ + DeviceIndex: c.Index, + Rotations: newRotations(rotations), + }) +} + +func (c *Controller) sendAsyncable(cmd buttplug.Message) error { + if c.async { + c.conn.Send(c.ctx, cmd) + return nil + } else { + _, err := c.conn.Command(c.ctx, cmd) + return err + } +} diff --git a/device/manager.go b/device/manager.go new file mode 100644 index 0000000..5292b95 --- /dev/null +++ b/device/manager.go @@ -0,0 +1,119 @@ +package device + +import ( + "encoding/json" + "sort" + "sync" + + "github.com/diamondburned/go-buttplug" +) + +// Manager holds an internal state of devices and does its best to keep it +// updated. +type Manager struct { + mutex sync.RWMutex + devices map[buttplug.DeviceIndex]Device + working sync.WaitGroup +} + +// NewManager creates a new device manager. +func NewManager() *Manager { + return &Manager{} +} + +// Devices returns the list of known devices. The list returned is sorted. +func (m *Manager) Devices() []Device { + m.mutex.RLock() + devices := make([]Device, 0, len(m.devices)) + for _, device := range m.devices { + devices = append(devices, device) + } + m.mutex.RUnlock() + + sort.Slice(devices, func(i, j int) bool { + return devices[i].Index < devices[j].Index + }) + + return devices +} + +// Controller returns a new device controller for the given device index. If the +// device is not found, then nil is returned. +func (m *Manager) Controller(conn ButtplugConnection, ix buttplug.DeviceIndex) *Controller { + m.mutex.RLock() + device, ok := m.devices[ix] + m.mutex.RUnlock() + + if !ok { + return nil + } + + return NewController(conn, device) +} + +// Listen listens to the given channel asynchronously. The listening routine +// stops when the channel closes. +func (m *Manager) Listen(ch <-chan buttplug.Message) { + m.working.Add(1) + go m.listen(ch) +} + +func (m *Manager) listen(ch <-chan buttplug.Message) { + defer m.working.Done() + + for ev := range ch { + m.onMessage(ev) + } +} + +func (m *Manager) onMessage(ev buttplug.Message) { + switch ev := ev.(type) { + case *buttplug.DeviceAdded: + m.mutex.Lock() + defer m.mutex.Unlock() + + m.addDevice(*ev) + + case *buttplug.DeviceRemoved: + m.mutex.Lock() + defer m.mutex.Unlock() + + case *buttplug.DeviceList: + m.mutex.Lock() + defer m.mutex.Unlock() + + m.devices = map[buttplug.DeviceIndex]Device{} + for _, device := range ev.Devices { + m.addDevice(buttplug.DeviceAdded{ + DeviceName: device.DeviceName, + DeviceIndex: device.DeviceIndex, + DeviceMessages: device.DeviceMessages, + }) + } + } +} + +func (m *Manager) addDevice(device buttplug.DeviceAdded) { + var msgs DeviceMessages + if device.DeviceMessages != nil { + var ex *buttplug.DeviceMessagesEx + if err := json.Unmarshal(device.DeviceMessages, &ex); err == nil { + msgs = convertDeviceMessagesEx(ex) + } + } + + m.devices[device.DeviceIndex] = Device{ + Name: device.DeviceName, + Index: device.DeviceIndex, + Messages: msgs, + } +} + +func (m *Manager) removeDevice(index buttplug.DeviceIndex) { + delete(m.devices, index) +} + +// Wait waits until all the background goroutines of Manager exits. +func (m *Manager) Wait() { + m.working.Wait() +} diff --git a/internal/buttplugschema/objectTmpl.tmpl b/internal/buttplugschema/objectTmpl.tmpl index 3b5fe45..e9fb2b7 100644 --- a/internal/buttplugschema/objectTmpl.tmpl +++ b/internal/buttplugschema/objectTmpl.tmpl @@ -2,9 +2,9 @@ struct { {{ range .Fields -}} {{ if .Description -}} {{ Comment .Description 1 }} - {{ .GoName }} {{ .GoType }} `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` + {{ .GoName }} {{ if not .Required }}*{{ end }}{{ .GoType }} `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` {{ else -}} - {{ .GoName }} {{ .GoType }} `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` + {{ .GoName }} {{ if not .Required }}*{{ end }}{{ .GoType }} `json:"{{.JSONName}}{{if not .Required}},omitempty{{end}}"` {{ end -}} {{ end -}} }