Set VLANs on a Hikvision Switch Using Its Web UI API
Many inexpensive devices have no official API, but their web UI must already have some way to talk to the hardware. My job is to find out how it talks, then speak that same language.
I needed to add a Hikvision smart managed switch to a network already built around UniFi. Its job was to put seven cameras and one NVR into the camera VLAN, then carry a trunk back to the upstream switch. I could have done it manually through the web UI; clicking through ten ports would hardly kill me. But I had several more of the same switch waiting to be installed at other locations, and I wanted something repeatable that could tell me what would change before I committed it. So I sat down, worked out the web UI's API, and wrote a script around it.
The IP addresses, zone names, and passwords in this article are examples. The API paths, field names, and power budget figures are real. I sent the requests to a device I had in my hands, using an administrator account I had set up myself.
The Switch ISAPI Endpoints Return 403, but the Web UI Can Do It
This brand uses ISAPI for cameras and NVRs, so I first tried the switch-related /ISAPI/... endpoints. They all returned 403, which made sense because this switch family is not actually part of the same product line as the cameras. At first, I thought that was it: I would have to configure it manually.
But the web UI could configure VLANs, so there had to be a way in. I left the browser's developer tools open, changed the VLAN on one port, and watched what it sent. The answer was a POST to a path that looked nothing like conventional REST.
The format is very consistent. Every command is a POST with its body wrapped in {"data": {...}}, all use digest auth, and the response is JSON containing a status, which is not the HTTP status. That was the first trap. HTTP might return 200 while status in the body says something else, along with an errorMsg. If I checked only r.status_code, I would think it had succeeded when it had done nothing at all.
import os, uuid, requests
from requests.auth import HTTPDigestAuth
HOST = os.environ.get("HIKSW_HOST", "192.168.10.30")
USER = os.environ.get("HIKSW_USER", "admin")
PASS = os.environ.get("HIKSW_PASS", "")
BASE = f"http://{HOST}/iot/global/0-global/model"
def api(path, data=None, method="post"):
r = requests.request(method, f"{BASE}/{path}",
auth=HTTPDigestAuth(USER, PASS),
json={"data": data or {}}, timeout=30)
try:
j = r.json()
except ValueError:
raise RuntimeError(f"{path}: HTTP {r.status_code} {r.text[:200]}")
if j.get("status") != 200: # status อยู่ใน body ไม่ใช่ HTTP
raise RuntimeError(f"{path}: {j.get('code')} {j.get('errorMsg')}")
return j.get("data", {})
The password comes from the environment, not from a value embedded in the file. The script checks for it first and stops immediately if it is missing, so it does not send an empty password and leave me wondering why I got a 401.
export HIKSW_HOST=192.168.10.30
export HIKSW_USER=admin
export HIKSW_PASS='<PASSWORD>'
python hikswitch.py status
Searches Are Paginated, and I Have to Loop Through Them
Commands beginning with Search do not return every row at once. They return a page, with responseStatus indicating whether there is more. The required parameters are searchID, for which I generate a new uuid each time, and searchResultPosition, which I increment myself by the number of results received.
def search(path, **extra):
out, pos = [], 1
while True:
d = api(path, {"searchID": str(uuid.uuid4()),
"searchResultPosition": pos,
"maxResults": 500, **extra})
rows = d.get("matchResults") or []
out += rows
if d.get("responseStatus") != "MORE" or not rows:
return out
pos += d.get("numOfMatches", len(rows))
def get_vlans():
return sorted(v["VLANID"] for v in search("service/operate/L2Mgr/SearchVLAN"))
def get_ports():
return {p["portID"]: p for p in search("service/operate/L2Mgr/SearchPortVLAN")}
The trailing not rows condition is not there for decoration. I encountered a case where it returned MORE but matchResults was empty. Without that line, the loop would never end.
The Trap That Makes Writes Change Nothing
This cost me more time than anything else in this job. When reading port status, the mode field is named VLANType, with all three letters in VLAN uppercase. But an update command requires vlanType, starting with lowercase letters. If I send the same name I read back, the switch simply returns 200 and changes nothing. No error, no warning; reading it again still returns the old value. So I made the reader accept both names.
def describe(p):
# อุปกรณ์ตอบกลับเป็น VLANType แต่ตอนเขียนใช้ vlanType (API ไม่สม่ำเสมอ)
if (p.get("VLANType") or p.get("vlanType")) == "access":
return f"access VLAN {(p.get('accessMode') or {}).get('VLANID')}"
t = p.get("trunkMode") or {}
return f"trunk PVID {t.get('pVid')} allowed [{t.get('VLANIDList')}]"
The broader lesson is do not assume 200 means success with an undocumented API. For every write command I send, I always read the values back and compare them, rather than checking only the returned code.
Put the Plan in a File and Have the Script Show What Will Change
I do not like scripts that make changes as soon as they run. One mistake with something like this can disconnect an entire building. So I wrote the port plan directly in the file as a dict, easy to read and visible on a single screen, with two commands: plan and apply.
TRUNK_VLANS = "1,20,30,40,50,100" # ทุก VLAN ที่ต้องวิ่งผ่าน uplink
CAM_VLAN = 20
PLAN = {
1: ("access", CAM_VLAN, "กล้อง 1"),
2: ("access", CAM_VLAN, "กล้อง 2"),
3: ("access", CAM_VLAN, "กล้อง 3"),
4: ("access", CAM_VLAN, "กล้อง 4"),
5: ("access", CAM_VLAN, "กล้อง 5"),
6: ("access", CAM_VLAN, "กล้อง 6"),
7: ("access", CAM_VLAN, "กล้อง 7"),
8: ("access", CAM_VLAN, "NVR"),
9: ("trunk", 1, "AP (ผ่าน PoE injector แยก)"),
10: ("trunk", 1, "uplink -> สวิตช์ตัวแม่"),
}
The plan command reads the actual state, compares it port by port, and prints which ports already match and which will change, along with any VLANs that have not been created yet. During apply, it creates the missing VLANs first, then sends the entire set of ports in one command using ModifyBatchPortVLAN.
for pid, (mode, pvid, note) in PLAN.items():
cur = ports.get(pid)
if cur is None:
print(f" [WARN] ไม่พบ port {pid} บนอุปกรณ์")
continue
if mode == "access":
item = {"portID": pid, "vlanType": "access", "accessMode": {"VLANID": pvid}}
newdesc = f"access VLAN {pvid}"
else:
item = {"portID": pid, "vlanType": "trunk",
"trunkMode": {"VLANIDList": TRUNK_VLANS, "pVid": pvid}}
newdesc = f"trunk PVID {pvid} allowed [{TRUNK_VLANS}]"
old = describe(cur)
mark = " " if old == newdesc else "->"
print(f" {mark} port {pid:<2} {note:<28} {old} => {newdesc}")
if old != newdesc:
changes.append(item)
if not apply:
print(f"\n[DRY-RUN] จะเพิ่ม VLAN {missing} และแก้ {len(changes)} พอร์ต - สั่ง apply เพื่อยืนยัน")
return
for v in missing:
api("service/operate/L2Mgr/AddVLAN", {"VLANID": v})
if changes:
api("service/operate/L2Mgr/ModifyBatchPortVLAN", {"portList": changes})
During configuration, I was sitting behind that very switch. Commands passed through the uplink port I was about to change. If I changed that port first and the new settings dropped my mgmt connection, the script would stall halfway through, leaving the other ports unconfigured. That would mean walking over and pressing reset on the device. So I ordered the ports to finish all endpoint ports first, then change the uplink last. Before touching it, I would leave another window open to check that I could still communicate with it.
CCTV Mode Is Set with a Button, Not Through the API
The first thing I had to check on the physical device was CCTV or Extend mode, a common feature on this switch family. Some models use DIP switches; others have a button on the front. It supports cable runs up to two hundred and fifty meters, at the cost of forcing that port to 10 Mbps half-duplex.
Endpoint ports with genuinely long cables
A single camera at the end of a two-hundred-meter cable, with a bitrate of a few megabits, is an acceptable tradeoff and better than installing a cabinet halfway along. But I need to make that choice deliberately and record which ports use this mode in the inventory.
Uplink ports and ports connected to an NVR
An uplink carrying seven cameras at once, reduced to 10 Mbps half-duplex, means stuttering video across the system. The symptoms will look like a faulty camera or a slow NVR, leading to days of troubleshooting when the problem is a single button.
When checking, I look at the speed the port actually negotiated, because this mode forces a fixed value. If I see 10, the mode is definitely enabled; it is not a bad cable.
# ดึงสถานะทุกพอร์ตออกมาแล้วมองหาค่าที่ผิดปกติ
python hikswitch.py status | grep -Ei '10 ?M|half'
# พอร์ตที่ควรเป็นกิกะบิตแต่ได้ 100 คือสายมีปัญหา
# พอร์ตที่ได้ 10 half-duplex คือติดโหมด CCTV ที่ตัวเครื่อง คนละสาเหตุกัน
python hikswitch.py status | awk '/port/ && !/1000/ {print "ตรวจ:", $0}'
Add Up the PoE Budget Before Buying, Not Before Plugging In
The second issue is the PoE power budget. The switch I used can supply 60 W in total, and I had seven cameras to connect, at roughly nine watts each.
63 exceeds 60 by three watts. That sounds small, but once the switch reaches its limit, it cuts power to the last few ports. The result is two or three cameras going offline and coming back at irregular intervals, which is very hard to diagnose because everything looks normal. My plan therefore moved the AP intended for port 9 to its own separate PoE injector. Even with only the cameras left, 63 W was still over budget. During the actual installation, I had to put two cameras on separate injectors as well.
The number on the box is the maximum the device can supply under good conditions, not what I should use continuously. I always plan around roughly 80 percent of the budget, leaving room for cameras that draw more power when their heaters come on in winter or their IR lights switch on at night. That draw is not the same as the manufacturer's daytime specification.
Check After Configuration and Save the State for Comparison
- Run
planevery time first, and read the full output to see how many ports will change. If a port I did not intend to touch appears, the plan in the file is wrong. - Run
apply, then have the script immediately read back and print the state again. Do not trust only the returned code. - Walk over and check the front-panel LEDs to confirm that ports meant to run at gigabit actually do, rather than being stuck at a hundred or ten megabits.
- Check the upstream switch to confirm that the trunk sees all configured VLANs and that no unexpected VLANs are getting through.
- Save the
statusoutput to a dated file so that the next time someone changes something through the web UI, a diff will reveal it.
python hikswitch.py status | tee "sw-$(date +%F).txt"
diff sw-2026-08-18.txt sw-2026-09-18.txt
Personally, I would not use a switch at this level for anything more complicated than this. Configuring a VLAN to separate cameras from the other networks is enough. I leave rules and routing control to the upstream switch and router, because when hardware at this price has problems, I have almost no tools for seeing what is happening inside it. The more complexity I put on it, the harder it will be to find the cause when it fails. Anyone with a model that offers finer control is welcome to go further.
What remains is a reader for per-port PoE status that shows the actual watts being supplied, so I can stop guessing from specifications, and an automatic comparison against the plan that alerts me when the actual settings drift from it. If I have left gaps in the explanation or made any typos, I will come back and correct them later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first