Skip to content

Зарядное устройство на модулях ESP32_Devkitc_V4 и SAMD21 M0-Mini. Проект Arduino, в разработке.

Notifications You must be signed in to change notification settings

olmoro/MKlon3.5v7

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MKlon3.5v7

Зарядное устройство на модулях ESP32DEVKITCV4, SAMD21 MINI и ILI9486 3.5".

(реализация конечного автомата в проекте зарядного устройства)

Проект Arduino.

2023.11.09

MKlon3.5 - это вариант компоновки проекта MKlon2 с сохранением основных параметров, исключая разве что пользовательский интерфейс, реализованный на дисплее ILI9486 3.5 дюйма с сенсорной панелью. Реализована "этажерочная" конструкция из трёх плат: дисплей и две платы размером 100х65 мм — плата управления и силовая плата. Предусмотрены разъемы для подключения логического анализатора к выводам обоих модулей - ESP32 и SAMD21 и сигнальные светодиоды.

Аппаратная часть проектировалась в среде Eagle v7.3.0, программная - в PlatformIO.

  1. Как это работает. Аппаратный аспект.
  1. Как это работает. Программный аспект
  1. Технические характеристики
  2. FSM
  3. Первый проект
  4. Драйвер SAMD21
  1. Документы
  1. Полезные ссылки
  2. About Me

Как это работает.

В зарядном устройстве пользовательский интерфейс реализован на модуле с ESP32 (ESP), а управление модулем силовой платы производится по последовательному каналу. На ESP обслуживается дисплей, RGB индикатор, зуммер, система контроля питания, система охлаждения, интерфейс драйвера силовой платы, USB, WiFi, BT. В памяти ESP хранятся заводские и пользовательские настройки. Оператору предоставляется возможность выбрать посредством меню требуемый режим работы, задать параметры и запустить. Настройки беспроводной сети позволяют в реальном времени на удаленном компьютере не только наблюдать в виде графиков процесс: напряжение, ток, баланс заряда/разряда, температуру, работу системы охлаждения, но и управлять им. Программное обеспечение ESP построено таким образом, что позволяет даже непрофессиональному программисту реализовать свой собственный алгоритм, воспользовавшись методикой из репозитория FSM.

Весь, или почти весь реалтайм реализован на отдельном модуле SAMD21 MINI (D21). Связь c ESP осуществляется по асинхронному каналу. Модуль D21 получает команду, по которой включаются соответствующие ресурсы: АЦП тока и напряжения, ШИМ-генератор, ЦАП управления разрядом, коммутатор выхода и др. Автоматически поддерживается устойчивость силового преобразователя при работе на малую нагрузку или на холостом ходе, отключение при перегрузках. Поддерживается режим быстрого разряда накопительных конденсаторов через нагрузку для разряда, минуя датчик тока, дабы не искажать подсчет ампер-часов. Когда возможно допускается регулировка параметров "на лету", иначе D21 переводит силовую часть в безопасный режим с выдачей сообщения управляющему контроллеру. D21 поддерживает управление зарядом и разрядом как командами, так и с помощью собственного ПИД-регулятора. Настройки коэффициентов для поддержания тока и напряжения могут быть разные. Выбор ПИД-регулирования имеет 4 режима: отключено, по току, по напряжению и с автовыбором по типу реализованного в TL494. Измерения напряжения на батарее производятся по четырехпроводной схеме в диапазоне от -2 до +18 вольт. Ток разряда измеряется на том же шунте, что и ток заряда от -10 до +10 ампер. Параметры фильтрации измерений задаются командами.

Питание прибора производится от внешнего AC/DC преобразователя (БП) на 18...19 вольт 6...9 ампер, что определяет выходные параметры зарядного устройства и делает само устройство с точки зрения электробезопасности более привлекательным.

В разделе "Как это работает" позиционные обозначения на рисунках могут отличаться от имеющихся в документации.


1. Как это работает. Межпроцессорное соединение.

Здесь реализована как минимум одна хитрость: модуль SAMD21 MINI запитан от изолированного источника питания со смещением в "минус" примерно на 200 милливольт, необходимые для дифференциальных измерений напряжения и тока. При реализованной топологии печатной платы существенного влияния на обмен по асинхронному интерфейсу не обнаружено.

Однако есть и недостаток: при одновременном подключении к одному компьютеру обоих USB указанное смещение становится равным нулю. И в результате как напряжение, так и ток отрицательной полярности при любом их значении индицируются вблизи нуля. Всё бы ничего, но в режиме разряда PID-регулятор поддержания установленного тока просто "сходит с ума" - всё добавляет и добавляет, пока ток не "упрется" в максимум, определённый сопротивлением нагрузки.

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

Активным в связке является ESP32, который генерирует запрос по асинхронному интерфейсу. Линия READY - резервная, При экспериментах служит для синхронизации логического анализатора.

В начало ^


Как это работает. Межпроцессорный обмен.

Связь между управляющим и измерительным контроллерами в части физического интерфейса состоялась в пользу UART, аппаратная реализация которого намного проще, чем прожорливый и склонный к "неожиданностям" капризный I2C.

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

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

Обмен идет посылками на скорости 230400 бод. Формат посылки (команды), как запроса, так и ответа выбран таким, чтобы укладываться в отведенные 500-600 микросекунд между регулярными циклами (1кГц) измерений тока и напряжения, включающих регулирование, обработку перегрузок и т.п. В пересчете на байты это не более 6-8 байт данных плюс 4 служебных (стартовый, код команды, число байт и контрольная сумма пакета) и ещё должен быть запас на возможный байт-стаффинг. Данные передаются только как целочисленные и только один раз за 100 мс.

Управляющий ESP32, если нет запроса на посылку целевой команды, отправляет запрос на получение данных от измерителя (2 байта напряжения и 2 байта тока) и 2 байта состояния SAMD21. Напряжение и ток в физических единицах - милливольтах и миллиамперах со знаком. А два байта состояния дают исчерпывающие данные для управления процессом, учитывая то, что весь "реал-тайм" исполняется контроллером SAMD21 и не требует "мгновенного" вмешательства управляющего ESP32 в процесс целевой командой. В таком случае одно измерение из 100 заменяется предыдущим, что практически не заметно при визуализации как на дисплее, так и браузере.

В начало ^


Как это работает. Измерители.

Измерение напряжения и тока производится по дифференциальной схеме. Микроконтроллер SAMD21 предоставляет такую возможность. Только следует учесть, что 12-разрядный АЦП становится фактически 11-разрядным плюс знаковый разряд. И рассчитывая параметры шунта и делителя напряжения следует оперировать половинным значением опорного напряжения. На входах установлены НЧ фильтры и диоды защиты. А чтобы при манипулировании с "крокодилами" вход измерителя напряжения не повисал неподключенным, одноимённые силовой и измерительный провод соединены резисторами (на рисунке не показаны).

В начало ^


Как это работает. Вычисление среднего.

Не вызывает никаких сомнений метод вычисления среднего за определенный интервал, когда сумму измерений делят на их число. Однако для получения следующего среднего придется всё повторить с начала, и на это потребуется время. Но есть метод, когда на следующем цикле измерения убирается (вычитается) самый ранний уровень и добавляется (прибавляется) новый, а затем вычисляется среднее. Всё бы хорошо, но чтобы сохранить все эти уровни может потребоваться немалый расход памяти. Есть метод, позволяющий устранить этот недостаток, заменив вычитаемый ранний уровень средним значением, и называется он скользящим средним. Наибольший выигрыш дает не произвольное число измерений, а кратное степеням двойки: 2, 4, 8, 16, тогда операцию деления можно заменить арифметическим сдвигом. Соответственно буфер (sum) для накопления должен иметь такое число разрядов, чтобы не возникало переполнения. Функция вычисления для 16-разрядного ADC может выглядеть так:

int16_t averageU(uint8_t k) 
{
  static int32_t sum = 0;  // объявили и инициализировали сумматор удвоенной, чем ADC разрядности 
  int16_t avrU = sum >> k;  // вычислили среднее за прошлый интервал (накопленное поделили на число измерений)
  sum += adcU - avrU;    // к сумматору добавили разность между новым измерением и средним
  return int16_t(sum >> k);  // вернули новое значение среднего
}

Внимание! Будьте внимательны со скобками - в C++ приоритеты примененных здесь операций в порядке убывания: () - >> += . То есть среднее равно (sum += adcU - (sum >> k)) >> k - это без округления, младшие биты просто отбрасываем. С округлением к результату следует прибавить:

  ((sum >> (k - 1)) & 1);

В этом проекте одновременно с подсчетом среднего производится преобразование 12-разрядного ADC в 16-разрядный с помощью простого приема, для этого при вычислении среднего за прошлый интервал результат сдвигается на k плюс число недостающих разрядов ADC, то есть на k + 4. Если бы не одно "но". Когда колебания измеряемого параметра укладываются в единицу счета АЦП, то и результат будет стремиться к "родному" 11-разрядному. С измерением напряжения на батарее скорее всего так и будет (плюс-минус 10 милливольт), а вот с током заряда, где колебания обусловлены физико-химическими процессами, плюс-минус 1 миллиампер вполне достижим.

А есть ли у метода "скользящее среднее" недостаток? Есть, в начальный период, пока сумматор не накопит достаточного количества измерений, значение среднего может оказаться существенно меньшим, чем реальное значение. Но и здесь не всё так печально. Во-первых, это можно использовать в мирных целях, например для "мягкого старта" системы регулирования. А во-вторых, объявляя сумматор инициализировать его не нулем, а некоторой величиной, например при измерении температуры радиатора задать величину, соответствующую температуре окружающей среды, не забыв сдвинуть влево на "k" разрядов. Для измерителя напряжения ЗУ это будет около 12 вольт. Так сумматор быстрее наберёт необходимое число измерений.

В начало ^


Как это работает. Схема разряда.

