doors_events.py
doors version 2.5 · 22 KB
| 1 | """ |
| 2 | Door / access-control events (ICT Protege WX) linked to cameras - the Doors custom module's core. |
| 3 | |
| 4 | Events come from an ICT Protege WX controller, polled by ProtegePoller (the module's recorder worker) via the |
| 5 | live monitoring feed (see protege_client.py). Each event's description sentence is parsed into |
| 6 | door / user / kind by parse_wx_description, and relay/output noise is filtered by _is_door_event. |
| 7 | Each event is stored, bookmarked on the mapped camera(s) so it shows on the recording timeline, and can |
| 8 | send an alert (denied / granted) through the NVR's alert channels and phones (api.notify). Closed, forced-open and held-open |
| 9 | events are skipped entirely (SKIP_KINDS: not stored, not bookmarked, no alert). Door -> camera mapping matches the door name |
| 10 | against the mapping text, so partial names work. |
| 11 | """ |
| 12 | import json |
| 13 | import re |
| 14 | import threading |
| 15 | import time |
| 16 | from datetime import datetime |
| 17 | |
| 18 | KINDS = { # 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". |
| 30 | SKIP_KINDS = {"closed", "forced", "held"} |
| 31 | KIND_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 |
| 41 | import re as _re |
| 42 | |
| 43 | # Lines we never turn into door events (output/relay chatter, comms, etc.) |
| 44 | WX_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 | |
| 64 | def _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 | |
| 74 | def 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 | |
| 94 | JSON_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)" |
| 101 | DOOR_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 | ] |
| 113 | USER_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 | |
| 120 | def _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 | |
| 128 | def _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 | |
| 133 | def 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 | |
| 181 | SCHEMA = """ |
| 182 | CREATE 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 | ); |
| 194 | CREATE INDEX IF NOT EXISTS idx_door_events_ts ON door_events(ts); |
| 195 | """ |
| 196 | |
| 197 | |
| 198 | class 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 | |
| 264 | class 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 | |
| 326 | class 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)} |