protege_client.py
doors version 2.5 · 9 KB
| 1 | """ |
| 2 | ICT Protege WX client - logs in and pulls door events, for the NVR's door integration. |
| 3 | |
| 4 | Speaks the WX web interface's own encrypted protocol (all reproduced and verified against the controller's |
| 5 | JavaScript in protege_crypto.py / protege_auth.py): |
| 6 | 1. InitSession -> challenge number |
| 7 | 2. CheckPassword -> reply number; the 16-char session key (WXKey) is derived from it |
| 8 | 3. every later request is AES-CBC encrypted with that key, prefixed by the SESSID cookie |
| 9 | Events come from the live monitoring feed the WX "All Events" page uses: |
| 10 | Request&Type=Events&SubType=Latest (recent batch), then &SubType=Update (new since last poll). |
| 11 | The decrypted reply is the WX key=value format; each event is Event<n>=<timestamp><description sentence>. |
| 12 | We poll every few seconds and pass new events to the Doors engine. Read-only: nothing here ever changes |
| 13 | anything on the controller. |
| 14 | """ |
| 15 | import csv |
| 16 | import io |
| 17 | import re |
| 18 | import secrets |
| 19 | import threading |
| 20 | import time |
| 21 | from datetime import datetime, timedelta |
| 22 | |
| 23 | import requests |
| 24 | |
| 25 | from .protege_auth import login_proof, session_key |
| 26 | from .protege_crypto import decrypt, encrypt |
| 27 | |
| 28 | DLL = "PRT_CTRL_DIN_ISAPI.dll" |
| 29 | |
| 30 | |
| 31 | class ProtegeError(Exception): |
| 32 | pass |
| 33 | |
| 34 | |
| 35 | class ProtegeWXClient: |
| 36 | def __init__(self, host, username, password, verify_tls=False, timeout=15): |
| 37 | self.base = host.rstrip("/") |
| 38 | if not self.base.startswith("http"): |
| 39 | self.base = "http://" + self.base |
| 40 | self.username = username |
| 41 | self.password = password |
| 42 | self.timeout = timeout |
| 43 | self.session = requests.Session() |
| 44 | self.session.verify = verify_tls |
| 45 | self.sessid = None |
| 46 | self.key = None |
| 47 | self.csrf = "" |
| 48 | self._lock = threading.Lock() |
| 49 | |
| 50 | # ------------------------------------------------------------ transport |
| 51 | def _raw(self, path, data, params=None): |
| 52 | r = self.session.post(f"{self.base}/{path}", data=data, params=params, |
| 53 | headers={"X-CSRF-Token": self.csrf, "Content-Type": "text/plain; charset=utf-8"}, |
| 54 | timeout=self.timeout) |
| 55 | r.raise_for_status() |
| 56 | return r.text |
| 57 | |
| 58 | def _command(self, command): |
| 59 | """Encrypt a command, send it, decrypt the reply. Assumes we're logged in.""" |
| 60 | if not self.key: |
| 61 | raise ProtegeError("not logged in") |
| 62 | payload = encrypt(command + "&Sequence=", self.key, self.sessid or "") |
| 63 | out = self._raw(DLL, payload) |
| 64 | if out.startswith("<invalid session>"): |
| 65 | raise ProtegeError("session expired") |
| 66 | return decrypt(out, self.key) |
| 67 | |
| 68 | # ------------------------------------------------------------ login |
| 69 | def login(self): |
| 70 | with self._lock: |
| 71 | self.sessid = "".join(secrets.choice("0123456789ABCDEF") for _ in range(32)) |
| 72 | self.session.cookies.set("SESSID", self.sessid) |
| 73 | self.key = None |
| 74 | self.csrf = "" |
| 75 | try: # CSRF token, if this firmware hands one out via ServerTime |
| 76 | st = self._raw(DLL, "Command&Type=Session&SubType=ServerTime") |
| 77 | m = re.search(r"CSRFToken=([^&\s]+)", st) |
| 78 | self.csrf = m.group(1) if m else "" |
| 79 | except Exception: |
| 80 | self.csrf = "" |
| 81 | # The login handshake (InitSession + CheckPassword) is sent PLAIN - the controller has no session |
| 82 | # key yet. Only requests AFTER login are encrypted (see _command / export_events). |
| 83 | challenge = self._raw(DLL, f"Command&Type=Session&SubType=InitSession&SessionID={self.sessid}").strip() |
| 84 | if not challenge.lstrip("-").isdigit(): |
| 85 | if challenge.startswith("<invalid session>"): |
| 86 | raise ProtegeError(f"InitSession refused: {challenge}") |
| 87 | raise ProtegeError("InitSession refused (controller unreachable or busy)") |
| 88 | name, pw = login_proof(self.username, self.password, challenge) |
| 89 | reply = self._raw(DLL, f"Command&Type=Session&SubType=CheckPassword&Name={name}&Password={pw}" |
| 90 | f"&SessionID={self.sessid}").strip() |
| 91 | if reply.startswith("FAIL"): |
| 92 | raise ProtegeError("login failed: bad username or password" if "No valid operator" in reply |
| 93 | else f"login failed: {reply[:60]}") |
| 94 | if not reply.lstrip("-").isdigit(): |
| 95 | raise ProtegeError(f"unexpected login reply: {reply[:60]}") |
| 96 | self.key = session_key(self.password, reply) |
| 97 | return True |
| 98 | |
| 99 | def ensure_login(self): |
| 100 | if not self.key: |
| 101 | self.login() |
| 102 | |
| 103 | # ------------------------------------------------------------ events |
| 104 | def fetch_events(self, first=False): |
| 105 | """Live event feed via the monitoring protocol (as the WX 'All Events' page uses): |
| 106 | Request&Type=Events&SubType=Latest - a batch of recent events (use on first poll) |
| 107 | Request&Type=Events&SubType=Update - only events new since the controller last sent us some |
| 108 | Returns a list of {ts (datetime|None), desc (str), raw (str), fields (dict)}, oldest first. |
| 109 | The reply is the WX key=value format (decrypted); each event is Event<n>=<timestamp><description>. |
| 110 | """ |
| 111 | self.ensure_login() |
| 112 | sub = "Latest" if first else "Update" |
| 113 | for attempt in range(2): |
| 114 | payload = encrypt(f"Request&Type=Events&SubType={sub}&Sequence=", self.key, self.sessid or "") |
| 115 | r = self.session.get(f"{self.base}/{DLL}", params=payload, |
| 116 | headers={"X-CSRF-Token": self.csrf}, timeout=self.timeout) |
| 117 | r.raise_for_status() |
| 118 | raw = r.text |
| 119 | if raw.startswith("<invalid session>"): |
| 120 | if attempt == 0: |
| 121 | self.login() |
| 122 | continue |
| 123 | return [] |
| 124 | try: |
| 125 | text = decrypt(raw, self.key) |
| 126 | except Exception: |
| 127 | if attempt == 0: |
| 128 | self.login() |
| 129 | continue |
| 130 | return [] |
| 131 | if text.strip() in ("<no response>", ""): |
| 132 | return [] |
| 133 | if not text.startswith("Event"): |
| 134 | # unexpected - re-login once in case the session lapsed |
| 135 | if attempt == 0 and ("invalid" in text.lower() or "fail" in text.lower()): |
| 136 | self.login() |
| 137 | continue |
| 138 | return [] |
| 139 | return parse_event_feed(text) |
| 140 | return [] |
| 141 | |
| 142 | def logout(self): |
| 143 | try: |
| 144 | if self.key: |
| 145 | self._raw(DLL, encrypt("Command&Type=Session&SubType=Logout", self.key, self.sessid)) |
| 146 | except Exception: |
| 147 | pass |
| 148 | self.key = self.sessid = None |
| 149 | |
| 150 | |
| 151 | def parse_event_feed(text): |
| 152 | """WX live-feed key=value reply -> [{ts, desc, raw, fields}], oldest first. |
| 153 | Each event is Event<n> = <timestamp><description>. The controller here formats the timestamp like |
| 154 | 'Tue 29/09/2026 13:35:46' and pads the description with spaces. TypeId<n>/User<n> may also be present.""" |
| 155 | kv = {} |
| 156 | for pair in text.split("&"): |
| 157 | if "=" in pair: |
| 158 | k, v = pair.split("=", 1) |
| 159 | kv[k] = v |
| 160 | idxs = sorted(int(k[5:]) for k in kv if k.startswith("Event") and k[5:].isdigit()) |
| 161 | events = [] |
| 162 | for n in idxs: |
| 163 | val = kv.get(f"Event{n}", "") |
| 164 | ts, desc = _split_ts_desc(val) |
| 165 | if not desc: |
| 166 | continue |
| 167 | fields = {"Description": desc} |
| 168 | if kv.get(f"User{n}"): |
| 169 | fields["User"] = kv[f"User{n}"] |
| 170 | if kv.get(f"TypeId{n}"): |
| 171 | fields["TypeId"] = kv[f"TypeId{n}"] |
| 172 | events.append({"ts": ts, "desc": desc, "raw": desc, "fields": fields}) |
| 173 | # feed comes newest-first; return oldest-first so we ingest in chronological order |
| 174 | events.reverse() |
| 175 | return events |
| 176 | |
| 177 | |
| 178 | # timestamp like "Tue 29/09/2026 13:35:46" or "29/09/2026 13:35:46" or ISO; capture it off the front |
| 179 | _TS_RE = re.compile(r"^\s*(?:[A-Za-z]{3}\s+)?" # optional weekday |
| 180 | r"(\d{1,4}[-/]\d{1,2}[-/]\d{1,4}[ T]\d{1,2}:\d{2}(?::\d{2})?(?:\s*[APap][Mm])?)" |
| 181 | r"(?:\.\d+)?\s*") # optional .000 milliseconds |
| 182 | |
| 183 | |
| 184 | def _split_ts_desc(val): |
| 185 | m = _TS_RE.match(val) |
| 186 | if m: |
| 187 | return _parse_dt(m.group(1)), val[m.end():].strip() |
| 188 | # fallback: WX classic 23-char timestamp prefix |
| 189 | return _parse_dt(val[:23]), val[23:].strip() |
| 190 | |
| 191 | |
| 192 | def _parse_dt(text): |
| 193 | text = text.strip() |
| 194 | for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%d/%m/%Y %I:%M:%S %p", "%d/%m/%Y %H:%M:%S", |
| 195 | "%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M:%S", "%d %b %Y %H:%M:%S", "%d %B %Y %I:%M:%S%p", |
| 196 | "%d/%m/%Y %H:%M", "%m/%d/%Y %H:%M", "%d/%m/%Y %I:%M %p", "%m/%d/%Y %I:%M %p", |
| 197 | "%Y-%m-%d %H:%M", "%d %b %Y %I:%M:%S %p", "%d %B %Y %H:%M:%S", "%b %d %Y %H:%M:%S"): |
| 198 | try: |
| 199 | return datetime.strptime(text, fmt) |
| 200 | except ValueError: |
| 201 | continue |
| 202 | return None |