Интеграция

Описание протоколов взаимодействия с матч-контроллером Кайросс

Данные от матч-контроллера могут передаваться в двух режимах:

  • по запросу (request, on-demand, pull mode)
  • подписка/прослушивание (subscribe, streaming, push mode)

Доступные протоколы и форматы данных

Протокол HTTP REST Server-Sent Events UDP Multicast NiBUS COLOSSEO
Среда Ethernet Ethernet Ethernet RS-485 RS-485
Режим запрос подписка подписка прослушивание прослушивание
Формат JSON JSON JSON бинарные данные бинарные данные

Имя хоста и адрес матч-контроллера

В некоторых случаях вам может понадобиться адрес матч-контроллера в сети. Можно попробовать обратиться к нему по имени matchpad.local. Чтобы на Windows корректно работали адреса вида *.local (mDNS, Multicast DNS), нужно:

  1. Поддержка mDNS в самой системе

    Windows 10 (с недавних версий) и Windows 11 уже имеют встроенную поддержку mDNS. Адреса hostname.local должны работать без дополнительного софта, если включены сетевые службы обнаружения.

  2. Если не работает «из коробки»

    Обычно проблема в том, что на системе не установлен или отключён Bonjour (служба от Apple, используемая для mDNS).

    👉 Возможные варианты:

    • Установить Bonjour Print Services от Apple (или он автоматически ставится вместе с iTunes / iCloud).
    • Убедиться, что служба Bonjour Service (mDNSResponder.exe) запущена.
  3. Настройки сети

    • mDNS работает через UDP порт 5353 на адрес мультикаста 224.0.0.251.
    • Нужно, чтобы брандмауэр Windows и антивирусы не блокировали этот порт.
    • В свойствах сетевого адаптера должно быть включено обнаружение сети.
  4. Проверка

    После установки и запуска службы можно проверить доступность:

    ping matchpad.local
    

    или

    nslookup matchpad.local
    
  5. Посмотреть IP-адрес на матч-контроллере

    Чтобы узнать IP-адреса, выданные контроллеру, откройте настройки подключения Wi-Fi, нажав на строку состояния. При этом не обязательно подключаться к Wi-Fi — на этой странице будут перечислены все выданные адреса.

UDP Multicast

Один из простых способов получать актуальную информацию о ходе игры — подписаться на мультикаст-рассылку по протоколу UDP.

  • Адрес: 232.10.11.12
  • Порт: 55000
  • Формат данных: текстовый JSON в кодировке UTF-8

Выбор пульта и зоны

Все пульты отправляют сообщения в одну multicast-группу и на один порт. Подписка на группу сама по себе не выбирает арену: без фильтра получатель увидит игры всех доступных пультов.

  • Для одной игры выбирайте IP отправителя UDP-пакета. Закрепите адрес пульта в сети; исходящий UDP-порт может измениться после перезапуска и не подходит как постоянный идентификатор.
  • data.zone — текущая настройка пульта, а не неизменный идентификатор устройства. Дополнительный фильтр по зоне должен проверять точное равенство. Не принимайте zone: 0 как любую требуемую зону: в общей сети это может смешать игры.
  • nibusId — идентификатор таймера, а не пульта и не зоны. В приведённых хоккейных примерах period имеет ID 1, break — 2, game-start-countdown — 3; при переходе между таймерами ID меняется независимо от зоны.

Общая структура пакета

Поле Обязательное Описание
event да Тип события (например, "tick")
data да Данные события (структура описана ниже)

Ниже приводятся типы событий и форматы данных для каждого из них.

Событие "tick" - время

  • Описание: Обновление текущего времени таймера.
  • Частота срабатывания:
    • Таймер остановлен: 1 раз в секунду
    • Таймер запущен: 10 раз в секунду
Поле Обязательное Значение Пример
kind да тип таймера:
primary - основной
secondary - дополнительный
"kind": "primary"
slug да Идентификатор текущего таймера "slug": "period"
view да текстовое представление таймера на табло "view": "09:48.3"
isRunning да true - таймер запущен
false - таймер остановлен
"isRunning": false
isRestTimer нет true - таймер перерыва
false - игровой таймер
"isRestTimer": false
elapsed нет Прошедшее время с момента старта в мс "elapsed": 2347
timestamp да Unix-время отправки данного сообщения "timestamp": 1759405394646
team нет Команда, запросившая командный тайм-аут:
home — Хозяева
away — Гости
"team": "home"
homePenalties нет Массив удаленных команды Хозяев:
number - номер удаленного
timeLeft - оставшееся время
"homePenalties": [{ "number": "12", "timeLeft": "1:21" }]
awayPenalties нет Массив удаленных команды Гостей:
number - номер удаленного
timeLeft - оставшееся время
"awayPenalties": [{ "number": "8", "timeLeft": "0:13" }]
shortClock нет 24-секунды
value - значение
isRunning - активно
"shortClock": { "value": "18", isRunning: true }
nibusId нет NiBUS-идентификатор таймера "nibusId": 1
zone да Зона матч-контроллера (0‑9) "zone": 0

Поле team передаётся только для командного тайм-аута (slug: "timeout"). Для медиа-тайм-аута (slug: "media-timeout") и остальных таймеров поле отсутствует.

Пример пакета командного тайм-аута:

{"event":"tick","data":{"kind":"secondary","slug":"timeout","view":"00:42","isRunning":true,"elapsed":18000,"timestamp":1759405394646,"team":"home","nibusId":4,"zone":0}}

Событие "scoreboard" - данные на табло

  • Описание: Обновление информации на табло (счёт, фолы, тайм-ауты и т.д.).
  • Частота срабатывания: при изменении состояния, но не реже 1 раза каждые 30 секунд
Поле Обязательное Значение Пример
sportSlug да Вид спорта "sportSlug": "basketball"
home да Команда Хозяев "home": {"score":0,"fouls":0,"name":"ЦСКА","town":"Москва","teamId":"n7hitgakhv1kxhvy89ilvhdp","timeouts":0}
home.score да Очки команды Хозяев "score": 0
home.fouls да Фолы команды Хозяев "fouls": 0
home.name да Название команды Хозяев "name": "ЦСКА"
home.town да Город команды Хозяев "town": "Москва"
home.teamId нет Идентификатор команды Хозяев "teamId": "n7hitgakhv1kxhvy89ilvhdp"
home.timeouts да Тайм-ауты команды "timeouts": 0
home.lineup нет Массив номеров игроков на площадке "lineup": ["11", "14", "22", "31", "44"]
home.filedOutPlayers нет Массив номеров удаленных до конца игры "filedOutPlayers": ["33"]
home.lastServerNumber нет В волейболе номер последнего подающего игрока "lastServerNumber": "12"
home.rotationCount нет В волейболе количество ротаций от начальной расстановки "rotationCount": 5
home.substitutionsCount нет Количество замен "substitutionsCount": 3
home.stats нет Статистика команды Хозяев (баскетбол, массив) "stats": [{"number": "32","points": 12, "fouls": 2, "name": "Александр Иванов"}, {"number": "11", "points": 8, "fouls": 1, "name": "Иван Петров"},...]
home.stats[].number да Номер игрока "number": "32"
home.stats[].points нет Количество очков игрока "points": 12
home.stats[].fouls нет Количество фолов игрока "fouls": 2
home.stats[].name нет Имя и Фамилия игрока "name": "Александр Иванов"
home.stats[].status нет Статус игрока (например, "disqualified") "status": "disqualified"
away да Команда Гостей, аналогично команде Хозяев "away": {"score":0,"fouls":0,"name":"УНИКС","town":"Казань","teamId":"lxf3lhv3vc6byoj7q5yxqoqb","lineup":["2","4","5","12","15"],"timeouts":0}
period да Период "period": "OT"
tournament нет Название турнира "tournament": "Единая лига ВТБ"
competitionId нет Идентификатор встречи "competitionId":"abs6b7s802kzra2gy6jed4km"
setScores нет Счет по партиям "setScores": [{"home": 21, "away": 19 }, {"home": 13, "away": 21 }]
possessionArrow нет Индикатор владения мячом "possessionArrow": "home"
zone да Зона матч-контроллера (0‑9) "zone": 0