Измерение тока разряда производится на том же шунте RS, что и ток заряда, исключая неоднозначность при подсчете залитых и слитых ампер-часов. Для облегчения теплового режима предусмотрен разъем для подключения внешней нагрузки. В случае отсутствия таковой ток ограничивается двух-ваттным резистором. В режиме заряда при чрезмерно малой нагрузке силовой DC/DC автоматически подгружается этим резистором для обеспечения его устойчивой работы, причем этот ток не фиксируется шунтом, а потому не вносит погрешности в подсчет ампер-часов.

В начало ^


Как это работает. Подключение заряжаемой батареи.

Здесь ничего нового - схема "цельнотянутая" с прототипа, коим является Кулон 912 и 920-й. Даже предохранитель и 300-амперный диод защиты от повреждения электролитических конденсаторов силового преобразователя при переполюсовке вписались как родные. Но это не более чем перестраховка - при разработке всякое может случиться. Последний барьер не помешает. В качестве ключей применены HEXFET МОП-транзисторы IRLR2905 с высокой скоростью переключения и повышенной лавиноустойчивостью, а также имеющие возможность управляться логическим уровнем. То есть оба изолированных источника в приборе могут быть 5-вольтовые. Посмотрим, оправдается ли такой выбор. Управление ключами производится через оптопару.

В начало ^


Как это работает. Питание модулей.

Питание модулей и схемы подключения батареи, коей нужна "дежурка" с гальваноразвязкой. Оказалось, что это достаточно просто получить, используя 1-ваттный преобразователь из 5 вольт входных в 12 вольт выходных. Аналогичный DC/DC на 5 вольт выходных используется для питания измерительного модуля SAMD21 для сдвига на 200 милливольт в минус от общего провода силового ИП. Эти пребразователи не имеют встроенного стабилизатора, но в обоих случаях это и не требуется. Подключение же их произведено со всеми рекомендациями - самовосстанавливающийся предохранитель на входе, LC-фильтры по входу и выходу, резисторы нагрузки и конденсатор между предполагаемыми "холодными" концами обмоток.

VD6 следует выбирать с минимальным обратным током, в лучшем случае он добавит пару милливольт к разности напряжений на клеммах.

В начало ^


Как это работает. Силовой DC/DC преобразователь.

Выбрана схема преобразователя с P-MOSFET IRFP9540 и драйвером MIC4420 как вполне достаточная, чтобы получить возможность отладки алгоритмов управления, оставляя на будущее масштабирование вольтамперных характеристик. В качестве "донора" был выбран XL4016 DC-DC Max 9A 300W, силовые компоненты которого, кроме 4016, были перенесены на плату прибора. Рабочая частота была воспроизведена путем настройки режима работы таймера SAMD21. Несмотря на относительно высокую частоту микроконтроллера (48МГЦ) и турбо-режим (72МГц) удалось получить лишь 9-разрядный выход 190-килогерцового ШИМа. Вкупе с ПИД-регулятором это обеспечивает заявленные дискретности установки тока и напряжения 10 мА и 10 мВ.

О резисторе на входе драйвера. Выяснилось, что в процессе работы может последовать включение питания без модуля SAMD21, и, чтобы избежать неконтролируемого напряжения на выходе преобразователя, пришлось подтянуть оказавшийся "в воздухе" вход к плюсу питания. Естественно, что если применен инвертирующий драйвер типа MIC4429, то вход надо подтянуть к общему проводу.

Выбор резистора в цепи затвора определяется следующими требованиями:

  1. ограничение тока драйвера (ниже допустимого для драйвера) при переключениях;
  2. ограничение скорости изменения напряжения на стоке, дабы не привести к броскам напряжения на затворе через емкость Миллера с возможным повреждением затвора и драйвера высоким напряжением;
  3. ограничение добротности затворной цепи, то есть колебательного контура, образованного емкостью затвора и паразитными индуктивностями проводников между драйвером затвора и самим кристаллом МОСФЕТа. Ибо высокодобротная цепь приводит к резонансным выбросам на затворе и возможности повреждения высоким напряжением как самого затвора, так и драйвера.

В итоге из доступных вариантов HCPL3120, UCC37322, транзисторный или MIC4420 предпочтительным по параметрам оказался последний. Опторазвязка, встроенные цепи компенсации эффекта Миллера или дешевизна реализации против 20-вольтовых MIC в схеме с конструктивно обеспеченном диапазоне напряжения питания 19-ю вольтами и выбором MOSFET с предельным напряжением исток-затвор 20 вольт дают возможность до предела упростить схему и получить хорошую повторяемость при использовании источника питания с гарантированными выходными параметрами.

В начало ^


Как это работает. Подключение вентилятора.

Система управления охлаждением охвачена обратной связью через датчик температуры типа NTC, установленный на радиаторе. Напряжение, подаваемое на вентилятор, управляется ПИД-регулятором. Порог температуры и коэффициенты Kp, Ki и Kd задаются настройками:

Как видно из иллюстрации, можно задать как ленивый, так и агрессивный характер системы. Мне понравился агрессивный, он же с перерегулированием - как только температура превысит порог, быстро её "сдуть" типа как бы чего не вышло, и через несколько таких включений-выключений установится некая средняя скорость вентилятора для поддержания температуры на заданном уровне.

В начало ^


Конструктивное исполнение.

Конструктивно зарядное устройство выполнено на трёх печатных платах - покупной дисплей и две заказные: плата управления и силовая плата.

  • дисплей ILI9486 480*320 3.5 дюйма с межосевым расстоянием между разъёмами (по факту) 92.5 мм. Поставщик хорошо упаковывает товар, тем и определился выбор. Сенсорная панель и карта памяти подключены.

  • плата управления (cpu), гербер-файл cpu3.5v5.zip, имеет размер 99.7 * 61.6 мм, что позволяет заказать изготовление с доставкой пяти штук за эквивалент $10.2 - около 800р.

  • силовая плата (pow), гербер-файл pow3.5v5.zip, имеет тот же размер за такие же деньги.

Механическое крепление - пластиковые стойки 12 мм для дисплея и 20 мм для силовой платы. Электрическое соединение плат cpu и pow выполнено на удлиннённых штыревых разъёмах с шагом 2.54 мм, что не исключает использование шлейфа в процессе отладки.

На модули ESP32_DEVKITC_V4 и SAMD21 M0-Mini устанавливаются штыри, а на плату соответственно цанговые гнёзда - они и надёжнее, и позволят модулям поместиться между платами прибора.

Почему 38-пиновая ESP32_Devkitc_V4, ведь на ней установлены только элементы питания и интерфейса? Вот и хорошо, в соответствии с аппетитом установим ESP32-WROOM-32D с 8 или 16 МБ вместо обычных 4 МБ да и габарит подходящий для установки на 62-х миллиметровую плату - и USB доступен, и антенна экранируется платами в меньшей степени. А припаять, поверьте, гораздо проще, чем выпаивать для замены. И штыри паять только после распайки ESP32.

Радиатор имеет посадочную площадь 62.5 * 51.0 мм - у меня, например, остался от какого-то "доисторического" компа. Крепится к плате на 5-6 миллиметровых стойках. Кстати, топология платы позволяет впаивать компоненты и ТО-220, и ТО-247 — любые, в соответствии с вашими предпочтениями и возможностями.

Вентилятор может быть любой – 50х50 или 60х60, на 12 или 24 вольта, до двух ватт. И почему бы не такой.

Силовые разъёмы все одинаковые типа PJ017, ноутбучные, на 6 ампер, формфактор 2.5 * 5.5 мм. Разъёмы имеют хороший теплоотвод на полигоны печатной платы. Ответные (штыревые) разъёмы допускают подсоединение двух проводов – силового 1.5 кв.мм. и тонкого сигнального без насилия. При этом калибровка, связанная с конечным сопротивлением проводов не требуется, а падение напряжения (проверено!) не превышает имеющийся запас по источнику питания. Такое преимущество даёт использование четырёхпроводного подключения к измерителю с дифференциальными входами. По предварительным расчётам в случае неправильного подключения (разъёмы-то все одинаковые!) ничего катастрофического для прибора случиться не должно.

Прибор оснащен двумя разъёмами для подключения 8-канального логического анализатора для контроля состояния дискретных портов модулей, RGB светодиодом, зуммером, тремя светодиодами аварийных ситуаций, дополнительным разъёмом для подачи 5-вольтового питания при работе с платой управления автономно, без силового блока. В наличии разъём для подключения внешней нагрузки в режиме разряда. И, наконец, разъём, на который выведены незадействованные порты ESP32 - а вдруг понадобятся.

Драйвер силового DC-DC преобразователя ... поскольку этот элемент в значительной степени влияет на качество системы регулирования, дабы избежать непредсказуемой реализации на дискретных компонентах и лишней головной боли всякий раз при поиске компромисса между быстродействием и устойчивостью, было принято решение использовать MIC4420 (повторяемость - наше всё!).

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

В начало ^


2. Как это работает. Программный аспект.

В директории src представлен полный комплект ПО прототипа с дисплеем 1,8 дюйма версии MKlon2.7a для модуля ESP32D.

Как это работает. Структура проекта.

Условно программную часть можно разделить на системную и целевую. Соответственно системная - зона ответственности разработчика за поддержку аппаратной части прибора и ничего, ну почти ничего не понимающего в аккумуляторах, а целевая - ответственность гуру в алгоритмах поддержки процессов при обслуживании батарей и мало смыслящего в тонкостях кодирования. Из этого следует, что набор приемов для него должен адаптировать системщик. В идеале, конечно ... но мне известен такой лишь один (Андрей З.).

Итак, для системщика, недавно пришедшего с Arduino совсем коротко:

Задачи Free RTOS:

  • Connect - управление беспроводным интерфейсом;
  • Main - управление выбором и исполнением выбранного режима;
  • Display - управление отображением на дисплее;
  • Cool - управление системой охлаждения;
  • Measure - обработка измерений питания, температуры, выхода кнопок, фильтрация;
  • Driver - отправка команд на силовой блок;
  • Loop - фоновая, прием ответа от силового блока.

Все задачи исполняются ядром 1, ядро 0 выделено для радиочастотных задач - BT и WiFi, что соответствует конфигурации Arduino по умолчанию.

Для понижения порога вхождения в программирование под Free RTOS использование возможностей операционной системы сведено к разумному минимуму.

