Skip to content

avtotor/monitor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CAN Monitor — Полная техническая документация

Консольное приложение для мониторинга CAN-шины в реальном времени.

Подключается к устройству на базе ESP32 (прошивка GVRET / ESP32S3) через Wi-Fi по TCP.


Сборка

make

Требования: gcc (MinGW-w64), C11, Winsock2 (ws2_32), Windows 10+.

Запуск с явным IP-адресом устройства:

can_monitor.exe 192.168.4.1

Без аргументов — UDP-discovery (порт 17222, таймаут 8 с), повтор каждые 3 с до успеха. Фолбека на хардкоженный IP нет — если discovery не находит устройство, программа показывает подсказку и продолжает попытки.


Архитектура

graph LR
    subgraph main.c ["main.c — главный цикл, тик ~1 мс"]
        A["Connection\nTCP/UDP · Winsock2"] -->|bytes| B["GvretParser\nконечный автомат"]
        B -->|ParsedFrame| C["FrameStore\nsorted array · 512 IDs"]
        C -->|entries| D["Display\nANSI · 10 Гц"]
    end
Loading
Файл Ответственность
connection.c TCP-соединение, UDP-discovery, recv/send
gvret_parser.c Разбор бинарного протокола, построение команд
frame_store.c База CAN-фреймов, подсчёт частоты
display.c ANSI-рендер консоли
main.c Главный цикл, тайминги, управление соединением

Главный цикл (main.c)

В конце каждой итерации — Sleep(1), реальный тик ~1 мс.

Константы тайминга

Константа Значение Назначение
DISPLAY_INTERVAL_MS 100 мс Частота обновления экрана (10 Гц)
KEEPALIVE_MS 2000 мс Интервал keepalive-пакета
RECONNECT_MS 3000 мс Пауза перед повтором подключения / discovery

Порядок действий за один тик

flowchart TD
    Tick([тик ~1 мс]) --> ConnOk{соединение?}

    ConnOk -- да --> Recv
    ConnOk -- "нет, прошло ≥ 3с" --> HasIP{IP задан?}

    HasIP -- нет --> UDP["UDP-discovery\nтаймаут 8 с"]
    UDP -- найден --> TCP
    UDP -- не найден --> Tick

    HasIP -- да --> TCP["TCP-connect\nтаймаут 2 с"]
    TCP -- OK --> Init["4 init-команды\nE7 · F1 07 · F1 06 · F1 0C"]
    TCP -- fail --> Tick
    Init --> Recv

    Recv["recv()\nдо 4096 байт"] --> Parse["gvret_parser_feed_bytes()"]
    Parse --> HasFrame{has_frame?}
    HasFrame -- да --> Update["frame_store_update()"] --> HasFrame
    HasFrame -- нет --> KA{"≥ 2с без\nkeepalive?"}
    KA -- да --> KASend["send F1 09"] --> Disp
    KA -- нет --> Disp{"≥ 100мс без\nрендера?"}
    Disp -- да --> Render["calculate_rates()\ndisplay_render()"] --> Tick
    Disp -- нет --> Tick
Loading

Соединение (connection.c)

UDP-discovery

Приложение слушает UDP-порт 17222, ожидая широковещательный пакет от ESP32.

Ожидаемый пакет: 4 байта = { 0x1C, 0xEF, 0xAC, 0xED }
IP-адрес отправителя становится целевым адресом.
  • Блокирует поток на timeout_ms = 5000 мс
  • Если пакет не пришёл — сообщение пользователю, повтор через 3 с
  • Если IP передан аргументом — discovery пропускается полностью

Debug: Discovery не срабатывает, если ESP32 и ПК в разных подсетях или прошивка ESP32 не отправляет broadcast.

TCP-соединение

socket() → ioctlsocket(FIONBIO=1) → connect() → select() → getsockopt(SO_ERROR)
  • connect() вызывается в неблокирующем режиме
  • select() ждёт готовности write-сокета, таймаут = 2000 мс
  • Сокет остаётся неблокирующим после connect — это важно для receive
  • При успехе: connected = true, отправляются 4 init-пакета

Debug: Код ошибки WSA отображается в строке статуса. Типичные значения: 10061 — порт закрыт, 10060 — таймаут, 10065 — нет маршрута.

Приём данных

