Ни для кого не секрет, что IoT‑устройства собирают огромное количество информации, в том числе личные данные пользователей и фиксируют их местоположение. А также они жёстко привязаны к конкретному производителю, т.е. пользователь зависим от конкретного производителя или бренда. Гаджеты совместимы с парой основных систем, но кроссплатформенность отсутствует. Устройства разных производителей часто не могут взаимодействовать друг с другом без дополнительных шлюзов. Универсального протокола связи устройств тоже нет. Из стандартов: Zigbee, Matter, Z‑Wave, Bluetooth, Wi-Fi — как следствие умные устройства плохо дружат между собой.

Есть ли выход из подобной ситуации?

Конечно да.

В прошлой статье я говорил о экосистеме MixRech с поисковиком и мессенджером. Ссылка тут. И там есть мессенджер MixChat, который управляет умными устройствами с помощью системного чат бота SmarMix в самом же MixChat.

SmartMix объединяет умные устройства в один стандарт MQTT

Мессенджер MixChat и микроконтроллер ESP32 взаимодействуют друг с другом по протоколу MQTT.

Вам потребуется собственный MQTT сервер (например, на Raspberry Pi или в облаке), но после разового подключения, вам не нужно будет настраивать SmartMix.

Как это работает?

Зайдя в чат со SmartMix, вы вводите параметры MQTT сервера по кнопке вверху и сохраняете. Потом вы подключаетесь к своему серверу и можете отправлять команды, для управления ESP32: включать/выключать светодиод, выводить температуру на экран.

Введите параметры сервера
Введите параметры сервера
Поздравляем! Теперь вы можете управлять устройством.

SmartMix не привязан к конкретному производителю. А это означает, что вы можете управлять разными устройствами без дополнительных настроек.

ESP32 не собирает данные пользователей

Когда вы отправляете сообщение SmartMix, ваши личные данные: пароли, номера телефонов, электронные почты или цифровые ID людей не используются для передачи.

Когда вы вводите параметры своего MQTT сервера, в.т.ч. и топики для отправки и получения, они шифруются и сохраняются в кэш по API запросу:

class SaveMqttView(generics.GenericAPIView):
    serializer_class = SaveMqttSerializer
    authentication_classes = [JWTAuthentication, CsrfExemptSessionAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = request.user.id
        server = serializer.validated_data['server']
        port = serializer.validated_data['port']
        client_id = serializer.validated_data['client_id']
        mqtt_username = serializer.validated_data['mqtt_username']
        mqtt_password = serializer.validated_data['mqtt_password']
        topic_sent = serializer.validated_data['topic_sent']
        topic_subscribe = serializer.validated_data['topic_subscribe']
        mqtt_values = list(request.session.values())
        for value in mqtt_values:
            if value == server or value == mqtt_username:
                return Response({"message": "Ошибка. Пользователь не может подключиться"
                                 " к другому серверу"}, status=403)
        key = gen_key(user, salt)
        request.session[f'server_{user}'] = encrypt_text(server, key).decode('utf-8')
        request.session[f'port_{user}'] = port
        request.session[f'client_id_{user}'] = client_id
        request.session[f'mqtt_username_{user}'] = encrypt_text(mqtt_username, key).decode('utf-8')
        request.session[f'mqtt_password_{user}'] = encrypt_text(mqtt_password, key).decode('utf-8')
        request.session[f'topic_sent_{user}'] = topic_sent
        request.session[f'topic_subscribe_{user}'] = topic_subscribe
        return Response({"message": "Данные MQTT успешно сохранены"})

Данные MQTT-сервера расшифровываются только на сервере при подключении, после чего вы подключаетесь к серверу.

В боте за подключение к серверу отвечает класс MQTT. Вот его ключевые методы:

connection() — устанавливает связь с сервером.

publish(msg) — отправляет команду в топик topic_sent.

subscribe() — слушает топик topic_subscribe, чтобы получить ответ от ESP32.

Полный код класса выглядит так:

Скрытый текст
import os
import time
import sys
from dotenv import load_dotenv
from paho.mqtt import client as mqtt_client
from .cifer import gen_key, decrypt_text


load_dotenv()
salt = os.getenv('CIFER_SALT')


class MQTT:
    def __init__(self, mqtt_dict, client, user_id, exit_flag, msg):
        key = gen_key(user_id, salt)
        self.client = client
        self.exit_flag = exit_flag
        self.msg = msg
        self.server = decrypt_text(mqtt_dict[f'server_{user_id}'], key).decode('utf-8')
        self.port = mqtt_dict[f'port_{user_id}']
        self.mqtt_username = decrypt_text(mqtt_dict[f'mqtt_username_{user_id}'], key).decode('utf-8')
        self.mqtt_password = decrypt_text(mqtt_dict[f'mqtt_password_{user_id}'], key).decode('utf-8')
        self.topic_sent = mqtt_dict[f'topic_sent_{user_id}']
        self.topic_subscribe = mqtt_dict[f'topic_subscribe_{user_id}']

    def connection(self):
        self.client.username_pw_set(self.mqtt_username, self.mqtt_password)
        # self.client.on_connect = "Connected to MQTT Broker!"
        if self.server and self.port:
            self.client.connect(self.server, int(self.port))
            print("Connect to server")
        else:
            print("Ошибка отправки. Не верные данные сервера")

    def publish(self, msg):
        # Отправить сообщение в MQTT
        if self.topic_sent:
            result = self.client.publish(self.topic_sent, msg)
            status = result[0]
            if status == 0:
                print(f"Отправлено `{msg}` в топик `{self.topic_sent}`")
            else:
                print(f"Не удалось отправить сообщение в топик {self.topic_sent}")
        else:
            print("Ошибка. Неверный топик для отправки")

    def on_connect(self, client, userdata, flags, rc):
        if rc == 0:
            self.client.subscribe(self.topic_subscribe)
        else:
            print("Failed to connect, return code %d\n", rc)

    def subscribe(self):
        try:
            if self.server and self.port:
                self.client.username_pw_set(self.mqtt_username, self.mqtt_password)
                self.client.on_connect = self.on_connect
                self.client.on_message = self.on_message
                self.client.connect(self.server, int(self.port))
                self.client.loop_start()  # Запускаем цикл в фоне
                start = time.time()
                while not self.exit_flag:
                    time.sleep(0.1)
                self.exit_flag = False
                print(time.time() - start)
                self.client.loop_stop()  # Останавливаем цикл
                print(self.client.is_connected)
                self.client.disconnect()
            else:
                print("Ошибка получения. Не верные данные сервера")

        except KeyboardInterrupt:
            print("Соединение прервано")
            sys.exit()

    def on_message(self, client, userdata, message):
        self.msg = message.payload.decode()
        print('MQTT MESSAGE:', message.payload.decode())
        self.exit_flag = True

    def print_message(self):
        print(self.msg)
        return self.msg


def return_mqtt(mqtt_dict, user_id):
    mqtt = MQTT(mqtt_dict, mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION1,
                mqtt_dict[f'client_id_{user_id}']), user_id,False, '')
    return mqtt