Меню выбора режимов:

  • BOOT - синхронизация параметров микроконтроллеров при подаче питания;
  • OPTIONS - операции с пользовательскими настройками;
  • UPID - тестовый режим экспериментов с пид-регулятором по напряжению;
  • IPID - тестовый режим экспериментов с пид-регулятором по току заряда;
  • DPID - тестовый режим экспериментов с пид-регулятором по току разряда;
  • TEMPLATE - шаблон режима;
  • CCCV - режим заряда "постоянный ток / постоянное напряжение" - не откорректирован;
  • CCCVT - то же для экспериментов;
  • DEVICE - регулировки калибровок, выбор параметров фильтрации и др.

Сохранение настроек:

Единственным местом хранения настроек обоих микроконтроллеров является энергонезависимая память (NVS) ESP32. При доступе к данным используются имена разделов и ключи параметров. Значения ключей вкупе с их именами могут быть удалены при необходимости. Реализован такой алгоритм удаления: при входе в выбранный режим 7-кратное нажатие кнопки "B" инициирует удаление всех или по выбору ключей данного раздела.

Регистр состояния:

Состояние контроллера силового блока автоматически добавляется в каждом сеансе запроса текущего напряжения и тока в нагрузке. Формат битовый.

  • status_switch - DC-DC подключен к клеммам;
  • status_power - DC-DC включен;
  • status_current_control - пид-регулятор в режиме контроля тока;
  • status_voltage_control - пид-регулятор в режиме контроля напряжения;
  • status_charge - заряд включен;
  • status_discharge - разряд включен;
  • status_auto_mode - пид-регулятор в автоматическом режиме;
  • status_pid - регулирование идёт под пид-регулятором;
  • status_overheating - фиксируется перегрев;
  • status_overload - фиксируется перегрузка;
  • status_power_limit - фиксируется перегрузка по мощности;
  • status_reverse_polarity - фиксируется переполюсовка;
  • status_short_circuit - фиксируется короткое замыкание нагрузки;
  • status_calibration - резерв;
  • status_upgrade - резерв;
  • status_reserve2 - резерв;

Обмен данными между задачами:

Обмен синхронизирован, так что до применения очередей Free RTOS дело так и не дошло. Все данные имеют тип int16_t. Потерь и искажения данных из-за (не)атомарности не замечено. Похоже, что технология Arduino здесь что-то делает за нас - профи пусть уточнят сей момент.

Используемые библиотеки:

Во избежание мелких недоразумений библиотеки размещены непосредственно в проекте

Команды обмена между модулями по асинхронному интерфейсу:

  1. Команды чтения результатов измерений
  • txReadUIS() - запрос на получение текущего значения тока, напряжения и состояния;
  • txGetState() - то же только состояния;
  1. Команды stop/go
  • txPowerAuto(float spV, float spI) - задать параметры DCDC и включить;
  • txPowerStop()- DCDC выключить;
  • txPowerMode(float spV, float spI, uint8_t mode) - тестовое включение с выбором режима ПИД;
  • txDischargeGo(float spI) - задать ток разряда и подключить;
  1. Команды работы с измерителями
  • txGetFactorU() - запросить коэффициент преобразования в милливольты;
  • txSetFactorU(short val) - записать;
  • txSetFactorDefaultU() - восстановить заводское значение;
  • txGetSmoothU()- запросить параметр сглаживания по напряжению;
  • txSetSmoothU(short val) - записать;
  • txGetShiftU() - запросить приборный сдвиг по напряжению;
  • txSetShiftU(short val) - записать;
  • txGetFactorI() - запросить по току;
  • txSetFactorI(short val);
  • txSetFactorDefaultI();
  • txGetSmoothI();
  • txSetSmoothI(short val);
  • txGetShiftI();
  • txSetShiftI(short val);
  1. Команды работы с ПИД-регулятором
  • txSetPidConfig(uint8_t m, float kp, float ki, float kd, uint16_t minOut, uint16_t maxOut);
  • txSetPidCoeff(unsigned short m, float kp, float ki, float kd);
  • txSetPidCoeffV(float kp, float ki, float kd);
  • txSetPidCoeffI(float kp, float ki, float kd);
  • txSetPidCoeffD(float kp, float ki, float kd);
  • txSetPidOutputRange(uint8_t m, uint16_t minOut, uint16_t maxOut);
  • txSetPidReconfig(uint8_t m, float kp, float ki, float kd, uint16_t minOut, uint16_t maxOut);
  • txPidClear();
  • txGetPidTreaty() - согласование параметров при обмене;
  • txGetPidConfig() - запросит текщие настройки ПИД-регулятора
  • txSetPidFrequency(unsigned short hz) - изменить частоту регулирования;
  1. Тестовые
  • txGetProbes();
  • txGetAdcOffset();
  • txSetAdcOffset(short val);
  • txAdcAutoOffset() - не реализована, резерв;
  1. И ещё команды, полный список см. в файле mtools.h.

Компиляция версии MKlon2v7a от 26 марта 2023г:

PLATFORM: Espressif 32 (3.5.0) > Espressif ESP32 Dev Module

HARDWARE: ESP32 240MHz, 320KB RAM, 4MB Flash RAM: [= ] 14.8% (used 48460 bytes from 327680 bytes) Flash: [========= ] 94.4% (used 1237806 bytes from 1310720 bytes

HARDWARE: ESP32 240MHz, 320KB RAM, 16MB Flash RAM: [= ] 14.8% (used 48460 bytes from 327680 bytes) Flash: [== ] 18.9% (used 1237806 bytes from 6553600 bytes)

В начало ^


Как это работает. State.

В проекте многие процессы реализованы как конечные автоматы (Finite State Maсhine, FSM), в виде последовательности шагов от одного состояния к другому. Выбран конечный автомат Мура, где выход определяется однозначно тем состоянием, в которое автомат переходит после приема входного сигнала. Реализацию состояния рассмотрим позже, а пока - основа проекта - базовый класс MState, отвечающий за переход между состояниями. Не приходилось использовать виртуальные функции? Мне тоже.

файл mstate.h

#ifndef _MSTATE_H_
#define _MSTATE_H_

class MTools;

class MState
{
  public:
    MState(MTools * Tools);
    virtual ~MState(){}
    virtual MState * fsm() = 0;
  protected:
    MTools * Tools = nullptr;
};

#endif

Функция fsm() объявлена виртуальной, в файлах реализации режимов наследуется как виртуальная и возвращает указатель на вызываемое состояние: this - в нашем случае при следующем вызове оставаться в том же состоянии, или назначить указатель через оператор new для перехода в иное состояние. Специальный указатель nullptr используется для завершения работы автомата. Класс MTools - класс утилит, которыми в конечном итоге пользуется разработчик целевого проекта.

файл mstate.cpp

#include "mstate.h"
#include "mtools.h"

MState::MState(MTools * Tools) : Tools(Tools) {}

Более подробно это изложено в разделе FSM.

В начало ^


3. Технические характеристики MKlon3.5

  • Зарядный ток Iз, А _____________________________ 0.05 – 6.0 ±(0.005 Iз + 0.05)
  • Шаг установки зарядного тока, А _____________ 0.01
  • Зарядное напряжение U, В ____________________ 1.0 – 18.0 ±(0.005 U + 0.05)
  • Шаг установки зарядного напряжения, _______ 0.01
  • Разрядный ток Iр, А ____________________________ 0.05 – 3.0 ±(0.005 Iр + 0.05)
  • Шаг установки разрядного тока, А ____________ 0.01
  • Максимальная рассеиваемая мощность, Вт __ не менее 40
  • Питание от внешнего AC/DC __________________ ноутбучный 19 В (4.74 ... 6.0 А)
  • Интерфейс _____________________________________ USB, WiFi, BT
  • Индикация _____________________________________ TFT 3.5, сенсорная панель
  • Диагностика ___________________________________ 2 разъема для логического анализатора
  • Micro-SD карта ________________________________ на дисплее
  • Защита по выходу _____________________________ переполюсовка, перегрузка по току, КЗ

4. FSM

Finite State Machine или finite-state machine или конечный автомат, кому как нравится.

"Это до предела упрощенная модель компьютера, имеющая конечное число состояний, которая жертвует всеми особенностями компьютеров такие как ОЗУ, постоянная память, устройства ввода-вывода и процессорными ядрами в обмен на простоту понимания, удобство рассуждения и легкость программной или аппаратной реализации." Поверим пока на слово.

Об одном способе реализации конечного автомата.

Идея состоит в том, что реализация каждого режима работы устройства должна производиться независимо от иных режимов, но использовать некий общий инструментарий, обеспечивающий безаварийный доступ к ресурсам прибора. Как говорил великий Х.Д.Милс: "Нахождение глубинной простоты в запутанном клубке сущностей - это и есть творчество в программировании." - не дословно, но близко к источнику. В свое время мне понадобилось не более двух месяцев, чтобы подавить в себе естественное отторжение и привести проект к виду, с которым комфортно и, главное, безопасно работать.

Конечный автомат, он же Finite State Mashine (FSM).

Любой или почти любой процесс можно представить в виде последовательности шагов от одного состояния к другому. Разработчик мало чего стоит, если не представляет себе все возможные состояния, число которых конечно - именно поэтому автомат и называют "конечным". Вдохновением можно запастись здесь. Способ несомненно хорош, Но вот синхронизировать данные двух структур... хвост может начать вилять собакой.

Как же будет выглядеть реализация конкретного режима, например простого заряда аккумуляторной батареи? Графически режим такого заряда может быть представлен так: идите от жирной точки вверху, в цветных бланках короткие примечания к состояниям. Заряд CC/CV Вырисовывается не так уж и много состояний - чуть более десятка. Слева - состояния ввода параметров с возможностью отказа от дальнейшего ввода и перехода на исполнение или выход. Справа - последовательность шагов заряда - подъем тока, удержание тока и удержание напряжения. В центре - состояние отложенного старта. Исходим из того, что этот режим заряда (CC/CV) у нас "в печёнках", у кого нет - представьте, что это алгоритм ёлочной гирлянды. Оформим каждое состояние соответствующим объявлением класса в общем поле имен CcCvFsm. Здесь же можно определить необходимые константы, свойственные для этого режима, чтобы всегда были "под рукой" в структурах, а в классах ничто не мешает определить свои локальные константы и переменные как private:

файл cccvfsm.h
#ifndef _CCCVFSM_H_
#define _CCCVFSM_H_

#include "mstate.h"

namespace CcCvFsm    // Поле имен для режима простого заряда
{
  struct MChConsts
  {
      // Пределы регулирования
      static constexpr float i_l =  0.2f;
      static constexpr float i_h = 12.2f;
      static constexpr float v_l = 10.0f;
      static constexpr float v_h = 16.0f;
  };

  struct MPidConstants
  {
      // Параметры регулирования
      static constexpr float outputMin            = 0.0f;
      static constexpr float outputMaxFactor      = 1.05f;     // factor for current limit
  };

  class MStart : public MState
  {      
    public:
      MStart(MTools * Tools);
      MState * fsm() override;
  };

  class MSetCurrentMax : public MState
  {
    public:  
      MSetCurrentMax(MTools * Tools);
      MState * fsm() override;
    private:
        // Пределы регулирования max тока
      static constexpr float above = 6.0f;
      static constexpr float below = 0.2f;
  };
 
  class MSetVoltageMax : public MState
  {
    public:  
      MSetVoltageMax(MTools * Tools);
      MState * fsm() override;
  };

  class MSetCurrentMin : public MState
  {
    public:    
      MSetCurrentMin(MTools * Tools);
      MState * fsm() override;
  };

  class MSetVoltageMin : public MState
  {
    public:    
      MSetVoltageMin(MTools * Tools);
      MState * fsm() override;
  };

  class MPostpone : public MState
  {
    public:  
      MPostpone(MTools * Tools);
      MState * fsm() override;
  };

  class MUpCurrent : public MState
  {
    public:  
      MUpCurrent(MTools * Tools);
      MState * fsm() override;
  };

  class MKeepVmax : public MState
  {
    public:
      MKeepVmax(MTools * Tools);
      MState * fsm() override;
  };

  class MKeepVmin : public MState
  {
    public:  
      MKeepVmin(MTools * Tools);
      MState * fsm() override;
  };

  class MStop : public MState
  {
    public: 
      MStop(MTools * Tools);
      MState * fsm() override;
  };

  class MExit : public MState
  {
    public:
      MExit(MTools * Tools);
      MState * fsm() override;
  };

};

#endif  // !_CCCVFSM_H_

Не трудно заметить, что каждое состояние (далее по тексту может именоваться как "шаг") представлено соответствующим классом, а методы единственной функцией fsm() - виртуальной. Классы состояний режима, как и структуры с константами имеют свое поле имен, так что в других режимах могут использоваться те же имена, что, согласитесь, удобно. А что за классы MState и MTools? Дойдем и до них. А пока оценим самодокументированность кода.

Перейдем к реализации. Определения всех состояний не приводятся, они однотипны. Рассмотрим на примере двух-трёх. Пока обращайте внимание только на содержательную часть, принимая "оболочку" как данность. По возможности используются говорящие имена. О классах достаточно знать лишь основы. Доверьтесь профессионалу - это при мне прямо "из-под волос" выдал программист, до которого нам далеко. Честно признаюсь - меня поначалу стошнило)) Пока просто скользните взглядом, не вчитывайтесь, ощутите возможность окинуть одним взглядом режим заряда от начала и до конца.

