Omada API ตอบ Success แต่ค่าไม่เปลี่ยน: ฟิลด์ลับของ SSID/LAN/forward
ข้อความ Success จาก API บอกได้แค่ว่าคำขอผ่านการตรวจรูปแบบ ไม่ได้บอกว่าค่าในระบบเปลี่ยนไปแล้ว ถ้าสคริปต์ไม่อ่านค่ากลับมาเทียบ คนเขียนจะเชื่อ Success ไปเรื่อย ๆ จนเจอ Wi-Fi ที่ไม่ได้ทำตามที่สั่ง
สรุปใน 1 นาที
- อาการ/ปัญหา
- PATCH ไป Omada controller ได้ errorCode 0 Success แต่ device type ของ SSID ไม่เปลี่ยน
- ใครควรอ่าน
- คนเขียนสคริปต์คุม Omada controller ผ่าน API
- ทางแก้แบบสั้น
- อ่านค่ากลับมาเทียบหลังทุกคำสั่ง ถ้าแก้ SSID ไม่ได้ให้ลบแล้วสร้างใหม่โดยไม่ตัดกิ่งที่ตัวเองนั่ง และดูตารางฟิลด์ของ LAN, DHCP และ port forward
พอดีผมตั้งค่า Omada ของที่บ้านทั้งระบบด้วยสคริปต์ Python เพราะ API ของมันไม่มีเอกสารสาธารณะ ต้องแกะเอาจากหน้าเว็บกับการลองยิงเอง ช่วงหนึ่งผมสั่ง PATCH เปลี่ยน device type ของ SSID ได้ errorCode: 0 กับข้อความ Success. กลับมาสวย ๆ แต่พอเปิดดูใน controller ค่ายังเหมือนเดิมทุกตัวอักษร ผมนั่งเดาอยู่พักใหญ่ว่า token หมดอายุ หรือส่งผิด site ก่อนจะพบว่า controller รับคำขอ ตอบสำเร็จ แล้วทิ้งค่าที่ผมส่งไปเฉย ๆ
เรื่องพื้นฐานอย่างการ login, Csrf-Token และ self-signed cert ผมเขียนไว้แล้วใน คุม TP-Link Omada OC200 ผ่าน REST API ด้วย Python บทความนี้เล่าเฉพาะส่วนที่ต่อจากนั้น คือกับดักที่ error ไม่บอกอะไรเลย IP, MAC, ชื่อ SSID และรหัสในบทความผมเปลี่ยนเป็นตัวอย่างหมดแล้ว ฟิลด์กับ error code คือของจริงที่เจอในเซสชันตั้งค่าบ้านช่วงกลางเดือนตุลาคม และทุกอย่างเป็นพฤติกรรมของ controller รุ่นที่ผมใช้อยู่ ผมไม่ได้ลองกับเฟิร์มแวร์รุ่นอื่น เลยไม่รับรองว่าตรงกัน
อาการ: Success แต่ค่าเดิม
ผมต้องการ SSID ที่ปล่อยจาก AP และ gateway พร้อมกัน เลยส่ง PATCH เปลี่ยน deviceType ของ SSID ที่สร้างไว้แล้ว ค่าที่ลองมีสามแบบคือ 1 สำหรับ AP อย่างเดียว 2 สำหรับ gateway และ 3 สำหรับทั้งสอง ตามที่ผมไล่ลองเอง ดูแล้วเหมือน bitmask แบบเดียวกับ band ที่ 1 คือ 2.4G, 2 คือ 5G, 4 คือ 6G แต่ข้อนี้เป็นข้อสังเกตจากการลอง ไม่ใช่เอกสาร
# ตัวอย่างโครงสคริปต์ — SSID_URL คือ path ของ SSID ตัวนั้นที่ได้จาก GET list
import os, requests, urllib3
urllib3.disable_warnings()
B = "https://192.168.10.121"
CID = "<omadacId>"
A = f"{B}/{CID}/api/v2"
S = f"{A}/sites/<siteId>"
s = requests.Session()
s.verify = False
s.headers["Csrf-Token"] = os.environ["OMADA_TOKEN"] # ได้จากขั้น login
body = s.get(SSID_URL).json()["result"]
body["deviceType"] = 3
r = s.patch(SSID_URL, json=body, timeout=30)
print(r.status_code, r.json())
# 200 {'errorCode': 0, 'msg': 'Success.'}
ผลที่ได้คือ errorCode: 0 ตรงตามที่เล่า พอ GET กลับมาอ่านค่า deviceType ยังเป็น 1 ผมลองซ้ำสามรอบก็ได้ผลเหมือนกัน แสดงว่าไม่ใช่จังหวะผิด หน้าเว็บของ controller มีข้อความกำกับไว้ว่า device type ของ SSID แก้ไม่ได้หลังสร้างแล้ว คือฟิลด์ถูกล็อกตอนสร้าง แต่ API ไม่ได้ปฏิเสธคำขอที่พยายามแก้ มันรับ แล้วเมินเฉย
สมมติฐานของผมคือ API ตรวจรูปแบบของ body ว่าถูก schema แต่ไม่ตรวจว่าฟิลด์ที่ส่งมาเป็นฟิลด์ที่ห้ามเปลี่ยนหรือเปล่า ข้อนี้ผมไม่ได้พิสูจน์ เป็นคำอธิบายที่เข้ากับอาการเท่านั้น และเพราะไม่รู้สาเหตุแน่ ๆ ผมเลยไม่สรุปว่าฟิลด์อื่นจะทำแบบเดียวกัน แค่ตั้งกฎกับตัวเองว่าทุกคำสั่งเขียนต้องมี GET เทียบตามหลังเสมอ

