diff --git a/cmd/cli/command/app/run.go b/cmd/cli/command/app/run.go index 1296377..59e9f11 100644 --- a/cmd/cli/command/app/run.go +++ b/cmd/cli/command/app/run.go @@ -8,6 +8,7 @@ import ( "forge.cadoles.com/arcad/edge/pkg/bus/memory" appHTTP "forge.cadoles.com/arcad/edge/pkg/http" "forge.cadoles.com/arcad/edge/pkg/module" + "forge.cadoles.com/arcad/edge/pkg/module/cast" "forge.cadoles.com/arcad/edge/pkg/storage/sqlite" "gitlab.com/wpetit/goweb/logger" @@ -96,6 +97,7 @@ func RunCommand() *cli.Command { appHTTP.WithServerModules( module.ContextModuleFactory(), module.ConsoleModuleFactory(), + cast.CastModuleFactory(), module.LifecycleModuleFactory(bus), module.NetModuleFactory(bus), module.RPCModuleFactory(bus), diff --git a/doc/apps/server-api/README.md b/doc/apps/server-api/README.md index 524dcb2..febff10 100644 --- a/doc/apps/server-api/README.md +++ b/doc/apps/server-api/README.md @@ -12,7 +12,7 @@ Comme son nom l'indique, elle permet d'exécuter des opérations d'initialisatio ```js function onInit() { - + console.log("My app booted !") } ``` @@ -25,4 +25,5 @@ Listes des modules disponibles côté serveur. - [`net`](./net.md) - [`rpc`](./rpc.md) - [`store`](./store.md) -- [`blob`](./blob.md) \ No newline at end of file +- [`blob`](./blob.md) +- [`cast`](./cast.md) \ No newline at end of file diff --git a/doc/apps/server-api/cast.md b/doc/apps/server-api/cast.md new file mode 100644 index 0000000..3c24286 --- /dev/null +++ b/doc/apps/server-api/cast.md @@ -0,0 +1,38 @@ +# Module `cast` + +Ce module permet de communiquer avec des appareils de présentation de type [Chromecast](https://store.google.com/fr/product/chromecast_setup?hl=fr). + +## Méthodes + +### `cast.refreshDevices(timeout?: string = '30s'): Promise` + +Rafraichit la liste locale des appareils de présentation disponibles sur les réseaux locaux de la borne. + +L'appel à cette méthode rafraîchit également la liste mise en cache et renvoyée par `cast.getDevices()`. + +### `cast.getDevices(): []Device` + +Retourne la liste mise en cache des appareils de présentation disponibles sur les réseaux locaux de la borne. + +La liste est initialement vide. Un appel initial à `cast.refreshDevices()` est nécessaire afin de mettre à jour celle ci. + +### `cast.loadUrl(deviceUuid: string, url: string, timeout?: string = '30s'): Promise` + +Charge l'URL donnée sur l'appareil de présentation identifié par l'UUID `deviceUuid`. + +### `cast.stopCast(deviceUuid: string, timeout?: string = '30s'): Promise` + +Stoppe l'application courante sur l'appareil de présentation identifié par l'UUID `deviceUuid`. + +## Objets + +### `Device` + +```typescript +interface Device { + uuid: string // UUID de l'appareil + name: string // Nom de l'appareil + host: string // Adresse IPv4 de l'appareil + port: number // Port distant du service +} +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 8b32c0d..a2ac329 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,17 @@ go 1.19 require modernc.org/sqlite v1.20.4 +require ( + github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e // indirect + github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 // indirect + github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 // indirect + github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 // indirect +) + require ( cdr.dev/slog v1.4.0 // indirect github.com/alecthomas/chroma v0.7.0 // indirect + github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 601c998..63ba143 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYU github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692 h1:JW4WZlqyaNWUUahfr7MigeDW6jmtam5cTzzo1lwsFhE= +github.com/barnybug/go-cast v0.0.0-20201201064555-a87ccbc26692/go.mod h1:Au0ipPuCBA7zsOC61SnyrYetm8VT3vo1UJtwHeYke44= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -66,6 +68,7 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/davecgh/go-spew v1.0.1-0.20160907170601-6d212800a42e/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -105,6 +108,8 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e h1:eeyMpoxANuWNQ9O2auv4wXxJsrXzLUhdHaOmNWEGkRY= +github.com/gogo/protobuf v0.0.0-20161014173244-50d1bd39ce4e/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -172,9 +177,13 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 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/hashicorp/go.net v0.0.0-20151006203346-104dcad90073 h1:9dodOMuH6u7LvPEkVydBv6KTHdm+SqsHOxHTzRW+1+w= +github.com/hashicorp/go.net v0.0.0-20151006203346-104dcad90073/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8 h1:yupxZNIxm5U8Tfb8g65irIuHkgF8c4koHC7daPSyMTE= +github.com/hashicorp/mdns v0.0.0-20151206042412-9d85cf22f9f8/go.mod h1:aa76Av3qgPeIQp9Y3qIkTBPieQYNkQ13Kxe7pze9Wb0= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE= @@ -202,6 +211,8 @@ github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGe github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8 h1:ALvJ9V8nNf04PFHMR2sot56N/pjrx5LzZGvUlnhdiCE= +github.com/miekg/dns v0.0.0-20161006100029-fc4e1e2843d8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -217,6 +228,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= @@ -229,11 +241,13 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.1.5-0.20160925220609-976c720a22c8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli/v2 v2.24.3 h1:7Q1w8VN8yE0MJEHP06bv89PjYsN4IHWED2s1v/Zlfm0= github.com/urfave/cli/v2 v2.24.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= @@ -299,6 +313,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20161013035702-8b4af36cd21a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/app/server.go b/pkg/app/server.go index 558156c..78708ee 100644 --- a/pkg/app/server.go +++ b/pkg/app/server.go @@ -145,7 +145,7 @@ func (s *Server) Stop() { func (s *Server) initModules(factories ...ServerModuleFactory) { runtime := goja.New() - runtime.SetFieldNameMapper(goja.UncapFieldNameMapper()) + runtime.SetFieldNameMapper(goja.TagFieldNameMapper("goja", true)) runtime.SetRandSource(createRandomSource()) modules := make([]ServerModule, 0, len(factories)) diff --git a/pkg/module/cast/cast.go b/pkg/module/cast/cast.go new file mode 100644 index 0000000..89e9798 --- /dev/null +++ b/pkg/module/cast/cast.go @@ -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 +} diff --git a/pkg/module/cast/cast_test.go b/pkg/module/cast/cast_test.go new file mode 100644 index 0000000..a11ff56 --- /dev/null +++ b/pkg/module/cast/cast_test.go @@ -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)) + } +} diff --git a/pkg/module/cast/error.go b/pkg/module/cast/error.go new file mode 100644 index 0000000..99dac2b --- /dev/null +++ b/pkg/module/cast/error.go @@ -0,0 +1,5 @@ +package cast + +import "errors" + +var ErrDeviceNotFound = errors.New("device not found") diff --git a/pkg/module/cast/module.go b/pkg/module/cast/module.go new file mode 100644 index 0000000..a23c4f1 --- /dev/null +++ b/pkg/module/cast/module.go @@ -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), + } + } +} diff --git a/pkg/module/cast/module_test.go b/pkg/module/cast/module_test.go new file mode 100644 index 0000000..5e6f41a --- /dev/null +++ b/pkg/module/cast/module_test.go @@ -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()) +} diff --git a/pkg/module/cast/testdata/cast.js b/pkg/module/cast/testdata/cast.js new file mode 100644 index 0000000..7a12193 --- /dev/null +++ b/pkg/module/cast/testdata/cast.js @@ -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) + }) diff --git a/pkg/module/cast/testdata/refresh_devices.js b/pkg/module/cast/testdata/refresh_devices.js new file mode 100644 index 0000000..a4d85d4 --- /dev/null +++ b/pkg/module/cast/testdata/refresh_devices.js @@ -0,0 +1,6 @@ +function refreshDevices() { + return cast.refreshDevices('5s') + .then(function(devices) { + return devices + }) +} \ No newline at end of file