Полный код всего проекта с поисковиком и мессенджером здесь.

После подключения к MQTT-серверу вы отправляете сообщение по MQTT:

mqtt_dict = dict(request.session.items())
send_to_mqtt(mqtt_dict, sender_id, content) 
def send_to_mqtt(mqtt_dict, user_id, msg):
    mqtt = return_mqtt(mqtt_dict, user_id)
    mqtt.connection()
    mqtt.publish(msg)

Сообщение доходит до ESP32, срабатывает внутренняя логика на ESP32. С ESP32 вы получаете только технические показатели (например, значение температуры).

ESP32 также подключается к MQTT и использует следующие методы и функции:

connect - устанавливает связь с сервером.

set_callback - обрабатывает полученное сообщение и отправляет ответ в топик topic_subscribe.

on_message - ловит полученную команду от бота SmartMix

subscribe - слушает топик topic_sent для получения сообщения от бота SmartMix.

check_msg - проверяет полученные сообщения из топика topic_sent в бесконечном цикле.

Полный код работы ESP32 с MQTT:

Скрытый текст
import os
import network
import time
from umqtt.simple import MQTTClient
from micro import flashing_led, led_off, show_temperature


print(os.getcwd())
print(os.listdir())


server = 'MQTT_SERVER'
port = 'MQTT_PORT'
topic = 'MQTT_TOPIC'
topic_sub = 'MQTT_TOPIC_ANSWER'
client_id = 'MQTT_CLIENT_ID'
username = 'MQTT_USERNAME'
password = 'MQTT_PASSWORD'
sta_if = network.WLAN(network.STA_IF)


if not sta_if.isconnected():
    # Подключение к Wi-Fi
    print("Connect to  Wi-Fi...")
    sta_if.active(True)
    print('Wi-Fi Interface states:')
    print('Active:', sta_if.active())
    print('Connected:', sta_if.isconnected())
    timeout = 20
    start = time.time()
    sta_if.connect('WIFI_SSID', 'WIFI_PASSWORD')
    while not sta_if.isconnected():
        print("Wating...")
        time.sleep(1)
        if time.time() - start > timeout:
            print("Connection is timeout")
            break
    print("Connected. IP:", sta_if.ifconfig())
else:
     # Подключение к Wi-Fi установлено
    print("Already connected, IP:", sta_if.ifconfig())


def on_message(topic, msg):
    answer = ''
    # Обработка сообщений
    print(msg)
    if msg == b'led_on':
        flashing_led()
        answer = 'Светодиод включён'
        client.publish(topic_sub, bytes(answer, 'utf-8'))
        print(answer)
    elif msg == b'led_off':
        led_off()
        answer = 'Светодиод выключен'
        client.publish(topic_sub, bytes(answer, 'utf-8'))
        print(answer)
    elif msg == b'temp':
        show_temperature()
        answer = show_temperature()
        print(answer)
        client.publish(topic_sub, bytes(answer, 'utf-8'))