Событие "updateLogo" - обновить логотип команды на табло

С помощью REST API можно загрузить логотип команды или фото игрока по его идентификатору используя URL http://matchpad.local/media/{mediaId}

Поле Обязательное Значение Пример
team да Команда:
home - Хозяев
away - Гостей
"team": "home"
primaryColor нет Основной цвет команды в hex "primaryColor": "#e31024"
secondaryColor нет Дополнительный цвет команды в hex "secondaryColor": "#cc1b51"
textColor нет Цвет текста в hex "textColor": "#ffffff"
logo нет Логотип команды "logo":{"id":"gv65tvb1tsnnghaowjfzsyis","width":143,"height":80}
logo.id да идентификатор логотипа "id": "gv65tvb1tsnnghaowjfzsyis"
logo.width нет Ширина логотипа в пикселях "width": 143
logo.height нет Высота логотипа в пикселях "height": 80
zone да Зона матч-контроллера (0‑9) "zone": 0

Событие "goalScoredBy" - гол забил

  • Описание: Отправляется при взятии ворот, содержит информацию об игроке, забившем гол, и при необходимости — об ассистентах. Используется для вывода сообщения на табло.
  • Виды спорта: Все, где фиксируются голы (футбол, хоккей, гандбол и т. п.).
  • Частота срабатывания: по факту события (не периодически).
Поле Обязательное Значение Пример
teamKind да Команда:
home - Хозяев
away - Гостей
"teamKind": "away"
number да Номер игрока "number": "00"
name нет Имя и Фамилия игрока "name": "Александр Иванов"
mediaId нет Идентификатор фото игрока "mediaId": "gv65tvb1tsnnghaowjfzsyis"
team нет Сведения о команде "team": { "name": "Дружба", "town": "Простоквашино", "logo": "lxf3lhv3vc6byoj7q5yxqoqb" }
team.name да Название команды "name": "Дружба"
team.town нет Родной город команды "town": "Простоквашино"
team.logo нет Идентификатор логотипа команды "logo": "gv65tvb1tsnnghaowjfzsyis"
zone да Зона матч-контроллера (0‑9) "zone": 0

Событие "playerStateChanged" - обновленная статистика игрока

  • Описание: Передаёт обновлённые данные по отдельному игроку: очки, фолы, количество голов, штрафы и т. д.
  • Виды спорта: Все командные виды, где ведётся индивидуальная статистика (например, баскетбол).
  • Частота срабатывания: при каждом изменении показателей игрока (например, добавлен гол, фол, очко и т. п.).
Поле Обязательное Значение Пример
team да Команда:
home - Хозяев
away - Гостей
"team": "home"
number нет Номер игрока "number": "27"
points нет Общее количество очков "points": 25
fouls нет Общее количество фолов "fouls": 2
status нет Статус игрока "status": "disqualified"
zone да Зона матч-контроллера (0‑9) "zone": 0

Пример UDP-клиента на python и с++

В целях тестирования добавлен скрипт для воспроизведения UDP multicast дампа на Python udp_replay.py и дамп с данными udp_dump.jsonl. Поместите их в одну папку и запустите скрипт.

python udp_replay.py --help # выдаст опции для запуска

python udp_replay.py # воспроизведет дамп

python udp_replay.py --delay 0.5 --loop  # задержка 0.5с, зациклить

python udp_replay.py --dump my_dump.jsonl  # другой файл дампа

👉 В примерах укажите IFACE — IPv4-адрес сетевого интерфейса компьютера-получателя, подключённого к сети пульта. Особенно важно выбрать его явно на macOS и при наличии VPN или нескольких адаптеров. Это не IP пульта. Если подписка на multicast не удалась, клиент завершится с ошибкой.

Оба примера ниже поддерживают SOURCE_IP (IP пульта) и ZONE (точное значение зоны). Оба фильтра применяются одновременно, если заданы. В Python значение None отключает соответствующий фильтр; в C++ для этого используются пустой SOURCE_IP и ZONE = -1. Режим без фильтров предназначен для диагностики всех источников. Перед подключением к системе матча обязательно задайте SOURCE_IP; фильтр ZONE при необходимости включайте после проверки настройки пульта.

# udp_client_async.py
import asyncio
import socket
import json
import sys

MCAST_GRP = "232.10.11.12"
MCAST_PORT = 55000
BUFFER_SIZE = 65536

# Укажите здесь IP вашего локального интерфейса.
# Например: "192.168.1.42" или оставьте "0.0.0.0" (INADDR_ANY).
IFACE = "0.0.0.0"

# Для интеграции с одной игрой задайте IP нужного пульта.
SOURCE_IP = None  # например, "192.168.1.50"
ZONE = None       # например, 3; 0 означает строго зону 0, а не все зоны

class MulticastProtocol(asyncio.DatagramProtocol):
    def connection_made(self, transport):
        self.transport = transport
        sock = transport.get_extra_info("socket")
        print("transport ready, sock:", sock.getsockname())

    def datagram_received(self, data, addr):
        try:
            if SOURCE_IP is not None and addr[0] != SOURCE_IP:
                return
            message = json.loads(data.decode("utf-8"))
            if not isinstance(message, dict) or not isinstance(message.get("data"), dict):
                raise ValueError("Ожидается объект JSON с объектом data")
            event_type = message.get("event", "unknown")
            event_data = message["data"]
            zone = event_data.get("zone")
            if type(zone) is not int or not 0 <= zone <= 9:
                raise ValueError("Отсутствует или некорректна data.zone (целое число 0–9)")
            if ZONE is not None and zone != ZONE:
                print(f"⚠️ Пакет от {addr[0]}:{addr[1]} пропущен: zone={zone}, ожидается {ZONE}")
                return
            print(f"📩 src={addr[0]}:{addr[1]} | событие={event_type} | data={event_data}")
        except Exception as e:
            print("⚠️ Ошибка парсинга/обработки пакета:", e, "raw:", data)

    def error_received(self, exc):
        print("❌ Ошибка UDP:", exc)

    def connection_lost(self, exc):
        print("❌ Соединение закрыто", exc)

