Управление с телеметрией по serial

В общем… заставил его принимать команды от пульта в строковом виде (например <19456;x:-51;y:340;b:0;>), парсить и т.д. и отвечать пульту текстом, содержащим все полученные контроллы (пока что три штуки).Также нагрузил вычислениями для всех шести лап: кривых 4й степени, которые не оптимизировал (возможно даже не буду) и вычислениями углов серво для достижения точки в локальных координатах.
Единственное что ему осталось делать - это парсить еще 5 команд, вычислять положение в пунктах меню, рассчитывать опорные точки кривых для каждой конечности (поворот и смещение паттернов) и простенький менеджер состояний.
Выдал в среднем чуть больше 50 циклов loop() за секунду


честно говоря, у нас ШИМ 20мс, то есть в секунду умещается как раз 50 импульсов, и я не вижу смысла пытаться вычислять и менять его чаще. Вот обмен данными происходит пореже, примерно 20 раз за секунду, но как бы я не умею нажимать кнопки и дергать стики быстрее, так что тоже норм.
Из неприятного, получается полноценное чтение приемником происходит примерно каждый второй - третий - второй - третий - (и так далее) цикл loop(), то есть в итерации, в которой производится чтение данных, меня ждет подтупание с используемым малым dt из предыдущей итерации, а в последующей - напротив, домножение кинематики на слишком большое dt. Пока понадеюсь, что эти рывки будут сглажены механической инерцией, и при этом не заставят серво нагреваться из-за попыток двигаться микро-рывками.

Добавил позже: кстати я же точно не знаю, сколько занимает по времени алгоритм приема сигнала, надо замерить будет тоже. Скорее всего прием происходит не каждый цикл просто потому что на него формируется ответ, который на пульте тоже принимается и на него формируется ответ… Специально же погибал разбирался, как на модулях максимальную скорость обмена по воздуху и по uart включить. Если прием на самом деле происходит быстро, то с кинематикой вообще всё ок будет.

прикольно, отнес мегу на зарядке в другую комнату (две шлакоблочные стены) и поехали задержки связи, меняющиеся при изменении положения антенны)))) Где-то треть пакетов только доходит, но это модули на самой минимальной мощности

Надо же, даже вычисление точек в пространстве? Похвально… Я б на 8 битах не решился, в сторонуSTM32 думал бы.
Вычисления 50 раз/с это нормально, скажем так, необходимый минимум.

а иначе смысл)) если интересно

float interpolation (float v0, float v1, float v2, float v3, float v4, float t) {
    
  return v0
       + (- 3 * v4 + 16 * v3 - 36 * v2 + 48 * v1 - 25 * v0) / 3 * t
       + (22 * v4 - 112 * v3 + 228 * v2 - 208 * v1 + 70 * v0) / 3 * t * t
       + (- 48 * v4 + 224 * v3 - 384 * v2 + 288 * v1 - 80 * v0) / 3 * t * t * t
       + (32 * v4 - 128 * v3 + 192 * v2 - 128 * v1 + 32 * v0) / 3 * t * t * t * t;
       
}

это ф-ия вычисляет значение многочлена 4й степени при парметре t, меняющемся от 0 до 1, причем такая кривая будет проходить через точки v0, v1, v2, v3 и v4. По идее коэффициенты перед степенями можно предвычислить для каждой кривой заранее, но я не парился. На 3д просто эта ф-ия применяется для каждой координаты отдельно, в результате получается примерно следующее


дополнительным бонусом вы можете имитировать ускорение, т.к. ф-ия параметрическая, и некоторые точки могут быть друг к другу ближе чем другие - между ними движение будет медленнее при равнозначном приросте t, и наоборот для точек, которые дальше.