client = MQTTClient(client_id, server, port, username, password)
client.connect()
client.set_callback(on_message)
client.subscribe(topic)
last_ping = time.time()
ping_interval = 30

while True:
    client.check_msg()
    now = time.time()
    if now - last_ping > ping_interval:
        client.ping()
        last_ping = now

Полный код для всего ESP32 здесь.

И в конце вы получаете зашифрованное сообщение от ESP32: "Светодиод включён", "Светодиод выключен" или значение температуры.

salt = os.getenv('CIFER_SALT')
key = gen_key(chat_id, salt)

message_content = get_mqtt(mqtt_dict, sender_id)
message_bot = Message(
    sender_bot_id=bot.id,
    content=encrypt_text(message_content, key),
    chat_id=chat_id,
)
def get_mqtt(mqtt_dict, user_id):
    mqtt = return_mqtt(mqtt_dict, user_id)
    mqtt.subscribe()
    return mqtt.print_message()

Для хранения данных сервера в кэше я использовал Redis, который развёрнут в контейнере и защищён паролем. Контейнер с Redis запускается вместе с поисковиком, мессенджером и Nginx.

Полный код:

Скрытый текст
version: '3.8'

services:
   mixrech:
      build: ./mixrech
      command: sh -c "python manage.py makemigrations && python manage.py migrate && python manage.py collectstatic --noinput && gunicorn mixrech.wsgi:application --bind 0.0.0.0:8000 --timeout 120"
      volumes:
      - .:/app
      - mixrech_static:/mixrech/staticfiles/
      
      env_file:
      - .env
      ports:
         - "8000:8000"

   mixchat:
      build: ./mixchat
      command: sh -c "python manage.py migrate && python manage.py collectstatic --noinput --verbosity 2 && gunicorn mixchat.wsgi:application --bind 0.0.0.0:8001 --timeout 120"
      volumes:
      - .:/app
      - mixchat_static:/mixchat/staticfiles/
      - media_data:/mixchat/main/media_v4/
      environment:
      - MEDIA_ROOT=/mixchat/main/media_v4/
      env_file:
      - .env
      ports:
         - "8001:8001"

   redis:
     image: redis:7-alpine
     container_name: mixchat_redis
     restart: unless-stopped
     ports:
      - "6379:6379" 
     volumes:
      - redis_data:/data
     command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes 
     networks:
      - default
     environment:
      - REDIS_PASSWORD=${REDIS_PASSWORD}
         
   nginx:
      image: nginx:1.25.3
      volumes:
         - ./nginx.conf:/etc/nginx/nginx.conf:ro
         - media_data:/app/media/
         - mixrech_static:/app/staticfiles/mixrech/
         - mixchat_static:/app/staticfiles/mixchat/
         - /etc/letsencrypt/live/mixrech.com/fullchain.pem:/etc/letsencrypt/live/mixrech.com/fullchain.pem
         - /etc/letsencrypt/live/mixrech.com/privkey.pem:/etc/letsencrypt/live/mixrech.com/privkey.pem
         
      depends_on:
        - mixrech
        - mixchat
      ports:
        - "80:80"
        - "443:443"

volumes:
   mixrech_static:
   mixchat_static:
   media_data:
   redis_data:

Архитектура шифрования

Соль (CIFER_SALT) для генерации ключа хранится на сервере в переменных окружениях и недоступна клиенту. Это исключает утечку паролей.

Шифрование симметричное и происходит оно во всех чатах.

import base64
from dotenv import load_dotenv
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt


load_dotenv()


def gen_key(id, salt):
    kdf = Scrypt(salt=salt.encode('utf-8'), length=32, n=2**14, r=8, p=1)
    key = kdf.derive(str(id).encode())
    return base64.urlsafe_b64encode(key)


def encrypt_text(text, key):
    fer = Fernet(key)
    encrypt_data = fer.encrypt(text.encode('utf-8'))
    return encrypt_data


def decrypt_text(text, key):
    fer = Fernet(key)
    decrypt_data = fer.decrypt(text)
    return decrypt_data


def encrypt_file(file_path, key):
    fer = Fernet(key)

    with open(file_path, 'rb') as f:
        data = f.read()

    encrypt_data = fer.encrypt(data)

    with open(file_path, 'wb') as f:
        f.write(encrypt_data)


def decrypt_file(file_path, key):
    fer = Fernet(key)

    with open(file_path, 'rb') as f:
        data = f.read()
    decrypt_data = fer.decrypt(data)

    with open(file_path, 'wb') as f:
        f.write(decrypt_data)

Итог

Чат-бот SmartMix управляет устройством без привязки к производителю, а само устройство не собирает данные пользователей.

Буду рад вопросам!