build 115 - кнопка в эфире будет загораться

This commit is contained in:
2026-08-26 12:59:31 +03:00
parent 077e9c496f
commit 2c9b0d4fcc
7 changed files with 357 additions and 154 deletions

View File

@@ -74,3 +74,11 @@ Fingerprint проекта рассчитывается по структуре
- Local vMix requests use keep-alive HTTP connections and a bounded parallel worker pool.
- Empty `Value` is forwarded so SetText can clear stale title fields.
- Fully backward compatible server fallback: older Agents continue to work in legacy mode.
## 1.5.0
- Real vMix Overlay 1-4 tally from the local XML API.
- Active overlays are resolved to Input number, GUID/key and title.
- Tally changes are sent to Hockey Server as `vmix.overlay_state`.
- This lets the web UI highlight the actual title currently on air, including titles prepared via `PreviewInput`.

Binary file not shown.

View File

@@ -27,10 +27,12 @@ import (
)
const (
agentVersion = "1.4.0"
protocolVersion = 1
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
heartbeatInterval = 8 * time.Second
agentVersion = "1.5.0"
protocolVersion = 1
websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
heartbeatInterval = 8 * time.Second
overlayPollInterval = 400 * time.Millisecond
overlayResendInterval = 2 * time.Second
)
type Config struct {
@@ -75,8 +77,22 @@ type VmixInventory struct {
}
type vmixAPIXML struct {
Version string `xml:"version"`
Inputs []vmixInputXML `xml:"inputs>input"`
Version string `xml:"version"`
Inputs []vmixInputXML `xml:"inputs>input"`
Overlays []vmixOverlayXML `xml:"overlays>overlay"`
}
type vmixOverlayXML struct {
Number string `xml:"number,attr"`
Key string `xml:"key,attr"`
Value string `xml:",chardata"`
}
type VmixOverlayTally struct {
Input string `json:"input"`
InputNumber string `json:"input_number,omitempty"`
InputKey string `json:"input_key,omitempty"`
InputTitle string `json:"input_title,omitempty"`
}
type vmixInputXML struct {
@@ -151,6 +167,7 @@ func (a *Agent) Start() {
a.logf("Agent %s started", agentVersion)
a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID))
go a.vmixMonitor()
go a.vmixOverlayMonitor()
go a.connectionLoop()
}
@@ -295,6 +312,130 @@ func (a *Agent) vmixMonitor() {
}
}
func (a *Agent) vmixOverlayMonitor() {
ticker := time.NewTicker(overlayPollInterval)
defer ticker.Stop()
lastHash := ""
var lastWS *wsConn
lastSentAt := time.Time{}
for {
a.mu.RLock()
vmixURL := a.cfg.VmixURL
ws := a.currentWS
a.mu.RUnlock()
overlays, connected := scanVmixOverlayTally(vmixURL)
encoded, _ := json.Marshal(overlays)
stateHash := fmt.Sprintf("%t:%s", connected, string(encoded))
force := ws != nil && ws != lastWS
periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval)
changed := stateHash != lastHash
if ws != nil && (force || periodic || changed) {
payload := map[string]any{
"type": "vmix.overlay_state",
"device_id": a.cfg.DeviceID,
"vmix_connected": connected,
"overlays": overlays,
"observed_at": time.Now().UTC().Format(time.RFC3339Nano),
}
if err := ws.WriteJSON(payload); err == nil {
lastHash = stateHash
lastWS = ws
lastSentAt = time.Now()
}
}
if ws == nil {
lastWS = nil
}
select {
case <-a.stop:
return
case <-ticker.C:
}
}
}
func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) {
result := map[string]VmixOverlayTally{}
target := strings.TrimSpace(base)
if target == "" {
return result, false
}
resp, err := vmixHTTPClient.Get(target)
if err != nil {
return result, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return result, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024))
if err != nil {
return result, false
}
var api vmixAPIXML
if err := xml.Unmarshal(body, &api); err != nil {
return result, true
}
byNumber := map[string]vmixInputXML{}
byKey := map[string]vmixInputXML{}
for _, input := range api.Inputs {
number := strings.TrimSpace(input.Number)
key := strings.TrimSpace(input.Key)
if number != "" {
byNumber[number] = input
}
if key != "" {
byKey[strings.ToLower(key)] = input
}
}
for _, overlay := range api.Overlays {
layer := strings.TrimSpace(overlay.Number)
if layer != "1" && layer != "2" && layer != "3" && layer != "4" {
continue
}
raw := strings.TrimSpace(overlay.Value)
rawKey := strings.TrimSpace(overlay.Key)
if raw == "" && rawKey == "" {
continue
}
var matched vmixInputXML
found := false
if raw != "" {
if item, ok := byNumber[raw]; ok {
matched, found = item, true
} else if item, ok := byKey[strings.ToLower(raw)]; ok {
matched, found = item, true
}
}
if !found && rawKey != "" {
if item, ok := byKey[strings.ToLower(rawKey)]; ok {
matched, found = item, true
}
}
entry := VmixOverlayTally{Input: raw}
if entry.Input == "" {
entry.Input = rawKey
}
if found {
entry.InputNumber = strings.TrimSpace(matched.Number)
entry.InputKey = strings.TrimSpace(matched.Key)
entry.InputTitle = strings.TrimSpace(matched.Title)
if entry.Input == "" {
entry.Input = entry.InputNumber
}
} else {
entry.InputKey = rawKey
if _, ok := byNumber[raw]; ok || raw != "" {
entry.InputNumber = raw
}
}
result[layer] = entry
}
return result, true
}
func suffixVersion(v string) string {
if v == "" {
return ""

View File

@@ -1,17 +1,57 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"net/http"
"net/http/httptest"
"testing"
)
func TestScanVmixInventory(t *testing.T) {
xml := `<vmix><version>29.0.0.48</version><inputs><input key="abc" number="12" type="GT" title="Scorebug"><text index="0" name="HomeTeam.Text">SKA</text><image index="0" name="HomeLogo.Source">c:\\logo.png</image></input></inputs></vmix>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/xml"); _, _ = w.Write([]byte(xml)) }))
defer srv.Close()
status, inv := scanVmix(srv.URL)
if !status.Connected || status.Version != "29.0.0.48" { t.Fatalf("bad status: %+v", status) }
if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 { t.Fatalf("bad inventory: %+v", inv) }
if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 { t.Fatalf("bad input: %+v", inv.Inputs[0]) }
xml := `<vmix><version>29.0.0.48</version><inputs><input key="abc" number="12" type="GT" title="Scorebug"><text index="0" name="HomeTeam.Text">SKA</text><image index="0" name="HomeLogo.Source">c:\\logo.png</image></input></inputs></vmix>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(xml))
}))
defer srv.Close()
status, inv := scanVmix(srv.URL)
if !status.Connected || status.Version != "29.0.0.48" {
t.Fatalf("bad status: %+v", status)
}
if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 {
t.Fatalf("bad inventory: %+v", inv)
}
if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 {
t.Fatalf("bad input: %+v", inv.Inputs[0])
}
}
func TestScanVmixOverlayTallyResolvesInputIdentity(t *testing.T) {
xml := `<vmix><inputs>` +
`<input key="geo-guid" number="16" type="GT" title="GEO">GEO</input>` +
`<input key="score-guid" number="5" type="GT" title="SCORE">SCORE</input>` +
`</inputs><overlays>` +
`<overlay number="1">5</overlay>` +
`<overlay number="2"></overlay>` +
`<overlay number="3"></overlay>` +
`<overlay number="4">16</overlay>` +
`</overlays></vmix>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write([]byte(xml))
}))
defer srv.Close()
overlays, connected := scanVmixOverlayTally(srv.URL)
if !connected {
t.Fatal("expected vMix tally connection")
}
if got := overlays["4"]; got.InputNumber != "16" || got.InputKey != "geo-guid" || got.InputTitle != "GEO" {
t.Fatalf("bad overlay 4 tally: %+v", got)
}
if got := overlays["1"]; got.InputNumber != "5" || got.InputKey != "score-guid" {
t.Fatalf("bad overlay 1 tally: %+v", got)
}
if _, ok := overlays["2"]; ok {
t.Fatalf("empty overlay 2 must not be active: %+v", overlays["2"])
}
}