ออดิตกล้อง CCTV ในวงเองด้วย ISAPI กับ ffprobe RTSP ไม่ผ่าน NVR
กล้องขึ้นเขียวบนหน้า NVR ไม่ได้แปลว่ากล้องดี มันแปลว่า NVR ยังต่อได้เท่านั้น สิ่งที่ต้องรู้จริงคือรุ่นอะไร firmware ปีไหน และสตรีมที่ออกมาเป็นภาพความละเอียดเท่าไหร่ที่กี่เฟรม ซึ่งต้องไปถามกล้องเอง
สรุปใน 1 นาที
- อาการ/ปัญหา
- NVR บอกแค่ว่ากล้องออนไลน์ ไม่บอกรุ่น firmware หรือความละเอียดสตรีมจริง
- ใครควรอ่าน
- คนดูแลระบบกล้องหลายยี่ห้อที่ไม่มีทะเบียนอุปกรณ์
- ทางแก้แบบสั้น
- สคริปต์ยิง ISAPI deviceInfo เข้ากล้องทีละตัว แล้วใช้ ffprobe อ่านสตรีม RTSP จริง ได้ทะเบียนอุปกรณ์ที่รันซ้ำเป็น health check ได้
พอดีผมเข้าไปดูระบบกล้องในอาคารที่รับดูแลต่อ สิบหกตัว หลายยี่ห้อ ติดตั้งคนละยุคกัน เอกสารที่ได้มามีแค่รายการซื้อขายกับผังจุดติดตั้ง ไม่มีทะเบียนอุปกรณ์ว่าตัวไหนรุ่นอะไร firmware อะไร เข้าหน้า NVR ดูก็เห็นแค่ชื่อช่องกับสถานะออนไลน์ ซึ่งไม่พอสำหรับตัดสินใจว่าตัวไหนต้องเปลี่ยนก่อน ผมเลยเขียนสคริปต์ไล่ถามกล้องทุกตัวโดยตรง ไม่ผ่าน NVR แล้วประกอบเป็นทะเบียนขึ้นมาใหม่ทั้งชุด ใช้เวลาไม่ถึงวันแต่ได้ของที่รันซ้ำทุกเดือนเป็น health check ได้เลย
เลข IP ในบทความเป็นค่าตัวอย่าง ส่วนคำสั่งและโค้ดคือของจริง
ขอบเขต: อุปกรณ์ของตัวเอง กับ credential ที่เราถือสิทธิ์อยู่
ต้องพูดเรื่องนี้ก่อนเป็นข้อแรก งานที่จะเล่าทั้งหมดคือการตรวจสอบอุปกรณ์ที่เราดูแลอยู่ ในวงที่เราดูแลอยู่ ด้วยรหัสผ่านที่เราได้รับมอบมาพร้อมงาน มันคือการตรวจทะเบียนทรัพย์สินของตัวเอง ไม่ใช่การไปหาทางเข้ากล้องที่ไม่ใช่ของเรา
สคริปต์ในบทความนี้ใช้ credential ที่เราถือสิทธิ์อยู่แล้ว จากเอกสารส่งมอบงาน สิ่งที่มันทำคือจับคู่ว่ากล้องตัวไหนอยู่ชุดไหน เพราะอุปกรณ์ที่ติดตั้งคนละปีมักตั้งรหัสคนละชุดตามผู้รับเหมาแต่ละราย ตัวที่ไม่ตรงกับชุดใดเลยจะถูกทำเครื่องหมายว่า "ต้องไปรีเซ็ตที่ตัวเครื่อง" ไม่ใช่ไปไล่ลองต่อ การเอาสคริปต์แบบนี้ไปยิงอุปกรณ์ที่ไม่ใช่ของตัวเองคือความผิดตามกฎหมาย และไม่ใช่สิ่งที่บทความนี้พูดถึง
กฎอีกข้อที่ผมถือคือไม่ฝังรหัสผ่านในไฟล์สคริปต์เด็ดขาด อ่านจาก environment variable อย่างเดียว เพราะสคริปต์พวกนี้สุดท้ายจะถูกก๊อปไปมา ส่งให้ช่างคนอื่น หรือเผลอ commit ขึ้น git
# ชุด credential ที่เราได้รับมอบมา วางไว้ใน environment ไม่ใช่ในไฟล์
export CAM_CREDS='admin:<PASSWORD>,admin:<PASSWORD>'
export NVR_CRED='admin:<PASSWORD>'
python cam-audit.py --net 192.168.10.0/24
import os
# "admin:<PASSWORD>,admin:<PASSWORD>" -> [("admin","..."), ("admin","...")]
CREDS = [tuple(c.split(":", 1)) for c in os.environ["CAM_CREDS"].split(",") if ":" in c]
ทำไมต้องถามกล้องตรง ไม่ดูจากหน้า NVR
เห็นแค่สิ่งที่ NVR จำไว้
สถานะออนไลน์คือ "NVR ต่อได้" เท่านั้น ชื่อช่องเป็นชื่อที่คนตั้ง ไม่ใช่ชื่อในตัวกล้อง ค่า resolution กับ bitrate ที่โชว์คือค่าที่ NVR ขอไว้ตอนเพิ่มช่อง ถ้ามีคนไปแก้ในกล้องทีหลัง หน้า NVR ไม่รู้เรื่องด้วย
ได้ของจริงจากตัวอุปกรณ์
รุ่น firmware serial MAC มาจาก ISAPI ของกล้องเอง ส่วน codec ความละเอียด และเฟรมเรตอ่านจากสตรีมที่ไหลออกมาจริง ด้วย ffprobe ไม่ใช่ค่าที่ใครจดไว้
เคสที่ทำให้ผมเลิกเชื่อหน้า NVR คือช่องหนึ่งขึ้นออนไลน์เขียวสวยงาม แต่พอ ffprobe เข้าไปดูจริงพบว่าสตรีมออกมาที่ความละเอียดต่ำกว่าที่ควรเป็นมาก เพราะมีคนเคยเข้าไปแก้ในตัวกล้องแล้วลืมแก้กลับ ดูจากหน้า NVR อีกสิบปีก็ไม่เจอครับ
ISAPI deviceInfo: ได้ทะเบียนอุปกรณ์ในคำสั่งเดียว
กล้อง Hikvision และรุ่นที่ใช้ firmware ตระกูลเดียวกันเปิด HTTP API ที่เรียกว่า ISAPI ไว้ ยืนยันตัวตนด้วย HTTP Digest ไม่ใช่ Basic ตัวที่ให้ข้อมูลทะเบียนครบที่สุดคือ /ISAPI/System/deviceInfo คืนมาเป็น XML
ลองด้วย curl ก่อนก็ได้ครับ ดูว่าเข้าถึงได้จริงไหม แล้วค่อยไปเขียนสคริปต์
curl -s --digest -u "admin:$CAM_PW" \
http://192.168.10.211/ISAPI/System/deviceInfo | head -20
ค่าที่ผมดึงออกมาใช้จริงมีห้าตัวคือ model, firmwareVersion, serialNumber, macAddress และ deviceName ตัว XML ที่คืนมาแบนมากไม่มี namespace ซับซ้อน ผมเลยใช้ regex ดึงตรงๆ ไม่ต้องลาก XML parser มา
import re, requests
from requests.auth import HTTPDigestAuth
def tag(t, x):
m = re.search("<%s>([^<]*)</%s>" % (t, t), x)
return m.group(1) if m else ""
def isapi_info(ip, creds, timeout=6):
"""ถาม deviceInfo ด้วยชุด credential ที่เราถือสิทธิ์อยู่ ตัวไหนตรงก็คืนข้อมูล"""
last = None
for user, pw in creds:
try:
r = requests.get("http://%s/ISAPI/System/deviceInfo" % ip,
auth=HTTPDigestAuth(user, pw), timeout=timeout)
if r.status_code == 200 and "DeviceInfo" in r.text:
return dict(ok=True, user=user,
model=tag("model", r.text),
fw=tag("firmwareVersion", r.text),
serial=tag("serialNumber", r.text),
mac=tag("macAddress", r.text).upper(),
name=tag("deviceName", r.text))
if r.status_code == 401:
last = "auth-mismatch" # ชุดนี้ไม่ใช่ ลองชุดถัดไป
except Exception as e:
last = type(e).__name__
return dict(ok=False, err=last or "no-credential-matched")
ตัวที่คืน no-credential-matched ไม่ได้แปลว่ากล้องเสียครับ ส่วนใหญ่แปลว่ามันคือตัวที่ผู้รับเหมารายเก่าตั้งรหัสไว้แล้วไม่ได้ส่งมอบ ผมจะจดไว้ในทะเบียนว่าต้องไปกดรีเซ็ตที่ตัวเครื่องตอนขึ้นบันไดรอบหน้า ไม่ใช่ไปนั่งไล่หารหัส
นอกจาก deviceInfo ยังมี endpoint ที่ใช้บ่อยอีกสองตัว ตัวแรกคือค่าเน็ตเวิร์กว่าเป็น DHCP หรือ static ตัวที่สองคือค่า encoding ของช่องสตรีม
B = "http://%s" % ip
net = requests.get(B + "/ISAPI/System/Network/interfaces/1/ipAddress",
auth=auth, timeout=6).text
vid = requests.get(B + "/ISAPI/Streaming/channels/101",
auth=auth, timeout=6).text
nmode = tag("addressingType", net) # dynamic หรือ static
codec = tag("videoCodecType", vid) # H.264 / H.265
res = "%sx%s" % (tag("videoResolutionWidth", vid), tag("videoResolutionHeight", vid))
fps = tag("maxFrameRate", vid) # ค่านี้คูณ 100 มา ต้องหารกลับ
gop = tag("GovLength", vid) # ระยะห่างคีย์เฟรม
brt = tag("videoQualityControlType", vid) # CBR / VBR
br = tag("constantBitRate", vid) or tag("vbrUpperCap", vid)
จุดที่ต้องระวังคือ maxFrameRate ของ ISAPI คืนค่ามาคูณร้อยไว้ เห็น 2500 คือ 25 fps ไม่ใช่ 2500 fps ครับ ส่วนเลขช่องใน path /Streaming/channels/101 อ่านว่าช่อง 1 สตรีมที่ 1 (main stream) ถ้าอยากได้ sub stream คือ 102
ยี่ห้อที่ไม่มี ISAPI ก็ยังตรวจได้ ด้วย RTSP กับ ffprobe
ในวงจริงมีกล้องรุ่นเก่าอีกสิบตัวที่ไม่มี HTTP API ให้ใช้ มีแต่หน้าเว็บที่ต้องใช้ปลั๊กอินเก่าซึ่งเบราว์เซอร์สมัยนี้ไม่รองรับแล้ว แต่ทุกตัวยังปล่อย RTSP ได้ ซึ่งพอสำหรับงานออดิตครับ เพราะสิ่งที่เราอยากรู้จริงๆ คือภาพที่ออกมาเป็นอย่างไร ไม่ใช่หน้าเว็บสวยแค่ไหน
ffprobe ที่ติดมากับ ffmpeg ทำงานนี้ได้ดีมาก สั่งให้มันคายเฉพาะ metadata ของ stream ออกมาเป็น JSON แล้วจบ ไม่ต้องดึงภาพลงมาเลย
ffprobe -v error -rtsp_transport tcp -rw_timeout 5000000 \
-select_streams v:0 \
-show_entries stream=codec_name,width,height,r_frame_rate,bit_rate \
-of json "rtsp://admin:[email protected]:554/profile1"
สามอ็อพชันที่ขาดไม่ได้คือ -rtsp_transport tcp เพราะ UDP จะเจอปัญหาแพ็กเกตหายในวงที่มีกล้องเยอะแล้วค้างยาว -rw_timeout 5000000 คือห้าวินาทีในหน่วยไมโครวินาที ถ้าไม่ใส่แล้วเจอกล้องที่รับ TCP แต่ไม่ยอมส่งข้อมูล มันจะค้างจนกว่าจะกด Ctrl-C และ -v error เพื่อให้ stdout มีแต่ JSON ล้วน เอาไป parse ต่อได้เลย
ห่อเป็นฟังก์ชัน Python แบบนี้ครับ
import subprocess, json
def rtsp_probe(ip, creds, path="profile1", timeout=14):
for user, pw in creds:
url = "rtsp://%s:%s@%s:554/%s" % (user, pw, ip, path)
try:
p = subprocess.run(
["ffprobe", "-v", "error", "-rtsp_transport", "tcp",
"-rw_timeout", "5000000", "-select_streams", "v:0",
"-show_entries", "stream=codec_name,width,height,r_frame_rate,bit_rate",
"-of", "json", url],
capture_output=True, text=True, timeout=timeout)
if p.returncode == 0 and '"streams"' in p.stdout:
s = (json.loads(p.stdout).get("streams") or [{}])[0]
fr = s.get("r_frame_rate", "?")
if "/" in str(fr): # "25/1" -> 25
num, den = str(fr).split("/")
fr = "%g" % (int(num) / int(den)) if int(den) else fr
br = s.get("bit_rate")
br = str(int(br) // 1000) if br and br.isdigit() else "N/A"
return dict(ok=True, user=user, codec=s.get("codec_name", "?"),
res="%sx%s" % (s.get("width", "?"), s.get("height", "?")),
fps=fr, kbps=br)
except subprocess.TimeoutExpired:
continue
except Exception:
continue
return dict(ok=False)
path ของ RTSP ต่างกันตามยี่ห้อครับ ตระกูลหนึ่งใช้ profile1 อีกตระกูลใช้ Streaming/Channels/101 บางเจ้าใช้ live/ch0 วิธีหาที่เร็วที่สุดคือดูคู่มือรุ่นนั้น ถ้าไม่มีคู่มือก็ลองสามแบบนี้ก่อน ครอบคลุมของที่เจอในตลาดไทยเกินครึ่ง
รวมเป็นทะเบียนแล้วเก็บเป็นหน้า HTML
ผลลัพธ์ที่ผมต้องการไม่ใช่ข้อความบนจอ terminal ครับ เพราะต้องส่งให้เจ้าของอาคารดูด้วย ผมเลยให้สคริปต์พ่นออกมาเป็นไฟล์ HTML หน้าเดียว มีตารางกล้องทั้งหมดพร้อมสถานะ เปิดในเบราว์เซอร์ได้เลยไม่ต้องติดตั้งอะไร
rows = []
for ch, ip in cameras: # [(1,"192.168.10.211"), ...]
info = isapi_info(ip, CREDS)
if info["ok"]:
stream = rtsp_probe(ip, [(info["user"], cred_of(info["user"]))])
rows.append(dict(ch=ch, ip=ip, vendor="ISAPI", model=info["model"],
fw=info["fw"], mac=info["mac"] or macof(ip),
codec=stream.get("codec", "-"), res=stream.get("res", "-"),
fps=stream.get("fps", "-"), ok=True))
else:
stream = rtsp_probe(ip, CREDS) # ไม่มี ISAPI ก็ยังเช็คสตรีมได้
rows.append(dict(ch=ch, ip=ip, vendor="RTSP only", model="(ไม่มี API)",
fw="-", mac=macof(ip),
codec=stream.get("codec", "-"), res=stream.get("res", "-"),
fps=stream.get("fps", "-"), ok=stream["ok"]))
n_on = sum(1 for r in rows if r["ok"])
print("cameras:", len(rows), "reachable:", n_on)
ส่วน macof(ip) ผมดึงจากตาราง ARP กับ DHCP lease ของเราเตอร์ เพราะกล้องหลายตัวเป็น DHCP client ที่ล็อก IP ด้วย reservation ตาม MAC การมี MAC ในทะเบียนทำให้ตอนกล้องพังแล้วเปลี่ยนตัวใหม่ เรารู้ทันทีว่าต้องไปแก้ reservation ที่บรรทัดไหน
ของที่ต้องเจอแน่ๆ ตอนออดิตรอบแรก
จากที่ทำมาหลายไซต์ ผลรอบแรกมักจะออกมาหน้าตาคล้ายกัน
หนึ่ง มีกล้องอย่างน้อยหนึ่งตัวที่ตายไปนานแล้วโดยไม่มีใครรู้ เพราะช่องนั้นบนจอมอนิเตอร์อยู่มุมล่างที่ไม่มีใครมอง
สอง firmware ของกล้องส่วนใหญ่เป็นเวอร์ชันตอนติดตั้ง ไม่เคยอัปเลยตั้งแต่วันแรก อันนี้ต้องดูเป็นกรณีไป กล้องที่ไม่ได้เปิดออกอินเทอร์เน็ตและอยู่ใน VLAN ปิด ความเสี่ยงต่ำกว่ามาก แต่ก็ต้องรู้ไว้ว่าตัวไหนเก่าแค่ไหน
สาม ค่าที่ตั้งไว้ในกล้องไม่ตรงกับที่คิด บางตัวเฟรมเรตต่ำกว่าที่ควร บางตัวยังเป็น H.264 ทั้งที่รุ่นนั้นรองรับ H.265 ซึ่งกินพื้นที่ต่างกันเกือบครึ่ง
สี่ กล้องบางตัวยังเป็น DHCP แบบไม่ได้จอง IP ไว้ วันดีคืนดี IP เปลี่ยน NVR ก็หาไม่เจอ อาการคือ "จู่ๆ กล้องหลุดเอง" ที่หาสาเหตุไม่เจอ
- ยืนยันขอบเขตก่อนเริ่ม ว่าอุปกรณ์ทั้งหมดอยู่ในความดูแลของเรา และ credential ที่ใช้คือของที่ได้รับมอบมา
- วาง credential ไว้ใน environment ไม่ฝังในไฟล์สคริปต์ แล้วรันจากเครื่องในวงเดียวกัน
- ยิง ISAPI deviceInfo เก็บรุ่น firmware serial MAC ตัวที่ไม่มี API ข้ามไปขั้นถัดไป
- ยิง ffprobe บนสตรีม RTSP จริง เก็บ codec ความละเอียด เฟรมเรต และบิตเรต
- ชนกับ ARP หรือ DHCP lease ของเราเตอร์เพื่อเติม MAC และตรวจว่าจอง IP ครบทุกตัว
- พ่นรายงาน HTML เก็บไว้ตามวันที่ เดือนถัดไปรันซ้ำแล้วเทียบ ตัวที่หายไปคือของที่ต้องขึ้นไปดูที่หน้างาน
ทำเป็น health check รายเดือน ไม่ใช่ทำครั้งเดียว
คุณค่าจริงของสคริปต์ชุดนี้ไม่ได้อยู่ที่การรันครั้งแรกครับ อยู่ที่การรันซ้ำ เพราะระบบกล้องเป็นระบบที่ไม่มีใครไปแตะจนกว่าจะต้องใช้ภาพย้อนหลัง แล้วตอนนั้นก็สายเกินไปแล้ว ตั้ง cron ให้รันเดือนละครั้งแล้วเก็บ JSON ตามวันที่ พอมีอะไรเปลี่ยนก็เห็นทันที
ส่วนตัวผมถือว่างานระบบกล้องที่ส่งมอบโดยไม่มีทะเบียนอุปกรณ์คืองานที่ยังไม่เสร็จ ราคาของทะเบียนคือเวลาเขียนสคริปต์ครึ่งวัน ส่วนราคาของการไม่มีทะเบียนคือวันที่ต้องขึ้นบันไดไล่ดูทีละตัวว่าตัวไหนคือตัวไหน ใครจะเอาโครงนี้ไปปรับใช้กับยี่ห้ออื่น เปลี่ยน endpoint กับ path ของ RTSP ได้ตามสะดวก ส่วนโครง ping แล้วถาม API แล้วยืนยันด้วยสตรีมจริง ใช้ได้กับทุกยี่ห้อเหมือนกันหมด
ที่ยังค้างในแผนของผมคือเพิ่มการดึงภาพนิ่งจากทุกตัวมาต่อเป็นหน้า contact sheet ให้ดูรวดเดียวว่ามุมกล้องยังตรงตามที่ติดตั้งไว้ไหม กับเช็คว่านาฬิกาในกล้องตรงกับ NTP หรือเปล่า เพราะเวลาในภาพที่เพี้ยนทำให้ภาพย้อนหลังใช้อ้างอิงไม่ได้ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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