2026年10月1日 星期四

台中公車雙向即時資訊系統 (TaichungBusLiveQuery)

台中公車雙向即時資訊系統 (TaichungBusLiveQuery)

這份程式碼是一個基於 Python Tkinter 所開發的桌面應用程式,專門用來實時查詢臺中市公車預估到站時間。

它整合了交通部 TDX 運輸資料流通服務平臺的開放 API,並具備兩層式動態下拉選單、去程/返程分頁顯示、30 秒背景自動定時更新以及到站時間顏色提示等功能。

一、 主要功能亮點

  1. 二階聯動選單(業者 $\rightarrow$ 路線):

    • 第一層:先選擇公車業者(如:台中客運、統聯客運等)。

    • 第二層:根據所選業者,動態載入該業者營運的公車路線號碼。

  2. 去程與返程分頁呈現:

    • 利用 ttk.Notebook 標籤頁將路線分為「往程 (去程)」與「返程」兩個獨立表格,避免資料混雜。

  3. 顏色識別系統:

    • 去程基本字體:綠色 (darkgreen)。

    • 返程基本字體:黑色 (black)。

    • 進站預警($\le 10$ 分鐘):無論去程或返程,只要預估到站時間小於等於 10 分鐘(包含「即將進站」),該列會粗體紅色 (red) 顯示。

  4. 30 秒自動背景更新機制:

    • 選定路線後,系統每秒進行倒數(於介面上顯示 🔄 N 秒後自動更新)。

    • 倒數歸零時自動向 TDX 抓取最新到站時間,無需手動刷網頁,且更新過程 UI 介面不會凍結或卡頓。

二、 程式碼結構與運作原理

程式結構主要分為 API 資料處理 與 UI 介面邏輯 兩大模組:

┌──────────────────────────────────────┐
│                      主程式 (Tkinter)                                                   │
├─────────────── ┬──────────────────────┤
│       1. 認證與抓取           │         2. 介面互動                             │
│  • get_access_token()    │  • combo_operator (選業者)             │
│  • fetch_routes()             │  • combo_route (選路線)                   │
│  • fetch_eta_data()         │  • Treeview (去/返程表格)                │
├──────────────────────────┴───────────┤
│                     3. 自動更新機制                                                   │
│  • start_timer() / update_timer_tick() / stop_timer()                  │
└──────────────────────────────── ─────┘

1. API 驗證與資料請求模組

  • get_access_token():

    • 使用 OAuth2 client_credentials 模式,將您提供的 CLIENT_ID 及 CLIENT_SECRET 發送至 TDX 驗證伺服器 (AUTH_URL),取得臨時存取金鑰(Token)。

  • fetch_routes():

    • 向 API 請求臺中市所有公車路線清單,包含路線名稱與營運業者資訊。此動作僅在程式啟動時執行 1 次。

  • fetch_eta_data():

    • 向 API /v2/Bus/EstimatedTimeOfArrival/City/Taichung 請求全臺中市公車的即時預估到站資料。這部分會每 30 秒重複調用。

2. UI 互動與邏輯處理模組 (TaichungBusApp 類別)

  • init_data()(初始化):

    • 呼叫 API 取得路線資料後,整理成雜湊表結構:{ 業者名稱: { 路線名稱: [RouteUIDs] } }。

    • 填入第一層「業者」下拉選單。

  • on_operator_selected()(選擇業者時):

    • 當使用者選定業者後,根據業者名稱從資料結構中找出對應的路線,並更新第二層「路線」選單。同時暫停計時器並清空表格。

  • on_route_selected()(選擇路線時):

    • 選定路線後,立即調用 refresh_eta_and_update_ui() 抓取最新到站時間並畫在表格上,隨後調用 start_timer() 啟動 30 秒自動倒數。

  • refresh_eta_and_update_ui() 與 populate_tree()(更新與繪製):

    1. 根據目前選定的路線 UID,在所有到站資料中進行過濾。

    2. 依 Direction == 0(去程)與 Direction == 1(返程)將資料分流,並按 StopSequence(站牌順序)進行排序。

    3. 依到站時間進行邏輯判斷:

      • EstimateTime 換算為分鐘。

      • 若 $\le 10$ 分鐘,套用 red_text 標籤(紅色)。

      • 若 $> 10$ 分鐘,去程套用 green_text,返程套用 black_text。

      • 若未發車或異常,則查表 ETA_STATUS_MAP 顯示文字(如「尚未發車」、「末班車已過」)。

