台中公車雙向即時資訊系統 (TaichungBusLiveQuery)
這份程式碼是一個基於 Python Tkinter 所開發的桌面應用程式,專門用來實時查詢臺中市公車預估到站時間。
它整合了交通部 TDX 運輸資料流通服務平臺的開放 API,並具備兩層式動態下拉選單、去程/返程分頁顯示、30 秒背景自動定時更新以及到站時間顏色提示等功能。
一、 主要功能亮點
二階聯動選單(業者 $\rightarrow$ 路線):
第一層:先選擇公車業者(如:台中客運、統聯客運等)。
第二層:根據所選業者,動態載入該業者營運的公車路線號碼。
去程與返程分頁呈現:
利用
ttk.Notebook標籤頁將路線分為「往程 (去程)」與「返程」兩個獨立表格,避免資料混雜。
顏色識別系統:
去程基本字體:綠色 (
darkgreen)。返程基本字體:黑色 (
black)。進站預警($\le 10$ 分鐘):無論去程或返程,只要預估到站時間小於等於 10 分鐘(包含「即將進站」),該列會粗體紅色 (
red) 顯示。
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()(更新與繪製):根據目前選定的路線 UID,在所有到站資料中進行過濾。
依
Direction == 0(去程)與Direction == 1(返程)將資料分流,並按StopSequence(站牌順序)進行排序。依到站時間進行邏輯判斷:
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):
每秒執行一次
update_timer_tick(),將倒數秒數減 1,並更新 UI 狀態標籤(如:🔄 29 秒後自動更新)。當
countdown == 0時,觸發 API 重新讀取,刷新介面並將秒數重置為 30,形成循環。當使用者切換業者或路線時,會觸發
stop_timer()取消原本的排程,防止背景計時器重複或發送無效請求。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()




沒有留言:
張貼留言