файл cccvfsm.cpp
#include "cccvfsm.h"
#include "mtools.h"
#include "mboard.h"
#include "mdisplay.h"
namespace CcCvFsm
{
  // Состояние "Старт", инициализация выбранного режима работы (Заряд CCCV).
  MStart::MStart(MTools * Tools) : MState(Tools)
  {
      // Параметры заряда из энергонезависимой памяти, Занесенные в нее при предыдущих включениях, как и
      // выбранные ранее номинальные параметры батареи (напряжение, емкость).
      // Tools->setVoltageMax( ... ); Tools->setVoltageMin( ... ); и т.п.
      
      // Индикация подсказки по строкам дисплея
      Display->getTextMode( (char*) "   CC/CV SELECTED    " );
      Display->getTextHelp( (char*) "  P-DEFINE  C-START  " );
      Display->progessBarOff();
  }
  MState * MStart::fsm()
  {
    switch ( Keyboard->getKey() )
    {
      case MKeyboard::C_CLICK :                // Если короткое нажатие на "C"
        // Пересчет из параметров батареи в напряжения и токи
        Tools->setVoltageMax( MChConsts::voltageMaxFactor * Tools->getVoltageNom() );
        Tools->setVoltageMin( MChConsts::voltageMinFactor * Tools->getVoltageNom() );
        Tools->setCurrentMax( MChConsts::currentMaxFactor * Tools->getCapacity() );
        Tools->setCurrentMin( MChConsts::currentMinFactor * Tools->getCapacity() );
        return new MPostpone(Tools);           // Выбран переход в состояние отложенного старта
      case MKeyboard::P_CLICK :                // Если короткое нажатие на "P"
        return new MSetCurrentMax(Tools);      // Выбран переход в состояние уточнения настроек заряда.
      default:;
    }
    Display->voltage( Board->getRealVoltage(), 2 ); // Во второй строке дисплея показывать напряжение
    Display->current( Board->getRealCurrent(), 1 ); // В первой строке дисплея показывать ток
    return this;                               // Ничего не выбрано, оставаться в этом состоянии до выбора
  };


  // Состояние "Коррекция максимального тока заряда"."
  MSetCurrentMax::MSetCurrentMax(MTools * Tools) : MState(Tools)
  {
    // Индикация подсказки
    Display->getTextMode( (char*) "U/D-SET CURRENT MAX" );
    Display->getTextHelp( (char*) "  B-SAVE  C-START  " );
  }
  MState * MSetCurrentMax::fsm()
  {
    switch ( Keyboard->getKey() )             // Что нажато и как долго
    {
      case MKeyboard::C_LONG_CLICK :
        return new MStop(Tools);              // Переход в состояние стоп
      case MKeyboard::C_CLICK :            
        return new MPostpone(Tools);          // Отказ от дальнейшего ввода параметров - исполнение
      case MKeyboard::B_CLICK :            
        Tools->saveFloat( MNvs::nCcCv, MNvs::kCcCvImax, Tools->getCurrentMax() ); 
        return new MSetVoltageMax(Tools);     // Сохранить и перейти к следующему параметру
      case MKeyboard::UP_CLICK :
      case MKeyboard::UP_AUTO_CLICK :
        Tools->currentMax = Tools->upfVal( Tools->currentMax, MChConsts::i_l, MChConsts::i_h, 0.1f );
        break;             // Корректировать по короткому нажатию на +0.1, по удержанию - повторять +0.1
      case MKeyboard::DN_CLICK :
      case MKeyboard::DN_AUTO_CLICK :
        Tools->currentMax = Tools->dnfVal( Tools->currentMax, MChConsts::i_l, MChConsts::i_h, 0.1f );
        break;
      default:;
    }
    // Если не закончили ввод, то индикация введенного
    Display->voltage( Board->getRealVoltage(), 2 );
    Display->current( Tools->getCurrentMax(), 1 );
    return this;                               // и остаемся в том же состоянии
  };


  //... несколько состояний аналогичны и опущены


  // Состояние: "Подъем и удержание максимального тока"
  MUpCurrent::MUpCurrent(MTools * Tools) : MState(Tools)
  {   
    // Индикация подсказки
    Display->getTextMode( (char*) " UP CURRENT TO MAX " );
    Display->getTextHelp( (char*) "       C-STOP      " );
    // Обнуляются счетчики времени и отданного заряда
    Tools->clrTimeCounter();
    Tools->clrAhCharge();
    // Включение (показано схематично, команды драйверу)
    Tools->setComAmp( ток );
    Tools->setComVolt( напряжение );
    Tools->setComGo();
  }     
  MUpCurrent::MState * MUpCurrent::fsm()
  {
    Tools->chargeCalculations();              // Подсчет отданных ампер-часов.
    // После пуска короткое нажатие кнопки "C" производит отключение тока.
    if(Keyboard->getKey(MKeyboard::C_CLICK)) { return new MStop(Tools); }    
    // Проверка напряжения и переход на поддержание напряжения.
    if( Board->getRealVoltage() >= Tools->getVoltageMax() ) { return new MKeepVmax(Tools); }
      
    // Индикация фазы подъема тока не выше заданного
    Display->voltage( Board->getRealVoltage(), 2 );
    Display->current( Board->getRealCurrent(), 1 );
    Display->progessBarExe( MDisplay::GREEN );
    Display->duration( Tools->getChargeTimeCounter(), MDisplay::SEC );
    Display->amphours( Tools->getAhCharge() );
      
    return this;
  };

  // Третья фаза заряда - достигнуто снижение тока заряда ниже заданного предела.
  // Проверки различных причин завершения заряда.
  MKeepVmin::MKeepVmin(MTools * Tools) : MState(Tools)
  {
    // Индикация подсказки
    Display->getTextMode( (char*) " KEEP VOLTAGE MIN  " );
    Display->getTextHelp( (char*) "       C-STOP      " );
    // Порог регулирования по напряжению (схематично)
    Tools->setComVoltMin();         
  }     
  MState * MKeepVmin::fsm()
  {
    Tools->chargeCalculations();        // Подсчет отданных ампер-часов.
    // Окончание процесса оператором.
    if (Keyboard->getKey(MKeyboard::C_CLICK)) 
    { return new MStop(Tools); }       
    // Здесь возможны проверки других условий окончания заряда
    // if( ( ... >= ... ) && ( ... <= ... ) )  { return new MStop(Tools); }
    // Максимальное время заряда, задается в "Настройках"
    if( Tools->getChargeTimeCounter() >= ( Tools->charge_time_out_limit * 36000 ) ) 
    { return new MStop(Tools); }
    Tools->setComVolt( указываем напряжение в милливольтах );           // Регулировка по напряжению
    Display->progessBarExe( MDisplay::MAGENTA );
    Display->duration( Tools->getChargeTimeCounter(), MDisplay::SEC );
    Display->amphours( Tools->getAhCharge() );
    return this;
  };


