ESP32-Flutter-MQTT-IoT-Controller
這套系統包含了 Flutter 跨平台 App、ESP32 韌體 與 MQTT 雲端傳輸機制。
專案中採用的 功能命名原則與對應關係 整理如下:
1. 核心功能名稱對照表
| 功能區塊 | Python / Flutter 變數與函式命名 | 說明 / 作用 |
| MQTT 連線設定 | _setupMqttClient() | 初始化 MQTT Client,設定 Broker、Port、Client ID 並進行連線。 |
| 發送控制指令 | _sendCmd(String cmd) | 將控制指令發送至 MQTT 主題(Topic: alex9ufo/led)。 |
| 接收狀態解析 | _handleStatusPayload(String payload) | 解析 ESP32 回傳的狀態字串(1on ~ 4off, allon, alloff),並同步更新介面上的燈態指示燈。 |
| 訊息接收監聽 | _onMessageReceived(...) | 監聽所有已訂閱主題(溫濕度、狀態)的最新訊息,並調用對應的處理程序。 |
2. MQTT Topic(主題)命名規範
alex9ufo/temp:溫度數據(ESP32 上傳至 App/Web)alex9ufo/humi:濕度數據(ESP32 上傳至 App/Web)alex9ufo/led:控制指令(App/Web 發送至 ESP32)alex9ufo/status:燈態即時狀態(ESP32 回傳至 App/Web)
3. 控制指令 Payload(有效載荷)命名規範
1on/1off:第 1 顆 LED / Relay 開啟或關閉2on/2off:第 2 顆 LED / Relay 開啟或關閉3on/3off:第 3 顆 LED / Relay 開啟或關閉4on/4off:第 4 顆 LED / Relay 開啟或關閉allon:全開所有 LED / Relayalloff:全關所有 LED / Relay
清除項目編譯快取
執行 flutter clean
重新取得套件依賴
執行 flutter pub get
完整重新編譯並執行
flutter run
支援 Flutter Web (WebSocket) 的完整版
lib/main.dart 程式碼。此程式碼可在 Chrome / Edge 瀏覽器中透過 WebSocket (ws://[broker.hivemq.com:8000/mqtt](https://broker.hivemq.com:8000/mqtt)) 連線,並與 ESP32 (Wokwi) 的 TCP Port (1883) 互通數據。 (Chrome / Edge 瀏覽器 執行)import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_browser_client.dart';
import 'package:mqtt_client/mqtt_client.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ESP32 MQTT 控制與燈態中心',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const MqttControlPage(),
);
}
}
class MqttControlPage extends StatefulWidget {
const MqttControlPage({super.key});
@override
State<MqttControlPage> createState() => _MqttControlPageState();
}
class _MqttControlPageState extends State<MqttControlPage> {
// --- MQTT 設定 (適用於 Web 的 WebSocket 埠號) ---
static const String broker = 'broker.hivemq.com';
static const int port = 8000; // HiveMQ WebSocket 專用 Port
static const String topicTemp = 'alex9ufo/temp';
static const String topicHumi = 'alex9ufo/humi';
static const String topicLed = 'alex9ufo/led';
static const String topicStatus = 'alex9ufo/status';
// --- 狀態變數 ---
String _temp = '--';
String _humi = '--';
String _latestStatus = '尚未接收';
final List<bool> _ledStates = [false, false, false, false];
late MqttBrowserClient _client;
bool _isConnected = false;
@override
void initState() {
super.initState();
_setupMqttClient();
}
@override
void dispose() {
_client.disconnect();
super.dispose();
}
// 初始化並透過 WebSocket 連線 MQTT Broker
Future<void> _setupMqttClient() async {
final String clientIdentifier =
'flutter_web_${DateTime.now().millisecondsSinceEpoch}';
// 建立瀏覽器專用的 WebSocket Client
_client = MqttBrowserClient('ws://$broker/mqtt', clientIdentifier);
_client.port = port;
_client.keepAlivePeriod = 60;
// 開啟詳細除錯 Log
_client.logging(on: true);
// 註冊事件 Listener
_client.onConnected = () {
print('[MQTT 事件] Web 端 WebSocket 連線成功!');
if (mounted) {
setState(() => _isConnected = true);
}
};
_client.onDisconnected = () {
print('[MQTT 事件] Web 端已斷線');
if (mounted) {
setState(() => _isConnected = false);
}
};
_client.onSubscribed = (String topic) {
print('[MQTT 事件] 成功訂閱主題: $topic');
};
final connMessage = MqttConnectMessage()
.withClientIdentifier(clientIdentifier)
.startClean()
.withWillQos(MqttQos.atMostOnce);
_client.connectionMessage = connMessage;
try {
print('[MQTT] 正在透過 WebSocket 連線至 ws://$broker:$port/mqtt ...');
await _client.connect();
} catch (e) {
print('[MQTT 連線例外] 錯誤訊息: $e');
_client.disconnect();
return;
}
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
// 訂閱主題
_client.subscribe(topicTemp, MqttQos.atMostOnce);
_client.subscribe(topicHumi, MqttQos.atMostOnce);
_client.subscribe(topicStatus, MqttQos.atMostOnce);
// 監聽接收訊息
_client.updates?.listen(_onMessageReceived);
} else {
print('[MQTT 連線失敗] 狀態: ${_client.connectionStatus?.state}');
_client.disconnect();
}
}
// 處理收到的 MQTT 訊息
void _onMessageReceived(List<MqttReceivedMessage<MqttMessage>> c) {
final MqttPublishMessage recMessage = c[0].payload as MqttPublishMessage;
final String payload = MqttPublishPayload.bytesToStringAsString(
recMessage.payload.message,
).trim();
final String topic = c[0].topic;
print('[MQTT 收到] 主題: $topic | 內容: $payload');
if (!mounted) return;
setState(() {
if (topic == topicTemp) {
_temp = payload;
} else if (topic == topicHumi) {
_humi = payload;
} else if (topic == topicStatus) {
_latestStatus = payload;
_handleStatusPayload(payload);
}
});
}
// 解析 Status Payload 並更新燈態
void _handleStatusPayload(String payload) {
print("[除錯] 解析狀態 Payload: '$payload'");
if (['1on', '2on', '3on', '4on'].contains(payload)) {
int idx = int.parse(payload[0]) - 1;
_ledStates[idx] = true;
} else if (['1off', '2off', '3off', '4off'].contains(payload)) {
int idx = int.parse(payload[0]) - 1;
_ledStates[idx] = false;
} else if (payload == 'allon') {
for (int i = 0; i < 4; i++) {
_ledStates[i] = true;
}
} else if (payload == 'alloff') {
for (int i = 0; i < 4; i++) {
_ledStates[i] = false;
}
}
}
// 發送 MQTT 指令
void _sendCmd(String cmd) {
if (!_isConnected) {
print('[MQTT 錯誤] 未連線,無法發送指令');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('MQTT 未連線,請點擊右上角重連'),
duration: Duration(seconds: 2),
),
);
return;
}
print('[MQTT 發送] 指令: $cmd');
final builder = MqttClientPayloadBuilder();
builder.addString(cmd);
_client.publishMessage(topicLed, MqttQos.atMostOnce, builder.payload!);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ESP32 MQTT 控制與燈態中心'),
centerTitle: true,
actions: [
IconButton(
icon: Icon(
_isConnected ? Icons.cloud_done : Icons.cloud_off,
color: _isConnected ? Colors.green : Colors.red,
),
onPressed: () {
if (!_isConnected) {
_setupMqttClient();
}
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 1. 環境數據卡片
_buildSensorCard(),
const SizedBox(height: 15),
// 2. LED 控制卡片
_buildControlCard(),
const SizedBox(height: 20),
// 3. 最新狀態顯示
Text(
'最新狀態: $_latestStatus',
style: const TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
color: Colors.grey,
),
),
],
),
),
);
}
// 環境數據 UI 區塊
Widget _buildSensorCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'環境數據',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const Divider(),
Row(
children: [
const Text('溫度:', style: TextStyle(fontSize: 16)),
const SizedBox(width: 15),
Text(
'$_temp °C',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
const Text('濕度:', style: TextStyle(fontSize: 16)),
const SizedBox(width: 15),
Text(
'$_humi %',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
],
),
),
);
}
// LED 控制與實時狀態 UI 區塊
Widget _buildControlCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'LED 控制與實時狀態',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const Divider(),
for (int i = 0; i < 4; i++) _buildLedRow(i),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => _sendCmd('allon'),
child: const Text('全開 (allon)'),
),
),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: () => _sendCmd('alloff'),
child: const Text('全關 (alloff)'),
),
),
],
),
],
),
),
);
}
// 單排 LED 控制列
Widget _buildLedRow(int index) {
final bool isOn = _ledStates[index];
final Color ledColor = isOn
? const Color(0xFF00FF00)
: const Color(0xFF1E1E1E);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
Text(
'LED ${index + 1}:',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
// 模擬指示燈
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: ledColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey, width: 1.5),
),
),
const Spacer(),
// 開啟按鈕
OutlinedButton(
onPressed: () => _sendCmd('${index + 1}on'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Text('開啟'),
),
const SizedBox(width: 8),
// 關閉按鈕
OutlinedButton(
onPressed: () => _sendCmd('${index + 1}off'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Text('關閉'),
),
],
),
);
}
}
不同版本 (手機版本)
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ESP32 MQTT 控制與燈態中心',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const MqttControlPage(),
);
}
}
class MqttControlPage extends StatefulWidget {
const MqttControlPage({super.key});
@override
State<MqttControlPage> createState() => _MqttControlPageState();
}
class _MqttControlPageState extends State<MqttControlPage> {
// --- MQTT 設定 ---
static const String broker = 'broker.hivemq.com';
static const int port = 1883;
static const String topicTemp = 'alex9ufo/temp';
static const String topicHumi = 'alex9ufo/humi';
static const String topicLed = 'alex9ufo/led';
static const String topicStatus = 'alex9ufo/status';
// --- 狀態變數 ---
String _temp = '--';
String _humi = '--';
String _latestStatus = '尚未接收';
final List<bool> _ledStates = [false, false, false, false];
late MqttServerClient _client;
bool _isConnected = false;
@override
void initState() {
super.initState();
_setupMqttClient();
}
@override
void dispose() {
_client.disconnect();
super.dispose();
}
// 初始化並連線 MQTT Broker
Future<void> _setupMqttClient() async {
final String clientIdentifier =
'flutter_client_${DateTime.now().millisecondsSinceEpoch}';
_client = MqttServerClient(broker, clientIdentifier);
_client.port = port;
_client.keepAlivePeriod = 60;
// --- 開啟詳細的除錯 Log ---
_client.logging(on: true);
// --- 註冊生命週期 Callback ---
_client.onConnected = () {
print('[MQTT 事件] 連線成功!');
if (mounted) {
setState(() => _isConnected = true);
}
};
_client.onDisconnected = () {
print('[MQTT 事件] 已斷線,狀態碼: ${_client.connectionStatus?.returnCode}');
if (mounted) {
setState(() => _isConnected = false);
}
};
_client.onSubscribed = (String topic) {
print('[MQTT 事件] 成功訂閱主題: $topic');
};
final connMessage = MqttConnectMessage()
.withClientIdentifier(clientIdentifier)
.startClean()
.withWillQos(MqttQos.atMostOnce);
_client.connectionMessage = connMessage;
try {
print('[MQTT] 正在嘗試連線至 $broker:$port...');
await _client.connect();
} catch (e) {
print('[MQTT 連線例外] 錯誤訊息: $e');
_client.disconnect();
return;
}
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
// 訂閱主題
_client.subscribe(topicTemp, MqttQos.atMostOnce);
_client.subscribe(topicHumi, MqttQos.atMostOnce);
_client.subscribe(topicStatus, MqttQos.atMostOnce);
// 監聽接收訊息
_client.updates?.listen(_onMessageReceived);
} else {
print('[MQTT 連線失敗] 狀態: ${_client.connectionStatus?.state}');
_client.disconnect();
}
}
// 處理收到的 MQTT 訊息
void _onMessageReceived(List<MqttReceivedMessage<MqttMessage>> c) {
final MqttPublishMessage recMessage = c[0].payload as MqttPublishMessage;
final String payload = MqttPublishPayload.bytesToStringAsString(
recMessage.payload.message,
).trim();
final String topic = c[0].topic;
print('[MQTT 收到] 主題: $topic | 內容: $payload');
if (!mounted) return;
setState(() {
if (topic == topicTemp) {
_temp = payload;
} else if (topic == topicHumi) {
_humi = payload;
} else if (topic == topicStatus) {
_latestStatus = payload;
_handleStatusPayload(payload);
}
});
}
// 解析 Status Payload 並更新燈態
void _handleStatusPayload(String payload) {
print("[除錯] 解析狀態 Payload: '$payload'");
if (['1on', '2on', '3on', '4on'].contains(payload)) {
int idx = int.parse(payload[0]) - 1;
_ledStates[idx] = true;
} else if (['1off', '2off', '3off', '4off'].contains(payload)) {
int idx = int.parse(payload[0]) - 1;
_ledStates[idx] = false;
} else if (payload == 'allon') {
for (int i = 0; i < 4; i++) {
_ledStates[i] = true;
}
} else if (payload == 'alloff') {
for (int i = 0; i < 4; i++) {
_ledStates[i] = false;
}
}
}
// 發送 MQTT 指令
void _sendCmd(String cmd) {
if (!_isConnected) {
print('[MQTT 錯誤] 未連線,無法發送指令');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('MQTT 未連線,請確認網路或重新整理'),
duration: Duration(seconds: 2),
),
);
return;
}
print('[MQTT 發送] 指令: $cmd');
final builder = MqttClientPayloadBuilder();
builder.addString(cmd);
_client.publishMessage(topicLed, MqttQos.atMostOnce, builder.payload!);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ESP32 MQTT 控制與燈態中心'),
centerTitle: true,
actions: [
IconButton(
icon: Icon(
_isConnected ? Icons.cloud_done : Icons.cloud_off,
color: _isConnected ? Colors.green : Colors.red,
),
onPressed: () {
if (!_isConnected) {
_setupMqttClient();
}
},
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// 1. 環境數據卡片
_buildSensorCard(),
const SizedBox(height: 15),
// 2. LED 控制卡片
_buildControlCard(),
const SizedBox(height: 20),
// 3. 最新狀態顯示
Text(
'最新狀態: $_latestStatus',
style: const TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
color: Colors.grey,
),
),
],
),
),
);
}
// 環境數據 UI 區塊
Widget _buildSensorCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'環境數據',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const Divider(),
Row(
children: [
const Text('溫度:', style: TextStyle(fontSize: 16)),
const SizedBox(width: 15),
Text(
'$_temp °C',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
const Text('濕度:', style: TextStyle(fontSize: 16)),
const SizedBox(width: 15),
Text(
'$_humi %',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
],
),
),
);
}
// LED 控制與實時狀態 UI 區塊
Widget _buildControlCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'LED 控制與實時狀態',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const Divider(),
for (int i = 0; i < 4; i++) _buildLedRow(i),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () => _sendCmd('allon'),
child: const Text('全開 (allon)'),
),
),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: () => _sendCmd('alloff'),
child: const Text('全關 (alloff)'),
),
),
],
),
],
),
),
);
}
// 單排 LED 控制列
Widget _buildLedRow(int index) {
final bool isOn = _ledStates[index];
final Color ledColor = isOn
? const Color(0xFF00FF00)
: const Color(0xFF1E1E1E);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
Text(
'LED ${index + 1}:',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(width: 10),
// 模擬指示燈
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: ledColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.grey, width: 1.5),
),
),
const Spacer(),
// 開啟按鈕
OutlinedButton(
onPressed: () => _sendCmd('${index + 1}on'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Text('開啟'),
),
const SizedBox(width: 8),
// 關閉按鈕
OutlinedButton(
onPressed: () => _sendCmd('${index + 1}off'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: const Text('關閉'),
),
],
),
);
}
}