// поворот сервопривода до указанного угла без проверки переданного значения угла на вхождение в допустимый диапазон!
    void set (float _angle) {

      // вычисление длины импульса ШИМ согласно калибровочным значениям и требуему углу положения вала.
      // Поскольку функция map() оперирует целыми числами, отбрасывая дробную часть,
      // для более точных вычислений производится домножение значений всех углов на 100
      uint16_t pulse = map(_angle * 100, minAngle * 100, maxAngle * 100, minAnglePulse, maxAnglePulse);

      // вывод нового ШИМ
      servo.writeMicroseconds(pulse);

    }

так производится отправка нового шим, соответствующего переданному углу. Можно повлиять на направление вращения, т.е. на то, с какого положения будет отсчитываться угол - просто при калибровке minAnglePulse и maxAnglePulse получаются какими надо, хранятся в EEPROM, а функция map как раз масштабирует даже перевернутые диапазоны вполне корректно.


   // установка когтя конечности в указанную позицию (цель), заданную в локальной системе координат
    // возвращает true если удалось вычислить все значения углов,
    // и они не выходят за пределы допустимых для соответствующих сервоприводов, иначе возвращает false
    bool set (tDot &target) {

      // считается, что конечность не должна "подламываться" под себя
      // хотя механически такое движение возможно
      if (target.x >= 0) {
        // длина проекции на горизонтальную плоскость отрезка,
        // проведенного от начала координат (сервопривод 0) до цели
        float xoy_target_dl = hypot(target.x, target.y);
        // угол поворота серво 0
        // задает направление конечности в сторону целевой точки
        float a0 = asin(target.y / xoy_target_dl) * deg;
        // длина отрезка от серво 1 до цели
        // не должна быть короче L2 - L1
        // не должна быть длиннее L1 + L2
        float s1_target_dl = hypot(xoy_target_dl - L0, target.z);
        if ((s1_target_dl >= L2 - L1) && (s1_target_dl <= L1 + L2)) {
          // угол наклона отрезка, проведенного от серво 1 до цели
          // минус угол серво 1 в треугольнике,
          // образованном L1, L2 и отрезком между серво 1 и целью
          float a1 = (atan2((xoy_target_dl - L0), target.z) - acos((L1 * L1 + s1_target_dl * s1_target_dl - L2 * L2) / (2 * L1 * s1_target_dl))) * deg;
          // угол поворота серво 2 в треугольнике,
          // образованном L1, L2 и отрезком между серво 1 и целью
          float a2 = acos((L1 * L1 + L2 * L2 - s1_target_dl * s1_target_dl) / (2 * L1 * L2)) * deg;
          // проверка всех вычисленных углов на вхождение в допустимые диапазоны соответствующих сервоприводов
          // воизбежание игнорирования ошибок кинематики, движение конечности будет осуществляться только в том случае, если все углы достижимы
          if (servos[0].check(a0) && servos[1].check(a1) && servos[2].check(a2)) {
            servos[0].set(a0);
            servos[1].set(a1);
            servos[2].set(a2);
            return true;
          } else return false;
        } else return false;
      } else return false;

    }

тут самая сложная тригонометрия, но посути тоже ничего непостижимого… Длины плеч и номера серв у меня с нуля. Оффсета в горизонтальной плоскости нет - лапы расчертил так, чтобы именно по положениям линий плеч в одной плоскости лежали, т.е. чуть упрощенные вычисления. Я хз как объяснить, короче бывает у многих в проектах, что кончик ноги не получается разместить ровно на одной линии с сервоприводом, который вертикально вращает… вот этого косяка я избежал, просто чтоб еще один atan не вычислять.

Это так… выдержки из классов, для понимания что всё на самом деле просто и посильно для меги

Сильно!

Вопрос в скорости)
Это ж сколько тысяч тактов надо для этого “кошмара 8-битных МК”? Почти 20мс?
Почему не заморочиться, просчитать заранее что возможно и не забить флеш таблицами?

Для анимаций планирую просто сделать массивы кривых, для кривых время выполнения прописать и флаг на необходимость переходить к выполнению следующей кривой (как бы менеджер очереди организовать)