 // Состояние: "Завершение заряда"
  MStop::MStop(MTools * Tools) : MState(Tools)
  {
    Tools->shutdownCharge();
    Display->getTextHelp( (char*) "              C-EXIT " );
    Display->getTextMode( (char*) "   CC/CV CHARGE OFF  " );
    Display->progessBarStop();
  }    
  MState * MStop::fsm()
  {
    switch ( Keyboard->getKey() )
    {
      case MKeyboard::C_CLICK :
        return new MExit(Tools);
      default:;
      
      //Display->progessBarOff();
    }
    return this;
  };

  // Состояние: "Индикация итогов и выход из режима заряда в меню диспетчера" 
  MExit::MExit(MTools * Tools) : MState(Tools)
  {
    Tools->shutdownCharge();
    Display->getTextHelp( (char*) "              C-EXIT " );
    Display->getTextMode( (char*) "   CC/CV CHARGE OFF  " );
    Display->progessBarOff();
  }    
  MState * MExit::fsm()
  {
    switch ( Keyboard->getKey() )
    {
      case MKeyboard::C_CLICK :
        Display->getTextMode( (char*) "    CC/CV CHARGE     " );
        Display->getTextHelp( (char*) " U/D-OTHER  B-SELECT " );
        return nullptr;                             // Возврат к выбору режима
      default:;
    }
    return this; // до нажатия кнопки "С" удерживается индикация о продолжительности и отданном заряде.
  };
};
// !Конечный автомат режима простого заряда (CCCV).

Вот она! глубинная простота. Про всякие там "поздние связывания" нам знать не обязательно. Но то, что определение класса состояния начинается с конструктора (помним, что конструктор не возвращает никаких значений) - это инициализация состояния, а далее следует определение объявленной ранее виртуальной функции, то есть что исполняется на этом шаге при каждом вызове. Короче. Любое отдельно взятое состояние будет выглядеть понятно. Получится думать не "галактикой", а одним и только одним состоянием в каждый момент. Есть одно "но", связанное с тем, что конструктор нового состояние создается до выхода из предыдущего. Не вдаваясь в объяснения - здесь так делать можно:

// Состояние: "..."

  MName::MMName(MTools * Tools) : MState(Tools)
  {   
    // Это конструктор, здесь инициализация состояния
    // Используя методы из MTools выполните то, что требуется исполнить при переходе в это
    // состояние из какого-то иного состояния. Учтите, что конструктор ничего не возвращает.
  }
    
  MName::MName * MName::fsm()
  {
    // Это определение и вызов функции, здесь описывается методами MTools что надо выполнять
    // вслед за инициализацией и при каждом вызове этого состояния операционной системой.
    // Если при каждом вызове состояния что-то безусловно меняется, например индикация,
    // то укажите здесь.

    // В порядке приоритета при необходимости приведите проверки условий, при выполнении
    // которых выполняется запрос перехода в иное выбранное состояние, например

    if( условие = true ) { return new MName2(Tools); }

    // Если ни одно из условий не выполняется, то запрашивается возврат в это же состояние.
    // Однако если в этом случае опять-таки что-то меняется, задаем  здесь.

    return this;
    // return nullptr;   // Или для выхода из последнего состояния к выбору иного режима
  };

Как вы заметили, выделенные строки одинаковы во всех состояниях, радость-то какая))

Определения состояний расположены в произвольном порядке. И связаны только через return this, return name или return nullptr, которые выполняются при следующем обращении операционной системы к задаче. Какой? Не всё сразу. В итоге: определения можно располагать в произвольном порядке, копировать при необходимости из других режимов не заботясь о повторении имен. Избыточность? Зато головной боли меньше. Кстати, реализация режима, взятого для примера, занимает в памяти всего лишь 0,2%.

Теперь базовый класс MState:

файл mstate.h
#ifndef _MSTATE_H_
#define _MSTATE_H_

class MTools;
class MBoard;
class MDisplay;
class MKeyboard;

class MState
{
  public:
    MState(MTools * Tools);
    virtual ~MState(){}
    virtual MState * fsm() = 0;

  protected:
    MTools    * Tools    = nullptr;
    MBoard    * Board    = nullptr;
    MDisplay  * Display  = nullptr;
    MKeyboard * Keyboard = nullptr;
};

#endif // !_MSTATE_H_

Функция fsm() объявлена виртуальной и в файлах реализации режимов наследуется как виртуальная, поэтому там "virtual" не обязателен, да и "override" добавлен исключительно для читабельности.

файл mstate.cpp

#include "mstate.h"
#include "mtools.h"

MState::MState(MTools * Tools) :
  Tools(Tools),
  Board(Tools->Board),
  Display(Tools->Display),
  Keyboard(Tools->Keyboard) {}

Вы не находите, что это шедевр? Не мой - профессионала в этом деле. Я не бездействовал, я сразу на капу нажал.
Попросил сделать максимально удобно из двух возможных вариантов для непрограммиста, но с навыками программирования (Во! как сказал - это я про себя). Кстати, если интересует почему все имена классов начинаются на одну и ту же букву - так это наше, фамильное)).

Далее по порядку: MTools - класс, где состедоточено большая часть инструментария. Предполагается, что это пишет разработчик аппаратной части проекта, хорошо осведомленный в том, что можно, а за какие рамки заходить нельзя. В отдельные классы оформлены (исторически так получилось) описания и управление некоторыми ресурсами аппаратной части проекта - MBoard, MDisplay, MKeyboard. К ним вернусь позже.

На очереди класс MDispatcher, который отвечает за выбор режима работы прибора посредством меню. Но сначала в конструкторе - инициализация всего-всего. Будете искать в Setup() привычные init(); - не ищите, конструктор класса по сути это и есть init().

файл dispatcher.h
#ifndef _DISPATCHER_H_
#define _DISPATCHER_H_

class MTools;
class MBoard;
class MDisplay;
class MState;

class MDispatcher
{
  public:
    enum MODES
    {
      OPTIONS = 0,    // режим ввода настроек (не отключаемый)
      TEMPLATE,       // шаблон режима
      DCSUPPLY,       // режим источника постоянного тока
      PULSEGEN,       // режим источника импульсного тока
      CCCVCHARGE,     // режим заряда "постоянный ток / постоянное напряжение"
      PULSECHARGE,    // режим импульсного заряда
      RECOVERY,       // режим восстановления
      STORAGE,        // режим хранения
      DEVICE,         // режим заводских регулировок
      SERVICE         // режим Сервис АКБ
    };

  public:
    MDispatcher(MTools * tools);

    void run();
    void delegateWork();
    void textMode(int mode);

  private:
    MTools    * Tools;
    MBoard    * Board;
    MDisplay  * Display;
    MState    * State = 0;

    bool latrus = false;
    int mode = CCCVCHARGE;
};

#endif //_DISPATCHER_H_

В начале, как и ранее было показано, идет конструктор класса. В данном случае выполняет роль инициализации всего-всего. Константы ... кто видел где константы??? Да где же им быть - в объявлении класса. Тут есть как свои достоинства, так и недостатки.

файл dispatcher.cpp
#include "mdispatcher.h"
#include "nvs.h"
#include "mtools.h"
#include "mboard.h"
#include "mkeyboard.h"
#include "mdisplay.h"

#include "modes/templatefsm.h"
// ...
#include "modes/cccvfsm.h"
#include "modes/servicefsm.h"

#include <string>

MDispatcher::MDispatcher(MTools * tools): Tools(tools), Board(tools->Board), Display(tools->Display)
{
    char sLabel[ MDisplay::MaxString ] = { 0 };
    strcpy( sLabel, "  OLMORO ** ELTRANS  " );
    Display->getTextLabel( sLabel );

    latrus = Tools->readNvsBool( MNvs::nQulon, MNvs::kQulonLocal, true );
    mode   = Tools->readNvsInt ( MNvs::nQulon, MNvs::kQulonMode, 0 );   // Индекс массива

    textMode( mode );
    Tools->powInd = Tools->readNvsInt  ( MNvs::nQulon, MNvs::kQulonPowInd, 3);
    // Индекс массива с набором батарей 3
    Tools->akbInd = Tools->readNvsInt  ( MNvs::nQulon, MNvs::kQulonAkbInd, 3);
    Tools->setVoltageNom( Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonAkbU, Tools->akb[3][0]));
    Tools->setCapacity( Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonAkbAh, Tools->akb[3][1]) );

    Tools->postpone = Tools->readNvsInt( MNvs::nQulon, MNvs::kQulonPostpone,  3 );

    // Калибровки измерителей 
    Board->voltageMultiplier  = Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonVmult,   1.00f );
    Board->voltageOffset      = Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonVoffset, 0.00f );
    Board->currentMultiplier  = Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonImult,   1.40f );
    Board->currentOffset      = Tools->readNvsFloat( MNvs::nQulon, MNvs::kQulonIoffset, 0.00f );
}

void MDispatcher::run()
{
  // Индикация при инициализации процедуры выбора режима работы
  Display->voltage( Board->getRealVoltage(), 2 );
  Display->current( Board->getRealCurrent(), 1 );

  // Выдерживается период запуска для вычисления амперчасов
  if (State)
  {
    // rabotaem so state mashinoj
    MState * newState = State->fsm();     
    if (newState != State)                  //state changed!
    {
      delete State;
      State = newState;
    }
    //esli budet 0, na sledujushem cikle uvidim
  }
  else //state ne opredelen (0) - vybiraem ili pokazyvaem rezgim
  {
    if (Tools->Keyboard->getKey(MKeyboard::UP_CLICK))
    {
      if (mode == (int)SERVICE) mode = OPTIONS;
      else mode++;
      textMode( mode );
    }

    if (Tools->Keyboard->getKey(MKeyboard::DN_CLICK))
    {
      if (mode == (int)OPTIONS) mode = SERVICE;
      else mode--;
      textMode( mode );
    }

    if (Tools->Keyboard->getKey(MKeyboard::B_CLICK))
    {
      // Запомнить крайний выбор режима
      Tools->writeNvsInt( MNvs::nQulon, "mode", mode );

      switch (mode)
      {
        case OPTIONS:     State = new OptionFsm::MStart(Tools);     break;
          // несколько режимов опущены
        case CCCVCHARGE:  State = new CcCvFsm::MStart(Tools);       break;
        case SERVICE:     State = new ServiceFsm::MStart(Tools);    break;
        default:          break;
      }
    } // !B_CLICK
  }
}

