Привет, Хабр!

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

Ну что ж, я ценю ваше мнение, поэтому данная статья будет посвящена реализации простого и недорого AR решения для отображения параметров системы сбора данных. Если стало интересно, то добро пожаловать под кат!

❯ Начало

Некоторые элементы данной статьи будут пересекаться с контекстом предыдущей, поэтому убедительно прошу ознакомиться с ней, чтобы иметь полное понимание происходящего. Спасибо!

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

❯ Корпус устройства

Корпус устройства не обладает каким-либо сложным конструктивом и выглядит следующим образом:

3D модель корпуса AR монитора
3D модель корпуса AR монитора

Чтобы сэкономить время и не мучиться с подгонкой оптической системы, я использовал в качестве базы готовое решение, в которое внес свои доработки. Благо, после презентации гарнитуры Google Glass в 2015 году, индийские товарищи «наплодили» DIY вариантов подобных корпусов. Ниже представлено более подробное описание элементов устройства:

3D модель корпуса AR монитора
3D модель корпуса AR монитора

Как вы можете видеть, оптическая система состоит из следующих элементов:

  1. Проекционное стекло;

  2. Фокусирующая линза;

  3. Зеркало.

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

Проекционное стекло
Проекционное стекло

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

Фокусирующая линза здесь все просто, данная линза необходима для формирования фокусного расстояния проецируемого изображения, чтобы правильно совместить картинку реальности и проекции. Данная линза взята из дешевого VR бокса и была подпилина под габариты выходного «окна».

Линза и зеркало
Линза и зеркало

Зеркало здесь не все так просто, как показала практика, в качестве зеркала нельзя использовать обычное стеклянное зеркало (как использовали индусы). При использовании обычного зеркала наблюдается большое расслоение проекции из-за двойного отражения. Наилучшим решением является использование металлического зеркала, которое применяется в лазерных системах. Зачастую данные зеркала достаточно дорогие, но для DIY решения вполне подойдет металлическое зеркало изготовленное из алюминиевого «блина» HDD диска, что я и применил на практике.

Как вы можете видеть выше на изображении, на корпусе присутствуют небольшие заслоняющие элементы, которые выглядят как «гармошка». Данные элементы я внес в конструкцию для борьбы с эффектом ореола в проекции, так как свет исходящий от дисплея отражался от стенок корпуса.

❯ Электроника

Принципиальная схема устройства не сложная, в качестве «мозгов» я выбрал модуль ESP32, по большей мере из за наличия Bluetooth интерфейса, а в качестве дисплея был выбран недорогой 0,66 дюймовый OLED модуль с разрешением 64х48. Почему именно данный модуль? он компактнее LCD и обладает большей яркостью пикселя. Ниже приведена принципиальная схема устройства:

Принципиальная схема  AR монитора
Принципиальная схема AR монитора

Для обеспечения питания в данном прототипе, применялся Li-on аккумулятор емкостью 250 mAh, а в качестве модуля зарядки использовалась популярная плата на базе TP4056. Ниже вы можете видеть компоновку элементов электроники в корпусе устройства:

Компоновка электроники
Компоновка электроники

Вид спереди:

Вид спереди
Вид спереди

❯ Программное обеспечение

Функционал ПО обеспечивает обмен данными в формате JSON между смартфоном наладчика и AR монитором с помощью мобильного приложения системы сбора данных.

Микро ПО AR монитора разрабатывалось в среде Arduino IDE и не отличается какой-то сложностью. Ниже представлен код устройства:

Main
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <ArduinoJson.h>
#include <Wire.h> 
#include "SSD1306Wire.h"              // Использую русифицированную библиотеку дисплея

SSD1306Wire  display(0x3c, 4, 5);
#define batPin 34
#define DURATION 10000

int battery = 0;
float bat   = 0;
int count   = 0;
float data  = 0;
char *unit  = ""; 
char *leg  = ""; 
long timeSinceLastModeSwitch = 0;



