MQTT 空品即時雙向監測站 (MQTT AQI Hub)
1. 專案套件配置 (pubspec.yaml)
name: aqi_app
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.13.5
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
mqtt_client: ^10.2.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
2. Flutter 完整程式碼 (main.dart)
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
void main() {
runApp(const AQIMonitorApp());
}
class AQIMonitorApp extends StatelessWidget {
const AQIMonitorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter AQI 監測站',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const AQIMonitorHomePage(),
);
}
}
// --- 全局常量與設定 ---
const String apiKey = "aa1bc2bf-24c2-4c55-980f-a12075c2caec";
const String defaultSite = "基隆";
const String mqttBroker = "broker.hivemq.com";
const int mqttPort = 1883;
const String subTopic = "alex9ufo/sitename";
const String pubTopic = "alex9ufo/weather";
const Duration updateInterval = Duration(minutes: 15);
// 84 個觀測站清單資料 (測站名稱, 縣市)
const List<MapEntry<String, String>> allSitesData = [
MapEntry("基隆", "基隆市"),
MapEntry("汐止", "新北市"),
MapEntry("新店", "新北市"),
MapEntry("土城", "新北市"),
MapEntry("板橋", "新北市"),
MapEntry("新莊", "新北市"),
MapEntry("菜寮", "新北市"),
MapEntry("林口", "新北市"),
MapEntry("淡水", "新北市"),
MapEntry("士林", "臺北市"),
MapEntry("中山", "臺北市"),
MapEntry("萬華", "臺北市"),
MapEntry("古亭", "臺北市"),
MapEntry("松山", "臺北市"),
MapEntry("大同", "臺北市"),
MapEntry("桃園", "桃園市"),
MapEntry("大園", "桃園市"),
MapEntry("觀音", "桃園市"),
MapEntry("平鎮", "桃園市"),
MapEntry("龍潭", "桃園市"),
MapEntry("湖口", "新竹縣"),
MapEntry("竹東", "新竹縣"),
MapEntry("新竹", "新竹市"),
MapEntry("頭份", "苗栗縣"),
MapEntry("苗栗", "苗栗縣"),
MapEntry("三義", "苗栗縣"),
MapEntry("豐原", "臺中市"),
MapEntry("沙鹿", "臺中市"),
MapEntry("大里", "臺中市"),
MapEntry("忠明", "臺中市"),
MapEntry("西屯", "臺中市"),
MapEntry("彰化", "彰化縣"),
MapEntry("線西", "彰化縣"),
MapEntry("二林", "彰化縣"),
MapEntry("南投", "南投縣"),
MapEntry("斗六", "雲林縣"),
MapEntry("崙背", "雲林縣"),
MapEntry("新港", "嘉義縣"),
MapEntry("朴子", "嘉義縣"),
MapEntry("臺西", "雲林縣"),
MapEntry("嘉義", "嘉義市"),
MapEntry("新營", "臺南市"),
MapEntry("善化", "臺南市"),
MapEntry("安南", "臺南市"),
MapEntry("臺南", "臺南市"),
MapEntry("美濃", "高雄市"),
MapEntry("橋頭", "高雄市"),
MapEntry("仁武", "高雄市"),
MapEntry("鳳山", "高雄市"),
MapEntry("大寮", "高雄市"),
MapEntry("林園", "高雄市"),
MapEntry("楠梓", "高雄市"),
MapEntry("左營", "高雄市"),
MapEntry("前金", "高雄市"),
MapEntry("前鎮", "高雄市"),
MapEntry("小港", "高雄市"),
MapEntry("屏東", "屏東縣"),
MapEntry("潮州", "屏東縣"),
MapEntry("恆春", "屏東縣"),
MapEntry("臺東", "臺東縣"),
MapEntry("花蓮", "花蓮縣"),
MapEntry("陽明", "臺北市"),
MapEntry("宜蘭", "宜蘭縣"),
MapEntry("冬山", "宜蘭縣"),
MapEntry("三重", "新北市"),
MapEntry("中壢", "桃園市"),
MapEntry("竹山", "南投縣"),
MapEntry("永和", "新北市"),
MapEntry("復興", "高雄市"),
MapEntry("埔里", "南投縣"),
MapEntry("馬祖", "連江縣"),
MapEntry("金門", "金門縣"),
MapEntry("馬公", "澎湖縣"),
MapEntry("關山", "臺東縣"),
MapEntry("麥寮", "雲林縣"),
MapEntry("富貴角", "新北市"),
MapEntry("大城", "彰化縣"),
MapEntry("臺灣大道", "臺中市"),
MapEntry("員林", "彰化縣"),
MapEntry("林森", "臺南市"),
MapEntry("南投(鹿谷)", "南投縣"),
MapEntry("屏東(琉球)", "屏東縣"),
MapEntry("新北(樹林)", "新北市"),
MapEntry("屏東(枋山)", "屏東縣"),
];
final Map<String, String> siteCountyMap = {
for (var item in allSitesData) item.key: item.value,
};
const Map<String, String> fieldNames = {
"sitename": "測站名稱",
"county": "縣市",
"aqi": "AQI 指標",
"pollutant": "主要污染物",
"status": "狀態",
"so2": "二氧化硫 (SO2 ppb)",
"co": "一氧化碳 (CO ppm)",
"o3": "臭氧 (O3 ppb)",
"o3_8hr": "臭氧8小時 (O3 8hr ppb)",
"pm10": "懸浮微粒 (PM10 ug/m3)",
"pm2.5": "細懸浮微粒 (PM2.5 ug/m3)",
"no2": "二氧化氮 (NO2 ppb)",
"nox": "氮氧化物 (NOx ppb)",
"no": "一氧化氮 (NO ppb)",
"wind_speed": "風速 (m/s)",
"wind_direc": "風向 (deg)",
"publishtime": "發布時間",
"co_8hr": "一氧化碳8小時 (CO 8hr)",
"pm2.5_avg": "PM2.5 移動平均",
"pm10_avg": "PM10 移動平均",
"so2_avg": "SO2 移動平均",
"longitude": "經度",
"latitude": "緯度",
"siteid": "測站代碼",
};
class AQIMonitorHomePage extends StatefulWidget {
const AQIMonitorHomePage({super.key});
@override
State<AQIMonitorHomePage> createState() => _AQIMonitorHomePageState();
}
class _AQIMonitorHomePageState extends State<AQIMonitorHomePage> {
String targetSite = defaultSite;
Map<String, dynamic> currentRecord = {};
// LCD 顯示文字
List<String> lcdLines = [
"AQI: ---",
"PM2.5: --- ug/m3",
"Connecting...",
"Time: --:--:--",
];
Color ledColor = Colors.grey;
String mqttStatus = "MQTT: 連線中...";
Color mqttStatusColor = Colors.orange;
MqttServerClient? mqttClient;
Timer? autoUpdateTimer;
@override
void initState() {
super.initState();
initMqtt();
fetchAirData();
// 15 分鐘定時自動更新
autoUpdateTimer = Timer.periodic(updateInterval, (_) {
fetchAirData();
});
}
@override
void dispose() {
autoUpdateTimer?.cancel();
mqttClient?.disconnect();
super.dispose();
}
// --- MQTT 連線與處理邏輯 ---
Future<void> initMqtt() async {
final clientId =
'Flutter-AQI-${Random().nextInt(0xFFFF).toRadixString(16).padLeft(4, '0')}';
mqttClient = MqttServerClient(mqttBroker, clientId)
..port = mqttPort
..logging(on: false)
..keepAlivePeriod = 60
..onConnected = onMqttConnected
..onDisconnected = onMqttDisconnected;
final connMess = MqttConnectMessage()
.withClientIdentifier(clientId)
.startClean();
mqttClient!.connectionMessage = connMess;
try {
await mqttClient!.connect();
} catch (e) {
debugPrint('[MQTT Error] 連線失敗: $e');
setState(() {
mqttStatus = "MQTT: 連線失敗";
mqttStatusColor = Colors.red;
});
}
}
void onMqttConnected() {
debugPrint('[MQTT] 已成功連線至 Broker: $mqttBroker');
setState(() {
mqttStatus = "MQTT: 已連線";
mqttStatusColor = Colors.green;
});
mqttClient!.subscribe(subTopic, MqttQos.atMostOnce);
debugPrint('[MQTT Sub] 已成功訂閱主題: $subTopic');
mqttClient!.updates!.listen((List<MqttReceivedMessage<MqttMessage>> c) {
final MqttPublishMessage recMess = c[0].payload as MqttPublishMessage;
final String rxSite = MqttPublishPayload.bytesToStringAsString(
recMess.payload.message,
).trim();
debugPrint("[MQTT Rx] 收到 Topic: '${c[0].topic}' | Payload 測站: '$rxSite'");
if (rxSite.isNotEmpty && rxSite != targetSite) {
setState(() {
targetSite = rxSite;
});
fetchAirData();
}
});
}
void onMqttDisconnected() {
setState(() {
mqttStatus = "MQTT: 已斷線";
mqttStatusColor = Colors.red;
});
}
void publishWeatherData(Map<String, dynamic> record) {
if (mqttClient?.connectionStatus?.state == MqttConnectionState.connected) {
final builder = MqttClientPayloadBuilder();
builder.addString(jsonEncode(record));
mqttClient!.publishMessage(
pubTopic,
MqttQos.atMostOnce,
builder.payload!,
);
debugPrint(
"[MQTT Tx] Published all data to topic '$pubTopic' successfully.",
);
}
}
// --- API 請求與數據處理 ---
Future<void> fetchAirData() async {
final encodedSite = Uri.encodeComponent(targetSite);
final url = Uri.parse(
'https://data.moenv.gov.tw/api/v2/aqx_p_432?limit=1&api_key=$apiKey&filters=sitename,eq,$encodedSite',
);
debugPrint("[HTTP GET] 請求網址: $url");
try {
final response = await http
.get(
url,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
List records = [];
if (data is List) {
records = data;
} else if (data is Map && data.containsKey('records')) {
records = data['records'];
}
if (records.isNotEmpty) {
processData(Map<String, dynamic>.from(records[0]));
} else {
loadFallbackData();
}
} else {
loadFallbackData();
}
} catch (e) {
debugPrint("[HTTP 請求失敗] 錯誤原因: $e");
loadFallbackData();
}
}
void processData(Map<String, dynamic> record) {
final sitename = record['sitename'] ?? targetSite;
final county = record['county'] ?? siteCountyMap[sitename] ?? "未知";
record['county'] = county;
final bool hasAqi =
record['aqi'] != null && record['aqi'] != '---' && record['aqi'] != '';
final bool hasPm25 =
record['pm2.5'] != null &&
record['pm2.5'] != '---' &&
record['pm2.5'] != '';
final int aqiVal = hasAqi ? int.tryParse(record['aqi'].toString()) ?? 0 : 0;
final double pm25Val = hasPm25
? double.tryParse(record['pm2.5'].toString()) ?? 0.0
: 0.0;
final String status = record['status'] ?? "No Data";
final String pubtime = record['publishtime'] ?? "--:--:--";
final String timeStr = pubtime.length >= 19
? pubtime.substring(11)
: pubtime;
final bool isBad = (hasAqi && aqiVal > 50) || (hasPm25 && pm25Val > 15.4);
final bool isValid = hasAqi || hasPm25;
Color currentLedColor;
if (!isValid) {
currentLedColor = Colors.grey;
} else if (isBad) {
currentLedColor = Colors.red;
} else {
currentLedColor = Colors.greenAccent;
}
final String engStatus = isValid ? getEnglishStatus(status) : "No Data";
final String line0 = "AQI: ${hasAqi ? aqiVal : '---'} ($engStatus)";
final String line1 =
"PM2.5: ${hasPm25 ? pm25Val.toStringAsFixed(1) : '---'} ug/m3";
final String line2 = isBad ? "Air Bad!" : (isValid ? "Air OK" : "No Data!");
final String line3 = "Time: $timeStr";
setState(() {
currentRecord = record;
lcdLines = [line0, line1, line2, line3];
ledColor = currentLedColor;
});
publishWeatherData(record);
}
void loadFallbackData() {
final String county = siteCountyMap[targetSite] ?? "未知";
final emptyRecord = <String, dynamic>{
"sitename": targetSite,
"county": county,
"aqi": "---",
"pollutant": "---",
"status": "---",
"so2": "---",
"co": "---",
"o3": "---",
"o3_8hr": "---",
"pm10": "---",
"pm2.5": "---",
"no2": "---",
"nox": "---",
"no": "---",
"wind_speed": "---",
"wind_direc": "---",
"publishtime": "--:--:--",
"co_8hr": "---",
"pm2.5_avg": "---",
"pm10_avg": "---",
"so2_avg": "---",
"longitude": "---",
"latitude": "---",
"siteid": "---",
};
processData(emptyRecord);
}
String getEnglishStatus(String status) {
const statusMap = {
"良好": "Good",
"普通": "Moderate",
"對敏感族群不健康": "Unhealthy*",
"對所有族群不健康": "Unhealthy",
"非常不健康": "V.Unhealthy",
"危害": "Hazardous",
};
return statusMap[status] ?? status;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('AQI 監測站 (下拉選單 & MQTT)'),
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. 控制區
_buildControlCard(),
const SizedBox(height: 10),
// 2. 模擬 20x4 LCD 螢幕區
_buildLcdDisplay(),
const SizedBox(height: 10),
// 3. 通訊與 LED 警報狀態
_buildStatusRow(),
const SizedBox(height: 10),
// 4. 分頁資料 (明細 / Raw JSON)
_buildDataTabs(),
],
),
),
);
}
// 下拉選單與控制 Card
Widget _buildControlCard() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Text(
'選擇觀測站: ',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<String>(
isExpanded: true,
value: allSitesData.any((e) => e.key == targetSite)
? targetSite
: defaultSite,
items: allSitesData.map((item) {
return DropdownMenuItem<String>(
value: item.key,
child: Text('${item.key} (${item.value})'),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null && newValue != targetSite) {
setState(() {
targetSite = newValue;
});
fetchAirData();
}
},
),
),
const SizedBox(width: 8),
ElevatedButton(onPressed: fetchAirData, child: const Text('強制更新')),
],
),
),
);
}
// 模擬 20x4 LCD 螢幕 (深綠底,亮綠文字)
Widget _buildLcdDisplay() {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: const Color(0xFF1B3022),
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: Colors.green.shade800, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: lcdLines.map((line) {
// 格式化確保 20 個字元等寬 padding
final paddedText = line.padRight(20).substring(0, 20);
return Text(
paddedText,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF33FF33),
letterSpacing: 1.2,
),
);
}).toList(),
),
);
}
// 狀態與 LED 燈號列
Widget _buildStatusRow() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Row(
children: [
const Text(
'警報 LED 狀態: ',
style: TextStyle(fontWeight: FontWeight.bold),
),
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: ledColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: ledColor.withValues(alpha: 0.6),
blurRadius: 6,
spreadRadius: 1,
),
],
),
),
const Spacer(),
Text(
mqttStatus,
style: TextStyle(
color: mqttStatusColor,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
// 明細表格與 RAW JSON 的 Tab 展示區
Widget _buildDataTabs() {
return SizedBox(
height: 380,
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
labelColor: Colors.teal,
indicatorColor: Colors.teal,
tabs: [
Tab(text: "所有數據明細"),
Tab(text: "RAW JSON"),
],
),
Expanded(
child: TabBarView(
children: [
// Tab 1: 明細列表
ListView.separated(
itemCount: currentRecord.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final key = currentRecord.keys.elementAt(index);
final val = currentRecord[key];
final cnName = fieldNames[key] ?? key;
final displayVal =
(val != null && val.toString().isNotEmpty)
? val.toString()
: "---";
return ListTile(
dense: true,
title: Text('$cnName ($key)'),
trailing: Text(
displayVal,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
);
},
),
// Tab 2: JSON 展示區
Container(
padding: const EdgeInsets.all(8.0),
color: Colors.grey.shade100,
child: SingleChildScrollView(
child: Text(
const JsonEncoder.withIndent(' ')
.convert(currentRecord),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
),
),
),
),
],
),
),
],
),
),
);
}
}


沒有留言:
張貼留言