Подключаем микроконтроллер ESP32 к Flutter-приложению
Всем привет! Это Мурат Насиров, Flutter-разработчик в Friflex. Мы разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter.
Сегодня расскажу, как использовать микроконтроллер ESP32 в связке с Flutter-приложением. В качестве примера покажу, как снимать показания температуры и влажности с помощью датчика DHT11 и передавать эти данные через Firebase Realtime Database в приложение.
Взаимодействие систем и общий план
Идея приложения заключается в следующем — создается Flutter-проект, к нему подключается Firebase, а также необходимые библиотеки для управления состоянием. Затем происходит настройка в сервисе Firebase базы данных Realtime Database, которая в реальном времени считывает показания с датчика и через микроконтроллер отправляет их в БД, где затем из этой базы данные считывает само приложение. Помимо прочего, будет реализована функция авторизации, которая позволит только созданным в Firebase аккаунтам управлять и мониторировать данными в БД.
Так как взаимодействие происходит между тремя системами, представлю вид того, как это будет выглядеть.
A — приложение, B — микроконтроллер ESP32
В целом, это все что надо знать о взаимодействии систем. Приступим к созданию проекта в Firebase.
Создаем проект в Firebase
Начинаем с регистрации проекта в Firebase Console. Следуем инструкции с картинками. Проходим авторизацию через Google-аккаунт и нажимаем Add Project.
Задаем имя проекта.
Отключаем сервис Google Analytics. В этом проекте он нам не нужен.
Готово. Теперь нужно подключить Firebase к Flutter-приложению. Сделаем это через Firebase CLI. Выбираем иконку Flutter.
Следуем инструкции. Перед этим не забудьте создать Flutter-проект.
После подключения Firebase на главной должны появиться проекты для iOS и Android, если вы выбрали их при установке Firebase.
Добавляем авторизацию через электронную почту и пароль.
Выбираем только Email/Password.
Добавляем двух пользователей: user@u.com — это пользователь, который будет проходить авторизацию в приложении. И device@d.com — это микроконтроллер ESP32.
Учетные записи решают вопрос с безопасностью. Мы позволяем записывать данные в Realtime Database только пользователям с особыми правами.
Обязательно сохраните учетные данные, они еще пригодятся.
Изучаем схему подключения микроконтроллера
Рассмотрим схему подключения.
Подключение DHT11 к ESP32
Все достаточно просто. Схема состоит из микроконтроллера ESP32, а также датчика температуры и влажности DHT11. Пин подключения сигнального провода можно выбрать любой из доступных вам для вашей модели микроконтроллера (они бывают с разным количеством выходов, то есть, пинов).
Описание пинов
У меня микроконтроллер выглядит вот так.
Микроконтроллер на макетной плате
Пишем код для микроконтроллера
Теперь переходим к коду для микроконтроллера. Написать его можно в Arduino IDE или с помощью плагина PlatformIO для VS Code. Второй способ интереснее, поэтому я воспользуюсь им.
Заходим на сайт VS Code, скачиваем редактор кода для своей операционной системы. В разделе Extensions находим и устанавливаем Platformio IDE.
Во время установки оказалось, что у меня нет Python-интерпретатора. На моем компьютере установлена операционная система Ubuntu. Поэтому открываю консоль и устанавливаю последнюю версию:
sudo apt install python3-venv
В появившемся окне в VS Code выбираем Try again.
Теперь можно создавать проект. Выбираем удобную папку для проекта. В PlatformIO нажимаем Create New Project — New Project.
Задаем имя и в качестве Board выбираем Espressif ESP32 Dev Module.
В готовый проект устанавливаем библиотеки. Для этого переходим в раздел Libraries.
По очереди находим и устанавливаем DHT sensor library, Adafruit Unified Sensor и Firebase ESP32 Client.
В файле platformio.ini
добавим пару строк скорости чтения и записи данных с платы:
upload_speed = 921600
monitor_speed = 115200
Пора кодить. Напишем скрипты для работы с Wi-Fi и датчиком. Создадим в папке lib
папки: firebase
, sensors
и wifi
.
Начнем с wifi
, создадим в этой папке два файла DeviceWiFi.cpp
и DeviceWiFi.h
, где первый это файл с исполняемым кодом, а второй заголовочный файл. Такое разбиение на модули позволит затем удобно импортировать код в main.cpp
.
DeviceWiFi.h
#include
/*!
Подключение к Wi-Fi
\param[in] ssid Имя точки доступа (Wi-Fi)
\param[in] pass Пароль точки доступа (Wi-Fi)
*/
void connectToWiFi(const char *ssid, const char *pass);
/*!
Переподключение к Wi-Fi, если соединение разорвано
\param[in] ssid Имя точки доступа (Wi-Fi)
\param[in] pass Пароль точки доступа (Wi-Fi)
*/
void loopReconnectToWiFi(const char *ssid, const char *pass);
DeviceWiFi.cpp
#include "DeviceWiFi.h"
void connectToWiFi(const char *ssid, const char *pass)
{
Serial.println("Устанавливается соединение с ");
Serial.print(ssid);
// Регистрация подключения на основе введенных данных
WiFi.begin(ssid, pass);
// Проверка подключения
while (WiFi.status() != WL_CONNECTED)
{
delay(1000);
Serial.print(".");
}
// Установка соединения, если все успешно
Serial.println("");
Serial.println("Соединение установлено!");
Serial.print("IP-адрес: ");
Serial.println(WiFi.localIP());
}
void loopReconnectToWiFi(const char *ssid, const char *pass)
{
// Если соединение разорвано происходит переподключение со всеми настройками
while (WiFi.status() != WL_CONNECTED)
{
connectToWiFi(ssid, pass);
}
}
Теперь напишем модуль с датчиком. Нам необходимо списать показания с DHT11 и обрабатывать их в ESP32. В папке sensors создаем файлы DeviceSensors.cpp
и DeviceSensors.h
DeviceSensors.h
#include "DeviceFirebase.h"
/* Отправляет данные с датчиков в DeviceFirebase#loopSendDataToRTDB */
void loopSendSensorsData();
DeviceSensors.cpp
#include "DeviceSensors.h"
#include
/* Номер пина датчика температуры и влажности */
uint8_t DHTPin = 27;
#define DHTTYPE DHT11
DHT dht(DHTPin, DHTTYPE);
void loopSendSensorsData()
{
int temp = dht.readTemperature();
int hum = dht.readHumidity();
loopSendDataToRTDB(temp, hum);
}
Теперь все собранные показания нужно отправить по Wi-Fi в Firebase. Создадим модуль, который будет отвечать за аутентификацию по уже ранее созданному пользователю в базе данных. В папке firebase создаем файлы DeviceFirebase.cpp
и DeviceFirebase.h
DeviceFirebase.h
#include
#include
/*!
Инициализация сервиса Firebase
\param[in] deviceApiKey Уникальный ключ устройства (достаточно Web API Key)
\param[in] deviceDatabaseUrl Ссылка на Firebase Realtime Database для записи данных
\param[in] deviceEmail Email для авторизации устройства
\param[in] devicePass Пароль
*/
void initFirebaseService(String deviceApiKey, String deviceDatabaseUrl, String deviceEmail, String devicePass);
/*!
Отправка данных с сформированными путями в Realtime Database
\param[in] temp Температура
\param[in] hum Влажность
*/
void loopSendDataToRTDB(int temp, int hum);
DeviceFirebase.cpp
#include "DeviceFirebase.h"
#include
#include
#include
FirebaseData fireData;
FirebaseConfig fireConfig;
FirebaseAuth fireAuth;
unsigned long epochTime;
// Получаем текущее время с сервера ntp
unsigned long getTime() {
time_t now;
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
return(0);
}
time(&now);
return now;
}
void initFirebaseService(String deviceApiKey, String deviceDatabaseUrl, String deviceEmail, String devicePass)
{
Serial.printf("Firebase Client v%s\n\n", FIREBASE_CLIENT_VERSION);
// Определение уникального ключа устройства и ссылки на Firebase Realtime Database
fireConfig.api_key = deviceApiKey;
fireConfig.database_url = deviceDatabaseUrl;
// Определение учетных данных для микроконтроллера
fireAuth.user.email = deviceEmail;
fireAuth.user.password = devicePass;
// Получение уникального токена микроконтроллера
fireConfig.token_status_callback = tokenStatusCallback;
// Регистрация работы сервиса
Firebase.begin(&fireConfig, &fireAuth);
// Получение UID и его отображение
Serial.println("Получение UID микроконтроллера");
while ((fireAuth.token.uid) == "")
{
Serial.print('.');
delay(1000);
}
Serial.print("Успешно подключено к Firebase!");
}
void loopSendDataToRTDB(int temp, int hum)
{
String basePath = "/devices";
String tempPath = basePath + "/temperature";
String humpPath = basePath + "/humidity";
String timePath = basePath + "/timestamp";
epochTime = getTime();
if (Firebase.ready())
{
Firebase.setInt(fireData, tempPath, temp);
Firebase.setInt(fireData, humpPath, hum);
Firebase.setInt(fireData, timePath, epochTime);
// Задержка в 1 минуту
delay(60000);
}
}
Для авторизации нам необходим Web API Key. Найти его можно с помощью кнопки справа от Project Overview — Project Settings — General. Этот API Key мы указываем в main.cpp
вместе с логином и паролем устройства.
Открываем src/main.cpp
и пишем:
main.cpp
#include "DeviceWiFi.h"
#include "DeviceFirebase.h"
#include "DeviceSensors.h"
// Подключение к WiFi
const char *wifiSsid = "wifi_name";
const char *wifiPass = "wifi_pass";
// Сервис для получения текущего времени в секундах
const char *ntpServer = "pool.ntp.org";
// Подключение к Firebase
/* Уникальный ключ микроконтроллера, определяемый в Google Cloud */
String deviceApiKey = "device_api_key";
/* Адрес Firebase Realtime Databse */
String deviceDatabaseUrl = "device_database_url";
/* Электронная почта микроконтроллера */
String deviceEmail = "device_email";
/* Пароль микроконтроллера */
String devicePass = "device_pass";
void setup()
{
Serial.begin(115200);
delay(10);
connectToWiFi(wifiSsid, wifiPass);
configTime(0, 0, ntpServer);
initFirebaseService(deviceApiKey, deviceDatabaseUrl, deviceEmail, devicePass);
}
void loop()
{
loopReconnectToWiFi(wifiSsid, wifiPass);
loopSendSensorsData();
}
Помните, я говорил, что нам понадобятся электронная почта и пароль от учетной записи, которую вы создали в Firebase? Момент настал. Указываем эти данные в соответствующих полях. В качестве deviceApiKey
указываем Web API Key, про который я также упоминал выше.
Остается разобраться с deviceDatabaseUrl.
Переходим в Firebase Console — Build — Realtime Database — Create Database. Выбираем сервер United States, затем Start in test mode. В этом режиме база данных (БД) будет существовать 30 дней. При необходимости можно увеличить срок.
Получаем БД, ура! Пока что данных в ней нет, но это легко исправить. Подключаем микроконтроллер ESP32 к компьютеру, после чего переходим в PlatformIO и нажимаем build
Чтобы после успешного билда загрузить наши скрипты, выбираем Upload and Monitor. Возвращаемся в Firebase Console — Build — Realtime Database и любуемся результатом.
Решаем возможные проблемы
Если ваш ESP32 не обнаруживается, возможно, нужно установить драйверы CH34x и CP210x. Проверьте работу USB-кабеля, вероятно, его необходимо заменить.
Чтобы проверить, опознается ли микроконтроллер, нужно подключить его к компьютеру и открыть раздел PlatformIO — Devices.
Также можно пройтись по инструкции на сайте Espressif.
Создаем Flutter-проект и настраиваем взаимодействие
Нам осталось написать приложение, которое будет отображать данные с микроконтроллера. Во время настройки Firebase CLI я говорил, что нам понадобится Flutter-проект. В нем вы увидите сгенерированные файлы.
Лишь lib/firebase_options.dart
нужно добавить в /.gitignore
, а android/app/google-services.json
и ios/firebase_app_id_file.json
можно удалить. Либо их можно тоже добавить в /.gitignore
, если нужно.
Я постараюсь максимально кратко описать, какие решения использовал в приложении, чтобы отобразить значения с микроконтроллера. Остальной код вы сможете найти в репозитории в конце статьи.
Начнем с пакетов. Нам будет достаточно использовать менеджер состояния, библиотеки Firebase для авторизации и БД, а также библиотеку для преобразования текущего времени с микроконтроллера.
pubspec.yaml
dependencies:
intl: 0.19.0
#---NETWORK DATA---#
firebase_core: 2.25.3
firebase_auth: 4.17.3
firebase_database: 10.4.4
#---STATE MANAGEMENT---#
flutter_riverpod: 2.4.10
Инициализируем Firebase и Riverpod
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const ProviderScope(child: MyApp()));
}
Мы использовали Firebase CLI, поэтому в аргументе options обязательно указываем currentPlatform
для платформ, которые мы выбрали при установке Firebase.
В моем случае в lib/firebase_options.dart
описывается попытка запуска на неподдерживаемой платформе. Для поддерживаемых платформ возвращаются сгенерированные ключи. Мы можем подкорректировать этот файл, потому что в моем случае запустить его можно только на Android, Linux и iOS.
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'apiKey',
appId: 'appId',
messagingSenderId: 'messagingSenderId',
projectId: 'projectId',
storageBucket: 'storageBucket',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'apiKey',
appId: 'appId',
messagingSenderId: 'messagingSenderId',
projectId: 'projectId',
storageBucket: 'storageBucket',
iosBundleId: 'iosBundleId',
);
}
Придадим нашему проекту следующую структуру.
Начнём с авторизации. Для этого создадим сервис, который будет выполнять функцию входа и выхода в приложение. Вход можно отследить при помощи значения id в модели User. Установим проверку, которая будет переключать на экран устройства при успешном входе. Либо на экран авторизации, если войти не удалось.
lib/auth/data/services/auth_service.dart
class AuthService {
/// Если аутентификация, либо авторизация прошла успешно, значение этого
/// геттера становится заполненная модель [User]
User? get user => FirebaseAuth.instance.currentUser;
String? get userId => user?.uid;
String? get userEmail => user?.email;
bool get isAlreadyLoggedIn => userId != null;
Future login({
required String email,
required String password,
}) async {
return FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
}
Future logout() async => FirebaseAuth.instance.signOut();
}
Для управления состоянием нужна логика. Она будет отображать в UI нужные виджеты, в зависимости от состояния. Это простой запрос в сеть, поэтому у нас четыре состояния: инициализация, загрузка, ошибка, успех. В арсенале у Riverpod есть StateNotifier
. Используя его вместе с AsyncValue
(тоже из этого пакета), получаем следующую картину:
lib/auth/domain/auth_riverpod.dart
class AuthStateNotifier extends StateNotifier> {
AuthStateNotifier({required AuthService service})
: _service = service,
// В super объявляется изначальное состояние (initial). Так как данных
// нет, ставим null
super(const AsyncData(null)) {
// Если пользователь уже был авторизован, просто меняем состояние на
// успешное
if (service.isAlreadyLoggedIn) state = AsyncData(service.user);
}
final AuthService _service;
Future logout() async {
if (state.isLoading) return;
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await _service.logout();
// После выхода делаем User равным null, так как от id внутри этой модели
// определяется состояние авторизованности в приложении
return null;
});
}
Future login({required String email, required String password}) async {
if (state.isLoading) return;
state = const AsyncLoading();
// AsyncValue.guard используется для удобства. По факту получается
// state = AsyncData(response.user) в случае успеха, либо
// state = AsyncError(error, stackTrace) в случае ошибки
state = await AsyncValue.guard(() async {
final response = await _service.login(
email: email,
password: password,
);
return response.user;
});
}
}
Теперь создаем несколько провайдеров для манипуляций состояниями в UI-слое. Нам нужны провайдеры для:
текстовых полей логина и пароля;
определения статуса авторизации в приложении;
модели пользователя (
User
).
Создадим lib/auth/domain/auth_scope_providers.dart
и добавим несколько провайдеров.
final loginAuthProvider = Provider.autoDispose((ref) {
final cotroller = TextEditingController();
// onDispose срабатывает в местах, где использовался
// ref.watch(loginAuthProvider). После выхода из экрана этот текстовый
// контроллер закрывается
ref.onDispose(() => controller.dispose());
return controller;
});
final passAuthProvider = Provider.autoDispose((ref) {
final controller = TextEditingController();
ref.onDispose(() => controller.dispose());
return controller;
});
final userProvider = Provider(
(ref) => ref.watch(authStateProvider).value,
);
/// Провайдер текущего состояния авторизованности
final authStateProvider =
StateNotifierProvider>(
(_) => AuthStateNotifier(service: AuthService()),
);
После определения всех провайдеров можно приступать к вёрстке экрана. В экране будет отображаться простой стилизованный вид ввода учётных данных.
Я не буду описывать весь код UI, а лишь покажу интересные на мой взгляд моменты. Создадим lib/auth/presentation/screens/auth_screen.dart
. В Riverpod есть возможность следить за определенным провайдером прямо в build
методе, поэтому объявим наши TextEditingController
для текстовых полей.
// Получаем значение TextEditingController, которое используем для
// текстового поля. ref.watch будет перестраивать виджет каждый раз, когда
// его значение меняется, например, при вызове у этого
// TextEditingController метода dispose
final loginController = ref.watch(loginAuthProvider);
final passController = ref.watch(passAuthProvider);
_listenAuthState(context, ref);
И установим для каждого текстового поля.
TextField(
controller: loginController,
decoration: const InputDecoration(hintText: 'Email'),
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
onEditingComplete: () => FocusScope.of(context).nextFocus(),
)
Также нарисуем кнопку, которая будет принимать значения с контроллеров текстовых полей.
ElevatedButton(
onPressed: () {
_completeInput(
context,
ref,
email: loginController.text.trim(),
pass: passController.text.trim(),
);
},
...
)
Эта кнопка выполняет функцию входа.
/// Метод завершения ввода логина и пароля
void _completeInput(
BuildContext context,
WidgetRef ref, {
required String email,
required String pass,
}) {
// Если у пользователя открыта клавиатура - скрываем ее
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 1;
if (isKeyboardVisible) FocusScope.of(context).unfocus();
// Вызываем функцию для входа
ref.read(authStateProvider.notifier).login(email: email, password: pass);
}
Выше я упоминал функцию _listenAuthState
. Опишем ее.
/// Отслеживает состояние авторизованности пользователя
void _listenAuthState(BuildContext context, WidgetRef ref) {
ref.listen>(authStateProvider, (previous, next) async {
...
// В случае успешной авторизации переходим на экран устройств
if (next.hasValue && context.mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const DevicesScreen()),
);
}
});
}
Со входом мы разобрались. Теперь приступаем к экрану с показаниями датчика DHT11. Начнем с модели. В видео выше было показано, что данные хранятся в Firebase Realtime Database. Это просто Map
с набором ключ или значение. Поэтому нам нужно сделать простой маппинг этих данных.
lib/devices/domain/model/devices_data.dar
@immutable
class DevicesData {
const DevicesData({
required this.humidity,
required this.temperature,
required this.readingDateTime,
});
/// Влажность
final int? humidity;
/// Температура
final int? temperature;
/// Время считывания
final DateTime? readingDateTime;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DevicesData &&
runtimeType == other.runtimeType &&
humidity == other.humidity &&
temperature == other.temperature &&
readingDateTime == other.readingDateTime;
@override
int get hashCode =>
humidity.hashCode ^ temperature.hashCode ^ readingDateTime.hashCode;
static DevicesData fromJson(Map json) {
// Так как отсчет времени происходит с начала эпохи (01.01.1970) в секундах, здесь происходит преобразование этого времени в понятный вид
final timestamp = json['timestamp'];
final readingDateTime = timestamp != null
? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)
: null;
return DevicesData(
temperature: json['temperature'],
humidity: json['humidity'],
readingDateTime: readingDateTime,
);
}
}
В логике мы сразу после получения данных смаппим их в эту модели.
lib/devices/domain/devices_riverpod.dart
class DevicesStateNotifier extends StateNotifier> {
DevicesStateNotifier({required DatabaseReference database})
: _database = database,
super(const AsyncData(null));
final DatabaseReference _database;
late StreamSubscription _subscription;
Future getDevicesData() async {
state = const AsyncLoading();
// Обновление данных происходит в реальном времени
_subscription = _database.onValue.listen((event) {
final data = event.snapshot.child('devices').value as Map?;
if (data == null) return;
state = AsyncData(DevicesData.fromJson(data));
}, onError: (e, s) => state = AsyncError(e, s));
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
Порядок, осталось сделать провайдер.
lib/devices/domain/devices_scope_providers.dart
final devicesStateProvider = StateNotifierProvider.autoDispose<
DevicesStateNotifier, AsyncValue>((_) {
return DevicesStateNotifier(database: FirebaseDatabase.instance.ref())
// При входе на экран, где используется этот провайдер сразу же начнется
// запрос в сеть посредством вызова getDevicesData
..getDevicesData();
});
Мы близки к завершению, осталось лишь написать простой UI для отображения показаний датчика. Создав lib/devices/presentation/screens/devices_screen.dart
в функции build
нужно реагировать на состояние, когда пользователь вышел из аккаунта.
final user = ref.watch(userProvider);
// authStateProvider провайдер вызовется по нажатию кнопки выхода. В теле
// этого слушателя стоит проверка
ref.listen>(authStateProvider, (prev, next) {
final uid = next.value?.uid;
// Если пользователь вышел - заменяем текущий экран на экран авторизации
if (uid == null) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const AuthScreen()),
);
}
});
Так как получение данных это асинхронная операция, в параметре body
виджета Scaffold
удобнее использовать функцию when
, которую предоставляет Riverpod.
Scaffold(
...
// Метод when в асинхронных операциях в Riverpod позволяет удобно
// распределять события загрузки, ошибки и успеха на нужные виджеты,
// которые описаны здесь
body: deviceData.when(
loading: () {...},
error: (error, stackTrace) {...},
data: (data) {...},
)
В аргументе data
находятся показания температуры и влажности, а также timestamp
, которые меняются динамически, раз в 1 минуту. Достаточно просто отобразить эти данные в UI.
Осталось самое главное.
lib/main.dart
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: 'Flutter Arduino',
debugShowCheckedModeBanner: false,
home: Consumer(
builder: (context, ref, child) {
final uid = ref.watch(userProvider)?.uid;
if (uid != null) return const DevicesScreen();
return const AuthScreen();
},
),
);
}
}
Все готово! Можно смотреть на красоту :)
Надеюсь, вам было интересно. Если появились вопросы, пишите в комментариях. Всем пока!
Исходники: код микроконтроллера, код Flutter-проекта.