int n = recv(sock, buf, max_len, 0);
// n > 0                → данные получены
// n == 0               → ESP32 закрыл соединение (graceful)
// n < 0, WSAEWOULDBLOCK → данных нет (норма для неблокирующего сокета)
// n < 0, другое        → ошибка → connection_disconnect()

Буфер приёма в main.c: 4096 байт за один вызов recv(). В одном recv() может прийти несколько GVRET-фреймов или половина фрейма — парсер справляется с этим через внутренний конечный автомат.


Протокол GVRET (gvret_parser.c)

Обрамление пакетов

Все пакеты начинаются с двух байт: 0xF1 <команда>. Парсер в состоянии GVRET_IDLE ждёт байт 0xF1, затем читает код команды.

Команды ESP32 → ПК

Байт команды Название Длина payload
0x00 CAN-фрейм 9 + DLC + 1 (контрольная сумма)
0x01 Синхронизация времени 4 байта
0x02 Цифровые входы 2 байта (пропускается)
0x03 Аналоговые входы 15 байт (пропускается)
0x06 Параметры шины 10 байт
0x07 Информация об устройстве 6 байт (используются первые 3)
0x09 Keepalive 2 байта
0x0C Количество шин 1 байт
0x0D Extended buses 15 байт (пропускается)

Структура CAN-фрейма (команда 0x00)

После заголовка 0xF1 0x00 парсер накапливает байты в buf[32]:

Смещение  Размер  Поле
──────────────────────────────────────────────────────────────
[0..3]      4     Timestamp (little-endian, мс, время работы ESP32)
[4..7]      4     CAN ID (little-endian)
                    бит 31     = флаг extended-фрейма (1=29-бит, 0=11-бит)
                    биты 30:0  = собственно CAN ID
[8]         1     lenBus
                    биты 3:0   = DLC (длина данных, 0..8)
                    биты 7:4   = номер шины (0=CAN0, 1=CAN1)
[9..9+DLC-1] DLC  Байты данных
[9+DLC]     1     Контрольная сумма (принимается, но игнорируется)

Полная длина пакета после заголовка 0xF1 0x00: 10 + DLC байт (минимум 10, максимум 18 при DLC=8).

Конечный автомат парсера

Каждый пакет — три шага:

flowchart LR
    A([IDLE]) -->|"① байт 0xF1"| B([GET_COMMAND])
    B -->|"② байт команды\nсм. таблицу"| C(["состояние\nчитает N байт"])
    C -->|"③ N байт прочитано"| A
    B -->|другой байт| A
Loading

Что происходит на шаге ②:

Команда Состояние Байт данных
0x00 READ_CAN_FRAME 9 + DLC + 1
0x01 READ_TIME_SYNC 4
0x02 SKIP_BYTES 2
0x03 SKIP_BYTES 15
0x06 READ_CANBUS_PARAMS 10
0x07 READ_DEV_INFO 6
0x09 READ_KEEPALIVE 2
0x0C READ_NUMBUSES 1
0x0D READ_EXT_BUSES 15
любой другой → сразу IDLE

Переменные состояния:

  • state — текущее состояние (GvretState)
  • step — счётчик байт внутри текущего сообщения (начинается с 0)
  • buf[32] — буфер накопления текущего сообщения
  • current_dlc — DLC, захваченный на шаге 8 CAN-фрейма

Ключевой момент в GVRET_READ_CAN_FRAME:

p->buf[p->step] = b;

if (p->step == 8) {
    p->current_dlc = b & 0x0F;              // DLC из младшего nibble lenBus
    if (p->current_dlc > 8) p->current_dlc = 8;  // защитное ограничение
}

if (p->step >= 9 + p->current_dlc) {
    // достигли байта контрольной суммы → фрейм готов
    emit_can_frame(p);
    p->state = GVRET_IDLE;
} else {
    p->step++;
}

step не инкрементируется на последнем байте (контрольная сумма).

Очередь фреймов

Готовые фреймы помещаются в кольцевой буфер frame_queue[64] внутри GvretParser. Из главного цикла забираются через gvret_parser_pop_frame().

Команды ПК → ESP32