Для шага думаю определить у каждой лапы цилиндр, который точно впишется в область допустимых положений когтя. Центр нижнего основания считать исходной точкой для конечности.

В режиме перестановки одновременно по три при начале шага просто сразу три конечности “зашагивают” немного вперед, а оставшиеся в это время толкают землю назад (вернее против вектора движения).

В режиме перестановки по две чуть сложнее, т.к. придется определить у основания цилиндра три зоны: центральный круг, в котором всё ок, среднее кольцо - если нога в нем оказалась, то надо бы задуматься о ее перестановке, и крайнее кольцо - попали в него, значит прям ппц надо переставляться. Пронумеровать правильно конечности и для перестановки выбирать “n” и “n + 3” -юю конечности (0-3, 1-4, 2-5) вбирая n-ной ту, которая ближе всего к красной зоне.

Шаги тоже думаю через кривые сделать, просто чтобы один и тот же обработчик использовать, да и не ловить резких изменений дельты времени. Если точки “кривых” расположены на одной прямой и на равном расстоянии, то по параметру как раз прямая равномерно и строится. А вот для перестановки можно сделать довольно хитрую кривую, которая сгладит рывки при подъеме и изменении движения лапы на противоположное.

Как примешивать вращение одновременно с поступательным движением пока хз, но скорее всего расположу еще по 1 кривой внутри “цилиндра лапы”, по дуге окружности с центром в центре робота, равномерно по ней распределенные. Потом возьму среднее между каждой точкой кривой для линейного и для вращательного движений и получу некую суммарную кривую, по которой должна двигаться лапа. Хз пока, в общем

Вот пока оно справляется 50 раз в секунду, особо париться не собираюсь. Перестанет справляться - буду дробные коэффициенты предвычислять до константного множителя и т.д.

Я считаю так… строковый протокол, неоптимизированная тригонометрия - это можно назвать лишними вычислениями конечно… но если шим не чаще 50 раз в секунду можно поменять, то наверное расчет кинематики 100 раз в секунду - это же тоже вдвое больше лишних вычислений, верно? У нас же почти нет понятия загрузки процессора, пустой loop будет грузить его на те же 100% )))

У меня по крайней мере плата будет видеть смысл своей кропотливой работы, а не так что быстро быстро сделала, но половина ее результата сразу в утиль летит (шутка на грани бреда)

Именно что не пустой, ещё куча задач по связи и чёрт знает ещё для чего)
Конечно не надо вычислять 100 раз/с, достаточно 50, но при избытке мощностей их можно направить на более точную работу, например вычисление ускорений.
Возник вопрос. Робот будет ходить по неровным поверхностям?

пока нет такой цели, но есть занятная идея реализации. Если подключить акселерометр и он сможет давать адекватные показания, то можно улавливать момент касания конечности до поверхности, находящейся выше основного уровня, и вносить соответствующую корректировку координаты z. То есть когда конечность упрется в выступ, и продолжит давить вниз - пойдет перекос корпуса, вот по нему можно определить и скорректироваться (решить треугольник просто)

такой будет стоить как 10 твоих роботов

t*t[*t] можно сразу оптимизировать предвычислением, полагаю.

180 тысяч рублей?

как минимум квадрат во временную, да. Но суть что (- 3 * v4 + 16 * v3 - 36 * v2 + 48 * v1 - 25 * v0) / 3 и последующие такие же значения можно вычислить 1 раз, если точки, через которые проходит кривая не меняются, т.е. при объявлении

И некоторые параметры, основанные на длинах плеч конечности, при вычислении углов серво тоже можно предвычислить при объявлении

а вот более интересное: скажем, перестановка лапы. Надо поднять вверх, немного занося назад, потом дугу по верху и опустить, плавно разворачивая горизонтальную скорость. Не обязательно решать в 3д, можно решить в боковой проекции, оставив, например Y равные нулю, т.е. параметрическую кривую считать только для двух координат. А потом полученную в моменте точку развернуть относительно центра по направлению вектора движения уже в 3d (нет, опять же только X и Y, т.к. вращение вокруг вертикальной оси).

