Привет снова, Хабр!
Помните моего робота на Arduino Uno Q с характером? Того, который умел подмигивать и обижаться? Так вот, за пару месяцев он серьезно прокачался. Теперь он не просто ездит и болтает, а:
- 🎤 Слушает ответы после того, как сам поговорил (и отправляет их в LLM для контекста!)
- 🌈 Светится разными цветами в зависимости от настроения (красный = злой, зеленый = радостный, синий = думает)
- 🗣️ Говорит на трех языках (английский, русский, чешский) голосами WaveNet от Google
- 🗺️ Рисует карту комнаты прямо в текстовом режиме
- 🧠 Помнит свои движения, чтобы не застревать в углах как пылесос из 2005-го
В этой статье я расскажу, как превратить простого робота в почти разумное существо, используя мультимодальный Gemini API, и почему мой робот теперь умнее некоторых людей (шутка... или нет?).
Что изменилось с прошлой версии?
В первой статье я описывал базовый AGI-цикл: камера → LLM → команда → движение. Это работало, но роботу не хватало контекста. Он не помнил, куда ехал, не понимал мои ответы и вообще вел себя как безэмоциональный тостер на колесах.
Вот что я добавил:
🎤 Аудио-взаимодействие (наконец-то двусторонняя связь!)
Раньше робот говорил, но не слушал. Это как разговаривать с начальником — односторонний разговор. Теперь после каждого своего высказывания робот включает микрофон на 10 секунд и записывает ответ пользователя в WAV-файл с правильными заголовками.
# Из main.py - после того как робот что-то сказал mic = Microphone() mic.start() with wave.open("mic.wav", "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) # S16_LE wf.setframerate(16000) for chunk in mic.stream(): wf.writeframes(chunk.tobytes()) if time.time() - start_time >= 10: break
Потом этот файл base64-кодируется и отправляется в Gemini вместе с картинкой и всеми остальными данными:
# media_service.py - мультимодальный запрос contents = [ types.Content( role="user", parts=[ types.Part.from_text(text=prompt_text), types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"), types.Part.from_bytes(data=audio_bytes, mime_type="audio/wav") # ВОТ ОНО! ] ) ]
Теперь я могу сказать роботу, куда ехать, и он меня услышит. Буквально. Это как Siri, только на колесах и с камерой.
🌈 RGB LED "настроение" (эмоции через цвет)
Раньше у робота была только LED-матрица для "лица". Теперь на крыше стоит трехцветный RGB-светодиод (пины D3, D5, D6), который показывает эмоции:
| Цвет | Настроение | Когда горит |
| 🟢 Зеленый | Радость/Успех | Нашел цель, выполнил задачу |
| 🔴 Красный | Злость/Блокировка | Застрял, препятствие < 25см |
| 🔵 Синий | Думает/Планирует | Обрабатывает данные, размышляет |
| 🟡 Желтый | Любопытство | Исследует новую зону |
| 🟠 Оранжевый | Осто��ожность | Рядом препятствие, но еще не критично |
| ⚪ Белый | Нейтральный | Дефолтное состояние |
LLM сам решает, какой цвет выбрать, основываясь на ситуации. В промпте прописаны четкие правила. Но вот фокус: Arduino Cloud использует CloudColoredLight, который работает с HSV (Hue, Saturation, Brightness). Поэтому в Python есть конвертер:
# main.py - преобразование HSV → RGB для MCU def update_rgb_from_values(): h = float(rgb_values.get("hue", 0)) / 360.0 s = float(rgb_values.get("sat", 0)) / 100.0 v = float(rgb_values.get("bri", 0)) / 100.0 r_float, g_float, b_float = colorsys.hsv_to_rgb(h, s, v) rgb = f"{int(r_float * 255)},{int(g_float * 255)},{int(b_float * 255)}"
🗣️ Мультиязычный TTS (говорит как носитель)
Google TTS с голосами WaveNet — это просто космос. Вместо роботизированного "бип-буп" робот говорит натуральным голосом:
- Английский: en-US-Neural2-D (мужской, четкий)
- Русский: ru-RU-Wavenet-D (мужской, с легким акцентом, но приятный)
- Чешский: cs-CZ-Wavenet-A (женский, мелодичный)
Язык выбирается через Arduino Cloud переменную lang. Меняю в облаке → робот тут же переключается.
# media_service.py - выбор голоса if lang == 'ru': voice = {'languageCode': 'ru-RU', 'name': 'ru-RU-Wavenet-D'} elif lang == 'cz' or lang == 'cs': voice = {'languageCode': 'cs-CZ', 'name': 'cs-CZ-Wavenet-A'} else: voice = {'languageCode': 'en-US', 'name': 'en-US-Neural2-D'}
И что важно — кэширование! Чтобы не синтезировать одно и то же по 100 раз:
cache_key = f"{lang}:{text}" if cache_key in TTS_CACHE: temp_filename = TTS_CACHE[cache_key] else: # Синтезируем и кэшируем
Теперь робот может говорить со мной по-русски, а с чехами — по-чешски. Это прям космополит на колесах.
🗺️ Текстовая карта пространства (poor man's SLAM)
SLAM (Simultaneous Localization and Mapping) — это круто, но сложно и требует лидара. Я пошел другим путем: попросил LLM рисовать карту в текстовом виде.
В промпте теперь есть инструкция:
\"7. MAPPING: Create and update a 2D text-mode map of the environment in the 'map' field. \" \"The map MUST be based on 1x1 meter blocks. Each block is represented by one letter \" \"(e.g., W: wall, S: sofa, D: door, R: robot, P: path, O: obstacle). \" \"You MUST include a legend explaining the letters used.\\n\\n\"
И робот реально строит карты! Вот пример из логов:
Map:
W W W W W
W P P S W
W P R P W
W D P P W
W W W W W
Legend:
W - Wall
P - Path (clear space)
R - Robot position
S - Sofa
D - Door
Конечно, это не идеальная карта (робот иногда врет), но лучше, чем ничего. И главное — эта карта передается обратно в LLM на следующей ��терации, так что он "помнит" планировку комнаты.
🧠 История движений и планирование
Самая большая проблема ранних версий — робот терял память после каждого действия. Ехал вперед, забывал, что делал это 2 секунды назад, и снова ехал вперед. Классическая амнезия.
Теперь есть три уровня памяти:
1. movement_history — массив всех команд движения:
movement_history = [ {"command": "forward", "distance_cm": 30, "angle_deg": 0}, {"command": "left", "angle_deg": 45}, {"command": "forward", "distance_cm": 20} ]
2. plan — глобальная стратегия:
"Plan: Explore north side of the room to find target"
3. subplan — тактика прямо сейчас:
"Subplan: Turning right 30 degrees to scan new area, avoiding wall on the left"
Все это отправляется в LLM на каждой итерации. Gemini анализирует историю и говорит: "Стоп, я уже пробовал ехать туда 3 раза, давай попробую другое направление".
Это кардинально меняет поведение. Робот перестал быть тупым реактивным ботом и стал стратегом.
Архитектура: как это все связано?
Система состоит из трех частей:
1. Arduino MCU (sketch.ino) — "Тело"
void loop() { // Читаем переменные из облака Bridge.call("get_speed").result(speed); Bridge.call("get_agi").result(agi); Bridge.call("get_rgb").result(rgb_str); // Читаем датчики distance = sonar.ping_cm(); temperature = thermo.getTemperature(); // Обновляем облако Bridge.call("set_distance", distance); // Если AGI-режим, вызываем Python if (agi) { String mvcmd; Bridge.call("agi_loop", distance).result(mvcmd); // Парсим и выполняем команду (MOVE/TURN/STOP) } }
Тупой, быстрый, надежный. Идеально для реального времени.
2. Python MPU (main.py) — "Координатор"
def agi_loop(distance): # Отправляем все в media_service resp = ask_llm_vision( distance=distance, plan=plan, subplan=subplan, movement_history=movement_history, space_map=space_map ) # Обрабатываем ответ if resp.get("speak"): speak(resp["speak"]["text"]) # ЗАПИСЫВАЕМ АУДИО ОТВЕТ! if resp.get("rgb"): rgb = resp["rgb"] if resp.get("move"): # Формируем команду для MCU move_cmd = f"MOVE|{cmd}|{distance}|{speed}" movement_history.append(resp["move"]) return move_cmd
3. Media Service (media_service.py) — "Мозг"
@app.post('/llm_vision') def llm_vision(): # Получаем картинку через Socket.IO image_data = get_image_from_socket() # Декодируем аудио из base64 audio_bytes = base64.b64decode(payload.get('audio')) # Шлем в Gemini response = send_to_gemini( prompt, image_data, lang=lang, audio_bytes=audio_bytes # МУЛЬТИМОДАЛЬНОСТЬ! ) return response # JSON с планом действий
Все работает асинхронно через HTTP, так что основной цикл не блокируется.
Ну и наконец Arduino Cloud:

3D Печать
Для разработки кастомных шасси, кабины и колес использовался OpenSCAD. Люблю его за параметризацию:

Питание
Для питания самого контроллера и периферии я решил использовать связку из USB-C Power Delivery (PD) донгла и портативного аккумулятора. Выбор пал на проверенный Xiaomi Power Bank емкостью 10 000 мАч. Пока что этой комбинации хватает с лихвой для всех нужд проекта. Система получилась компактной и довольно универсальной. Заряда хватает на пару дней покатушек.
А вот дальше началось самое "веселье", которое, возможно, вызовет легкий диссонанс у опытных инженеров-электронщиков. В целях упрощения схемы и минимизации компонентов, я принял решение запитать моторы напрямую от выходов Arduino. Да, я знаю про риски перегрузки стабилизатора, просадки напряжения, возможные сбои и даже выход платы из строя. Это был осознанный риск.
К моему удивлению (и, признаюсь, некоторому шоку), эксперимент оказался успешным! Проект включает в себя довольно активное использование моторов, в том числе резкие повороты на максимальной скорости. Я ожидал увидеть характерные признаки перегрузки: сброс Arduino, нестабильную работу, "коричневые" просадки напряжения. Но ничего подобного не произошло. Arduino не перегружается, и вся система работает стабильно и предсказуемо.
Протокол команд MCU (важно!)
Arduino понимает три команды:
MOVE (линейное движение)
MOVE|forward|30|45
↑ ↑ ↑
| | скорость (0-90)
| расстояние в см
направление (forward/back)
Калибровка: ~20 см/сек при скорости 45. MCU считает время так:
float cm_per_sec = 20.0 * (mvspd / 45.0);
unsigned long ms = (dist / cm_per_sec) * 1000.0;
TURN (поворот)
TURN|left|60|45
↑ ↑ ↑
| | скорость
| угол в градусах
направление (left/right)
Калибровка: ~30 мс/градус при скорости 45.
STOP
Просто останавливает сервоприводы (пишет 90 в оба).
Грабли версии 2.0
1. WAV-заголовки в аудио
Первая версия записи микрофона не работала, потому что я тупо писал байты в файл без WAV-заголовка. Gemini API отказывался это жрать.
Решение: модуль wave!
with wave.open("mic.wav", "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(16000) wf.writeframes(chunk.tobytes())
Теперь файл открывается везде, и Gemini его понимает.
2. Цветокоррекция RGB
Мой RGB-светодиод — общий катод, и каналы светят с разной яркостью. Зеленый был настолько ярким, что весь робот казался зеленым, даже когда он должен был быть красным.
Решение: костыль в коде!
analogWrite(redPin, r / 1.2);
analogWrite(greenPin, g / 2);
analogWrite(bluePin, b);
Подобрал коэффициенты на глаз. Теперь цвета нормальные.
3. LLM иногда тупит с JSON
Даже с четким промптом Gemini иногда возвращает:
Here's my plan:
{"speak": {"text": "I see a wall"}, "move": ...}
Вместо чистого JSON. Пришлось добавить парсер с регулярками:
# Если не парсится — ищем JSON в тексте m = re.search(r"\{[\s\S]*\}", response_text) if m: return json.loads(m.group(0))
Реальный пример работы
Сценарий: Робот должен найти елку в комнате.
[AGI Loop #1] Distance: 200 cm Vision: Видит стену справа, слева свободно LLM Decision: - speak: "Ищу елку. Поворачиваю налево для сканирования." - rgb: "255,255,0" (желтый — любопытство) - move: {"command": "left", "angle_deg": 45} - plan: "Systematically scan the room by turning and moving forward" - subplan: "Turned left 45 deg, will move forward next" [Робот говорит, записывает ответ (тишина), поворачивает налево] [AGI Loop #2] Distance: 180 cm Vision: Видит что-то зеленое впереди Audio: (пусто) LLM Decision: - speak: "Вижу что-то зеленое. Еду проверить." - rgb: "0,255,0" (зеленый — радость) - move: {"command": "forward", "distance_cm": 50} - subplan: "Green object detected, approaching to verify if it's the tree" [Робот едет вперед] [AGI Loop #3] Distance: 35 cm Vision: Елка крупным планом! LLM Decision: - speak: "Нашел елку! Задание выполнено!" - rgb: "0,255,0" (зеленый) - move: {"command": "stop"} - plan: "Goal completed: Christmas tree found"
Весь процесс занял ~60 секунд и три итерации. Без памяти робот бы просто ехал кругами.
Что дальше?
Робот уже офигенный, но есть куда расти:
IMU (Accelerometer/Gyroscope) — для точной ориентации в пространстве
SLAM с настоящей картой — через OpenCV и фильтр Калмана
Манипулятор — хочу, чтобы робот мог брать вещи
Persistent memory — сохранять карту и историю между запусками
Keyword spotting — реагировать на "Эй, робот!" без постоянной записи
Исправить вот это:


Что по деньгам
Ниже представлена таблица с перечнем всех основных компонентов, их приблизительной стоимостью (в долларах США, исходя из цен на AliExpress) и итоговой суммой.
Arduino Uno Q | Основной микроконтроллер проекта | $44 |
Веб-камера | Дешевая китайская USB-веб-камера (для визуального ввода) | $3 |
USB-донгл (адаптер) | Для подключения веб-камеры и PD | $3 |
Ультразвуковой датчик | Для измерения расстояния (например, HC-SR04) | $1.5 |
Bluetooth-колонка | Для аудиовывода или обратной связи | $2.5 |
Беспаяльная макетная плата и провода | Для прототипирования и коммутации компонентов | $5 |
Пластик для 3D-печати | Около 200 грамм (на корпус, крепления и т.п., при цене ~15$/кг) | $3 |
3D Принтер | Подарок, не считается :) | $0 |
Итоговая стоимость проекта | $64 |
|Как видно из таблицы, общая стоимость проекта составила весьма демократичные 64 доллара США. Это наглядно демонстрирует, что для реализации интересных и функциональных DIY-решений не всегда требуются значительные инвестиции.
Arduino-плата: Основная часть бюджета приходится на контроллер. Выбор более бюджетных плат (например, ESP32 или ESP8266, если функционал позволяет) мог бы еще сильнее снизить эту статью расходов.
Периферия: Веб-камера, донгл, датчик и колонка — это типичные представители недорогих, но вполне функциональных модулей, широко доступных на рынке.
Расходники: Стоимость пластика для 3D-печати и базовых элементов для прототипирования (макетная плата, провода) также минимальна.
Итоги
За два месяца робот эволюционировал от "тупого болтуна на колесах" до почти разумного существа:
✅ Слушает и понимает речь
✅ Говорит на трех языках
✅ Выражает эмоции через цвет
✅ Помнит свои действия
✅ Строит карту комнаты
✅ Планирует стратегию
Это не игрушка, это исследовательская платформа для изучения embodied AI. И самое крутое — весь стек opensource (ну, кроме Gemini API, но его можно заменить на локальный LLaMA)
Код проекта открыт и доступен на GitHub: https://github.com/msveshnikov/agi-robot
Если хотите повторить — пишите, помогу. Если есть Uno Q с вебкамерой, можно попробовать запустить, но придется крутить камеру руками.
---
P.S. Робот вчера попросил меня купить ему "руки, чтобы открывать холодильник". Я сказал "нет". Он покраснел и уехал в угол дуться. Это уже не баг, это фича.
P.P.S. Если кто-то знает, как научить робота мыть посуду — пишите в комментарии. Срочно.