BLEServer *pServer = NULL;
BLECharacteristic *pCharacteristic = NULL;
bool deviceConnected = false;
uint8_t txValue = 0;

#define SERVICE_UUID        "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"

void send_json(String json){
      dsjson(json);
}

class MyServerCallbacks : public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
    }

    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
    }
};



class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) {
        std::string rxValue = pCharacteristic->getValue();
        if (!rxValue.empty()) {
            send_json(rxValue.c_str());              // Обработка полученных данных
        }
    }
};

void setup() {
  display.init();
  display.flipScreenVertically();
  display.setFont(ArialMT_Plain_10);
  BLEDevice::init("AR Monitor");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());
  
  BLEService *pService = pServer->createService(BLEUUID(SERVICE_UUID));
  pCharacteristic = pService->createCharacteristic(
                      BLEUUID(CHARACTERISTIC_UUID),
                      BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_WRITE
                    );

  pCharacteristic->addDescriptor(new BLE2902());
  pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
  
  pService->start();
  
  BLEAdvertising *pAdvertising = pServer->getAdvertising();
  pAdvertising->start();
}

void loop() { 
   process();
}

DataProcess
void process(){
     if (millis() - timeSinceLastModeSwitch > DURATION) {
     int anbat = map(analogRead(batPin), 0, 4095, 0, 420);
           bat = anbat*0.01;
     int bata  = bat*100;
       battery = map(bata, 257, 419, 0, 100);
    timeSinceLastModeSwitch = millis();
  }
   if (!deviceConnected) {
       display_text(18,"УСТРОЙСТВО ГОТОВО К ПОДКЛЮЧЕНИЮ","CYBRX","tech", battery);   
    }else{
       display_text(18, leg, String(data), unit, battery);
    } 
   
delay(10);
}

void dsjson(String json){
  StaticJsonDocument<200> doc;
  deserializeJson(doc, json);
 
  leg       = doc["legend"];  // Имя отображаемого параметра
  data      = doc["data"];    // Значение параметра
  unit      = doc["unit"];    // Единица измерения 
}

DisplayProcess
void display_text(int posY, String texts, String data_bt, String unit_bt, int bat_2){
     int co = texts.length()*6;
     int point;
     int positionLine;
     if(co > 120){
        count++;
        point = co - count;
        positionLine = point;
     if(count > co+60){
        count = 0;
        }
     }else { 
       positionLine = 64;
     }
    display.clear();
    display.setTextAlignment(TEXT_ALIGN_CENTER);
    display.setFont(Font5x7);
    display.drawString(positionLine, posY, texts);
    display.setFont(ArialMT_Plain_16);
    display.drawString(64, 32, data_bt);
    display.setFont(Font5x7);
    display.setTextAlignment(TEXT_ALIGN_RIGHT);
    display.drawString(96, 56, unit_bt);
    if(bat != 0){
    display.drawProgressBar(32, 56, 20, 6, bat_2);
       }
    display.display(); 
}

➤ Обмен в мобильном приложении

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

Класс поиска BLE устройства
public class BleScanner {
    private BluetoothAdapter bluetoothAdapter;
    private BluetoothLeScanner bluetoothLeScanner;
    private boolean scanning;
    private ScanCallback scanCallback;
    private Handler handler;
    private ScanResultListener scanResultListener;

    public BleScanner() {
        bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        bluetoothLeScanner = bluetoothAdapter.getBluetoothLeScanner();
        scanning = false;
        handler = new Handler(Looper.getMainLooper());
        setupScanCallback();
    }

    public void setScanResultListener(ScanResultListener listener) {
        this.scanResultListener = listener;
    }