เวลา API เขียนค่าได้ errorCode 0 ให้ถือว่าได้แค่ "คำขอถูกรูปแบบ" เท่านั้น ส่วนค่าที่เปลี่ยนจริงหรือไม่ต้องพิสูจน์ด้วยการอ่านกลับ แล้วเทียบเป็นฟิลด์ ๆ กับสิ่งที่ส่งไป ถ้าไม่ตรงให้สคริปต์ล้มเสียงดัง ไม่ใช่เดินต่อ
ตัวช่วยเล็ก ๆ ที่ผมใส่ในทุกคำสั่งเขียน
หลังเจอเคสนี้ผมเลิกเรียก patch ตรง ๆ แล้วห่อด้วยฟังก์ชันที่เขียน อ่านกลับ และเทียบเฉพาะฟิลด์ที่ส่งไป ถ้าฟิลด์ไหนไม่ตรงก็โยน error ออกมา พร้อมบอกว่าส่งอะไรไป ได้อะไรกลับมา
def patch_verified(s, url, changes: dict, read_url=None):
"""PATCH แล้ว GET กลับมาเทียบ ถ้าไม่ตรงให้ล้ม"""
cur = s.get(url).json()["result"]
cur.update(changes) # ส่ง object เต็มเสมอ ดูหัวข้อ LAN
r = s.patch(url, json=cur, timeout=30).json()
if r.get("errorCode") != 0:
raise RuntimeError(f"PATCH ล้ม: {r}")
after = s.get(read_url or url).json()["result"]
wrong = {k: (v, after.get(k)) for k, v in changes.items() if after.get(k) != v}
if wrong:
raise RuntimeError(f"errorCode 0 แต่ค่าไม่เปลี่ยน (ส่ง, ได้): {wrong}")
return after
ฟังก์ชันนี้ไม่ได้ฉลาด มันแค่ทำให้ "ค่าไม่เปลี่ยน" กลายเป็น exception ที่เห็นทันทีในเทอร์มินัล แทนที่จะรู้ตัวตอนผู้ใช้ในบ้านบอกว่า Wi-Fi ไม่ออกจาก gateway ข้อควรระวังคือฟิลด์บางตัวที่ controller ปรับรูปให้เอง เช่นลำดับ list หรือตัวพิมพ์ใหญ่เล็กของ MAC การเทียบตรง ๆ อาจเตือนเกินจริง ให้ปรับการเทียบเป็นฟิลด์ต่อฟิลด์ตามชนิดค่า
ทางออกของ SSID: ลบแล้วสร้างใหม่ แต่อย่าทำจาก Wi-Fi ตัวนั้น
เมื่อ device type แก้ไม่ได้ ทางเดียวที่ผมเจอคือลบ SSID แล้วสร้างใหม่ด้วยชื่อเดิม ลำดับมีผล เพราะถ้าสร้างชื่อซ้ำใน WLAN group เดียวกันก่อนลบ controller จะตอบ error -33219 เลยต้องลบก่อนแล้วค่อยสร้าง และช่วงระหว่างนั้น Wi-Fi บ้านดับประมาณ 1-2 นาที
ตรงนี้คือกับดักที่ผมเรียกว่า "ตัดกิ่งที่ตัวเองนั่งอยู่" ถ้าผมนั่งสั่งลบจากโน้ตบุ๊กที่ต่อ Wi-Fi ชื่อนั้นอยู่ พอ SSID หายการเชื่อมต่อของโน้ตบุ๊กก็ขาด คำสั่งสร้างใหม่ที่ตามมาไม่มีทางออกไปถึง controller ผมเลยสั่งงานผ่าน cloud portal ของ controller จากมือถือที่ต่อ 4G ซึ่งไม่ผ่าน Wi-Fi ของบ้านเลย session ที่สั่งงานเลยรอดตลอดช่วงที่ Wi-Fi ดับ
- เตรียมค่าทั้งหมดของ SSID เดิมไว้ก่อนลบ ชื่อ ความปลอดภัย band และรหัสที่เก็บจากแหล่งของตัวเอง ไม่ใช่จาก chat
- เปิด cloud portal ของ controller จากมือถือที่ปิด Wi-Fi และต่อ 4G แล้วยืนยันว่าเห็นหน้า controller จริง
- ลบ SSID เดิม แล้วสร้างใหม่ชื่อและรหัสเดิม ติ๊ก device type ให้ครบทั้ง EAP และ Gateway ในขั้นสร้าง
- รอ 1-2 นาที เครื่องในบ้านต่อกลับเองเพราะชื่อและรหัสเหมือนเดิม
- อ่านค่า SSID ใหม่กลับมา แล้วเทียบ device type ว่าเป็นค่าที่ตั้งใจ
การลบ SSID ตัดเครือข่ายไร้สายของทุกคนในบ้านจนกว่าจะสร้างใหม่เสร็จ และถ้า AP ทั้งหมดอาศัย Wi-Fi นั้นคุยกับ controller อยู่ก็จะเข้าไปแก้ต่อไม่ได้ ควรมีทางเข้า controller ที่ไม่ผ่าน SSID นั้นก่อนเริ่มเสมอ ผมทำที่บ้านตัวเองและพร้อมรับสภาพ ถ้าเป็นงานของลูกค้าต้องแจ้งช่วงเวลาที่เน็ตหลุดก่อน
LAN: ส่ง object เต็ม และ DHCP เป็นตัวเลข
เส้นทางแก้ Default LAN คือ PATCH ไปที่ {S}/setting/lan/networks/{id} ตรงนี้ controller ไม่เงียบ มันตอบ error ออกมาตรง ๆ แต่ข้อความบอกแค่ว่าฟิลด์ไหนเป็น null ไม่ได้บอกว่าต้องมีอะไรบ้าง ผมเจอสามเรื่องตามลำดับ
เรื่องแรก ต้องส่ง object เต็มที่ GET มา แล้วแก้เฉพาะช่องที่ต้องการ ถ้าตัดฟิลด์ออกจะได้ -1001 ว่าฟิลด์นั้น null เรื่องที่สอง lanNetworkIpv6Config ต้องมี proto ถ้าไม่มีจะได้ -1001 กับข้อความ Parameter [proto] should not be null แก้ด้วยการใส่ {"enable": 0, "proto": 0} เรื่องที่สาม dhcpSettings.ipRangeStart และ ipRangeEnd เป็นตัวเลข integer ไม่ใช่สตริง IP
import ipaddress
def ip2int(ip: str) -> int:
return int(ipaddress.IPv4Address(ip))
net = ipaddress.ip_network("192.168.10.0/24")
print(ip2int(str(net.network_address))) # 3232238080 ปลายต่ำของวง
print(ip2int(str(net.broadcast_address))) # 3232238335 ปลายสูงของวง
lan = s.get(f"{S}/setting/lan/networks/<lanId>").json()["result"]
lan["lanNetworkIpv6Config"] = {"enable": 0, "proto": 0}
lan["dhcpSettings"]["ipRangeStart"] = ip2int("192.168.10.100")
lan["dhcpSettings"]["ipRangeEnd"] = ip2int("192.168.10.200")
patch_verified(s, f"{S}/setting/lan/networks/<lanId>", {"dhcpSettings": lan["dhcpSettings"]})
ตัวเลข 3232238080 กับ 3232238335 คือ network address และ broadcast address ของวง 192.168.10.0/24 ตามที่แปลงได้ ผมใส่ไว้ให้เทียบว่าค่าที่ได้ออกมาถูกหน้าตา อีกกับดักคือถ้า site ยังไม่มี gateway เลย การแก้ Default LAN จะไม่ผ่าน ได้ error -1 หรือ -33515 พร้อมข้อความว่า LAN interfaces could not be none ต้อง adopt gateway เข้า site ให้เสร็จก่อนถึงจะแก้ได้
ตารางฟิลด์ที่เดาไม่ได้
ส่วนที่เหลือเป็นฟิลด์ที่ผมไม่มีทางเดาได้จากชื่อ ต้องเห็นของจริงถึงรู้ จดรวมไว้เป็นตารางเดียว
| งาน | ฟิลด์ที่เป็นกับดัก | อาการถ้าผิด |
|---|---|---|
| DHCP reservation | ใช้ชื่อ mac ไม่ใช่ clientMac |
-1001 ว่า must not be null |
| port forward | ต้องมี from: 0 (0 = Source IP Any) |
ไม่ได้จดอาการไว้ |
| port forward | interfaceWanPortId เป็น list ของ portUuid จาก /setting/wan/networks |
ไม่ได้จดอาการไว้ |
| port forward | virtualWanId: [] และ dMZ: false ตัว D และ M พิมพ์ใหญ่ |
ไม่ได้จดอาการไว้ |
| port forward | externalPort และ forwardPort เป็นสตริง ใส่ช่วงได้เช่น "80-100" |
ไม่ได้จดอาการไว้ |
| port forward | protocol: 0 = All, 1 = TCP, 2 = UDP |
ได้โปรโตคอลไม่ตรงที่ต้องการ |
| LAN | ipRangeStart และ ipRangeEnd เป็น integer |
ไม่ได้จดอาการไว้ |
ฟิลด์ของ port forward ผมไม่ได้เดา แต่ดักเอาจากหน้าเว็บตอนกดบันทึกจริง ทำแบบนี้ได้กับอุปกรณ์เกือบทุกตัวที่หน้าเว็บคุยกับ backend ผ่าน XHR ผมฉีด JS ครอบ XMLHttpRequest.prototype.open กับ send เก็บทุก request ที่ไม่ใช่ GET ไว้ใน window.__log แล้วกรอกฟอร์มด้วยการคลิกกับพิมพ์จริง กด Save แล้วอ่าน log เทคนิคนี้เขียนละเอียดไว้ใน ดักคำสั่งจากหน้าเว็บของอุปกรณ์ เลยไม่เล่าซ้ำ
# port forward — ตัวอย่างทั่วไป ค่า ip และพอร์ตเป็นตัวอย่าง
pf = {
"name": "web-example",
"status": True,
"from": 0, # Source IP: Any
"interfaceWanPortId": ["<portUuid>"],
"virtualWanId": [],
"externalPort": "8443", # สตริง ใส่ช่วงได้ เช่น "8000-8010"
"forwardPort": "443",
"forwardIp": "192.168.10.50",
"protocol": 1, # 1 = TCP
"dMZ": False, # D และ M ตัวใหญ่
}
r = s.post(f"{S}/setting/transmission/portForwardings", json=pf).json()
assert r["errorCode"] == 0, r
rows = s.get(f"{S}/setting/transmission/portForwardings").json()["result"]["data"]
assert any(x["name"] == "web-example" for x in rows), "ไม่เจอรายการที่เพิ่งสร้าง"
บรรทัดสุดท้ายคือกฎเดิม เขียนแล้วอ่านกลับ ตัวอย่างนี้ผมเขียนรูปทรงของ payload จากฟิลด์ที่ยืนยันแล้วในตาราง ชื่อฟิลด์อย่าง forwardIp กับ name ผมไม่ได้ย้ำกับ controller อีกรอบตอนเขียนบทความ ถ้านำไปใช้ให้เทียบกับ request ที่ดักจากหน้าเว็บของรุ่นที่ตัวเองมี

