A device that only speaks to itself
The building already had the hardware for good attendance data. Hikvision terminals at the doors were reading faces, fingerprints, and cards on every entry, stamping each one with a time. None of it reached a report. HR pulled raw logs out of the vendor dashboard by hand, typed the times into a spreadsheet, and still lost people who walked in through a door nobody was watching that morning. The paper sign-in sheet stayed on the front desk as the real record.
The terminals sat on a private LAN with no route to the internet and a self-signed TLS certificate. There was no webhook to subscribe to and no cloud to call. The only way in was the device’s own ISAPI, a digest-authenticated HTTP API you poll one terminal at a time. So the job was narrow. Reach a machine that only talks on its own network, pull every punch out of it, and turn that into something a department head can read the same day. Credentials come from the environment (HIK_HOST, HIK_USER, HIK_PASSWORD) with command-line fallbacks for a one-off run, kept out of the source from the first commit.
Paging a terminal that answers in fragments
The pull goes straight at /ISAPI/AccessControl/AcsEvent over HTTP digest auth, with a date range and an event filter (major type 5 for access-control events). The device will not hand back the whole range in one response. It returns a page, a count of the rows in that page, and a status string, and it expects you to ask again from the next position.
Two things make the loop trickier than it first looks. The continuation signal is a string, responseStatusStrg of MORE, not an HTTP code or a cursor. And numOfMatches can come back as zero partway through a range that still has rows behind it, which stalls a naive loop that trusts the count alone. The fetch tracks searchResultPosition itself and stops on any of three conditions: the status stops saying MORE, the page comes back empty, or the running position passes the totalMatches the device reported. A short pause sits between requests, because the same terminal is also unlocking a door for whoever is standing in front of it.
def fetch_events(session, host, auth, start_iso, end_iso, page_size=50):
url = host.rstrip("/") + "/ISAPI/AccessControl/AcsEvent?format=json"
position = 0
while True:
cond = {"AcsEventCond": {
"searchID": "att-report", "searchResultPosition": position,
"maxResults": page_size, "major": 5, "minor": 0,
"startTime": start_iso, "endTime": end_iso}}
# self-signed device cert, so verify stays off until a real one lands
acs = session.post(url, json=cond, auth=auth, verify=False).json()["AcsEvent"]
for ev in acs.get("InfoList") or []:
yield ev
# the terminal answers in pages. 'MORE' means one more is waiting, but
# numOfMatches can also return 0 mid-range, which has to stop us too.
got = acs.get("numOfMatches", 0)
position += got or page_size
if acs.get("responseStatusStrg") != "MORE" or got == 0:
break
time.sleep(0.15) # don't hammer a terminal that's also opening doors TLS verification is off by default for the self-signed certificate, behind a flag that turns it back on the day the device gets a real one.
The first punch of the day is not the check-in
Once the events are in memory, the obvious move is to call the earliest punch a check-in and the latest a check-out. That breaks on the ordinary mess of a real day. Someone who forgets to badge in and only badges out at six in the evening leaves a single punch, and first-equals-last would file it as a 6 PM arrival, which is worse than no data because it looks plausible.
So normalization reads the device’s own labels first. Hikvision can tag an event with an attendanceStatus of checkIn or checkOut, and where those tags exist the earliest checkIn and the latest checkOut win. Only when a day has no labels at all does it fall back to first and last punch, and even then it throws out a check-out that lands at or before the check-in and marks the day as a missing punch instead. A day with one lone check-out is recorded as a missing check-in rather than invented into a full shift. That distinction is the gap between hours you can pay against and hours you have to go back and argue about a week later.
Whose nine is a ten
Attendance only means something against a schedule, and a single cutoff for everyone would have mislabeled half the team. Most staff are late after 9:00. Two people run a 10:00 shift, and their cutoff moves with them, so a 9:40 badge that is late for one person is early for the next. Check-outs before 17:30 count as an early leave.
The policy also carries the exceptions a manager would otherwise keep in their head. One employee’s late arrivals across three named months are logged as an approved medical exception and left out of the late count, with the reason attached to the record instead of lost. A new joiner and one internal account are held out of the published report while staying on the roster. The expected-workday math runs Monday to Friday and drops public holidays, so Nepali New Year and the May Day substitute leave are never counted as days anyone was absent. Attendance percentage is measured against that trimmed calendar, not a flat count of days in the range.
One command, two files
A single run produces two artifacts. A CSV pair for payroll, one row per punch and one row per employee-day with first check-in, last check-out, punch count, and hours on site. And a PDF for department heads, rendered with ReportLab: an overview page for the whole team, then a page per person with monthly averages, late days annotated with how many minutes each ran over, missing punches, and absent workdays. The reader gets the roll-up and the receipts in the same document.
This was a solo architecture and build, scoped and shipped in a single sprint. The risk that mattered was accuracy, because an attendance report that miscounts loses the room the first time someone finds their own good day marked late. Before the first report ran in production I checked the normalization against a quarter of real event data, reading the awkward days by hand, the missing punches and the double taps and the ten o’clock crowd, until the numbers matched the shift that was actually worked.
Outcomes
- Paged ISAPI
- Two CSVs
- Shift-aware
- Manual to automated
- Department-scoped
- Validated