Z reguły przyjęło się, że brokerem MQTT musi być wydajny serwer, usługa chmurowa, albo co najmniej mikrokomputer w stylu Raspberry Pi. Pytanie jednak brzmi - czy na pewno? Czy nie dałoby się zrealizować prostego serwera MQTT na układzie pokroju ESP32 lub BK7231, przy ograniczonym rozmiarze pamięci RAM, powiedzmy, do kilkunastu kilobajtów? W tym temacie postaram się pokazać, że jest to możliwe i MQTT wcale nie jest tak zasobożernym protokołem, jakby się to mogło wydawać. Przedstawię tu moje rozważania implementacyjne, napotkane problemy i zabiegi, jakie zastosowałem, by zmniejszyć użycie pamięci, a potem przedstawię efekty mojej pracy.
Serwerek opracuję w moim środowisku OBK, co oznacza, że będzie on działać na 32 platformach (w tym na BK7231, ESP32 i Windows):
Wieloplatformowy firmware IoT wspierający aż 32 platformy - podsumowanie OBK 2025
Czym jest MQTT?
MQTT (Message Queuing Telemetry Transport) to lekki protokół komunikacyjny warstwy aplikacji, zaprojektowany do wymiany komunikatów w modelu opartym o subskrypcje (subscription). Komunikacja odbywa się za pośrednictwem brokera, który pośredniczy między klientami publikującymi dane a klientami subskrybującymi określone tematy, eliminując konieczność bezpośrednich połączeń między nimi i przekazując klientom tylko te dane, które ich dotyczą. Dodatkowo subskrypcje mogą zawierać znaki specjalne wildcard, co daje większą kontrolę nad tym, jakie informacje są przekazywane.
Architektura prostego serwera MQTT
Jeden serwer MQTT obsługuje wiele klientów, a wiele klientów to wiele wątków, czy to nie jest logiczna implikacja? Otóż nie - swój serwer zrealizowałem w oparciu o nieblokujące sockety (gniazda). Pozwala to znacznie zredukować wymóg zasobów po stronie systemu operacyjnego i nie wymaga tworzenia dodatkowych wątków. Najpierw tworzę jeden nieblokujący socket, który oczekuje na nowe połączenia:
Kod: C / C++
Potem w funkcji "QuickTick" (taki odpowiednik loop - odświeżenie) wykonuję z głównego wątku OBK tzw. polling, by odbierać nowe połączenia.
Kod: C / C++
Dla nowych połączeń dopiero alokuję strukturę klienta. Struktury klienta trzymam w kolejce, a nie w liście - dzięki temu nie muszę z góry znać jej rozmiaru. Aby zrobić tablicę, musiałbym albo określić jej rozmiar (np. MAX_CLIENTS) albo dynamicznie ją poszerzać przez realloc, a kolejka zrealizowana jest tak, że każdy klient ma po prostu wskaźnik na następnego, co zmniejsza narzut pamięci,
Analogicznie, tak wygląda obsługa klientów i iteracja kolejki:
Kod: C / C++
Reszta sprowadza się tylko do przetwarzania pakietów zgodnie ze standardem MQTT, sprawdzania hasła, itd, ale można jeszcze przytoczyć użyte struktury danych. Kolejki używam też do listy subskrypcji:
Kod: C / C++
Jak widać powyżej, nie ma tu wcale dużo zmiennych ani złożonych mechanizmów. Same przetwarzanie pakietów też sprowadza się zasadniczo do kilku typów zapytań, takich jak:
- logowanie (z odpowiedzią)
- subskrypcja tematu (z odpowiedzią)
- usunięcie subskrypcji (z odpowiedzią)
- publikacje danych (z potwierdzeniem)
- ping tudzież heartbeat - utrzymywanie stanu online, notyfikacja w stylu "urządzenie wciąż żyje"
- odłączenie od serwera ("pożegnanie")
Poniżej enumeracja pakietów:
Kod: C / C++
Resztę jednak możecie zobaczyć już w moim projekcie na GitHub, nie chciałem w tym temacie jednak robić pełnej dokumentacji protokołu MQTT, bo to można znaleźć w sieci.
Problemy z Tasmotą
Początkowo testy pokazały, że klient MQTT z OpenBeken działa, natomiast ten z Tasmoty nie pokazuje subskrybowanych tematów. OpenBeken korzysta z MQTT z LWIP, a Tasmota PubSubClient. Zacząłem diagnozować krok po kroku, gdzie jest różnica i gdzie psuje się komunikacja.
Info:GEN:MQTTS: parse offset=0 lenBytes=2 remLen=138 pktTotal=141 raw=[10 8A 01 00]
Info:GEN:MQTTS: pkt type=1 flags=0x0 remLen=138 from ''
Info:GEN:MQTTS: client 'DVES_476739' connected
Info:GEN:MQTTS: recv 27 bytes from 'DVES_476739' [31 1F 00 17 74 ...]
Info:GEN:MQTTS: recv 139 bytes from 'DVES_476739' [4F 6E 6C 69 6E ...]
Info:GEN:MQTTS: parse offset=0 lenBytes=1 remLen=110 pktTotal=112 raw=[4F 6E 6C 69]
Info:GEN:MQTTS: pkt type=4 flags=0xf remLen=110 from 'DVES_476739'
Info:GEN:MQTTS: recv 724 bytes from 'DVES_476739' [31 FA 04 00 25 ...]
Info:GEN:MQTTS: parse offset=0 lenBytes=2 remLen=634 pktTotal=637 raw=[31 FA 04 00]
Info:GEN:MQTTS: pkt type=3 flags=0x1 remLen=634 from 'DVES_476739'
Info:GEN:MQTTS: parse offset=637 lenBytes=1 remLen=85 pktTotal=87 raw=[31 55 00 26]
Info:GEN:MQTTS: pkt type=3 flags=0x1 remLen=85 from 'DVES_476739'
Szybko okazało się, że pakiety z Tasmoty są łączone w jeden ciąg bajtów, TCP pewnie dodatkowo je fragmentuje. TCP nie gwarantuje tego, że całość dojdzie w jednej ramce, recv może odczytać część pakietu, a część pojawić się później. Na skutek tego musiałem zrezygnować z globalnego bufora dla recv i przenieść ten bufor do struktury klienta:
Kod: C / C++
Bufor alokuję i poszerzam na bieżąco wedle potrzeb. Z reguły po kilku realokacjach ten bufor się ustatkowuje. Trochę szkoda, że jest taka potrzeba, bo zwiększa to użycie RAM, ale trudno.
Prezentacja praktyczna
Kompilujemy (można online) OBK z włączonym sterownikiem MQTTServer w obk_config.h:
#define ENABLE_DRIVER_MQTTSERVER 1
Polecam też kompilować z Berry, bo Berry w OBK ma już integracje tego serwera.
Uruchamiany sterownik - startDriver MQTTServer. Najlepiej w autoexec.bat:
Można też go skonfigurować:
- ms_publish Topic Value
- ms_user UserName
- ms_pass Pass
- ms_port 123
Wspierany jest tylko jeden użytkownik. Ustawione dane wpisujemy na innych urządzeniach - testowałem z OBK i Tasmota - powinny się one pojawić u nas na panelu, choć ta ich lista jest tu tylko w ramach testów i do debugowania:
Na liście są przyciski wysyłające komendy Tasmoty (też zgodne z OBK), ale to również jest tylko dla testów.
Większe możliwości otwiera dla nas język skryptowy Berry. Poniżej przykładowy skrypt autoexec.be nasłuchujący wybranych tematów od podłączonych urządzeń (komenda ms_subscribe):
autoexec = module('autoexec')
autoexec.init = def()
autoexec.power_sub = ms_subscribe("cmnd/+/POWER", def (topic, payload)
print("Berry POWER: " + topic + " = " + payload)
end)
autoexec.get_sub = ms_subscribe("+/+/get", def (topic, payload)
print("Berry GET: " + topic + " = " + payload)
end)
autoexec.ip_sub = ms_subscribe("+/ip", def (topic, payload)
print("Berry Got IP Report: " + topic + " = " + payload)
end)
autoexec.uptime_sub = ms_subscribe("+/uptime", def (topic, payload)
print("Berry Got UpTime Report: " + topic + " = " + payload)
end)
autoexec.ha_sub = ms_subscribe("homeassistant/+", def (topic,payload)
print("Berry HA Discovery: " + topic + " = " + payload)
end)
autoexec.con_sub = ms_subscribe("+/connected", def (topic, payload)
print("Berry Connected: " + topic + " = " + payload)
end)
autoexec.sensor_sub = ms_subscribe("stat/+/SENSOR", def (topic, payload)
print("Berry SENSOR: " + topic + " = " + payload)
end)
autoexec.stat_sub = ms_subscribe("stat/+/+", def (topic,payload)
print("Berry STAT: " + topic + " = " + payload)
end)
end
return autoexec
Rezultaty:
Berry to jednak nie tylko nasłuchiwanie. Możemy też publikować dane (komenda ms_publish). W ten sposób można robić różne automatyzacje. Przykładowo, poniższy skrypt przekazuje stan drugiego przekaźnika na włączniku światła do lampki LED. Warto tu wspomnieć, że ten włącznik, mam zamontowany w puszce z przewodami do jednej lampy, a mimo to ma on aż trzy przyciski - jeden z nich steruje lampą, dwa pozostałe są do automatyzacji.
autoexec = module('autoexec')
autoexec.init = def()
autoexec.power_state_sub = ms_subscribe("stat/tasmota_476739/POWER2", def (topic, payload)
ms_publish("cmnd/obk174083A4/POWER",payload);
print("Berry POWER STATE: " + topic + " = " + payload)
end)
end
return autoexec
Filmik:
W ten sposób można również zrobić bardziej złożone mechanizmy, przykładowo:
- reakcje na przyciski (OBK wspiera zdarzenia przycisków, pojedyncze kliknięcie, podwójne, itd, wciśnięcie)
- zdarzenia rozłożone w czasie (Berry w OBK ma funkcję oczekiwania)
- zdarzenia przypisane do danych godzin (OBK ma system kalendarza - addClockEvent)
Podsumowanie
Nie ma problemu z tym, by uruchomić prosty serwer MQTT bezpośrednio na układzie takim jak BK7231, a w razie potrzeby można przejść na ESP32 z dodatkowym PSRAM (z 100 kB RAM zyskujemy wtedy kilka megabajtów). Pamięć Flash zasadniczo wcale tu nie ogranicza, a z RAM też nie jest źle, choć wymóg trzymania osobnych buforów dla pakietów dla każdego klienta nieco zwiększa zużycie. Oprócz tego same listy klientów i subskrypcji są dość lekkie i nie zajmują dużo.
Opracowany serwer MQTT działa i wcale nie jest taki wymagający - wszystko zrealizowałem bez wątków dzięki użyciu nieblokujących socketów, a listy klientów i subskrypcji są oparte na kolejkach, więc nie alokują nadmiernych ilości pamięci na starcie a jednocześnie nie są ograniczone rozmiarowo.
Ciekawą obserwacją może być to, że standard MQTT z OBK, gdzie każda wartość to osobny "publish" jest znacznie lżejszy pod kątem RAM niż standard Tasmoty, gdzie całość jest publikowana jako jeden JSON, który trzeba chwilę trzymać w pamięci na czas przetwarzania.
Dodatkowa integracja z Berry sprawia, że możliwości robią się naprawdę duże. Można zaprogramować sobie dowolną logikę i odzwierciedlać działanie Home Assistant na malutkim mikrokontrolerze.
Dalsze plany:
- dodać więcej przykładów języka skryptowego Berry dla obsługi MQTT
- dodać osobny sterownik, który niczym Home Assistant odbiera HASS Discovery i pokazuje samodzielnie urządzenia na panelu OBK, bez żadnego skryptowania i bez pisania kodu
- sprawdzić sterownik serwera na różnych platformach oraz zbadać, ile urządzeń można wspierać na BK7231 mającym z reguły wolne około 100 kB RAM, a ile na ESP32 z PSRAM (8 MB RAM lub więcej)
Czy widzicie jakieś zastosowania dla takiego mniejszego serwera MQTT? Wydaje mi się, że to może być dobra alternatywa dla osób, które nie chcą stawiać pełnego Home Assistant.
Powiązane i dokumentacja:
https://github.com/openshwprojects/OpenBK7231T_App
https://openbekeniot.github.io/webapp/devicesList.html
Berry w OpenBeken
Online Builds (kompilacja) w OBK
Wieloplatformowy firmware IoT wspierający aż 32 platformy - podsumowanie OBK 2025
Fajne? Ranking DIY Pomogłem? Kup mi kawę.