สคริปต์เดินต่อ
- ได้ Success ก็ไปขั้นถัดไป
- รู้ว่าผิดตอนผู้ใช้บ่น
- ไม่รู้ว่าฟิลด์ไหนถูกเมิน
- ย้อนหาสาเหตุยาก
สคริปต์ล้มทันที
- ส่งแล้ว GET แล้วเทียบเป็นฟิลด์
- ฟิลด์ที่ถูกเมินโผล่ในข้อความ error
- เพิ่มเวลาต่อคำสั่งเล็กน้อย
- รู้ทันทีว่าต้องลบสร้างใหม่
กับดักอื่นที่เจอระหว่างทาง
เรื่องเล็กที่ทำให้เสียเวลาไม่น้อยมีสามข้อ ข้อแรก controller ที่เพิ่งบูตหรือรีบูต /api/info จะตอบ 404 อยู่ประมาณ 2-5 นาที ทั้งที่หน้าเว็บขึ้นแล้ว ตั้ง timeout ของ login ไว้ 60-90 วินาที และอย่าตีความ 404 ช่วงนั้นว่าพัง ข้อสอง การ adopt อุปกรณ์ด้วย POST /cmd/devices/adopt ที่ส่ง {mac} มักหมดเวลาที่ 20 วินาที แต่คำสั่งสำเร็จจริง อย่ารีบ retry ให้ poll สถานะของ device แทน โดยรหัสสถานะที่ผมเจอคือ 14 = Connected, 20 = Pending, 10 และ 11 = Provisioning หรือ Configuring, 0 = Disconnected
import time
STATUS = {14: "Connected", 20: "Pending", 10: "Provisioning", 11: "Configuring", 0: "Disconnected"}
def wait_connected(s, mac, tries=40, gap=6):
for _ in range(tries):
devs = s.get(f"{S}/devices").json()["result"]
d = next((x for x in devs if x["mac"].upper() == mac.upper()), None)
st = d["status"] if d else None
print(f"[..] {mac} -> {STATUS.get(st, st)}")
if st == 14:
return d
time.sleep(gap)
raise TimeoutError(f"{mac} ไม่เป็น Connected ภายใน {tries*gap} วินาที")
wait_connected(s, "AA-BB-CC-DD-EE-FF") # ชื่อฟิลด์ status และโครง result ให้เทียบกับ response จริงของรุ่นที่ใช้
ข้อสาม หน้า login ของ controller ผ่านเบราว์เซอร์ที่สคริปต์ขับมีค่า autofill ค้างในช่อง user กับ password พิมพ์ทับแล้วข้อความต่อท้ายกันจนได้ Invalid username or password ต้องกด Ctrl+A ลบก่อนพิมพ์ทุกครั้ง ส่วนฝั่ง API ล้วน ๆ ไม่มีปัญหานี้
สิ่งที่ผมไม่รู้
ผมไม่ได้ลองว่าฟิลด์ข้างต้นเหมือนกันบนเฟิร์มแวร์รุ่นอื่นไหม และไม่รู้ความหมายของ errorCode อื่นนอกจากที่ระบุไว้ รวมถึงไม่รู้เหตุผลภายในที่ controller ยอมรับ PATCH แล้วเงียบ ที่เขียนมาข้างบนเป็นสมมติฐานที่เข้ากับอาการ ถ้าใครเจอพฤติกรรมต่างจากนี้บนรุ่นของตัวเองก็ถือว่าตารางนี้เป็นจุดเริ่มต้นให้ไปดักเทียบเองเลยครับ ส่วนวิธีที่ผมใช้ตลอดคือเขียนแล้วอ่านกลับ ทุกครั้งโดยไม่มีข้อยกเว้น ตรงไหนผมสรุปตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย