
Всем привет! Это Мурат Насиров, Flutter-разработчик в Friflex. Мы разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter.
Сегодня расскажу, как использовать микроконтроллер ESP32 в связке с Flutter-приложением. В качестве примера покажу, как снимать показания температуры и влажности с помощью датчика DHT11 и передавать эти данные через Firebase Realtime Database в приложение.
Взаимодействие систем и общий план
Идея приложения заключается в следующем — создается Flutter-проект, к нему подключается Firebase, а также необходимые библиотеки для управления состоянием. Затем происходит настройка в сервисе Firebase базы данных Realtime Database, которая в реальном времени считывает показания с датчика и через микроконтроллер отправляет их в БД, где затем из этой базы данные считывает само приложение. Помимо прочего, будет реализована функция авторизации, которая позволит только созданным в Firebase аккаунтам управлять и мониторировать данными в БД.
Так как взаимодействие происходит между тремя системами, представлю вид того, как это будет выглядеть.
В целом, это все что надо знать о взаимодействии систем. Приступим к созданию проекта в 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 только пользователям с особыми правами.
Обязательно сохраните учетные данные, они еще пригодятся.

Изучаем схему подключения микроконтроллера
Рассмотрим схему подключения.
Все достаточно просто. Схема состоит из микроконтроллера 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 <WiFi.h> /*! Подключение к 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 <DHT.h> /* Номер пина датчика температуры и влажности */ 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 <WString.h> #include <FirebaseESP32.h> /*! Инициализация сервиса 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 <addons/TokenHelper.h> #include <addons/RTDBHelper.h> #include <time.h> 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<void> 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<UserCredential> login({ required String email, required String password, }) async { return FirebaseAuth.instance.signInWithEmailAndPassword( email: email, password: password, ); } Future<void> logout() async => FirebaseAuth.instance.signOut(); }
Для управления состоянием нужна логика. Она будет отображать в UI нужные виджеты, в зависимости от состояния. Это простой запрос в сеть, поэтому у нас четыре состояния: инициализация, загрузка, ошибка, успех. В арсенале у Riverpod есть StateNotifier. Используя его вместе с AsyncValue (тоже из этого пакета), получаем следующую картину:
lib/auth/domain/auth_riverpod.dart
class AuthStateNotifier extends StateNotifier<AsyncValue<User?>> { AuthStateNotifier({required AuthService service}) : _service = service, // В super объявляется изначальное состояние (initial). Так как данных // нет, ставим null super(const AsyncData(null)) { // Если пользователь уже был авторизован, просто меняем состояние на // успешное if (service.isAlreadyLoggedIn) state = AsyncData(service.user); } final AuthService _service; Future<void> logout() async { if (state.isLoading) return; state = const AsyncLoading(); state = await AsyncValue.guard(() async { await _service.logout(); // После выхода делаем User равным null, так как от id внутри этой модели // определяется состояние авторизованности в приложении return null; }); } Future<void> 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<TextEditingController>((ref) { final cotroller = TextEditingController(); // onDispose срабатывает в местах, где использовался // ref.watch(loginAuthProvider). После выхода из экрана этот текстовый // контроллер закрывается ref.onDispose(() => controller.dispose()); return controller; }); final passAuthProvider = Provider.autoDispose<TextEditingController>((ref) { final controller = TextEditingController(); ref.onDispose(() => controller.dispose()); return controller; }); final userProvider = Provider<User?>( (ref) => ref.watch(authStateProvider).value, ); /// Провайдер текущего состояния авторизованности final authStateProvider = StateNotifierProvider<AuthStateNotifier, AsyncValue<User?>>( (_) => 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<AsyncValue<User?>>(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.dart
@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<AsyncValue<DevicesData?>> { DevicesStateNotifier({required DatabaseReference database}) : _database = database, super(const AsyncData(null)); final DatabaseReference _database; late StreamSubscription<DatabaseEvent> _subscription; Future<void> 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<DevicesData?>>((_) { 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<AsyncValue<User?>>(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<void> 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-проекта.
ссылка на оригинал статьи https://habr.com/ru/articles/795347/
Добавить комментарий