async def main():
    loop = asyncio.get_running_loop()

    # Создаем сокет и настраиваем опции до передачи в asyncio
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    # На macOS/новых Linux полезно разрешить REUSEPORT (если доступно)
    if hasattr(socket, "SO_REUSEPORT"):
        try:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
        except OSError:
            pass

    # Привязываемся к порту на всех интерфейсах
    sock.bind(("", MCAST_PORT))

    # Подписываемся на мультикаст
    try:
        # Для macOS/Unix предпочтительнее 4s4s: group + local_if
        mreq = socket.inet_aton(MCAST_GRP) + socket.inet_aton(IFACE)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
    except Exception as e:
        print("⚠️ Не удалось присоединиться к мультикаст-группе:", e)
        print("   Попробуй поставить IFACE = реальный IP интерфейса (например, ipconfig getifaddr en0).")
        sock.close()
        raise

    sock.setblocking(False)

    transport, protocol = await loop.create_datagram_endpoint(
        lambda: MulticastProtocol(),
        sock=sock
    )

    if SOURCE_IP is None:
        print("⚠️ Диагностический режим: принимаются пакеты от всех пультов")
    print(f"Фильтры: SOURCE_IP={SOURCE_IP}, ZONE={ZONE}")
    print(f"🔌 Подключение к UDP Multicast {MCAST_GRP}:{MCAST_PORT} (IFACE={IFACE})...")

    try:
        # держим соединение бесконечно
        await asyncio.Event().wait()
    except asyncio.CancelledError:
        pass
    except KeyboardInterrupt:
        print("\n❌ Остановка клиента...")
    finally:
        transport.close()

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        sys.exit(0)

// udp_client.cpp
// UDP Multicast клиент для Windows (Visual Studio)
// Компиляция: cl.exe udp_client.cpp /EHsc Ws2_32.lib
// или через Visual Studio: создать Console App и добавить этот файл

#include <iostream>
#include <string>
#include <cstring>
#include <stdexcept>
#include <winsock2.h>
#include <ws2tcpip.h>
#include <nlohmann/json.hpp> // нужно установить: vcpkg install nlohmann-json

#pragma comment(lib, "Ws2_32.lib")

using json = nlohmann::json;

const char* MCAST_GRP = "232.10.11.12";
const int MCAST_PORT = 55000;
const int BUFFER_SIZE = 65536;
// IPv4 интерфейса компьютера-получателя, не пульта.
const char* IFACE = "0.0.0.0";
// Для одной игры задайте IP пульта, например "192.168.1.50".
const char* SOURCE_IP = ""; // пустая строка: все источники (диагностика)
const int ZONE = -1;       // -1: все зоны; 0: строго зона 0; 1–9: выбранная зона

class UdpMulticastClient {
private:
    SOCKET sock;
    in_addr sourceAddress{};
    struct sockaddr_in addr;
    char buffer[BUFFER_SIZE];

public:
    UdpMulticastClient() : sock(INVALID_SOCKET) {}

    ~UdpMulticastClient() {
        if (sock != INVALID_SOCKET) {
            closesocket(sock);
        }
        WSACleanup();
    }

    bool initialize() {
        // Инициализация Winsock
        WSADATA wsaData;
        if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
            std::cerr << "❌ WSAStartup failed" << std::endl;
            return false;
        }

        if (ZONE < -1 || ZONE > 9 ||
            (SOURCE_IP[0] != '\0' && InetPtonA(AF_INET, SOURCE_IP, &sourceAddress) != 1)) {
            std::cerr << "❌ Некорректный SOURCE_IP или ZONE" << std::endl;
            return false;
        }

        // Создаём UDP сокет
        sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
        if (sock == INVALID_SOCKET) {
            std::cerr << "❌ Socket creation failed: " << WSAGetLastError() << std::endl;
            return false;
        }

        // Разрешаем повторное использование адреса
        BOOL reuse = TRUE;
        if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, 
                      (char*)&reuse, sizeof(reuse)) < 0) {
            std::cerr << "⚠️ SO_REUSEADDR failed" << std::endl;
        }

        // Привязываемся к порту
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_addr.s_addr = htonl(INADDR_ANY);
        addr.sin_port = htons(MCAST_PORT);

        if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
            std::cerr << "❌ Bind failed: " << WSAGetLastError() << std::endl;
            return false;
        }

        // Подписываемся на мультикаст группу
        struct ip_mreq mreq{};
        if (InetPtonA(AF_INET, MCAST_GRP, &mreq.imr_multiaddr) != 1 ||
            InetPtonA(AF_INET, IFACE, &mreq.imr_interface) != 1) {
            std::cerr << "❌ Некорректный MCAST_GRP или IFACE" << std::endl;
            return false;
        }

        if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, 
                      (char*)&mreq, sizeof(mreq)) < 0) {
            std::cerr << "❌ IP_ADD_MEMBERSHIP failed: " << WSAGetLastError() << std::endl;
            return false;
        }

        if (SOURCE_IP[0] == '\0') {
            std::cout << "⚠️ Диагностический режим: принимаются пакеты от всех пультов" << std::endl;
        }
        std::cout << "Фильтры: SOURCE_IP=" << SOURCE_IP << ", ZONE=" << ZONE
                  << ", IFACE=" << IFACE << std::endl;
        std::cout << "🔌 Подключение к UDP Multicast " 
                  << MCAST_GRP << ":" << MCAST_PORT << std::endl;
        return true;
    }

    void run() {
        std::cout << "📡 Ожидание событий... (Ctrl+C для выхода)" << std::endl;
        std::cout << std::string(50, '-') << std::endl;

        while (true) {
            memset(buffer, 0, BUFFER_SIZE);
            
            struct sockaddr_in sender;
            int senderLen = sizeof(sender);
            
            int bytesReceived = recvfrom(sock, buffer, BUFFER_SIZE - 1, 0,
                                        (struct sockaddr*)&sender, &senderLen);

            if (bytesReceived < 0) {
                std::cerr << "⚠️ recvfrom error: " << WSAGetLastError() << std::endl;
                continue;
            }

            if (bytesReceived == 0) {
                continue;
            }

            if (SOURCE_IP[0] != '\0' && sender.sin_addr.s_addr != sourceAddress.s_addr) {
                continue;
            }
            char senderIp[INET_ADDRSTRLEN]{};
            if (InetNtopA(AF_INET, &sender.sin_addr, senderIp, sizeof(senderIp)) == nullptr) {
                std::cerr << "⚠️ Не удалось определить IP отправителя" << std::endl;
                continue;
            }
            const std::string source = std::string(senderIp) + ":" +
                                       std::to_string(ntohs(sender.sin_port));

            // Парсим JSON
            try {
                std::string jsonStr(buffer, bytesReceived);
                json message = json::parse(jsonStr);

                if (!message.is_object() || !message.contains("data") || !message["data"].is_object()) {
                    throw std::runtime_error("Ожидается объект JSON с объектом data");
                }
                const json& eventData = message["data"];
                if (!eventData.contains("zone") || !eventData["zone"].is_number_integer() ||
                    eventData["zone"] < 0 || eventData["zone"] > 9) {
                    throw std::runtime_error("Отсутствует или некорректна data.zone (целое число 0–9)");
                }
                const int zone = eventData["zone"].get<int>();
                if (ZONE != -1 && zone != ZONE) {
                    std::cerr << "⚠️ Пакет от " << source << " пропущен: zone=" << zone
                              << ", ожидается " << ZONE << std::endl;
                    continue;
                }
                std::string eventType = message.value("event", "unknown");

                std::cout << "📩 src=" << source << " | событие=" << eventType
                         << " | data=" << eventData.dump() << std::endl;

            } catch (const json::parse_error& e) {
                std::cerr << "⚠️ Ошибка парсинга JSON: " << e.what() << std::endl;
                std::cerr << "   raw: " << std::string(buffer, bytesReceived) << std::endl;
            } catch (const std::exception& e) {
                std::cerr << "⚠️ Ошибка: " << e.what() << std::endl;
            }
        }
    }
};