Замерил время приема сообщения длиной 50 символов (9 элементов) + парсинг + подготовка ответа из 10 параметров, один из которых строка на 100 символов. На это в сумме тратится от 18 до 25 мс, из них 4-9 мс уходит на чтение из serial.

На обмен сообщениями предполагаемой размерности будет уходить около 10мс, в контексте задачи приемлемо.

В итоге класс получился такой


class cWirelessLink {

  public:
  
    // открытие порта, проверка работоспособности модуля
    String start(HardwareSerial *_serial, uint8_t _setPin) {
      
      serial = _serial;
      setPin = _setPin;

      String log = "Methood start (pin " + String(setPin) + " might been connected to HC-12 SET pad)";

      pinMode(setPin, OUTPUT); // конфигурация пина активации режима AT
      serial -> begin(115200); // поднятие порта на 115200
      delay(500);
      digitalWrite(setPin, LOW); // активация режима AT команд
     
      // далее идет отправка AT команд в модуль и получение результата
      // по datasheet так и не стало понятно, перед отправкой нужны задержки 40мс или после, или между
      // поэтому используются задержки по 40мс между каждыми взаимодействиями с модулем
     
      // отправка запроса на проверку подключения
      delay(40);
      serial -> print("AT\r");
      delay(40);
     
      if (serial -> readString().indexOf("OK") != -1) {

        // подключение удалось сразу на 11520
        // считаем что модуль уже сконфигурирован
        // однако это лишь допущение
        // так как не проводится проверка других параметров
     
        log += "\r\nSucessfully connected on 115200";
     
        serial -> print("AT+RX\r"); // запрос конфигурации
        delay(40);
        log += "\r\n" + serial -> readString();
     
      } else {
     
        log += "\r\n Failed to connect on 115200, trying configurate on 9600"; // походу подключен новый модуль, попытка настроить на 9600
        
        serial -> end(); // закрытие порта
        serial -> begin(9600); // поднятие порта на стандартнх 9600
     
        delay(40);
        serial -> print("AT+B115200\r"); // отправка конфигурации - скорость порта
        delay(40);
        serial -> print("AT+FU1\r"); // отправка конфигурации - режим
        delay(40);
        serial -> print("AT+P1\r"); // отправка конфигурации - мощность
        delay(40);
     
        digitalWrite(setPin, HIGH);  // выход из режима AT команд
        delay(80); // задержка для применения настроек модулем по datasheet 80мс
     
        serial -> flush(); // очистка буфера порта (там результат применения конфигов, возможно прочий мусор)
        serial -> end(); // закрытие порта
        serial -> begin(9600); // поднятие порта на вожделенных 115200
        
        digitalWrite(setPin, LOW); // запуск режима AT команд
        delay(40);
        serial -> print("AT\r"); // отправка запроса на проверку подключения
        delay(40);
     
        if (serial -> readString().indexOf("OK") != -1) {
     
          log += "\r\nSucessfully connected on 115200 after configurate"; // подключение после настройки удалось
     
          serial -> print("AT+RX\r"); // запрос конфигурации
          delay(40);
          log += "\r\n" + serial -> readString(); // вывод конфигурации
     
        } else log += "\r\nFailed to configure HC-12 wia AT commands, check physical connection"; // попытка настройки не удалась - проверяем провода,
                                                                                                  // подаем питание закоротив SET на GND (отключив от Arduino!!!)
      }
     
      // serial -> print("AT+DEFAULT\r"); delay(40); // сброс модуля на заводские (еси нужно потестить работу метода)

      digitalWrite(setPin, HIGH);  // выход из режима AT команд
      delay(80);

      serial -> flush(); // очистка буфера порта

      return log;
     
    }
    
