feat(server,module): add cast module
This commit is contained in:
209
pkg/module/cast/cast.go
Normal file
209
pkg/module/cast/cast.go
Normal file
@ -0,0 +1,209 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/barnybug/go-cast"
|
||||
"github.com/barnybug/go-cast/discovery"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
UUID string `goja:"uuid"`
|
||||
Host net.IP `goja:"host"`
|
||||
Port int `goja:"port"`
|
||||
Name string `goja:"name"`
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
CurrentApp DeviceStatusCurrentApp `goja:"currentApp"`
|
||||
Volume DeviceStatusVolume `goja:"volume"`
|
||||
}
|
||||
|
||||
type DeviceStatusCurrentApp struct {
|
||||
ID string `goja:"id"`
|
||||
DisplayName string `goja:"displayName"`
|
||||
StatusText string `goja:"statusText"`
|
||||
}
|
||||
|
||||
type DeviceStatusVolume struct {
|
||||
Level float64 `goja:"level"`
|
||||
Muted bool `goja:"muted"`
|
||||
}
|
||||
|
||||
const (
|
||||
serviceDiscoveryPollingInterval time.Duration = 2 * time.Second
|
||||
)
|
||||
|
||||
func getDeviceClientByUUID(ctx context.Context, uuid string) (*cast.Client, error) {
|
||||
device, err := findDeviceByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
client := cast.NewClient(device.Host, device.Port)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func findDeviceByUUID(ctx context.Context, uuid string) (*Device, error) {
|
||||
service := discovery.NewService(ctx)
|
||||
defer service.Stop()
|
||||
|
||||
go func() {
|
||||
if err := service.Run(ctx, serviceDiscoveryPollingInterval); err != nil {
|
||||
logger.Error(ctx, "error while running cast service discovery", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
LOOP:
|
||||
for {
|
||||
select {
|
||||
case c := <-service.Found():
|
||||
if c.Uuid() == uuid {
|
||||
return &Device{
|
||||
Host: c.IP().To4(),
|
||||
Port: c.Port(),
|
||||
Name: c.Name(),
|
||||
UUID: c.Uuid(),
|
||||
}, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break LOOP
|
||||
}
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(ErrDeviceNotFound)
|
||||
}
|
||||
|
||||
func findDevices(ctx context.Context) ([]*Device, error) {
|
||||
service := discovery.NewService(ctx)
|
||||
defer service.Stop()
|
||||
|
||||
go func() {
|
||||
if err := service.Run(ctx, serviceDiscoveryPollingInterval); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Error(ctx, "error while running cast service discovery", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
devices := make([]*Device, 0)
|
||||
found := make(map[string]struct{})
|
||||
|
||||
LOOP:
|
||||
for {
|
||||
select {
|
||||
case c := <-service.Found():
|
||||
if _, exists := found[c.Uuid()]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
devices = append(devices, &Device{
|
||||
Host: c.IP().To4(),
|
||||
Port: c.Port(),
|
||||
Name: c.Name(),
|
||||
UUID: c.Uuid(),
|
||||
})
|
||||
found[c.Uuid()] = struct{}{}
|
||||
|
||||
case <-ctx.Done():
|
||||
break LOOP
|
||||
}
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func loadURL(ctx context.Context, deviceUUID string, url string) error {
|
||||
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
controller, err := client.URL(ctx)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Ignore context.DeadlineExceeded errors. github.com/barnybug/go-cast bug ?
|
||||
if _, err := controller.LoadURL(ctx, url); err != nil && !isLoadURLContextExceeded(err) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// False positive workaround.
|
||||
func isLoadURLContextExceeded(err error) bool {
|
||||
return err.Error() == "Failed to send load command: context deadline exceeded"
|
||||
}
|
||||
|
||||
func stopCast(ctx context.Context, deviceUUID string) error {
|
||||
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if _, err := client.Receiver().QuitApp(ctx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStatus(ctx context.Context, deviceUUID string) (*DeviceStatus, error) {
|
||||
client, err := getDeviceClientByUUID(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctrlStatus, err := client.Receiver().GetStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
status := &DeviceStatus{
|
||||
CurrentApp: DeviceStatusCurrentApp{
|
||||
ID: "",
|
||||
DisplayName: "",
|
||||
StatusText: "",
|
||||
},
|
||||
Volume: DeviceStatusVolume{
|
||||
Level: *ctrlStatus.Volume.Level,
|
||||
Muted: *ctrlStatus.Volume.Muted,
|
||||
},
|
||||
}
|
||||
|
||||
if len(ctrlStatus.Applications) > 0 {
|
||||
status.CurrentApp.ID = *ctrlStatus.Applications[0].AppID
|
||||
status.CurrentApp.DisplayName = *ctrlStatus.Applications[0].DisplayName
|
||||
status.CurrentApp.StatusText = *ctrlStatus.Applications[0].StatusText
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
63
pkg/module/cast/cast_test.go
Normal file
63
pkg/module/cast/cast_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestCastLoadURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getenv("TEST_CAST_MODULE") != "yes" {
|
||||
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
devices, err := findDevices(ctx)
|
||||
if err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
if e, g := 1, len(devices); e != g {
|
||||
t.Fatalf("len(devices): expected '%v', got '%v'", e, g)
|
||||
}
|
||||
|
||||
dev := devices[0]
|
||||
|
||||
ctx, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
if err := loadURL(ctx, dev.UUID, "https://go.dev"); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
ctx, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel3()
|
||||
|
||||
status, err := getStatus(ctx, dev.UUID)
|
||||
if err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
spew.Dump(status)
|
||||
|
||||
ctx, cancel4 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel4()
|
||||
|
||||
if err := stopCast(ctx, dev.UUID); err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
}
|
5
pkg/module/cast/error.go
Normal file
5
pkg/module/cast/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package cast
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrDeviceNotFound = errors.New("device not found")
|
263
pkg/module/cast/module.go
Normal file
263
pkg/module/cast/module.go
Normal file
@ -0,0 +1,263 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
ctx context.Context
|
||||
server *app.Server
|
||||
mutex struct {
|
||||
devices sync.RWMutex
|
||||
refreshDevices sync.Mutex
|
||||
loadURL sync.Mutex
|
||||
quitApp sync.Mutex
|
||||
getStatus sync.Mutex
|
||||
}
|
||||
devices []*Device
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "cast"
|
||||
}
|
||||
|
||||
func (m *Module) Export(export *goja.Object) {
|
||||
if err := export.Set("refreshDevices", m.refreshDevices); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'refreshDevices' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("getDevices", m.getDevices); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'getDevices' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("loadUrl", m.loadUrl); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'loadUrl' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("stopCast", m.stopCast); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'stopCast' function"))
|
||||
}
|
||||
|
||||
if err := export.Set("getStatus", m.getStatus); err != nil {
|
||||
panic(errors.Wrap(err, "could not set 'getStatus' function"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) refreshDevices(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
rawTimeout := call.Argument(0).String()
|
||||
|
||||
timeout, err := m.parseTimeout(rawTimeout)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
promise := m.server.NewPromise()
|
||||
|
||||
go func() {
|
||||
m.mutex.refreshDevices.Lock()
|
||||
defer m.mutex.refreshDevices.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
devices, err := findDevices(ctx)
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error refreshing casting devices list", logger.E(errors.WithStack(err)))
|
||||
|
||||
promise.Reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
m.mutex.devices.Lock()
|
||||
m.devices = devices
|
||||
m.mutex.devices.Unlock()
|
||||
}
|
||||
|
||||
devicesCopy := m.getDevicesCopy(devices)
|
||||
promise.Resolve(devicesCopy)
|
||||
}()
|
||||
|
||||
return rt.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Module) getDevices(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
m.mutex.devices.RLock()
|
||||
defer m.mutex.devices.RUnlock()
|
||||
|
||||
devices := m.getDevicesCopy(m.devices)
|
||||
|
||||
return rt.ToValue(devices)
|
||||
}
|
||||
|
||||
func (m *Module) loadUrl(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber))
|
||||
}
|
||||
|
||||
deviceUUID := call.Argument(0).String()
|
||||
url := call.Argument(1).String()
|
||||
|
||||
rawTimeout := call.Argument(2).String()
|
||||
|
||||
timeout, err := m.parseTimeout(rawTimeout)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
promise := m.server.NewPromise()
|
||||
|
||||
go func() {
|
||||
m.mutex.loadURL.Lock()
|
||||
defer m.mutex.loadURL.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
err := loadURL(ctx, deviceUUID, url)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error while casting url", logger.E(err))
|
||||
|
||||
promise.Reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
promise.Resolve(nil)
|
||||
}()
|
||||
|
||||
return m.server.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Module) stopCast(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber))
|
||||
}
|
||||
|
||||
deviceUUID := call.Argument(0).String()
|
||||
rawTimeout := call.Argument(1).String()
|
||||
|
||||
timeout, err := m.parseTimeout(rawTimeout)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
promise := m.server.NewPromise()
|
||||
|
||||
go func() {
|
||||
m.mutex.quitApp.Lock()
|
||||
defer m.mutex.quitApp.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
err := stopCast(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error while quitting casting device app", logger.E(errors.WithStack(err)))
|
||||
|
||||
promise.Reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
promise.Resolve(nil)
|
||||
}()
|
||||
|
||||
return m.server.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Module) getStatus(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
panic(errors.WithStack(module.ErrUnexpectedArgumentsNumber))
|
||||
}
|
||||
|
||||
deviceUUID := call.Argument(0).String()
|
||||
rawTimeout := call.Argument(1).String()
|
||||
|
||||
timeout, err := m.parseTimeout(rawTimeout)
|
||||
if err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
|
||||
promise := m.server.NewPromise()
|
||||
|
||||
go func() {
|
||||
m.mutex.getStatus.Lock()
|
||||
defer m.mutex.getStatus.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := getStatus(ctx, deviceUUID)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "error while getting casting device status", logger.E(err))
|
||||
|
||||
promise.Reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
promise.Resolve(status)
|
||||
}()
|
||||
|
||||
return m.server.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Module) getDevicesCopy(devices []*Device) []Device {
|
||||
devicesCopy := make([]Device, 0, len(m.devices))
|
||||
|
||||
for _, d := range devices {
|
||||
devicesCopy = append(devicesCopy, Device{
|
||||
UUID: d.UUID,
|
||||
Name: d.Name,
|
||||
Host: d.Host,
|
||||
Port: d.Port,
|
||||
})
|
||||
}
|
||||
|
||||
return devicesCopy
|
||||
}
|
||||
|
||||
func (m *Module) parseTimeout(rawTimeout string) (time.Duration, error) {
|
||||
var (
|
||||
timeout time.Duration
|
||||
err error
|
||||
)
|
||||
|
||||
if rawTimeout == "undefined" {
|
||||
timeout = defaultTimeout
|
||||
} else {
|
||||
timeout, err = time.ParseDuration(rawTimeout)
|
||||
if err != nil {
|
||||
return defaultTimeout, errors.Wrapf(err, "invalid duration format '%s'", rawTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
func CastModuleFactory() app.ServerModuleFactory {
|
||||
return func(server *app.Server) app.ServerModule {
|
||||
return &Module{
|
||||
server: server,
|
||||
devices: make([]*Device, 0),
|
||||
}
|
||||
}
|
||||
}
|
95
pkg/module/cast/module_test.go
Normal file
95
pkg/module/cast/module_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
package cast
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"forge.cadoles.com/arcad/edge/pkg/app"
|
||||
"forge.cadoles.com/arcad/edge/pkg/module"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func TestCastModule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getenv("TEST_CAST_MODULE") != "yes" {
|
||||
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
server := app.NewServer(
|
||||
module.ConsoleModuleFactory(),
|
||||
CastModuleFactory(),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/cast.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Load("testdata/cast.js", string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
defer server.Stop()
|
||||
|
||||
time.Sleep(20 * time.Second)
|
||||
}
|
||||
|
||||
func TestCastModuleRefreshDevices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getenv("TEST_CAST_MODULE") != "yes" {
|
||||
t.Skip("Test skipped. Set environment variable TEST_CAST_MODULE=yes to run.")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.SetLevel(slog.LevelDebug)
|
||||
|
||||
server := app.NewServer(
|
||||
module.ConsoleModuleFactory(),
|
||||
CastModuleFactory(),
|
||||
)
|
||||
|
||||
data, err := ioutil.ReadFile("testdata/refresh_devices.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Load("testdata/refresh_devices.js", string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatalf("%+v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
defer server.Stop()
|
||||
|
||||
result, err := server.ExecFuncByName("refreshDevices")
|
||||
if err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
|
||||
promise, ok := server.IsPromise(result)
|
||||
if !ok {
|
||||
t.Fatal("expected promise")
|
||||
}
|
||||
|
||||
value := server.WaitForPromise(promise)
|
||||
|
||||
spew.Dump(value.Export())
|
||||
}
|
21
pkg/module/cast/testdata/cast.js
vendored
Normal file
21
pkg/module/cast/testdata/cast.js
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
cast.refreshDevices('5s')
|
||||
.then(function(devices) {
|
||||
console.log(devices)
|
||||
|
||||
if (devices === null) {
|
||||
throw new Error("devices should not be null");
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
throw new Error("devices.length should not be 0");
|
||||
}
|
||||
|
||||
return devices
|
||||
})
|
||||
.then(function(devices) {
|
||||
return cast.getStatus(devices[0].uuid)
|
||||
})
|
||||
.then(function(status) {
|
||||
console.log(status)
|
||||
})
|
6
pkg/module/cast/testdata/refresh_devices.js
vendored
Normal file
6
pkg/module/cast/testdata/refresh_devices.js
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
function refreshDevices() {
|
||||
return cast.refreshDevices('5s')
|
||||
.then(function(devices) {
|
||||
return devices
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user