void MDispatcher::textMode(int mode)
{
  char sMode[ MDisplay::MaxString ] = { 0 };
  char sHelp[ MDisplay::MaxString ] = { 0 };

  switch(mode)
  {
    case OPTIONS:
      sprintf( sMode, "OPTIONS: BATT.SELECT," );
      sprintf( sHelp, "CALIBRATION,TIMER ETC" );
    break;

    case CCCVCHARGE:
      sprintf( sMode, "    CC/CV CHARGE:    " );
      sprintf( sHelp, " U/D-OTHER  B-SELECT " );
    break;

      // ...
    case SERVICE:
      sprintf( sMode, "  BATTERY SERVICE:   " );
      sprintf( sHelp, " ADJUSTING THE DEVICE" );
    break;

    default:
      sprintf( sMode, "  ERROR:             ");
      sprintf( sHelp, "  UNIDENTIFIED MODE  " );
    break;
  }

  Display->getTextMode( sMode );
  Display->getTextHelp( sHelp );
}

Диспетчер выполнят две функции: первая - работа с меню до запуска выбранного режима, вторая - инициализация состояния и вызов виртуальной функции, определенной в классе этого состояния. Функция run() диспетчера проверяет номер состояния, который возвращается активным состоянием на ноль (nullptr) - это означает выход из режима в меню выбора. При ненулевом значении и отличным номером инициируется новое состояние, иначе будет исполнена та же функция, что и при предыдущем вызове естественно без инициализации. Всё тривиально просто. Но исполнителя требуется в первую очередь аккуратность. Большую часть диспетчера занимает отправка на дисплей строк меню. Какие-то фоновые функции можно выполнить и здесь, в диспетчере, но лучше под них выделить отдельную задачу для RTOS и сбагрить её кому-то. И обратите внимание, как запускается fsm диспетчера - пригодится для реализации неотключаемых процессов.

Задачи RTOS. Хотите вы или нет, но с операционной системой реального времени Free RTOS придется подружиться. Разработчики Expressif постарались максимально облегчить жизнь программиста. Учебники по Free RTOS - в помощь, но надо иметь ввиду, что учебники писались для одноядерных процессоров, а ESP32 имеет два ядра. И блокировать, например, оба не есть хорошо. Рекомендуется для задач обслуживания радиотехнического блока использовать одно ядро, а для целевой программы - другое. Оформить сказанное не просто, а очень просто. Многое в настройках RTOS уже сделано за нас, тем более, если используется SDK от Expressif да ещё и под Ардуиной. Уверяю, что скоро вы забудете, что ваш код исполняется под ОС. А вот без каких данных не обойтись - так это временные параметры процессов. Монопольно занимать одной задачей более 13 миллисекунд - моветон. Система отработает рестарт. На каждую задачу отводится 1 миллисекунда, потом обрабатывается другая задача. Иногда нельзя допустить перерыва в обработке ... впрочем это азы - отправляю к учебнику. Вот так выглядит инициализация RTOS и разбиение нашего функционала на задачи. Некоторая особенность реализации вызвана ардуиновским делением файла main.cpp на setup() и loop(). Профи оценят.

файл main.cpp
#include "mboard.h"
#include "mcommands.h"
#include "mdisplay.h"
#include "mtools.h"
#include "mdispatcher.h"
#include "mconnmng.h"
#include "mmeasure.h"
#include "connectfsm.h"

static MBoard      * Board      = 0;
static MDisplay    * Display    = 0;
static MTools      * Tools      = 0;
static MCommands   * Commands   = 0;
static MMeasure    * Measure    = 0;
static MDispatcher * Dispatcher = 0;
static MConnect    * Connect    = 0;

void connectTask ( void * );
void displayTask ( void * );
void coolTask    ( void * );
void mainTask    ( void * );
void measureTask ( void * );
void driverTask  ( void * );

void setup()
{
  Display    = new MDisplay();
  Board      = new MBoard(Display);
  Tools      = new MTools(Board, Display);
  Commands   = new MCommands(Board);
  Measure    = new MMeasure(Tools);
  Dispatcher = new MDispatcher(Tools);
  Connect    = new MConnect(Tools);

  // Выделение ресурсов для каждой задачи: память, приоритет, ядро.
  // Все задачи исполняются ядром 1, ядро 0 выделено для радиочастотных задач - BT и WiFi.
  xTaskCreatePinnedToCore ( connectTask, "Connect", 10000, NULL, 1, NULL, 1 );
  xTaskCreatePinnedToCore ( mainTask,    "Main",    10000, NULL, 2, NULL, 1 );
  xTaskCreatePinnedToCore ( displayTask, "Display",  5000, NULL, 2, NULL, 1 );
  xTaskCreatePinnedToCore ( coolTask,    "Cool",     1000, NULL, 2, NULL, 1 );
  xTaskCreatePinnedToCore ( measureTask, "Measure",  5000, NULL, 2, NULL, 1 );
  xTaskCreatePinnedToCore ( driverTask,  "Driver",   5000, NULL, 2, NULL, 1 );
}

void loop() {}                            // Это тоже задача, пустая в данном случае

// Задача подключения к WiFi сети (полностью заимствована как есть)
void connectTask( void * )
{
  while(true)
  {
    Connect->run();
    // Период вызова задачи задается в TICK'ах, TICK по умолчанию равен 1мс.
    vTaskDelay( 10 / portTICK_PERIOD_MS );
  }
  vTaskDelete( NULL );
}

// Задача выдачи данных на дисплей
void displayTask( void * )
{
  while(true)
  {
    Display->runDisplay(
                        Board->Overseer->getCelsius(),
                        Tools->getAP() );
    vTaskDelay( 250 / portTICK_PERIOD_MS );
  }
  vTaskDelete( NULL );
}

// Задача управления системой теплоотвода.
void coolTask( void * )
{
  while (true)
  {
    Board->Overseer->runCool();
    vTaskDelay( 200 / portTICK_PERIOD_MS );
  }
  vTaskDelete( NULL );
}

// Задача обслуживает выбор режима работы и
// управляет конечным автоматом выбранного режима вплоть да выхода из него
void mainTask ( void * )
{
  while (true)
  {
    // Выдерживается период запуска для вычисления амперчасов. Если прочие задачи исполняются в     // порядке очереди, то эта точно по таймеру - через 0,1с.
    portTickType xLastWakeTime = xTaskGetTickCount();
    Dispatcher->run();
    vTaskDelayUntil( &xLastWakeTime, 100 / portTICK_PERIOD_MS );    // период 0,1с
  }
  vTaskDelete( NULL );
}

// Задача управления измерениями
void measureTask( void * )
{
  while (true)
  {
    Measure->run();
    vTaskDelay( 10 / portTICK_PERIOD_MS );
  }
  vTaskDelete(NULL);
}

void driverTask( void * )
{
  while (true)
  {
    Commands->doCommand();
    vTaskDelay( 100 / portTICK_PERIOD_MS );
  }
  vTaskDelete(NULL);
}

Согласитесь - ничего сложного и запутанного в реализации конечного автомата по этому способу нет.

