Лазерный уровень на поворотной головке — вот проект, на котором я упёрся в ручное управление. Мишень висит на другом конце стола, и каждый раз лезть к плате с ноутбуком, чтобы подправить угол на пару градусов, оказалось мучением. Хотелось как на нормальном приборе: крутишь ручку — головка едет, отпустил — стоит и держит.
Первый скетч я написал за пять минут, и он почти заработал: analogRead с A0, map в диапазон 0-2048, разница с текущей позицией — и прямо в step(). На бумажке всё сходилось, а на валу выходил цирк. При резком повороте ручки мотор нёсся к цели сломя голову и один раз сорвался с гулом; в покое вал мелко подрагивал — шаг туда, шаг обратно, каждую секунду, без остановки.
В этом уроке доведём тот скетч до ума: ручка как цель, а не как скорость; доведение мелкими порциями вместо одного рывка; мёртвая зона против дребезга покоя. Заодно посчитаем, с какой скоростью вал догоняет ручку и от чего она зависит на самом деле.
Ручка приказывает, вал догоняет
У ручки и вала два разных договора. Первый: ручка задаёт скорость — отвернул от центра, вал едет, вернул — стоит. Второй: ручка задаёт положение — каждое деление ручки отвечает конкретному углу вала. Для наведения я всегда беру второй договор: снял руку — точка осталась на мишени, и позицию не надо помнить. Первый хорош для подач и конвейеров, где важен темп, а не место.
С договором положения скетч превращается в погоню. Каждая итерация loop() читает A0, пересчитывает в целевые шаги — map(analogRead(A0), 0, 1023, 0, 2048) — и сравнивает с текущей позицией. Разница положительная — шаг вперёд, отрицательная — назад. Вал послушно повторяет ручку, а пересчёт тот же, что мы разбирали в уроке про арифметику редуктора: полная ручка — полный оборот, 2048 позиций.
Только не отдавайте разницу в step() целиком. step(diff) на семьсот шагов при 10 RPM — это две секунды, в течение которых loop() заперт: кнопки молчат, Serial молчит, а вал едет с постоянной скоростью драйвера, без разгона и торможения. Рывок и догонялка в одном флаконе. Лечится порциями — о них сейчас.
Передаточное между ручкой и валом — ваш выбор, а не догма. Оборот ручки на оборот вала — самый читаемый вариант. Для микронаводки я растягиваю шкалу: map в диапазон 0-4096 — оборот ручки уезжает в два оборота вала, разрешение поднимается до четырёх шагов на деление, цель цепляется тоньше. Цена честная: перегон через весь диапазон удлиняется до восьми тысяч шагов, и без второй передачи такой указатель станет тягучим. Подбирайте масштаб под задачу: мишень — 2048, настройка оптики — 4096.
| Схема | Ручка задаёт | Что видит вал | Слабое место |
|---|---|---|---|
| Ручка как скорость | Направление и темп | Едет, пока ручка отклонена от центра | Позиция не помнится: нет нуля |
| Ручка как цель | Абсолютное положение | Догоняет ручку и держит угол | Рывок при скачке и дрожь в покое |
| Цель + зона + темп | Абсолютное положение | Догоняет плавно и замирает | Отстаёт при быстром повороте ручки |
Порционная погоня устроена так: разница больше порога — двигаемся к цели мелкими шажками по таймеру. Один шаг каждые восемь миллисекунд — это 125 шагов в секунду, то есть около 22 градусов вала в секунду. При setSpeed(10) библиотека и так не даёт быстрее 341 шага в секунду, так что мой темп сидит ниже потолка с запасом; сам потолок и срывы мы считали в уроке про скорость и позиционирование. Полный перегон через весь диапазон — 2048 шагов — занимает шестнадцать секунд: небыстро, зато без единого пропуска.
Хочется живее — добавьте вторую передачу: если разница перевалила за сто пятьдесят шагов, берите по три шага за такт. Скорость догоняния вырастет втрое, до 66 градусов в секунду, а рывок останется размазанным: три шага за раз против семисот одним куском. Для указателя это естественный почерк: быстрый перегон сменяет тихий довод, две передачи хватает.
Темп держите на millis(), а не на delay(): delay(8) глушит весь скетч целиком, а сравнение millis() с меткой прошлого шага оставляет loop() живым — кнопки и датчики опрашиваются между шагами. Приём разобран в уроке про millis вместо delay, здесь он работает на механику.
Темп подбирается под механизм, и тут полезно понимать, кто на самом деле рулит. При setSpeed(10) такт библиотеки — 2,9 миллисекунды, мой интервал восемь — в три раза реже, значит, скорость погони задаёт мой таймер. Сожмёте интервал до двух миллисекунд — упрётесь в потолок библиотеки на 341 шаг в секунду, и дальше таймер перестанет иметь значение. Для шторки с тугим ходом я, наоборот, ставлю десять-двенадцать миллисекунд: вал едет неспешно, момент успевает продавливать трение без звона.
#include <Stepper.h> const int STEPS_PER_REV = 2048; Stepper stepper(STEPS_PER_REV, 8, 10, 9, 11); int pos = 0; // позиция в шагах, 0..2048 — int хватаетunsigned long lastStep = 0;unsigned long lastReport = 0; void setup() { stepper.setSpeed(10); Serial.begin(9600);} void loop() { int target = map(analogRead(A0), 0, 1023, 0, 2048); int diff = target - pos; int portion = 1; // первая передача if (abs(diff) > 150) { portion = 3; } // вторая: цель далеко if (abs(diff) > 6 && millis() - lastStep >= 8) { if (diff > 0) { stepper.step(portion); pos += portion; } else { stepper.step(-portion); pos -= portion; } lastStep = millis(); } if (millis() - lastReport >= 1000) { Serial.print("pos="); Serial.print(pos); Serial.print(" target="); Serial.println(target); lastReport = millis(); }}Мёртвая зона — та самая шестёрка в условии. Шум на аналоговом входе гуляет на одно-два деления из 1023, и каждое деление после map — это два шага вала. Без зоны вал в покое шарахается на три-четыре шага туда-сюда; с зоной в шесть шагов — три деления АЦП — он замирает наглухо. Цена невелика: вал может остановиться в шести шагах от цели, это около одного градуса. Для наведения хватает; если бы мне нужен был точный прибор, я бы сузил зону и гасил шум усреднением нескольких чтений, а не порогом.
Строка с map — сердце скетча, и у неё есть зуб: map() не ограничивает выход. Подали 1050 вместо 1023 — получите 2100, за пределом оборота. На честном потенциометре такого не бывает, но если цель когда-нибудь приедет из Serial или с другого датчика — оберните результат в constrain(0, 2048), одна строка страхует весь механизм.
И честности ради: каждый шаг всё же держит loop() на пару миллисекунд — при 10 RPM это цена одного такта библиотеки, проверяли мы её на циферблате. Порции делают паузы короткими, но не нулевыми; совсем без пауз — это уже AccelStepper с кооперативным run(), о нём впереди по плану раздела.
Если после сборки зона в шесть шагов покажется грубой — сначала лечите шум, а не порог. Провод от средней ноги потенциометра до A0 я держу коротким и кладу конденсатор сто нанофарад с входа на землю: наводка садится, и размах покоя сужается до одного деления. Тогда и зона в три-четыре шага работает без дрожи, а довод становится заметно точнее. На длинных проводах через всю макетку — только зона, усреднение тут не спасёт от наводки на весь диапазон.
Проверяем в симуляторе
В симуляторе та же схема: потенциометр на A0, мотор на пинах 8, 10, 9, 11, циферблат повторяет позицию вала. Резко крутаните ручку — вал догонит порциями, без рывка, а Serial раз в шестьсот миллисекунд напечатает пару pos= и target=: смотреть удобно в Serial-мониторе, числа идут парами, чтобы график не рассыпался на позиционные ряды.
Затем остановите ручку между делениями — вал замрёт, даже если до цели осталось несколько шагов: работает мёртвая зона. И поменяйте темп: интервал в условии с восьми миллисекунд на четыре — скорость догоняния удвоится, потому что её ограничивает мой таймер, а не setSpeed. Полушаговый режим из урока про режимы обмоток сделал бы тот же ход ещё плавнее, но это уже ручное управление фазами.
Симулятор повторяет и вторую передачу: дёрните ползунок далеко от текущего положения — первые шаги пойдут тройками, а ближе к цели мотор сам перейдёт на одиночные. Порог сто пятьдесят шагов — это семь процентов оборота, около двадцати шести градусов: видно по циферблату, где меняется почерк. На настоящей плате скетч тот же до буквы, только карту выводов сверяйте со своей распайкой.
- 1. Что задаёт ручка потенциометра в схеме с договором положения?
- 2. Сколько шагов вала даёт одно деление АЦП после map в диапазон 0-2048?
- 3. Что случится, если отдать разницу до цели в step() одним куском?
- 4. Один шаг каждые восемь миллисекунд — какая скорость на валу?
- 5. map() получил 1050 вместо максимума 1023. Что вернёт map(x, 0, 1023, 0, 2048)?
- Договор положения: map(analogRead(A0), 0, 1023, 0, 2048) — цель, и вал гонится за разницей
- step(diff) одним куском запирает loop() на секунды — доводите порциями по таймеру millis()
- Один шаг каждые 8 мс — 125 шагов в секунду, около 22 градусов вала; вторая передача — по 3 шага за такт
- Мёртвая зона 6 шагов (3 деления АЦП) гасит дрожь покоя ценой остановки в шаге от цели
- map() не ограничивает выход: для целей вне потенциометра страхуйтесь constrain(0, 2048)
Ручка — самый наглядный приказчик, но не единственный. Если механизму нужны точные повторяющиеся ходы — плюс десять, минус десять — ручка уступает кнопкам, а с ними приходит новая пара граблей: как считать нажатие ровно один раз и сколько шагов прячется в десяти градусах. Этим займёмся в следующем уроке.
Частые вопросы по теме урока
- Что задаёт ручка потенциометра в схеме с договором положения?
- Каждое деление ручки отвечает конкретному углу: map переводит 0-1023 в 0-2048 шагов, и вал догоняет цель. Ручка-скорость — другой договор: он для подач и конвейеров, где важен темп, а не место. Целевой угол вала
- Сколько шагов вала даёт одно деление АЦП после map в диапазон 0-2048?
- 2048 позиций на 1023 деления — почти ровно два шага на деление. Отсюда и мёртвая зона: шум в два-три деления — это четыре-шесть шагов, и порог в шесть шагов накрывает его с запасом. Около двух шагов
- Что случится, если отдать разницу до цели в step() одним куском?
- step() блокирующий: семьсот шагов при 10 RPM — две секунды молчания loop(). Плюс вал едет с постоянной скоростью драйвера, без разгона и торможения — тот самый рывок, который портит наводку. loop() запрётся на время всего хода
- Один шаг каждые восемь миллисекунд — какая скорость на валу?
- 125 шагов в секунду умножаем на 0,176 градуса — около 22 градусов в секунду. Полный перегон 2048 шагов займёт примерно шестнадцать секунд, и всё это время loop() остаётся живым. Около 22 градусов вала в секунду
- map() получил 1050 вместо максимума 1023. Что вернёт map(x, 0, 1023, 0, 2048)?
- map() пропорцию не ограничивает: выход уедет за диапазон. Для источников цели вне потенциометра добавляйте constrain(0, 2048). 2100 — за пределом оборота