3. 自動更新機制 (after 非阻塞定時器)

程式沒有使用傳統多執行緒(Threading),而是使用 Tkinter 的內建排程 self.root.after(1000, self.update_timer_tick):

  1. 每秒執行一次 update_timer_tick(),將倒數秒數減 1,並更新 UI 狀態標籤(如:🔄 29 秒後自動更新)。

  2. 當 countdown == 0 時,觸發 API 重新讀取,刷新介面並將秒數重置為 30,形成循環。

  3. 當使用者切換業者或路線時,會觸發 stop_timer() 取消原本的排程,防止背景計時器重複或發送無效請求。

  4. TDX運輸資料流通服務平臺






import requests

import json

import tkinter as tk

from tkinter import ttk, messagebox


# ================= 1. TDX API 設定 =================

#CLIENT_ID = "YOUR_CLIENT_ID_HERE"      # 請填入您的 Client ID

#CLIENT_SECRET = "YOUR_CLIENT_SECRET_HERE"  # 請填入您的 Client Secret

# ================= 1. TDX API 設定 =================

CLIENT_ID = "alex9ufo-d413e6e281-d8e560-e4d296"      # 請填入您的 Client ID

CLIENT_SECRET = "9251521bb-81604-432060-b38c-8b2eade28ae704"  # 請填入您的 Client Secret


# API 網址設定

AUTH_URL = "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token"

ROUTE_API_URL = "https://tdx.transportdata.tw/api/basic/v2/Bus/Route/City/Taichung"

ETA_API_URL = "https://tdx.transportdata.tw/api/basic/v2/Bus/EstimatedTimeOfArrival/City/Taichung"


REFRESH_INTERVAL = 30  # 設定自動更新週期為 30 秒


# 狀態碼對照表

ETA_STATUS_MAP = {

    0: "正常預估",

    1: "尚未發車",

    2: "交管不停靠",

    3: "末班車已過",

    4: "今日未營運"

}


# ================= 2. API 請求函式 =================

def get_access_token():

    """取得 TDX OAuth2 Access Token"""

    auth_data = {

        'grant_type': 'client_credentials',

        'client_id': CLIENT_ID,

        'client_secret': CLIENT_SECRET

    }

    try:

        response = requests.post(AUTH_URL, data=auth_data, timeout=10)

        response.raise_for_status()

        return response.json().get('access_token')

    except Exception as e:

        messagebox.showerror("認證失敗", f"無法取得 Access Token,請檢查 Key 設定。\n{e}")

        return None


def fetch_routes(token):

    """取得公車路線與業者資訊"""

    headers = {"Authorization": f"Bearer {token}"}

    res = requests.get(f"{ROUTE_API_URL}?$format=JSON", headers=headers, timeout=10)

    return res.json() if res.status_code == 200 else []


def fetch_eta_data(token):

    """單獨取得預估到站資料 (批次更新)"""

    headers = {"Authorization": f"Bearer {token}"}

    res = requests.get(f"{ETA_API_URL}?$format=JSON", headers=headers, timeout=10)

    return res.json() if res.status_code == 200 else []


# ================= 3. Tkinter UI 邏輯 =================

class TaichungBusApp:

    def __init__(self, root):

        self.root = root

        self.root.title("臺中市公車預估到站查詢(30秒自動更新+顏色顯示)")

        self.root.geometry("680x580")


        # 資料與狀態儲存

        self.token = None

        self.operators_map = {}  # {業者名稱: {路線名稱: [RouteUID, ...]}}

        self.eta_data = []       # API 傳回的所有 ETA 資料

        

        self.countdown = REFRESH_INTERVAL  # 倒數計時器

        self.timer_id = None               # Tkinter after 計時器 ID


        # 畫面元件佈局

        self.setup_ui()

        

        # 初始化載入路線與業者資料

        self.init_data()


    def setup_ui(self):

        # 選單與狀態區域

        frame_select = ttk.Frame(self.root, padding=10)

        frame_select.pack(fill=tk.X)


        # 第一層選單:業者

        ttk.Label(frame_select, text="1. 選擇公車業者:").grid(row=0, column=0, sticky=tk.W, pady=5)

        self.combo_operator = ttk.Combobox(frame_select, state="readonly", width=28)

        self.combo_operator.grid(row=0, column=1, pady=5, padx=5)

        self.combo_operator.bind("<<ComboboxSelected>>", self.on_operator_selected)


        # 第二層選單:公車路線

        ttk.Label(frame_select, text="2. 選擇公車路線:").grid(row=1, column=0, sticky=tk.W, pady=5)

        self.combo_route = ttk.Combobox(frame_select, state="readonly", width=28)

        self.combo_route.grid(row=1, column=1, pady=5, padx=5)

        self.combo_route.bind("<<ComboboxSelected>>", self.on_route_selected)


        # 立即更新按鈕

        btn_refresh = ttk.Button(frame_select, text="手動立即更新", command=self.manual_refresh)

        btn_refresh.grid(row=0, column=2, padx=10)


        # 自動更新狀態提示標籤

        self.lbl_status = ttk.Label(frame_select, text="請選擇業者與路線", foreground="gray")

        self.lbl_status.grid(row=1, column=2, padx=10, sticky=tk.W)


        # 使用 Notebook (頁籤) 將「去程」與「返程」分開

        self.notebook = ttk.Notebook(self.root)

        self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)


        # 建立「去程」分頁與表格

        self.tab_outward = ttk.Frame(self.notebook)

        self.notebook.add(self.tab_outward, text=" 往程 (去程) ")

        self.tree_outward = self.create_treeview(self.tab_outward)


        # 建立「返程」分頁與表格

        self.tab_return = ttk.Frame(self.notebook)

        self.notebook.add(self.tab_return, text=" 返程 ")

        self.tree_return = self.create_treeview(self.tab_return)


        # 廣域設定 Treeview 樣式 tags 顏色

        # 去程預設:綠色,返程預設:黑色,10分鐘內:紅色

        for tree in (self.tree_outward, self.tree_return):

            tree.tag_configure("green_text", foreground="darkgreen")

            tree.tag_configure("black_text", foreground="black")

            tree.tag_configure("red_text", foreground="red", font=('TkDefaultFont', 9, 'bold'))


    def create_treeview(self, parent):

        """共用的表格建立邏輯"""

        frame = ttk.Frame(parent, padding=5)

        frame.pack(fill=tk.BOTH, expand=True)


        columns = ("stop_seq", "stop_name", "eta_status")

        tree = ttk.Treeview(frame, columns=columns, show="headings")

        

        tree.heading("stop_seq", text="順序")

        tree.heading("stop_name", text="站牌名稱")

        tree.heading("eta_status", text="預估到站狀態")

        

        tree.column("stop_seq", width=60, anchor=tk.CENTER)

        tree.column("stop_name", width=300, anchor=tk.W)

        tree.column("eta_status", width=180, anchor=tk.CENTER)


        scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=tree.yview)

        tree.configure(yscroll=scrollbar.set)

        

        tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        return tree


    def init_data(self):

        """取得 Token 並載入路線與業者資料"""

        self.token = get_access_token()

        if not self.token:

            return


        routes = fetch_routes(self.token)

        self.operators_map.clear()


        # 解析路線資料,整理為 { 業者: { 路線名: [RouteUIDs] } }

        for r in routes:

            operators = r.get("Operators", [])

            route_name = r.get("RouteName", {}).get("Zh_tw", "未知路線")

            route_uid = r.get("RouteUID")


            for op in operators:

                op_name = op.get("OperatorName", {}).get("Zh_tw", "其他業者")

                if op_name not in self.operators_map:

                    self.operators_map[op_name] = {}

                

                if route_name not in self.operators_map[op_name]:

                    self.operators_map[op_name][route_name] = []

                self.operators_map[op_name][route_name].append(route_uid)


        # 更新第一層選單(業者)

        operators_list = sorted(list(self.operators_map.keys()))

        self.combo_operator["values"] = operators_list

        self.combo_route["values"] = []

        self.combo_route.set("")

        self.combo_operator.set("")

        self.clear_trees()


    def clear_trees(self):

        """清空去程與返程表格"""

        for item in self.tree_outward.get_children():

            self.tree_outward.delete(item)

        for item in self.tree_return.get_children():

            self.tree_return.delete(item)


    def on_operator_selected(self, event):

        """選取業者時,更新路線選單,並停止定時器"""

        self.stop_timer()

        self.lbl_status.config(text="請選擇路線", foreground="gray")

        

        selected_op = self.combo_operator.get()

        if selected_op in self.operators_map:

            routes_list = sorted(list(self.operators_map[selected_op].keys()))

            self.combo_route["values"] = routes_list

            self.combo_route.set("")

            self.clear_trees()


    def on_route_selected(self, event):

        """選取路線時,觸發首次更新並啟動 30 秒自動更新"""

        self.stop_timer()

        self.refresh_eta_and_update_ui()

        self.start_timer()


    def refresh_eta_and_update_ui(self):

        """核心向 API 抓取 ETA 並刷新表格畫面的邏輯"""

        selected_op = self.combo_operator.get()

        selected_route = self.combo_route.get()


        if not selected_op or not selected_route or not self.token:

            return


        target_uids = self.operators_map.get(selected_op, {}).get(selected_route, [])

        

        # 重新向 API 請求最新預估到站資料

        self.eta_data = fetch_eta_data(self.token)

        self.clear_trees()


        # 篩選選擇路線的資料

        matched_etas = [eta for eta in self.eta_data if eta.get("RouteUID") in target_uids]


        # 分別整理去程 (Direction=0) 與 返程 (Direction=1)

        outward_list = [e for e in matched_etas if e.get("Direction") == 0]

        return_list = [e for e in matched_etas if e.get("Direction") == 1]


        # 依站牌順序 (StopSequence) 排序

        outward_list.sort(key=lambda x: x.get("StopSequence", 0))

        return_list.sort(key=lambda x: x.get("StopSequence", 0))


        # 填入 UI (去程傳入預設標籤 green_text, 返程傳入 black_text)

        self.populate_tree(self.tree_outward, outward_list, default_tag="green_text")

        self.populate_tree(self.tree_return, return_list, default_tag="black_text")


    def populate_tree(self, tree, eta_list, default_tag):

        """將 ETA 資料寫入 Treeview 並依條件判斷字型顏色"""

        for idx, eta in enumerate(eta_list, start=1):

            stop_seq = eta.get("StopSequence", idx)

            stop_name = eta.get("StopName", {}).get("Zh_tw", "未知站牌")

            status = eta.get("EstimateTime")

            

            tag_to_use = default_tag

            

            if status is not None:

                minutes = status // 60

                

                # 判定:若小於等於 10 分鐘(包含即將進站),使用紅色文字

                if minutes <= 10:

                    tag_to_use = "red_text"


                status_str = "即將進站" if minutes < 1 else f"{minutes} 分鐘"

            else:

                code = eta.get("EstimateStatus", 1)

                status_str = ETA_STATUS_MAP.get(code, "尚未開出")


            tree.insert("", tk.END, values=(stop_seq, stop_name, status_str), tags=(tag_to_use,))


    # ================= 4. 自動定時器機制 =================

    def start_timer(self):

        """啟動 1 秒執行一次的倒數計時器"""

        self.countdown = REFRESH_INTERVAL

        self.update_timer_tick()


    def stop_timer(self):

        """停止計時器"""

        if self.timer_id:

            self.root.after_cancel(self.timer_id)

            self.timer_id = None


    def update_timer_tick(self):

        """每秒更新一次倒數文字,歸零時觸發 API 重新整理"""

        self.lbl_status.config(text=f"🔄 {self.countdown} 秒後自動更新", foreground="blue")

        

        if self.countdown == 0:

            self.refresh_eta_and_update_ui()

            self.countdown = REFRESH_INTERVAL  # 重設倒數時間

        else:

            self.countdown -= 1


        # 排程 1000 毫秒 (1秒) 後再次觸發

        self.timer_id = self.root.after(1000, self.update_timer_tick)


    def manual_refresh(self):

        """手動立即更新按鈕事件"""

        if self.combo_route.get():

            self.stop_timer()

            self.refresh_eta_and_update_ui()

            self.start_timer()


# ================= 5. 主程式啟動 =================

if __name__ == "__main__":

    root = tk.Tk()

    app = TaichungBusApp(root)

    root.mainloop()


沒有留言:

張貼留言

台中公車雙向即時資訊系統 (TaichungBusLiveQuery)- Node-red 程式

  台中公車雙向即時資訊系統 ( TaichungBusLiveQuery )   Node-red 程式 Python Tkinter 是單機桌面 UI 程式,主動發起 API 請求並渲染視窗。 Node-RED 是基於事件驅動的流程(Flow-based)系統,適合在背...