Правка от 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 брать» задают постоянно. Пока в этой таблице одна строка, и та отрицательная.