Взаимодействовать с OpenAI несложно. Их API хорошо задокументирован в спецификации OpenAPI и поэтому доступен через простые HTTP-запросы.

Регистрация и получение ключа API также тривиальны.

На стороне ESP32 есть пакет openai, реализующий нужный нам функционал. Это простая оболочка вокруг API OpenAI, реализующая завершение (чат).

Чат

Чат — наиболее очевидный вариант использования OpenAI. Несмотря на то, что реализовать его относительно просто, можно получить преимущество, используя пакет chatbot.

Единственная оставшаяся задача — общаться с пользователем. Это можно сделать с помощью обычных чат-сервисов. В пакете чат-бота есть примеры интеграций с Telegram и Discord.

Простой пример HTTP также показывает, что нет необходимости использовать службу чата, чтобы использовать функциональность чата OpenAI.

Делаем устройства интеллектуальными

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

Пакет device_bot упрощает создание таких устройств. Как пользователь, необходимо указать функциональность устройства. Библиотека берет на себя все остальное.

В нашем примере у нас есть устройство с тремя периферийными устройствами:

  • Два светодиода (зеленый на контакте 23 и красный на контакте 22)
  • Датчик температуры/влажности (DHT11 на контакте 32)

Мы хотим предоставить OpenAI следующие функции:

  • green_led(<true|false>)
  • red_led(<true|false>)
  • temperature()
  • humidity()

Прежде чем мы начнем писать код, давайте установим нужные нам пакеты. Мы предполагаем, что вы уже установили Ягуар.

jag pkg init
jag pkg install toit-dhtxx
jag pkg install device_bot
jag pkg install telegram

Теперь мы можем начать наш main.toit, добавив следующие строки import вверху файла:

import telegram
import device_bot show *
import gpio
import dhtxx.dht11

В качестве следующего шага давайте реализуем функции устройства, которые будут доступны для OpenAI:

LED_GREEN_PIN ::= 23
LED_RED_PIN ::= 22

class Leds:
  pin_green_/gpio.Pin
  pin_red_/gpio.Pin

  constructor:
    pin_green_ = gpio.Pin LED_GREEN_PIN --output
    pin_red_ = gpio.Pin LED_RED_PIN --output

  functions -> List:
    return [
      Function
          --syntax="green_led(<true|false>)"
          --description="Turns the green LED on or off."
          --action=:: | args/List |
            pin_green_.set (args[0] ? 1 : 0),
      Function
          --syntax="red_led(<true|false>)"
          --description="Turns the red LED on or off."
          --action=:: | args/List |
            pin_red_.set (args[0] ? 1 : 0),
    ]

Для простоты мы следуем шаблону примера кода для объявления периферийных устройств. Мы заключаем код в класс и раскрываем функциональность через метод functions.

Класс Function используется для объявления функциональности устройства. Он принимает три именованных аргумента: syntax, description и action. Первые два предоставляют больше информации о функции, тогда как action argument — это лямбда, вызываемая при вызове функции.

И syntax, и description отправляются в OpenAI. Чем более они интуитивно понятны, тем больше вероятность того, что OpenAI поймет функцию.

syntax дополнительно используется пакетом device_bot для вывода количества аргументов.

Аргумент action — это лямбда, которая вызывается при вызове функции. Он получает список аргументов и возвращает результат. По функциям светодиода возвращать нечего, поэтому с этим не заморачивались.

В случае с DHT11 мы должны вернуть температуру и влажность. Вот как это выглядит:

DHT11_PIN ::= 32

class Dht11Sensor:
  data_/gpio.Pin
  sensor_/dht11.Dht11

  constructor:
    data_ = gpio.Pin DHT11_PIN
    sensor_ = dht11.Dht11 data_

  close:
    data_.close

  functions -> List:
    return [
      Function
          --syntax="temperature()"
          --description="Reads the temperature in C as a float"
          --action=:: sensor_.read_temperature,
      Function
          --syntax="humidity()"
          --description="Reads the humidity in % as a float"
          --action=:: sensor_.read_humidity,
    ]

Теперь нам нужно все соединить. Как видно из установки пакета, мы будем использовать Telegram, но подойдут любые средства связи с устройством.

main --openai_key/string --telegram_token/string:
  leds := Leds
  dht11_sensor := Dht11Sensor

  // Connect to Telegram
  telegram_client := telegram.Client --token=telegram_token

  // Keep track of the last chat-id we've seen.
  // A more sophisticated bot would need to make sure that
  // only authenticated users can manipulate the device.
  chat_id/int? := null

  // Give the device a way to send messages to us.
  functions := [
    Function
      --syntax="print(<message>)"
      --description="Print a message"
      --action=:: | args/List |
        message := args[0]
        telegram_client.send_message --chat_id=chat_id "$message"
  ]
  functions.add_all leds.functions
  functions.add_all dht11_sensor.functions

  // Create a device bot.
  device_bot := DeviceBot --openai_key=openai_key functions

  // Start listening to new messages and interpret them.
  telegram_client.listen: | update/telegram.Update |
    if update is telegram.UpdateMessage:
      print "Got message: $update"
      message/telegram.Message? := (update as telegram.UpdateMessage).message
      if message.text == "/start":
        continue.listen

      chat_id = message.chat.id
      device_bot.handle_message message.text --when_started=::
        telegram_client.send_message --chat_id=chat_id "Running"

Вот и все. После запуска кода на устройстве (jag run main.toit) мы можем отправлять команды на устройство через Telegram.

Под капотом

Архитектура

