#include <WiFi.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <SettingsESP.h>
#include "driver/twai.h"
#include <initializer_list>
// Бортовой компьютер на ESP32: читает данные автомобиля через диагностический
// разъём OBD-II по CAN, показывает их на дисплее и на странице Settings.
//
// Плата подключается к шине через трансивер SN65HVD230: сам контроллер
// TWAI в ESP32 выдаёт логические уровни TXD/RXD, а шине нужна дифференциальная
// пара CANH/CANL. Трансивер питается от 3,3 В.
//
// ESP32 D5 → CTX (TXD трансивера) CANH → вывод 6 разъёма OBD-II
// ESP32 D4 ← CRX (RXD трансивера) CANL → вывод 14 разъёма OBD-II
// ESP32 3V3 → 3V3 GND → выводы 4/5 (общая земля)
// LCD1602 (I2C 0x27): SDA D21, SCL D22; кнопка D13 на землю
//
// Что умеет:
// - циклически опрашивает показания (службы OBD-II 01): обороты, скорость,
// температуры, нагрузку, расход воздуха, дроссель, топливо, напряжение,
// коррекции топлива; спрашивает у блока, какие из них он поддерживает;
// - считает то, чего в автомобиле нет: мгновенный и средний расход,
// пробег и время поездки, запас хода;
// - читает и (по разрешению) сбрасывает коды неисправностей, читает VIN;
// - сканирует блоки на шине (запрос всем, 0x7DF) и отправляет свои запросы
// UDS «чтение данных по идентификатору» (служба 0x22) — для
// автомобилей группы VAG это способ дотянуться до измеряемых блоков,
// адреса и идентификаторы которых зависят от модели (в странице — поле
// «Свой запрос UDS»);
// - все настройки — на странице Settings (кнопка «Веб-интерфейс»).
//
// Важно про настоящий автомобиль:
// - Программа только ЧИТАЕТ. Запись в автомобиль (сброс кодов) заперта
// выключателем на странице и после каждого включения платы снова
// выключена.
// - В автомобилях VAG диагностический разъём соединён с шиной через шлюз;
// стандартные запросы OBD-II он пропускает, а расширенные измерения
// и управление (например, отопитель) требуют своих сеансов и кодов доступа.
// - Плата питается от бортсети через понижающий преобразователь 12→5 В
// с защитой, а не от вывода 16 разъёма напрямую.
// --- сеть -------------------------------------------------------------
const char *SSID = "PinPort-GUEST", *PASSWORD = "";
const char *AP_SSID = "CarComputer", *AP_PASSWORD = "carcomp12";
const char *ADMIN_PASSWORD = ""; // пусто — страница без пароля
// --- выводы -----------------------------------------------------------
const gpio_num_t PIN_CAN_TX = GPIO_NUM_5; // к CTX трансивера
const gpio_num_t PIN_CAN_RX = GPIO_NUM_4; // к CRX трансивера
const byte PIN_BUTTON = 13; // страница дисплея; долго — сброс поездки
const byte PIN_LED = 2; // мигает, когда автомобиль отвечает
LiquidCrystal_I2C lcd(0x27, 16, 2);
// --- показания --------------------------------------------------------
struct PidInfo { uint8_t pid; const char *name; const char *unit; bool show; };
enum { P_RPM, P_SPEED, P_COOLANT, P_OIL, P_INTAKE, P_AMBIENT, P_LOAD, P_THROTTLE, P_MAF,
P_MAP, P_FUEL, P_VOLT, P_STFT, P_LTFT, P_TIMING, P_STATUS, PIDS };
const PidInfo PID[PIDS] = {
{ 0x0C, "Обороты", "об/мин", true },
{ 0x0D, "Скорость", "км/ч", true },
{ 0x05, "Охлаждающая жидкость", "°C", true },
{ 0x5C, "Масло", "°C", true },
{ 0x0F, "Воздух на впуске", "°C", true },
{ 0x46, "Воздух снаружи", "°C", true },
{ 0x04, "Нагрузка двигателя", "%", true },
{ 0x11, "Дроссель", "%", true },
{ 0x10, "Расход воздуха (MAF)", "г/с", true },
{ 0x0B, "Давление во впуске", "кПа", true },
{ 0x2F, "Уровень топлива", "%", true },
{ 0x42, "Напряжение бортсети", "В", true },
{ 0x06, "Коррекция топлива, короткая", "%", true },
{ 0x07, "Коррекция топлива, длинная", "%", true },
{ 0x0E, "Угол опережения", "°", true },
{ 0x01, "Состояние (лампа, число кодов)", "", false },
};
// Запросы. Типы объявлены выше всех функций: Arduino вставляет прототипы
// перед первой из них, и типам в них надо уже существовать.
enum Kind : uint8_t { K_NONE, K_PID, K_SUPPORT, K_SCAN, K_VIN, K_DTC, K_CLEAR, K_UDS };
struct Job { Kind kind; uint32_t reqId, respId; uint8_t len; uint8_t req[7]; uint8_t arg; };
float val[PIDS];
bool have[PIDS]; // хоть раз получено
bool supported[PIDS]; // блок сказал, что поддерживает (до опроса — считаем, что да)
uint32_t valAt[PIDS];
bool mil = false; // лампа неисправности двигателя
uint8_t milCount = 0;
// --- настройки --------------------------------------------------------
struct Config {
uint8_t version;
uint8_t rate; // 0 — 500 кбит/с, 1 — 250, 2 — 125
uint8_t target; // 0 — двигатель (0x7E0), 1 — всем (0x7DF)
uint16_t period; // пауза между запросами, мс
uint16_t pids; // какие показания опрашивать (по одному биту)
uint16_t tank; // бак, л
bool logFrames; // писать кадры CAN в журнал
};
const uint8_t VERSION = 2;
Config cfg;
Preferences prefs;
bool allowWrite = false; // нарочно не сохраняется
void setDefaults() {
cfg.version = VERSION;
cfg.rate = 0;
cfg.target = 0;
cfg.period = 120;
cfg.pids = 0x7fff;
cfg.tank = 55;
cfg.logFrames = false;
}
void saveSettings() {
prefs.begin("carcomp", false);
prefs.putBytes("cfg", &cfg, sizeof cfg);
prefs.end();
}
void loadSettings() {
prefs.begin("carcomp", false);
size_t got = prefs.getBytes("cfg", &cfg, sizeof cfg);
prefs.end();
if (got == sizeof cfg && cfg.version == VERSION) return;
setDefaults();
saveSettings();
}
// --- журнал -----------------------------------------------------------
sets::Logger journal(1800);
sets::Logger canLog(1800);
void note(const String &line) {
uint32_t s = millis() / 1000;
char stamp[16];
snprintf(stamp, sizeof stamp, "%02lu:%02lu:%02lu ", s / 3600, s / 60 % 60, s % 60);
journal.print(stamp);
journal.println(line);
Serial.println(line);
}
String hexId(uint32_t v) {
String s = String(v, HEX);
s.toUpperCase();
return s;
}
String hexBytes(const uint8_t *d, size_t n) {
String s;
char b[4];
for (size_t i = 0; i < n; i++) {
snprintf(b, sizeof b, "%02X", d[i]);
if (i) s += ' ';
s += b;
}
return s;
}
// --- шина CAN: драйвер --------------------------------------------------
bool canUp = false;
const char *RATE_NAME = "500 кбит/с;250 кбит/с;125 кбит/с";
bool canStart() {
twai_general_config_t g = TWAI_GENERAL_CONFIG_DEFAULT(PIN_CAN_TX, PIN_CAN_RX, TWAI_MODE_NORMAL);
g.tx_queue_len = 8;
g.rx_queue_len = 32;
twai_timing_config_t t;
if (cfg.rate == 1) t = TWAI_TIMING_CONFIG_250KBITS();
else if (cfg.rate == 2) t = TWAI_TIMING_CONFIG_125KBITS();
else t = TWAI_TIMING_CONFIG_500KBITS();
// Приёмный фильтр: ответы диагностики лежат в 0x700…0x7FF (двигатель —
// 0x7E8, коробка — 0x7E9, у VAG на UDS — 0x77A…). Всё остальное, чем
// шина полна в движении, контроллер отбрасывает сам, не будя процессор.
// В маске единица значит «не важно».
twai_filter_config_t f;
f.acceptance_code = (uint32_t)0x700 << 21;
f.acceptance_mask = ~((uint32_t)0x700 << 21);
f.single_filter = true;
if (twai_driver_install(&g, &t, &f) != ESP_OK) { note("CAN: драйвер не установился"); return false; }
if (twai_start() != ESP_OK) { note("CAN: драйвер не запустился"); twai_driver_uninstall(); return false; }
canUp = true;
note(String("CAN: запущен на ") + (cfg.rate == 1 ? "250" : cfg.rate == 2 ? "125" : "500") + " кбит/с (TX D5, RX D4)");
return true;
}
void canStop() {
if (!canUp) return;
twai_stop();
twai_driver_uninstall();
canUp = false;
}
bool canSend(uint32_t id, const uint8_t *data, uint8_t n) {
twai_message_t m = {};
m.identifier = id;
m.data_length_code = 8;
memset(m.data, 0x55, 8); // заполнитель, как у блоков VAG
memcpy(m.data, data, n);
if (cfg.logFrames) canLog.println(String("→ ") + hexId(id) + " " + hexBytes(m.data, 8));
return twai_transmit(&m, 0) == ESP_OK;
}
// --- транспорт ISO-TP и запросы ------------------------------------------
const byte JOBS = 8;
Job jobs[JOBS];
byte jobHead = 0, jobCount = 0;
bool addJob(Kind k, uint32_t reqId, uint32_t respId, std::initializer_list<uint8_t> req, uint8_t arg) {
if (jobCount >= JOBS) return false;
Job &j = jobs[(jobHead + jobCount) % JOBS];
j.kind = k; j.reqId = reqId; j.respId = respId; j.arg = arg;
j.len = 0;
for (uint8_t b : req) if (j.len < 7) j.req[j.len++] = b;
jobCount++;
return true;
}
bool addJob(Kind k, uint32_t reqId, uint32_t respId, std::initializer_list<uint8_t> req) {
return addJob(k, reqId, respId, req, 0);
}
struct Txn {
bool active;
Job job;
bool functional;
uint32_t sentAt, deadline;
uint32_t from; // кто отвечает
uint8_t buf[96];
uint16_t need, have;
uint8_t sn;
bool multi;
} tx;
// Результаты для страницы.
uint32_t lastRx = 0; // когда пришёл последний кадр-ответ
uint32_t okCount = 0, missCount = 0;
byte missRun = 0;
String vin = "—";
String scanText = "не сканировали";
uint32_t scanIds[8];
byte scanN = 0;
uint16_t dtcs[12]; // код в кодировке SAE J2012: два байта
byte dtcN = 0;
bool dtcRead = false;
String udsResult = "—";
String supportText = "не опрашивали";
bool suppApplied = false;
uint32_t suppMask[3]; // поддерживаемые PID по диапазонам 01–20, 21–40, 41–60
byte suppGot = 0;
const char *UDS_NRC(uint8_t c) {
switch (c) {
case 0x10: return "общий отказ";
case 0x11: return "служба не поддерживается";
case 0x12: return "подфункция не поддерживается";
case 0x13: return "неверная длина запроса";
case 0x22: return "условия не выполнены";
case 0x31: return "идентификатор вне диапазона (не поддерживается)";
case 0x33: return "нет доступа: нужен сеанс и код";
case 0x78: return "ответ будет позже";
default: return "причина не расшифрована";
}
}
void decodePid(byte i, const uint8_t *d, uint8_t n) {
float v = NAN;
uint8_t a = n > 0 ? d[0] : 0, b = n > 1 ? d[1] : 0;
switch (PID[i].pid) {
case 0x0C: v = (a * 256 + b) / 4.0; break;
case 0x0D: v = a; break;
case 0x05: case 0x0F: case 0x46: case 0x5C: v = (float)a - 40; break;
case 0x04: case 0x11: case 0x2F: v = a * 100.0 / 255.0; break;
case 0x10: v = (a * 256 + b) / 100.0; break;
case 0x0B: v = a; break;
case 0x42: v = (a * 256 + b) / 1000.0; break;
case 0x06: case 0x07: v = (a - 128) * 100.0 / 128.0; break;
case 0x0E: v = a / 2.0 - 64; break;
case 0x01: {
// Число кодов изменилось — значит, пора прочитать сами коды.
uint8_t n = a & 0x7f;
if (n != milCount || !dtcRead) addJob(K_DTC, 0x7E0, 0x7E8, { 0x03 });
mil = a & 0x80; milCount = n; v = n;
break;
}
}
val[i] = v;
have[i] = true;
valAt[i] = millis();
}
void finishTxn(bool ok) {
tx.active = false;
if (ok) { okCount++; missRun = 0; }
}
String dtcName(uint16_t v);
void onPayload(uint32_t from, const uint8_t *p, uint16_t n) {
if (n == 0) return;
lastRx = millis();
Kind k = tx.job.kind;
if (p[0] == 0x7f) {
uint8_t nrc = n > 2 ? p[2] : 0;
if (nrc == 0x78) { tx.deadline = millis() + 2000; return; } // «ждите»
if (k == K_UDS) {
udsResult = String("отказ 0x") + hexId(nrc) + ": " + UDS_NRC(nrc);
note("UDS: " + udsResult);
} else {
note(String("отказ службы 0x") + hexId(p[1]) + ": " + UDS_NRC(nrc));
}
finishTxn(true);
return;
}
switch (k) {
case K_PID:
if (p[0] == 0x41 && n >= 3 && p[1] == PID[tx.job.arg].pid) decodePid(tx.job.arg, p + 2, n - 2);
finishTxn(true);
break;
case K_SUPPORT:
if (p[0] == 0x41 && n >= 6) {
uint32_t m = ((uint32_t)p[2] << 24) | ((uint32_t)p[3] << 16) | ((uint32_t)p[4] << 8) | p[5];
suppMask[tx.job.arg] = m;
suppGot |= 1 << tx.job.arg;
}
finishTxn(true);
break;
case K_SCAN:
// Отвечают несколько блоков: собираем всех до конца окна ожидания.
if (scanN < 8) { bool seen = false; for (byte i = 0; i < scanN; i++) if (scanIds[i] == from) seen = true; if (!seen) scanIds[scanN++] = from; }
break;
case K_VIN:
if (p[0] == 0x49 && n >= 20) {
vin = "";
for (byte i = 0; i < 17; i++) vin += (char)p[3 + i];
note("VIN: " + vin);
}
finishTxn(true);
break;
case K_DTC:
if (p[0] == 0x43 && n >= 2) {
dtcN = 0;
for (byte i = 0; i < p[1] && dtcN < 12 && 2 + i * 2 + 1 < n; i++) {
dtcs[dtcN++] = (uint16_t)p[2 + i * 2] << 8 | p[3 + i * 2];
}
dtcRead = true;
String list;
for (byte i = 0; i < dtcN; i++) list += String(i ? ", " : "") + dtcName(dtcs[i]);
note(String("коды: ") + (dtcN ? list : String("нет")));
}
finishTxn(true);
break;
case K_CLEAR:
if (p[0] == 0x44) { dtcN = 0; dtcRead = true; mil = false; milCount = 0; note("коды неисправностей сброшены"); }
finishTxn(true);
break;
case K_UDS:
if (p[0] == 0x62 && n >= 3) {
String s = String("DID ") + hexBytes(p + 1, 2) + ": " + hexBytes(p + 3, n - 3 > 24 ? 24 : n - 3);
bool text = n > 3;
for (uint16_t i = 3; i < n; i++) if (p[i] < 32 || p[i] > 126) text = false;
if (text) { s += " «"; for (uint16_t i = 3; i < n; i++) s += (char)p[i]; s += "»"; }
udsResult = s;
note("UDS: " + s);
} else {
udsResult = "неожиданный ответ: " + hexBytes(p, n > 12 ? 12 : n);
}
finishTxn(true);
break;
default:
finishTxn(true);
}
}
void startTxn(const Job &j) {
memset(&tx, 0, sizeof tx);
tx.active = true;
tx.job = j;
tx.functional = j.reqId == 0x7DF;
uint8_t d[8];
d[0] = j.len;
memcpy(d + 1, j.req, j.len);
canSend(j.reqId, d, j.len + 1);
tx.sentAt = millis();
tx.deadline = tx.sentAt + (j.kind == K_SCAN ? 300 : 250);
}
void sendFlowControl(uint32_t toId) {
const uint8_t fc[3] = { 0x30, 0x00, 0x00 }; // продолжай, без ограничений
canSend(toId, fc, 3);
}
void onFrame(const twai_message_t &m) {
if (cfg.logFrames) canLog.println(String("← ") + hexId(m.identifier) + " " + hexBytes(m.data, m.data_length_code));
if (!tx.active || m.extd || m.rtr || m.data_length_code < 2) return;
bool mine = tx.functional ? (m.identifier >= 0x7E8 && m.identifier <= 0x7EF) : m.identifier == tx.job.respId;
if (!mine) return;
const uint8_t *d = m.data;
uint8_t pci = d[0] >> 4;
if (pci == 0) {
uint8_t len = d[0] & 0x0f;
if (len == 0 || len > 7) return;
onPayload(m.identifier, d + 1, len);
} else if (pci == 1 && !tx.functional) {
tx.need = ((d[0] & 0x0f) << 8) | d[1];
if (tx.need > sizeof tx.buf) { note("ответ длиннее буфера — отбрасываю"); finishTxn(true); return; }
memcpy(tx.buf, d + 2, 6);
tx.have = 6;
tx.sn = 1;
tx.multi = true;
tx.from = m.identifier;
tx.deadline = millis() + 300;
sendFlowControl(tx.job.reqId); // без этого блок замолчит через секунду
} else if (pci == 2 && tx.multi) {
if ((d[0] & 0x0f) != (tx.sn & 0x0f)) { note("ISO-TP: кадр продолжения не по порядку"); finishTxn(false); return; }
uint16_t take = min<uint16_t>(7, tx.need - tx.have);
memcpy(tx.buf + tx.have, d + 1, take);
tx.have += take;
tx.sn++;
tx.deadline = millis() + 300;
if (tx.have >= tx.need) { tx.multi = false; onPayload(tx.from, tx.buf, tx.need); }
}
}
void canPump() {
if (!canUp) return;
twai_message_t m;
while (twai_receive(&m, 0) == ESP_OK) onFrame(m);
if (tx.active && (int32_t)(millis() - tx.deadline) > 0) {
if (tx.job.kind == K_SCAN) {
scanText = "";
for (byte i = 0; i < scanN; i++) {
uint32_t id = scanIds[i];
scanText += hexId(id) + (id == 0x7E8 ? " (двигатель)" : id == 0x7E9 ? " (коробка передач)" : "") + (i + 1 < scanN ? "; " : "");
}
if (!scanN) scanText = "никто не ответил";
note("сканирование: " + scanText);
finishTxn(true);
} else {
missCount++;
if (++missRun == 5) note("автомобиль не отвечает на запросы");
if (tx.job.kind == K_UDS) udsResult = "нет ответа (блок не слушает этот адрес?)";
if (tx.job.kind == K_DTC || tx.job.kind == K_CLEAR) note("коды: нет ответа");
finishTxn(false);
}
}
}
// --- опрос ----------------------------------------------------------------
byte pollIdx = 0;
uint32_t pollAt = 0, statusAt = 0, dtcAt = 0;
bool startupDone = false;
uint32_t bootAt = 0;
uint32_t physId() { return cfg.target ? 0x7DF : 0x7E0; }
uint32_t respId() { return 0x7E8; }
bool pidWanted(byte i) {
if (i == P_STATUS) return true;
return (cfg.pids >> i) & 1 && supported[i];
}
void applySupport() {
for (byte i = 0; i < PIDS; i++) {
uint8_t p = PID[i].pid;
byte range = p / 0x20;
uint32_t base = range * 0x20;
if (!(suppGot & (1 << range))) { continue; }
supported[i] = (suppMask[range] >> (32 - (p - base))) & 1;
}
String s;
for (byte i = 0; i < PIDS; i++) if (!supported[i]) s += String(s.length() ? ", " : "") + PID[i].name;
supportText = s.length() ? String("нет в автомобиле: ") + s : String("все нужные есть");
}
void scheduleWork() {
if (!canUp) return;
uint32_t now = millis();
if (!startupDone && now - bootAt > 1500) {
startupDone = true;
addJob(K_SCAN, 0x7DF, 0x7E8, { 0x01, 0x00 });
for (byte r = 0; r < 3; r++) addJob(K_SUPPORT, 0x7E0, 0x7E8, { 0x01, (uint8_t)(r * 0x20) }, r);
addJob(K_VIN, 0x7E0, 0x7E8, { 0x09, 0x02 });
addJob(K_DTC, 0x7E0, 0x7E8, { 0x03 });
return;
}
if (!startupDone) return;
if (suppGot == 7 && !suppApplied) { suppApplied = true; applySupport(); }
if (tx.active || jobCount) return;
if (now - pollAt < cfg.period) return;
pollAt = now;
// Раз в 30 секунд — коды: лампа могла загореться, пока ехали.
if (now - dtcAt > 30000) { dtcAt = now; addJob(K_DTC, 0x7E0, 0x7E8, { 0x03 }); return; }
for (byte n = 0; n < PIDS; n++) {
pollIdx = (pollIdx + 1) % PIDS;
if (!pidWanted(pollIdx)) continue;
if (pollIdx == P_STATUS && now - statusAt < 5000) continue;
if (pollIdx == P_STATUS) statusAt = now;
addJob(K_PID, physId(), respId(), { 0x01, PID[pollIdx].pid }, pollIdx);
return;
}
}
void runJobs() {
if (!canUp || tx.active || !jobCount) return;
Job j = jobs[jobHead];
jobHead = (jobHead + 1) % JOBS;
jobCount--;
startTxn(j);
}
bool connected() { return canUp && lastRx && millis() - lastRx < 3000; }
// --- поездка ----------------------------------------------------------------
struct Trip { float km; float sec; float liters; float maxSpeed; } trip;
float fuelLph() {
// Бензин: воздух / топливо = 14,7 по массе, плотность 740 г/л.
if (!have[P_MAF] || millis() - valAt[P_MAF] > 3000) return NAN;
return val[P_MAF] * 3600.0 / (14.7 * 740.0);
}
float instantConsumption() { // л/100 км, а на стоянке — л/ч со знаком минус
float lph = fuelLph();
if (isnan(lph)) return NAN;
if (!have[P_SPEED] || val[P_SPEED] < 3) return -lph;
return lph / val[P_SPEED] * 100.0;
}
void tripTick() {
static uint32_t at = 0;
uint32_t now = millis();
if (!at) at = now;
float dt = (now - at) / 1000.0;
at = now;
if (!connected() || !have[P_RPM] || val[P_RPM] < 300) return;
trip.sec += dt;
if (have[P_SPEED]) { trip.km += val[P_SPEED] * dt / 3600.0; if (val[P_SPEED] > trip.maxSpeed) trip.maxSpeed = val[P_SPEED]; }
float lph = fuelLph();
if (!isnan(lph)) trip.liters += lph * dt / 3600.0;
}
float avgConsumption() { return trip.km > 0.5 ? trip.liters / trip.km * 100.0 : NAN; }
float avgSpeed() { return trip.sec > 5 ? trip.km / (trip.sec / 3600.0) : NAN; }
float rangeKm() {
float avg = avgConsumption();
if (isnan(avg) || !have[P_FUEL] || avg < 0.5) return NAN;
return val[P_FUEL] / 100.0 * cfg.tank / avg * 100.0;
}
void resetTrip() { memset(&trip, 0, sizeof trip); note("поездка сброшена"); }
// --- коды неисправностей ---------------------------------------------------
String dtcName(uint16_t v) {
char c[6];
snprintf(c, sizeof c, "%c%d%03X", "PCBU"[v >> 14], (v >> 12) & 3, v & 0xfff);
return String(c);
}
struct DtcInfo { const char *code; const char *text; };
const DtcInfo DTC_TABLE[] = {
{ "P0016", "Рассогласование положения коленвала и распредвала" },
{ "P0087", "Давление топлива в рейке слишком низкое" },
{ "P0101", "Датчик расхода воздуха: сигнал вне диапазона" },
{ "P0106", "Датчик давления во впуске: сигнал вне диапазона" },
{ "P0113", "Датчик температуры впуска: высокий сигнал" },
{ "P0171", "Смесь слишком бедная (ряд 1)" },
{ "P0172", "Смесь слишком богатая (ряд 1)" },
{ "P0234", "Избыточное давление наддува" },
{ "P0299", "Недостаточное давление наддува" },
{ "P0300", "Пропуски зажигания (несколько цилиндров)" },
{ "P0301", "Пропуски зажигания, цилиндр 1" },
{ "P0302", "Пропуски зажигания, цилиндр 2" },
{ "P0303", "Пропуски зажигания, цилиндр 3" },
{ "P0304", "Пропуски зажигания, цилиндр 4" },
{ "P0335", "Датчик положения коленвала: цепь" },
{ "P0420", "Эффективность катализатора ниже порога (ряд 1)" },
{ "P0442", "Система улавливания паров: небольшая утечка" },
{ "P0455", "Система улавливания паров: большая утечка" },
{ "P0500", "Датчик скорости автомобиля" },
{ "P0562", "Напряжение бортсети низкое" },
{ "U0100", "Потеряна связь с блоком управления двигателем" },
};
String dtcText() {
if (!dtcRead) return "не читали";
if (!dtcN) return "нет";
String s;
for (byte i = 0; i < dtcN; i++) {
String c = dtcName(dtcs[i]);
const char *t = "нет в таблице скетча";
for (const DtcInfo &d : DTC_TABLE) if (c == d.code) { t = d.text; break; }
s += c + " — " + t + "\n";
}
return s;
}
// --- действия: их зовут и страница, и монитор порта -----------------------
char udsReq[6] = "7E0", udsResp[6] = "7E8", udsDid[6] = "F190";
// Скорость шины сменили: драйвер перезапускается на новой.
void applyRate() {
canStop();
canStart();
lastRx = 0;
}
// Свой запрос: служба 22 (чтение данных по идентификатору).
void requestUds(const char *rq, const char *rs, const char *did) {
uint32_t a = strtoul(rq, nullptr, 16), r = strtoul(rs, nullptr, 16), d = strtoul(did, nullptr, 16);
if (!a || !r || a > 0x7ff || r > 0x7ff || d > 0xffff) {
udsResult = "адреса — hex до 7FF, DID — hex до FFFF";
note("UDS: " + udsResult);
return;
}
udsResult = "ждём ответа…";
addJob(K_UDS, a, r, { 0x22, (uint8_t)(d >> 8), (uint8_t)d });
}
// Сброс кодов — единственная запись в автомобиль. Заперта выключателем.
void requestClear() {
if (!allowWrite) {
note("сброс кодов заперт: включите «Разрешить запись» в настройках");
return;
}
addJob(K_CLEAR, 0x7E0, 0x7E8, { 0x04 });
note("запрошен сброс кодов");
}
// --- страница: GyverLibs/Settings ---------------------------------------
SettingsESP sett("Бортовой компьютер");
enum : size_t {
W_LINK = 1, W_BUS, W_JOURNAL, W_CANLOG, W_TRIP_KM, W_TRIP_T, W_TRIP_AVGV, W_TRIP_MAXV,
W_INST, W_AVG, W_RANGE, W_MIL, W_DTC, W_VIN, W_SCAN, W_SUPP, W_UDS,
W_V = 30, // показания: W_V + номер PID
};
// String(float, знаков) дополняет короткие числа пробелом слева до ширины
// двух знаков (" 5"), а на дисплее и в панели он лишний.
String fmt(float v, byte d) {
if (isnan(v)) return String("—");
String s = String(v, (unsigned int)d);
s.trim();
return s;
}
String fmt(float v) { return fmt(v, 1); }
String valText(byte i) {
if (!supported[i]) return "нет в автомобиле";
if (!have[i]) return "—";
if (millis() - valAt[i] > 5000) return fmt(val[i]) + " " + PID[i].unit + " (устарело)";
byte d = (PID[i].pid == 0x0C || PID[i].pid == 0x0D || PID[i].pid == 0x0B) ? 0 : 1;
return fmt(val[i], d) + " " + PID[i].unit;
}
String linkText() {
if (!canUp) return "CAN остановлен";
if (connected()) return "автомобиль отвечает";
if (!lastRx) return "ждём ответа автомобиля";
return "нет ответа";
}
String busText() {
if (!canUp) return "—";
twai_status_info_t st;
if (twai_get_status_info(&st) != ESP_OK) return "нет данных";
const char *s = st.state == TWAI_STATE_RUNNING ? "работает" : st.state == TWAI_STATE_BUS_OFF ? "шина отключена" :
st.state == TWAI_STATE_RECOVERING ? "восстановление" : "остановлен";
return String(s) + ", ошибки передачи " + st.tx_error_counter + ", приёма " + st.rx_error_counter +
", не ушло кадров " + st.tx_failed_count;
}
String tripTime() {
uint32_t s = (uint32_t)trip.sec;
char b[16];
snprintf(b, sizeof b, "%lu:%02lu:%02lu", s / 3600, s / 60 % 60, s % 60);
return b;
}
String instText() {
float c = instantConsumption();
if (isnan(c)) return "—";
return c < 0 ? fmt(-c, 2) + " л/ч (стоим)" : fmt(c) + " л/100 км";
}
// Сколько показаний включено в опрос.
byte wantedCount() { byte n = 0; for (byte i = 0; i < P_STATUS; i++) if (pidWanted(i)) n++; return n; }
void build(sets::Builder &b) {
bool changed = false;
if (b.beginGroup("Сейчас")) {
b.Label(W_LINK, "Связь", linkText());
b.Label(W_BUS, "Контроллер CAN", busText());
for (byte i = 0; i < P_STATUS; i++) b.Label(W_V + i, PID[i].name, valText(i));
b.endGroup();
}
if (b.beginGroup("Поездка")) {
b.Label(W_INST, "Расход сейчас", instText());
b.Label(W_AVG, "Расход средний", fmt(avgConsumption()) + " л/100 км");
b.Label(W_TRIP_KM, "Пройдено", fmt(trip.km) + " км");
b.Label(W_TRIP_T, "В пути", tripTime());
b.Label(W_TRIP_AVGV, "Средняя скорость", fmt(avgSpeed(), 0) + " км/ч");
b.Label(W_TRIP_MAXV, "Максимальная скорость", fmt(trip.maxSpeed, 0) + " км/ч");
b.Label(W_RANGE, "Запас хода", fmt(rangeKm(), 0) + " км");
if (b.Button("Сбросить поездку", sets::Colors::Yellow)) resetTrip();
b.endGroup();
}
if (b.beginGroup("Коды неисправностей")) {
b.Label(W_MIL, "Лампа двигателя", mil ? String("горит, кодов: ") + milCount : String("не горит"));
b.Label(W_DTC, "Коды", dtcText());
if (b.beginButtons()) {
if (b.Button("Прочитать", sets::Colors::Aqua)) addJob(K_DTC, 0x7E0, 0x7E8, { 0x03 });
if (b.Button("Сбросить (служба 04)", sets::Colors::Red)) {
requestClear();
}
b.endButtons();
}
b.endGroup();
}
if (b.beginGroup("Автомобиль")) {
b.Label(W_VIN, "VIN", vin);
b.Label(W_SCAN, "Блоки, ответившие на запрос всем", scanText);
b.Label(W_SUPP, "Показания", supportText);
if (b.beginButtons()) {
if (b.Button("Сканировать блоки", sets::Colors::Aqua)) { scanN = 0; addJob(K_SCAN, 0x7DF, 0x7E8, { 0x01, 0x00 }); }
if (b.Button("Прочитать VIN", sets::Colors::Aqua)) addJob(K_VIN, 0x7E0, 0x7E8, { 0x09, 0x02 });
if (b.Button("Что поддерживается", sets::Colors::Aqua)) {
suppGot = 0; suppApplied = false; supportText = "не опрашивали";
for (byte r = 0; r < 3; r++) addJob(K_SUPPORT, 0x7E0, 0x7E8, { 0x01, (uint8_t)(r * 0x20) }, r);
}
b.endButtons();
}
b.endGroup();
}
if (b.beginGroup("Свой запрос UDS (служба 22)")) {
b.Input("Адрес запроса, hex", udsReq);
b.Input("Адрес ответа, hex", udsResp);
b.Input("Идентификатор данных (DID), hex", udsDid);
b.Label(W_UDS, "Ответ", udsResult);
if (b.Button("Отправить", sets::Colors::Green)) {
requestUds(udsReq, udsResp, udsDid);
}
b.endGroup();
}
if (b.beginGroup("Настройки")) {
if (b.Select("Скорость шины", RATE_NAME, &cfg.rate)) {
changed = true;
applyRate();
}
if (b.Select("Кому запрос показаний", "Двигателю (0x7E0);Всем (0x7DF)", &cfg.target)) changed = true;
if (b.Number("Пауза между запросами, мс", &cfg.period, 20, 2000)) changed = true;
if (b.Number("Бак, л", &cfg.tank, 10, 120)) changed = true;
if (b.Switch("Писать кадры CAN в журнал", &cfg.logFrames)) changed = true;
b.Switch("Разрешить запись в автомобиль (после перезагрузки — выкл.)", &allowWrite, sets::Colors::Red);
b.endGroup();
}
if (b.beginGroup("Что опрашивать")) {
for (byte i = 0; i < P_STATUS; i++) {
bool on = (cfg.pids >> i) & 1;
if (b.Switch(PID[i].name, &on)) {
if (on) cfg.pids |= 1 << i; else cfg.pids &= ~(1 << i);
changed = true;
}
}
b.endGroup();
}
b.Log(W_JOURNAL, journal, "Журнал");
if (cfg.logFrames) b.Log(W_CANLOG, canLog, "Кадры CAN");
if (changed) {
saveSettings();
note("настройки сохранены (страница)");
}
}
void update(sets::Updater &u) {
u.update(W_LINK, linkText());
u.update(W_BUS, busText());
for (byte i = 0; i < P_STATUS; i++) u.update(W_V + i, valText(i));
u.update(W_INST, instText());
u.update(W_AVG, fmt(avgConsumption()) + " л/100 км");
u.update(W_TRIP_KM, fmt(trip.km) + " км");
u.update(W_TRIP_T, tripTime());
u.update(W_TRIP_AVGV, fmt(avgSpeed(), 0) + " км/ч");
u.update(W_TRIP_MAXV, fmt(trip.maxSpeed, 0) + " км/ч");
u.update(W_RANGE, fmt(rangeKm(), 0) + " км");
u.update(W_MIL, mil ? String("горит, кодов: ") + milCount : String("не горит"));
u.update(W_DTC, dtcText());
u.update(W_VIN, vin);
u.update(W_SCAN, scanText);
u.update(W_SUPP, supportText);
u.update(W_UDS, udsResult);
u.update(W_JOURNAL, journal);
if (cfg.logFrames) u.update(W_CANLOG, canLog);
}
String ipText = "нет сети";
void startNetwork() {
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) delay(250);
if (WiFi.status() == WL_CONNECTED) {
ipText = WiFi.localIP().toString();
} else {
// В машине сети может не быть: своя точка доступа, чтобы зайти
// со смартфона.
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASSWORD);
ipText = WiFi.softAPIP().toString();
Serial.printf("сети %s нет — своя точка доступа %s\n", SSID, AP_SSID);
}
if (!LittleFS.begin(true)) Serial.println("LittleFS не поднялась");
if (*ADMIN_PASSWORD) sett.setPass(ADMIN_PASSWORD);
sett.begin();
sett.onBuild(build);
sett.onUpdate(update);
sett.setUpdatePeriod(2000);
Serial.printf("бортовой компьютер: http://%s/\n", ipText.c_str());
}
// --- дисплей и кнопка -----------------------------------------------------------
byte page = 0;
String pad16(String s) {
while (s.length() < 16) s += ' ';
return s.substring(0, 16);
}
void lcdShow() {
static uint32_t at = 0;
if (millis() - at < 250) return;
at = millis();
String a, b;
if (!connected()) {
a = canUp ? "OBD-II waiting" : "CAN stopped";
b = "CAN " + String(cfg.rate == 1 ? "250k" : cfg.rate == 2 ? "125k" : "500k");
} else if (page == 0) {
a = "V" + fmt(val[P_SPEED], 0) + " n" + fmt(val[P_RPM], 0);
b = "T" + fmt(val[P_COOLANT], 0) + "C U" + fmt(val[P_VOLT]) + "V";
} else if (page == 1) {
a = "Trip " + fmt(trip.km) + "km";
float c = instantConsumption();
b = isnan(c) ? "-" : (c < 0 ? fmt(-c, 1) + " L/h" : fmt(c) + " L/100");
} else {
a = String("MIL ") + (mil ? "ON " : "off ") + "DTC " + dtcN;
b = dtcN ? dtcName(dtcs[(millis() / 2000) % dtcN]) : "no codes";
}
lcd.setCursor(0, 0); lcd.print(pad16(a));
lcd.setCursor(0, 1); lcd.print(pad16(b));
}
void handleButton() {
static bool was = false;
static uint32_t downAt = 0;
bool down = digitalRead(PIN_BUTTON) == LOW;
if (down && !was) downAt = millis();
if (!down && was) {
uint32_t held = millis() - downAt;
if (held > 1500) resetTrip();
else if (held > 30) page = (page + 1) % 3;
}
was = down;
}
// --- монитор порта ----------------------------------------------------
void handleSerial() {
static String buf;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n' && c != '\r') { if (buf.length() < 32) buf += c; continue; }
if (!buf.length()) continue;
if (buf == "status") {
Serial.println(linkText() + "; " + busText());
for (byte i = 0; i < P_STATUS; i++) Serial.printf(" %s: %s\n", PID[i].name, valText(i).c_str());
} else if (buf == "dtc") {
Serial.println(dtcText());
} else if (buf == "vin") {
Serial.println(vin);
} else if (buf == "scan") {
scanN = 0;
addJob(K_SCAN, 0x7DF, 0x7E8, { 0x01, 0x00 });
} else if (buf == "clear") {
requestClear();
} else if (buf.startsWith("rate ")) {
int r = buf.substring(5).toInt();
if (r >= 0 && r < 3) { cfg.rate = r; applyRate(); saveSettings(); }
Serial.printf("скорость шины: %s\n", busText().c_str());
} else if (buf.startsWith("uds ")) {
char q[6] = "", p[6] = "", d[6] = "";
if (sscanf(buf.c_str() + 4, "%5s %5s %5s", q, p, d) == 3) requestUds(q, p, d);
else Serial.println("uds <запрос> <ответ> <DID>, например: uds 7E0 7E8 F190");
} else {
Serial.printf("команды: status, dtc, vin, scan, clear, rate N, uds Q R DID (получено «%s», %u байт)\n", buf.c_str(), buf.length());
}
buf = "";
}
}
// --- setup и loop -----------------------------------------------------
void setup() {
Serial.begin(115200);
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_BUTTON, INPUT_PULLUP);
for (byte i = 0; i < PIDS; i++) { val[i] = NAN; supported[i] = true; }
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Car computer");
loadSettings();
startNetwork();
bootAt = millis();
canStart();
note("бортовой компьютер запущен");
}
void loop() {
static uint32_t blinkAt = 0;
sett.tick();
static bool wasConnected = false;
if (connected() != wasConnected) {
wasConnected = connected();
note(wasConnected ? "связь с автомобилем есть" : "связь с автомобилем потеряна");
}
handleButton();
handleSerial();
canPump();
scheduleWork();
runJobs();
tripTick();
lcdShow();
if (millis() - blinkAt >= (connected() ? 500 : 1500)) {
blinkAt = millis();
digitalWrite(PIN_LED, !digitalRead(PIN_LED));
}
delay(2);
}
Бортовой компьютер автомобиля на ESP32: OBD-II по CAN, SN65HVD230 и панель Settings
Читает данные автомобиля через диагностический разъём OBD-II: обороты, скорость, температуры, расход, коды неисправностей и VIN. Связь по CAN через трансивер SN65HVD230, транспорт ISO-TP, запросы UDS для VAG, дисплей 16×2 и панель Settings. В симуляторе автомобиль задаётся свойствами детали, а схема проверяется на ошибки монтажа.
Что на схеме
Плата ESP32 DevKit v1, к ней подключены:
- Трансивер CAN SN65HVD230 (модуль) «Трансивер CAN» —
xc - Разъём OBD-II автомобиля (CAN) «Автомобиль (OBD-II)» —
obd - LCD 16x2 (I2C) «Дисплей» —
lcd1 - Кнопка «Страница / сброс» —
btn
Задействованы выводы платы: 3V3, GND, D5, D4, VIN, D21, D22, D13. Всего соединений на схеме: 13.
Схема и что на ней
Бортовой компьютер: плата ESP32 подключается к диагностическому разъёму OBD-II автомобиля по шине CAN, опрашивает блок управления двигателем и показывает данные на дисплее 16×2 и на странице Settings. Задумано для автомобилей группы VAG (VW, Audi, Škoda, SEAT), но стандартные службы OBD-II одинаковы у всех машин с CAN.
Связь с шиной идёт через модуль трансивера SN65HVD230: контроллер TWAI в ESP32 выдаёт логические уровни на выводах TX и RX, а шине нужна дифференциальная пара CANH/CANL. Трансивер питается от 3,3 В — это его штатное питание, и с логикой ESP32 он согласован без делителей.
Что подключено
- SN65HVD230 (модуль): CTX — к D5 (передатчик TWAI), CRX — к D4 (приёмник), 3V3 и GND — к плате; CANH и CANL — к выводам 6 и 14 разъёма OBD-II.
- Разъём OBD-II: вывод 6 — CANH, 14 — CANL, 4 и 5 — земля, 16 — плюс бортсети. Земля модуля и земля автомобиля должны быть общими.
- Дисплей LCD1602 (I2C, 0x27) на D21/D22 и кнопка на D13. Короткое нажатие листает страницы (скорость и обороты, поездка, коды), долгое — сбрасывает поездку.
- Плата питается от бортсети через понижающий преобразователь 12→5 В с защитой от выбросов (в автомобиле напряжение доходит до десятков вольт при отключении нагрузки); вывод 16 напрямую на плату подключать нельзя. В симуляторе плата питается от USB.
Что читается и как
Скетч опрашивает показания службой OBD-II 01: обороты, скорость, температуры охлаждающей жидкости, масла, воздуха на впуске и снаружи, нагрузку, дроссель, расход воздуха (MAF), давление во впуске, уровень топлива, напряжение бортсети, коррекции топлива и угол опережения. По одному запросу за раз, с паузой из настройки (по умолчанию 120 мс).
Какие показания есть у конкретного автомобиля, скетч спрашивает сам: запросы PID 00, 20 и 40 возвращают битовые маски, и то, чего в автомобиле нет, отмечается как «нет в автомобиле» и больше не опрашивается. Кроме того, скетч раз в пять секунд читает состояние (PID 01): лампа двигателя и число кодов; если число изменилось — читает сами коды (служба 03).
Из показаний считается то, чего в автомобиле нет: мгновенный расход по расходу воздуха (для бензинового двигателя: воздух/топливо 14,7 по массе, плотность топлива 740 г/л; для дизеля и наддувных двигателей на обеднённой смеси оценка неточна), средний расход, пробег и время поездки, запас хода по остатку топлива и объёму бака.
Транспорт: ISO-TP
Диагностические ответы длиннее семи байт (VIN — 20 байт, список кодов) не помещаются в один кадр CAN и передаются по ISO 15765-2. Блок присылает первый кадр (0x1N), после чего тестер обязан ответить кадром Flow Control (0x30) — иначе блок через секунду бросает передачу. Скетч отвечает сам и собирает кадры продолжения (0x2N) по порядку. Если кадр продолжения потерян или пришёл не в свою очередь, ответ отбрасывается.
Адресация 11-битная: запрос двигателю — 0x7E0, ответ — 0x7E8; коробка передач — 0x7E1/0x7E9; запрос «всем» — 0x7DF. Приёмный фильтр контроллера настроен на диапазон 0x700–0x7FF, весь остальной трафик шины отбрасывается аппаратно.
Скорость шины
Диагностическая шина автомобилей группы VAG работает на 500 кбит/с; на некоторых автомобилях встречаются 250 и 125. Скорость контроллера и шины обязана совпадать: при несовпадении кадры не распознаются, подтверждения нет, счётчик ошибок передачи растёт до 128 (состояние «пассивная ошибка»), и в панели видно «ошибки передачи 128, не ушло кадров N». Скорость выбирается в панели; драйвер при этом перезапускается.
Панель Settings
- Сейчас: связь, состояние контроллера CAN (счётчики ошибок) и все показания.
- Поездка: расход мгновенный и средний, пробег, время, средняя и максимальная скорость, запас хода.
- Коды неисправностей: лампа, коды с расшифровкой для двадцати самых частых (в том числе типичных для VAG — P0016, P0299, P0420…), кнопки «Прочитать» и «Сбросить».
- Автомобиль: VIN, блоки на шине (запрос всем), поддерживаемые показания.
- Свой запрос UDS (служба 22): адрес запроса, адрес ответа и идентификатор данных (DID). Автомобили VAG отдают «измеряемые блоки» именно так; какие DID есть у вашей машины и что они означают, зависит от блока и поколения — их берут из документации к вашему блоку. В симуляторе автомобиль знает F190 (VIN), F187 и F189; на остальные отвечает отказом 0x31, как настоящий блок.
- Настройки: скорость шины, кому слать запросы, пауза между запросами, объём бака, что опрашивать, журнал кадров CAN.
Запись в автомобиль заперта. Сброс кодов (служба 04) сбрасывает не только лампу, но и готовность мониторов диагностики, поэтому запрос выполняется только при включённом выключателе «Разрешить запись» в настройках; он не сохраняется и после каждой перезагрузки платы снова выключен. Это заготовка под управление: когда понадобится, например, автономный отопитель, команды пойдут по тому же пути — через этот выключатель.
В симуляторе
Автомобиль на шине — деталь «Разъём OBD-II». Её свойства — показания, которые отдают блоки: обороты, скорость, температуры, коды неисправностей (через запятую, например P0171,P0300), VIN, скорость шины. Менять их можно на ходу, и они сразу оказываются на дисплее и в панели. Режим «городской цикл» сам ведёт скорость и обороты по 90-секундной кривой.
Симулятор проверяет монтаж и называет причину, если связи нет: нет трансивера между платой и разъёмом, перепутаны CANH и CANL, не запитан трансивер, нет общей земли, вывод CTX не подведён к плате, прошивка передаёт на другой вывод, чем подключён трансивер, скорость не совпадает. Сообщение попадает в журнал симулятора.
Чего в модели нет: арбитража с другими узлами (на шине один собеседник), шлюза, который в настоящей машине пропускает не все запросы, служб сеансов и кодов доступа, а также VAG-овских транспортных протоколов TP 2.0 и KWP — они использовались до перехода на UDS и требуют отдельной реализации.
Что заменить при сборке
- поставить преобразователь 12→5 В и предохранитель на питание платы; трансивер — от 3V3 платы;
- убедиться, что на модуле SN65HVD230 вывод Rs посажен на землю (быстрый режим), а перемычка терминатора 120 Ом снята: шина автомобиля уже терминирована с обоих концов;
- в LCD-модулях подтяжки I2C нередко идут на 5 В — для ESP32 это перебор, снимите перемычки подтяжки или поставьте преобразователь уровней;
- проверить скорость шины и адреса вашего автомобиля; для расширенных измерений VAG — документация к блоку.
Дальше: отопитель
Управление автономным отопителем (Webasto) — следующий шаг. Способ зависит от модели отопителя: одни управляются по диагностической шине автомобиля, другие — по собственной линии. Заготовка готова: запись в автомобиль заперта отдельным выключателем, а очередь запросов уже умеет отправлять произвольные службы и разбирать отрицательные ответы.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- Preferences
- LittleFS
- LiquidCrystal I2C
- Settings
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
Preferences— хранение настроек во flash, переживает перезапускLiquidCrystal_I2C— текстовый экран 16×2 по двум проводамmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.println()— печатает строку в монитор портаWiFi.begin()— подключается к точке доступа виртуального Wi-Fidelay()— пауза, на время которой ядро занято ожиданиемWiFi.softAP()— поднимает собственную точку доступаSerial.printf()— печатает в монитор порта с форматированиемdigitalRead()— читает уровень на выводеSerial.begin()— открывает монитор порта на заданной скоростиpinMode()— назначает выводу режим: выход, вход или вход с подтяжкойdigitalWrite()— выставляет на выводе высокий или низкий уровень
В скетче 972 строки; свои функции: setDefaults(), saveSettings(), loadSettings(), note(), hexId(), hexBytes(), canStart(), canStop(); подключено заголовков: 8. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить». В мониторе порта: «CAN: запущен на 500 кбит/с», через несколько секунд — «сканирование: 7E8 (двигатель); 7E9 (коробка передач)» и VIN. Сценарий сам меняет показания автомобиля; чтобы управлять вручную, снимите галочку «запускать со сценарием».
- Нажмите на деталь obd («Автомобиль») и задайте скорость 60 км/ч и обороты 2400: на дисплее появится V60 n2400. Кнопкой btn листайте страницы: скорость и обороты, поездка (пробег, расход), коды.
- Откройте «Веб-интерфейс»: все показания, расход сейчас и средний, пробег и запас хода. Поставьте у obd режим «городской цикл» — скорость и обороты пойдут по кривой сами.
- Впишите в свойство dtc коды неисправностей, например P0171,P0300: через несколько секунд лампа загорится, коды появятся в панели с расшифровкой. Кнопка «Сбросить» ничего не сделает, пока не включён выключатель «Разрешить запись».
- В панели, в разделе «Свой запрос UDS», отправьте запрос F190 на адрес 7E0 (ответ с адреса 7E8) — придёт VIN; идентификатор 1234 вернёт отказ 0x31.
- Поставьте у obd скорость шины 250 кбит/с: связь пропадёт, в панели ошибки передачи вырастут до 128, а в журнале симулятора будет сказано, что скорости не совпали. Верните 500 — связь восстановится. Выберите 250 кбит/с в панели — драйвер перезапустится на новой скорости.
- Разорвите провод CANH или CANL, уберите общую землю или подключите CTX к другому выводу — журнал симулятора назовёт причину.
Смотрите также
- Подключение LCD 1602 с I2C — Символьный дисплей 16×2
- Подключение Кнопка — Тактовая кнопка
- Метеостанция с тенденцией давления — Часы, температура, влажность и давление на одном экране — и стрелка, куда идёт давление за последний час. Три устройства…
- Кодовый замок с блокировкой — Клавиатура, экран и реле электрозамка. Код звёздочками, три ошибки подряд — блокировка на полминуты, смена кода с подтве…
- Термостат с гистерезисом — DHT22 меряет, реле греет, ручка задаёт уставку. Гистерезис в полградуса и выдержка в десять секунд берегут реле. Темпера…
- Автополив и фитолампа на Arduino Nano — Настоящее устройство для подоконника: фитолампа 230 В по расписанию и два насоса 12 В, каждый со своим периодом полива…
- Автополив и фитолампа на ESP32 с веб-админкой — То же устройство, что на Arduino Nano — фитолампа 230 В по расписанию и два насоса 12 В со своим периодом полива, — но с…
- Автополив и фитолампа на ESP32: веб-админка и MQTT — Третий вариант прибора: к веб-админке ESP32 добавлен MQTT. Состояние — климат, лампа, насосы, всё расписание — уходит бр…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.