localeye.cam

← Back

protege_client.py

doors version 2.5 · 9 KB

1"""
2ICT Protege WX client - logs in and pulls door events, for the NVR's door integration.
3
4Speaks the WX web interface's own encrypted protocol (all reproduced and verified against the controller's
5JavaScript 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
9Events 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).
11The decrypted reply is the WX key=value format; each event is Event<n>=<timestamp><description sentence>.
12We poll every few seconds and pass new events to the Doors engine. Read-only: nothing here ever changes
13anything on the controller.
14"""
15import csv
16import io
17import re
18import secrets
19import threading
20import time
21from datetime import datetime, timedelta
22
23import requests
24
25from .protege_auth import login_proof, session_key
26from .protege_crypto import decrypt, encrypt
27
28DLL = "PRT_CTRL_DIN_ISAPI.dll"
29
30
31class ProtegeError(Exception):
32 pass
33
34
35class 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
151def 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
184def _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
192def _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