    private void setupScanCallback() {
        scanCallback = new ScanCallback() {
            @SuppressLint("MissingPermission")
            @Override
            public void onScanResult(int callbackType, ScanResult result) {
                super.onScanResult(callbackType, result);
                BluetoothDevice device = result.getDevice();
                if (device.getName() != null) {                                                     // Пропускаем отправку в слушетель устройства не с именем AR Monitor
                    String dev;
                    try {
                        dev = convert_to_utf_8(device.getName());                                   // Проверяем на кириллицу
                    } catch (UnsupportedEncodingException e) {
                        throw new RuntimeException(e);
                    }
                    if (dev.equals("AR Monitor")) {                                             // Если нашли наше устройство, то отправляем его в слушатель
                         scanResultListener.onDeviceFound(device);
                         stopScan();
                    }
                }
            }

            @Override
            public void onBatchScanResults(List<ScanResult> results) {
                super.onBatchScanResults(results);
                // Handle batch scan results if needed
            }

            @Override
            public void onScanFailed(int errorCode) {
                super.onScanFailed(errorCode);
                // Handle scan failure
                scanResultListener.onScanFailed(errorCode);
            }
        };
    }

    @SuppressLint("MissingPermission")
    public void startScan() {
        if (!scanning && bluetoothLeScanner != null) {
            scanning = true;
            bluetoothLeScanner.startScan(scanCallback);
            handler.postDelayed(this::stopScan, 10000);                                    // Останавливаем сканирование после 10 сек
        }
    }

    @SuppressLint("MissingPermission")
    public void stopScan() {
        if (scanning && bluetoothLeScanner != null) {
            scanning = false;
            bluetoothLeScanner.stopScan(scanCallback);
        }
    }

    public interface ScanResultListener {
        void onDeviceFound(BluetoothDevice device);

        void onScanFailed(int errorCode);
    }

    private  String convert_to_utf_8(String data) throws UnsupportedEncodingException {
        String return_data = "";
        if(data !=null) {
            byte[] ptext = data.getBytes(getEncoding(data));
            return_data =  new String(ptext, StandardCharsets.UTF_8);;
        }
        return return_data;
    }
    public static String getEncoding(String str) {
        String encode = "GB2312";
        try {
            if (str.equals(new String(str.getBytes(encode), encode))) {
                return encode;
            }
        } catch (Exception ignored) {}
        encode = "ISO-8859-1";
        try {
            if (str.equals(new String(str.getBytes(encode), encode))) {
                return encode;
            }
        } catch (Exception ignored) {}
        encode = "UTF-8";
        try {
            if (str.equals(new String(str.getBytes(encode), encode))) {
                return encode;
            }
        } catch (Exception ignored) {}
        encode = "GBK";
        try {
            if (str.equals(new String(str.getBytes(encode), encode))) {
                return encode;
            }
        } catch (Exception ignored) {}
        return "";
    }
}

Поиск устройства запускается с помощью метода startScan() и, в случае наличия нашего AR монитора поблизости, возвращает MAC адрес нашего устройства для инициализации подключения. Далее полученный MAC адрес сохраняется в памяти приложения. Для работы с BLE подключением, реализован следующий класс:

Класс управления BLE подключением
public class BLEManager {

    private static final UUID SERVICE_UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b");
    private static final UUID CHARACTERISTIC_UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361b26a8");

    private final Context context;
    private BluetoothAdapter bluetoothAdapter;
    private BluetoothGatt bluetoothGatt;
    private BluetoothGattCharacteristic characteristic;
    private boolean connected;
    public BLEManager(Context context) {
        this.context = context;
        BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
        if (bluetoothManager != null) {
            bluetoothAdapter = bluetoothManager.getAdapter();
        }
    }