int main() {
    // Устанавливаем UTF-8 для консоли (для эмодзи)
    SetConsoleOutputCP(CP_UTF8);

    UdpMulticastClient client;

    if (!client.initialize()) {
        std::cerr << "❌ Не удалось инициализировать клиент" << std::endl;
        return 1;
    }

    try {
        client.run();
    } catch (const std::exception& e) {
        std::cerr << "❌ Ошибка: " << e.what() << std::endl;
        return 1;
    }

    return 0;
}
#!/usr/bin/env python3
"""
Скрипт для воспроизведения UDP multicast дампа (для тестирования).
Читает udp_dump.jsonl и отправляет события в мультикаст-группу.

python udp_replay.py --help

Воспроизведите дамп:
python udp_replay.py

С опциями:
python udp_replay.py --delay 0.5 --loop  # задержка 0.5с, зациклить
python udp_replay.py --dump my_dump.jsonl  # другой файл дампа
"""
import asyncio
import socket
import json
import sys
import argparse
from pathlib import Path

MCAST_GRP = "232.10.11.12"
MCAST_PORT = 55000
TTL = 2  # Time-to-live для мультикаста


async def replay_dump(
    dump_file: str,
    delay: float = 0.1,
    loop_replay: bool = False
):
    """
    Воспроизводит дамп UDP событий.
    
    Args:
        dump_file: Путь к файлу дампа (JSON Lines)
        delay: Задержка между отправкой пакетов (в секундах)
        loop_replay: Зациклить воспроизведение
    """
    if not Path(dump_file).exists():
        print(f"❌ Файл {dump_file} не найден!")
        sys.exit(1)

    # Создаём UDP сокет для отправки
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, TTL)

    print(f"🎬 Воспроизведение дампа: {dump_file}")
    print(f"📡 Отправка в {MCAST_GRP}:{MCAST_PORT}")
    print(f"⏱️  Задержка между пакетами: {delay}s")
    if loop_replay:
        print("🔁 Режим: зацикленное воспроизведение")
    print("-" * 50)

    try:
        while True:
            with open(dump_file, "r", encoding="utf-8") as f:
                count = 0
                for line in f:
                    line = line.strip()
                    if not line:
                        continue

                    try:
                        entry = json.loads(line)
                        event_type = entry.get("event", "unknown")
                        event_data = entry.get("data", {})

                        # Формируем исходное сообщение
                        message = {
                            "event": event_type,
                            "data": event_data,
                        }

                        # Отправляем в мультикаст
                        payload = json.dumps(message, ensure_ascii=False)
                        sock.sendto(
                            payload.encode("utf-8"),
                            (MCAST_GRP, MCAST_PORT)
                        )

                        count += 1
                        print(
                            f"📤 [{count}] Отправлено: "
                            f"{event_type} | {event_data}"
                        )

                        # Задержка между пакетами
                        await asyncio.sleep(delay)

                    except json.JSONDecodeError as e:
                        print(f"⚠️ Ошибка парсинга строки: {e}")
                        continue

            print(f"\n✅ Воспроизведено {count} событий")

            if not loop_replay:
                break

            print("\n🔁 Повтор воспроизведения...\n")
            await asyncio.sleep(1)

    except KeyboardInterrupt:
        print("\n❌ Остановка воспроизведения...")
    finally:
        sock.close()


def main():
    parser = argparse.ArgumentParser(
        description="Воспроизведение UDP multicast дампа"
    )
    parser.add_argument(
        "--dump",
        default="udp_dump.jsonl",
        help="Путь к файлу дампа (по умолчанию: udp_dump.jsonl)",
    )
    parser.add_argument(
        "--delay",
        type=float,
        default=0.1,
        help="Задержка между пакетами в секундах (по умолчанию: 0.1)",
    )
    parser.add_argument(
        "--loop",
        action="store_true",
        help="Зациклить воспроизведение",
    )

    args = parser.parse_args()

    try:
        asyncio.run(
            replay_dump(
                dump_file=args.dump,
                delay=args.delay,
                loop_replay=args.loop
            )
        )
    except KeyboardInterrupt:
        sys.exit(0)


if __name__ == "__main__":
    main()


Загрузить дамп для тестирования udp_dump.jsonl

Server-Sent Events (SSE)

Ещё один способ получать актуальную информацию — это подписка на события по протоколу SSE по адресу:
http://matchpad.local/api/v1/events

Вместо имени сервера matchpad.local можно использовать IP-адрес матч-контроллера.
Подписку можно проверить прямо в браузере, просто открыв указанную ссылку.

Протокол SSE идеально подходит для браузерных клиентов — например, для вывода информации о матче на HTML-странице. Именно этот механизм используется встроенным веб-сервером для отображения данных на видеотабло и SmartTV.

Пример открытой ссылки в браузере

event: scoreboard
data: {"sportSlug":"basketball","home":{"score":0,"fouls":0,"name":"ЦСКА","town":"Москва","teamId":"n7hitgakhv1kxhvy89ilvhdp","lineup":["2","8","9","11","41"],"timeouts":0},"away":{"score":0,"fouls":0,"name":"УНИКС","town":"Казань","teamId":"lxf3lhv3vc6byoj7q5yxqoqb","lineup":["2","4","5","12","15"],"timeouts":0},"period":"1","tournament":"Единая лига ВТБ","competitionId":"abs6b7s802kzra2gy6jed4km"}

event: updateLogo
data: {"team":"home","logo":{"id":"af5t865jqxyoebsot7jgil9t","width":147,"height":216},"primaryColor":"#d61a20","secondaryColor":"#003274","textColor":"#ffffff"}

event: updateLogo
data: {"team":"away","logo":{"id":"y2dny9a6jmj4zar666a0gtij","width":1820,"height":1820},"primaryColor":"#2faf6b","secondaryColor":"#d36d14","textColor":"#ffffff"}

event: tick
data: {"kind":"primary","slug":"period","view":"09:57.3","isRunning":false,"elapsed":2727,"timestamp":1759494388845,"nibusId":1}

👉 Форматы событий совпадают с описанными в разделе UDP Multicast, но поле zone добавляется только в UDP-пакеты и отсутствует в SSE.

Пример SSE-табло на HTML, JavaScript и CSS

Пример показывает только названия команд, счёт, время основного таймера и период. scoreboard обновляет команды, счёт и период, tick — время. Дополнительные таймеры (kind: "secondary", например тайм-аут) не заменяют основное время; перерыв и предстартовый отсчёт относятся к основным таймерам и отображаются. Время берётся из view, без собственного отсчёта в браузере.

Сохраните первые три вкладки в одной папке как index.html, scoreboard.js и scoreboard.css. Разместите файлы на веб-сервере. По умолчанию JS подключается к /api/v1/events на том же адресе, что и страница. В вашей интеграции направьте этот путь через reverse proxy на нужный матч-контроллер либо разместите страницу на одном origin с SSE-сервером.

Для отдельного адреса измените SSE_URL в JS, например на http://10.1.110.4/api/v1/events. В этом случае сервер должен разрешать origin страницы через CORS. Открытие файла через file:// не заменяет настройку CORS. HTTPS-страница не должна обращаться к HTTP-потоку: используйте совместимую схему или proxy. Простое открытие SSE-ссылки в браузере проверяет доступность потока, но не разрешения для другой страницы.

SSE подключается к конкретному контроллеру по URL, поэтому фильтр zone здесь не нужен. Указывайте адрес нужного пульта. Этот учебный пример не переключает основной и резервный контроллеры автоматически.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Табло матча</title>
  <link rel="stylesheet" href="scoreboard.css">
  <script src="scoreboard.js" defer></script>
</head>
<body>
  <main class="scoreboard" aria-label="Табло матча">
    <div class="teams">
      <section>
        <h1 id="home-name">Хозяева</h1>
        <p id="home-score" class="score">—</p>
      </section>
      <span class="separator" aria-hidden="true">:</span>
      <section>
        <h1 id="away-name">Гости</h1>
        <p id="away-score" class="score">—</p>
      </section>
    </div>
    <p id="clock" class="clock" aria-label="Время">—:—</p>
    <p class="period">Период <span id="period">—</span></p>
    <p id="status" class="status" role="status">Подключение…</p>
  </main>
</body>
</html>
// scoreboard.js
// При отдельном адресе контроллера требуется разрешённый сервером CORS.
const SSE_URL = "/api/v1/events";
const element = (id) => document.getElementById(id);
const status = element("status");
let lastEventAt = 0;

function show(id, value) {
  // textContent: названия команд выводятся как текст, а не HTML.
  element(id).textContent = String(value ?? "—");
}

function listen(source, eventName, update) {
  source.addEventListener(eventName, (event) => {
    try {
      const data = JSON.parse(event.data);
      if (!data || typeof data !== "object" || Array.isArray(data)) return;
      update(data);
      lastEventAt = Date.now();
      status.textContent = "Подключено";
    } catch (error) {
      console.warn(`Некорректное событие ${eventName}`, error);
    }
  });
}

const source = new EventSource(SSE_URL);
listen(source, "scoreboard", (data) => {
  show("home-name", data.home?.name);
  show("away-name", data.away?.name);
  show("home-score", data.home?.score);
  show("away-score", data.away?.score);
  show("period", data.period || "—");
});
listen(source, "tick", (data) => {
  if (data.kind === "primary" && typeof data.view === "string") {
    show("clock", data.view);
  }
});
source.onopen = () => {
  lastEventAt = Date.now();
  status.textContent = "Ожидание данных…";
};
source.onerror = () => {
  status.textContent = "Связь потеряна. Последние данные; переподключение…";
  // EventSource сам переподключается: не создаём второе соединение.
};
setInterval(() => {
  if (lastEventAt && Date.now() - lastEventAt > 5000) {
    status.textContent = "Нет новых данных. Показаны последние значения.";
  }
}, 1000);
/* scoreboard.css */
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  background: #101820;
  color: #f7fafc;
  font-family: system-ui, sans-serif;
}
.scoreboard { width: min(100%, 900px); text-align: center; }
.teams {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: 16px;
}
h1 { font-size: clamp(18px, 4vw, 36px); overflow-wrap: anywhere; }
p { margin: 12px 0; }
.score, .separator { font-size: clamp(48px, 12vw, 110px); }
.separator { align-self: end; margin-bottom: 12px; }
.score, .clock { font-variant-numeric: tabular-nums; }
.clock { color: #ffd166; font-size: clamp(40px, 10vw, 80px); }
.period { font-size: 24px; }
.status { color: #b8c4ce; font-size: 14px; }

Python-пример независимо от HTML выводит те же данные в терминал. Сохраните его как sse_client_async.py, укажите SSE_URL и установите зависимость: python -m pip install aiohttp. Запуск: python sse_client_async.py. Ограничения браузерного CORS к Python-клиенту не относятся.

# sse_client_async.py
import asyncio
import json
import aiohttp

SSE_URL = "http://matchpad.local/api/v1/events"
RECONNECT_DELAY = 5


def update_board(event, data, board):
    if not isinstance(data, dict):
        return
    if event == "scoreboard":
        home, away = data.get("home"), data.get("away")
        if not isinstance(home, dict) or not isinstance(away, dict):
            return
        board.update(
            home=home.get("name", "—"), away=away.get("name", "—"),
            home_score=home.get("score", "—"), away_score=away.get("score", "—"),
            period=data.get("period") or "—",
        )
    elif event == "tick" and data.get("kind") == "primary":
        if not isinstance(data.get("view"), str):
            return
        board["time"] = data["view"]
    else:
        return
    print(
        f"{board['home']} {board['home_score']} : {board['away_score']} {board['away']}"
        f" | {board['time']} | Период {board['period']}", flush=True,
    )


async def read_events(content):
    # Разбор потока Matchpad: строки LF/CRLF, комментарии и многострочное data.
    event, data_lines = "message", []
    async for raw_line in content:
        line = raw_line.decode("utf-8").rstrip("\r\n")
        if not line:
            if data_lines:
                yield event, "\n".join(data_lines)
            event, data_lines = "message", []
            continue
        if line.startswith(":"):
            continue
        field, _, value = line.partition(":")
        if value.startswith(" "):
            value = value[1:]  # SSE удаляет только один пробел после двоеточия.
        if field == "event":
            event = value
        elif field == "data":
            data_lines.append(value)
    # Незавершённое событие при обрыве не обрабатываем.


async def sse_client(url):
    # Нет общего лимита 5 минут; зависшее соединение обнаруживается по sock_read.
    timeout = aiohttp.ClientTimeout(total=None, sock_connect=10, sock_read=45)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        while True:
            board = dict(home="—", away="—", home_score="—", away_score="—",
                         time="—:—", period="—")
            try:
                print(f"Подключение к {url}", flush=True)
                async with session.get(url, headers={"Accept": "text/event-stream"}) as response:
                    response.raise_for_status()
                    if response.content_type != "text/event-stream":
                        raise ValueError("Ожидался text/event-stream; проверьте URL и доступ")
                    async for event, raw_data in read_events(response.content):
                        if event not in ("scoreboard", "tick"):
                            continue
                        try:
                            data = json.loads(raw_data)
                        except json.JSONDecodeError:
                            print("Некорректный JSON, событие пропущено", flush=True)
                            continue
                        update_board(event, data, board)
                print("Поток закрыт сервером", flush=True)
            except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as error:
                print(f"Ошибка SSE: {error}", flush=True)
            print(f"Связь потеряна. Переподключение через {RECONNECT_DELAY} с…", flush=True)
            await asyncio.sleep(RECONNECT_DELAY)


if __name__ == "__main__":
    try:
        asyncio.run(sse_client(SSE_URL))
    except KeyboardInterrupt:
        pass

При обрыве браузерный пример сохраняет последние значения и показывает состояние связи; Python сообщает об обрыве и заново собирает состояние после подключения. Это примеры отображения, а не журнал всех событий: сервер не передаёт id для восстановления пропущенной истории. После подключения он отправляет текущее состояние.

REST API

Полная справка в формате openapi/swagger по REST API доступна при подключении к матч-контроллеру с помощью браузера по адресу http://matchpad.local/api-docs. Вместо matchpad.local можно указать IP-адрес матч-контроллера.

Протокол NiBUS

NiBUS — проприетарный промышленный протокол фирмы Ната-Инфо. Скачать описание протокола NiBUS.pdf

👉 Полное описание протокола выходит за рамки данной публикации. Здесь рассмотрены только ключевые особенности, важные для интеграции матч-контроллера.

Особенностью протокола NiBUS является использование специальных двухбайтовых маркеров (служебных последовательностей), которые передаются без паузы между байтами и используются для управления доступом к каналу RS-485. Некоторые "умные" контроллеры и преобразователи, например устройства MOXA, могут некорректно обрабатывать такие маркеры из-за особенностей внутренней буферизации и автоматического управления направлением передачи, что приводит к искажению данных. Для стабильной работы рекомендуется использовать трансиверы, разработанные Ната-Инфо — Siolynx2 и C22. Скорость подключения к Siolynx2 — 115200 бод, для остальных устройств — 57600 бод.

Каждый кадр данных начинается с преамбулы 0x7e, затем идут адреса получателя и отправителя, служебные поля и размер N. Поле размера расположено со смещением 14 байт от начала кадра и включает байт протокола и все данные этого протокола. Полный размер кадра вместе с преамбулой и CRC равен 17 + N байт.

Для NMS поле данных кадра представляет собой не одно сообщение, а последовательность из одного или нескольких самостоятельных NMS-сообщений.

Формат кадра

Поле Смещение в байтах Значение/Фильтр
Преамбула 0 0x7e
... Адреса и сервисные поля
Размер 14 Размер кадра N, включая байт протокола максимум 238 байт
Протокол 15 0x01 — NMS
Последовательность NMS ... Одно или несколько полных NMS-сообщений
CRC [15 + N]..[16 + N] CRC16-CCITT, big-endian

Формат отдельного NMS-сообщения

Матч-контроллер передаёт данные табло только через NMS-сервис InformationReport дополнительные флаги не используются. Поэтому универсальный битовый заголовок NMS в данном случае можно представить в упрощённом фиксированном виде.

Смещения в таблице считаются от начала каждого отдельного NMS-сообщения, а не от начала кадра NiBUS:

Смещение Значение Размер Описание
+0 0x18 1 байт Константа сервиса InformationReport
+1 ID 1 байт Идентификатор сообщения
+2 Length 1 байт Размер тела NMS: один байт TYPE плюс размер DATA
+3 TYPE 1 байт Тип значения/данных, например 0x13 для VT_UI4
+4 DATA Length - 1 байт Данные/значение

Таким образом, одно сообщение имеет вид [0x18] [ID] [Length] [TYPE] [DATA], а его полный размер равен 3 + Length байт. Константа 0x18 является первым байтом упакованного заголовка NMS для InformationReport.

Например, поле данных кадра с игровым временем, временем атаки и точным временем для системы видеогол содержит три полных NMS-сообщения подряд:

18 05 0b 91 [10 байтов времени]
18 05 0b 91 [10 байтов ShortClock]    — время атаки (опционально)
18 32 05 13 [4 байта миллисекунд, LE] — точное время в миллисекундах (опционально)
│  │  │  │
│  │  │  └─ TYPE
│  │  └──── Length: TYPE + DATA
│  └─────── ID (0x32 = 50)
└────────── константа InformationReport

Заголовок 18 ID Length повторяется перед каждым значением. Квадратные скобки в примере служат только для пояснения и в кадр не передаются.

Чтобы перейти к следующему NMS-сообщению, прибавьте к текущему смещению 3 + Length. Разбор заканчивается перед CRC кадра, по смещению 15 + N. Вместе с игровым временем передаётся точное время в миллисекундах, а между ними также может передаваться время 24-секундника.

Стандартные типы данных

Тип Значение Размер Описание
VT_BOOL 0x0b 8 бит Значение TRUE = 1/FALSE = 0
VT_I1 0x10 8 бит Знаковый байт
VT_I2 0x02 16 бит Знаковое короткое целое
VT_I4 0x03 32 бита Знаковое целое
VT_I8 0x14 64 бита Знаковое длинное целое
VT_UI1 0x11 8 бит Байт
VT_UI2 0x12 16 бит Короткое целое
VT_UI4 0x13 32 бита Целое
VT_UI8 0x15 64 бита Длинное целое
VT_R4 0x04 32 бита Значение с плавающей точкой
VT_R8 0x05 64 бита Значение с плавающей точкой удвоенной точности
VT_LPSTR 0x1e Строка символов с терминирующим нулем
VT_DATE 0x07 80 бит Дата/время в формате BCD
DD-MM-YYYY HH:MM:SS.0mmmbW
DD – дата
MM – месяц
YYYY – год
HH – час (0..23)
MM – минуты
SS – секунды
mmm – миллисекунды
W – день недели (1..7, 1 – вс, 2 – пн, … 7 – сб)
b – зарезервировано
VT_VECTOR 0x80 Модификатор типа. Означает вектор типизированных элементов

Контрольная сумма

Циклическая контрольная сумма подсчитывается (не включает преамбулу) по стандарту CRC16-ССITT. Делитель равен 1 0001 0000 0010 0001 = 0x1021. Начальное значение 0x0000. Хранится в формате big-endian.

Детализация информационных сообщений

👉 Вся текстовая информация в сообщениях представлена кодировкой Windows-1251, бинарные данные используют порядок байт (endianness) little-endian, формат BCD означает binary-coded decimal (например 3810 хранится и передается как байт 0x38) .

Время

ID: 0x05
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - флаги:

бит название описание
0 TFM_ACTIVE таймер активизирован
1 TFM_DOTS включить точки между часами и минутами
(используется в основном для показа реального времени)
2 TFM_REST_TIMER таймер отсчитывает время перерыва
3 TFM_MILLS_IMPORTANT необходимо показать миллисекунды
4 TFM_MILLIS_10RESOLUTION разрешение миллисекунд 1/10
5 TFM_HIDDEN не отображать таймер
6 TFM_SECONDARY не отображать данный таймер на знакоместах основного времени

+1 байт - идентификатор таймера:

для баскетбола:

Значение Таймер
0x01 игровой таймер
0x02 перерыв
0x03 время до начала матча
0x04 тайм-аут
0x05 овертайм
0x06 перерыв овертайма
0x07 короткий перерыв
0x20 время атаки (24c)

для волейбола:

Значение Таймер
0x01 перерыв
0x02 тайм-аут
0x03 технический тайм-аут

для гандбола:

Значение Таймер
0x01 игровой таймер
0x02 перерыв
0x03 время до начала матча
0x04 тайм-аут
0x05 овертайм
0x06 перерыв овертайма
0x07 короткий перерыв

для мини-футбола (футзал):

Значение Таймер
0x01 игровой таймер
0x02 перерыв
0x03 время до начала матча
0x04 тайм-аут

для хоккея:

Значение Таймер
0x01 игровой таймер
0x02 перерыв
0x03 время до начала матча
0x04 тайм-аут
0x05 овертайм
0x06 перерыв овертайма

+2 байт - минуты (BCD)

+3 байт - секунды (BCD)

+4 байт - доли секунд (BCD)

+5..8 байты — цвет таймера (RGB, UInt32LE):

Значение Состояние
0xffff00 таймер запущен (желтый)
0xff0000 таймер остановлен (красный)

+9 байт — расширяемое битовое поле команды, запросившей тайм-аут:

Бит Значение Команда
0 0 Хозяева
0 1 Гости

Для определения команды необходимо учитывать только младший бит: team = value & 0x01. Биты 1–7 зарезервированы для последующего расширения протокола и должны игнорироваться при чтении.

Поле команды используется для таймера с идентификатором тайм-аута. Для остальных таймеров младший бит равен 0.

Точное время игрового таймера в миллисекундах

ID: 50 (0x32)
TYPE: VT_UI4 (0x13)
DATA: +0..3 байты — точное значение времени таймера в миллисекундах (UInt32LE):

  • для таймера с обратным отсчётом — оставшееся время;
  • для таймера с прямым отсчётом — прошедшее время.

Сообщение ID 50 передаётся только для основного игрового таймера, в том же кадре NiBUS, что и основное сообщение времени ID 5, и используется в системе видеогол. Для таймеров перерыва и дополнительных таймеров сообщение отсутствует. Порядок NMS-сообщений игрового времени в кадре:

  1. основное игровое время — ID 5;
  2. время атаки (ShortClock) — ID 5, если оно передаётся;
  3. точное время игрового таймера в миллисекундах — ID 50.

Основное сообщение ID 5 сохраняет прежний формат и значение для совместимости. Во время работы игрового таймера кадр передаётся 10 раз в секунду, даже если отображение ID 5 меняется только раз в секунду. Остальные таймеры сохраняют прежнюю частоту передачи и не содержат ID 50.

Счет команды хозяев

ID: 0x06
TYPE: VT_UI2 (0x12)
DATA: +0..1 байт - значение счета

Счет команды гостей

ID: 0x07
TYPE: VT_UI2 (0x12)
DATA: +0..1 байт - значение счета

Период

ID: 0x08
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение периода

Фолы команды хозяев

ID: 0x09
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение

Фолы команды гостей

ID: 0x0a
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение

Перерывы команды хозяев

ID: 0x0e
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение

Перерывы команды гостей

ID: 0x0f
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение

Количество игроков в команде

ID: 0x10
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - команда:

Значение Команда
0x00 Хозяева
0x01 Гости
0x02 Судьи

+1..2 байт - количество игроков

Информация о игроке

ID: 0x11
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - команда:

Значение Команда
0x00 Хозяева
0x01 Гости
0x02 Судьи

+1 байт - зарезервировано

+2 байт - индекс игрока (начинается с 0)

+3 байт - номер игрока (BCD)

+4 байт - роль игрока:

Значение Роль
0x00 Основной состав
0x01 Запасной
0x02 Капитан
0x03 Тренер

+5 байт - фамилия (не более 30 знаков, терминированная 0), страна (не более 20 знаков), допинформация (терминированная 0)

Пример: Петров А.\0РОССИЯ\0\0

Статистика по игроку

ID: 0x12
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - команда:

Значение Команда
0x00 Хозяева
0x01 Гости
0x02 Судьи

+1 байт - номер игрока (BCD)

+2 байт - индекс игрока (начинается с 0)

+3 байт - суммарное число очков (BCD)

+4 байт - cуммарное число фолов (BCD)

+5 байт - статус игрока:

Бит Статус
0 Находится на площадке
1 В пакете присутствует статистика по броскам

Опционально:

+6 байт - количество 1-очковых бросков игрока (BCD)

+7 байт - количество 2-очковых бросков игрока (BCD)

+8 байт - количество 3-очковых бросков игрока (BCD)

Название команды хозяев

ID: 0x13
TYPE: VT_LPSTR (0x1e)
DATA: +0..M-1 байт - название команды, терминированное 0

Название команды гостей

ID: 0x14
TYPE: VT_LPSTR (0x1e)
DATA: +0..M-1 байт - название команды, терминированное 0

Город/страна команды хозяев

ID: 0x15
TYPE: VT_LPSTR (0x1e)
DATA: +0..M-1 байт - город/страна, терминированное 0

Город/страна команды гостей

ID: 0x16
TYPE: VT_LPSTR (0x1e)
DATA: +0..M-1 байт - город/страна, терминированное 0

Название турнира

ID: 0x17
TYPE: VT_LPSTR (0x1e)
DATA: +0..M-1 байт - название турнира, терминированное 0

Указатель владения мячом

ID: 0x18
TYPE: VT_UI1 (0x11)
DATA: +0 байт - значение

Значение Указатель
0x00 Погашен
0x01 Хозяева
0x02 Гости

Вывод информационного сообщения

ID: 0x19
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - тип сообщения:

Значение Тип
0x0b мяч забросил
0x0c фол

+1..M-1 байт - фаимлия игрока (не более 30 знаков, терминированная 0), номер игрока (не более 2 знаков), название команды (не более 20 знаков), допинформация (терминированная 0)

Пример: Петров А.\012\0ЦСКА\0\0

Переключение провайдера игры (смена вида спорта)

ID: 0x1b
TYPE: VT_UI2 (0x12)
DATA: +0..1 байт - вид спорта

Значение Вид спорта
0x0000 хоккей
0x0001 баскетбол
0x0002 волейбол
0x0003 гандбол
0x0004 футзал

Статистика по удалениям (Гандбол/Мини-футбол/Хоккей)

ID: 0x1c
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - количество строк (всего позиций 2*N, для 2х команд)

Строка 1 (хозяева)

+1 байт - флаги:

Бит Флаг
0 данная строка валидна
1 1 - время удаления активно, 0 - отложенный штраф

+2 байт - номер игрока (BCD)

+3 байт - минуты (BCD)

+4 байт - секунды (BCD)

+5 байт - зарезервировано

Всего N строк для хозяев, затем N строк для гостей: 5*(2*N) байт.

Статистика "Волейбол"

ID: 0x1d
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - счет команды хозяев в 1-партии

+1 байт - счет команды хозяев в 2-партии

+2 байт - счет команды хозяев в 3-партии

+3 байт - счет команды хозяев в 4-партии

+4 байт - счет команды хозяев в 5-партии

+5 байт - счет команды гостей в 1-партии

+6 байт - счет команды гостей в 2-партии

+7 байт - счет команды гостей в 3-партии

+8 байт - счет команды гостей в 4-партии

+9 байт - счет команды гостей в 5-партии

Количество замен в команде (Волейбол)

ID: 0x1e
TYPE: VT_UI1 | VT_VECTOR (0x91)
DATA:

+0 байт - команда:

Значение Команда
0x00 Хозяева
0x01 Гости

+1 байт - количество замен

Пример NiBUS-парсера на Python

# nibus_parser.py
import serial
import struct
import sys

# Настройки порта
SERIAL_PORT = "COM3"  # Измените на ваш порт (например, "/dev/ttyUSB0" на Linux/macOS)
BAUD_RATE = 57600  # Укажите 115200, если используете Siolynx2

# Константы протокола
PREAMBLE = 0x7E
PROTOCOL_NMS = 0x01
INFORMATION_REPORT_HEADER = 0x18

# Типы данных NiBUS
VT_TYPES = {
    0x0B: ("VT_BOOL", 1),
    0x10: ("VT_I1", 1),
    0x02: ("VT_I2", 2),
    0x03: ("VT_I4", 4),
    0x14: ("VT_I8", 8),
    0x11: ("VT_UI1", 1),
    0x12: ("VT_UI2", 2),
    0x13: ("VT_UI4", 4),
    0x15: ("VT_UI8", 8),
    0x04: ("VT_R4", 4),
    0x05: ("VT_R8", 8),
    0x1E: ("VT_LPSTR", None),  # строка переменной длины
    0x07: ("VT_DATE", 10),
    0x91: ("VT_UI1|VT_VECTOR", None),  # вектор байтов
}


def calc_crc16(data):
    """Вычисление CRC16-CCITT (полином 0x1021, начальное значение 0x0000)"""
    crc = 0x0000
    for byte in data:
        crc ^= byte << 8
        for _ in range(8):
            if crc & 0x8000:
                crc = (crc << 1) ^ 0x1021
            else:
                crc = crc << 1
            crc &= 0xFFFF
    return crc


def parse_data(data_type, data_bytes):
    """Парсинг данных в зависимости от типа"""
    if data_type not in VT_TYPES:
        return f"Unknown type 0x{data_type:02x}", data_bytes.hex()
    
    type_name, size = VT_TYPES[data_type]
    
    if data_type == 0x1E:  # VT_LPSTR - строка
        try:
            # Ищем терминирующий ноль
            end = data_bytes.find(b'\x00')
            if end == -1:
                return type_name, data_bytes.decode('windows-1251', errors='replace')
            return type_name, data_bytes[:end].decode('windows-1251', errors='replace')
        except:
            return type_name, data_bytes.hex()
    
    elif data_type == 0x91:  # VT_UI1|VT_VECTOR - массив байтов
        return type_name, ' '.join(f'{b:02x}' for b in data_bytes)
    
    elif data_type == 0x0B:  # VT_BOOL
        return type_name, bool(data_bytes[0])
    
    elif data_type in [0x11, 0x10]:  # VT_UI1, VT_I1
        fmt = 'B' if data_type == 0x11 else 'b'
        return type_name, struct.unpack(fmt, data_bytes[:1])[0]
    
    elif data_type in [0x12, 0x02]:  # VT_UI2, VT_I2
        fmt = '<H' if data_type == 0x12 else '<h'
        return type_name, struct.unpack(fmt, data_bytes[:2])[0]
    
    elif data_type in [0x13, 0x03]:  # VT_UI4, VT_I4
        fmt = '<I' if data_type == 0x13 else '<i'
        return type_name, struct.unpack(fmt, data_bytes[:4])[0]
    
    elif data_type in [0x15, 0x14]:  # VT_UI8, VT_I8
        fmt = '<Q' if data_type == 0x15 else '<q'
        return type_name, struct.unpack(fmt, data_bytes[:8])[0]
    
    elif data_type == 0x04:  # VT_R4 - float
        return type_name, struct.unpack('<f', data_bytes[:4])[0]
    
    elif data_type == 0x05:  # VT_R8 - double
        return type_name, struct.unpack('<d', data_bytes[:8])[0]
    
    elif data_type == 0x07:  # VT_DATE - BCD дата/время
        return type_name, ' '.join(f'{b:02x}' for b in data_bytes[:10])
    
    return type_name, data_bytes.hex()

def parse_nms_messages(payload):
    """Разбор всех NMS-сообщений из поля данных одного кадра NiBUS"""
    cursor = 0
    message_number = 1

    while cursor < len(payload):
        if len(payload) - cursor < 3:
            print(f"⚠️ Неполный заголовок NMS по смещению {cursor}")
            return False

        # Matchpad передаёт только InformationReport без дополнительных флагов.
        if payload[cursor] != INFORMATION_REPORT_HEADER:
            print(
                f"⚠️ NMS №{message_number}: ожидался InformationReport "
                f"0x18, получено 0x{payload[cursor]:02x}"
            )
            return False

        report_id = payload[cursor + 1]
        body_length = payload[cursor + 2]

        body_start = cursor + 3
        body_end = body_start + body_length
        if body_end > len(payload):
            print(
                f"⚠️ NMS №{message_number}: заявлена длина {body_length}, "
                "но данных в кадре недостаточно"
            )
            return False

        body = payload[body_start:body_end]

        if not body:
            print(f"⚠️ NMS №{message_number}: InformationReport без TYPE")
            return False

        data_type = body[0]
        data_bytes = body[1:]
        type_name, parsed_data = parse_data(data_type, data_bytes)
        print(
            f"📦 NMS №{message_number} | InformationReport | "
            f"ReportId: {report_id} (0x{report_id:02x}) | "
            f"TYPE: {type_name} (0x{data_type:02x}) | DATA: {parsed_data}"
        )

        # Следующее сообщение начинается сразу после тела текущего.
        cursor = body_end
        message_number += 1

    return True


def parse_nibus_frame(frame):
    """Парсинг кадра NiBUS"""
    if len(frame) < 20:
        print("⚠️ Слишком короткий кадр")
        return False
    
    # Проверка преамбулы
    if frame[0] != PREAMBLE:
        print(f"⚠️ Неверная преамбула: 0x{frame[0]:02x}")
        return False
    
    # Размер кадра
    frame_size = frame[14]
    
    if len(frame) < 15 + frame_size + 2:
        print(f"⚠️ Неполный кадр (ожидается {15 + frame_size + 2}, получено {len(frame)})")
        return False
    
    # Байт 15 задаёт протокол поля данных кадра.
    protocol = frame[15]
    if protocol != PROTOCOL_NMS:
        return False
    
    # CRC
    crc_start = 15 + frame_size
    crc_received = struct.unpack('>H', frame[crc_start:crc_start + 2])[0]
    
    # Проверка CRC (без преамбулы)
    crc_calculated = calc_crc16(frame[1:crc_start])
    
    if crc_calculated != crc_received:
        print(f"⚠️ Ошибка CRC: вычислено 0x{crc_calculated:04x}, получено 0x{crc_received:04x}")
        return False
    
    # Между байтом протокола и CRC находится последовательность полных NMS.
    nms_payload = frame[16:crc_start]
    return parse_nms_messages(nms_payload)

def main():
    print(f"🔌 Подключение к {SERIAL_PORT} на скорости {BAUD_RATE} бод...")
    
    try:
        ser = serial.Serial(
            port=SERIAL_PORT,
            baudrate=BAUD_RATE,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=1
        )
        
        print("✅ Порт открыт. Ожидание данных...")
        
        buffer = bytearray()
        
        while True:
            # Читаем данные
            if ser.in_waiting:
                chunk = ser.read(ser.in_waiting)
                buffer.extend(chunk)
            
            # Ищем преамбулу
            while len(buffer) >= 20:
                preamble_pos = buffer.find(bytes([PREAMBLE]))
                
                if preamble_pos == -1:
                    # Преамбулы нет, очищаем буфер
                    buffer.clear()
                    break
                
                # Удаляем мусор до преамбулы
                if preamble_pos > 0:
                    buffer = buffer[preamble_pos:]
                
                # Проверяем, достаточно ли данных для определения размера
                if len(buffer) < 15:
                    break
                
                frame_size = buffer[14]
                total_size = 15 + frame_size + 2  # +2 для CRC
                
                # Ждём полного кадра
                if len(buffer) < total_size:
                    break
                
                # Извлекаем кадр
                frame = buffer[:total_size]
                buffer = buffer[total_size:]
                
                # Парсим кадр
                parse_nibus_frame(frame)
    
    except serial.SerialException as e:
        print(f"❌ Ошибка порта: {e}")
        sys.exit(1)
    except KeyboardInterrupt:
        print("\n❌ Остановка...")
        sys.exit(0)
    finally:
        if 'ser' in locals() and ser.is_open:
            ser.close()
            print("🔌 Порт закрыт")

if __name__ == "__main__":
    main()

COLOSSEO Universal Protocol

Этот протокол используется, если вы подключаете табло или часы производства COLOSSEO.

📄 Скачать описание протокола:
COLOSSEO Universal Protocol.pdf

⚠️ Обратите внимание: нельзя одновременно использовать оба протокола — NiBUS и COLOSSEO.
Необходимо выбрать только один.