Всё. Успехов! Версия FSM от 28 января 2021 года
редакция 2 декабря 2022 года

                           В начало [^](#menu)                                     

5. Первый проект.

От слов перейдем к делу.

  1. Вооружившись решимостью запрограммировать проект заряда имени себя любимого, которого ни у кого нет - придумаем ему короткое и звучное название (не проекту - пока только режиму, назовём к примеру MYMODE).

  2. Найдите в папке modes файлы templatefsm.h и templatefsm.cpp с примером реализации режима и скопируйте в ту же папку но под названием вашего режима mymodefsm.h и mymode.cpp, произведя обязательные изменения в следующих стоках:

файл mymodefsm.h

#ifndef _MYMODEFSM_H_   //#ifndef _TEMPLATEFSM_H_
#define _MYMODEFSM_H_   //#define _TEMPLATEFSM_H_

namespace Mymode        //namespace Template

файл mymodefsm.cpp

#include "modes/mymodefsm.h"    //#include "modes/templatefsm.h"

namespace Mymode                //namespace Template

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

  1. Откройте файл mdispatcher.h и в списке режимов подберите новому режиму достойное место, исключая первое и последнее. При переборе режимов кнопками он будет вызываться после TEMPLATE или CCCV в зависимости от того, в каком направлении листается список режимов. Не забудьте поставить запятую.
  public:
    enum MODES
    {
      BOOT = 0,            // режим синхронизации
      OPTIONS,             // режим ввода настроек
      UPID,                // режим настройки регулятора по напряжению
      IPID,                // режим настройки регулятора по току
      DPID,                // режим настройки регулятора по току разряда
      TEMPLATE,            // шаблон режима 
    MYMODE,              // мой режим
      CCCV,                // режим заряда "постоянный ток / постоянное напряжение"
      CCCVT,               // режим заряда CC/CV "технологический"
      //DISCHARGE,           // режим разряда
      DEVICE               // режим заводских регулировок
    };
  1. Откройте файл mdispatcher.cpp и откорректируйте строку с указателем пути к файлу вашего режима
#include "modes/mymodefsm.h"    //#include "modes/templatefsm.h"

В том же файле найдите строку с case TEMPLATE и введите аналогичную для вашего режима - это будет указание диспечеру осуществлять запуск именно вашего режима.

          case TEMPLATE:    State = new Template::MStart(Tools);  break;
          case MYMODE:      State = new Mymode::MStart(Tools);  break;

В том же файле найдите, скопируйте и отредактируйте указание диспетчеру что надо выводить на экран при пролистывании списка возможных режимов в поиске вашего режима. Размер строк лучше не нарушать, пробелы в начале и конце обязятельны по крайней мере в этой версии.

    case TEMPLATE:
      sprintf(sMode, "    TEMPLATE:     " );
      sprintf(sHelp, "     EXAMPLE      " );
    break;

    case MYMODE:
      sprintf(sMode, "     MYMODE:      " );
      sprintf(sHelp, "      FIRST       " );
    break;    
  1. Компилируем, загружаем, находим наш режим, запускаем его и проверяем. СТОП! Надо бы удостовериться, что запущен не TEMPLATE, а именно наш. Открываем файл .cpp нашего режима и в строке
    Board->ledsOn();         // Светодиод светится белым как индикатор входа в режим

меняем цвет светодиода с белого на какой-нибудь другой, повторяем необходимые действия и вуаля - пусть это ещё и не проект, но видно, что именно мы виляем хвостом, а не наоборот.

Передохнём... Далее будем работать с классами - без них никак.

И напоследок... Объявление класса будем делайть в отдельном файле, если привыкли делать по-ардуиновски, так это плохая привычка, от которой следует избавиться и чем скорее, тем лучше.

  1. Далее будете шаг за шагом, объявлять и определять состояние за состоянием в соответствии со своими желаниями. Вам понадобятся файлы mboard.h, mtools.h и mdisplay.h в которыx объявлены все утилиты, составляющие ваш арсенал как разработчика. А ниже в помощь представлен пример реализации одного из состояний режима заряда с подробными комментариями.

Примечание: Для упрощения восприятия в этом примере управление всеми ресурсами - как аппаратными, так и программными производится через класс MTools.

Файл cccvfsm.h

  /*  Объявляется поле имен для этого (CCCV) режима. Имена классов, констант
    и переменных к большой радости любителей "копи-паста" в других режимах можно 
    использовать без изменений. */
namespace MCccv
{
  /* Здесь объявляются и определяются общие для этого поля имен константы */
  struct MConst
  {
    static constexpr float fixed_kp_v = 0.100f;
    ...
  };

  /* Объявляются ВСЕ КЛАССЫ, описывающие состояния (шаги) режима, В ЛЮБОЙ ПОСЛЕДОВАТЕЛЬНОСТИ. */
  class M... 
  {};

  class MUpCurrent : public MState
  {
    public:   
      MUpCurrent(MTools * Tools);
      MState * fsm() override;      // Объявлена виртуальная функция, 
                                    // вызывается только по адресу 
                                    // и возвращает указатель на состояние.
    private:
      /* Здесь объявляются и определяются константы и переменные этого класса. */
      static constexpr float voltage_max_factor = 0.95f;
      static constexpr float voltage_max = 14.4f;
      float maxV;                   // Переменную можно объявить видимой только в этом классе ... 
  };

  class M...
  {};
};

Файл cccvfsm.cpp

namespace MCccv
{
  float maxV;   // ... или здесь, но видимой во всех классах данного поля имен MCccv

  /*  Начальный этап заряда - ток поднимается не выше заданного уровня, при достижении 
    заданного максимального напряжения - переход к его удержанию. 
    Подсчитывается время и отданный заряд, оператор может лишь прекратить заряд. */

  // Состояние "Подъем и удержание максимального тока" описывается классом MUpCurrent
  MUpCurrent::MUpCurrent(MTools * Tools) : MState(Tools)
  {   
    /*  Конструктор класса выполняет инициализацию при входе в это состояние
      только в том случае, если совершен переход из другого состояния, а не 
      возврат, когда такого перехода не было. (Эта функция возложена на диспетчер)*/

    maxV = Tools->readNvsFloat("cccv", "maxV", voltage_max);  /* Так берутся данные из
      энергонезависимой памяти - "имя", "ключ", значение по умолчанию */

    Tools->showMode((char*)"  CONST CURRENT   ");     // Показывать фазу заряда
    Tools->showHelp((char*)"   *C, C - STOP   ");     // Показывать активные кнопки как помощь
    Tools->ledsGreen();                               // Светодиод включить зеленым
    Tools->clrTimeCounter();                          // Обнулить счетчик времени
    Tools->clrAhCharge();                             // Обнулить счетчик ампер-часов

    /* Включение преобразователя и коммутатора драйвером силовой платы.
     Параметры PID-регулятора заданы в настройках прибора. Здесь задаются сетпойнты 
     по напряжению и току. Подъем тока и удержание производится ПИД-регулятором.
    */ 
    Tools->txPowerAuto(maxV, maxI, maxS);             /* Начать процесс заряда, подав
      команду с максимальным напряжением, током и скоростью нарастания тока. На этом 
      инициализация состояния закончена. Не забываем, что конструктор класса ничего не 
      возвращает при исполнении */ 
  }
    /*  Методы класса здесь в единственном числе, и представлены функцией */   
  MUpCurrent::MState * MUpCurrent::fsm()
  {
    /* Здесь располагается всё, что надо выполнить при каждом вхождении в это состояние: */
    Tools->chargeCalculations();                        // Обновить отданные ампер-часы.
    /*  Проверим, не была ли нажата, какая и как долго, кнопка. И если да, то "бииип" и 
      закажем переход в указанное состояние, иначе выполним то, что должны сделать перед 
      повторным входом в это же состояние. */ 
    switch ( Tools->getKey() )
    {
      case MKeyboard::C_CLICK:
      case MKeyboard::C_LONG_CLICK: Tools->buzzerOn();  /* Если зафиксировано короткое или
        длинное нажатие, то заказано досрочное прекращение заряда оператором и ... */
      return new MStop(Tools);                          // ... переход в состояние MStop.
      // case ... остальные кнопки,если надо.
      default:;
    }

      /*  Если по кнопкам переходов нет, то выполняем проверки, например, не пора ли переходить 
        ко второй фазе заряда, если напряжение на батарее достигло некоторого уровня: */
    if(Tools->getRealVoltage() >= maxV * voltageMaxFactor);
    return new MKeepVmax(Tools);                        // ... переход к удержанию напряжения. 
    
      /*  Если ничего из вышеперечисленного не случилось, обновляем индикацию ... */
    Tools->showVolt(Tools->getRealVoltage(), 3);  // Три знака после зпт
    Tools->showAmp (Tools->getRealCurrent(), 2);  // ... двух достаточно
    Tools->initBar(TFT_GREEN);                    /* Бегущая зеленая полоска, когда 
      состоится переход ко второй фазе её назначим желтой */  
    Tools->showDuration(Tools->getChargeTimeCounter(), MDisplay::SEC); /* Это время - 
      добавилась 0,1 с - ровно через столько операционная система производит проверку 
      состояния */
    Tools->showAh(Tools->getAhCharge());  /* Обновим набежавшие ампер-часы */
    return this;   /* И если ничего не забыли, закажем операционке возврат в это состояние, 
      естественно, без инициализации. Кстати, если надо "вывалиться" из текущего режима в 
      главное меню, то заказывается такой переход не менее красиво: return nullptr; */ 
  };  // MUpCurrent

  ...
};

В начало ^


6. Драйвер SAMD21.

Измерение тока занимает не более 30 микросекунд:

Измерение напряжения занимает не более 30 микросекунд:

ШИМ преобразователя имеет частоту около 190 килогерц:

ПИД-регулирование с частотой от 10 до 250 герц занимает не более 35 микросекунд:

Прием посылки от ESP32 не мешает измерениям:

Ответная посылка передается не мешая измерениям:

Данная реализация драйвера задумывалась как масштабируемое техническое решение - как поёт Алёна Апина: "я его слепила из того что было". А потому нелишне будет узнать, как пересчитать "хотелки" в технические понятия.

Выбор аналоговых портов.

Для получения времени измерения в пределах 25-30 микросекунд настроки аналогово-цифрового преобразователя, как бы не отличались каналы измерения напряжения и тока, выбраны одинаковыми, и задаются только при инициализации, но не при переключении от измерения тока к измерению напряжения и наоборот. В указанный период удалось "втиснуть" 16 дифференциальных измерений, их усреднение, проверку нахождения в разрешенном диапазоне и активацию защиты в случае необходимости - такие преимущества дает реализация измерений и управления на одном кристалле. А наибольшую задержку вносит оптопара - аж целых 25 микросекунд - но на такое время и 40-амперный MOSFET становится "160-амперным".

void initMeasure()
{
  analogPrescaler(2);                 // 2 (25 µs), 3 (38 µs), 4(65 µs)
  analogReference2(0x03);             // выбор опорного VREFA = 1240 mV или
  //analogReference2(0x00);           //               INTREF = 1000 mV
  analogGain(0x01);                   // усиление *2.0
  analogReadConfig(0x00, 0x00, 0x00); // bits, samples, divider - отключено
  analogReferenceCompensation(0);     // автокомпенсация начального смещения выключена
}

Следует добавить, что НЧ фильтры на входах измерителей имеют частоту среза примерно по 8-10 килогерц. Даёт ли это равенство какой-то эффект осталось не выясненным, но частота среза взята из рекомендации для INA226 - а вдруг?

В начало ^

Коэффициент пересчета в миллиамперы.

constexpr float VREFA  = 1240.0F;      // mV   (внешний источник опорного напряжения)
constexpr float GAIN   =    2.0F;      // усиление
constexpr float ADCMAX = 2048.0F;      // для дифференциального режима 2^11
constexpr float SHL    =  512.0F;      // множитель для целочисленных вычислений 2^9

  // Данные аппаратной поддержки измерителя тока:
constexpr float KSHUNT = 1000.0/20.0F;  // mA/mV (1A/20mV) параметр шунта (два по R04 в параллель)
  // Ожидаемый коэффициент преобразования в миллиамперы        
constexpr unsigned short factor_default_i = short(KSHUNT*VREFA*GAIN*SHL/ADCMAX);  // 0x7918

В начало ^

Коэффициент пересчета в милливольтры.

  // Данные аппаратной поддержки измерителя напряжения:
constexpr float RUP    =  10.0F;      // кОм (верхнее плечо входного делителя напряжения)
constexpr float RDN    =   0.3F;      // кОм (нижнее плечо)
constexpr float KDEL   = (RUP+RDN)/RDN;
  // Ожидаемый коэффициент преобразования в милливольты        
constexpr unsigned short factor_default_v = short(KDEL*VREFA*GAIN*SHL/ADCMAX); //0x5326

В начало ^

Выбор таймера ШИМ.

В качестве донора силового преобразователя, с которого были использованы электролитические конденсаторы и дроссель неизвестной индуктивности, был использован 9-амперный "китаец", работавший на частоте порядка 190 килогерц. Но как бы не была высока производительность выбранного микроконтроллера на этой частоте возможен лишь 9-разрядный ШИМ в турбо-режиме timer/channel:

TCC2/WO[0]: PA00, PA12, PA16

TCC2/WO[1]: PA01, PA13, PA17

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

Инициализация таймера:

#include "SAMD21turboPWM.h"

TurboPWM pwm;
  // Параметры настройки таймера T2
constexpr bool                    pwm_turbo       = true;   // turbo on/off
constexpr unsigned int            pwm_tccdiv_out  = 1;      // делитель для таймера 2 (1,2,4,8,16,64,256,1024)
constexpr unsigned long long int  pwm_steps_out   = 0x01FF; // разрешение для таймера 2 (2 to counter_size)

class TurboPWM {
  public:
    void setClockDivider(unsigned int GCLKDiv, bool turbo);
    int timer(unsigned int timernumber, unsigned int TCCDiv, unsigned long long int steps, bool fastPWM);
    int analogWrite(int pin, unsigned int dutyCycle);
    int enable(unsigned int timerNumber, bool enabled);
    float frequency(unsigned int timerNumber);
  private:
    unsigned int _GCLKDiv = 1;                // Main clock divider: 1 to 255 for both TCC0 and TCC1
    bool _turbo = false;                      // False for 48MHz clock, true for 96MHz clock
    const unsigned int _maxDutyCycle = 1000;  // The maximum duty cycle number; duty cycle will be (dutyCycle / _maxDutyCycle) * 100%
};

void initPwm()
{
  pwm.setClockDivider(1, pwm_turbo);           // Input clock is divided by 1 and sent to Generic Clock, Turbo is On/Off
  pwm.timer(2, pwm_tccdiv_out,  pwm_steps_out,  true);  // T2, divider, resolution (подстройка частоты), single-slope PWM
  pwm.enable(2, false);
}

В начало ^

Выбор ПИД-регулятора.

Исходя из повышенных требований к быстродействию системы выбор однозначно состоялся не в пользу решений с использованием математики с плавающей точкой. Только целочисленные вычисления могли дать приемлемый результат. Тем более, что наличие двух микроконтроллеров позволяло разместить работу с общепринятым форматом коэффициентов float на ESP32, там их преобразовать в целочисленные и отправить опять таки по протоколу, работающему только с целочисленными данными на SAMD21.

Таким образом выбрана была библиотека FastPid.h, которую, естественно, пришлось попилить на две части. Под ESP32 преобразуются в целочисленные пропорциональный, интегральный и дифференциальный коэффициенты с проверками их на корректность, а исполнительная часть реализована под SAMD21. Скажете - как это сложно, вот у Кулона-912 нет никаких коэффициентов, и ничего, работает. Что тут ответить... Видишь сусликов? - Нет, не вижу. - Вот и я не вижу, а они есть. В роли "сусликов" резервные позиции для конденсаторов, которые приходится подбирать в случае необходимости при заводской регулировке. Согласен, на потоке где позиции закрываются в точном соответствии с документацией, другое дело для самосборщиков. По мне так лучше кнопочками...

В указанной библиотеке фиксированную частоту 10 герц заменил на 200 герц, соответственно нашим потребностям.

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

По аналогии с аппаратным решением TL494 в рабочей версии управление по току и напряжению объединены в один режим - кто кого перетянет (в TL494 там проводное ИЛИ):

 // Запуск и выбор регулятора производится выбором pidMode: MODE_OFF, MODE_V, MODE_I, MODE_D, MODE_AUTO_V.
void regulation(short fbV, short fbI)
{
  switch ( pidMode )
  {
    case MODE_OFF:  doModeOff();       break;   // Выход из регулирования с отключением всего
    case MODE_V:    doModeV(fbV);      break;   // регулирование по напряжению 
    case MODE_I:    doModeI(fbI);      break;   // регулирование по току заряда
    case MODE_D:    doModeD(fbI);      break;   // регулирование по току разряда
    case MODE_AUTO_V:
      if(fbI < setpoint[MODE_I])                // если ток менее заданного, но не разряд))
      {
        doModeV(fbV);
      }
      else    // Иначе перейти к регулированию по току.
      {
        saveState(MODE_V);                      // Сохранить регистры регулятора
        restoreState(MODE_I);                   // Перейти к регулированию по току
        PidPwm.setCoefficients( kP[MODE_I], kI[MODE_I], kD[MODE_I] );
        pidMode = MODE_AUTO_I;
      }
      break; //case MODE_AUTO_V

    case MODE_AUTO_I:
      if(fbV <= setpoint[MODE_V])               // Регулировать ток, если напряжение не выше заданного.
      {
        doModeI(fbI);
      }
      else                                      // Иначе перейти к регулированию по напряжению
      {
        saveState(MODE_I);
        restoreState(MODE_V);
        PidPwm.setCoefficients( kP[MODE_V], kI[MODE_V], kD[MODE_V] );
        pidMode = MODE_AUTO_V;
      }
      break;  //case MODE_AUTO_I

    default:;
  } //switch(pidMode)

где в сокращенном виде (изменения регистра состояния не приводятся)

void doModeOff()
{
  swPinOff();                       // отключить от выходных клемм
  writePwmOut(0x0000);              // преобразователь выключить
  dacWrite10bit(0);
}

void doModeV(short fbV)
{
  swPinOn();                        // подключение к силовым клеммам
  writePwmOut(PidPwm.step(setpoint[MODE_V], fbV));
}

void doModeI(short fbI)
{
  swPinOn();                        // коммутатор включен
  writePwmOut(PidPwm.step(setpoint[MODE_I], fbI));
}

void doModeD(short fbI)
{
  swPinOn();                        // коммутатор включен
  dacWrite10bit(PidDac.step(setpoint[MODE_D], -fbI));
}

Особо следует остановиться на сохранении и восстановлении регистров состояния регулятора при переходах с целью получения неразрывной функции регулирования:

// Сохранение и восстановление регистров регулятора для корректного перехода
void saveState( int mode )
{  
  switch (mode)
  {
    case MODE_V:
      sLastSpU  = PidPwm.getLastSp();
      sLastErrU = PidPwm.getLastErr();
      break;

    case MODE_I: 
      sLastSpI  = PidPwm.getLastSp();
      sLastErrI = PidPwm.getLastErr();
      break;

    default: break;
  }
}

void restoreState( int mode )
{
  switch (mode)
  {
    case MODE_V:
      PidPwm.setLastSp( sLastSpU );
      PidPwm.setLastErr( sLastErrU );
      break;

    case MODE_I: 
      PidPwm.setLastSp( sLastSpI );
      PidPwm.setLastErr( sLastErrI );
      break;

    default: break;
  }
}

В начало ^

Подбор параметров.

Подбор коэффициентов ПИД-регулятора, действительно, то ещё шаманство. На картинке это выглядит весьма привлекательно:

Да и метод Циглера-Никольса вполне рабочий. Но какой у нас выбор? Подбирать емкости в петле обратной связи (фильтры измерителей, фильтр усилителя ошибки, а фильтр на выходе DC-DC?), причем на все случаи жизни, или иметь настраиваемый (подстраиваемый) под реализованное схемное решение, результат "улучшения" или нехилый выбор заряжаемых банок?

А вот с тем, что проиллюстрировано ниже при внешней привлекательности, предстоит ещё разбираться и разбираться - суслик вроде бы виден:

При токе в нагрузке менее 150 мА, когда может быть избыток генерируемой мощности:

пид-регулятор периодически отключает преобразователь. Точнее отключает функция из другой библиотеки - SAMD21turboPWM.h, а ПИД-регулятор аккуратно включает ШИМ при очередном вызове. И ШИМ при этом 50/50, что соответствует отдаче половины максимальной мощности, и в некоторых случаях быстренько приводится к тому, что был перед отключением... Ток в режиме CC и напряжение в режиме CV поддерживаются в заданных параметрах плюс-минус менее 5 МЗР, работа преобразователя устойчива:

через 5 миллисекунд генерация возобновляется:

Словом, ведет себя как регулятор 4016, снижая частоту ШИМ в такой же ситуации с нагрузкой. Разница в том, что здесь как бы два ШИМа, но дроссель работает на родной частоте 190 килогерц и это очень хорошо. Надобность в подключении балластного резистора, на котором рассеивается с таким трудом добытая мощность в бесполезное тепло, отпадает.

Параметр "The maximum duty cycle number" = 1000 каким-то образом влияет на такое поведение регулятора, "а коли доктор сыт, так и больному легче" (доктор, "Формула любви").

Холостой ход и выход в режим CV:

Никаких специальных настроек - в режиме холостого хода на клеммах поддерживалось напряжение в пределах 13.0-14.0 вольт при установленном 13.5 уровне. Выброс напряжения не превышал четверти вольта. Подключение батареи не сопровождалось искрением, переход в режим поддержания напряжения прошел штатно.

И, наконец, вишенка на торте - задействованные аналоговые входы измерителей тока и напряжения не случайно оказались именно теми же, к которым подключены встроенные аналоговые компараторы SAMD21. Не ожидали такого подарка? Я тоже. Но об этом потом.

В начало ^


moro logo


7. Документация.

Плата управления. Схема.

Лист 1

Лист 2

Силовая плата. Схема.

Лист 1


8.Полезные ссылки

В начало ^


9. About Me

🚀 I'm a full stack developer urk2t@yandex.ru

В начало ^

About

Зарядное устройство на модулях ESP32_Devkitc_V4 и SAMD21 M0-Mini. Проект Arduino, в разработке.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published