Устройство, которое мы используем, представляет собой стандартную плату ESP32. Сам чип очень дешевый и его можно купить примерно за 1 доллар США. Полная плата, как видно на видео, ненамного дороже.

Однако, в отличие от реальных компьютеров, у ESP32 есть некоторые ограничения. Несмотря на то, что они довольно быстрые (два ядра работают на частоте 240 МГц), у них очень мало памяти. Используемый нами ESP32 имеет всего 520 КБ ОЗУ, из которых 320 КБ доступны приложению. Это немного. Компьютер, на котором я это пишу, имеет 64 ГБ ОЗУ, что составляет более 100 000 раз. Таким образом, память является основным ограничением ESP32 и этого проекта.

К ESP32 прикреплен датчик температуры/влажности DHT11 и два светодиода. DHT11 — это датчик, который может измерять температуру и влажность. Это не очень хорошо, но дешево и широко распространено. Светодиоды представляют собой стандартные светодиоды, соединенные последовательно с токоограничивающими резисторами. (Как правило, резистор на 330 Ом является хорошим выбором для светодиодов, подключенных к 3,3 В.)

Общение с пользователем осуществляется через Telegram. Их бот
API очень удобен в использовании, и с ним легко начать работу. Бот взаимодействует с ESP32 через HTTP-запросы (так же, как браузеры используют для получения веб-страниц). Самая большая проблема здесь заключается в том, что связь должна осуществляться через HTTPS (зашифрованную версию HTTP). Соединение HTTPS занимает много памяти, которой, как упоминалось ранее, на ESP32 недостаточно.

Запросы к OpenAI также выполняются через запросы HTTPS, что очень похоже на то, как ESP32 взаимодействует с Telegram.

OpenAI используется для преобразования запросов пользователя в код. Однако нам все еще нужно разобраться в возвращаемом коде. Это делает простой интерпретатор. Сначала он анализирует код и строит AST (абстрактное синтаксическое дерево). Каждый узел AST имеет функцию eval, и программа рекурсивно оценивается путем вызова eval на корневом узле AST. Этот подход неэффективен, но прекрасно работает для небольших программ (именно с ними мы имеем дело). Он прост в исполнении и не занимает много места.

Жизнь запроса

В качестве первого шага ESP32 подключается к Telegram и прослушивает новые сообщения. Когда приходит новое сообщение, текст сообщения извлекается и передается классу DeviceBot.

Затем класс DeviceBot создает поддельный диалог с этим сообщением. Он добавляет к сообщению запроса некоторый контекст, что позволяет чат-боту OpenAI разумно отвечать. На момент написания контекстные сообщения были следующими:

Сообщение с описанием

Given a simplified C-like programming language with the following builtin functions:
- print(<message>).
- sleep(<ms>).
- to_int(<number or string>). Converts the given number or string to an integer.
- random(<min>, <max>).
- now(): Returns the ms since the epoch.
- List functions: 'list_create()', 'list_add(<list>, <value>)', 'list_get', 'list_set', 'list_size'.
- Map function: 'map_create()', 'map_set(<map>, <key>, <value>)', 'map_get', 'map_contains', 'map_keys', 'map_values', 'map_size'.
    'map_get' throws an error if the key is not in the map.
    Keys can be integers or strings.

Example:
```
// Create a map from numbers to their squares.
let map = map_create();
let i = 0;
let sum = 0;
while (i < 10) {
    map_set(map, i, i * i);
    sum = sum + i * i;
    i = i + 1;
}
// Print the map.
print(map);
// Print the square of 5.
print(map_get(map, 5));

let keys = map_keys(map);
// Print the element in the middle of the key list.
print(list_get(keys, to_int(list_size(keys) / 2)));
// Print the average of the squares.
print(sum / list_size(keys));
```

This language is *not* Javascript. It has no objects (not even 'Math') or self-defined functions. No 'const'.

Сообщение о функциях

The language furthermore has the following functions:
<FUNCTIONS>
Under no circumstances use any function that is not on the builtin list or this list!

Сообщение запроса

Write a program that implements the functionality below (after '===').
Only respond with the program. Don't add any instructions or explanations.
====
<REQUEST>

<FUNCTIONS> заменяется синтаксисом и описаниями функций, переданных классу DeviceBot. <REQUEST> заменяется полученным сообщением запроса.

Эти сообщения отправляются чат-боту OpenAI, который отвечает программой. Например, запрос «Мигать зеленым светодиодом с частотой 1 Гц в течение 5 секунд» может дать следующий ответ:

// Blink the green LED at 1Hz for 5 seconds.
let start = now();
while (now() - start < 5000) {
  green_led(true);
  sleep(500);
  green_led(false);
  sleep(500);
}

Эта программа загружается в интерпретатор, который анализирует ее и создает AST (абстрактное синтаксическое дерево):

У каждого узла в этом AST есть метод eval, который выполняет соответствующую операцию. Например, узел Program оценивает список statements, а узел While оценивает узлы condition и body.

В качестве примера, вот код для узла If:

eval scope/List [brek] [cont]:
  if condition.eval scope:
    return then.eval scope brek cont
  else if els:
    return els.eval scope brek cont
  return null

scope содержит переменные, определенные в текущей области. Переменные brek и cont используются для реализации операторов break и continue.

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

Заключение

Эта статья дала вам хорошее представление о том, что возможно с OpenAI на ESP32. Если вы хотите попробовать это сами, вы можете найти код на GitHub.

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

Надеюсь, вам понравилось читать об этом так же, как мне понравилось писать.