Функция Байты Назначение
gvret_build_enable_binary() E7 Переключить ESP32 в бинарный режим
gvret_build_get_dev_info() F1 07 Запросить информацию об устройстве
gvret_build_get_bus_params() F1 06 Запросить параметры шины
gvret_build_get_num_buses() F1 0C Запросить количество шин
gvret_build_keepalive() F1 09 Keepalive-ping
gvret_build_time_sync() F1 01 Синхронизация времени (не используется)

Init-последовательность при подключении:

→ E7          (включить бинарный режим, затем Sleep(50 мс))
→ F1 07       (запрос device info)
→ F1 06       (запрос bus params)
→ F1 0C       (запрос num buses)

Хранилище фреймов (frame_store.c)

Структура хранения

FrameEntry entries[512];   // отсортированный по CAN ID массив
int count;                 // текущее число уникальных ID
uint64_t total;            // суммарное число принятых фреймов
uint64_t last_rate_calc;   // метка времени последнего пересчёта

Массив поддерживается отсортированным — поиск по ID через бинарный поиск, вставка нового ID со сдвигом элементов вправо. Таблица в консоли всегда отсортирована по возрастанию ID.

Поля FrameEntry

struct FrameEntry {
    uint32_t  id;             // CAN ID
    bool      extended;       // true=29-бит, false=11-бит
    uint8_t   bus;            // номер шины (0=CAN0, 1=CAN1)
    uint8_t   dlc;            // длина данных
    uint8_t   data[8];        // актуальные данные
    uint8_t   prev_data[8];   // данные из предыдущего обновления
    uint64_t  count;          // сколько раз пришёл этот ID
    uint64_t  count_snapshot; // count на момент последнего пересчёта частоты
    double    rate;           // фреймов/с (обновляется раз в 0.5 с)
    uint64_t  first_seen;     // GetTickCount64() при первом появлении
    uint64_t  last_seen;      // GetTickCount64() при последнем обновлении
    bool      data_changed;   // true если data изменился в последнем update
};

Логика frame_store_update()

Новый ID:
  → бинарный поиск позиции вставки → сдвиг массива → memset нового элемента
  → data_changed = true
  → data = frame.data, count = 1

Существующий ID:
  → data_changed = (memcmp(data, frame.data) != 0)
  → если изменился: prev_data ← data
  → data ← frame.data
  → count++, last_seen = now

Подсчёт частоты (frame_store_calculate_rates)

Вызывается каждые 100 мс, но реально считает не чаще чем раз в 0.5 с:

double elapsed = (now - store->last_rate_calc) / 1000.0;
if (elapsed < 0.5) return;

for (int i = 0; i < store->count; i++) {
    e->rate = (e->count - e->count_snapshot) / elapsed;
    e->count_snapshot = e->count;
}
store->last_rate_calc = now;
  • Колонка /s обновляется каждые ~0.5 с, не 10 Гц
  • Для нового ID — первые 0.5 с показывается 0

Отображение (display.c)

Инициализация

GetStdHandle(STD_OUTPUT_HANDLE)
SetConsoleMode(... | ENABLE_VIRTUAL_TERMINAL_PROCESSING)  // разрешить ANSI на Windows
printf("\033[?25l")      // скрыть курсор
printf("\033[2J\033[H")  // очистить экран, курсор в (1,1)

Последовательность рендера (каждые 100 мс)