    // все сообщения начинаются с < и заканчиваются >
    // сразу после < следует строковое представление числа - хеш суммы сообщения
    // все элементы в сообщении разделяются ;
    // имя элемента от его значения отделяется :
    // использование < > ; : в именах и значениях элементов не допускается,
    // проверки на эти вхождения в методах add нет! (можно добавить, но целесообразно ли?)

    // добавление элемента к предназначенному к отправке сообщению, вернет true только если было добавлено
    bool add (const String name, String value) {

      if (outcoming.length() + value.length() + 1 < byteTreshold) {
        // элемент будет добавлен, только если это не приведет к превышению допустимого размера сообщения,
        // а также если элемента с таким же именем еще нет в сообщении
        if (outcoming.indexOf(";" + name + ":") != -1) return false;
        outcoming += name + ":" + value + ";";
        return true;
      } else return false;

    }

    // поиск элемента по его имени в принятом сообщении, возврат значения, либо пустой строки если элемент не нашелся
    String parse (const String name) {

      uint8_t pos = recieved.indexOf(";" + name + ":") + name.length() + 2;
      return (pos > 2) ? recieved.substring(pos, recieved.indexOf(";", pos)) : "";

    }

    // отправка сообщения, вернет true если не превышен максимальный размер сообщения
    // строка, содержащая подготовленное сообщение очищается в любом случаае
    bool send () {

      if (outcoming.length() <= byteTreshold) {

        // первая ";" считается частью сообщения (упрощает извлечение первого элемента в сообщении методом parse)
        outcoming = ";" + outcoming;
        // обрамляется в <>, сразу после стартового символа помещается хеш
        serial -> print("<" + hash(outcoming) + outcoming + ">");
        // запоминается момент последней отправки сообщения
        lastTransmittingTime = millis();
        outcoming = "";
        return true;

      } else {

        outcoming = "";
        return false;

      }

    }

    // возвращает время в милисекундах, прошедшее с момента последней состоявшейся отправки исходящего сообщения
    uint16_t silence () { return millis() - lastTransmittingTime; }

    // вернет true, когда получит корректное сообщение целиком
    // что будет поводом вызвать parse() для получения значений ожидаемых элементов
    bool get() {

      // принимаемый символ
      char character = '_';
      // счетчик принятых символов
      uint16_t count = 0;
      // время последнего успешного приема символа
      uint32_t last = millis();

      // ожидание символа начала сообщения
      while (character != '<') {
        if (serial -> available()) {
          character = (char)serial -> read();
          last = millis();
          count++;
          if (count > receiverByteTreshold) return false; // может быть прервано по превышению количества принятых бит
        } else if (millis() - last > timeTreshold) return false; // может быть прервано по времени "молчания" порта
      }

      // в character будет "<" можно было присвоить константой, или вовсе не записывать,
      recieved = "";
      recieved += character; // но так нагляднее отлаживать

      // ожидание символа конца сообщения, аккумуляция принимаемых данных
      while (character != '>') {
        if (serial -> available()) {
          character = (char)serial -> read();
          recieved += character;
          last = millis();
          count++;
          if (count > receiverByteTreshold) {
            // может быть прервано по превышению количества принятых бит
            recieved = "";
            return false;
          }
        } else if (millis() - last > timeTreshold) {
          // может быть прервано по времени "молчания" порта
          recieved = "";
          return false;
        }
      }
      
      // это последнее место в коде, где сообщение можно посмотреть/вывести целиком
      // сообщение получено, необходимо выделить хеш
      String declared = recieved.substring(1, recieved.indexOf(";"));
      // из сообщения удаляеются хеш, символы начала и конца
      recieved.remove(0, recieved.indexOf(";"));
      recieved.remove(recieved.indexOf(">"));

      // если вычисленый методом класса хеш равен полученному в сообщении, то элементы полученного сообщения остаются в памяти
      if (declared == hash(recieved)) return true;
      else {
        // если хеш не сошелся то сообщение очищается
        recieved = "";
        return false;
      }

    }

