Я редко занимаюсь чем-то «низкоуровневым». Мой примерный рабочий день состоит из разработки мобильных приложений для бизнеса и кучи разноплановых менеджерских штук. Само собой, иногда хочется отвлечься. Получается это редко, и, наверное, поэтому, доставляет особое удовольствие. В этот раз отвлечься было решено с помощью набора «Амперка. Тетра», который предназначен для детей и с помощью которого можно узнать основы схемотехники, названия всяких сенсоров, и попробовать попрограммировать что-то, работающее в реальном мире, а не на экране компьютера. В общем, когда ты взрослый бородатый мужик — вообще самое оно.
Сразу оговорюсь, что я, хоть и занимался всякой теорией (в том числе и схемотехникой), а еще мне в своей время пришлось попрограммировать и протоколы, и поработать на низком уровне со всякими Bluetooth, и даже драйвер клавиатуры для хакинтоша я когда-то дорабатывал (очень хотелось, а без клавиатуры было тяжело), занимаюсь я этим настолько редко, что мои знания по этому предмету можно считать близкими к нулю. Опыт разве что помогает мне быстрее находить направление изучения, но нисколько не уменьшает количество ошибок и граблей, на которые я с размаху наступаю.
Так вот, вернемся к теме, Тетра. Программировать этот набор (который сделан на Arduino Leonardo) предполагается на языке Scratch (наследник Лого, с которым я развлекался в школе), используя приложение S4A, и когда я это попробовал… Не, оно, конечно, прикольно, но чего-то не хватало. Леговости не хватало. О чём я? О Системе.
Если посмотреть серию Toys That Made Us про Lego, там дикое количество раз упоминается слово «Система». Конструктор Лего основан на всем известных блоках, они все друг к другу подходят, и этот принцип (вместе с базовыми правилами соединения блоков) называется System. Есть ещё TECHNIC для «машинок», но там смысл тот же. Именно поэтому, используя тысячи разных видов блоков, можно соорудить что угодно, от минималистических фигурок до целого мира, и это позволяет легко, минуя идею «А что, если..?», перейти к реализации, посмотреть, что получится.
Тетра основана на схеме подключения «Troyka», которая тоже снаружи выглядит, как Система. Но, увы, она получилась совсем не столь стройной, как у Лего. И далеко не всегда можно соединить всё со всем. Но про это позже, вернёмся к развлекалову.
«Для ребёнка» (честно!)
Обосновать покупку такой игрушки легко. Ребёнок есть, спрашиваешь, хочет ли он игрушку (при этом вообще не нужно уточнять, какую именно), у него рефлекторно загораются глаза, «Хочу!!!», говорит, ну и всё, можно дальше не сомневаться. То, что играть в результате будет папа, а ребёнок — только бегать вокруг и радоваться, это тоже важно. Ну, и программировать можно вместе, лампочки мигают, потыкать-понажимать всегда в радость!
Итак, Тетра приехала. Я повёлся на «одинаковость» Troyka-модулей и, не прочитавши документацию, заказал ещё два модуля вместе с Тетрой: блок светодиодов 8 на 8, чтобы можно было смайлики рисовать, и четырёхразрядный дисплей. Грустно, когда только лампочками можно поморгать, хочется чего-то более продвинутого.
Посмотрев на названия на выводах блока светодиодов и дисплея (на буковки, конечно же, помните про нулевые знания? Я ничего в этих буковках не понимаю), я догадался, что подключить их «запросто, как в Лего» мне не светит, и отложил на несколько дней, решив, что сначала стоит вообще разобраться, как тут оно работает «для детей». Для детей — нужно скачать программу S4A (Scratch for Arduino), и там составлять блоки, создавая приложения. Помимо того, что S4A не работает под последней версией macOS (есть виртуальные машины, не страшно), а под Линуксом нужно сделать дюжину телодвижений apt-get’ом (побегав по форумам), чтобы оно заработало (инструкции на основном сайте, конечно же, не достаточно), особых проблем не возникло.
Первые программы успешно были «нарисованы», запущены и поморгали лампочками. Ребёнок узнал, что такое потенциометр, исполнительное устройство, почему светодиод обозначается по-английски LED, и чем сенсоры отличаются от датчиков. Начало обучения можно считать успешным.
Дисклеймер
По идее, дальше работа с Тетрой так и представляется. Формируем стенд из предложенных устройств, пишем что-то на Скрэтче, запускаем, радуемся. На этом уровне Тетра — вполне приличный конструктор. С хорошим учебником, который можно брать и использовать, например, в школе. Впрочем, у меня вызвали сложность несколько моментов. Вот они:
- Обозначение и расположение слотов подключения устройств явно разрабатывал программист. Почему, например, рядом стоят цифровые выходы с номерами 13, 12 и 10, а номер 11 стоит в стороне? О_о
- Было бы круто, если бы значения датчиков настраивались в понятных числах, а не в абстрактных. Да, так ближе к «настоящей разработке на Ардуино», но нет, не думаю, что это упрощает обучение.
- Можно (не знаю, правда, насколько сложно) доработать S4A так, чтобы она лучше отражала действительность. Иначе приходится запоминать/угадывать, что есть где, легко запутаться во всех числах. Для WeDo (или какого-то другого конструктора) там это сделано (датчики называются понятно на экране), а для Тетра — нет, там просто AnalogN/DigitalN. Это мешает, отвлекает от процесса программирования.
Но это не важно. Повторюсь, мне S4A и процесс работы с Тетрой понравился.
Тем не менее, мне почему-то захотелось большего:
- во-первых, я хотел писать код как код, а не рисовать его (программист я, или где?!).
- во-вторых, я очень хотел научиться работать с более широким спектром устройств (тем более, что в форм-факторе Тройки выпускается много интересного, включая полноценные дисплеи, куча датчиков, моторчиков и чего душе угодно). Меня всегда расстраивала необходимость собирать схемы. Нельзя просто так подключить светодиод, нужно подобрать ему резистор. А этот датчик от другого напряжения работает, давайте тут схемку прикрутим. Breadboard’ы с натыканными туда проводками меня вгоняют в тоску. Именно поэтому я и уцепился за Troyka-формат. Там более или менее готовые компоненты, это же круто!
Поэтому S4A я отложил, и пошёл думать, что бы сделать дальше.
А теперь, собственно, дисклеймер. То, что будет дальше — это не рецепт «сделать Тетру лучше». Это проверка границ возможного. Исследование того, куда заведёт меня мозг, используя Тетру, как стартовую площадку.
Что делать будем?
Последние несколько лет я, в основном, создаю приложения, используя язык Swift (для iOS и немного для рядом стоящего Мака). Поэтому я решил, что буду использовать именно его. Tetra общается с компьютером по обычному последовательному порту (через USB, но это почти не важно), я с ним уже работал, примеров огромное количество, так что разберёмся.
Промежуточную цель я для себя сформулировал так: создать фреймворк на Swift, при помощи которого можно:
- подсоединиться к Тетре,
- считать значения с датчиков,
- передать в Тетру управляющие команды для работы с устройствами.
Всё это предполагалось делать, используя протокол, который использует S4A. В их инструкции есть «firmware» для самой Ардуины (этот же файл упоминается и в форумах Амперки), я посчитал, что раз есть «серверная» часть, удастся разобраться, что это такое и как работает.
Дополнительными целями было:
- узнать побольше, как происходит разработка для Ардуино,
- понять разницу в разных устройствах, их протоколах, способах подключения, ограничениях,
- сделать несколько устройств (может, уже с отдельной Ардуиной, в отдельном корпусе). Полезных, или, хотя бы, забавных.
Получилось? Конечно! Сейчас расскажу, как.
Протокол
Начать стоило с выяснения, как S4A общается с платой. Поглядев в прошивку для Ардуино, я нашёл два кусочка кода, которые, как оказалось, и содержат в себе весь протокол общения. Заодно понял, что этот протокол создан для другой поделки, которая называется PicoBoard, и вообще, к Scratch подключается много всяких штук. Вот код.
Код похож на обычный С/С++, но не совсем. Это С++-подобный язык, на котором пишутся программы для Ардуино.
void send(byte sensor, int value) {
Serial.write(B10000000 | ((sensor & B1111) << 3) | ((value >> 7) & B111));
Serial.write(value & B1111111);
}
void receive(byte first, byte second) {
pin = (first >> 3) & 0x0F;
newVal = ((first & 0x07) << 7) | (second & 0x7F);
...
}
Документации по протоколу я не нашёл, но из этого кода можно легко её придумать. Итак, выглядит двухбайтовый пакет (не важно, передаваемый или получаемый) вот так:
1SSSSVVV 0VVVVVVV
Где SSSS
— это номер пина, с которого считываем или куда записываем, а VVVVVVVVVV
— 10-битное значение. Значения для цифровых портов почему-то передаются как 0 и 1023, а «аналоговые» значения для устройств (которые передаются в Тетру) обычно от 0 до 255. Вот и весь протокол. Ардуина опрашивает пины (настройка пинов там же, в прошивке) настолько часто, насколько может, делает очень простую фильтрацию данных с датчиков, и значения по-одному отправляет вот такими пакетиками клиенту. Клиент, в свою очередь, должен постоянно отправлять значения для настройки исполнительных устройств. Я попробовал эту, последнюю часть не делать, но выяснилось, что в этом случае прошивка думает, что S4A отвалился, и сбрасывает все значения.
Обратите внимание на единицу и ноль в начале байтов. Это — контрольные биты. Если вы поймали байт с нулём, а перед ним не было байта с единицей, то такой нужно пропустить, так как первый где-то потерялся.
Подключение по последовательному порту
Вторая задача. Подключиться к последовательному порту и прочитать/записать что-нибудь. Гуглим Serial Swift, находим первую либу (цели использовать что-то нет, но нужно что-то, чтобы написать своё), читаем исходник https://github.com/yeokm1/SwiftSerial/blob/master/Sources/SwiftSerial.swift. Судя по команде в прошивке Serial.begin(38400)
, скорость соединения должна быть 38400, а остальные значения — «по умолчанию». Что такое по умолчанию? Ищем доки по Ардуино, там эти умолчания есть. Теперь нужно написать немного кода для подключения к порту. Ну, или скопипастить соответствующие куски из SwiftSerial, убрав Linux-части и те варианты read/write, которые мне нужны. Запустилось? Угу, но, почему-то, не идёт дальше open
. Гуглим, что же это может быть. Оказывается, это нормальное поведение в macOS, и чтобы открытие порта не блокировалось, нужно добавить параметр O_NONBLOCK
. Добавляем, ура, подключились!
open(path, readWriteParam | O_NOCTTY | O_EXLOCK | O_NONBLOCK)
Остальной код в текущей версии можно посмотреть тут https://github.com/bealex/TetraSwift/blob/master/Sources/Serial/SerialPort.swift. Он ни в коем случае не идеален, зато работает. Да, и после экспериментов я вернул Linux-часть, чтобы запускать на Raspberry Pi.
Чтение, запись
Дальше пробуем прочитать из порта в цикле что-нибудь и записать что-нибудь. Номера пинов известны, со значениями немного подробнее.
- Значения, приходящие от датчиков, 10-битные, беззнаковые (от 0 до 1023). Это теоретические значения, на практике же каждый датчик как может, так и присылает.
- Значения, записываемые в цифровые устройства — HIGH или LOW. Для Тетры HIGH — это почему-то 1023 (а не 1, как можно было бы), а LOW — 0. Добро пожаловать в мир условностей, где «включено» — это что угодно, кроме нуля. ¯\_(ツ)_/¯
- Значения, записываемые в аналоговые устройства — 8-битные, беззнаковые (от 0 до 255). Так же, как и с датчиками, реальные значения зависят от исполнительного устройства. Некоторые используют весь диапазон, некоторые — только его часть.
- Номер пина, как мы выше увидели, это 4-битное значение (от 0 до 15). И про пины нужно чуть больше информации, какое устройство к какому подключено. Там всё «сделано программистом», поэтому так:
- Аналоговые сенсоры 1—4 соответствуют Тетровским номерам (написанным на плате), а пин пятого — 0.
- Цифровые сенсоры (кнопки): вторая — это шестой пин, а третья — седьмой.
- «Аналоговые» (в кавычках, потому что там ненастоящие аналоговые) устройства: пятый — пятый цифровой порт, шестой — второй цифровой порт, а девятый — девятый цифровой порт. Моторные порты 3/7/8 живут на цифровых пинах 4/3/8 соответственно
- Цифровые устройства соответствуют портам, от 10 до 13 включительно.
С пинами я не понял, почему так. Скорее всего, оно так «получилось» в PicoBoard, а тут просто взяли, что было. А у меня это здесь: https://github.com/bealex/TetraSwift/blob/master/Sources/IOPort.swift. Сам маппинг названий в порты в конце файла.
Читаем, выводим в лог. Пишем в разные пины, всё моргает и жужжит. Ура!
Что там было сложного? В целом, ничего. Нужно только аккуратно использовать стандартные функции чтения/записи и помнить, что если дать такой процедуре несколько байт, то не факт, что она запишет всё, может часть недозаписать, эту часть нужно отправлять ещё раз. Совершенно стандартная процедура при работе с портами ввода-вывода.
Дальше нужно этим как-то управлять.
Интерфейс фреймворка
Когда я смотрел в сторону Swift, мне хотелось сделать там что-то «похожее на Scratch». То есть чтобы можно было простой последовательностью действий или блоков реализовать несложную логику работы. На начальном этапе получилось что-то совсем простое (когда я только только записал в последовательный порт что-то):
let tetra = Tetra(...) { event in
// Сюда идут события с сенсоров.
}
// Поехали!
tetra.start()
// Зажгли 13 светодиод.
tetra.write(actuator: .ledDigital13(true))
// Подождали секунду.
Thread.sleep(forTimeInterval: 1)
// Потушили 13 светодиод.
tetra.write(actuator: .ledDigital13(false))
// Приехали. :)
tetra.stop()
Вся связь названий с сенсорами и исполнительными устройствами была где-то в потрохах, да и обрабатывать события от всех сенсоров в одном замыкании было так себе. Следующая итерация выглядела иначе.
let tetra = Tetra(...)
// Пока не завершится этот вызов или есть обработчики событий, приложение не остановится.
// Этот пример не остановится никогда, так как ждёт нажатия на кнопку.
tetra.run {
// Ждём нажатия на кнопку.
tetra.waitFor(.button2, is: true) {
// И моргаем светодиодом.
tetra.write(actuator: .ledDigital13(true))
Thread.sleep(forTimeInterval: 1)
tetra.write(actuator: .ledDigital13(false))
}
}
Правда, куда лучше делать не tetra.write(actuator: .ledDigital13(true))
(что это вообще значит?), а что-нибудь такое: tetra.digitalLED13.on()
. И действительно.
let tetra = Tetra(...)
tetra.run {
// По одной кнопке включаем светодиод.
tetra.whenOn(.button2) {
tetra.digitalLED13.on()
}
// А по другой — выключаем.
tetra.whenOn(.button3) {
tetra.digitalLED13.off()
}
// Ну, и последовательного исполнения немного.
tetra.digitalLED12.on()
tetra.sleep(0.3)
tetra.digitalLED12.off()
}
О, уже на что-то похоже. Теперь нужно добавить конфигурацию портов, вдруг я захочу переставить сенсоры куда-нибудь.
let tetra = Tetra(...)
tetra.installSensors(
analog: [
AnalogSensor(kind: .light, port: .analogSensor5),
...
AnalogSensor(kind: .temperature, port: .analogSensor3),
],
digital: [
...
DigitalSensor(kind: .button, port: .digitalSensor3),
]
)
tetra.installActuators(
analog: [
AnalogActuator(kind: .motor, port: .motor4, maxValue: 180),
AnalogActuator(kind: .buzzer, port: .analog9, maxValue: 200),
AnalogActuator(kind: .analogLED(.green), port: .analog5, maxValue: 200),
...
],
digital: [
DigitalActuator(kind: .digitalLED(.green), port: .digital10),
...
]
)
Зачем там цвета указаны? Чтобы в лог было удобнее выводить. Но это не важно. Видите ли вы тут проблему? Наверняка их тут много, мне сходу видно две:
- во-первых, на один порт можно назначить кучу устройств. И порт почему-то внутри устройства, что-то тут не так.
- во-вторых, никак нет возможности легко добавить устройства другого типа. Только analog, digital, и всё. У них ещё и интерфейс у всех простой (я выше описывал значения, которые считываются или передаются) — число туды, число сюды. А если нужно передать строку, чтобы напечатать на экране?
До этого этапа я попробовал добавить другие типы устройств: экранчики всякие, например. Потому что хотел быстрее получить результат. В процессе подтвердил то, что увидел выше, и пошёл переделывать.
В общем, так себе получилась настройка. Думаем дальше. Получается так.
tetra.install(sensors: [
.analog0: AnalogSensor(kind: .light),
.analog1: AnalogSensor(kind: .potentiometer),
.analog2: AnalogSensor(kind: .magnetic),
.analog3: AnalogSensor(kind: .temperature),
.analog4: DigitalSensor(kind: .infrared),
.digital6: DigitalSensor(kind: .button),
.digital7: DigitalSensor(kind: .button),
])
tetra.install(actuators: [
.digital4: AnalogActuator(kind: .motor),
.digital9: AnalogActuator(kind: .buzzer),
.digital5: AnalogActuator(kind: .analogLED(.green)),
.digital6: AnalogActuator(kind: .analogLED(.red)),
.digital10: DigitalActuator(kind: .digitalLED(.green)),
.digital11: DigitalActuator(kind: .digitalLED(.yellow)),
.digital12: DigitalActuator(kind: .digitalLED(.yellow)),
.digital13: DigitalActuator(kind: .digitalLED(.red)),
.digital7: LEDMatrixActuator(kind: .ledMatrix(.monochrome)),
.digital8: QuadNumericDisplayActuator(kind: .quadDisplay),
])
Во, отлично!
Теперь давайте вспомним, за что я критиковал S4A. За то, что значения выдаёт странные. Давайте прилепим сенсорам обработчики значений, чтобы температуру выдавали в градусах Цельсия, а значение потенциометра — как число от 0.0 до 1.0.
Тут же удобно для исполнительных устройств пообрезать значения, которые они не должны получать.
// Сделаем, чтобы температура не скакала туды-сюды, сгладим.
// И добавим функцию преобразования Ардуинного значения в Цельсии.
AnalogSensor(
kind: .temperature,
sampleTimes: 32,
tolerance: 0.05,
calculate: Calculators.celsiusTemperature
)
// Мотор не должен получать большие значения, чем 180.
AnalogActuator(kind: .motor, maxValue: 180)
Ещё один момент. Удобно будет командовать устройствами, а не Тетрой. Выше было «Тетра, поймай значения от устройства и сделай вот что». А правильно будет «Устройство, когда тебе придёт значение, вот что с ним нужно сделать».
И ещё. Хранить устройства во фреймворке — неправильно. Конфигурация должна быть снаружи, в приложении, фреймворку должно быть всё равно, какие там устройства подключены.
Отлично! Переделываем, кажется, получается что-то удобоваримое. Напишем мелкую программу.
Сначала инициализация
class Tetra: TetraInterface {
let temperatureSensor = TemperatureSensor()
let potentiometer = Potentiometer()
...
let quadDisplay = QuadNumericDisplayActuator()
let redAnalogLED = AnalogLED()
let greenAnalogLED = AnalogLED()
init(pathToSerialPort: String) {
let serialPort = HardwareSerialPort(path: pathToSerialPort, rate: .baud115200)
super.init(serialPort: serialPort, useTetraProtocol: true)
add(sensor: temperatureSensor, on: .analog3)
add(sensor: potentiometer, on: .analog1)
...
add(actuator: greenAnalogLED, on: .digital5)
add(actuator: redAnalogLED, on: .digital6)
add(actuator: quadDisplay, on: .digital8)
...
}
}
И сам код, использующий эту настройку:
Tetra(pathToSerialPort: serialPort).run {
guard let tetra = $0 as? Tetra else { return }
// Поуправляем аналоговыми светодиодами при помощи потенциометра.
tetra.potentiometer.whenValueChanged { value in
if value < 0.5 {
tetra.greenAnalogLED.value = 0
tetra.redAnalogLED.value = (0.5 - value) * 2
} else {
tetra.greenAnalogLED.value = (value - 0.5) * 2
tetra.redAnalogLED.value = 0
}
}
// Выведем температуру на дисплей.
tetra.temperatureSensor.whenValueChanged { value in
tetra.quadDisplay.value = String(format: "%.1f˚", value)
}
}
Может, теперь можно и протокол взаимодействия с Тетрой поменять? Попробуем.
Протоколы
Ещё один дисклеймер. Тот протокол, который я предлагаю — так себе протокол. Он отлично работает у меня дома, но даже PicoBoard сделан лучше. Помните, там 0 и 1 в начале байтов? Это крутая штука, которая при должной обработке поможет восстановить поток данных, если какой-то байт вдруг где потеряется. Я не хочу делать крутой протокол, я просто исследую, играюсь. Поэтому протокол будет простой. Но не совсем.
Итак, что я хотел от протокола:
- Чтобы он мог конфигурировать клиента под плату. Расставили устройства, прошили конфигурацию, а дальше — запускаем Swift, и он сам понимает, где там датчик температуры, а где чем поморгать можно. Не нужен блок конфигурации вообще!
- Чтобы можно было работать с устройствами сложнее, чем лампочки и моторчики. Экраны всякие, опять же.
- Чтобы не нужно было постоянно обновлять значения. Ардуино это всё умеет делать сама, зачем двойную работу совершать?
- Чтобы связывался с Ардуиной на большей скорости и позволял делать более интересные штуки.
В этом месте я задумался. Слишком много вариантов, даже если учесть, что данные должны передаваться по обычному последовательному порту. К примеру, можно сделать бинарный протокол. Причём как свой, так и используя какой-нибудь Protocol Buffers. Я некоторое время думал его использовать (для Ардуины есть реализация nanopb), но после гугления выяснил, что Protocol Buffers плохо подходят, чтобы гонять по последовательному соединению. Также можно было сделать текстовый протокол (его отлаживать проще, но он сам многословнее будет).
В результате я остановился на бинарном протоколе. Первая итерация была такая:
- У каждой команды есть код и есть данные. Размер данных либо известен заранее, либо вычисляется по коду и начальных байтах в данных.
- Структура каждой команды жёстко задана и не меняется.
- Для каждого устройства можно добавить свою команду, которая будет как-то интерпретироваться Ардуиной.
С этой итерацией всё работало хорошо, но через некоторое время, когда я стал добавлять отдельные команды для разных устройств (для дисплеев, более сложные команды для исполнительных устройств), появилось желание сделать более общий протокол. Добавлять команду на каждое устройство круто, но неудобно. Про это отдельно написать можно будет.
Тестирование
Когда есть интересный проект, хочется им заниматься везде. Дома есть сама Тетра, чтобы подключить и пробовать. А если теперь хочется поэкспериментировать где-то ещё? Таскать с собой устройства неудобно, поэтому нужно что-то, их заменяющее. Это приводит к очевидному изменению в коде, которое полезно делать в любом случае. Выделению протокола последовательного порта и созданию мок-версии этого порта, чтобы можно было даже без физического устройства посылать туда данные и получать результат.
Это прикольно ещё и тем, что позволяет сделать эмуляторы сенсоров и устройств. Ну и сам класс последовательного порта требовал переработки, структурно он выглядел очень странно. Например, требовалось сначала открыть порт, а потом настроить его. Почему бы эту последовательность не скрыть? И передача/получение байтов из последовательного порта — тоже операция, которую неудобно использовать снаружи. Лучше, когда она живёт внутри реализации, а не в месте вызова.
В результате я пришёл к следующему:
- Есть протокол
DevicePort
, который умеет только start/stop/write/read. - Есть реализации, одна из которых — честный
SerialPort
, а вторая — имитирует Ардуину, то, как оно обрабатывает прибывающие байтики (тем протоколом, который сейчас у меня работает), и отвечает согласно тестовому сценарию.
В общем, тестирование тоже вполне вырисовалось.
Что у меня вышло?
Забавное приложение и куча позитива. Идеально ли то, что вышло? Ни в коем случае. И я, надеюсь, продолжу развлекаться. Мне кажется, что вот такое отвлечение важно для сохранения сознательности. Для напоминания себе, что даже через двадцать лет работы разработчиком и чем-то-ещё необходимо постоянно ковырять новое и непонятное, чтобы быть специалистом. Чтобы освежить и закрепить принципы, полученные в проектах, докладах, до которых удалось дойти своим мозгом. А иначе вместо решения задач получается активное прыгание на месте. Как упражнение на кардиотренажёрах. Конечно, хорошо, но в профессиональном смысле — так себе.
До новых встреч!
:–)
tetra.quadDisplay.value = "L8R"