using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; using System.Threading; using System.Web.Script.Serialization; using System.Windows.Forms; // mscorlib keeps obsolete copies of these three in the parent namespace, so // spelling out which one is meant is not optional — the aliases win the tie. using TYPEATTR = System.Runtime.InteropServices.ComTypes.TYPEATTR; using FUNCDESC = System.Runtime.InteropServices.ComTypes.FUNCDESC; using IMPLTYPEFLAGS = System.Runtime.InteropServices.ComTypes.IMPLTYPEFLAGS; using ParameterModifier = System.Reflection.ParameterModifier; namespace ZKBridge { /// /// Exposes the ZK4500 over http://localhost:8787 so the ERP, running in a /// browser, can use a USB scanner it could never reach on its own. /// /// Quasar app --HTTP--> ZKBridge --ZKFPEngX (ActiveX)--> ZK4500 /// /// This build targets the classic ZKFinger SDK: the ZKFPEngX control, /// CLSID {CA69969C-2F27-41D3-954D-A48B941C3BA7}, the one the SDK's own /// Demo.exe uses. Everything is late-bound COM, so nothing has to be /// compiled against the vendor's DLLs — if the demo runs on this PC, /// this runs on this PC. /// /// The control is an apartment-threaded ActiveX object that reports /// captures through COM events, so one STA thread owns it and pumps /// messages; HTTP threads hand it work and wait on signals. /// internal static class Program { private const string Prefix = "http://localhost:8787/"; private static readonly Guid Clsid = new Guid("CA69969C-2F27-41D3-954D-A48B941C3BA7"); private const int DefaultTimeoutMs = 10000; private const int EnrollTimeoutMs = 45000; // ── State owned by the STA thread ───────────────────────────────── private static dynamic _fp; // the ZKFPEngX control private static Control _pump; // hidden control: Invoke marshals onto the STA private static Form _host; // invisible window the OCX is sited on private static Ax _ax; // its AxHost wrapper private static bool _hosted; // windowed (like the demo) or bare COM private static Guid _sourceIid; private static readonly List _sinks = new List(); // keep delegates alive private static int _cacheDb = -1; private static int _loaded; private static string _initError = "Starting…"; // ── Waiters the event handlers complete ─────────────────────────── private static readonly object Gate = new object(); // one scanner, one operation private static ManualResetEventSlim _captureDone; private static string _captureTemplate, _captureImage; private static ManualResetEventSlim _enrollDone; private static string _enrollTemplate, _enrollImage; private static bool _enrollOk, _enrolling; private static Mutex _single; // one bridge per machine [STAThread] private static void Main() { Console.Title = "ZK4500 Bridge (ZKFPEngX)"; // Two bridges fight over one scanner and both lose — the second // window refuses to start instead. _single = new Mutex(true, "ZKBridge-8787", out bool firstInstance); if (!firstInstance) { Say("Another ZKBridge window is already running — use that one."); Say("Press Enter to close this window."); Console.ReadLine(); return; } // The control lives on this thread; WinForms gives it a message // pump so its events actually arrive. _pump = new Control(); var _ = _pump.Handle; // force handle creation string problem = Open(); Say(problem ?? Ready()); var listener = new HttpListener(); listener.Prefixes.Add(Prefix); try { listener.Start(); } catch (HttpListenerException e) { Say("Cannot listen on " + Prefix + " — " + e.Message); Say("If another ZKBridge window is open, close it. Otherwise run ZKBridge.exe as Administrator once, then start it normally."); Console.ReadLine(); return; } Say("Listening on " + Prefix + " (close this window to stop)"); // Accept on a worker; handle each request on the pool. The STA // thread stays free to pump COM events. new Thread(() => { while (listener.IsListening) { HttpListenerContext ctx; try { ctx = listener.GetContext(); } catch { break; } ThreadPool.QueueUserWorkItem(delegate { Handle(ctx); }); } }) { IsBackground = true }.Start(); Application.Run(); // the message pump } private static string Ready() { return string.Format("Scanner ready ({0}) — {1}x{2}, engine {3}{4}", _hosted ? "windowed host" : "bare host", (int)_fp.ImageWidth, (int)_fp.ImageHeight, (string)_fp.FPEngineVersion, string.IsNullOrEmpty((string)_fp.SensorSN) ? "" : ", serial " + (string)_fp.SensorSN); } // ── Device lifecycle (STA only) ─────────────────────────────────── /// Creates and initialises the control. Null on success. private static string Open() { try { Close(); Type t = Type.GetTypeFromCLSID(Clsid); if (t == null) return Broken("The ZKFPEngX control is not registered. Run the SDK's setup.exe, or regsvr32 ZKFPEngX.ocx."); // Site the control on a real (invisible) window, exactly the // way the SDK's own demo hosts it on a dialog. Created bare, // this control initialises and answers properties but never // starts its capture loop — connected, yet deaf to fingers. try { _host = new Form { ShowInTaskbar = false, FormBorderStyle = FormBorderStyle.None, StartPosition = FormStartPosition.Manual, Location = new System.Drawing.Point(-2000, -2000), Size = new System.Drawing.Size(1, 1), Opacity = 0, }; _ax = new Ax(Clsid.ToString()); _host.Controls.Add(_ax); _host.Show(); // off-screen and transparent _ax.CreateControl(); _fp = _ax.Ocx; _hosted = _fp != null; } catch (Exception ex) { Say("Windowed hosting failed (" + ex.Message + ") — using bare COM instead."); DisposeHost(); } if (_fp == null) { _fp = Activator.CreateInstance(t); _hosted = false; } int rc = (int)_fp.InitEngine(); if (rc != 0) { return Broken("InitEngine failed (code " + rc + "). Close Demo.exe and any other ZKBridge window, replug the reader, then press Reconnect."); } if ((int)_fp.SensorCount < 1) { return Broken("The engine started but no reader was found. Replug the ZK4500, then press Reconnect."); } // 10.0 is the modern template format; every stored print and // the whole matching path stay in one consistent format. try { _fp.FPEngineVersion = "10.0"; } catch { } _fp.EnrollCount = 3; // Capture mode until an enrolment explicitly starts. Register // mode left on is the classic reason presses go silent. try { _fp.IsRegister = false; } catch { } HookEvents(); _fp.Active = true; // start listening for fingers _initError = null; return null; } catch (COMException e) { return Broken("COM error opening the scanner: " + e.Message); } catch (Exception e) { return Broken(e.Message); } } /// /// A failed open, done properly: release the control (a half-open one /// keeps the device hostage and makes every retry fail too) and store /// the reason where /status can report it. /// private static string Broken(string why) { Close(); _initError = why; return why; } private static void Close() { _loaded = 0; if (_fp != null) { try { if (_cacheDb > 0) _fp.FreeFPCacheDB(_cacheDb); } catch { } _cacheDb = -1; try { _fp.Active = false; } catch { } try { _fp.EndEngine(); } catch { } _fp = null; } DisposeHost(); } private static void DisposeHost() { try { _ax?.Dispose(); } catch { } try { _host?.Dispose(); } catch { } _ax = null; _host = null; _hosted = false; } /// AxHost with the guts exposed — real OLE siting for the OCX. private sealed class Ax : AxHost { public Ax(string clsid) : base(clsid) { } public object Ocx { get { return GetOcx(); } } } // ── COM events, without an interop assembly ─────────────────────── // // The dispids of OnCapture/OnEnroll differ between OCX builds, so they // are read from the control's own type library at runtime: find the // coclass's [default, source] dispinterface, note its IID, and map // event names to dispids. ComEventsHelper then does the sink plumbing. [ComImport, Guid("B196B283-BAB4-101A-B69C-00AA00341D07"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IProvideClassInfo { [return: MarshalAs(UnmanagedType.Interface)] ITypeInfo GetClassInfo(); } private delegate void Ev0(); private delegate void Ev1(object a); private delegate void Ev2(object a, object b); private delegate void Ev3(object a, object b, object c); private delegate void Ev4(object a, object b, object c, object d); private static void HookEvents() { // name, dispid, parameter count — read from the control's own // type library, because dispids differ between OCX builds. var events = new List>(); ITypeInfo coclass = ((IProvideClassInfo)_fp).GetClassInfo(); coclass.GetTypeAttr(out IntPtr pAttr); var attr = (TYPEATTR)Marshal.PtrToStructure(pAttr, typeof(TYPEATTR)); int implCount = attr.cImplTypes; coclass.ReleaseTypeAttr(pAttr); for (int i = 0; i < implCount; i++) { coclass.GetImplTypeFlags(i, out IMPLTYPEFLAGS flags); const IMPLTYPEFLAGS wanted = IMPLTYPEFLAGS.IMPLTYPEFLAG_FDEFAULT | IMPLTYPEFLAGS.IMPLTYPEFLAG_FSOURCE; if ((flags & wanted) != wanted) continue; coclass.GetRefTypeOfImplType(i, out int href); coclass.GetRefTypeInfo(href, out ITypeInfo source); source.GetTypeAttr(out IntPtr pSrcAttr); var srcAttr = (TYPEATTR)Marshal.PtrToStructure(pSrcAttr, typeof(TYPEATTR)); _sourceIid = srcAttr.guid; int funcs = srcAttr.cFuncs; source.ReleaseTypeAttr(pSrcAttr); for (int f = 0; f < funcs; f++) { source.GetFuncDesc(f, out IntPtr pFunc); var fd = (FUNCDESC)Marshal.PtrToStructure(pFunc, typeof(FUNCDESC)); var names = new string[1]; source.GetNames(fd.memid, names, 1, out int _); if (!string.IsNullOrEmpty(names[0])) events.Add(Tuple.Create(names[0], fd.memid, (int)fd.cParams)); source.ReleaseFuncDesc(pFunc); } break; } if (_sourceIid == Guid.Empty) throw new InvalidOperationException("The control's event interface was not found in its type library."); // Every event gets a sink: the interesting ones drive the logic, // the rest are logged so a silent scanner can be diagnosed from // this console alone. var wired = new List(); foreach (var e in events) { string name = e.Item1; Delegate handler; switch (e.Item3) { case 0: handler = new Ev0(() => OnComEvent(name)); break; case 1: handler = new Ev1(a => OnComEvent(name, a)); break; case 2: handler = new Ev2((a, b) => OnComEvent(name, a, b)); break; case 3: handler = new Ev3((a, b, c) => OnComEvent(name, a, b, c)); break; case 4: handler = new Ev4((a, b, c, d) => OnComEvent(name, a, b, c, d)); break; default: continue; // RFID card events we never use } ComEventsHelper.Combine(_fp, _sourceIid, e.Item2, handler); _sinks.Add(handler); wired.Add(name); } Say("Events wired: " + string.Join(", ", wired)); } /// Every control event lands here: log it, then act on it. private static void OnComEvent(string name, params object[] args) { try { Say(" · " + name + Fmt(args)); switch (name.ToLowerInvariant()) { case "oncapture": CaptureArrived(args); break; case "onenroll": EnrollArrived(args); break; case "onfeatureinfo": FeatureArrived(); break; } } catch (Exception e) { Say(name + ": " + e.Message); } } private static string Fmt(object[] args) { if (args == null || args.Length == 0) return ""; var parts = new List(); foreach (object a in args) parts.Add(a == null ? "null" : (a is string s ? s : (a.GetType().IsArray ? "bytes" : a.ToString()))); return "(" + string.Join(", ", parts) + ")"; } /// A print in capture mode. Fulfils a waiting /capture. private static void CaptureArrived(object[] args) { if (_enrolling) return; if (args.Length > 0 && !Truthy(args[0])) return; Fulfil(TemplateFrom(args.Length > 1 ? args[1] : null)); } /// /// Features extracted from a press — the earliest moment a template /// exists. Some OCX builds never raise OnCapture outside register /// mode, so a waiting /capture is fulfilled from here as well; Fulfil /// keeps only the first result. /// private static void FeatureArrived() { if (_enrolling || _captureDone == null) return; Fulfil(TemplateFrom(null)); } private static void Fulfil(string template) { var waiter = _captureDone; if (waiter == null || waiter.IsSet || string.IsNullOrEmpty(template)) return; _captureTemplate = template; _captureImage = GrabImage(); waiter.Set(); } /// Third press of an enrolment. The control merged the template itself. private static void EnrollArrived(object[] args) { _enrolling = false; try { _fp.IsRegister = false; } catch { } if (_enrollDone == null) return; _enrollOk = args.Length > 0 && Truthy(args[0]); if (_enrollOk) { _enrollTemplate = TemplateFrom(args.Length > 1 ? args[1] : null); _enrollImage = GrabImage(); _enrollOk = !string.IsNullOrEmpty(_enrollTemplate); } _enrollDone.Set(); } /// The event's own template if usable, else the control's last one. private static string TemplateFrom(object variantTemplate) { string s = null; if (variantTemplate != null) { try { s = (string)_fp.EncodeTemplate1(variantTemplate); } catch { } } if (string.IsNullOrEmpty(s)) { try { s = (string)_fp.GetTemplateAsString(); } catch { } } if (string.IsNullOrEmpty(s)) { try { object v = _fp.GetVerTemplate(); s = (string)_fp.EncodeTemplate1(v); } catch { } } return s; } private static bool Truthy(object v) { try { return Convert.ToBoolean(v); } catch { return v != null; } } /// The last scanned image, as a data URI the app can show directly. private static string GrabImage() { string path = Path.Combine(Path.GetTempPath(), "zkbridge-" + Guid.NewGuid().ToString("N") + ".jpg"); try { _fp.SaveJPG(path); return "data:image/jpeg;base64," + Convert.ToBase64String(File.ReadAllBytes(path)); } catch { return null; } finally { try { File.Delete(path); } catch { } } } // ── Run-on-the-STA helper ───────────────────────────────────────── private static T Sta(Func work) { return (T)_pump.Invoke(work); } // ── Endpoints ───────────────────────────────────────────────────── private static Dictionary Status() { return Sta(() => { bool open = _fp != null; return new Dictionary { { "success", true }, { "connected", open && (int)_fp.SensorCount > 0 }, { "width", open ? (int)_fp.ImageWidth : 0 }, { "height", open ? (int)_fp.ImageHeight : 0 }, { "loaded", _loaded }, { "serial", open ? (string)_fp.SensorSN : null }, { "host", open ? (_hosted ? "windowed" : "bare") : null }, { "engine", open ? (string)_fp.FPEngineVersion : null }, { "enrolling", _enrolling }, { "enroll_remaining", open && _enrolling ? (object)(int)_fp.EnrollIndex : null }, { "message", _initError }, }; }); } private static Dictionary Reconnect() { lock (Gate) { string problem = Sta(() => Open()); return problem == null ? Reply(true, "Scanner reconnected.") : Fail(problem); } } private static Dictionary Capture(Dictionary body) { lock (Gate) { if (NotOpen(out var no)) return no; int timeout = Int(body, "timeoutMs", DefaultTimeoutMs); _captureTemplate = _captureImage = null; // Make sure the control is in capture mode and armed — both // are no-ops when already true, and their absence is silence. Sta(() => { try { _fp.IsRegister = false; } catch { } try { _fp.BeginCapture(); } catch { } return null; }); using (_captureDone = new ManualResetEventSlim(false)) { bool got = _captureDone.Wait(timeout); _captureDone = null; if (!got || string.IsNullOrEmpty(_captureTemplate)) return Fail("No finger was placed on the scanner."); var reply = Reply(true, null); reply["template"] = _captureTemplate; reply["image"] = Bool(body, "image", true) ? _captureImage : null; return reply; } } } /// /// One whole enrolment: the control collects three presses of the same /// finger and merges them itself. Blocks until done; the app polls /// /status meanwhile to show "press 2 of 3". /// private static Dictionary Enroll(Dictionary body) { lock (Gate) { if (NotOpen(out var no)) return no; int timeout = Int(body, "timeoutMs", EnrollTimeoutMs); _enrollTemplate = _enrollImage = null; _enrollOk = false; using (_enrollDone = new ManualResetEventSlim(false)) { Sta(() => { _enrolling = true; try { _fp.IsRegister = true; } catch { } _fp.BeginEnroll(); return null; }); bool finished = _enrollDone.Wait(timeout); _enrollDone = null; if (!finished) { Sta(() => { _enrolling = false; try { _fp.CancelEnroll(); } catch { } try { _fp.IsRegister = false; } catch { } return null; }); return Fail("Enrolment was not completed. Press the same finger three times."); } if (!_enrollOk || string.IsNullOrEmpty(_enrollTemplate)) return Fail("Those three presses did not agree. Use the same finger, flat and centred."); var reply = Reply(true, null); reply["template"] = _enrollTemplate; reply["image"] = _enrollImage; return reply; } } } private static Dictionary CancelEnrollNow() { return Sta(() => { _enrolling = false; try { if (_fp != null) _fp.CancelEnroll(); } catch { } try { if (_fp != null) _fp.IsRegister = false; } catch { } _enrollDone?.Set(); return Reply(true, "Enrolment cancelled."); }); } /// 1:1 — does the finger on the scanner match this stored template? private static Dictionary Verify(Dictionary body) { string stored = Str(body, "template"); if (string.IsNullOrEmpty(stored)) return Fail("No stored fingerprint was supplied."); var scan = Capture(body); if (!(bool)scan["success"]) return scan; string candidate = (string)scan["template"]; return Sta(() => { // VerFingerFromStr takes the stored template by ref (it can // "learn"); learning is off, so the copy is discarded. object[] args = { stored, candidate, false, false }; var mods = new[] { new ParameterModifier(4) }; mods[0][0] = true; mods[0][3] = true; bool matched = (bool)((object)_fp).GetType().InvokeMember( "VerFingerFromStr", System.Reflection.BindingFlags.InvokeMethod, null, _fp, args, mods, null, null); var reply = Reply(true, null); reply["matched"] = matched; reply["score"] = matched ? (object)(int)_fp.GetVerScore() : 0; return reply; }); } /// Load every enrolled template so /identify can search them. private static Dictionary LoadDb(Dictionary body) { var list = body.ContainsKey("templates") ? body["templates"] as object[] : null; if (list == null) return Fail("No templates were supplied."); lock (Gate) { if (NotOpen(out var no)) return no; return Sta(() => { if (_cacheDb > 0) { try { _fp.FreeFPCacheDB(_cacheDb); } catch { } } _cacheDb = (int)_fp.CreateFPCacheDB(); if (_cacheDb <= 0) return Fail("Could not create the matching cache."); int loaded = 0, failed = 0; foreach (object row in list) { var entry = row as Dictionary; int id = Int(entry, "id", 0); // the Laravel user id string tpl = Str(entry, "template"); if (entry == null || id <= 0 || string.IsNullOrEmpty(tpl)) { failed++; continue; } int rc = (int)_fp.AddRegTemplateStrToFPCacheDB(_cacheDb, id, tpl); if (rc >= 0) loaded++; else failed++; } _loaded = loaded; var reply = Reply(true, null); reply["loaded"] = loaded; reply["failed"] = failed; return reply; }); } } /// 1:N — who does the finger on the scanner belong to? private static Dictionary Identify(Dictionary body) { if (_loaded == 0) return Fail("No fingerprints are loaded. Load the database first."); var scan = Capture(body); if (!(bool)scan["success"]) return scan; string candidate = (string)scan["template"]; return Sta(() => { object[] args = { _cacheDb, candidate, 0, 0 }; var mods = new[] { new ParameterModifier(4) }; mods[0][2] = true; mods[0][3] = true; int id = (int)((object)_fp).GetType().InvokeMember( "IdentificationFromStrInFPCacheDB", System.Reflection.BindingFlags.InvokeMethod, null, _fp, args, mods, null, null); bool matched = id > 0; // FPIDs are user ids, always >= 1 var reply = Reply(true, null); reply["matched"] = matched; reply["id"] = matched ? (object)id : null; reply["score"] = matched ? (object)Convert.ToInt32(args[2]) : null; return reply; }); } private static bool NotOpen(out Dictionary fail) { bool open = Sta(() => _fp != null); fail = open ? null : Fail(_initError ?? "No fingerprint reader is connected."); return !open; } // ── HTTP plumbing ───────────────────────────────────────────────── private static void Handle(HttpListenerContext ctx) { HttpListenerRequest req = ctx.Request; HttpListenerResponse res = ctx.Response; res.Headers["Access-Control-Allow-Origin"] = "*"; res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; res.Headers["Access-Control-Allow-Headers"] = "Content-Type"; // Chrome refuses to let a page reach localhost without this. res.Headers["Access-Control-Allow-Private-Network"] = "true"; if (req.HttpMethod == "OPTIONS") { res.StatusCode = 204; res.Close(); return; } string path = req.Url.AbsolutePath.TrimEnd('/'); // The root is a human: serve the built-in test console, so the // scanner can be exercised with a browser and nothing else. if (path.Length == 0 && req.HttpMethod == "GET") { byte[] page = Encoding.UTF8.GetBytes(TestPage); res.ContentType = "text/html; charset=utf-8"; res.ContentLength64 = page.Length; try { res.OutputStream.Write(page, 0, page.Length); res.OutputStream.Close(); } catch { } return; } if (path.Length == 0) path = "/status"; Dictionary reply; try { var body = ReadBody(req); switch (path) { case "/status": reply = Status(); break; case "/reconnect": reply = Reconnect(); break; case "/capture": reply = Capture(body); break; case "/enroll": reply = Enroll(body); break; case "/enroll/cancel": reply = CancelEnrollNow(); break; case "/enroll/merge": // The old bridge merged three captures; ZKFPEngX enrols // in one device-side flow instead. reply = Fail("This bridge enrols with POST /enroll — one call, three presses."); break; case "/verify": reply = Verify(body); break; case "/db/load": reply = LoadDb(body); break; case "/identify": reply = Identify(body); break; default: res.StatusCode = 404; reply = Fail("Unknown endpoint " + path); break; } } catch (Exception e) { res.StatusCode = 500; reply = Fail(e.Message); } Say(req.HttpMethod + " " + path + " -> " + reply["success"]); Write(res, reply); } private static Dictionary ReadBody(HttpListenerRequest req) { var empty = new Dictionary(); if (!req.HasEntityBody) return empty; using (var reader = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8)) { string raw = reader.ReadToEnd(); if (string.IsNullOrEmpty(raw.Trim())) return empty; var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }; return json.Deserialize>(raw) ?? empty; } } private static void Write(HttpListenerResponse res, Dictionary reply) { var json = new JavaScriptSerializer { MaxJsonLength = int.MaxValue }; byte[] payload = Encoding.UTF8.GetBytes(json.Serialize(reply)); res.ContentType = "application/json"; res.ContentLength64 = payload.Length; try { res.OutputStream.Write(payload, 0, payload.Length); res.OutputStream.Close(); } catch { /* the page navigated away mid-scan */ } } // ── Helpers ─────────────────────────────────────────────────────── private static Dictionary Reply(bool success, string message) { var reply = new Dictionary { { "success", success } }; if (message != null) reply["message"] = message; return reply; } private static Dictionary Fail(string message) { var reply = Reply(false, message); reply["matched"] = false; return reply; } private static string Str(Dictionary body, string key) { return body != null && body.ContainsKey(key) ? body[key] as string : null; } private static int Int(Dictionary body, string key, int fallback) { if (body == null || !body.ContainsKey(key) || body[key] == null) return fallback; try { return Convert.ToInt32(body[key]); } catch { return fallback; } } private static bool Bool(Dictionary body, string key, bool fallback) { if (body == null || !body.ContainsKey(key) || body[key] == null) return fallback; try { return Convert.ToBoolean(body[key]); } catch { return fallback; } } private static void Say(string line) { Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + " " + line); } /// /// The page at http://localhost:8787/ — a self-contained test console, /// so proving the scanner works takes a browser and a finger, nothing /// else. Single quotes throughout keep this verbatim string readable. /// private const string TestPage = @" ZK4500 Bridge — Test

ZK4500 bridge — test console

checking…
finger "; } }