  private:
  
    // ссылка на используемый HC-12 serial порт
    HardwareSerial *serial;
    // номер вывода, к которому подключен вход SET платы HC-12
    uint8_t setPin;
    // максимально допустимая длина сообщения
    const uint16_t byteTreshold = 512;
    // максимальное количество перебираемых бит данных при попытке принять сообщение
    // (длина + максимальное кол-во символов хеш + открывающий и закрывающий тег) * 2
    const uint16_t receiverByteTreshold = (byteTreshold + 5 + 2) * 2;
    // количество миллисекунд неполучения ответа от serial, по достижении которых происходит сброс при приеме данных
    const uint16_t timeTreshold = 10;
    // время последней состоявшейся отправки сообщения (не путать с успешным приемом на стороне получателя)
    uint16_t lastTransmittingTime = 0;
    // строка для хранения принятого сообщения (в случае неудачной попытки будет пустой)
    String recieved = "";
    // строка для хранения подготавливаемых к отправке данных (опустошается после любой попытки отправки)
    String outcoming = "";

    // функция вычисления хеш суммы
    String hash (String& data) {
      // в качестве хеша используем XOR по четным и нечетным символам (байтам) строки
      uint8_t odd = 0, even = 0;
      for (uint8_t i = 0; i < data.length(); i++)
        if (i % 2) odd ^= data[i]; // нечетные
        else even ^= data[i]; // четные
      // четная сумма занимает старший байт в итоговом числе
      return String((uint16_t) even * 256 + odd);
    }

};

Инициализация

wirelessLink.start(&Serial2, 19)
// либо с выводом лога
Serial.println(wirelessLink.start(&Serial2, 19));

использование

if (wirelessLink.get()) {

  // было принято сообщение
  
  // запрашиваем параметры по имени
  int x = wirelessLink.parse("anyX").toInt();
  String str = wirelessLink.parse("MyStr");
  // ...

  // набиваем параметрами сообщение-ответ
  wirelessLink.add("anyY", String(Y));
  wirelessLink.add("anyAnswer", stringAnswer);
  // ...
  
  // отправляем
  wirelessLink.send();

} else if (wirelessLink.silence() > 100) {

  // здесь можно обработать долгое неполучение никаких данных от устройства-ответчика
  // например послать инициативный запрос на восстановление связи
  
  wirelessLink.add("err", "Where you are???");
  wirelessLink.send();

}

В целом пока что устраивает, благодарю всех за полезную информацию

133 поста, а оказалось ни кто не помог, он сам всё решил. Капец боец.)))

Я таких вумных новичков за 2 года не видывал на просторах форума. Втихаря на ус мотаю идеи, только тссс!

Ежели путь робота непредсказуем, возможно стоит думать в направлении тензодатчиков на ногах. Не знаю какие конкретно и как, просто мысли.

Звучит как “путь самурая” или “путь джедая” :slight_smile:

Да, не понятно почему такая концепция хождения для гексапода.

Не помню уже, но видел!
Для неровных поверхностей на кончиках лап микроконцевики и всё! Паук не спотыкается, вопрос шести кнопок.

Может мой концепт отопытничаете? Он прост в понимании для повторяющих.
Концепт :slight_smile
1.В массиве хранятся углы набора серв для разных поз-кадров.
2.Функция самописная плавно ведёт качалки всех серв от кадра к кадру, замыкая их в разные походки - кино да и только.

Такое же ощущение, но менять не стал)
@lilik , ведь интересней когда робот сам “думает” как и чем ему шагнуть) А для этого

не очень. Именно тензодатчики. Понимать на какую ногу какая нагрузка.
Спасибо этим вашим Вольфрамам с его теориями. Вспомнились первые программы клеточного автомата…Эх.

Самое страшное произойдёт, когда робот поймёт зачем и для чего шагнуть))))

1 лайк

Тротиловой шашки в шасси для облегчения работы Джона Коннора

1 лайк