Конфиг теплицы жил на карте файлом config.txt, и в июне его поправили на ноутбуке заказчика: добавили строку для второго вентилятора. После этого устройство перестало реагировать на порог — молча, без единого намёка на причину. Вечером я открыл файл и увидел, что заказчик писал со вкусом: threshold = 26 — с пробелами вокруг знака равенства, как пишут люди, а не скетчи. Мой парсер из урока про CSV и конфиг сравнивал ключ с образцом threshold, получал threshold с хвостом-пробелом, расходился с образцом и молча оставлял заводское значение. Через неделю там же всплыл комментарий строкой # порог для лета — и парсер послушно разобрал его как ключ с мусорным значением.
С тех пор я отношусь к конфигу как к полю ввода живого человека: пробелы, Windows-переводы строк, комментарии, пустые строки и вообще белиберда — всё это придёт в парсер обязательно. Каркас чтения config.txt в том уроке уже стоит; здесь разберём анатомию кривых строк: что именно срезает trim(), чем опасен ноль от toInt() и как проверить число по-честному через strtol. За полгода кривых конфигов список устаканился: пробелы вокруг равно, от Windows, BOM в начале файла, комментарии, пустые строки, число с хвостовой буквой — ниже по пунктам.
readStringUntil: строка за один вызов, но с двумя сюрпризами
f.readStringUntil('\n') тянет из файла символы, пока не встретит перевод строки, и отдаёт всё одной строкой String. Первый сюрприз — таймаут. Если терминатор не пришёл, функция возвращает пустоту не сразу: она ждёт, по умолчанию 1000 миллисекунд. Для Serial это осмысленно — собеседник мог задуматься. Файл же заканчивается жёстко, и если последняя строка не закрыта переводом строки, а блокноты любят так сохранять, каждая загрузка скетча подрастает на секунду. Лечится одним вызовом f.setTimeout(100) перед циклом: файлу дольше сотни миллисекунд ждать нечего. Секунда на старте не катастрофа, но конфиг, который перечитывается при каждой перезагрузке, за месяц накрутит полчаса простоя, а на батарейном устройстве каждая секунда пробуждения на счету.
Второй сюрприз сидит внутри строк. Windows-блокнот заканчивает каждую строку парой , и если резать по , в хвосте остаётся невидимый . Чтению числа он не мешает — toInt остановится на нём, — а вот сравнение ключей убивает наповал: строка threshold с хвостовым не равна threshold. Отсюда железное правило: сразу после чтения делаю line.trim() — срезает пробелы, табы и с обоих концов. Проверка занимает секунды: откройте конфиг в редакторе с показом непечатаемых символов — и станет видимым раньше, чем вы начнёте подозревать карту. Механику той же функции на Serial-потоке я разбирал в уроке про команды readStringUntil, там таймаут, наоборот, выручает. А курсор, с которого начинается чтение, остаётся с прошлого урока про режимы открытия File.
Тайминг на числах: конфиг из шести строк читается мгновенно, секунда теряется один раз — на последней строке без терминатора. Но если скетч перечитывает конфиг не только на старте, а по запросу в loop, лишняя секунда уже заметна, и setTimeout окупается с первого же перечитывания. Мой формат файла: после последнего параметра всегда жму Enter — пустая строка в конце, и таймаут не наступает никогда.
| Строка в файле | key | value | Что сделает парсер |
|---|---|---|---|
| threshold=26 | threshold | 26 | примет значение |
| threshold = 26 | threshold с пробелом | 26 с пробелом | после trim обеих частей примет; без trim — мимо |
| threshold=26 | threshold | 26 | после trim примет; до числа не дойдёт и так |
| # комментарий | нет | нет | indexOf не найдёт равно — строка пропущена |
| threshold | нет | нет | ключ без значения — пропущен, дефолт остаётся |
Разбор строки после trim — четыре вызова: indexOf('=') находит позицию знака, substring(0, eq) берёт ключ, substring(eq + 1) — значение, toInt() переводит текст в число. Нет равно — indexOf отдаст -1, и это готовый фильтр: комментарии с решёткой, пустые строки и случайная белиберда отсеиваются одним сравнением, не долетая до ключей. Полезная привычка при отладке: печатайте строку в монитор с обрамляющими скобками — [threshold = 26] — и невидимые пробелы станут заметными.
А вот toInt() требует уважения. На строке без цифр он возвращает 0, на строке 26x вернёт 26, бросив букву на месте. Ноль-на-мусоре — мина: порог вентилятора, ставший нулём из-за опечатки, включает тревогу сразу после старта, и вы ищете короткое замыкание, а виноват молчаливый ноль. Я после того случая разделил два сценария: ноль в конфиге либо запрещён диапазоном, либо обрабатывается явно как легальное значение. Нулевую ловушку parseInt мы щупали в уроке про parseInt и числа — внутри String.toInt() прячется тот же механизм. Три разных файла дают одинаковый ноль на выходе: abc — мусор, пустое поле — тоже, а 26abc честно отдаёт 26 и выбрасывает хвост. Диапазон и проверка strtol разделяют эти случаи.
strtol: число, которое можно проверить
Когда значение важнее, чем просто число, — это порог, от которого что-то включается, — я перевожу его не toInt(), а strtol(). Механика: строка копируется в char-буфер через toCharArray, strtol(buf, &end, 10) парсит число и записывает в end указатель на первый непрочитанный символ. Проверка одна: если end указывает туда же, где начинался буфер, ни одной цифры в строке не было — мусор, и дефолт остаётся при своём. Отличить abc от 0 — всего-то делов, а на toInt() эти случаи неотличимы, и это его главная слабость на ответственных числах. Заодно strtol понимает знак минус: отрицательные пороги, если они в вашей задаче осмысленны, распарсятся без дополнительного кода.
Дальше диапазон: принятое число прогоняю через constrain — порог вне 1-1023 стоит столько же, сколько мусор, поэтому границы честнее любые умолчаний. И общее правило из базового урока остаётся в силе: у каждого параметра есть дефолт в коде, конфиг его только уточняет. Нет карты, нет файла, кривая строка — устройство работает на заводских числах, а не падает и не замирает. Диапазоны держите рядом с ключами: порог 1-1023 и гистерезис 1-100 у меня выведены из схемы, а не с потолка, и меняются вместе с ней.
#include <SPI.h>#include <SD.h> const int chipSelect = 10; int threshold = 300; // дефолты: конфиг их только уточняетint hysteresis = 10; // Честное чтение числа: strtol не молчит на мусоре, как toIntint parseValue(String val) { char buf[12]; val.toCharArray(buf, sizeof(buf)); char *end; long n = strtol(buf, &end, 10); if (end == buf) { return -1; // ни одной цифры: мусор } return (int)n;} void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("Нет карты - работаем на дефолтах"); return; } File f = SD.open("config.txt", FILE_READ); if (!f) { Serial.println("Нет config.txt - работаем на дефолтах"); return; } f.setTimeout(100); // файл не ждёт дольше сотни миллисекунд while (f.available()) { String line = f.readStringUntil('\n'); line.trim(); // срезает пробелы и
от блокнота if (line.length() == 0) { continue; // пустая строка } int eq = line.indexOf('='); if (eq < 0) { continue; // комментарий или белиберда - мимо } String key = line.substring(0, eq); String val = line.substring(eq + 1); key.trim(); val.trim(); int n = parseValue(val); if (n < 0) { Serial.print("Кривое значение у ключа: "); Serial.println(key); continue; // дефолт остаётся при своём } if (key == "threshold") { threshold = constrain(n, 1, 1023); } else if (key == "hysteresis") { hysteresis = constrain(n, 1, 100); } } f.close(); Serial.print("threshold="); Serial.println(threshold); Serial.print("hysteresis="); Serial.println(hysteresis);} void loop() { }Имена параметров в файле — контракт между картой и прошивкой: threshold читается, Threshold с большой буквы — нет, регистр важен. Принятые значения я печатаю в Serial при старте: одна строка в мониторе экономит поездку на объект, когда через месяц выясняется, что конфиг лежит в файле с другим именем или вообще в другой папке. Имена параметров фиксируются раз и навсегда: прошивка старой версии на другом объекте должна читать тот же файл без сюрпризов, поэтому никаких переименований ключей между версиями. И помните про кучу: String под каждую строку — кусок памяти длиной со строку; для конфига из шести коротких строк это безобидно, а вот парсить лог мегабайтными строками на Uno нельзя — вся оперативка 2 КБ, и про её экономию я подробно писал в уроке про макрос F.
Если String-методы кажутся магией, есть путь попроще и прозрачнее: побайтовый цикл из прошлого урока — собираете строку сами и контролируете каждый байт. А когда параметров станет десяток, пригодится дисциплина колонок из урока про CSV-формат: заголовок в первой строке облегчит жизнь и конфигу, и вам. И последнее: правьте конфиг не на живой карте логгера, а на копии — выдернуть карту из работающего устройства можно, а вот вернуть её с битой таблицей уже нельзя.
- 1. В config.txt строка threshold = 26 — с пробелами вокруг равно. Без trim() что получит парсер?
- 2. Что вернёт String.toInt() на строке abc?
- 3. Последняя строка config.txt не закрыта переводом строки. Что произойдёт при чтении?
- 4. Файл сохранён блокнотом Windows, строки режутся по \n. Что останется в хвосте строки?
- 5. Чем strtol выгоднее toInt() для значений, от которых зависит логика?
- readStringUntil('\n') забирает строку целиком; без терминатора в конце файла ждёт таймаут — ставьте f.setTimeout(100)
- line.trim() сразу после чтения: срезает пробелы, табы и \r от Windows-блокнота, иначе ключи перестают совпадать
- indexOf('=') вернул -1 — строка без значения: комментарии и мусор отсеиваются одним сравнением
- toInt() молча отдаёт 0 на мусоре; для порогов и уставок берите strtol с endptr и проверяйте, что цифры были
- Каждый параметр — дефолт в коде плюс constrain диапазона: конфиг уточняет, а не заменяет заводское поведение
- Конфиг печатайте в Serial при старте: одна строка лога экономит поездку за неправильно названным файлом
Частые вопросы по теме урока
- В config.txt строка threshold = 26 — с пробелами вокруг равно. Без trim() что получит парсер?
- substring отдаст ключ с хвостовым пробелом и значение с ведущим. Сравнение key == threshold провалится, и параметр молча останется на дефолте. Trim обеих частей решает это. Ключ с хвостовым пробелом, сравнение с образцом провалится
- Что вернёт String.toInt() на строке abc?
- toInt молча отдаёт 0 на строке без цифр. Поэтому мусор и честный ноль на toInt неотличимы — для ответственных чисел нужен strtol с проверкой endptr. 0 без какого-либо признака
- Последняя строка config.txt не закрыта переводом строки. Что произойдёт при чтении?
- Без терминатора функция ждёт таймаут — секунду по умолчанию, — потом отдаёт накопленное. Один раз на загрузку; лечится f.setTimeout(100) перед циклом. readStringUntil подождёт таймаут, по умолчанию 1000 мс
- Файл сохранён блокнотом Windows, строки режутся по \n. Что останется в хвосте строки?
- Windows заканчивает строки парой . Разрезая по , вы оставляете в хвосте: число он не портит, а сравнение ключей ломает. Trim срезает его сразу. Невидимый символ \r
- Чем strtol выгоднее toInt() для значений, от которых зависит логика?
- Если end указывает на начало буфера, цифр в строке не было — это мусор, и дефолт остаётся. toInt на мусоре молча отдаёт 0, и отличить его от честного нуля нельзя. strtol через endptr показывает, была ли вообще хоть одна цифра