2026年10月3日 星期六

MQTT 空品即時雙向監測站 (MQTT AQI Hub)

 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,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}



沒有留言:

張貼留言

MQTT 空品即時雙向監測站 (MQTT AQI Hub)

  MQTT 空品即時雙向監測站 (MQTT AQI Hub) 1. 專案套件配置 ( pubspec.yaml ) name : aqi_app description : "A new Flutter project." # The following ...