localeye.cam

← Back

doors_events.py

doors version 2.5 · 22 KB

1"""
2Door / access-control events (ICT Protege WX) linked to cameras - the Doors custom module's core.
3
4Events come from an ICT Protege WX controller, polled by ProtegePoller (the module's recorder worker) via the
5live monitoring feed (see protege_client.py). Each event's description sentence is parsed into
6door / user / kind by parse_wx_description, and relay/output noise is filtered by _is_door_event.
7Each event is stored, bookmarked on the mapped camera(s) so it shows on the recording timeline, and can
8send an alert (denied / granted) through the NVR's alert channels and phones (api.notify). Closed, forced-open and held-open
9events are skipped entirely (SKIP_KINDS: not stored, not bookmarked, no alert). Door -> camera mapping matches the door name
10against the mapping text, so partial names work.
11"""
12import json
13import re
14import threading
15import time
16from datetime import datetime
17
18KINDS = { # first match wins - order matters ("door forced open" is forced, not opened)
19 "forced": ("forced",),
20 "held": ("held open", "left open", "propped", "ajar", "held", "dotl"),
21 "denied": ("denied", "invalid", "rejected", "unknown card", "not valid", "refused", "unauthorised",
22 "unauthorized", "access not granted"),
23 "granted": ("granted", "valid card", "access allowed", "unlocked by", "unlocked door", "credential accepted",
24 "entry granted", "exit granted"),
25 "opened": ("opened", "door open", "is open"),
26 "closed": ("closed", "secured", "door close"),
27}
28# never recorded (not stored, bookmarked or alerted): too much noise for this site (2026-10-22). They are still told apart
29# (KINDS) so they can't be mistaken for "opened" / "other".
30SKIP_KINDS = {"closed", "forced", "held"}
31KIND_LABELS = {"forced": "Forced open", "held": "Held open", "denied": "Access denied", "granted": "Access granted",
32 "opened": "Opened", "closed": "Closed", "other": "Event"}
33
34# --- ICT Protege WX live-feed description parsing ------------------------------------------------
35# The WX writes each event as a sentence, e.g.:
36# "Door Warehouse Entry Unlocked By Access"
37# "User Wayne Entry Granted Warehouse Entry Using Master By Card [Card 152***:4****]"
38# "Justin Unlocked Door Front Entry"
39# "Door Warehouse Entry Opened" / "Closed"
40# "Output Controller Output 2 ON By Door Warehouse Entry (LOCK)" <- relay noise, ignored
41import re as _re
42
43# Lines we never turn into door events (output/relay chatter, comms, etc.)
44WX_NOISE = _re.compile(r"\boutput controller\b|\(lock\)|\block output\b"
45 r"|\b(?:un)?locked\s+by\s+access\b", _re.I)
46
47# Door name extractors, tried in order. Each returns the door text.
48_WX_DOOR_RES = [
49 _re.compile(r"\bgranted\s+(?P<door>.+?)\s+using\b", _re.I), # "... Granted Warehouse Entry Using ..."
50 _re.compile(r"\bunlocked\s+door\s+(?P<door>.+?)\s*$", _re.I), # "... Unlocked Door Front Entry"
51 _re.compile(r"\blocked\s+door\s+(?P<door>.+?)\s*$", _re.I),
52 _re.compile(r"^door\s+(?P<door>.+?)\s+(?:unlocked|locked|opened|closed|open|close|forced|held|"
53 r"left\s+open|access\s+granted|access\s+denied)\b", _re.I), # "Door <name> Opened"
54 _re.compile(r"\b(?:by\s+door|at\s+door|door)\s+(?P<door>.+?)\s*(?:\(|$)", _re.I),
55]
56
57_WX_USER_RES = [
58 _re.compile(r"^user\s+(?P<user>.+?)\s+(?:entry|exit)\s+granted\b", _re.I), # "User Wayne Bulluck Entry Granted"
59 _re.compile(r"^user\s+(?P<user>.+?)\s+(?:entry|exit)\s+denied\b", _re.I),
60 _re.compile(r"^(?P<user>[A-Z][\w.'-]+(?:\s+[A-Z][\w.'-]+){0,3})\s+(?:unlocked|locked)\s+door\b", _re.I),
61]
62
63
64def _is_door_event(desc):
65 """True if this WX description is a door/access event worth recording (not relay/output noise)."""
66 d = (desc or "").strip()
67 if not d or WX_NOISE.search(d):
68 return False
69 low = d.lower()
70 return ("door" in low or "entry granted" in low or "entry denied" in low
71 or "exit granted" in low or "access" in low)
72
73
74def parse_wx_description(desc):
75 """-> {door, user, event, kind} for a WX live-feed description sentence."""
76 d = " ".join((desc or "").split()) # collapse the padding whitespace
77 out = {"door": None, "user": None, "event": d, "kind": _kind(d)}
78 for rx in _WX_DOOR_RES:
79 m = rx.search(d)
80 if m:
81 door = _clean(m.group("door"), 60)
82 if door:
83 # trim a trailing "Entry"/"Door" artefact only if it left something sensible
84 out["door"] = door
85 break
86 for rx in _WX_USER_RES:
87 m = rx.search(d)
88 if m:
89 out["user"] = _clean(m.group("user"), 60)
90 break
91 return out
92
93
94JSON_KEYS = {
95 "door": ("door", "door_name", "doorname", "door_description", "reader", "reader_name", "location", "point"),
96 "user": ("user", "user_name", "username", "name", "cardholder", "card_holder", "person", "credential_name"),
97 "event": ("event", "event_type", "type", "description", "message", "action", "status", "text"),
98}
99_SYSLOG_PRI = re.compile(r"^<\d{1,3}>(\d\s)?")
100_ACTION = r"(?:access|opened|open|closed|forced|held|left|by|user|was|is|has|denied|granted|unlocked|locked|alarm)"
101DOOR_RES = [
102 # "Door: Front Entry - User: ..." / "Door=Loading Dock 2; Event=..."
103 re.compile(r"door\s*(?:name)?\s*[:=]\s*[\"']?(?P<door>[^\"',;|\n]{1,60}?)[\"']?"
104 r"(?=\s+-\s|\s*[,;|]|\s+(?:user|by|card|event|reader)\b|$)", re.I),
105 # "Door Break Room Held Open" (but not "door unlocked by ...", where the next word is the action)
106 re.compile(r"(?:^|[\s,;|:])door\s+(?!" + _ACTION + r"\b)(?P<door>[A-Z0-9][\w #/.\-]{0,40}?)(?=\s+" + _ACTION +
107 r"\b|\s*[,;|]|\s*$)", re.I),
108 # "Main Lobby door unlocked by ..."
109 re.compile(r"(?:^|[\s,;|:])(?P<door>[A-Z][\w#/.\-]*(?:\s+[A-Z0-9][\w#/.\-]*){0,3}\s+door)\b"),
110 # "Access denied at Front Entry for user ..."
111 re.compile(r"\bat\s+(?P<door>[A-Z][\w #/.\-]{1,40}?)(?=\s+(?:for|by|user)\b|\s*[,;|(]|\s*$)"),
112]
113USER_RES = [
114 re.compile(r"(?:user|cardholder|card holder|by user)\s*(?:name)?\s*[:=]?\s*[\"']?(?P<user>[A-Za-z][A-Za-z0-9 .'\-]{1,40}?)"
115 r"[\"']?(?=\s*(?:[,;|()\n]|$| at | on | via | - | door\b| card\b| using\b))", re.I),
116 re.compile(r"\bby\s+(?P<user>[A-Z][A-Za-z.'\-]+(?:\s+[A-Z][A-Za-z.'\-]+){0,3})"),
117]
118
119
120def _kind(text):
121 t = (text or "").lower()
122 for kind, words in KINDS.items():
123 if any(w in t for w in words):
124 return kind
125 return "other"
126
127
128def _clean(v, limit=80):
129 v = " ".join(str(v).split()).strip(" \"'.,;:-") if v not in (None, "") else ""
130 return v[:limit] or None
131
132
133def parse(raw, custom_pattern=None):
134 """-> {"door", "user", "event", "kind"} (any may be None). Never raises."""
135 text = _SYSLOG_PRI.sub("", (raw or "").strip())
136 out = {"door": None, "user": None, "event": None}
137 data = None
138 if text[:1] in "{[":
139 try:
140 data = json.loads(text)
141 data = data[0] if isinstance(data, list) and data else data
142 except ValueError:
143 data = None
144 if isinstance(data, dict):
145 flat = {str(k).lower().replace(" ", "_"): v for k, v in data.items()}
146 for field, keys in JSON_KEYS.items():
147 for k in keys:
148 if flat.get(k) not in (None, ""):
149 out[field] = _clean(flat[k])
150 break
151 text_for_kind = " ".join(str(v) for v in data.values())
152 else:
153 if custom_pattern:
154 try:
155 m = re.search(custom_pattern, text, re.I)
156 if m:
157 for field in out:
158 if field in m.groupdict():
159 out[field] = _clean(m.group(field))
160 except re.error:
161 pass
162 if not out["door"]:
163 for rx in DOOR_RES:
164 m = rx.search(text)
165 if m:
166 out["door"] = _clean(m.group("door"))
167 break
168 if not out["user"]:
169 for rx in USER_RES:
170 m = rx.search(text)
171 if m:
172 out["user"] = _clean(m.group("user"))
173 break
174 text_for_kind = text
175 out["kind"] = _kind(out["event"] or "") if out["event"] and _kind(out["event"]) != "other" else _kind(text_for_kind)
176 if not out["event"]:
177 out["event"] = KIND_LABELS[out["kind"]] if out["kind"] != "other" else _clean(text, 120)
178 return out
179
180
181SCHEMA = """
182CREATE TABLE IF NOT EXISTS door_events (
183 id INTEGER PRIMARY KEY AUTOINCREMENT,
184 ts REAL NOT NULL,
185 door TEXT,
186 user_name TEXT,
187 event TEXT,
188 kind TEXT NOT NULL,
189 cameras TEXT NOT NULL,
190 raw TEXT,
191 source TEXT,
192 matched INTEGER NOT NULL DEFAULT 0
193);
194CREATE INDEX IF NOT EXISTS idx_door_events_ts ON door_events(ts);
195"""
196
197
198class DoorStore:
199 """The door_events table, through the module's own database connection (api.db: this table only). The table is the one
200 the NVR used before Doors became a custom module, so an existing server keeps its whole door history."""
201
202 def __init__(self, db):
203 self.db = db
204 db.script(SCHEMA)
205
206 def add(self, ts, door, user, event, kind, cameras, raw, source):
207 return self.db.execute("INSERT INTO door_events(ts,door,user_name,event,kind,cameras,raw,source,matched) "
208 "VALUES(?,?,?,?,?,?,?,?,?)",
209 (ts, door, user, event, kind, json.dumps(cameras), raw, source, 1 if cameras else 0))[0]
210
211 def events(self, q=None, kind=None, door=None, start_ts=None, end_ts=None, before_id=None, limit=100):
212 where, params = self._filter(q, kind, door, start_ts, end_ts)
213 if before_id:
214 where += " AND id<?"
215 params.append(int(before_id))
216 rows = self.db.query("SELECT * FROM door_events WHERE 1=1" + where + " ORDER BY id DESC LIMIT ?", (*params, int(limit)))
217 for r in rows:
218 r["cameras"] = json.loads(r["cameras"] or "[]")
219 return rows
220
221 def exists(self, ts, raw, window=3.0):
222 """True if an identical event (same raw text) is already stored within +/- window seconds - so a re-poll or a
223 recorder restart doesn't create duplicates."""
224 return self.db.one("SELECT 1 AS x FROM door_events WHERE raw=? AND ts BETWEEN ? AND ? LIMIT 1",
225 (raw, ts - window, ts + window)) is not None
226
227 def door_names(self):
228 return [r["door"] for r in self.db.query("SELECT DISTINCT door FROM door_events WHERE door IS NOT NULL ORDER BY door")]
229
230 def delete(self, event_id):
231 return bool(self.db.execute("DELETE FROM door_events WHERE id=?", (int(event_id),))[1])
232
233 def _filter(self, q=None, kind=None, door=None, start_ts=None, end_ts=None):
234 sql, params = "", []
235 if kind:
236 sql += " AND kind=?"
237 params.append(kind)
238 if door:
239 sql += " AND door=?"
240 params.append(door)
241 if q:
242 sql += " AND (door LIKE ? OR user_name LIKE ? OR event LIKE ? OR raw LIKE ?)"
243 params += [f"%{q}%"] * 4
244 if start_ts is not None:
245 sql += " AND ts>=?"
246 params.append(start_ts)
247 if end_ts is not None:
248 sql += " AND ts<?"
249 params.append(end_ts)
250 return sql, params
251
252 def count(self, **f):
253 where, params = self._filter(**f)
254 return self.db.one("SELECT COUNT(*) AS n FROM door_events WHERE 1=1" + where, tuple(params))["n"]
255
256 def delete_matching(self, **f):
257 where, params = self._filter(**f)
258 return self.db.execute("DELETE FROM door_events WHERE 1=1" + where, tuple(params))[1]
259
260 def prune(self, days):
261 return self.db.execute("DELETE FROM door_events WHERE ts<?", (time.time() - days * 86400,))[1]
262
263
264class DoorEvents:
265 """Turns one controller event into a stored row, timeline bookmarks on the mapped camera(s), and an optional alert."""
266
267 def __init__(self, api, doors_store):
268 self.api, self.store = api, doors_store
269 self.configure()
270
271 def configure(self):
272 d = self.api.settings()
273 self.pattern = d.get("pattern") or None
274 self.mappings = [m for m in (d.get("mappings") or []) if isinstance(m, dict) and (m.get("match") or "").strip() and m.get("camera")]
275 self.alert_kinds = {k for k, on in (("denied", d.get("alert_denied", False)),
276 ("granted", d.get("alert_granted", False))) if on}
277 self.cameras = {c["stream"]: c["name"] for c in self.api.cameras()}
278
279 def match_cameras(self, door, raw):
280 """Cameras whose mapping text appears in the door name - or anywhere in the raw message."""
281 hay_door, hay_raw = (door or "").lower(), (raw or "").lower()
282 cams, label = [], None
283 for m in self.mappings:
284 needle = m["match"].strip().lower()
285 if needle and (needle in hay_door or needle in hay_raw) and m["camera"] in self.cameras:
286 if m["camera"] not in cams:
287 cams.append(m["camera"])
288 label = label or m["match"].strip()
289 return cams, label
290
291 def ingest(self, raw, source="protege", ts=None, display_raw=None, parsed=None):
292 """Handle one incoming event. `raw` is what we parse; `display_raw`, if given, is the human-readable text stored and
293 shown on the Doors page. `parsed`, if given, is a ready {door,user,event,kind} dict (WX descriptions).
294 Returns None for a kind in SKIP_KINDS (closed / forced / held open): nothing is stored, bookmarked or sent."""
295 ts = ts or time.time()
296 raw = (raw or "").strip()[:4000]
297 stored_raw = (display_raw if display_raw is not None else raw).strip()[:4000]
298 p = parsed if parsed is not None else parse(raw, self.pattern)
299 if p["kind"] in SKIP_KINDS:
300 return None
301 cams, label = self.match_cameras(p["door"], raw + " " + stored_raw)
302 door = p["door"] or label
303 eid = self.store.add(ts, door, p["user"], p["event"], p["kind"], cams, stored_raw, source)
304 when = datetime.fromtimestamp(ts)
305 note = " · ".join(x for x in (f"🚪 {door or 'Door'}", KIND_LABELS.get(p["kind"], p["event"]), p["user"]) if x)
306 for cam in cams:
307 try:
308 self.api.add_bookmark(cam, when, note)
309 except Exception:
310 pass
311 if cams and p["kind"] in self.alert_kinds:
312 self._alert(cams[0], when, door, p)
313 return {"id": eid, "ts": ts, "door": door, "user": p["user"], "event": p["event"], "kind": p["kind"],
314 "cameras": cams, "matched": bool(cams)}
315
316 def _alert(self, cam, when, door, p):
317 title = f"🚪 {KIND_LABELS.get(p['kind'], 'Door event')}: {door or 'door'}"
318 msg = (f"{door or 'Door'} - {p['event']}" + (f" ({p['user']})" if p["user"] else "")
319 + f" at {when.strftime('%I:%M:%S %p').lstrip('0')}\nCamera: {self.cameras.get(cam, cam)}")
320 try:
321 self.api.notify(cam, title, msg, ts=when.timestamp())
322 except Exception as exc:
323 self.api.log(f"alert failed: {exc}")
324
325
326class ProtegePoller:
327 """Polls an ICT Protege WX for door events and feeds them into DoorEvents. This is the module's recorder worker."""
328
329 def __init__(self, api):
330 self.api = api
331 self.store = DoorStore(api.db)
332 self.doors = DoorEvents(api, self.store)
333 self.client = None
334 self._stop = threading.Event()
335 self.thread = None
336 self.last_ok = None
337 self.last_error = None
338 self.events_seen = 0
339 self._seen = set() # (ts, raw) of recently handled events, to avoid duplicates across polls
340 self._last_key = None
341 self._pruned = 0.0
342 self._adopted = False # its bookmarks from before the NVR recorded who made a bookmark: claimed once (cleanup)
343 self.reload()
344
345 # ---- the worker interface the NVR calls (services/custom_modules.py RecorderHost)
346 def reload(self):
347 d = self.api.settings()
348 self.doors.configure()
349 self.host = (d.get("protege_host") or "").strip()
350 self.username = (d.get("protege_username") or "").strip()
351 self.password = d.get("protege_password") or ""
352 self.verify_tls = bool(d.get("protege_verify_tls", False))
353 self.interval = max(5, int(d.get("protege_poll_seconds") or 10))
354 self.retention_days = int(d.get("retention_days") or 365)
355 key = (self.host, self.username, self.password, self.verify_tls)
356 if key != self._last_key: # credentials changed -> drop the old client so we log in fresh
357 self.client = None
358 self._last_key = key
359
360 def start(self):
361 self._stop.clear()
362 self.thread = threading.Thread(target=self._run, daemon=True, name="protege-poll")
363 self.thread.start()
364
365 def stop(self):
366 self._stop.set()
367
368 def status(self):
369 return {"host": self.host, "connected": self.client is not None, "events_seen": self.events_seen,
370 "last_ok": self.last_ok, "last_error": self.last_error, "poll_seconds": self.interval}
371
372 def handle(self, action, body):
373 if action == "test":
374 return self.test()
375 return {"ok": False, "error": "unknown action"}
376
377 def cleanup(self):
378 """The NVR's own cleanup calls this (every cleanup_interval_minutes; NVRs from 2026-10-27): door history older than "Keep door
379 history (days)" goes, and with it the bookmarks this module put on the cameras' timelines for those events - never a person's
380 bookmark. The ones it made before the NVR recorded who made a bookmark (their note starts with the door sign) are claimed first,
381 once. (The NVR also removes this module's bookmarks once the recording they mark has been deleted.)"""
382 if not self.retention_days:
383 return
384 cutoff = time.time() - self.retention_days * 86400
385 gone = self.store.prune(self.retention_days)
386 marks = 0
387 if hasattr(self.api, "delete_bookmarks"):
388 if not self._adopted:
389 self._adopted = True
390 n = self.api.adopt_bookmarks("\U0001F6AA ")
391 if n:
392 self.api.log(f"claimed {n} door bookmark{'s' if n != 1 else ''} made before the NVR recorded who made them")
393 marks = self.api.delete_bookmarks(before=cutoff)
394 if gone or marks:
395 self.api.log(f"cleanup: {gone or 0} old door event{'s' if gone != 1 else ''}, {marks} bookmark{'s' if marks != 1 else ''}")
396
397 # ---- polling
398 def _prune(self):
399 if hasattr(self.api, "delete_bookmarks"):
400 return # (an NVR that has it calls cleanup() with its own cleanup; this is for older NVRs)
401 if self.retention_days and time.time() - self._pruned > 3600:
402 self._pruned = time.time()
403 try:
404 self.store.prune(self.retention_days)
405 except Exception as exc:
406 self.api.log(f"pruning old door events failed: {exc}")
407
408 def _run(self):
409 from .protege_client import ProtegeWXClient
410 while not self._stop.wait(self.interval):
411 self._prune()
412 if not (self.host and self.username):
413 continue
414 try:
415 first = False
416 if self.client is None:
417 self.client = ProtegeWXClient(self.host, self.username, self.password, self.verify_tls)
418 self.client.login()
419 first = True # first poll after (re)login: pull the recent batch
420 events = self.client.fetch_events(first=first)
421 new = 0
422 for e in sorted(events, key=lambda x: x["ts"] or datetime.min):
423 desc = e.get("desc") or e.get("raw") or ""
424 if not _is_door_event(desc): # skip relay/output noise
425 continue
426 ts = e["ts"].timestamp() if e["ts"] else time.time()
427 sig = (round(ts), desc.strip())
428 if sig in self._seen:
429 continue
430 # DB is the source of truth, so a recorder restart doesn't re-ingest old events
431 if self.store.exists(ts, desc.strip()):
432 self._seen.add(sig)
433 continue
434 self._seen.add(sig)
435 if self.doors.ingest(desc, source="protege", ts=ts, display_raw=desc.strip(), parsed=parse_wx_description(desc)):
436 new += 1 # (a skipped kind - closed / forced / held - isn't counted)
437 if len(self._seen) > 5000: # keep the dedupe set from growing without bound
438 self._seen = set(list(self._seen)[-2000:])
439 self.events_seen += new
440 self.last_ok = time.time()
441 self.last_error = None
442 except Exception as exc:
443 self.last_error = f"{datetime.now():%Y-%m-%d %H:%M} {exc}"
444 self.client = None # force a fresh login next time
445 self.api.log(f"Protege poll failed: {exc}")
446
447 def test(self):
448 """One-shot connection test for the Settings page."""
449 from .protege_client import ProtegeWXClient
450 if not (self.host and self.username):
451 return {"ok": False, "error": "Enter the controller address and a username first"}
452 try:
453 c = ProtegeWXClient(self.host, self.username, self.password, self.verify_tls)
454 c.login()
455 events = c.fetch_events(first=True)
456 c.logout()
457 door = [e for e in events if _is_door_event(e.get("desc", ""))]
458 return {"ok": True, "message": f"Connected - {len(events)} recent event(s), {len(door)} door/access event(s)"}
459 except Exception as exc:
460 return {"ok": False, "error": str(exc)}