    @SuppressLint("MissingPermission")
    public void connectToDevice() {
        if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
            return;
        }
        // Адрес ESP32
        String DEVICE_ADDRESS = new MySharedPreferences(context).getString("VrMAC", "00:00:00:00:00");
        if(!Objects.equals(DEVICE_ADDRESS, "00:00:00:00:00")) {
            BluetoothDevice device = bluetoothAdapter.getRemoteDevice(DEVICE_ADDRESS);
            bluetoothGatt = device.connectGatt(context, false, gattCallback);
        }
    }

    private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
        @SuppressLint("MissingPermission")
        @Override
        public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
            super.onConnectionStateChange(gatt, status, newState);
            if (newState == BluetoothGatt.STATE_CONNECTED) {
                connected = true;
                gatt.discoverServices();
            } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
                connected = false;
            }
        }

        @Override
        public void onServicesDiscovered(BluetoothGatt gatt, int status) {
            super.onServicesDiscovered(gatt, status);
            if (status == BluetoothGatt.GATT_SUCCESS) {
                BluetoothGattService service = gatt.getService(SERVICE_UUID);
                characteristic = service.getCharacteristic(CHARACTERISTIC_UUID);
            }
        }
        @Override
        public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
            super.onCharacteristicChanged(gatt, characteristic);
            if (CHARACTERISTIC_UUID.equals(characteristic.getUuid())) {
                byte[] data = characteristic.getValue();
                String dataStr = new String(data);                                                  // Здесь можно обработать полученные данные
            }
        }
    };

    @SuppressLint("MissingPermission")
    public void sendData(String data) {
        if (bluetoothGatt != null && characteristic != null) {
            characteristic.setValue(data.getBytes());
            bluetoothGatt.writeCharacteristic(characteristic);

        }
    }

    @SuppressLint("MissingPermission")
    public void disconnect() {
        if (bluetoothGatt != null) {
            bluetoothGatt.disconnect();
            bluetoothGatt.close();
        }
    }
    public boolean isConnected() {
        return connected;
    }
}

Для подключения к устройству используется метод connectToDevice(), а для передачи данных на устройство используется метод sendData(), где в качестве аргумента передается строка в формате JSON. Ниже представлена функция передачи для данных на устройство:

Функция передачи данных на AR монитор
private void sendToAr(String legend, float data, String unit){
    if(bleManager.isConnected) {
      JSONObject json = new JSONObject();
                 json.put("legend", legend);
                 json.put("data",     data);
                 json.put("unit",     unit);
      
      bleManager.sendData(json.toString()); // Отправка JSON по BLE
    }
  }

Данная функция реализована в Foreground Service в котором выполняется циклический запрос требуемого параметра из системы сбора данных, а полученные данные передаются в устройство с помощью выше описанной функции. Активация Foreground сервиса в приложении выполняется с помощью элемента «переключатель» «Трансляция данных в AR устройство».

❯ Итоги

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

  • Микроконтроллер ESP-32S - $2,26;

  • Дисплейный модуль SSD1306 - $2,06;

  • Аккумулятор Li-po 250mAh - $2,04;

  • Модуль заряда TP4056 - $1,12 (за 5 шт);

  • Остальные компоненты - $1;

    Итоговая стоимость компонентов: ~ $7,6.

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

Испытание первого прототипа в 2020 году
Испытание первого прототипа в 2020
Испытание первого прототипа в 2020

PS: Данное решение не обошло стороной и моё хобби: я давно катаюсь на моноколесе и решил применить данный AR монитор для отображения телеметрии, предварительно добавив в приложение WheelLog пару классов для работы с устройством, результат мне очень понравился.

Ссылки к статье:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале 

Перейти ↩

Комментарии (4)


  1. Tomasina
    05.11.2024 08:20

    Нет самого интересного - что же видит человек процессе работы.


    1. CyberexTech Автор
      05.11.2024 08:20

      В предыдущей статье было показано. Там фото и видео работы.


    1. sim2q
      05.11.2024 08:20

      что же видит человек процессе работы


  1. buldo
    05.11.2024 08:20

    Кажется на Ali за адекватные деньги продаются правильные стекла. Типа 500р за 5 штук. А вот полуотражающие стекла уже дороже - 1500р за штуку.