Консольное приложение для мониторинга 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
| Файл | Ответственность |
|---|---|
connection.c |
TCP-соединение, UDP-discovery, recv/send |
gvret_parser.c |
Разбор бинарного протокола, построение команд |
frame_store.c |
База CAN-фреймов, подсчёт частоты |
display.c |
ANSI-рендер консоли |
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
Приложение слушает UDP-порт 17222, ожидая широковещательный пакет от ESP32.
Ожидаемый пакет: 4 байта = { 0x1C, 0xEF, 0xAC, 0xED }
IP-адрес отправителя становится целевым адресом.
- Блокирует поток на
timeout_ms= 5000 мс - Если пакет не пришёл — сообщение пользователю, повтор через 3 с
- Если IP передан аргументом — discovery пропускается полностью
Debug: Discovery не срабатывает, если ESP32 и ПК в разных подсетях или прошивка ESP32 не отправляет broadcast.
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-фреймов или половина фрейма —
парсер справляется с этим через внутренний конечный автомат.
Все пакеты начинаются с двух байт: 0xF1 <команда>.
Парсер в состоянии GVRET_IDLE ждёт байт 0xF1, затем читает код команды.
| Байт команды | Название | Длина 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 байт (пропускается) |
После заголовка 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
Что происходит на шаге ②:
| Команда | Состояние | Байт данных |
|---|---|---|
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().
| Функция | Байты | Назначение |
|---|---|---|
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)
FrameEntry entries[512]; // отсортированный по CAN ID массив
int count; // текущее число уникальных ID
uint64_t total; // суммарное число принятых фреймов
uint64_t last_rate_calc; // метка времени последнего пересчётаМассив поддерживается отсортированным — поиск по ID через бинарный поиск, вставка нового ID со сдвигом элементов вправо. Таблица в консоли всегда отсортирована по возрастанию ID.
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
};Новый 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
Вызывается каждые 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
GetStdHandle(STD_OUTPUT_HANDLE)
SetConsoleMode(... | ENABLE_VIRTUAL_TERMINAL_PROCESSING) // разрешить ANSI на Windows
printf("\033[?25l") // скрыть курсор
printf("\033[2J\033[H") // очистить экран, курсор в (1,1)\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
| Код | Действие |
|---|---|
\033[H |
Курсор в позицию (1,1) |
\033[K |
Очистить до конца текущей строки |
\033[J |
Очистить от курсора до конца экрана |
\033[?25l |
Скрыть курсор |
\033[?25h |
Показать курсор (при выходе) |
\033[1m |
Жирный |
\033[31m |
Красный (Disconnected) |
\033[32m |
Зелёный (Connected) |
\033[33m |
Жёлтый (диагностика, изменившиеся байты) |
\033[36m |
Голубой (заголовок) |
- Следить за строкой
>>— она показывает каждый шаг подключения. - Если передан IP явно — discovery пропускается, сразу TCP.
- TCP-ошибки:
WSA 10061— порт 23 закрыт, прошивка не запущенаWSA 10060— таймаут, проверить Wi-Fi и маршрутизациюWSA 10065— нет маршрута, ПК не в сети ESP32-AP
- Строка устройства должна показать
Device: ESP32_RET build XXXXиCAN0: XXXXXX bpsв течение ~200 мс после подключения. Если нет — init-пакеты потеряны, смотретьINIT FAILв строке>>. - ESP32 должен получить
0xE7для переключения в бинарный режим. В текстовом режиме он шлёт ASCII-строки — парсер их игнорирует (ждёт0xF1, всё остальное отбрасывается в состоянииGVRET_IDLE). - Добавить в
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() |
- Первые 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-процессингом.
Отправляется каждые 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