Описание протоколов взаимодействия с матч-контроллером Кайросс
Данные от матч-контроллера могут передаваться в двух режимах:
- по запросу (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), нужно:
-
Поддержка mDNS в самой системе
Windows 10 (с недавних версий) и Windows 11 уже имеют встроенную поддержку mDNS. Адреса hostname.local должны работать без дополнительного софта, если включены сетевые службы обнаружения.
-
Если не работает «из коробки»
Обычно проблема в том, что на системе не установлен или отключён Bonjour (служба от Apple, используемая для mDNS).
👉 Возможные варианты:
- Установить Bonjour Print Services от Apple (или он автоматически ставится вместе с iTunes / iCloud).
- Убедиться, что служба Bonjour Service (mDNSResponder.exe) запущена.
-
Настройки сети
- mDNS работает через UDP порт 5353 на адрес мультикаста 224.0.0.251.
- Нужно, чтобы брандмауэр Windows и антивирусы не блокировали этот порт.
- В свойствах сетевого адаптера должно быть включено обнаружение сети.
-
Проверка
После установки и запуска службы можно проверить доступность:
ping matchpad.localили
nslookup matchpad.local -
Посмотреть 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-сообщений игрового времени в кадре:
- основное игровое время —
ID 5; - время атаки (ShortClock) —
ID 5, если оно передаётся; - точное время игрового таймера в миллисекундах —
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.
Необходимо выбрать только один.