// Copyright 2018 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) // // Model representing and methods for controlling a remote web display. package field import ( "fmt" "github.com/Team254/cheesy-arena-lite/websocket" "net/url" "reflect" "sort" "strconv" "strings" "sync" "time" ) const ( minDisplayId = 100 displayPurgeTtlMin = 30 ) type DisplayType int const ( InvalidDisplay DisplayType = iota PlaceholderDisplay AllianceStationDisplay AnnouncerDisplay AudienceDisplay FieldMonitorDisplay PitDisplay QueueingDisplay TwitchStreamDisplay ) var DisplayTypeNames = map[DisplayType]string{ PlaceholderDisplay: "Placeholder", AllianceStationDisplay: "Alliance Station", AnnouncerDisplay: "Announcer", AudienceDisplay: "Audience", FieldMonitorDisplay: "Field Monitor", PitDisplay: "Pit", QueueingDisplay: "Queueing", TwitchStreamDisplay: "Twitch Stream", } var displayTypePaths = map[DisplayType]string{ PlaceholderDisplay: "/display", AllianceStationDisplay: "/displays/alliance_station", AnnouncerDisplay: "/displays/announcer", AudienceDisplay: "/displays/audience", FieldMonitorDisplay: "/displays/field_monitor", PitDisplay: "/displays/pit", QueueingDisplay: "/displays/queueing", TwitchStreamDisplay: "/displays/twitch", } var displayRegistryMutex sync.Mutex type Display struct { DisplayConfiguration DisplayConfiguration IpAddress string ConnectionCount int Notifier *websocket.Notifier lastConnectedTime time.Time } type DisplayConfiguration struct { Id string Nickname string Type DisplayType Configuration map[string]string } // Parses the given display URL path and query string to extract the configuration. func DisplayFromUrl(path string, query map[string][]string) (*DisplayConfiguration, error) { if _, ok := query["displayId"]; !ok { return nil, fmt.Errorf("Display ID not present in request.") } var displayConfig DisplayConfiguration displayConfig.Id = query["displayId"][0] if nickname, ok := query["nickname"]; ok { displayConfig.Nickname, _ = url.QueryUnescape(nickname[0]) } // Determine type from the websocket connection URL. This way of doing it isn't super efficient, but it's not really // a concern since it should happen relatively infrequently. for displayType, displayPath := range displayTypePaths { if path == displayPath+"/websocket" { displayConfig.Type = displayType break } } if displayConfig.Type == InvalidDisplay { return nil, fmt.Errorf("Could not determine display type from path %s.", path) } // Put any remaining query parameters into the per-type configuration map. displayConfig.Configuration = make(map[string]string) for key, value := range query { if key != "displayId" && key != "nickname" { displayConfig.Configuration[key], _ = url.QueryUnescape(value[0]) } } return &displayConfig, nil } // Returns the URL string for the given display that includes all of its configuration parameters. func (display *Display) ToUrl() string { var builder strings.Builder builder.WriteString(displayTypePaths[display.DisplayConfiguration.Type]) builder.WriteString("?displayId=") builder.WriteString(url.QueryEscape(display.DisplayConfiguration.Id)) if display.DisplayConfiguration.Nickname != "" { builder.WriteString("&nickname=") builder.WriteString(url.QueryEscape(display.DisplayConfiguration.Nickname)) } // Sort the keys so that the URL generated is deterministic. var keys []string for key := range display.DisplayConfiguration.Configuration { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { builder.WriteString("&") builder.WriteString(url.QueryEscape(key)) builder.WriteString("=") builder.WriteString(url.QueryEscape(display.DisplayConfiguration.Configuration[key])) } return builder.String() } func (display *Display) generateDisplayConfigurationMessage() interface{} { return display.ToUrl() } // Returns an unused ID that can be used for a new display. func (arena *Arena) NextDisplayId() string { displayRegistryMutex.Lock() defer displayRegistryMutex.Unlock() // Loop until we get an ID that isn't already used. This is inefficient if there is a large number of displays, but // that should never be the case. candidateId := minDisplayId for { if _, ok := arena.Displays[strconv.Itoa(candidateId)]; !ok { return strconv.Itoa(candidateId) } candidateId++ } } // Creates or gets the given display in the arena registry and triggers a notification. func (arena *Arena) RegisterDisplay(displayConfig *DisplayConfiguration, ipAddress string) *Display { displayRegistryMutex.Lock() defer displayRegistryMutex.Unlock() display, ok := arena.Displays[displayConfig.Id] if ok && displayConfig.Type == PlaceholderDisplay { // Don't rewrite the registered configuration if the new one is a placeholder -- if it is reconnecting after a // restart, it should adopt the existing configuration. arena.Displays[displayConfig.Id].ConnectionCount++ arena.Displays[displayConfig.Id].IpAddress = ipAddress } else { if !ok { display = new(Display) display.Notifier = websocket.NewNotifier("displayConfiguration", display.generateDisplayConfigurationMessage) arena.Displays[displayConfig.Id] = display } display.DisplayConfiguration = *displayConfig display.IpAddress = ipAddress display.ConnectionCount += 1 display.lastConnectedTime = time.Now() display.Notifier.Notify() } arena.DisplayConfigurationNotifier.Notify() return display } // Updates the given display in the arena registry. Triggers a notification if the display configuration changed. func (arena *Arena) UpdateDisplay(displayConfig DisplayConfiguration) error { displayRegistryMutex.Lock() defer displayRegistryMutex.Unlock() display, ok := arena.Displays[displayConfig.Id] if !ok { return fmt.Errorf("Display %s doesn't exist.", displayConfig.Id) } if !reflect.DeepEqual(displayConfig, display.DisplayConfiguration) { display.DisplayConfiguration = displayConfig display.Notifier.Notify() arena.DisplayConfigurationNotifier.Notify() } return nil } // Marks the given display as having disconnected in the arena registry and triggers a notification. func (arena *Arena) MarkDisplayDisconnected(displayId string) { displayRegistryMutex.Lock() defer displayRegistryMutex.Unlock() if existingDisplay, ok := arena.Displays[displayId]; ok { if existingDisplay.ConnectionCount == 1 && existingDisplay.DisplayConfiguration.Type == PlaceholderDisplay && existingDisplay.DisplayConfiguration.Nickname == "" && len(existingDisplay.DisplayConfiguration.Configuration) == 0 { // If the display is an unconfigured placeholder, just remove it entirely to prevent clutter. delete(arena.Displays, existingDisplay.DisplayConfiguration.Id) } else { existingDisplay.ConnectionCount -= 1 } existingDisplay.lastConnectedTime = time.Now() arena.DisplayConfigurationNotifier.Notify() } } // Removes any displays from the list that haven't had any active connections for a while and don't have a nickname. func (arena *Arena) purgeDisconnectedDisplays() { displayRegistryMutex.Lock() defer displayRegistryMutex.Unlock() deleted := false for id, display := range arena.Displays { if display.ConnectionCount == 0 && display.DisplayConfiguration.Nickname == "" && time.Now().Sub(display.lastConnectedTime).Minutes() >= displayPurgeTtlMin { delete(arena.Displays, id) deleted = true } } if deleted { arena.DisplayConfigurationNotifier.Notify() } }