В предыдущих двух статьях цикла, мы сначала научились разбирать протокол для управления блоком питания FNIRSI DPS150, а затем – изучили все основные команды для работы с ним. Теперь с одной стороны, было бы полезно показать готовую программу для работы с этим блоком питания, но с другой… А уровень ли это SE7ENа? Ну программа, ну для управления железкой… Да мы, системные программисты, по три библиотеки в день пишем на основе готовых команд… Нет! Для уровня SE7ENа в статье должна быть какая-то изюминка!
Поэтому я решил при разработке функционала, подробно рассказать про важность перехода от структур к «сырым» данным и обратно, от «сырых» данных к структурам. А чтобы было интереснее, сделаю это не только на языках C/C++, но ещё и на Питоне.
Кому интересно, приступаем!
Ниже приведены ссылки на предыдущие публикации по этой теме. По ходу статьи я буду давать ссылки на них всякий раз, когда надо будет освежить в памяти те или иные факты, описанные там.
Детали протокола управления блоком питания Fnirsi DPS-150
Анализируем протокол управления блоком питания Fnirsi DPS 150
1 Немного теории о структурах
Основное назначение структур в языках программирования, как ни банально это звучит, помогать структурировать данные. В стародавние времена, когда в обычных школах всем преподавали Бейсик, на подготовительных курсах в Политехе, где имелись солидные ЭВМ ДВК-3, уже обучали Паскалю. Поэтому, что такое «Запись» (Record), нам, старшеклассникам, разъяснили очень быстро. Сначала мы старательно тренировались объединять в них ФИО, телефон и дату рождения каких-то виртуальных людей, но потом выяснилось, что без записей сложно построить такие элементы данных, как список и дерево. В общем, без них никуда! Очень полезные штуки!
Как-то всю учёбу в ВУЗе мы пролетели на Паскале, поэтому о том, что в языке Си (который разрабатывался как высокоуровневая замена ассемблеру PDP-11), структуры, совместно с другими сущностями, решают и задачи стыка с аппаратурой, я узнал уже в этом тысячелетии. Давайте поясню суть.
Все помнят советский сериал про Холмса? Титры там незабываемые…
Вот когда букв много – это содержимое памяти. Когда мы выделяем в памяти переменную структурного типа, мы как раз накладываем ту самую маску. Данные в памяти как лежали, так и продолжают лежать на тех же местах, но с точки зрения программы, они получают осмысленное значение. Причём я специально выбрал те титры, где есть поля «Роль», «Имя» и «Фамилия». То есть, полей у структуры много.
Теперь давайте вспомним несколько правил, на которых базируется язык Си:
- для любой переменной может быть взят её адрес размещения в памяти,
- указатель на элемент одного типа может быть преобразован в указатель на элемент другого типа,
- имя массива и указатель на его первый элемент – это одно и то же.
Вот и прекрасно. Допустим, есть у нас некая функция pSerial->SendData,, которая принимает в качестве аргументов указатель на начало массива байтов и количество байт на отправку. Должны ли мы готовить именно массив байтов? Нет! Мы вполне можем разместить в памяти структуру. Я возьму пример из своей старой, но вполне реальной программы, без переделки. Там я не объявлял тип, а сразу разместил всё в памяти, так как структура использовалась всего один раз:
struct
{
WORD header;
WORD msgID;
float time;
DWORD dwMode;
} alignData;
Теперь у меня есть переменная alignData, к полям которой я могу обращаться через точку. Ну я и заполняю их:
alignData.header = 0xa551;
alignData.msgID = 0x1111;
alignData.time = pMS->alignTime;
alignData.dwMode = pMS->alignInsAndMagMode;
И вот теперь начинается ловкость рук, и никакого мошенничества. Сначала я применю оператор & к имени переменной. У меня получится указатель на ту самую структуру (для которой даже нет типа, так что я не могу тут его написать, но он и не важен). Я тут же преобразую этот указатель к указателю на байты, написав (BYTE*). Да, программа старая, типы ещё использовались из заголовка
pSerial->SendData((BYTE*)&alignData,sizeof (alignData));
Давайте теперь я покажу то же самое, но единым фрагментом:
struct
{
WORD header;
WORD msgID;
float time;
DWORD dwMode;
} alignData;
alignData.header = 0xa551;
alignData.msgID = 0x1111;
alignData.time = pMS->alignTime;
alignData.dwMode = pMS->alignInsAndMagMode;
pSerial->SendData((BYTE*)&alignData,sizeof (alignData));
Возвращаясь к аналогии с титрами, я сначала создал пустую маску:
Затем заполнил её поля:
После чего убрал маску, получив «сырое» содержимое памяти, над которой эта маска только что была. Как жаль, что фильм создавался в эпоху, когда титры рисовались вручную, поэтому эффектного рисунка создатели нам не оставили. Но давайте представим, что эта надпись где-то там есть…
И вот теперь мы, с чистой совестью, рассматриваем этот участок уже как обычный массив байт, передав указатель на него в функцию, которая всё отправляет.
2 То же, но в обратную сторону
В принципе, никто не мешает при приёме данных пойти точно таким же путём: создать в памяти переменную, типа «Структура», а её адрес передать в функцию, которая читает данные извне. Но именно перекачка данных в структуру не всегда будет оптимальной. Дело в том, что копирование данных – весьма медленная операция. А данных может быть много, например, если они пришли из сети. При этом, драйвер сначала принимает данные в системный буфер. Что с ними будет дальше – зависит от фантазии программистов, но встречал я случаи, когда при портировании библиотеки LwIp данные из буфера приёма сначала копировались в промежуточный буфер, а уже затем – в буфер прикладной части программы. Для быстрых систем это окажется приемлемым, но язык Си придумывали в эпоху, когда производительность ЭВМ измерялась сотнями тысяч операций в секунду. Не потеряла эта проблема актуальности и на современных дешёвых системах. Если можно не копировать данные, то лучше и не копировать их. Но как они тогда попадут в ту память, в которой размещена структура?
Чтобы решить эту задачу, создатели языка Си, также дали нам возможность наоборот привести указатель на массив к указателю на структуру. Кстати, в титрах именно так и делается. Берётся картинка и уже на неё накладывается маска.
Вот я нашёл какой-то чужой код, который принимает в качестве аргумента команду (явно тоже взятую из принятого массива) и указатель на буфер, в котором размещены какие-то данные:
int LCD_X_DisplayDriver(unsigned LayerIndex, unsigned Cmd, void * pData)
{
Дальше начинается веселье. В зависимости от команды данные могут иметь совершенно разную структуру. И код, не копируя их никуда, просто рассматривает указатель на массив то как указатель на структуру LCD_X_SETORG_INFO:
case LCD_X_SETORG: {
//
// Required for setting the display origin which is
// passed in the 'xPos' and 'yPos' element of p
LCD_X_SETORG_INFO * p;
p = (LCD_X_SETORG_INFO *)pData;
_LCD_SetOrg((int)LayerIndex, p->xPos, p->yPos);
break;
}
То как указатель на структуру LCD_X_SHOWBUFFER_INFO (имеющую совершенно другие поля):
case LCD_X_SHOWBUFFER: {
LCD_X_SHOWBUFFER_INFO * p;
p = (LCD_X_SHOWBUFFER_INFO *)pData;
_aPendingBuffer[LayerIndex] = p->Index;
break;
}
То как указатели других типов. Заведя переменную p, имеющую нужный в данный момент указательный тип, программа начинает достукиваться до полей выбранной ею структуры через операцию «стрелка», указывая имена полей после неё.
Ключевой момент здесь в том, что данные в каждую структуру не копировались. Мы просто рассматривали один и тот же буфер как разные виды данных. В этом примере копирование не заняло бы много времени, но был у нас проект, где мы убирали копирование целых сетевых пакетов, просто передавая указатель на системный буфер, куда этот пакет был принят, и там производительность «до» и «после» различалась очень сильно!
Давайте по традиции я покажу код в виде монолитного фрагмента:
int LCD_X_DisplayDriver(unsigned LayerIndex, unsigned Cmd, void * pData)
{
…
switch (Cmd) {
…
case LCD_X_SETORG: {
//
// Required for setting the display origin which is
// passed in the 'xPos' and 'yPos' element of p
LCD_X_SETORG_INFO * p;
p = (LCD_X_SETORG_INFO *)pData;
_LCD_SetOrg((int)LayerIndex, p->xPos, p->yPos);
break;
}
…
case LCD_X_SHOWBUFFER: {
LCD_X_SHOWBUFFER_INFO * p;
p = (LCD_X_SHOWBUFFER_INFO *)pData;
_aPendingBuffer[LayerIndex] = p->Index;
break;
}
…
case LCD_X_SETLUTENTRY: {
//
// Required for setting a lookup table entry which
// is passed in the 'Pos' and 'Color' element of p
LCD_X_SETLUTENTRY_INFO * p;
p = (LCD_X_SETLUTENTRY_INFO *)pData;
_LCD_SetLUTEntry((int)LayerIndex, p->Color, p->Pos);
break;
}
3 Вывод для языка Си
Итак. Чтобы отправить данные куда-то, их надо разложить в буфере по правилам, описанным той или иной документацией. Давайте возьмём первый попавшийся рисунок из документации на шину USB… Ну, скажем, формат команды для USB-флэшки.
Не так важно, какое поле за что отвечает. Важно, что есть документ, который говорит, что в USB-устройство должен уйти массив данных, который по таким-то адресам должен содержать такие-то поля. И мы можем хранить эти поля у себя в переменных отдельно, а перед передачей массива, выделить массив байт и аккуратно разложить в нём всё, что нам нужно.
Но вместо этого мы можем завести вот такую структуру:
typedef struct {
uint32_t dCBWSignature;
uint32_t dCBWTag;
uint32_t dCBWDataTransferLength;
uint8_t bmCBWFlags;
uint8_t bCBWLUN;
uint8_t bCBWCBLength;
uint8_t CBWCB[16];
}msc_bbb_cbw;
И вместо переменных, хранящих те или иные параметры, хранить одну переменную. Тип переменной – эта структура. Ну всё равно же нам как-то надо значения хранить, почему не так? Но именно при таком хранении, компилятор сам (Сам! САМ!!!) разместит все поля так, что если мы возьмём адрес этой структуры в памяти, мы автоматически получим уже отформатированный массив под передачу!
В первом случае, мы отдельно храним, отдельно форматируем, во втором – мы всё так же храним, а форматирование за нас делает компилятор, просто потому, что мы ему рассказали, как правильно всё разложить по полочкам. В общем, мы к необходимой функции хранения, совершенно бесплатно получаем ещё и функцию форматирования.
Ну, а при обратном преобразовании тоже нам бесплатно на массив «странных» данных наложат ту маску, при которой мы будем видеть данные именно в том виде, в каком нам надо.
Ну замечательно же? И многие этим пользуются. Правда, многие, но не все. Если бы все пользовались, я бы не стал это всё писать. Поэтому после первичных разъяснений для всех, переходим к агитации для тех, кто этот механизм старательно игнорирует.
4 А что там на C++?
Однажды делали мы с напарником проект на C++. Вполне себе сетевой. И когда я собрался создать структуру, которая бы форматировала нужные нам данные, напарник начал читать мне лекцию, что так принято в этих ваших чистых Сях. А Плюсы, говорил он, объектно-ориентированный язык. Поэтому в нём надо делать функции сериализации и десериализации. Ну, то есть, как раз всё хранить в переменных-членах класса, никак не привязанных к передаваемому массиву, а по мере надобности – формировать тот массив и раскладывать всё по его байтикам.
Пришлось объяснить ему, что возможно, он и прав… Но даже если и так, то прав он только когда работает на большой ЭВМ. Мы же с ним делали проект на процессоре с 20 килобайтами флэша, не помню сколькими килобайтами ОЗУ (но понятно, что весьма скромный размер был) и работающим на частоте 48 мегагерц.
Для сериализации и десериализации пришлось бы писать код, который бы занял существенный объём памяти программ. Данные бы хранились дважды – в переменных-членах класса, и в передаваемом буфере, что создавало бы повышенную на грузку на ОЗУ (которого в контроллерах вечно не хватает). Ну, и наконец, процесс переноса занимал бы существенное время (в своей статье про DMA я показал, что доступ к памяти идёт ой как не за один такт). По иронии судьбы, в том проекте нам было велено пользоваться буферами без копирования данных именно для повышения быстродействия. А тут – расходы на преобразование.
Так что если вы работаете на ПК или ещё на какой быстрой машине, то можете идти на поводу у моды и использовать подход с сериализацией-десериализацией, копируя байтики. Но если система у вас простенькая – зачем тратиться на то, что и так получится бесплатно? Кто сказал, что сериализация обязательно должна гонять байты? Хотите сериализацию? Ну, сделайте вы ту самую переменную-член в виде структуры и работайте с ней. Все поля будут там заполняться сами, вы этого не увидите, но компилятор обеспечит это. А эта ваша сериализация пусть константы дозаполняют… Ой, для этого же конструктор придуман… Ну пусть контрольную сумму считает, да указатель на память возвращает. Так вы и за модой угонитесь, и производительность не просадите! А также сэкономите память программ и память данных. Последней на микроконтроллерах в сетевых приложениях вечно не хватает.
5 Хорошо, а что на Питоне?
Один наш Заказчик, который славится очень щепетильным подходом к оптимизации, прислал нам для ускорения работ свой код на Питоне. При этом он очень горячо сокрушался, что в отличие от кода на Сях, здесь приходится делать что-то, типа такого:
def to_byte(self):
value = 0
value |= (int(self.increment_level) & 0b111)
value |= (int(self.reset_maybe) & 0b1) << 3
value |= (int(self.rapid_mode) & 0b1) << 4
value |= (int(self.unknown5) & 0b1) << 5
value |= (int(self.unknown6) & 0b1) << 6
value |= (int(self.yes_no_required) & 0b1) << 7
return value
или такого:
def to_bytes(self):
return struct.pack("ABCD",
self.magic,
self.seed,
self.leds.to_byte())
А я, хоть и не пишу на Питоне, периодически вынужден совать нос в чужой код на этом языке. Иногда этот код писали разработчики из весьма крупных и солидных контор, причём видно, что делали это с любовью. И вот, услышав эту жалобу, я вспомнил, что видел, видел, видел красиво решение, основанное на структурах! Разумеется, пришлось много рыться в загашниках, но я нашёл его! Правда, тогда просто отправил Заказчику, и оно осталось не проверенным ни на его, ни на моей стороне.
А тут, начав писать цикл статей, я понял, что это – отличный повод во всём разобраться и сделать практическую часть не на привычных мне С++, а на Питоне, с применением правильного подхода к формированию пакетов. И сам разберусь, и другим помогу это сделать!
В общем, есть в Питоне такая библиотека – ctypes. Вообще, как следует из описания, она придумана для того, чтобы можно было вызывать машинный код, размещённый в динамически компонуемых библиотеках (файлы *.dll для Windows и *.so для Линукса). А у этих библиотек аргументы могут быть представлены в виде структур. Правильных структур! Тех самых структур, про которые я писал выше, у которых положение полей должно отстоять от адреса начала на определённое, чётко заданное смещение!
Но где сказано, что эта библиотека должна использоваться только для вызова DLLек? Нигде! Мы вполне можем с её помощью организовывать структуры в памяти для личного пользования.
Структуры объявляются примерно так (надо заполнить переменную _fields_ до первого обращения к хранилищу):
class cluster(ctypes.Structure):
_fields_ = [('nStars',ctypes.c_int),
('M_wd_up',ctypes.c_double),
('parameter',ctypes.c_double * 5),
('mean',ctypes.c_double * 5)]
Перечень допустимых типов данных огромен. Здесь я только намечаю основы, остальное все желающие найдут в документации, благо в сети она есть.
И теперь мы можем обращаться к полям структур штатными питоновскими методами, указывая их имена через точку. Но с другой стороны, мы можем получить указатель на эту структуру (приведя его потом к указателю на массив, либо на питоновскую строку), или же наоборот, направить указатель на структуру так, чтобы увидеть содержимое массива или строки в структурированном виде.
Честно говоря, переписывать фрагменты документации я не хочу, все могут почитать и оригиналы, так что давайте лучше рассмотрим всё это дело на примере работы с нашим многострадальным блоком питания. Будем формировать команды для передачи в UART при помощи этого прогрессивного механизма!
6 Практическая часть
6.1 Общая подготовка
Ну что, поехали! Правда, я на Питоне пишу плохо, так что строго не судите, тут главное – идея.
Сначала добавим в пустой файл исходника необходимые библиотеки:
import argparse
import serial
import ctypes
Функцию main() я взял типовую, работающую с UART (да, я попросил у ChatGPT, чтобы он мне её создал). Вот её тело:
def main():
# Настройка аргументов командной строки с помощью argparse
parser = argparse.ArgumentParser(description="Send 'Hello World' over a COM port.")
parser.add_argument("--COM_NUMBER", required=True, help="Specify the COM port (e.g., COM3)")
args = parser.parse_args()
# Получаем номер порта из аргументов командной строки
port = args.COM_NUMBER
# Настройки COM-порта
baud_rate = 9600 # Скорость передачи данных
bytesize = serial.EIGHTBITS # 8 бит данных
parity = serial.PARITY_NONE # Без проверки четности
stopbits = serial.STOPBITS_ONE # 1 стоп-бит
# Открываем COM-порт и отправляем данные
try:
with serial.Serial(port, baud_rate, bytesize=bytesize, parity=parity, stopbits=stopbits, timeout=1) as ser:
print("Message sent successfully.")
except serial.SerialException as e:
print(f"Error opening port {port}: {e}")
exit(1)
if __name__ == "__main__":
main()
6.2 Иерархия классов
Из первой статьи цикла мы уже хорошо знаем, что команды содержат поля, относящиеся к протоколу и поля, относящиеся к самой команде. К протоколу относятся адрес, субадрес, код команды и длина массива данных, идущие в начале посылки, а также контрольная сумма, расположенная в самом конце. Ну, а непосредственные данные уникальны для каждой команды.
Мне очень хотелось сделать так, чтобы постоянную часть не надо было размещать в каждом классе. С учётом, что проект писался на скорую руку, я решил эту задачу лишь частично. Что идёт в начале, то было вынесено в общий класс, а вот контрольную сумму пришлось добавлять ко всем классам. Увы.
Из кучи документов мы знаем, что в наследнике класса ctypes.Structure надо объявить переменную _fields_, чтобы описать поля структуры, которые будут храниться в объекте. ChatGPT говорит, что если надо сделать цепочку наследуемых классов, то в потомках следует писать что-то, типа:
class CommandWithSingleFloat (DpsCmd):
_fields_ = DpsCmd._fields_ + [
("floatValue", ctypes.c_float),
("cs", ctypes.c_ubyte),
]
То есть, наращивать список. В принципе, он прав, но в частности – нет. Я провёл много экспериментов… Между получившимися списками вклинивается какое-то странное поле. Экспериментально удалось выяснить, что для устранения проблемы в классе-предке имя переменной должно быть каким-нибудь другим. Я использовал _fields1_ вместо _fields_. Тогда проблема уйдёт сама собой. Также стоит помнить, что у нас длина готовой структуры будет не кратна 32 битам, так что стоит установить упаковку данных с точностью до байта, чтобы не происходило автоматических выравниваний.
С учётом всего, вышесказанного, общий предок для всех команд, объявляется так:
class DpsCmd (ctypes.Structure):
_pack_ = 1
_fields1_ = [
("cmd1", ctypes.c_ubyte),
("cmd2", ctypes.c_ubyte),
("cmd3", ctypes.c_ubyte),
("dataLen", ctypes.c_ubyte)
]
Чуть позже мы добавим ему функционала. А пока, опишем потомков. Я предлагаю сделать иерархию следующей:
Просто во второй статье цикла мы выяснили, что есть команды, которые передают значение с плавающей точкой, а есть, которые передают байт. Получается, что нам нужен промежуточный уровень иерархии, описывающий эти два вида команд. И, наконец, третий уровень иерархии – конкретные команды, базирующиеся либо на вещественном, либо на байтовом предке.
6.3 Классы второго уровня иерархии
Вот и прекрасно. Давайте сделаем вот такой класс, который передаёт команды в формате float:
class CommandWithSingleFloat (DpsCmd):
_pack_ = 1
_fields_ = DpsCmd._fields1_ + [
("floatValue", ctypes.c_float),
("cs", ctypes.c_ubyte),
]
И всё! И даже не надо никакого функционала! Есть предок, первые байты там будут положены в переменную-член _fields1_. Ещё раз напоминаю, что единица в имени выстрадана. Она устраняет проблемы на стыке. Здесь мы формируем переменную _fields_ (уже без единицы) из общих и персональных полей. Причём поле данных у нас будет типа ctypes.c_float.
Класс передающий байтовые параметры, кроме имени, будет отличаться буквально одной строкой (надеюсь, вы найдёте её самостоятельно):
class CommandWithSingleByte (DpsCmd):
_pack_ = 1
_fields_ = DpsCmd._fields1_ + [
("byteValue", ctypes.c_ubyte),
("cs", ctypes.c_ubyte),
]
6.4 Добавляем функционал в класс первого уровня иерархии
Пора проверять идею, всё ли работает верно. Я это делал на примере функции расчёта контрольной суммы. Вы можете повторять всё такими же короткими шагами, вставляя после каждого из них отладочную печать. Итак, добавляем в класс DpsCmd функцию:
def CalcCheckSum(self):
Интересно, будут ли размеры структур соответствовать действительности? Скорее, чтобы получить ответ на этот вопрос, чем по архитектурным соображениям, я стал заполнять поле структуры, содержащее длину, в этой функции. Так появилась первая строка, с которой я очень долго экспериментировал:
def CalcCheckSum(self):
# Заполнили поле с длиной команды, в зависимости от длины структуры
self.dataLen = ctypes.sizeof(self)-5
Создав объекты типов CommandWithSingleFloat и CommandWithSingleByte и вызвав их функцию CalcCheckSum(), я, глядя на временно добавленный отладочный вывод, понял, что всё верно. Размер у структур получается такой, какой требуется. Ну, точнее, вначале всё было плохо, но когда я добавил ту самую единичку к имени _fields1_, всё наладилось.
Теперь для расчёта суммы, нам нужен указатель на байтовое представление структуры. Для этого, добавляем строку:
# Получили указатель на память, где структура лежит
byte_ptr = ctypes.cast(ctypes.byref(self), ctypes.POINTER(ctypes.c_ubyte))
С этого момента у нас есть прекрасный массив, из которого мы можем читать данные и писать их в него обратно. При этом данные будут обновляться и в полях структуры. Ну, и изменение полей структуры тут же будет отражаться в массиве. Огромный простор для разных экспериментов с одновременным отладочным выводом! Правда, в реализации самого функционала есть одна маленькая проблема. Как выяснилось, тип c_ubyte не поддерживает операции сложения. Читать из него можно, писать – тоже. А вот складывать две таких переменных – нельзя. Поэтому сумму мы копим в обычной переменной типа int:
# uByte не имеет операции сложения, поэтому
# будем копить КС в переменной типа int
cs = 0
# Бежим по всем байтам, которые подлежат расчёту
for i in range (2,ctypes.sizeof(self)-1):
cs += byte_ptr[i]
А когда всё рассчитано – кладём результат в поле структуры:
# Отбросили старшие байты суммы и сохранили в структуре
self.cs = cs & 0xff
Собственно, всё! Полное тело функции будет выглядеть так:
def CalcCheckSum(self):
# Заполнили поле с длиной команды, в зависимости от длины структуры
self.dataLen = ctypes.sizeof(self)-5
# Получили указатель на память, где структура лежит
byte_ptr = ctypes.cast(ctypes.byref(self), ctypes.POINTER(ctypes.c_ubyte))
# uByte не имеет операции сложения, поэтому
# будем копить КС в переменной типа int
cs = 0
# Бежим по всем байтам, которые подлежат расчёту
for i in range (2,ctypes.sizeof(self)-1):
cs += byte_ptr[i]
# Отбросили старшие байты суммы и сохранили в структуре
self.cs = cs & 0xff
6.5 Ещё одна функция первого уровня иерархии
Чтобы получить красивый код, я решил в этот же класс DpsCmd поместить и функцию отправки в COM-порт. Если бы проект был большим, я бы сто раз подумал, делать так или нет, но для тестового проекта получилось очень даже забавно. Будем считать, что это и есть функция сериализации, о которой я тут так много рассуждал. Ссылку на объект uart приходится передавать в неё в качестве аргумента. Для отправки данных в UART массивом не обойтись, здесь приходится пользоваться формированием строки, на основе памяти, в которой размещена структура. Для этого будем использовать функцию ctypes.string_at(). В итоге, новая функция выглядит так:
def SendToUart (self, uart):
self.CalcCheckSum()
data_to_send = ctypes.string_at(ctypes.byref(self), ctypes.sizeof(self))
uart.write (data_to_send)
6.6 Классы третьего уровня иерархии
Ну вот, когда основной функционал готов, давайте сделаем классы, относящиеся к конкретным командам. Честно говоря, мне больше всего нравится посылать команду установки напряжения. Так получилось, что именно её я нашёл первой, так давайте её первой и отправим. Делаем класс:
class cmdSetVoltage (CommandWithSingleFloat):
def __init__(self, voltage):
super().__init__(0xf1, 0xb1, 0xc1)
self.floatValue = voltage
Она вызывает предка, задав ему конкретный код команды (из таблицы, приведённой во второй части статьи). А требуемое напряжение она помещает в поле уже отдельно. Теперь можно разместить объект в переменной, а затем вызвать эту функцию, а можно вызвать её без создания переменных с объектом, написав внутри функции main() так:
cmdSetVoltage (3.49).SendToUart(ser)
Почему 3.49? Так в первой части статьи мы устанавливали именно это значение в фирменной программе и получили такой лог:
OUT f1 b1 c1 04 29 5c 5f 40 e9
Вот и прекрасно! Запускаем сниффер COM-порта, а затем – наш питоновский код. И видим в логах…
Ура! Всё работает!
Из второй части статьи известно, что сразу после открытия порта следует послать команду CMD_1. Давайте скорее опишем класс, формирующий её:
class cmdFirstCommand (CommandWithSingleByte):
def __init__(self):
super().__init__(0xf1, 0xc1, 0x00)
self.byteValue = 1;
Теперь в функции main() будет сразу две команды:
cmdFirstCommand().SendToUart(ser)
cmdSetVoltage (3.49).SendToUart(ser)
Посылаем их… Иииииии… Вуаля! Вот фотография экрана блока:
Всё работает! Но статья снова получилась огромной, поэтому дописывать код для передачи данных и делать анализатор ответов мы будем уже в следующей части. Если, конечно, интерес к теме ещё не угаснет. Просто в моих предыдущих циклах, рейтинги с каждой следующей частью падали просто катастрофически, так что теперь я не пишу в стол, а дописываю новое лишь убедившись, что информация кому-то нужна. Может, всем уже и так всё понятно. А так – в следующей статье запланируем пример приёма данных с превращением содержимого буфера в поля структуры.
7 Заключение
В статье показано, что структуры – это мощное средство для обработки потоковых данных. Структуры легко могут быть преобразованы в массив под передачу, а принятый массив не менее легко может быть разобран в виде структур. Это касается не только языков С/C++, но даже языка Python, при использовании библиотеки CTypes.