\033[H              ← курсор в (1,1) без очистки экрана
Строка 1: "CAN Monitor v1.0 | Status: Connected  IP: x.x.x.x:23"
Строка 2: диагностическое сообщение (>> ...)
Строка 3: модель устройства + скорость CAN
Строка 4: Total: N frames / Unique: N IDs / Uptime: HH:MM:SS
Строка 5: двойной разделитель (=== ширина терминала)
Строка 6: заголовки колонок
Строка 7: разделитель (--- ширина терминала)
Строки 8..N: строки таблицы CAN-фреймов
\033[J              ← очистить от текущей позиции до конца экрана

Почему нет мерцания: курсор возвращается в начало (\033[H) и перезаписывает строки поверх предыдущих. Каждая строка заканчивается \033[K (очистка до конца строки).

Расчёт числа отображаемых строк

int header_lines = 7;
int max_rows = height - header_lines;

Формат строк таблицы

  CAN ID               | DLC | Data                     |  Count  |  /s
  ─────────────────────────────────────────────────────────────────────
  0x1FF                |  8  | 01 02 03 04 05 06 07 08  | 1000000 |  500
  0x18FF1234           |  4  | AA BB CC DD              |      42 |    1
  • CAN ID: %-20s — 20 символов, 11-бит: 0x%03X, 29-бит: 0x%08X
  • DLC: %d — 1 символ
  • Data: hex-строка с ручным padding до 24 видимых символов. Изменившиеся байты подсвечиваются жёлтым (\033[33m).
  • Count: %7llu
  • /s: %4.0f

ANSI-коды

Код Действие
\033[H Курсор в позицию (1,1)
\033[K Очистить до конца текущей строки
\033[J Очистить от курсора до конца экрана
\033[?25l Скрыть курсор
\033[?25h Показать курсор (при выходе)
\033[1m Жирный
\033[31m Красный (Disconnected)
\033[32m Зелёный (Connected)
\033[33m Жёлтый (диагностика, изменившиеся байты)
\033[36m Голубой (заголовок)

Отладка

Нет соединения

  1. Следить за строкой >> — она показывает каждый шаг подключения.
  2. Если передан IP явно — discovery пропускается, сразу TCP.
  3. TCP-ошибки:
    • WSA 10061 — порт 23 закрыт, прошивка не запущена
    • WSA 10060 — таймаут, проверить Wi-Fi и маршрутизацию
    • WSA 10065 — нет маршрута, ПК не в сети ESP32-AP

Подключён, но фреймов нет

  1. Строка устройства должна показать Device: ESP32_RET build XXXX и CAN0: XXXXXX bps в течение ~200 мс после подключения. Если нет — init-пакеты потеряны, смотреть INIT FAIL в строке >>.
  2. ESP32 должен получить 0xE7 для переключения в бинарный режим. В текстовом режиме он шлёт ASCII-строки — парсер их игнорирует (ждёт 0xF1, всё остальное отбрасывается в состоянии GVRET_IDLE).
  3. Добавить в main.c для проверки потока байт:
    if (n > 0) fprintf(stderr, "RX %d bytes\n", n);
    Запустить с редиректом: can_monitor.exe 2>debug.log

Отладка парсера

Добавить в начало process_byte() в gvret_parser.c:

fprintf(stderr, "state=%d step=%d byte=0x%02X\n", p->state, p->step, b);

Типичные признаки проблем:

Симптом Вероятная причина
Парсер всегда в GVRET_IDLE Байт 0xF1 не приходит, ESP32 в текстовом режиме
Фрейм застрял в READ_CAN_FRAME Поток обрывается посередине фрейма, ждёт следующий recv()
emit_can_frame() не вызывается DLC = 0? Тогда завершение на шаге 9, проверить buf[8]
Все ID = 0 raw_id всегда 0 — проверить buf[4..7] в emit_can_frame()

Частота /s всегда 0

  • Первые 0.5 с после появления ID — норма.
  • Если остаётся 0: проверить что frame_store_update() вызывается и gvret_parser_has_frame() возвращает true.

Проблемы с отображением

  • Колонки съехали: ширина терминала менее ~65 символов. Расширить окно.
  • Нет цветов / ANSI-коды видны как текст: запустить из Windows Terminal или ConEmu. Старый cmd.exe до Windows 10 версии 1903 не поддерживает ENABLE_VIRTUAL_TERMINAL_PROCESSING.
  • Курсор виден: та же проблема с VT-процессингом.

Keepalive

Отправляется каждые 2 с (F1 09). Некоторые прошивки ESP32 разрывают TCP при отсутствии keepalive. Если соединение стабильно рвётся каждые ~10 с — проверить что last_keepalive обновляется и connection_send() возвращает true.


Структура файлов

can2wifi_monitor/
├── .github/
│   └── workflows/
│       └── build.yml       GitHub Actions (MSYS2/MinGW64, artifact upload)
├── src/
│   ├── main.c              Главный цикл, тайминги, управление соединением
│   ├── connection.h/.c     TCP (неблокирующий), UDP-discovery, recv/send
│   ├── gvret_parser.h/.c   Конечный автомат протокола + построители команд
│   ├── frame_store.h/.c    База данных CAN-фреймов, расчёт частоты
│   └── display.h/.c        ANSI-рендер консольного интерфейса
└── Makefile                gcc -std=c11, ws2_32

About

Real-time CAN bus monitor via ESP32S3/Wi-Fi (Windows CLI)

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors