ลบใบหน้าในเครื่องสแกนไม่ได้ เพราะ API ตอบ notSupport
ปัญหาไม่ได้อยู่ที่เครื่องสแกนอ่านหน้าไม่แม่น มันแม่นเกินไปด้วยซ้ำ ปัญหาคือคนที่ลาออกไปเมื่อสองเดือนก่อนยังเดินผ่านประตูได้อยู่ เพราะคำสั่งลบใบหน้าที่เอกสารบอกว่ามี ยิงเข้าไปจริงแล้วเครื่องตอบกลับมาคำเดียวว่า notSupport
พอดีได้งานต่อยอดจากระบบทะเบียนพนักงานที่ทำค้างไว้ โจทย์คือให้ทะเบียนกลางกับเครื่องสแกนใบหน้าที่ประตูตรงกันโดยไม่ต้องมีใครเดินไปกดหน้าเครื่อง ผมนึกว่าเป็นงานครึ่งวัน อ่าน API แล้วเขียนลูป sync แค่นั้น แต่พอลงมือจริงกลับติดอยู่ตรงข้อเดียวคือ "ลบ" ไล่ยิงคำสั่งลบใบหน้าทุกแบบที่เอกสารระบุไว้ ได้คำตอบเดียวกันหมดคือ notSupport ทั้งที่หน้า capabilities ของเครื่องเองประกาศว่ารองรับ สุดท้ายทางที่ใช้ได้จริงไม่ใช่การลบ ผมเลยเอามาเขียนไว้ทั้งเส้นทาง เพราะกว่าจะเจอเสียเวลาไปสองวันเต็ม
เลข IP ในบทความเป็นค่าตัวอย่าง ส่วนคำสั่ง โครงสร้าง JSON และรหัสตอบกลับเป็นของจริงที่เจอมา
ขอบเขต: เครื่องของเราเอง กับรหัสที่ได้รับมอบมา
ต้องปักหมุดข้อนี้ก่อน สิ่งที่เล่าทั้งบทความคือการดูแลเครื่องควบคุมการเข้าออกที่อยู่ในความรับผิดชอบของเราเอง ในวงแลนที่เราดูแล ด้วยบัญชีผู้ดูแลที่ได้รับมอบมาพร้อมงาน มันคืองานทะเบียนทรัพย์สินกับทะเบียนคน ไม่ใช่การหาทางเข้าเครื่องที่ไม่ใช่ของเรา
โค้ดทุกชิ้นในบทความนี้ใช้บัญชีผู้ดูแลที่ได้รับมอบมาพร้อมงาน และยิงไปที่เครื่องในวงปิดที่เราดูแลเท่านั้น ระบบควบคุมการเข้าออกเป็นอุปกรณ์ที่กระทบสิทธิ์คน การเอาสคริปต์แบบนี้ไปยิงเครื่องที่ไม่ใช่ของตัวเองคือความผิดตามกฎหมาย และไม่ใช่สิ่งที่บทความนี้พูดถึง
กติกาที่ผมถือเสมอคือไม่ฝังรหัสผ่านลงไฟล์สคริปต์ อ่านจาก environment variable อย่างเดียว เพราะไฟล์พวกนี้สุดท้ายจะถูกก๊อปไปเครื่องอื่น ส่งต่อให้ช่างคนถัดไป หรือเผลอ commit ขึ้น git แล้วตามลบไม่ทัน
# บัญชีผู้ดูแลของเครื่องที่เราได้รับมอบมา วางไว้ใน environment ไม่ใช่ในไฟล์
export ACS_HOST=10.10.10.41
export ACS_USER=admin
export ACS_PASS='<PASSWORD>'
python face-sync.py --dry-run
import os, requests
from requests.auth import HTTPDigestAuth
HOST = os.environ["ACS_HOST"]
AUTH = HTTPDigestAuth(os.environ["ACS_USER"], os.environ["ACS_PASS"])
BASE = "http://%s/ISAPI" % HOST # ISAPI ใช้ Digest ไม่ใช่ Basic
ทำไมต้อง sync สองทาง ไม่ใช่ยิงทางเดียว
ตอนออกแบบรอบแรกผมคิดแบบง่ายที่สุดคือให้ทะเบียนกลางเป็นเจ้าของความจริง แล้วยิงคำสั่งลงเครื่องอย่างเดียว ใครเข้าใหม่ก็เพิ่ม ใครออกก็ลบ จบ ปรากฏว่าใช้ไม่ได้ เพราะของจริงมีคนไปกดหน้าเครื่องด้วย ช่างที่ไปติดตั้งเพิ่มอีกประตูก็ลงทะเบียนคนที่เครื่องตรง ๆ ไม่ได้ผ่านระบบกลาง พอ sync ทางเดียวคนกลุ่มนั้นจะโดนลบทิ้งทุกคืนโดยไม่มีใครรู้ว่าหายไปไหน
ระบบกลางทับเครื่องเสมอ
เขียนง่ายและคาดเดาได้ แต่ทุกอย่างที่ถูกสร้างที่หน้าเครื่องจะหายเงียบ ๆ ในรอบถัดไป และเราจะไม่มีทางรู้เลยว่าเครื่องเคยมีอะไรอยู่บ้าง เพราะไม่เคยอ่านกลับ
อ่านของจริงก่อนตัดสินใจ
ดึงทะเบียนในเครื่องออกมาเทียบกับระบบกลางทุกรอบ แล้วแยกเป็นสามกอง คือต้องเพิ่ม ต้องปิดสิทธิ์ และที่เครื่องมีแต่กลางไม่มี กองสุดท้ายไม่แตะ แค่รายงานให้คนตัดสินใจ
กองที่สามสำคัญกว่าที่คิดครับ เพราะมันคือรายการที่บอกว่าระบบกลางไม่ครบ ไม่ใช่รายการที่บอกว่าเครื่องผิด ผมให้สคริปต์พ่นกองนี้ออกมาเป็นไฟล์แยกทุกคืน แล้วเดือนแรกได้รายชื่อกลับมาสิบกว่ารายการที่ต้องไปตามเก็บเข้าระบบกลาง
อ่านทะเบียนในเครื่องออกมาก่อน ด้วย UserInfo/Search
ขาแรกของวงจรคือดึงรายชื่อในเครื่องออกมาให้หมด endpoint คือ POST /ISAPI/AccessControl/UserInfo/Search?format=json ตัวนี้เป็น paging ต้องวนขอทีละหน้า และต้องส่ง searchID เป็นค่าคงที่ของรอบนั้นไปด้วย ถ้าเปลี่ยน searchID กลางคันเครื่องจะเริ่มนับใหม่แล้ววนไม่จบ
{
"UserInfoSearchCond": {
"searchID": "sync-20261030",
"searchResultPosition": 0,
"maxResults": 30
}
}
def fetch_users(page_size=30):
out, pos = [], 0
while True:
body = {"UserInfoSearchCond": {"searchID": "sync-20261030",
"searchResultPosition": pos,
"maxResults": page_size}}
r = requests.post(BASE + "/AccessControl/UserInfo/Search?format=json",
auth=AUTH, json=body, timeout=10)
r.raise_for_status()
blk = r.json().get("UserInfoSearch", {})
rows = blk.get("UserInfo", []) or []
out.extend(rows)
# responseStatusStrg = MORE แปลว่ายังมีหน้าถัดไป OK แปลว่าหมดแล้ว
if blk.get("responseStatusStrg") != "MORE" or not rows:
return out
pos += len(rows)
ค่าที่ผมใช้จริงจากแต่ละแถวมีไม่กี่ตัว คือ employeeNo ที่เป็นกุญแจหลัก name และก้อน Valid ที่บอกว่าสิทธิ์ยังเปิดอยู่หรือไม่ กับช่วงเวลาที่ใช้ได้
{
"employeeNo": "A1007",
"name": "ตัวอย่าง ทดสอบ",
"userType": "normal",
"Valid": { "enable": true, "beginTime": "2026-01-01T00:00:00",
"endTime": "2030-12-31T23:59:59", "timeType": "local" },
"numOfFace": 1
}
numOfFace คือจำนวนใบหน้าที่ผูกกับคนนั้น ผมใช้ค่านี้เป็นตัวชี้ว่าคนไหนลงทะเบียนใบหน้าไว้แล้วบ้าง เพราะคนที่มีแต่บัตรไม่มีใบหน้าจะเป็นศูนย์
ทางที่ตัน: คำสั่งลบใบหน้าตอบ notSupport ทุกรูปแบบ
พอได้รายชื่อมาแล้ว ขั้นต่อไปที่คิดว่าง่ายที่สุดคือลบใบหน้าของคนที่ออกไปแล้ว เอกสารของตระกูลนี้บอกว่าคลังใบหน้าอยู่ใต้ FDLib แล้วลบด้วย FDSearch ได้ ผมเลยลองยิงตรง ๆ ก่อนด้วย curl
curl -s --digest -u "$ACS_USER:$ACS_PASS" -X PUT \
"http://$ACS_HOST/ISAPI/Intelligent/FDLib/FDSearch/Delete?format=json&FDID=1&faceLibType=blackFD" \
-H 'Content-Type: application/json' \
-d '{"FPID":[{"value":"A1007"}]}'
ได้กลับมาแบบนี้ และได้แบบเดียวกันเป๊ะไม่ว่าจะเปลี่ยน faceLibType เป็นอะไร เปลี่ยน method เป็น DELETE ยิงแบบมี FDID หรือไม่มี ใส่ FPID เป็น employeeNo หรือเป็นค่า PID ที่อ่านมาจากคลังเอง
{
"statusCode": 4,
"statusString": "Invalid Operation",
"subStatusCode": "notSupport",
"errorCode": 1073741830,
"errorMsg": "notSupport"
}
ที่ทำให้เสียเวลาคือหน้า capabilities ของเครื่องประกาศว่ามีความสามารถนี้ชัด ๆ ลองเรียกดูแล้วยังเห็นชื่อ operation ครบ
curl -s --digest -u "$ACS_USER:$ACS_PASS" \
"http://$ACS_HOST/ISAPI/Intelligent/FDLib/capabilities?format=json" | head -30
ก้อน capabilities ของอุปกรณ์ตระกูลนี้เป็นสเปกของตระกูล ไม่ใช่ของรุ่น เครื่องปลายทางที่เป็นเทอร์มินัลควบคุมประตูใช้เฟิร์มแวร์คนละสายกับกล้องวิเคราะห์ภาพ คลังใบหน้าถูกผูกติดกับระเบียนผู้ใช้แน่นกว่าจนไม่มีคำสั่งลบเดี่ยว ๆ ให้ใช้ ผมเสียเวลาไล่ยิงอยู่เกือบวันเพราะเชื่อ capabilities มากเกินไป
ข้อสรุปว่า "ลบรูปใบหน้าเดี่ยว ๆ ไม่ได้" ในหัวข้อนี้ผมมาเจอทีหลังว่าไม่จริง ปุ่มลบในหน้าเว็บของเครื่องเองทำได้ และคำสั่งที่มันใช้ไม่อยู่ในเอกสาร วิธีหาคำสั่งนั้นผมเขียนแยกไว้ใน ตอนดัก HAR จากหน้าเว็บของเครื่อง ส่วนวิธีปิดสิทธิ์ข้างล่างนี้ยังใช้ได้และเร็วกว่าเมื่อโจทย์คือแค่ไม่ให้เข้าประตู
ทางที่ใช้ได้จริง: ไม่ลบ แต่ปิดสิทธิ์ทิ้ง
ทางออกที่ใช้ได้จริงคือเลิกคิดว่าต้องลบ แล้วไปแก้ที่ระเบียนผู้ใช้แทน endpoint คือ PUT /ISAPI/AccessControl/UserInfo/Modify?format=json แล้วสั่งปิด Valid.enable เป็น false ผลลัพธ์ที่ประตูคือคนนั้นสแกนแล้วไม่เปิด ซึ่งตรงกับสิ่งที่โจทย์ต้องการอยู่แล้ว
{
"UserInfo": {
"employeeNo": "A1007",
"Valid": { "enable": false }
}
}
จุดที่กินเวลาอีกครึ่งวันคือ body ต้องสั้นแค่นี้จริง ๆ รอบแรกผมส่ง Valid ไปทั้งก้อนตามที่อ่านกลับมาจาก Search คือมี beginTime endTime timeType ครบ เพราะคิดว่าส่งของเดิมกลับไปน่าจะปลอดภัยที่สุด ปรากฏว่าเด้ง
{
"statusCode": 6,
"statusString": "Invalid Content",
"subStatusCode": "badJsonContent",
"errorCode": 1073741825,
"errorMsg": "badJsonContent"
}
ตัวที่ทำให้พังคือ beginTime ที่มีอยู่แล้วในเครื่อง พอส่งกลับไปพร้อมกับคำสั่งแก้ เครื่องตีความว่าเป็นการตั้งค่าช่วงเวลาใหม่แล้วตรวจรูปแบบเข้มกว่าตอนอ่านออกมา ผมเลยตัดทุกฟิลด์ที่ไม่ได้ตั้งใจแก้ออกให้หมด เหลือ employeeNo กับสิ่งที่จะเปลี่ยนเท่านั้น แล้วผ่านทันที
def disable_user(employee_no):
"""ปิดสิทธิ์แทนการลบ body ต้องสั้นที่สุด มีแค่กุญแจกับฟิลด์ที่จะเปลี่ยน"""
body = {"UserInfo": {"employeeNo": str(employee_no),
"Valid": {"enable": False}}}
r = requests.put(BASE + "/AccessControl/UserInfo/Modify?format=json",
auth=AUTH, json=body, timeout=10)
j = r.json()
if j.get("statusCode") != 1:
raise RuntimeError("%s / %s" % (j.get("subStatusCode"), j.get("errorMsg")))
return True
ของที่อ่านออกมาได้ ไม่ได้แปลว่าส่งกลับเข้าไปได้ทั้งก้อน ตอนแก้ค่าให้ส่งเฉพาะกุญแจ กับฟิลด์ที่ตั้งใจจะเปลี่ยน เท่านั้น ฟิลด์เวลาทุกตัวคือตัวจุดชนวน badJsonContent ที่เจอบ่อยที่สุด กฎนี้ใช้ได้กับ endpoint อื่นในตระกูลเดียวกันด้วย ไม่ใช่เฉพาะ UserInfo
ข้อดีที่ตามมาโดยไม่ได้ตั้งใจคือการปิดสิทธิ์เก็บร่องรอยไว้ ต่างจากการลบทิ้ง ถ้าวันหนึ่งต้องย้อนดูว่าคนนี้เคยมีสิทธิ์เข้าประตูไหนบ้างจนถึงวันไหน ระเบียนยังอยู่ครบ ส่วนตัวผมว่าในระบบที่กระทบสิทธิ์คน การปิดดีกว่าการลบอยู่แล้ว
วงจร sync ทั้งรอบ กับกติกาที่ต้องมี
พอได้ทั้งขาอ่านและขาเขียนแล้ว ที่เหลือคือประกอบเป็นรอบเดียวจบ ผมให้มันรันตีหนึ่งของทุกคืน รอบหนึ่งใช้เวลาไม่ถึงนาทีสำหรับทะเบียนหลักร้อยคน
def sync(hr_active, dry_run=True):
on_device = {u["employeeNo"]: u for u in fetch_users()}
to_disable, unknown, plan = [], [], []
for emp, u in on_device.items():
enabled = u.get("Valid", {}).get("enable", False)
if emp in hr_active:
continue # ยังทำงานอยู่ ไม่แตะ
if emp.startswith("A") and enabled:
to_disable.append(emp) # กองที่ระบบกลางบอกว่าออกแล้ว
elif emp not in hr_active:
unknown.append(emp) # ลงทะเบียนที่หน้าเครื่อง ไม่แตะ
for emp in to_disable:
plan.append(("disable", emp))
if not dry_run:
disable_user(emp)
return plan, unknown
กติกาสามข้อที่ผมบังคับใส่ไว้ในสคริปต์ตัวนี้ หนึ่ง ค่า default ของสคริปต์คือ --dry-run ต้องพิมพ์ --apply เท่านั้นถึงจะเขียนจริง สอง ถ้ารอบไหนจะปิดสิทธิ์เกินสิบคนพร้อมกันให้หยุดแล้วรายงานเฉย ๆ เพราะนั่นแปลว่าทะเบียนกลางเพี้ยน ไม่ใช่คนลาออกพร้อมกันสิบคน สาม ห้ามแตะระเบียนที่ไม่ได้ขึ้นต้นด้วยรหัสชุดที่ระบบกลางเป็นคนออกให้
# รันจริงต้องพิมพ์ apply เท่านั้น และมีเพดานกันทะเบียนกลางเพี้ยน
python face-sync.py --apply --max-disable 10 | tee /var/log/face-sync/$(date +%F).log
log ในเครื่องเต็มแล้วลบไม่ได้ เหลือแต่ล้างทั้งเครื่อง
เรื่องที่เจอตามมาหลังระบบเดินได้คือบันทึกเหตุการณ์ในตัวเครื่อง อ่านออกมาได้ผ่าน POST /ISAPI/AccessControl/AcsEvent?format=json ค้นย้อนหลังตามช่วงเวลาได้ปกติ แต่พอความจุใกล้เต็มแล้วอยากตัดของเก่าทิ้ง กลับไม่มีคำสั่งให้ลบเฉพาะช่วง มีแต่การล้างค่าโรงงานซึ่งล้างทะเบียนคนไปด้วยทั้งชุด
curl -s --digest -u "$ACS_USER:$ACS_PASS" -X POST \
"http://$ACS_HOST/ISAPI/AccessControl/AcsEvent?format=json" \
-H 'Content-Type: application/json' \
-d '{"AcsEventCond":{"searchID":"logdump","searchResultPosition":0,
"maxResults":24,"major":5,"minor":0,
"startTime":"2026-10-01T00:00:00+07:00",
"endTime":"2026-10-30T23:59:59+07:00"}}'
คำสั่งล้างค่าโรงงานเป็นทางเดียวที่ล้างบันทึกเหตุการณ์ในเครื่องได้จริง แต่มันล้างทะเบียนผู้ใช้และใบหน้าทั้งหมดไปพร้อมกัน ถ้ากดไปแล้วไม่มีสำเนาทะเบียนอยู่นอกเครื่อง แปลว่าต้องเรียกคนทั้งหมดมาลงทะเบียนใบหน้าใหม่ทีละคน ทางที่ถูกคือดูดเก็บออกมาเป็นไฟล์ตามรอบ แล้วปล่อยให้ตัวเครื่องวนทับของเก่าเอง
ตัวเลขความจุของรุ่นที่ผมเจอคือเก็บได้ประมาณหนึ่งแสนห้าหมื่นเหตุการณ์แล้ววนทับ ประตูที่คนผ่านวันละพันกว่าครั้งแปลว่าเก็บย้อนหลังได้ราวสี่เดือน ซึ่งสั้นกว่าที่หลายที่ต้องเก็บตามนโยบายภายใน ผมเลยให้สคริปต์ตัวเดียวกันดูดบันทึกออกมาเป็น JSON รายวันเก็บไว้นอกเครื่องตั้งแต่วันแรก ไม่รอให้ถึงวันที่ต้องใช้
สรุปเป็นกติกาที่ผมยึดกับอุปกรณ์ตระกูลนี้
- ยืนยันขอบเขตก่อนเริ่มทุกครั้ง ว่าเครื่องอยู่ในความดูแลของเรา และบัญชีที่ใช้คือของที่ได้รับมอบมา วางไว้ใน environment ไม่ใช่ในไฟล์
- อย่าเชื่อ capabilities เต็มร้อย ทดสอบคำสั่งที่จะใช้จริงกับเครื่องรุ่นนั้นด้วย curl ก่อนลงมือเขียนสคริปต์
- ตอนแก้ค่า ส่ง body สั้นที่สุดเสมอ มีแค่กุญแจกับฟิลด์ที่ตั้งใจเปลี่ยน ฟิลด์เวลาเป็นตัวจุดชนวน badJsonContent
- ออกแบบเป็น sync สองทาง อ่านของจริงในเครื่องมาเทียบก่อนตัดสินใจ กองที่เครื่องมีแต่ระบบกลางไม่มี ให้รายงาน ไม่ใช่ลบ
- ปิดสิทธิ์แทนการลบ เก็บร่องรอยไว้ และได้ผลที่ประตูเหมือนกัน
- ดูดบันทึกเหตุการณ์ออกมาเก็บนอกเครื่องตั้งแต่วันแรก เพราะในเครื่องวนทับ และล้างเฉพาะ log ไม่ได้
- ทุกสคริปต์ที่เขียนลงอุปกรณ์ ต้อง dry-run เป็นค่าเริ่มต้น และมีเพดานจำนวนรายการต่อรอบ
ส่วนตัวผมคิดว่าบทเรียนจริงของงานนี้ไม่ใช่เรื่องยี่ห้อหรือ endpoint ไหนใช้ได้ไม่ได้ แต่เป็นเรื่องที่ว่าเอกสารกับของจริงไม่ตรงกันเป็นเรื่องปกติของอุปกรณ์ฝังตัว คนที่เขียนโค้ดคุยกับอุปกรณ์ต้องทดสอบคำสั่งจริงกับเครื่องจริงก่อนเสมอ แล้วออกแบบให้ระบบทำงานต่อได้แม้คำสั่งที่หวังไว้จะใช้ไม่ได้ ในเคสนี้ถ้าผมยืนยันว่าต้องลบให้ได้อย่างเดียว งานคงค้างอยู่จนทุกวันนี้
ที่ยังค้างในแผนของผมคือทำขาที่สามเพิ่ม ให้เครื่องยิงเหตุการณ์กลับมาหาเราเองแบบ event-driven แทนที่จะรอรอบตีหนึ่ง กับหาวิธีดึงรูปใบหน้าที่ลงทะเบียนไว้ออกมาสำรองนอกเครื่อง เผื่อวันที่ต้องล้างค่าโรงงานจริง ๆ จะได้ไม่ต้องเรียกคนมาถ่ายใหม่ทั้งหมด ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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