feat(module): generalizing casting process
All checks were successful
arcad/edge/pipeline/head This commit looks good

This commit is contained in:
2024-01-12 12:11:41 +01:00
parent 776dbba5b0
commit 335b34625b
22 changed files with 729 additions and 690 deletions

View File

@ -0,0 +1,78 @@
package chromecast
import (
"context"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
gcast "github.com/barnybug/go-cast"
"github.com/pkg/errors"
)
type Client struct {
client *gcast.Client
}
// Close implements cast.Client.
func (c *Client) Close() error {
c.client.Close()
return nil
}
// Load implements cast.Client.
func (c *Client) Load(ctx context.Context, url string) error {
controller, err := c.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
}
// Status implements cast.Client.
func (c *Client) Status(ctx context.Context) (cast.DeviceStatus, error) {
ctrlStatus, err := c.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
}
// Unload implements cast.Client.
func (c *Client) Unload(ctx context.Context) error {
if _, err := c.client.Receiver().QuitApp(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
var _ cast.Client = &Client{}
// False positive workaround.
func isLoadURLContextExceeded(err error) bool {
return err.Error() == "Failed to send load command: context deadline exceeded"
}

View File

@ -0,0 +1,43 @@
package chromecast
import (
"net"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
)
const DeviceTypeChromecast cast.DeviceType = "chromecast"
type Device struct {
UUID string `goja:"uuid" json:"uuid"`
Host net.IP `goja:"host" json:"host"`
Port int `goja:"port" json:"port"`
Name string `goja:"name" json:"name"`
}
// DeviceHost implements cast.Device.
func (d *Device) DeviceHost() net.IP {
return d.Host
}
// DeviceID implements cast.Device.
func (d *Device) DeviceID() string {
return d.UUID
}
// DeviceName implements cast.Device.
func (d *Device) DeviceName() string {
return d.Name
}
// DevicePort implements cast.Device.
func (d *Device) DevicePort() int {
return d.Port
}
// DeviceType implements cast.Device.
func (d *Device) DeviceType() cast.DeviceType {
return DeviceTypeChromecast
}
var _ cast.Device = &Device{}

View File

@ -0,0 +1,254 @@
package chromecast
import (
"context"
"net"
"regexp"
"strconv"
"strings"
"sync"
"time"
"gitlab.com/wpetit/goweb/logger"
"github.com/barnybug/go-cast"
"github.com/barnybug/go-cast/log"
"github.com/hashicorp/mdns"
"github.com/pkg/errors"
)
const (
serviceDiscoveryPollingInterval time.Duration = 500 * time.Millisecond
)
type Discovery struct {
found chan *cast.Client
entriesCh chan *mdns.ServiceEntry
stopPeriodic chan struct{}
}
func NewDiscovery(ctx context.Context) *Discovery {
d := &Discovery{
found: make(chan *cast.Client),
entriesCh: make(chan *mdns.ServiceEntry, 10),
}
go d.listener(ctx)
return d
}
func (d *Discovery) Run(ctx context.Context, interval time.Duration) error {
ifaces, err := findMulticastInterfaces(ctx)
if err != nil {
return errors.WithStack(err)
}
var wg sync.WaitGroup
for _, iface := range ifaces {
hasIPv4, hasIPv6, err := retrieveSupportedProtocols(iface)
if err != nil {
return errors.WithStack(err)
}
if !hasIPv4 && !hasIPv6 {
continue
}
if err := d.queryIface(iface, !hasIPv4, !hasIPv6); err != nil {
return errors.WithStack(err)
}
pollCtx, cancel := context.WithCancel(ctx)
defer cancel()
wg.Add(1)
go func(ctx context.Context, iface net.Interface) {
defer wg.Done()
if err := d.pollInterface(ctx, iface, interval, !hasIPv4, !hasIPv6); err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return
}
logger.Error(
ctx, "could not poll interface",
logger.CapturedE(errors.WithStack(err)), logger.F("iface", iface.Name),
)
}
}(pollCtx, iface)
}
wg.Wait()
return nil
}
func (d *Discovery) queryIface(iface net.Interface, disableIPv4, disableIPv6 bool) error {
err := mdns.Query(&mdns.QueryParam{
Service: "_googlecast._tcp",
Domain: "local",
Timeout: 3 * time.Second,
Entries: d.entriesCh,
Interface: &iface,
DisableIPv6: disableIPv6,
DisableIPv4: disableIPv4,
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
func (d *Discovery) pollInterface(ctx context.Context, iface net.Interface, interval time.Duration, disableIPv4, disableIPv6 bool) error {
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
if err := d.queryIface(iface, disableIPv4, disableIPv6); err != nil {
return errors.WithStack(err)
}
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
}
}
func (d *Discovery) Stop() {
if d.stopPeriodic != nil {
close(d.stopPeriodic)
d.stopPeriodic = nil
}
}
func (d *Discovery) Found() chan *cast.Client {
return d.found
}
func (d *Discovery) listener(ctx context.Context) {
for entry := range d.entriesCh {
name := strings.Split(entry.Name, "._googlecast")
// Skip everything that doesn't have googlecast in the fdqn
if len(name) < 2 {
continue
}
log.Printf("New entry: %#v\n", entry)
client := cast.NewClient(entry.AddrV4, entry.Port)
info := decodeTxtRecord(entry.Info)
client.SetName(info["fn"])
client.SetInfo(info)
select {
case d.found <- client:
case <-time.After(time.Second):
case <-ctx.Done():
break
}
}
}
func decodeDnsEntry(text string) string {
text = strings.Replace(text, `\.`, ".", -1)
text = strings.Replace(text, `\ `, " ", -1)
re := regexp.MustCompile(`([\\][0-9][0-9][0-9])`)
text = re.ReplaceAllStringFunc(text, func(source string) string {
i, err := strconv.Atoi(source[1:])
if err != nil {
return ""
}
return string([]byte{byte(i)})
})
return text
}
func decodeTxtRecord(txt string) map[string]string {
m := make(map[string]string)
s := strings.Split(txt, "|")
for _, v := range s {
s := strings.Split(v, "=")
if len(s) == 2 {
m[s[0]] = s[1]
}
}
return m
}
func isIPv4(ip net.IP) bool {
return strings.Count(ip.String(), ":") < 2
}
func isIPv6(ip net.IP) bool {
return strings.Count(ip.String(), ":") >= 2
}
func findMulticastInterfaces(ctx context.Context) ([]net.Interface, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, nil
}
multicastIfaces := make([]net.Interface, 0)
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback == net.FlagLoopback {
continue
}
if iface.Flags&net.FlagRunning != net.FlagRunning {
continue
}
if iface.Flags&net.FlagMulticast != net.FlagMulticast {
continue
}
multicastIfaces = append(multicastIfaces, iface)
}
return multicastIfaces, nil
}
func retrieveSupportedProtocols(iface net.Interface) (bool, bool, error) {
adresses, err := iface.Addrs()
if err != nil {
return false, false, errors.WithStack(err)
}
hasIPv4 := false
hasIPv6 := false
for _, addr := range adresses {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return false, false, errors.WithStack(err)
}
if isIPv4(ip) {
hasIPv4 = true
}
if isIPv6(ip) {
hasIPv6 = true
}
if hasIPv4 && hasIPv6 {
return hasIPv4, hasIPv6, nil
}
}
return hasIPv4, hasIPv6, nil
}

View File

@ -0,0 +1,110 @@
package chromecast
import (
"context"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
gcast "github.com/barnybug/go-cast"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func init() {
cast.Register(DeviceTypeChromecast, &Service{})
}
type Service struct {
}
// Find implements cast.Service.
func (s *Service) Find(ctx context.Context, deviceID string) (cast.Device, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
results, err := s.scan(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
for dev := range results {
if dev.DeviceID() != deviceID {
continue
}
return dev, nil
}
return nil, errors.WithStack(cast.ErrDeviceNotFound)
}
// NewClient implements cast.Service.
func (*Service) NewClient(ctx context.Context, device cast.Device) (cast.Client, error) {
client := gcast.NewClient(device.DeviceHost(), device.DevicePort())
if err := client.Connect(ctx); err != nil {
return nil, errors.WithStack(err)
}
return &Client{client}, nil
}
// Scan implements cast.Service.
func (s *Service) Scan(ctx context.Context) ([]cast.Device, error) {
results, err := s.scan(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
devices := make([]cast.Device, 0)
for dev := range results {
devices = append(devices, dev)
}
return devices, nil
}
func (*Service) scan(ctx context.Context) (chan cast.Device, error) {
discovery := NewDiscovery(ctx)
defer discovery.Stop()
go func() {
if err := discovery.Run(ctx, serviceDiscoveryPollingInterval); err != nil && !errors.Is(err, context.DeadlineExceeded) {
logger.Error(ctx, "could not run chromecast discovery", logger.CapturedE(errors.WithStack(err)))
}
}()
results := make(chan cast.Device)
go func() {
defer close(results)
found := make(map[string]struct{})
LOOP:
for {
select {
case c := <-discovery.Found():
dev := &Device{
Host: c.IP().To4(),
Port: c.Port(),
Name: c.Name(),
UUID: c.Uuid(),
}
if _, exists := found[dev.UUID]; !exists {
results <- dev
found[dev.UUID] = struct{}{}
}
case <-ctx.Done():
break LOOP
}
}
}()
return results, nil
}
var _ cast.Service = &Service{}

View File

@ -0,0 +1,31 @@
package chromecast
import "forge.cadoles.com/arcad/edge/pkg/module/cast"
type DeviceStatus struct {
CurrentApp DeviceStatusCurrentApp `goja:"currentApp" json:"currentApp"`
Volume DeviceStatusVolume `goja:"volume" json:"volume"`
}
// State implements cast.DeviceStatus.
func (s *DeviceStatus) State() string {
return s.CurrentApp.StatusText
}
// Title implements cast.DeviceStatus.
func (s *DeviceStatus) Title() string {
return s.CurrentApp.DisplayName
}
var _ cast.DeviceStatus = &DeviceStatus{}
type DeviceStatusCurrentApp struct {
ID string `goja:"id" json:"id"`
DisplayName string `goja:"displayName" json:"displayName"`
StatusText string `goja:"statusText" json:"statusText"`
}
type DeviceStatusVolume struct {
Level float64 `goja:"level" json:"level"`
Muted bool `goja:"muted" json:"muted"`
}