Правка от 21.08.2026. Текст я писал с языковой моделью, и в первой редакции из-за этого разошлись статья и код: команд инициализации оказалось четыре вместо девяти, а байт-заполнитель уехал из экспериментальной ветки в описание рабочей. Расхождения нашёл @kzkvv, сверив текст с репозиторием. Всё, что он назвал, исправлено — подробности в разделе «Обновления» в конце. Замеры, код и выводы мои; теперь каждый фрагмент кода вставлен из файла, а не пересказан по памяти.
Меня зовут Груздев Дмитрий Михайлович, я инженер АСУТП. Промышленная автоматизация — это измерительные каналы, полевые шины и возня с тем, что «должно работать по стандарту», а по факту работает как получилось у производителя железки. Эта статья — про то, как привычка проверять всё измерением помогла найти конкретное ограничение в самом массовом диагностическом адаптере.
Завязка: плавающий контакт, который не ловится сканером
У меня VW Polo Sedan, и в какой-то момент парктроник начал жить своей жизнью: иногда пищал корректно, иногда выдавал ложное препятствие, иногда молчал. Классическая картина плавающего контакта в жгуте — код ошибки то есть, то нет.
Первым делом я взял то, что берут все: дешёвый ELM327 и телефон с популярным приложением. Оно уверенно показывало двигатель и наотрез отказывалось видеть блок парковочной системы. Ничего удивительного: универсальные приложения работают с OBD-II, а OBD-II обязан покрывать только то, что влияет на выбросы.
Потом взял нормальный сканер, который блок видел, — и упёрся во вторую стену, которая оказалась важнее первой.
Штатный сканер показывает факт, но не показывает момент. На вопрос «есть ли ошибка» он отвечает хорошо. Но при плавающем дефекте вопрос другой: «в какой момент она появляется и что я в этот момент трогал руками». Пока подключаешься, ждёшь инициализацию, листаешь меню до нужного блока — момент пропадания прошёл. А главное — руки. Со сканером они заняты сканером: шевелить проводку и одновременно смотреть в экран в одиночку не выходит.
Отсюда идея, из которой выросло всё остальное: нужен регистратор. Программа сидит в цикле, сама опрашивает блок, пищит в момент появления или исчезновения ошибки и пишет CSV с метками времени. Тогда руки на жгуте, а не на экране, и хронология остаётся на диске.
Первая версия называлась PDC_Diag, была консольной и занимала около 2400 строк. Сейчас это VAG Diag v5.1: 6380 строк в app/ и tests/ (wc -l app/*.py app/ui.html tests/*.py), веб-интерфейс, четыре типа адаптеров, тесты и CI. А по дороге нашлось ограничение, ради которого я и пишу статью.
UDS и ISO-TP: два абзаца для тех, кто не в теме
Диагностика блоков за пределами OBD-II идёт по протоколу UDS (Unified Diagnostic Services) поверх CAN: посылаешь блоку номер сервиса с параметрами, блок отвечает. 0x22 — прочитать данные по идентификатору, 0x19 02 — список ошибок, 0x14 — стереть, 0x10 — переключить сессию, 0x3E — «диагност ещё здесь». В программе ровно эти пять, 11-битные адреса; TP 2.0 я сознательно не реализовывал — на моей машине он не нужен.
Проблема в том, что CAN-кадр несёт максимум 8 байт данных, а список ошибок в них не помещается. Поверх CAN живёт транспортный уровень ISO-TP (ISO 15765-2). Короткий ответ уезжает одним кадром: первый байт — длина, дальше данные, хвост добивается заполнителем. Длинный ответ идёт многокадрово: блок шлёт первый кадр (First Frame), в заголовке которого лежит полная заявленная длина сообщения, и останавливается. Продолжать он не имеет права, пока получатель не пришлёт кадр разрешения на продолжение — Flow Control, начинающийся с байта 0x30. Только после него блок досылает остаток последовательными кадрами.
Пауза с ожиданием разрешения — то место, где всё ломается.
Главное: ELM327 не даёт разрешения на продолжение блокам кузова
Я направил первую рабочую версию на блок парковочной системы и получил странное. Запрос списка ошибок уходит, приходит ровно один кадр — и всё, дальше тишина. А следующий запрос блок отклоняет с кодом «занят» (0x21), и так примерно секунду, после чего оживает. Долго думал, что виноват мой код: переписывал разбор, менял таймауты, ставил задержки. Ничего. Тогда полез смотреть, что реально уходит на шину.
Картина сложилась такая. Прошивка ELM327 ведёт многокадровый обмен ISO-TP только с одной парой адресов — 0x7E0 для запроса и 0x7E8 для ответа. Это адреса блока управления двигателем, зашитые намертво. Увидев первый кадр от 0x7E8, адаптер честно отправляет туда кадр разрешения и дочитывает сообщение целиком.
Блоки кузовной электроники живут по другим адресам и, что важнее, с другим смещением между запросом и ответом: у меня запрос уходит на 0x70A, а ответ приходит с 0x774. Это не «запрос плюс восемь». Адаптер такой ответ видит и показывает — первый кадр отдаёт исправно. Но кадр разрешения в этот адрес не посылает: правило зашито.
Дальше всё механически: блок отдал первый кадр и встал в ожидание. Разрешения нет, блок держит незавершённую передачу и на любой новый запрос отвечает «занят», пока не сработает транспортный таймаут.
Дело оказалось не в коде, не в машине и не в блоке, а в адаптере за шестьсот рублей. ELM327 сделан прежде всего как интерфейс к блоку двигателя. Сырые кадры он отдаёт и с остальными блоками, но сборку многокадрового ответа оставляет программе — и нигде об этом не предупреждает.
Что я пробовал и почему это не сработало
Дальше было четыре недели вечеров, с 12 июля по 9 августа — по записям в журнале обмена четырнадцать подходов к машине. Каждый вариант проверялся на живой машине: теоретически ни один из них не проверяется никак. Результат отрицательный, и именно поэтому его стоит опубликовать.
Способ |
Что произошло |
|---|---|
|
Все четыре команды принимаются с |
|
Адаптер приписывает свой служебный байт поверх нашего — на шину уходит не то, что мы отправили |
|
Заставляет ждать ответ строго по правилу «адрес запроса плюс восемь». Ответ с |
|
Дешёвые клоны отвечают |
Доказательство подмены кадра
Получилось случайно, когда я пытался отправить кадр разрешения вручную в режиме ATCAF0. В этом режиме байт длины формируешь сам. Отправляю 03 22 F1 97 — «длина 3, сервис 0x22, идентификатор F1 97». А блок ругается так, будто номер сервиса у него 0x03: он принял за сервис мой собственный счётчик длины. Значит, между моей строкой и шиной кто-то вставил впереди ещё байт — служебный, который адаптер дописывает сам.
Дальше механика. Кадр разрешения обязан начинаться с байта 0x30. Адаптер приписывает перед ним свой байт — и на шину уходит нечто, у чего первый полубайт не 3. Для блока это не Flow Control, а мусор: он молча его выбрасывает и продолжает ждать разрешения, которого не будет никогда.
После этого я перестал искать комбинацию AT-команд, которая всё исправит.
И вот здесь в первой редакции была моя главная ошибка. Я написал «обойти невозможно», а проверял на одном конкретном клоне, который представляется как v1.5. @200sx_Pilot справедливо возразил в комментариях: Pyren на Рено и Carista на Шкоде работают с теми же дешёвыми адаптерами. Значит, на части адаптеров многокадровый обмен с блоками кузова всё-таки идёт, и правильная формулировка — «на этом адаптере обойти не удалось». Программа с самого начала перебирает три способа (ATCRA; ATCRA с вручную заданным кадром разрешения; сырые кадры, где разрешение шлёт она сама) и теперь пишет в журнал, какой сработал. На моём — ни один.
Что удалось выжать без полного решения
Можно было бы написать «купите нормальное железо». Но машина с плавающим контактом стояла во дворе, а результат нужен был в тот же вечер, а не после доставки. Раз целиком список не читается — выжмем максимум из того, что приходит.
Количество ошибок определяется точно. Полная заявленная длина лежит в заголовке первого кадра — того, который адаптер отдаёт исправно, — а каждая запись в ответе на 19 02 занимает фиксированное число байт. Зная длину, я знаю сколько ошибок в блоке, даже если не могу прочитать их все. Для регистратора это ровно то, что нужно: важна динамика счётчика, а не перечень.
Первая запись приходит целиком. В первом кадре после заголовка хватает места на одну полную запись — значит, у меня есть код первой ошибки и её тип отказа.
Фильтрация по признаку меняет состав списка. Это выяснилось случайно, когда я гонял маски подряд и заметил, что коды в ответах разные. Сервис 19 02 принимает маску состояния — блок возвращает только ошибки с совпадающими битами. Меняя маску, я меняю выборку, и первой оказывается разная ошибка. Прогоняя серию запросов с разными масками, вытаскиваю несколько кодов вместо одного.
# app/pdc_diag.py — выборка кодов по разным маскам состояния. # Полный список не дочитывается, но при каждой маске первой # в ответе оказывается другая запись. Собираем их в объединение. STATUS_MASKS = ("FF", "01", "02", "08", "20", "04", "10", "40", "80") def collect_dtcs_by_masks(elm, req_id, resp_id): found = {} for mask in STATUS_MASKS: frames = elm.request(req_id, resp_id, "1902" + mask) head = parse_first_frame(frames) if head is None: continue # заявленная длина -> точное число записей, даже если список оборван found["_count"] = (head.total_len - 3) // 4 # точное число ошибок rec = head.first_record() # первая запись приходит целиком if rec: found[rec.code] = rec # дубли схлопываются сами time.sleep(2.0) # иначе блок отвечает "занят" (0x21) return found
Пауза в две секунды не выдумана: при 1,5 с блок отвечал 0x21 в трёх попытках из пяти, при 2,0 — ни разу.
Это костыль, и я не собираюсь называть его иначе. Чтения списка целиком он не заменяет — он позволил закрыть конкретную задачу конкретным вечером, и всё.
Настоящее решение: USB-CAN по протоколу SLCAN
Правильный выход оказался и дешевле, и проще, чем я думал. Есть USB-CAN адаптеры, работающие по текстовому протоколу SLCAN — CANable, CANtact и совместимые, стоят примерно как приличный ELM327.
Разница вот в чём: SLCAN-адаптер не имеет логики транспортного уровня вообще. Он отдаёт сырые кадры шины как есть и отправляет сырые кадры, которые ему дали: никаких зашитых адресов, приписанных байтов и «умных» правил. Вся сборка ISO-TP, включая кадр разрешения, делается в моей программе.
И длинные списки читаются целиком, со всех блоков, с первого раза.
# app/pdc_slcan.py — приём многокадрового ответа с ручной отправкой # кадра разрешения на продолжение (Flow Control). FC_CONTINUE = bytes([0x30, 0x00, 0x00, 0, 0, 0, 0, 0]) # BS=0, STmin=0 def read_isotp(self, tx_id, rx_id, timeout=2.0): deadline = time.time() + timeout data, expected, next_sn = bytearray(), 0, 1 while time.time() < deadline: frame = self._read_frame(rx_id) if frame is None: continue pci = frame[0] >> 4 if pci == 0x1: # первый кадр expected = ((frame[0] & 0x0F) << 8) | frame[1] data += frame[2:] self._send_frame(tx_id, FC_CONTINUE) # то, чего не делает ELM327 continue if pci == 0x2: # последовательный кадр if (frame[0] & 0x0F) != next_sn: raise IsoTpError("нарушен порядок кадров") next_sn = (next_sn + 1) & 0x0F data += frame[1:] if len(data) >= expected: return bytes(data[:expected]) raise IsoTpError("ответ оборван: получено %d из %d байт" % (len(data), expected))
Восемь строк логики — ровно то, чего не хватает в прошивке за 500 рублей. Обратная сторона, сборка одиночного запроса:
# app/pdc_core.py — одиночный кадр ISO-TP: байт длины впереди, # хвост добивается заполнителем до восьми байт. def build_single_frame(payload): """Собирает одиночный кадр ISO-TP из полезной нагрузки, с добиванием до 8 байт.""" frame = bytes([len(payload)]) + payload return frame.ljust(8, b"\x00") # build_single_frame(b"\x22\xF1\x97") -> 03 22 F1 97 00 00 00 00
Про заполнитель отдельно, потому что в первой редакции статьи здесь стоял 0xAA и это было неправдой. В рабочем пути добивка нулевая — pdc_core.py:512. Байт 0xAA живёт в другом месте, pdc_diag.py:1224, в экспериментальной ветке «сырые кадры»: там я проверял, отличает ли блок мою добивку от заводской, потому что сам VAG добивает именно AA. Не отличает: длина берётся из первого байта кадра, хвост блоку безразличен. В статью значение уехало из эксперимента и было выдано за рабочее.
Именно байт длины 0x03 блок и принимал за номер сервиса, когда ELM327 приписывал перед ним свой байт.
И детектор обрыва, общий для любого транспорта:
# app/pdc_core.py — определить, что длинный ответ оборван, # и всё равно вытащить заявленную длину. def analyse_response(frames): """Возвращает (данные, полные_ли_данные, заявленная_длина).""" if not frames: return b"", False, 0 if (frames[0][0] >> 4) != 0x1: # короткий ответ n = frames[0][0] & 0x0F return frames[0][1:1 + n], True, n total = ((frames[0][0] & 0x0F) << 8) | frames[0][1] # заявленная длина data = bytearray(frames[0][2:]) for f in frames[1:]: data += f[1:] complete = len(data) >= total if not complete: log.warning("длинный ответ оборван: %d из %d байт — " "адаптер не прислал кадр разрешения", len(data), total) return bytes(data[:total]), complete, total
Программа не притворяется, что всё хорошо: в отчёт идёт, сколько байт получено из скольких заявленных.
Приём, который спас архитектуру: новый интерфейс притворяется старым
К моменту, когда я добрался до SLCAN, программа уже работала с ELM327 по Wi-Fi, USB и Bluetooth. Добавление четвёртого, принципиально другого типа адаптера должно было означать переписывание половины кода. Не означало — благодаря приёму, применённому дважды.
Новый способ связи повторяет методы старого, и код выше по стеку не меняется вовсе.
Первый случай. Изначально ELM327 подключался по Wi-Fi, то есть через сетевой сокет: класс Elm327 вызывал sendall, recv, settimeout, close. Когда понадобился USB-адаптер, я не стал добавлять ветвления «если COM-порт, то иначе», а написал класс SerialChannel, повторяющий ровно эти четыре метода.
# app/pdc_serial.py — COM-порт, притворяющийся сетевым сокетом. class SerialChannel: """Повторяет интерфейс socket: sendall / recv / settimeout / close.""" def __init__(self, port, baudrate=38400, timeout=5.0): self._ser = _open_serial(port, baudrate, timeout) def sendall(self, data: bytes) -> None: self._ser.write(data) self._ser.flush() def recv(self, bufsize: int = 4096) -> bytes: return self._ser.read(bufsize) or b"" def settimeout(self, value) -> None: self._ser.timeout = value def close(self) -> None: self._ser.close() # Подмена в одну строку. Класс Elm327 не знает, что работает не по сети. elm = Elm327() elm.sock = SerialChannel("COM3", baudrate=38400) # вместо socket.create_connection(...) elm.init() # дальше всё как обычно
Ни одной правки в Elm327. Bluetooth приехал бесплатно: в Windows он монтируется как виртуальный COM-порт, то есть это тот же SerialChannel с другим именем.
Второй случай — тот же приём этажом выше. SlcanAdapter повторяет методы Elm327: cmd, set_header, accept_all, identify. Его cmd() принимает запрос в том же виде и возвращает ответ строками того же текстового формата, раскладывая собранные данные обратно в кадры. Внутри работает совсем другой протокол с собственной сборкой ISO-TP, наружу торчит привычный интерфейс.
Весь прикладной разбор — расшифровка кодов, сессии, монитор, экспорт в CSV — остался общим. Итог: четыре типа адаптера и ни одной правки в прикладном коде.
Соблазн был обратный: «правильная» абстракция транспорта с базовым классом, реестром и фабрикой. Хорошо, что не стал — повторение существующего интерфейса оказалось короче и надёжнее, потому что не требовало трогать работающий код.
Восемь строк, каждая из которых стоила вечера
Это таблица из документации проекта. Ни одна строка не была спроектирована — каждая появилась после того, как что-то сломалось на машине и я потратил вечер на причину. Выглядит костылями, пока не знаешь историю.
Место |
Почему так |
|---|---|
Чистка буфера перед каждой командой |
Иначе остаток ответа одного блока читается как ответ следующего, и имена блоков в списке перемешиваются между собой |
Минимум служебных команд перед запросом |
Дешёвый адаптер захлёбывается: после десятка AT-команд подряд начинает возвращать пустоту вместо ответов |
Снятие фильтра через |
|
Повтор при ответе «занят» ( |
Блок остаётся занят примерно секунду после незавершённой длинной передачи |
Пауза 2 секунды между выборками ошибок |
По той же причине — иначе вся серия масок вернёт |
Заявленная длина берётся из заголовка первого кадра |
Позволяет знать точное число ошибок, даже когда список не дочитан |
Расширенная сессия перед стиранием |
Без переключения сессии сервисом |
Отслеживание ответа |
Непонятая адаптером команда молча не выполняется, и дальше программа работает на неверных допущениях |
Дороже всех обошлась последняя строка. Символ ? означает «команда не поддержана»: ничего не падает, ничего не логируется, работа продолжается — просто фильтр, который ты считал поставленным, не поставлен. Два вечера на «плавающий баг», оказавшийся молча проигнорированной командой.
Сверх таблицы есть ещё один механизм: программа считает подряд идущие пустые ответы и, если их больше порога, сама переинициализирует адаптер — ATZ и вся стартовая последовательность заново. Дешёвые клоны иногда уходят в себя, и единственное лечение — сброс; раньше я делал это, дёргая питание.
Вот полная последовательность — pdc_core.py, строки 229–238. В первой редакции статьи я перечислил четыре команды из девяти, потому что писал по памяти, а не по файлу:
ATZ сброс адаптера ATE0 не повторять команду в ответе ATL0 без лишних переводов строки ATS0 без пробелов в ответе ATH1 показывать идентификаторы кадров ATSP6 ISO 15765-4, CAN 11 бит, 500 кбит/с ATCAF1 автосборка ISO-TP ATAT0 предсказуемые тайминги ATST32 ожидание ответа около 200 мс
Плюс ATCF000 и ATCM000 в _recover — обнуление фильтра и маски.
Три команды здесь прямо относятся к теме статьи, и без них разговор теряет смысл. ATH1 — без него не видно, с какого адреса пришёл ответ, а весь разбор смещения +0x6A держится именно на этом. ATAT0 стоит вместо ATAR намеренно: ATAR заставляет ждать ответ по правилу «запрос плюс восемь», и ответы блоков кузова отбрасываются. Список короткий не из эстетики: каждая лишняя команда на дешёвом клоне — риск получить пустоту вместо ответа.
Ещё программа смотрит напряжение бортсети: ниже 11,5 В блоки начнут выдавать ложные ошибки, выше 13,3 В — двигатель запущен, лучше заглушить. Тоже из опыта: половину первого вечера я гонялся за ошибками, которых не было, потому что сел аккумулятор.
Заглушка и тесты: как отлаживать автомобиль на столе
Ездить к машине ради каждой правки — плохая обратная связь. Поэтому я написал mock_elm327.py (313 строк) — заглушку, изображающую автомобиль по сети: отвечает как ELM327 на AT-команды, изображает блок парковочной системы с ошибками, отвечает на стандартные запросы OBD-II, а с ключом --glitch периодически «теряет» датчик, чтобы проверить логику регистратора.
По-настоящему ценной она стала после одной доработки. Заглушка изображает блок кузовной электроники, который отдаёт длинный ответ только после кадра разрешения. Главная особенность настоящих блоков — и главная проблема ELM327 — воспроизводится прямо на столе. С этого момента я отлаживал и детектор обрыва, и SLCAN-сборку, и перебор масок, не выходя из дома.
Поверх заглушки живёт tests/test_smoke.py — 151 строка, 27 проверок, все проходят: разбор кадров ISO-TP, обнаружение обрыва длинного ответа, чтение заявленной длины из заголовка, сборка многокадрового ответа, расшифровка кодов и типов отказа, полный обмен с заглушкой от инициализации до чтения списка.
GitHub Actions гоняет compileall и эти тесты на Python 3.8, 3.11 и 3.12 при каждом push и pull request. Версия 3.8 в матрице потому, что программа должна запускаться на старом гаражном ноутбуке, а не потому, что так принято.
Код ошибки — это три числа, а показывают одно
Возвращаюсь к тому, ради чего всё затевалось. Большинство приложений показывает код ошибки одной строкой — «B1234». Это треть информации: в ответе 19 02 каждая запись содержит номер кода, байт типа отказа и байт состояния. Для поиска дефекта два последних важнее первого.
Байт типа отказа говорит, что произошло в электрической части цепи: обрыв, замыкание, пропажа связи — то есть не логическая ошибка, а физика. Это прямое указание, где искать:
Тип отказа |
Где искать |
|---|---|
Обрыв цепи |
Прозванивать жилу от разъёма блока до разъёма датчика |
Замыкание на массу |
Проверять изоляцию по всей длине участка |
Замыкание на плюс |
Искать протёртую изоляцию рядом с силовой цепью |
Нет сообщений от узла |
Проверять питание и массу самого узла, а не сигнальную линию |
Сигнал нестабилен |
Искать шевелением жгута — статические замеры ничего не покажут |
Байт состояния говорит, живой дефект или исторический, и определяет метод поиска:
Состояние |
Как искать |
|---|---|
Активна сейчас |
Дефект присутствует в момент опроса — искать замерами, мультиметр покажет |
Была ранее |
Плавающий дефект, сейчас цепь в норме — искать только шевелением жгута под наблюдением |
Повторю, потому что это главный практический вывод: состояние важнее номера кода. Номер говорит, какая цепь; состояние — каким инструментом её искать. Гоняться мультиметром за ошибкой в состоянии «была ранее» — гарантированно потерянный вечер.
Как отделить живой дефект от исторического
Когда я впервые прочитал блок парктроника, там лежало восемь ошибок. Половина — следы старого удара в бампер, которые никто не стирал годами: к моей проблеме отношения не имели, только путали.
Методика отделения простая:
Прочитать список и сохранить снимок.
Стереть все ошибки.
Цикл зажигания: выключить, подождать, включить.
Включить заднюю передачу и подержать 30 секунд.
Прочитать список снова.
Вернувшиеся ошибки актуальны. Остальные были историей. У меня из восьми вернулась одна, и задача сузилась с «непонятно что» до «конкретный канал конкретного датчика».
Пункт про заднюю передачу не формальность: без неё блок парковочной системы датчики не опрашивает вообще.
Режим «Монитор» и поиск неисправного датчика за десять минут
Оставался вопрос: какой датчик. Их восемь — четыре в переднем бампере и четыре в заднем. Штатный ответ на такой вопрос: снимать бампер и проверять каждый. Я нашёл способ не снимать.
В программе есть режим «Монитор»: живой счётчик активных ошибок, обновляющийся в цикле. Он работает на заявленной длине из заголовка, то есть точен даже там, где список не дочитывается.
Включить заднюю передачу (без неё опроса нет).
Отключить все датчики проверяемого бампера — работаем по одному бамперу за раз.
Запустить монитор и запомнить, какое число он показывает.
Подключать датчики по одному, ожидая 10–15 секунд после каждого.
Счётчик уменьшился — канал исправен. Не сдвинулся — вот дефектный канал. Десять минут на бампер, снимать ничего не надо. У меня виноватым оказался передний.
Чем всё кончилось с проводкой
Монитор указал на конкретный датчик, а тип отказа показывал «сигнал нестабилен» — значит, статический замер бесполезен, надо шевелить.
Я запустил регистратор с писком, взял жгут в руки и пошёл вдоль него. Пищать начало возле кузовного проёма, где жгут был передавлен. Разобрал гофру — жила с надломленной медью: изоляция целая, проводник переломлен почти полностью и контактирует только в определённом положении.
Перепаял участок, термоусадка, нормальная фиксация. Стёр ошибки, цикл зажигания, задняя передача, чтение. Код не вернулся — и не вернулся через месяц.
От «начал искать» до «нашёл» — минут сорок по меткам времени в CSV, из которых тридцать пять ушло на снятие обшивки. Само обнаружение заняло около трёх минут: руки были на жгуте, а не на экране.
Что ещё появилось в программе
Пока я гонялся за одним проводом, программа обросла тем, что понадобилось по дороге:
Веб-интерфейс на локальном сервере (
127.0.0.1:8765): браузер как оболочка, GUI-фреймворк не нужен, работает и с телефона в том же Wi-Fi.Универсальный раздел OBD-II: коды, готовность систем самодиагностики, VIN, стоп-кадр. Про «любой автомобиль с 2001 года», как было написано в первой редакции, — неправда. В США режимы OBD-II обязательны с 1996 года, в Европе EOBD — для бензиновых с 2001, для дизельных с 2004. У машин с других рынков колодка может стоять, а режимов не быть вовсе.
Живые параметры с автоопределением поддерживаемых и записью в CSV.
Мастер поиска датчика — формализация протокола «отключил — записал»: строит карту «код ↔ датчик» экспериментально, без справочников с распиновкой, которых для многих блоков в открытом доступе нет.
Сравнение снимков до и после ремонта: что ушло, что осталось, что появилось.
Тест аккумулятора и генератора по графику напряжения при пуске и офлайн-словарь стандартных кодов.
Приложение для Android на Kotlin — не обёртка, протокол переписан: исходники, готовый APK. Смысл в нём один: работают Bluetooth-адаптеры, к которым ни из браузера, ни из Termux не подобраться, а в бардачке у большинства лежит именно такой. Живьём пока проверена только связка с программной заглушкой на двух телефонах — Redmi Note 13 и Mi 11 Lite.
Отчёт
REPORT_TO_SEND.txtс полным журналом обмена — один файл, который прикладывается к вопросу на форуме, чтобы отвечающему не пришлось выпытывать подробности по одной.
Рамки специально жёсткие: ноль внешних зависимостей, только стандартная библиотека Python. Архив должен работать сразу после распаковки, вместе с переносимым интерпретатором внутри: в гараже нет ни интернета, ни желания разбираться с pip. Лицензия MIT.
По объёму: в репозитории около 7450 строк, из них код и разметка в app/ и tests/ — около 6380. pdc_diag.py — 1660, webui.py — 1025, ui.html — 985, pdc_core.py — 975, menu.py — 438, mock_elm327.py — 313, pdc_slcan.py — 231, pdc_obd.py — 218, pdc_serial.py — 216, pdc_codes.py — 168, test_smoke.py — 151.
Про LLM-ассистента, коротко и без рекламы
Обвязку писал ассистент: разбор аргументов, работа с CSV, HTML-страница интерфейса, значительная часть тестов. Это ускорило рутину и позволило не тратить внимание на то, в чём нет инженерного содержания.
Стандарт читал человек. ISO 15765-2 и описание сервисов UDS я разбирал сам — иначе было не понять, что вообще происходит с многокадровым обменом. А ограничение ELM327 нашлось только измерением на живой машине. Ассистент уверенно предлагал комбинации AT-команд, которые «должны работать», включая всю четвёрку ATCRA / ATFCSH / ATFCSD / ATFCSM из таблицы выше. Они не работали. Не потому, что ассистент врал: он честно пересказывал документацию, а документация описывает, как задумано, а не как сделано в конкретном клоне. Разница между этими двумя вещами и есть содержание статьи.
Что бы я сделал иначе
Самокритика по пунктам, потому что ошибок было достаточно.
Купил бы SLCAN-адаптер сразу. Он стоит примерно как ELM327. Я потратил месяц вечеров, заставляя ELM327 делать то, чего он не умеет, вместо того чтобы за неделю проверить гипотезу «дело в железе».
Написал бы заглушку первой, а не пятой. День работы, который я откладывал до тех пор, пока ездить к машине из-за каждой правки не стало совсем тошно.
Не делал бы веб-интерфейс так рано. webui.py на 1025 строк плюс ui.html на 985 — треть проекта. Консольная версия закрывала мою задачу полностью, а веб-интерфейс нужен, чтобы программой мог пользоваться кто-то ещё; делать его до появления такого человека было рано.
Логировал бы сырой обмен с первого дня. Полный журнал я добавил, только когда упёрся в загадку с 0x21. Будь он с начала, ограничение обнаружилось бы недели на две раньше: там всё видно, надо было только смотреть.
Чего бы не менял: отказа от внешних зависимостей и приёма с подменой интерфейса.
Чего до сих пор не понимаю: почему один и тот же клон в одних сессиях отвечает на ATCRA внятно, а в других — ?. Гипотеза про перегрев или просадку питания, но я её не проверял.
Что из этого стоит унести
Главное я бы сформулировал так: ELM327 — это не спецификация, а торговая марка, которую переписали кто во что горазд. Мой клон ведёт многокадровый обмен ISO-TP только с парой 0x7E0 / 0x7E8, и AT-командами это не расширяется, потому что дело не в настройке, а в прошивке. На других адаптерах может быть иначе — судя по комментариям, бывает. Проверять придётся опытом, спецификации тут не помогут.
Второе, и это про привычку, а не про CAN: упёрся в стену — проверь, не врёт ли инструмент измерения. Я потратил кучу вечеров на поиск ошибки в своём коде, в машине и в блоке, прежде чем посмотрел, что реально уходит на шину. Байт длины, принятый блоком за номер сервиса, был виден с самого начала. Я просто не смотрел.
И третье. Отрицательный результат стоит публиковать. Таблица из четырёх неработающих обходов полезнее всего остального текста — она экономит следующему тот же месяц. Правда, теперь я знаю, что публиковать его надо с указанием границ: «не работает на вот этом адаптере», а не «не работает».
Программа лежит на GitHub под лицензией MIT: https://github.com/gdm0991/vagdiag. Ставить ничего не нужно — переносимый Python внутри архива.
Что действительно пригодилось бы — отчёты REPORT_TO_SEND.txt с других автомобилей: полный журнал обмена, какие адреса откликнулись, как назвались блоки, что ответили. Из этого складывается справочник «модель — адреса блоков», которого в открытом виде не существует. Каждый такой файл — одна машина, которую следующему не придётся перебирать вслепую.
И вопрос, ради которого я, честно говоря, и пишу: встречался ли кому-нибудь ELM327, который умеет дочитывать длинные ответы от блоков кузова — то есть сам шлёт кадр разрешения на адрес, не связанный с запросом правилом «плюс восемь»? Назовите модель и версию прошивки. Я перебрал всё, что было под рукой, и не нашёл ни одного. Буду рад ошибиться.
Обновления
21 августа 2026. Статью правил по комментариям. Ниже — что именно и по чьему замечанию, чтобы обсуждение выше не повисло в воздухе.
Что было |
Что стало |
Кто заметил |
|---|---|---|
Четыре команды инициализации |
Все девять плюс две в |
|
Заполнитель |
В рабочем пути |
|
«Восемь датчиков» без объяснения |
Четыре спереди, четыре сзади; проверяем по одному бамперу |
|
«OBD-II на любом автомобиле с 2001 года» |
США с 1996, Европа для бензиновых с 2001 и для дизелей с 2004; колодка ничего не гарантирует |
|
«Блок увидел электрически», «боковой режим» |
Написано человеческими словами |
|
«Обойти невозможно» |
«На этом адаптере обойти не удалось»; программа перебирает три способа и сообщает, какой сработал |
|
«ELM327 — это спецификация» (подразумевалось по умолчанию) |
«ELM327 — торговая марка, которую переписали кто во что горазд; совместимость проверяется только опытом» — это стало главным выводом статьи |
Отдельно про то, что нашлось по ходу правок и оказалось важнее самих правок. Комментарий 200sx_Pilot заставил меня сверить код с кодом — и выяснилось, что в Android-порт лестница из трёх способов дочитать длинный ответ не попала вовсе. Приложение получало первый кадр и сдавалось. Увидеть это тестами было нельзя: заглушка в обеих версиях отдавала только первый кадр, и разница не проявлялась. В версии 1.2 лестница перенесена, и в журнал пишется, какой способ сработал.
Формулировку про торговую марку я взял у @NutsUnderline почти дословно: она короче и точнее того, к чему я шёл сам полтора абзаца.
И просьба, которая стала осмысленной только сейчас. Если у вас ELM327, на котором первая или вторая ступень срабатывает — напишите, что написано на плате и что адаптер отвечает на ATI. Из таких строчек складывается таблица «плата — прошивка — что умеет», которой нигде нет, а вопрос «какой ELM327 брать» задают постоянно. Пока в этой таблице одна строка, и та отрицательная.
Комментарии (11)

Oden
20.08.2026 11:04Планируется ли портирование на андроид?

GDM0991 Автор
20.08.2026 11:04Уже не планируется — уже лежит. Kotlin и Compose, не обёртка над питоновской версией, протокол переписан.
Исходники: https://github.com/gdm0991/vagdiag/tree/main/android-app
Готовый APK: https://github.com/gdm0991/vagdiag/releases
Ради чего вообще стоило делать: Bluetooth-адаптеры. Из браузера к ним не подобраться, из Termux тоже, а в бардачке у большинства лежит именно такой. Wi-Fi и USB через OTG тоже есть, но USB живьём проверить не смог — кабеля у меня нет.
Чтобы не было завышенных ожиданий: полный цикл приложение прошло пока через программную заглушку адаптера, на двух телефонах — Redmi Note 13 (Android 15) и Mi 11 Lite (Android 13). На живой машине компьютерная версия обкатана куда лучше. Поставите и что-то отвалится — заводите issue, мне это сейчас полезнее всего.

sav13
20.08.2026 11:04А почему бы не использовать VAG-KKL, VAG-COM или что-то подобное?
Предназначение ELM327 все же считать основные параметры двигателя и сбросить ошибку у тысячи различных авто с телефона.
А для нормальной работы с машинами есть диагностические сканеры

GDM0991 Автор
20.08.2026 11:04Спорить не буду: для регулярной работы нормальный сканер быстрее и удобнее.
Задача была другая. При плавающем контакте сканер отвечает на вопрос «есть ли ошибка», а мне нужно было «в какой момент она появляется, когда я шевелю вот этот кусок жгута». Для такого нужен самописец: сидит в цикле, пищит на изменение счётчика, пишет CSV с метками времени. Второй мотив менее уважительный — было интересно, сколько можно выжать из железки за шестьсот рублей. Оказалось, меньше, чем хотелось, и статья в основном про то, где именно упираешься.
VAG-KKL — это K-line, на Polo Sedan с CAN он бы не помог. VCDS хорош, но у меня его нет, а покупать ради одного перебитого провода не хотелось.

kzkvv
20.08.2026 11:04Автор, спасибо за статью. Однако на хабр заходишь за человеческими текстами, которые писал человек, в своем собственном авторском стиле. После прочтения этой статьи остался осадок будто пообщался с claude/gpt - это не исправление ошибок нейронкой в готовом тексте, тут реально половина статьи нейронкой написана.
Но буду объективен. Технические несоответствия:
Сама последовательность специально короткая:
ATZ,ATE0(выключить эхо),ATSP6(ISO 15765-4, CAN 11 бит, 500 кбит/с),ATCAF1. Всё. Каждая лишняя команда — риск получить пустоту вместо ответа.В тексте выше у вас 4 команды. В гите в методе Elm327.init - 9 штук. Плюс _recover добавляет поверх еще 2 штуки, но это уже мелочи.
Либо если речь о сбросе идет (reset_state), то там тоже больше 4 команд - 7 штук или более.Elm327.init

Оставался вопрос: какой из восьми датчиков
В коде задкументированы "все четыре задних датчика" и нет упоминания передних, если подразумевается что остальные 4 спереди. Либо LLM-ка выдумала +4 датчика?
Скрытый текст

PAD = 0xAAВ тексте статьи у вас "заполнитель"
0xAA, но на гите 0x00 - это прям разные байты. И в целом гит и статья будто бы расходятся.Статья:
PAD = 0xAA # заполнитель; блок его игнорирует def build_single_frame(payload: bytes) -> bytes: if len(payload) > 7: raise ValueError("не помещается в одиночный кадр") frame = bytes([len(payload)]) + payload return frame + bytes([PAD]) * (8 - len(frame))Гит:
def build_single_frame(payload): """Собирает одиночный кадр ISO-TP из полезной нагрузки, с добиванием до 8 байт.""" frame = bytes([len(payload)]) + payload return frame.ljust(8, b"\x00")Универсальный раздел OBD-II — на любом автомобиле с 2001 года
Подушню, есть машины 2001-2002 годов как минимум без OBD2. Тот же "патруль" например ( Nissan Patrol Y61 3.0 ZD30, 2002–2004). Наличие 16-пинового разъема не гарантирует поддержку OBD2. Полная поддержка пришла уже позже.
Далее ничего технического, чисто наблюдения:
OBD-II обязан покрывать только то, что влияет на выбросы
Какие именно выбросы в данном контексте имеются ввиду? Выбросы данных - все равно по контексту не подходит.
обе руки заняты сканером, а не жгутом
...
Руки свободны, хронология остаётся на диске
В руках жгут, руки заняты.
а на самом деле это интерфейс к блоку двигателя с боковым режимом «покажу сырые кадры, дальше сам»
Что такое "боковой режим"? С автомобилями и CAN знаком, но не понимаю этой формулировки.
Байт типа отказа говорит, что блок увидел электрически.
увидел электрически - это как? Это ведь явно не технический сленг.
Как итог: человек с аналогичной проблемой будет вынужден разбираться с нейрослопом вместо реального "дебага".
Статья "от себя" и без LLM на условном drive2 была бы куда более полезна, чем обилие галюцинаций.
GDM0991 Автор
20.08.2026 11:04Спасибо за разбор. Открыть гит и сверить с текстом — это больше, чем я мог рассчитывать, и по делу почти всё.
Про нейросеть отвечу сразу, иначе остальное будет выглядеть отпиской. Да, текст я писал с языковой моделью. И расхождения, которые вы нашли, вылезли ровно там, где я диктовал по памяти вместо того, чтобы открыть файл. Это не оправдание, это причина: сверялся бы с кодом — девять команд не превратились бы в четыре.
Команды инициализации. Ваша правда. pdc_core.py, строки 229–238: ATZ, дальше ATE0, ATL0, ATS0, ATH1, ATSP6, ATCAF1, ATAT0, ATST32. В _recover сверху ещё ATCF000 и ATCM000. Хуже, чем просто неточность: три из них прямо про тему статьи. Без ATH1 не видно, с какого адреса пришёл ответ, и весь разговор про смещение теряет смысл. ATAT0 стоит вместо ATAR намеренно — в статье про это есть, а в списке команд нет. Кто повторил бы за мной, получил бы другое поведение адаптера и не понял почему.
PAD. Тоже ваша. Рабочий путь — pdc_core.py, строка 512, добивка нулями. 0xAA живёт в pdc_diag.py, строка 1224, в третьем способе с сырыми кадрами: я там проверял, отличает ли блок мою добивку от заводской — VAG добивает AA. Не отличает, длина берётся из первого байта. В статью значение уехало из эксперимента и было выдано за рабочее.
Восемь датчиков против четырёх. Тут противоречия нет, но виноват всё равно я — не объяснил. Датчиков восемь: четыре в переднем бампере, четыре в заднем. Отключаешь по одному бамперу за раз, поэтому в подсказке четыре. Формулировку в menu.py уже поправил, в статью допишу.
На любом автомобиле с 2001 года — ошибка, и ваш Patrol ровно тот случай. Правильно: в США OBD-II обязателен с 1996, в Европе EOBD для бензиновых с 2001 и для дизелей с 2004, а у машин с других рынков разъём на месте, а режимов нет. В README и в pdc_obd.py уже исправлено, в Android-версии тоже.
Выбросы — выхлопные. OBD-II затевался как экологическое требование, и обязательные режимы покрывают то, что влияет на состав выхлопа. Написал так, что вышла загадка.
Руки — поймали. Со сканером руки заняты сканером, с регистратором — жгутом, и это не «свободные руки». Имел в виду, что не надо одновременно смотреть в экран и щупать проводку.
Боковой режим и увидел электрически — крыть нечем, я это сам сочинил, и никому оно не помогло. Первое означало, что ELM327 сделан прежде всего как интерфейс к блоку двигателя, а сырые кадры отдаёт постольку-поскольку. Второе — что байт типа отказа описывает электрику цепи: обрыв, замыкание, а не логическую ошибку. Так и напишу.
Ещё одно, чего вы не писали, но что всплыло из комментария 200sx_Pilot: «обойти невозможно» сказано слишком широко. Проверял-то я на одном клоне.
Правки внесу сегодня отдельным разделом с датой, чтобы ваш комментарий не повис в воздухе. Вечера мне это стоило, но сэкономит больше.

NutsUnderline
20.08.2026 11:04наименование ELM327 гарантирует... да наверно практически ничего: сколь я помню там полно неоригиналов с очень разной степенью совместимости. это закрытая soc со своим промежуточным протоколом который пытаются эмулировать. так что да это тот случай когда "все свое" гораздо лучше

GDM0991 Автор
20.08.2026 11:04Да, и я на этом ровно и попался: решил, что ELM327 — это спецификация. А это марка, которую переписали кто во что горазд, и совместимость проверяется только опытом.
Отсюда практический вывод, до которого я дошёл поздно: сравнивать адаптеры надо не по надписи, а по поведению. Программа теперь перебирает три способа дочитать длинный ответ и пишет в журнал, какой сработал. Если такие строчки собирать от разных людей, сложится таблица «плата — прошивка — что умеет». Пока в ней одна строка, и та отрицательная.
Про «всё своё» согласен целиком. SLCAN-адаптер, у которого логики транспортного уровня нет вообще, снял вопрос за вечер и стоит столько же.
200sx_Pilot
ELM за шесть долларов плюс Pyren или PyClip читают Рено вдоль и поперек.
Кариста с тем же адаптером читает и позволяет частично что-то менять в Шкоде.
Я даже и не знаю, что сказать. Пирен вроде даже не скрывает кода.
GDM0991 Автор
В точку, и возразить нечем. Проверял я на одном клоне — том, что представляется как v1.5. Написал «обойти нельзя», а честно было бы «на этом обойти не вышло».
Полез смотреть Pyren после вашего комментария и наткнулся на неприятную для себя вещь. В версии для компьютера у меня с самого начала была лестница из трёх способов заставить адаптер дочитать длинный ответ: ATCRA и адаптер справляется сам; ATCRA плюс вручную заданный кадр разрешения; сырые кадры, где разрешение шлёт сама программа. А в Android-порт я эту лестницу просто не перенёс — приложение получало первый кадр и сдавалось. Заметить было нечем: заглушка в обоих случаях отдавала только первый кадр, разницы не видно. Нашлось только сверкой кода с кодом, и повод посмотреть дал ваш комментарий.
Теперь лестница есть в обеих версиях, и в журнал пишется, какой способ сработал, вроде «0x70A: длинный ответ дочитан — адаптер справился сам (ATCRA)». Мой клон по-прежнему не умеет, доходит до третьей ступени и там встаёт.
Если тот адаптер, которым Pyren читает Рено, у вас под рукой — скажите, что на плате написано и что он отвечает на ATI. Хочу собрать список «какой адаптер что умеет»: такого списка нигде нет, а вопрос «какой ELM327 брать» задают постоянно. Пока в списке одна строка, и та отрицательная.
200sx_Pilot
https://www.drive2.ru/l/466148126151934429/