diff --git a/debian/control b/debian/control index 2b09da3..ed04c48 100644 --- a/debian/control +++ b/debian/control @@ -16,6 +16,7 @@ Depends: ${python3:Depends}, python3-dbus, python3-usb, python3-yaml, + python3-numpy, dbus, open-fprintd (>= 0.6~), innoextract (>= 1.6~) diff --git a/scripts/enroll_moh_chip.py b/scripts/enroll_moh_chip.py new file mode 100644 index 0000000..bd5f1cf --- /dev/null +++ b/scripts/enroll_moh_chip.py @@ -0,0 +1,226 @@ +"""Debug/CLI tool: native enrollment on a 06cb:00a2 sensor. + +Captures N frames from the connected sensor, runs the native feature +pipeline (validitysensor/moh_native.py), builds a chip-storable template +from the baked-in framing scaffold (blobs_a2.build_ws_scaffold) — no +captured reference template needed — stores it via raw 0x47, then +optionally tries to identify the finger to verify the chip accepts our +template. + +Run on a machine with the sensor plugged in and python-validity +initialised (i.e., the usual `validity-sensors-firmware` & TLS handshake +have already been done — same prerequisites as the existing `enroll` +script in this repo). + +Usage: + sudo python3 scripts/enroll_moh_chip.py \\ + --parent \\ + [--match] # try to identify after enroll + + --subtype N WinBio subtype (= finger position). Defaults to 0xf5 + (right index, common test). Look at validitysensor/ + fingerprint_constants.py for the full list. + + --match after storing, capture a fresh frame and ask the chip + to identify (sensor.match_finger). If the chip matches + it back to the userid we just enrolled, the pipeline + is end-to-end working. +""" +import argparse +import logging +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def _try_match(log): + """Capture a fresh frame and ask the chip to identify. Returns True on a + match, False on 'not recognized' (logged cleanly, no traceback).""" + from validitysensor.sensor import sensor as Sensor + log.info('LIFT FINGER, then place the SAME finger to verify the chip matches ...') + try: + usrid, subtype_out, hsh = Sensor.identify( + lambda e: log.warning(f'identify capture retry: {e}')) + log.info(f'✓ CHIP MATCHED: usrid={usrid}, subtype=0x{subtype_out:x}') + return True + except Exception as e: + log.warning(f'✗ NO MATCH ({e})') + return False + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--subtype', default='0xf5', + help='WinBio subtype, hex or decimal (default 0xf5)') + ap.add_argument('--parent', type=int, default=5, + help='parent user dbid (use --list-users to find the ' + 'StgWindsor user dbid; default 5)') + ap.add_argument('--frames', type=int, default=6, + help='number of DISTINCT placements to capture (default 6); ' + 'the best 4 (most keypoints) fill the template\'s 4 v30 ' + 'sections. Vary finger placement between captures for ' + 'coverage.') + ap.add_argument('--match', action='store_true', + help='after enroll, capture again and try to identify') + ap.add_argument('--dry-run', action='store_true', + help='build the envelope but DO NOT store on chip; ' + 'write it to /tmp/native_envelope.bin instead') + ap.add_argument('--match-only', action='store_true', + help='do NOT enroll; just run the chip identify against ' + 'whatever is already stored.') + ap.add_argument('--delete-dbid', type=int, default=None, + help='delete the FINGER record with this dbid (see ' + '--list-users), then exit. ' + 'Refuses to delete a USER record (would orphan fingers) ' + 'unless --force.') + ap.add_argument('--force', action='store_true', + help='allow --delete-dbid to delete a non-finger record') + ap.add_argument('--user-sid', default=None, + help='enroll under this user SID, creating the user if it ' + 'does not exist (self-contained: no pre-existing ' + 'user needed). Overrides --parent.') + ap.add_argument('--list-users', action='store_true', + help='dump the chip DB tree (db.dump_raw) and exit; ' + 'use to find a real parent dbid to pass via --parent') + args = ap.parse_args() + + logging.basicConfig(level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s') + log = logging.getLogger('enroll_moh_chip') + + # Imports that need the venv + libusb actually wired + from validitysensor.init import open as open_device + from validitysensor.sensor import sensor as Sensor, RebootException + from validitysensor.db import db + + subtype = int(args.subtype, 0) + parent = args.parent + + log.info('reference-free: using baked-in WS-body framing scaffold') + + try: + open_device() + except RebootException: + log.info('sensor rebooted — re-opening') + open_device() + + if args.delete_dbid is not None: + try: + rec = db.get_record_value(args.delete_dbid) + rtype = rec.type + except Exception: + rtype = None + if rtype is not None and rtype != 6 and not args.force: + log.error(f'dbid={args.delete_dbid} is type {rtype} ' + f'({"USER" if rtype == 5 else "non-finger"}), not a FINGER ' + f'(type 6). Deleting it would orphan its children. Pick a ' + f'FINGER dbid from --list-users, or pass --force.') + return 2 + log.info(f'deleting record dbid={args.delete_dbid} (type {rtype}) ...') + try: + db.del_record(args.delete_dbid) + log.info(f'✓ deleted dbid={args.delete_dbid}') + except Exception as e: + log.error(f'delete failed: {e}') + return 2 + return 0 + + if args.match_only: + ok = _try_match(log) + log.info('MATCH-ONLY (chip identify against stored fingers): ' + + ('MATCHED.' if ok else 'NO MATCH.')) + return 0 if ok else 3 + + if args.list_users: + log.info('chip user storage + enrolled users (find parent dbid here):') + try: + stg = db.get_user_storage(name='StgWindsor') + log.info(f' StgWindsor: dbid={stg.dbid}, ' + f'{len(stg.users)} user(s)') + for u_meta in stg.users: + udbid = u_meta['dbid'] + try: + u = db.get_user(udbid) + log.info(f' user dbid={udbid} ' + f'identity={u.identity!r} ' + f'fingers={len(u.fingers)}') + for f in u.fingers: + log.info(f' finger dbid={f["dbid"]} ' + f'subtype=0x{f["subtype"]:02x}') + except Exception as e: + log.info(f' user dbid={udbid} (could not parse: {e})') + except Exception as e: + log.error(f'get_user_storage failed: {e}') + log.info('try dumping all roots 1..16:') + for r in range(1, 17): + try: + rec = db.get_record_value(r) + val = bytes(rec.value) + log.info(f' root {r}: type={rec.type} ' + f'val[:32]={val[:32].hex()}') + except Exception: + pass + return 0 + + if args.dry_run: + # Capture + build envelope but don't talk to the chip. + from validitysensor.sensor import CaptureMode, glow_start_scan, glow_end_scan + from validitysensor.moh_native import native_template + import numpy as np + glow_start_scan() + log.info('place finger now (dry-run, will not store)') + x, y, w1, w2, img_data = Sensor.capture(CaptureMode.ENROLL) + glow_end_scan() + img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) # NO transpose (feature frame) + if img.shape != (112, 112): + ys = (np.arange(112) * img.shape[0] // 112) + xs = (np.arange(112) * img.shape[1] // 112) + img112 = img[ys[:, None], xs[None, :]] + else: + img112 = img + img_q16 = img112.astype(np.int32) << 16 + # Log frame stats + save the raw frame for offline inspection. + # A good capture looks like: 112x112, min=0 max=255 mean~135 std~75. + log.info(f' CAPTURE: raw dims {x}x{y} ({len(img_data)}B); 112x112 ' + f'min={int(img112.min())} max={int(img112.max())} ' + f'mean={float(img112.mean()):.1f} std={float(img112.std()):.1f}') + _p = f'/tmp/native_capture_{x}x{y}.bin' + with open(_p, 'wb') as fp: + fp.write(img112.astype(np.uint8).tobytes()) + log.info(f' saved capture -> {_p}') + from validitysensor.moh_native import extract_frame_native as _ext + log.info(f' pipeline on live frame: {len(_ext(img_q16))} keypoints') + envelope = native_template(img_q16, subtype=subtype) + with open('/tmp/native_envelope.bin', 'wb') as f: + f.write(envelope) + log.info(f'✓ wrote /tmp/native_envelope.bin ({len(envelope)} bytes)') + return 0 + + if args.user_sid: + usr = db.lookup_user(args.user_sid) + if usr is None: + parent = db.new_user(args.user_sid) + log.info(f'created user {args.user_sid!r} → dbid {parent}') + else: + parent = usr.dbid + log.info(f'using existing user {args.user_sid!r} → dbid {parent}') + + log.info(f'enrolling subtype 0x{subtype:x} under parent dbid {parent} ' + f'with {args.frames} frame(s)...') + recid = Sensor.enroll_moh(parent, subtype, + num_frames=args.frames) + log.info(f'✓ native enrollment stored, recid={recid}') + + if args.match: + # identify() = capture(IDENTIFY) (waits for finger-present) + match. + if _try_match(log): + log.info(' → native pipeline produces chip-acceptable templates!') + return 0 + return 3 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.py b/setup.py index ac6d50b..d2eadfa 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ 'bin/validity-led-dance', 'bin/validity-sensors-firmware', ], - install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12'], + install_requires=['cryptography >= 2.1.4', 'pyusb >= 1.0.0', 'pyyaml >= 3.12', 'numpy'], data_files=[ ('share/dbus-1/system.d/', ['dbus_service/io.github.uunicorn.Fprint.conf']), ('lib/python-validity/', ['dbus_service/dbus-service']), diff --git a/tests/test_moh_native_smoke.py b/tests/test_moh_native_smoke.py new file mode 100644 index 0000000..1ad0fa7 --- /dev/null +++ b/tests/test_moh_native_smoke.py @@ -0,0 +1,67 @@ +"""Smoke tests for the native (float) MoH pipeline. + +These do NOT pin byte-exact output (this branch is intentionally non-byte-exact +— see the commit that introduced the float rewrite). They guard the contract +that the chip parses literally: keypoint bounds, descriptor length, the v30 +record layout, the envelope framing, and the TID round-trip. + +Run: PYTHONPATH=. python -m pytest tests/test_moh_native_smoke.py +""" +import numpy as np +import pytest + +from validitysensor import moh_native as m + + +def _synthetic_frame(seed=7): + """A textured 112x112 uint8 frame (gaussian blobs + noise) → Q16.""" + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:112, 0:112] + img = np.full((112, 112), 128.0) + for _ in range(60): + cy, cx = rng.integers(8, 104, 2) + amp = rng.uniform(-90, 90) + r = rng.uniform(2, 6) + img += amp * np.exp(-((yy - cy) ** 2 + (xx - cx) ** 2) / (2 * r * r)) + img = np.clip(img + rng.normal(0, 8, img.shape), 0, 255).astype(np.uint8) + return img.astype(np.int32) << 16 + + +def test_extract_frame_native_shape_and_bounds(): + kps = m.extract_frame_native(_synthetic_frame()) + assert kps, "expected at least one keypoint on a textured frame" + assert len(kps) <= m.FRAME_KP_CAP + for gx, gy, orient, desc in kps: + assert 3 <= gx < 109 and 3 <= gy < 109, "keypoint outside the A960 bound" + assert 0.0 <= orient < m.TWO_PI + assert len(desc) == m.V30_DESC_LEN == 16 + + +def test_serialize_v30_section_layout(): + desc = bytes(range(16)) + sec = m.serialize_v30_section([(5, 7, desc)], n_slots=m.V30_SECTION_RECORDS) + assert len(sec) == m.V30_SECTION_BYTES + assert sec[0:16] == desc # [16B desc] + assert sec[16] == 5 and sec[17] == 7 # [x][y] + assert sec[18:36] == bytes(18) # tail zero-padded + + +def test_native_template_envelope_and_tid(): + env = m.native_template(_synthetic_frame(), subtype=0x00f5) + assert len(env) == 23136 + ws = env[12:12 + m.WS_SIZE] + assert len(ws) == m.WS_SIZE + # TID is recomputable from the WS body alone (no device secret). + assert env[23072:23104] == m.compute_tid(ws) + + +def test_compute_tid_rejects_wrong_size(): + with pytest.raises(ValueError): + m.compute_tid(b"\x00" * 100) + + +def test_subpix_refine_rejects_out_of_range(): + resp = np.zeros((57, 57)) + assert m.subpix_refine_kp(resp, 0, 10) is None # x on the border + assert m.subpix_refine_kp(resp, 10, 0) is None # y on the border + assert m.subpix_refine_kp(resp, 10, 10) is None # flat → singular Hessian diff --git a/validitysensor/blobs.py b/validitysensor/blobs.py index a9f16ab..e6cacc5 100644 --- a/validitysensor/blobs.py +++ b/validitysensor/blobs.py @@ -1,4 +1,4 @@ -def __load_blob(blob: str) -> bytes: +def __device_blobs(): from .usb import usb if usb.usb_dev().idVendor == 0x138a: @@ -11,8 +11,14 @@ def __load_blob(blob: str) -> bytes: elif usb.usb_dev().idVendor == 0x06cb: if usb.usb_dev().idProduct == 0x009a: from . import blobs_9a as blobs + elif usb.usb_dev().idProduct == 0x00a2: + from . import blobs_a2 as blobs + + return blobs + - globals()[blob] = getattr(blobs, blob) +def __load_blob(blob: str) -> bytes: + globals()[blob] = getattr(__device_blobs(), blob) return globals()[blob] @@ -20,3 +26,9 @@ def __load_blob(blob: str) -> bytes: init_hardcoded_clean_slate = lambda: __load_blob('init_hardcoded_clean_slate') reset_blob = lambda: __load_blob('reset_blob') db_write_enable = lambda: __load_blob('db_write_enable') + +# Whether this device should enroll via the byte-exact native pipeline +# (Sensor.enroll_moh) instead of the DLL-style 0x68/0x6b enrollment +# session. Per-device blob modules opt in by defining `moh_enroll`; +# modules that don't define it default to False. +moh_enroll = lambda: bool(getattr(__device_blobs(), 'moh_enroll', False)) diff --git a/validitysensor/blobs_a2.py b/validitysensor/blobs_a2.py new file mode 100644 index 0000000..691a4f0 --- /dev/null +++ b/validitysensor/blobs_a2.py @@ -0,0 +1,372 @@ +from .util import unhex + +init_hardcoded = unhex(''' +06020000017f157bcf4f0360ff4dae96e4721ee8834bf6ab6f2828da9ad2b3ff40ab7e5176c478dd2459747722fc914b8b98b22d5a9574f +52c0e8b257b952e7ce12ed46ef77801481d698006fd09e5a9a8d7e9ca705a79549b984504778513b72668f9a5b7f41dd5537460ced2c46a +c9a70e345638ba7cb04f3829105f64bd2ddf5a0cb418ad350b022d2eace9fe21042e402bc9524bd87208271849c199280ffed5a881a5470 +74fcaf33a22aec694f028b9cf56818d9792066e3bbfc72d6930050d45c9a2edeba1abd07f3b40e3830ff31aa9dab715f59bb150686d5648 +808a9ff9e775f58f6c6e3910eb1a579bb206178545d54c5dd32a0d3e247e64d73afe84d701b42a8920ea02768734221260d083bebb39c17 +6d129c01d1a0f1388497171402ba041afd925d71e76ce4905e44fa5fd528759a2c9f12895864b5aa394dc71a4a17161dd82197a10742fa5 +f3135c5e78820e36653fa3db535f57c71897242939d7da50f81070ce9ab81c61af6ac29a6c6c4a5df73ffd08542fb540e417939ed117298 +051d27736c2faf1c5577a2133b6f60ea7484c692faae2a49c51c8e6f69af77774bad51a9adeea3109d3611d6a437cdc0c356e46a8f9a6d3 +054c5519c17c986e54f61f8329006ce184c275984799dbdf5512188fa8ff10aa2ddc25eb697ebdcb156509309ade5d7909a734bf35ec69e +062cb941c2ea4af0958110da93bd2b5f17fc9b1ebdbd8020a3c36f22a68f707226cec716126d6a830219521669bd59fa0e4bd35db6ef0aa +2999d1c0e7acf67e598696cd58cc4bdb1b7c037ee9a085f784c4 +''') + +init_hardcoded_clean_slate = init_hardcoded + + +reset_blob = unhex(''' +060200000194ae50290095416eaf60993da4ef11bd6030d70ac18c4c0870986ad44419e1da1edd2dc3d1e4cf37b810b15ad23f2700019604 +bc5717e74a990d2c627ce86b3df1dc3d46e206853a378e8e1be1e485f258149c4340bdda556d4d40fa1765c514be8d598f270f13cab9776b +b93d998b556f6c8237fdda3f12444fd55692431d5c9bfab9539886b29e8cf6d42ac870575e11431c675b1293b190945700d02696d11215d0 +ee171861abe26629ed8dac09072141d2808871c1bf02ff04f2c44554c129ecec8a33ad1bea2378161cc7d2b2a7564d8e80cd93422f983de8 +c239f47092d9af84a208f30cb9c7697f51a3eb3fef0ed88e9476f437bbb5e3fbbdba30b857e86b2cc47aadb1c3a5673ab48ecb98c6cd78fc +2e1788178bef05a943c44b716f1c86615f57c86602f3194456cddc9fc9861ada5050bdd46561fe19cff1cda09f4a0a672374462029ed80b0 +dfd924698129ba54525b0e8c0302e1330205dfda9f312aa48f86ab14831e8383729962ba36ea2c856f54a0f4a08551cac4b1f3b06a65a640 +f4d842207739e804e3fdc479e553614de45874bb0de22cce180f70216f59847b59dd73b33ece0e1504b5b87c0ab2d271ec852e67be7fcd40 +93b4ba59f5ca2887f773457b481da04c0df080a2c1d332baf8e2255d1a9dd831d334dc279cc32e69b695e10fb2673a5057bc2aa4ada8230f +259c8f7cf15cf364a4e93481aa0663f877375c3d17aff4ffc333bb741d58bc7bba30ea4f02318809d836be4a583f2aa72182ccc1f7eab12c +caabda2911a4c9a0d714436cc44ef0e4a784ee6bd107d41db2eee4ec82afd5b7cf38752af2033bf8494fd4454278360c606fe3911a3602a8 +2e51d080f37ccea86f32983918fa37567ebba63d442b26d714dde23f7950b8d3efc082b929c914d9f346f604f75ce6adc510565b998ed93d +d723e767f91553ebdaf009a8484bd5f2eb7634955418d719ed13778067981665d0944515c7ec5c330ce6bc7da13c905739905358e1158312 +78949fac315f66619f36509314b6354fecee1f2e2b67a774d3c36f59a20389b4269de15c27498df9a957292691640e84cb753438725ef385 +c9a2fac86391779e2137a1b32229e1db77bb961a0733498ab474a81e3425972dcd91c9abc1aa043d008d7eea32fd14c554f49332bcc0a1af +a66953468bde12d5cdf58c18c23e007ca0324ad9227d173a6a0932282f12e2cdf4644c4810b9522f897b982bcfdfa0adff24c9a319e3eaa4 +5b80d7f05918e23a3bfc037b8d2590821feb96b102e88df26ec59a7adb874f9d3b441d27496e5d829ad46282301c023496181d44ca8602a4 +898f9a44b3ae1feaa9b0f03bb5a16d1b1f4bee84eb94f871131f363012833796cc7b70035ea7953d2c34df48048c5a1ed1a089ee873a041c +733f903a973560bfb0f21478e7abbe31e4e8be25e142b207a32f14c9ac7e87df2ecfa5f834bcc386f98eeebe2778639bc92597f3fe35c489 +0b40ea6a4e6464e51dd7ee0ae576f383f760e90bca1b3eb2dae76cc8203ca9821b8294961cf0f47a01ed6f4f2fd3670ed72dbc3cad3fc97d +917f094510cb29b99c159bbe076aac2f6394b9dc23ee3efaf7fb26abc1f43cd5f4f05cc07ccda68cdf4b6cd9d24ecd6a71b0124ba4bd8a68 +3f9ffab0974fddc6213e48d0bd122ce040c2237dc96923f45f2f178ebae421d38112e7bedf2ffc21ea75504fe6d3ad6cca7e3d55533274fc +dd62f756d556412d73b7b3239092a012e4ca8a2d06a1b2f99316ba629a34e4c42a204aff8b0164f506a2fbafc82d275333ff7a08e1142088 +48cd74ebea814c65677ee08c91253520ef0eb633bbc185f2782ed15f3dcc0050ca5d82171d274565f37f9a79b704cc5cc9271051994c3f83 +3604e39da2af76d052d38731a966538de7e216ebd8dbe93e53ac166d12ec545e6c593c420dfb622b533b543e1d55f7759697fa72c43a74c5 +8329147cf195af1dac890d5929981d76384574f97f2b70cdee809c8fa0835e5bb6302c3a918ac7f029f61032ac70fac8f4a01e6c71af33e2 +77501795e7a65cd2673ea0e31d075304b980dcc0a8a759259f768471f901bf188cd721b6192c44bf0e0ed9a2aa4f82c25c6c91b87ad10525 +795a16bc9a0bf4db440c115d59ba041a972dbb76e59e81faaa8828be586406bc2a820b6070d02237b1d49c2c8ba77e6a16a811c1361bcea9 +17c250d4237fa702871013e082197da0889bfda7ef0296435f78ae9857a65ef22efc82e29cc89a709c91ed19dc2dc762d8ff911718ba76d4 +4412a64739701c13a66b39195ec29f3f1383d02167b68e166d0cfde5747e6ebfb0680e32922dcd1b39da794f9815c788156671699f8bd2fe +ce8380da706a77e6f23aff40e97b5f4e5b004f9d82317ca0eb6776a0bbeec9e796bf73b9eb1b8a408998c73b1443f9d46d1552d8af3c3fa5 +e2f5724feb0d60571467d869e99e12edf6b00c868ad22068413f9b3b5d08b517311817f946460025626b59ee3c1692339563e044db3dbbe4 +eafd1c1872b7ec09c13c98e0cc83958d8b25cf1a00874e7445bb0b0db0a0dfb12c1bc1b5324f62fdb7ef7dcb7794d28afe29c10c3a301147 +a9215c1050d65a20c09f4f303832add42ac29fd18370ff0e4fd6540ce35594d2d1dd28c7480f0c1c7eb55ae8045add9c96c1346eda69f58c +011127cfd617016d6feb6ea0d146c029c4a6065f2a25e54a5c8ffe672e5c54fd6e3eabe24b7fc15a900e3b1db23afa26631e3abf14500dc3 +f7323228265c781ae0863ab6cfc1204fff3c82a8fcbb6b6fb3e745a93f9ee385f26479335d63e17535161a61c88ae8088d6ffde8e91ea101 +18ac305f0f100f662784f288bde31ce32b31de2fdb268bcf8047099129f18c6b1f9726d27a070f514f7f52ccdbd71d64c809c5a3968b62a3 +d53eb011c3189836f1e2d71ba6ed57213daff9a6c1370206f60666ae21475a36952269dd600ccfbd923f767225a657ffb6281e32668eae49 +9430cfb484e2c7ce1e3ddfef16a871b7d55819fe3bfb1897beef0f44973a1c07b823e7a006ce8588a8851b078f5c799ef9b419c5867f1557 +d1b067d449b11e3eb778f9b46e522bf3ca6565fed4c4540f623c0a18244bacaeb2b6480ca898db8da9a108f6722a54e457e88c480cf8a4f8 +00b57d64f938dd9e30dab0d991c642455eeded98bc58b5bf437eff1de56b3639f014093b873a3f780dc17e7c4280790848fd5f491d6e2c48 +b5fc2325cd88952228c52432049e655802fe125a00cd949b9ed54470c71b092bfb282e0816107646814d0187b523c8700459ba2323ff2cd6 +6f8ee4e954bb8f13eaa2bcc7a2ce812046df9087bbc7ba7cc110e6aac465daf2c63b55c0930f0d4ca083a20df924f3d395871c39f296b1be +3bfaec1ae5aa3956dc5c4b497695b97a304cc1acd2708867bed635fedbe5a0567881bf2a691ffa55e9a922630f8de180c00d8a8d5f045dfd +9fb029cc8b53dfab9d5533d07ddd2893652004d9495efc2ccf502e4184228652723f956c80b62c566a89e81a862108b2c32b20ded004581d +cea6e5d5de730fae2a4bf2563e411078e41099b7e07aba9ee907ca4821e9633d74cbf0a2731d884b73374358e6f48077fcb49e4c57271085 +ce413e1ea0264b2a5fd94be7e86937ec246384c5cda5eac6b78944399e17d859d84727037b7539358e7b936687726233c0dff89ed5c2efab +a3d74d0924304d7090695ad38b6c1d5777782421802dab55db87a7589fe17240b9b04ef82f9f1d962422e5144e5199b8b056221a401513d0 +5e119022741711c0ebb4cd500d94bb462bca839e35cef0f2efb54a81f218f98a181fb6911cf51e12a7467c6ce95c4cadb90b51af7933eb66 +ca107eeced4fe9af1319416d02445811b78d0bed82b5c11e23699077c169f7e1475e27b8ce5e01d335b09878ca43ba06715181d303b117ba +cfe91948bc9a4ec19d1a2cf1fe86b1d4e7bdd72ae1cce0b3ff16d2eb707a758c96e18ae589cee08a1d6a2edc0197a698bfd027fed54c05be +b0263d22637761bc5c473d358f547939170b6d9a922e143ed46f13207cb9219f2bbc5b328a7c6be7bc6274b3189ec4d629570eaf3b53d3ac +f67bebc3858b68a2366ede7ad5fe823966f157431c8af929963cfc71a35910f36f2a54238f02d80d9b87d1de6c55cfde41135bdc1d3d8122 +82a219261ea1b89a37281840894d3a3aba6c054cd8c6b414fb151bd7a886fb05ad9ae52598da66821062bb9225f61709b858aa4d04507caa +c685ed1fba138b37fbc21c4b60168de2673e5a2fe97a42a193db17b85a9bf56b6b76216e252a2b7b546a2553f59b682f2fe8ffe5880fb6d4 +c71f167c9fd2dacefa86c815ff907533831c74ccf72926f8260166b0fcbc661a37533c2770a7c46da1e94c34f808eea1cf6fb25bcfd18820 +18d263f0d58733a1811788c7c02e43a80f5b2b51035ee366c9bde05cc8af9180ab677e5c6c430d42d73554e75c2095d555ba2df0686700b6 +a9a1d3b747178756b6c0a9dec0647eb65b48068e09d9b7ae494dfe78155a8d451ab58121f2e980c565d1414a7fa6d09ca4ee5cee33c4f3ce +90d342c6f440f309c85388cb04e6c2fe6316313d285c0297091a2adcab6ca3cf5d816072f9080d00192b1e3e2a61376cb07e1903d5155fc6 +7cab3e3872f2ea5c96dd0dd097c6e8be2da87285372581bdde00381bc3c467cbcc7bda35a474b45348d3a0f2a03c5578c83575822fd04c49 +31b01329fc5ae2c539665cece4434bbd3dabece80a849e2a8c9e00d9bd9a48557c6f15886d438670b5e4edc817303530ca046d1317c4913d +34e3bfda7400cf027bad9020b9ba1b59d719e7a50ede002e125d79b97c76ab35e3d0cd4ba0917749a22aee8ce3d1b7253f5f00eb06ec65aa +435e5e337b7d63a6f4030636cf5523863efc9af50ece0982ceebf51817ba9f4b354e090741a032fe0467c5ee1c2920e19713a9498165f214 +75f8655644eb91f52faa64e11b2d66fe1e4a5d1c8522c7d87958942d8d685dc75b2a81b7534fecfe3c166435a8acbcfd396f080cf7a574dc +992afa0862f2bda9a576e86fe63f6b86829728ad362b32be26bf8bdc58264500419887171ef3b0bfe846bd0d0fce41bfb1e7af16d49927ca +5efffa72ef8176228dd995601e981d13495476504ce4d396060e967c75afd1dfbb32bfa6d17ea022d8dc232ba5ff569ffa99fc00cb017019 +246b1299164c0f73434f8144176f396ae002a103cb745494fac01e95de19d059e0e18a5ccd9d827a3211d50ea58476ea4d78f669f2ff079c +582336be1d5f68d32a6c256f0b826ebc6cd829ebb81fbf2e2c592975686cb50ded7d825d218e4d3da3bf3e846ee33df0f0c644de6a88cdfb +78181130cbd495b8b590ba426377319dcc6d63aa14a757c4aa787a3fb9f995868362c8734281b33e01e8f508d886a8442dc620381d33c3f1 +010db797520cd051f368b4da5ed48fad00089f1d5a92fcb516443c0f4c2fb1c7e65ab49324d19e52c3bd623351409cbb5b3c2250fcc1dc84 +c4ea666bfb29d1f834f76598bf59eb005517a8accffe01772f63a358959dc99bdf9b3857d4fd84202206fb43e6b85fa34761023459a2991b +3b095b7c4ed13c7a30c7cc00b680af8810b15da9d86681c555504e526ae41e7067ce1338d171d7ccd8ebe3abc2bf2d88650ea20ee65b74f8 +bcb49b8fc0b821339d0a155f6c0b6a76d0106a74689b07f18b645a733009d55d2c041ffd0afaadc5a078246adc8c37e1c49d1bd3afe2d069 +762a66e9f542391f7cb4cd3b14e6b81b43db9ffe66b70252a0b08609951871d1f0d2fa0428b0c1ebb0511f9c5f1acef05e3cb0ba42e4bbb1 +0e75b121c5976698616b76fca02a133af96b5e0ea65daa56a710bb901c9b7a21bce1597b8853588fd63a56f3602288841a7ed27c48672b79 +4ba6b183579a70a8a6b57e543df8fabb57b3c8d36fae5398567db8ed2c1479b5e3df8a1aa7aca105310244c8aefd5df6ef5f0ac034ebd269 +62538073fb668d1759440683877d7c0974ef090a3fa2eb619027bf04fd4653b87e7774a7c314cd119bb29788f379289411eb8f5bd711c5d3 +6b51b124bae704f17638665fdf3dc7f9d9424322f9ae69a52368cad46cbd83e4c06774a11106a0cdbc0ddcf7ce14d6d18b366ec22641e3a3 +ec85340324b6fc9b931fe5fae2ddc3f1197d33700195dea7bd9d68794ad681a69cb52ed4a7bf1394527f46dbe55f588972435927a2368944 +bad5e8cdcc5a328d8bb80f25a0cec36bb57c6ff23ecca2609e51492b65fe6de3edb2c5f7abfbbfa1397f7b8aa8dc1947e4da0575e6743c51 +37fbc856391e6d630d86a6e993ec0073099f16aaa51d28230fa262e94c9287660d106bc1ddc29f24adeb29dbc20918d02f90ae1406fa4584 +c4f7385b5526e1a03aecd30c112d657a8f076ffdc3bf6e4eaa6a5c3e2e74fe97a4bc7a81e70d89eea52c70219ceca20d4abd723e6bc4f8f0 +38f251b3ed8e723713b4a6c20e5c124cacd1b089229776583a0a97f94cd259b3faeed3f8af7d4e587c32a70405b43cebaca583ec31b64065 +f9eaed500a9565a89da3bb4cb4b82daaf8b01ad29b24994d00fd0b1cdfe6d24d4b2c6dedb48fefcb5637696cde216d2e74dc27a01a40ce8f +ce23ac868f358f12f1ded5e7ff6a8c0bf625fcd53c64a424a54af8c9410309d69d4fa2e1dc1d55035b7577ae9b01bd891fd4da588569f385 +b927becdeb49a17595a9f7093818094087ed073e8d9631d9ecebbe15a5ae4698a8bdfe9a231a0aff79777af37be5a6b839a03fec653a550d +783e45115d7e75b3451304404db5d4f02a332067a26b29520e5d9460d439d9451827f44fab7fe98f62c29baeaabad015747e542d283a5652 +998002cd7fb18a69685b1747761d470b186e69291644031bfc164bb631958fab0f0a42e2606ef0805b5d184a694f997eea597663a3fb75f1 +25759e1990e99e927043c2a27a2492a0f1b501961c9d8b7589c5fb4ae0309f1d3a0300b1b749c8fb26357d6de1ac81444e35d378703ba570 +ad8e1b1c41d13e41268240f600a0c8f9131e9a4b8306f9835de89c8737cfe88bd4217e906f5504ce60b386e453a42753bcb8b4aa1bd0198d +d04baabeadcc07d4eb2337b94794231ecf73c25b3c9152f8a332f43fa6301a64e68f09d8b34a5525b612920dbdf89885c70153f91bb48362 +528d15b1e4dfecaea7d3eed3b3cfd24c06c35cebe6695a2f0256d293a18ff1642d417ba66f817ec92ff4a4e091ae81e3ede0ddaf50ac781a +0e1abe2d6b483029a7f488d22df9d411dec62c7b9e6f57d3507ba1cf46835b679a4b32e60ea4f48383494b04d9588b4a967bd056e0b855d2 +e318b20f0d995de3b1800ff197fe44c59f2f8dd98a46d90cc05b04e450617c0c70a3cbfb725c48bcf132b7ca45dad3085bbcdfe700aa24db +8f0e444ada4b045699dcccef864585abdcfafb68bcd2e86d53c1388d8e3495e1474e6e82a9a851e3cec6449016fe6c6494cf577a0fe69439 +964d21a3f7fabed0343d31c69cf5bfecbb3d32229bff3ba0229eda25a49b9967bfb9ce578f7d619c85e6587e958815cf4c35dae946473a2a +919fcafbf158e8bd463a8fc397e209a7cdd08f2061e5170b8eb14474d2ea38f7ce7194187cbfbb11c3f850476f53b5dc500df17cc6d85117 +fb35d4216a898ace5d703b066635ae942427385f0f08d78d556295a01a5be900c2473ca3b38d90b5d6f588f8faf4e5507f70838e84ad6492 +6d7e9a2683a40a174c89c73b4ae2812e6cae3c10d239f6ad9be00d471c8831db7977c3678ee5b4e15a1de9e79c057f8103ecf63ab879a183 +396d3540b57d205e779f65436dbea553af6659276afe1b65a96ad0b06ed413f599b81d94162b5967dbd2d03654b9dbb006ee8f2d41eb49e9 +0fa524d2c38130670eb12317db5b9d3f32ee403d035a1a70093dad4ddd00d6bc97f94aa306fc7845b0f9cf2edbab2ba62f3f85985410dd91 +dd36fe79006d593e935fc09522aa0c55d6aa5d52c22ce7fb3a0b785b13a2f7ecab723ebf23ceba7a4f481a4b4275bb63c74d3e3510d3d237 +fed4785f4bd8c7b088c6bf7d4c4e013e3055fd1104fed8ca4d5ef292e288730bb50157cfe0f99619a78cd2bb92fd56db50325819bf623b13 +44418ad7783fe5fe0003322889f460337d40943acbcca9a5e50e8216f056547a62cc55f3598aab83bda8a27eb3bd1d35aa2e3d176f523ee2 +f13ccb0624aa9f2ada406876b39a3842e859b31760e376db61d7db22e45f1b6e35262bd25b521b232b48d208af37d7fcdcf878f6e4e27521 +530f5959eedfffc3ce1ba8f2eee866c56360bef91fbdddc4fc6ee8f505aae0a3831d77c474c29be76105ccab3418cd810f2b72d58a425729 +bd78cc9b16ce2a95b48502229bdb3cb975041490b7e070b29fc63d315ec2b2571dc33b3cee93e5fb7cd6b55599440c6b2f28c66335b42f26 +e9cc56e374532174ac667a1f824414ead4f1f9319dd15bedc0d3c1f88717575f484431a7e9685350af7412caad2f305a0698c3f197b12db0 +99a54a4168f76c882e4fad7854db49c289f727b2d02a4ae642bae0cb7051a6cc8858fa8c4a8501c54cc7ef8141dbd8f966d89747f057b975 +db14794d81d566a7023e4b92733e9aec8d510fe5ccf63ecdaa4386e044a30da744bff020f7b539c45442a6af84d3c30b2e38a808d73cfdae +496aa7c546bc080fbd83c539d3b4aa6164e4f50b2f33816e81e665116a31b0b57ee0b2371da70a8f2150b4bbedd3e8ba6027212a3f0cb8eb +196d17a58f3965f89ba43ff82f2de139e9a6982590792681fbdb2b8d35342b3479b7a31321d547ca4c7fb055a452b49a900e7237e8c163e7 +b1fa4715d82654f736ea518b33223b10eb4a45c8a963a6c70b42a9affdd1497f41c01eb4f9c13e944743c0a79dc41e9fef5ede753f29b9bc +dd98558da7d0823004921dae6fcfc98b6ab1e0a1a6ac7dde74a52afc390041dfa7ee8329a9aa2928377d41a8883ca4545b1195e7a562b5dc +b52988b6d5b2febbd00daa845afadc1ed1226f2755909dbfd26dc79fb6f4a8e1ceef1540f8cf6be6fef8b7c00397b1bedc016e7bc9cdcc43 +5784fb2c8e859cc9a5be6b4b24cee7e1d53f6d60f39c02f23a782e860cf0ab22999889b5a2b39b9ae20880b0339e56982e03368ee8d2e28f +5fd8ca3968938feb0fefd3605e6d3669ef7d3fbda289d91a74d99f5d9a45e5b77dfb58ebb15f62522de726d460188d761bb50d6780d27079 +a901a6ca3f04b339cf1b5dbb728a2f26d07cb8d7b3e63c78abec69192f0ef915e523a00a0722d69e79a22cf61e99b66807467d3cad2b0b48 +548acf2224215642a5ce817e39645d320abfc11dbd65186b5be3700c3eae579b295f5d6db25fa350afbddde6fe6cb1565c369ee2ed1662c8 +265976f7c549015ad70f79fadcceddac5d57c46860246f293bdcbb46f58c0e02144040562159289a58f1e396894acb680f7a2520f386545f +e81b570b89cbb48318ecd8ba948ec648a9b12c9c97ab080814d3386114da29ea9019086ba02eefd1adc22dd1f55743c1e7f869958aed1a47 +cd8e4e479b000c88dde795209257f1d7d625df1ae27ab79b681a327f9db4a5a45d8e6c3870ac751225f4c98e41a44fe6dd571166fc7471a5 +8ca7224d3e4259c4ed8603b41e914a649b49392e77e9073fbed54a494f61a7f166116fa47429837a169ba056e189e642719873845f89f741 +3a15e856ad369945d301cc91baf842ff355d71ca4f45641c73e3a7b1f6c6df530bcd3a43fe6cea949ad5920029a827237a8497e236fda61f +1d86bbea9ee97089e46d7e52051f938b645ce43a97a18fae5832c2206874aa717e9d0a942be137094e5258c7bb1af7c5935eec00c08a8d26 +26bfe06cc1788de23d5d2513a919c56933a4312398da4d8644580ea552291c9fadb6a17823dfecf65d3c12abc65acafdefd96ffc57fb571c +280286fc7030757c51e58178d4088419fad8c5331e73504f6c0083cdc8ed054ddfc8d99b0dddf3ec705da169debbc15911d424c07bc8753d +c4ae4614cad71c1e3cb95f50ef03326986e8e3d4cd668c2da94da5fd1ee60279da0f1c1c4fd59d364b4d92e613fcfa3e8917e9b5ae3b350a +96bf6cdbca1150a3f34662126565da147b677b43fa53e6ff06435b1b6260ec1ea3856d60e86587507915af88198f0a5e9dfe2a4ef24bd1e3 +ab68b6db80a5225b62f69cafd89db1e5218e5c56640aaecae36f42a4c0c162f9d354aa05655e0ed86616da01425a6bee99c7f0b0512fae2d +e7e8ce9c2ab594e4ac6005392b3348a4bb9e6f046e850eb85008bc019ce746a557a731a906ec8aeb7012570ff31a2b2f38f7f62d2303857e +2e50770f85c56de514cf68268fac275e11c0236f1b395e40629e038db68757351587c8cf5071b80fca03437af67c915d70d3a12493a4c9a2 +bea32f62bd6e4806c3d65395f4ccb792d2fcb8d5592a06bcca0c40afb2fdec39664ad324ae73fda1107662e847e936f5599b0af587c71e8d +216252992657a3563e04747c9a46c36f64b1471143ddf0b3184f1ae51f0c6d7af85940df008c2320f9c796c5a0115c4407911625e1debc95 +3396a9ed5ac2b6f4b034c162092497f43a0de4ee8d5902a888e2f9b897ebcbf8cba366b1d0dbe94e1e3de656bc8b949fb575aa26a6f9e4cd +bc4a27d312e5f8e5d8d9ff815177d52c1a0628dff52fdd6e4d90e26268e01c9efd4427fce66cbd06775288d3dec9dd91b2ddcb12d153a547 +a764797c452c5fc0da3312391391fc3a48ed511ba35ff7b89fff9538b103695c4b0471b41d9fd1165e2a7ff68465f43e26ed14aa4172e53d +c1f5c18b8c54bf1306a631a3b162048f1ce36b4305bc7eb9821e9013a1266fdd4718458be9afdb23e7d4ee4ebb46c79f147f23f3ac3c5442 +2878be372409af0c8f022636a914aa19b499b6033584c9687b7de23ae61966cf10c662bc7e17ad1e4cd2ea5620de1de4e20e6be52c465102 +a8a8a2f7fb0258861697db83f786ce5ecf8fe5f745b35f4f0a8e2db07b4821085fe959ed3c19f5847c677878552157a6ac8ca60f3425e5b1 +126ea57526285e3cee038f57ffdc3e95e4c938145f7f71a433e19cdf61dbf3a5318c225ca9c1b26245ba89579888157647f7405d2efc1b70 +cd9cc02ba5a5fca1a49ed89ee5e89dcdf37865f8241054dab3ff459cd515a14635ac1ecfa387ac1e83522e815b67d3668effd17793b87938 +0e483d4368e6638365c68474ddd74be64cc46ab1435d4dda78171a56629b8f3a488c2705fd0d332c83a345ab337ae542eb803098eaacd0a7 +4a96518d56a410d57321eaec18ea1eddae63ef7a3b8152f50cddebd82f47d1cb773058db6d3b38d3e81fa897b988f3d04b7bcf26ad14d700 +3c988ec7b25332746f9bc4005102704f05f5d5ee5341ef3b30d318fc3904da34eac2ff3a246b1a6ec60145cbc66b8e79006dc08514a2dcea +9f5d89642edbc7b9f41d811ee1caf10343a286e1e4c5f17cdc640c8aa5e40dbc89de3a18975ba4558391e27650679a829ac644aecb00a430 +74a0aa358f5664876388ed742bf571d020b29059c8326f68411979d3967a314bff40126b5f656f1ca0ea4d7261b7a0149691ad08cd2b2b2f +2d9075afaa4e4c849ac650925b2c2f489075fe539f8cd7b1fc7cf4781808fa29484c4f3b9da3cf38cb31c58cb3ded4daf88de27c8f447cf0 +98b7ff69e7914cac7171b2290133fa1854b8a331e35d7f002ae75b1589758e6b2151072a43514973375185240b7e8caf231398fff3e6787a +28ef17829f4b42501fe0af78943f1e0cc419647ce0c2e9d1ab50f19f515d4bc75edc6e2d31bd17ef69f0a63d4e385a8f5bb00908f83bb86b +b20239b55d62730b43ddb783fa805e6ee4b4cf6eb66ac29bb5ce631e294b439517f5856ad1f2fcffae3936b40b14b2d6dd4e32c0a8091bce +bf24c0c64f54094366fb61269962feedb4072daf5c5a40641dc827849682a21850dc1554d53574d9608c97e4abcf94f9c6a28cf5b484d674 +36d3ecadd6e13ce57c11434a57e87aac9562079e22c6d2ab310b9ab1271d95bc268ba98404612ba78c3f032fc59322334870feff72f2520a +cb71682220f727928391051a74e6928e74706a7a5a036ed8a0d4be09f9088df632adc8ff816d7d830c8c3ee0074432b9dbf1e9ba49b12796 +d1eef1bacfc32b941e52b0ebda65241e198935d0dc4a72f997c8b26283ea93261d483754693f9834451a696fe4bb1b6e39f1cfab45f3e792 +fefd5fd2b94a698caca4eb8b2be7e1eb6c9a06a56bf22745ee3af4126dce6e5a77fb348a0ecf2416c055d2e6d4019174ec7326f7239727f7 +eafcdbf3f8323f1cb0b1f63d75f4522c575f82ba0188b03734c3fb374b90d9773c75ea981a23510a7add04ed0b13f80372b63fcb1135e8eb +3129bc970df3a6096b92d17d7f23a3256a7133b4c3c71cd581f68575751255c2517ff3c3e328fc8f50ec5b27d3868a99848103ff73b82478 +bb86e52e677923a905bfa608e80a571108e4e46cbbf4871b078457b8e06eff87a15ecbf3cfe59d597cb4cb2663de42d39bfd260442be708e +8985e5552cbe191a42fbf9eacc4505d4c857a67eb0a43b27cae86b6141279ebd32a2dcb65a94f9fa8e7e17fb8447d435311740cbfe3ddb2a +8f2826c69dec1750d4136406238b128bff704d9fed6444d14b7bebcb269fa4203a262d8bd0b33912199566b1896c03915d090d070488243b +b1566b9d464571f1b5870d959b0d0fbac9e6f783dee5cea9019110d02e76a92e5e963644519456cfbaf6b598da56ddea81ac549b93db86bf +e54930d67675775ba5525b7576c2df1965d7b317bdcec7150df1c15706f81d1c2ebea7269d9ed0ab10312c448f5907730d12447cbc9cd4d3 +6e09a8979a1960db9ca7058fd30bda25bdca5fd48ab4db2be3afcabe691ea64effd6b724eba76e2637e519cee7ff9f711061245b174363f7 +5c9da2fba536d2ab7b6fecd277129693d17f0bda731589df1905367bc80ef014b05e95dd520a9f08924c306ce25ce4fc343c66f9651e9edd +b57ca60d3b030f6046693811a022461ad45bed406f14722740a6633edde992ab2e2ac5d94dd70399d7cee5eab350b2c10ad668e6d705eddc +0b1d0e22292e34e416f0e9b361fd4ae2558a256175dae60caa8f00585f3401a209a25cc8e72632559e15e80600b4c741769c637d83da4768 +de13f619f8bfae3b7843b8ea46064b03dcb2bb2397cbf19a9ee773ac5a560f32fd7a3c95d7d344d924c715c43b4c0d644c0b1b28187c66ad +6a56e3c105b2fe83b585b3ef1f11e3f77d21d58eee9b513d52dbd8630c8bf194c30a64dbb96e707adda4e5caac59990e1e54bb9ee4816c8e +6822d69ac52f815e384cc8e8b3938f87d585681b38069426035d40abfa4ce7cab34f56a45e9ffdfedf53d81e0b08a5ac32441701c2cd1f26 +8bb722116912f55260c128fe5c55a197f6440dce2df4bd426b3178761ec043cafa57e41c02db17d1a712919dd2186fc0de1b3f4bdbb129ae +22f8965d4d84d8a687d7d6404f6760d2363a713ca19ff06d648d311c5871383594f2c34151b0b812c4ce12ea3e240754aeef9faf7a61f36f +835de3090e1ea22767c69cf376312bd3207ea4f939668ed3e700c72d50d3516778d0006b0da8bca7b65c5fa8ede67cbf1b1e9e79d259a392 +2c2328e8385f544283fca61778af7771e25b4fa50e3a1d9b04a19bb74450d772374846314cd6e8fc3ed05c19d72d0bdea8c3acd4095d1083 +4c1d12a97de5992bfd813c450c0704782c4806ef6e77f9c2b8184c26fc2bde78327923b81e48f186b972bb4d9267f9220e3c03e9f87048ad +2da7ec8dab8118adf777c155bd46091bf990374e5cd8dad3624fef8c646eb0446d0c3e8b18c93546f9592a86accbf6ab7b947ee7d279220c +0da062555199e5954639fdfebffa16078f49c7d6b1097c8ee10494143e743a7524a934746ff5e6e2b02c985d2780b4c667d43cd1c3ba6cdf +4752b120c3b481df2117f31d0cd49744da9b5ddabe5e58c7a4625b88370b1fb87887012c5636d8cbbe258d01083c31c01e4b359e7d12f230 +a1ee229dde4427e201a9c682e4b59a1509bfa8b7ad42ab952300b0dd032c9200512683ef4178a526039284ec43d4c6e93e9d76f1c97746eb +e0c23ad3c9736579f4192dde55c42723ee71a0c5cf16903b1f3ee9662cac9e357abc7b5cb1b3f322cf935db49ee440fa9626d98c035d5249 +949f9f5395c79eddffc354b3f6895daef19bab519e9c2cfaa663ef8d543846bd11c889dda634335b61dfbfcd1450c09f9391519ffd5c1fcc +335f2d167fe5829250718ab75c40a976b77866930a737b07f4afd00e2f377108f132e7280523acdb8d3912a797b90d707417326b705233ad +fb5511859de8e10530b5e23f4c253d89ef06545b29c67d0b9c42f8ba6f611946d42f1403e52d8807996b4bfabed64f1a19d96819db892394 +bcea025a460b189c53b32b131c2b94c12eeedc1ffd610f2418b5d5dfa92537dc75812785f45de70ce54c4730025554d72d274201c0830ac4 +6c1e857575879384a38ddab4a559bc528663428cc703c936a7aa0ce2f659502c044b96754710397b4a65a7d65caa3ea2125fd755a49aaa95 +92002b7df32f4783f4e80ddadc52916253d3a20d5b2d382691bb0a9baa670ba4b0e1c10e2ceae232fb908061e7723b04ef85905e2d53385a +5e2f0974dcbe0922443757e4f15572601e0d467fe22885cf6e1f9eb23f7d1060b99111d6339d8b4c85c64824e7c4230383e06b76864de724 +07ac80fa0376771262792c4002659bfe37a10d11431acaf1fb60c51acc6316d4463dfbf7249bbf6608f84049a0f0ea25c73b27bef0a7faab +f29f0d31a218b19f9cef07b2c829f774920442dbfeea24480cd2a19787d4ed06504804b3a2e67f2e2c29a28aba4f396c5dfe786b2f992023 +210adcfb65e4abc2329bfe4adfacad47b41f5ac8f16f23a7d0227469e144d30ff97006dea1fb2790e9dfa00e0ca63e0fd72ecb8157e3e5ba +7c15f38294b3e5a35d6099c327dc59a6ec1fcee0bd12c59da5271d8ed7b7559a643ae89ad31c5ea3593ff4dedc1b6afec2617c985a19438b +d89855ae153142f8e381f5c16d40239e6efb5bfe369a6c80444369f074924d1ee1e07a692db90e14a0e198ecb48367cfb0c457fb322ce82e +9185d3db1f53632392a3b077ebb8c6bddba51ee4ed15a5255fecb40e6e059f6b9b5a8ad363926c31478eaf588c7e7a4ea191e65bb368c339 +1581a3e5cd8ecd3356cb898e1b55a3961e2a3d6d198165e1c0964f7a365ade2ca52f584bb1647be33dc8d9bec281621e5383fca73b3b27c5 +d379e64443f825deb31d9cd50ae1a704d45c76a19d799cf5aafd44cb2c31ff1f858b7b7ecc4e068cfad674d3118e2d8a63018858970caaa2 +d0798081d58edd7d6108f6c52bf0978e315be35f67a5fd4eb8321a025ebccfcd95170c9e1024f054ae056f1673aac664557f0f30989e9345 +d5243df96b47661aa1a564e3b7827bfd78688452646996a6bcecacb41d2f204cb5944f96171afce281b1667c8adb4c4c22545aa96e6c7639 +c12f3eca88850a1560917aaac0f18388e576d058673f1530a287b1c2ab5d376b4b7bbc7bd968372787ce27ac69b3a1af0424e0ee037137a2 +94a9e2f2e36b5eebf62e2d8a5393876e692bf51c943b45446c2262436051879028170be8dc8296f973a577cc78c2b4719aaa7a603ddf720e +11d8905df6c1417ddfeea4d321098d4df3f46d83fdb7450a2e647ca40932c4dce5f465104e890e28e6053c962a97ee7a530318dbc91bec18 +75fe613b60bdd4d14036d0112338f9fb3a897f987e93bf476ef00beb93dc7d95843c32b5b7da7b635179dceffa454962602896bca7768907 +c0e75998628e04123ccab906bb53043fffffbefdd31861eec8d0ee38a9a557e239104409debcef034d88b96de96020871c84827e997d23de +92eb69743ed330080483963f518383d0ad2e6c9d287ee77c3b2ed9cb30414f3b9f47912419f4f508ee1fe290df38e3394fb91356681ab0c1 +11a8662284c828aae2e238cda6d4fc6d4ca507e62fcbf3c05cf4088c8577b5c5512356b4da446b4c5b326b84494304151eccaf13744661ba +33a0270dedc61304dc01f60a1f4c53cc3a90a61efcf39e6b2fa08b07bac9487e6a215578a3cc5c2a71afdff0bc3c4081d214ac165479b13b +e8327aae7e4b4057e045d5e79e94039d409dc601ea99107c1a2590c942c9114bf334105c661f753f57c9e2ef991f78eee52aceed99401584 +023d84c7862638636dae492fd52a52e4a10e500985e4e15fe423294cd115a0a8517dc5dfc0ce0e0c0b567b6913ef6b6d6c0fda87f80fcaf8 +3274bdce67db1a6b885053f512c75bf8256d6cb621985878d576fa62766110c7fb0eb425ed1dc2477e28d4def42c693a68b09df862e18dc1 +d1edf196fd2e5299f4da5f121b1fd09eb1312521e9133f8f1fa4ae72f71b4fb885ad3c90ff69fe767ff47df9f23efa0cddbd82b0ba0d253b +3167aa1a132cbeb4f6734448abc3a71f4799864be9f933cb0bac62f14d71fea80857ad8a23edb93dc00ecaf880380b2d2156a5a1a00b8c20 +23c77c77cd7b170b3956d11e0c76493bd0064358c6e3649115f1163bbc1662f3951ba1271902e77a1d0c8742d619593fc7d92bc2a83937d0 +7ae613d618226c1edf26536257bb580481be697516dad0235bda7a33001dfe0dd36fb3771603724e7f1cc36190e87de7f112695b2d98723f +baa64e2c0abfbacf47deaad5cc78e70c99a8e8c799e96ce4163612dbd32ad99b2ad3a9e59896b508eac80c8ec48b7fbbbdc60318e09b88bc +61f1e0944edfc52c8c13a41ace60194fc6f76bed8b4438d787343adb3317a6601e991d5eabd53f0d1c08f6931e +''') + +db_write_enable = unhex(''' +06020000016c5d73d6328c96d856b0d1ec9856580b787d5e56963cc0a798b8be5331b183a894f23e2ce1ba672a20d887f6cc884c3dd02744 +1377e68fb1d2531329dc8fc35716132db947295abeff7d8644417bbb5ddf2319b4fbde57c50759adaee10ee8e60400a2d7f09a7c0d9934f7 +575a00c44a41692015de80959d9f0b0edee6e70037423b98733b5e1d2f892e0ff5f2e0bb364d79b6273856553e4df0e49e19883058f16fdf +698345da73ee561ac8dfc2a38c3c594c5972edebdd056923be21749da4b089c8148f73ac9917bdcd6976ce17d77b919dc56daf21bc544aa4 +b3c6113037adf103e9b74a440b720104860a420bb211612d62da3a1327d2c4a6cf04bfb54d47218be9525c428078efa9241e6a42667e9079 +c91251026c6862575af5c40d7515615804816dda6c5495e6f9abd5c77e638925b28bbb0403d4f1aebf7d86bd5bc73a22a41fe2c7e19db79c +404fd3db43c8d87fecaf6ecf163f74659768a4c6cbe562d8d14b8ce4710b9164a3bed100918d9872aaa9bc9a448b117823f1e86fe9ba9ac2 +7649737ea497c83efff69022e6db7fe707947075d17c1de4bb1a712e4fddde31524379dea95497b675e4e70f4e88c8346807fff1083bee32 +9d4cacc54d8576886272f1fb35c692b8c9156041a78aa4f16ce24cc3c4d0e2ca7eb957360a5d1a14da3046b73f996381b3f67b136d46fe73 +86eb0b0e2468be7771a153567ecd9192628f71a9382a6f555cb77b72c959d79c8111133777056cc73f40e5f448a064fbfabc5ab6c30d9f67 +cc87e641336ef37ff498b9af98212dbf3775b8a67b7ad933b5603dcbd3d7f9f2d90cefb292835d4671e07ba8573f4b8b62c537178939a41c +1658f09574de0037ddb4e24f179db751e477527382b627f6990900eaeafc6a5f1e58966d96c9bfbac6e931a514c235e829c5868c065fd430 +df2d27ea21be94525ea5ccd22688d56780a4a9c7e2072b1084aabdc7d5c5ed243d903f5c9bf336b033d9508a0e6dc3b06757d0b9dfa26e85 +305ce953562dd43fc12fa2e81dff81ac0a1ca5ea0d3ebc209ea11046349c092c16c7680eb9d67c6bd1d57ab10fd7c07d93e00fb0370c8900 +2051e1774b1555ce048eebc15ed4af35f9a4e2cf4b77bc5b0f58a7ab7a12aebf3c3a935c2bb278d446437b91c8cb7ce9e9c49b243a18c454 +72ce601ee001bc9371fef9c03fb3cbb57b119f62f81723148402275b46f917aab7ab4e54e0846eb40c9fa0ecb8f0d3d66fcfd9b6b5198c2e +6451c29458726322c02401fe154cd24b21ddc6a3c4a69b95e2cc0c971cc2e78403bdeb70ffa5a343b7075609dc38cf5a0792ef55aa48991c +a9237e911c66ec493dd1de176b2d7496100c803b58e071ea1ca3bb5c0fbdafc7a0ae50a56dab64002232d4c464bb78fd809a3ab74271fba1 +287fc1b6ad5e5c973b06a6984d95f0dc8909e6695aa6fe6cf5c263f657e068bde23045cab2264066d5e63d9db8374681a46a5c2d3127b34d +848e31575d493ad64f263429c57808c36028f27a7070bf519c32b99f85fdedcfebc8033d519663f03bf082654bb7a774bccc701da2d74322 +9cde9ff252472b3a53430db42a4c6dc49c8fecdf1c70c872a22dfb29c273b131f4e9d2bd29f46c7c6f23c0bc05579ed6e22015b9cf49f670 +5b093d85b5751d7a62921e5c51ed9c6bd0d4c44c3545e308712d5a0aea11399835e093bb606288b360e8d2634ade12876d5ed9cd264f5f14 +21f4814299b734c9c914d5157afc2bd558c304e6ac81a320098acbbfaaa689cf0ecca3c82792961af28bcae17f1031e819944dc4421cea03 +7d2864db5878d9c0062345675a61f704e540182308629d07466d4d38c41f0a7271bf21480edf8bab7a9545a0074018764e81cddcb91c8672 +2fdb9e01c9f7398c91df65c3c78e0b594a9a04f02b5b0a52f0218e68b1f0dd0a268c6f380b8c01ef821617f45fa651946027e35e8d9e05f6 +8db61ffaeabdb693235d00b0bb4e964a6185a853e7457e57fabb2f25ab3cef8ccc20bc1132590debc6dbb9f084ac49f5e5d5228de6d216a2 +a5b6ed5e890c2ca726397838d715328a9d18d0df04de167613856bf09c898ef01855fccfcea1b3b0dd7e7b0fb209c324d1e4c9d6ee346eef +81ded4325c7b0e6521857e37cd5ee076fdd8f778579f9510bae6d7501de64606d1c1f6f6bc0f5a64c1b9431b5ef24214e4893b9f7b4e182b +81cbb3660974397716bb9bdfa90103c192758a03e25e23c35ce10afc0fb631be50eda1790310e9cddaea5e4426036aad63df8265c73b5571 +dd8379cab5ef759de28b5de50ed03f04135949f2aa6d1084ca796034ea187246ea8c8cbcb5c25974c1408d889d35bd7f90e8eebc6ea8c08f +e26fee6bf5d4044472455ced150c74c3ce56657f1284c4478bfd5d2bacae3587c3055055813caed2e8ccba2739c0786754a7737a7ae610d1 +f4801e8d1f3ecc682f4303dbf0bf6f7510476269f1b64b15f2fc49f3f7e7bf5e22c2ce2b818c2020c19fcad9f2346aa15e7ce970985ca396 +ed11302bae047101a6bca2d2b7dd22cbc6cc6bd656fd56fa43f6da509d781277724f70b9751abf5f7f2a7fd2be6d0aef4b4b12fda505852f +2da92240b2f9c588d711f48e6d6030b28122aab5d58e362ee06360ade5a25086666ca6734cb1c1f260003a1cc7fd7acbd48aec5c9f37a3f1 +fc63702a4080e1d3b33573d6a545b94ea917cebb0735fa227268665cee87084ddc1eb6efb789c72eba6c46fcba0c469fe98e1694b1fc7bed +5d0da758b822c70a32f50fe4e4109ee27cc1fb6f3c7f62b7631f2f8034bd413645c07cf80a4a5838d8e1dde89cdb5028bb2af3d2131cd750 +f25a2c6d6fc8bfd1eade07abebec83a66f1c30c3916776234b86eafa6502d55dd714bb482073ebf6ce89ab2b339926fef55fbb125522fb8d +dd00ba9dafcfe0440d53796cc22df0d01ee14e1341dcd0c13957f5bc3f20c89990da0da30f67d3dc4a0fa259a084007c475e302d3c7c62a2 +9174157d9c56c0b40d03d81c53ee6e5e1144ba16b51896837679ad73519ce977be5ed20cfa7c70481242f4d8c1b42f34f2b06554536c59e0 +2743f9f20d9fe131a2b8982dca65216374f29a0a1e5478b009d51511448151fabd889f360711a98c28749368c3f1b59ebfa268e5a084286e +f550f72ca5902c5436e4551209b724675d3dd97b610d8d60c57181a14466c595d6fdaf7cd5e8c6a9327e7b0ada21429d399957aacf62cbe7 +5cd6b550b1eee8df3d3122574e7b61925a7c469b4c1105411ee2362a7bf912c833bb9fdc4ce479ac1f0941bf8935ada689d9b2cddd76490d +35914981236eda3336c2127807eb652cdfa0f23ebfd5af57a9df6c6ac52ef2ec4423b61e4e66cc52908296dd71db43308acbd22441bcc123 +7b7d34f4afe1892df61dc63abb5a5c584fbaf696d93b734614c575a39be4eade166e483534b702b0c264207c7be8633f5386a60c033942b2 +6d793c7e868205ed0c735aa7b36f8375978fb76629cb2a628e45f4fc81be3e4435b65f6844512a5f12a0e1882b4e8d9109b97a0993f2b488 +53a4772c7d03b83043c8912fc317bf712dc6ed61de8828ae250eeb78cccd21434015e0f638948030ef39eebffb4df2ca0d26cb07e459940e +e2f1eb64d4a840497ba0ea516c7d64d1d4ca74237171bd47caaf03c46a6d7e26767194df3bee3cb49c038f7a6f170d45434591022756a39b +78c3905f44ffba8bf3b0a3ad157c10f2fc4e7869526cd824ac17a72a02c2aac119c69beff3890c6090a993849762799b929529137c234bae +fc0c691848 +''') + + +# This product (06cb:00a2, MoH) enrolls via the byte-exact native pipeline +# (Sensor.enroll_moh), not the DLL-style 0x68/0x6b enrollment session. +moh_enroll = True + + +# ─── 06cb:00a2 native feature-pipeline data (RE-derived; moved here from +# moh_data.py since it is hardware-specific to this product, like the blobs +# above). Descriptor BRIEF/aggregation tables + the WS-body framing scaffold; +# imported directly by validitysensor.moh_native. ─────────────────────────── +# BRIEF descriptor: 128 (a, b) gradient-test index pairs (was brief_table.bin). +BRIEF_TABLE = ( + (0, 2), (1, 3), (0, 4), (1, 5), (0, 6), (1, 7), + (2, 4), (3, 5), (2, 6), (3, 7), (4, 6), (5, 7), + (8, 10), (9, 11), (12, 14), (13, 15), (8, 16), (9, 17), + (18, 20), (19, 21), (22, 20), (23, 21), (24, 26), (25, 27), + (28, 16), (29, 17), (18, 30), (19, 31), (10, 32), (11, 33), + (34, 10), (35, 11), (36, 38), (37, 39), (12, 24), (13, 25), + (40, 42), (41, 43), (8, 34), (9, 35), (12, 22), (13, 23), + (24, 44), (25, 45), (40, 24), (41, 25), (26, 46), (27, 47), + (34, 48), (35, 49), (30, 50), (31, 51), (52, 54), (53, 55), + (56, 46), (57, 47), (28, 32), (29, 33), (12, 50), (13, 51), + (30, 20), (31, 21), (52, 22), (53, 23), (52, 18), (53, 19), + (12, 42), (13, 43), (40, 30), (41, 31), (8, 48), (9, 49), + (52, 42), (53, 43), (42, 20), (43, 21), (52, 40), (53, 41), + (54, 50), (55, 51), (46, 50), (47, 51), (12, 30), (13, 31), + (12, 56), (13, 57), (40, 14), (41, 15), (34, 36), (35, 37), + (26, 50), (27, 51), (28, 48), (29, 49), (42, 18), (43, 19), + (14, 30), (15, 31), (28, 34), (29, 35), (8, 32), (9, 33), + (12, 18), (13, 19), (42, 22), (43, 23), (40, 50), (41, 51), + (52, 26), (53, 27), (56, 22), (57, 23), (26, 22), (27, 23), + (42, 54), (43, 55), (34, 32), (35, 33), (14, 24), (15, 25), + (10, 36), (11, 37), (12, 46), (13, 47), (26, 30), (27, 31), + (26, 54), (27, 55), +) + +# E090 aggregation: 29 (window_size_idx, dy_off, dx_off) windows (was aggr_table.bin). +AGGR_TABLE = ( + (0, -7, -7), (0, 0, -7), (0, -7, 0), (0, 0, 0), (1, -2, -7), + (1, -7, -2), (2, -3, -7), (2, -7, -3), (1, 3, 3), (2, 5, -3), + (2, -3, 5), (2, 1, 1), (2, 1, -3), (2, -7, 1), (1, -7, -7), + (2, -7, 5), (1, -2, -2), (1, 3, -7), (1, 3, -2), (1, -7, 3), + (2, 5, -7), (2, -3, -3), (2, 5, 5), (2, 5, 1), (1, -2, 3), + (2, 1, 5), (2, -7, -7), (2, -3, 1), (2, 1, -7), +) + +# WS-body framing scaffold (was native_ws_scaffold.bin, 23056 B, ~99% zeros). +WS_SCAFFOLD_SIZE = 23056 +_WS_SCAFFOLD_CHUNKS = ( + (4, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + '40480000060205000200080103020100000000004c0000005a00000056000000590000000004' + '017b2389ff000046f0ffff5b7be8ff9561e8ff8821acff0000e7f2ffffda7af6ffb320eaff98' + '237fff0000c4efffff4e35fbffa559f1ffa11df0ff0000af0500004d290e00796d01008221fc' + 'ff0000f00200003989130092630800a51e00000100e4feffff798a050098dd06')), + (280, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + '0400b811')), + (292, bytes.fromhex( # fixed header + sec0_pre (counts/config/id/leads/blob/matrix) + 'fa402d718170e4de301581828f410c2055')), + (4809, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + '9e9fbcd8dfdfe1e6000000680004000000000003000400040000006900080070000000700000' + '006b000400050000006c0010')), + (4868, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + '01000000030000006a000400030000000500b811')), + (4896, bytes.fromhex( # section 0->1 boundary framing (trailer + next header) + 'fa608b73b2786c5fb8e38f82dfd0882757')), + (9413, bytes.fromhex( # section 1->2 boundary framing (trailer + next header) + '9cabc1d0d3d5d8e80000000600b811')), + (9436, bytes.fromhex( # section 1->2 boundary framing (trailer + next header) + 'fa408f369278ee5f39eb9fa2df900c375b')), + (13953, bytes.fromhex( # section 2->3 boundary framing (trailer + next header) + '979ebcd0d3d5d9e30000000700b811')), + (13976, bytes.fromhex( # section 2->3 boundary framing (trailer + next header) + 'fa40aabc9852c45a384cada20801086459')), + (18493, bytes.fromhex( # section 3 trailer / tail + '979ebed3d9dbdde0000000010004')), +) + + +def build_ws_scaffold(): + """Rebuild the 23056-byte WS-body framing scaffold (zeros + framing chunks).""" + buf = bytearray(WS_SCAFFOLD_SIZE) + for off, data in _WS_SCAFFOLD_CHUNKS: + buf[off:off + len(data)] = data + return bytes(buf) diff --git a/validitysensor/firmware_tables.py b/validitysensor/firmware_tables.py index bd0b867..f4d2855 100644 --- a/validitysensor/firmware_tables.py +++ b/validitysensor/firmware_tables.py @@ -22,6 +22,11 @@ 'driver': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'referral': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe', 'sha512': 'a4a4e6058b1ea8ab721953d2cfd775a1e7bc589863d160e5ebbb90344858f147d695103677a8df0b2de0c95345df108bda97196245b067f45630038fb7c807cd' + }, + SupportedDevices.DEV_a2: { + 'driver': 'https://download.lenovo.com/pccbbs/mobiles/r0yfp10w.exe', + 'referral': 'https://download.lenovo.com/pccbbs/mobiles/r0yfp10w.exe', + 'sha512': '00116d8fe70e4fb0e030b256cc620118c8112e8d69b49431acc5d3203ecaf76f11b584a4b1ced0738876b868277b36be303eec8edeca501331b710b143df255c' } } @@ -29,5 +34,6 @@ SupportedDevices.DEV_90: '6_07f_Lenovo.xpfwext', SupportedDevices.DEV_97: '6_07f_lenovo_mis_qm.xpfwext', SupportedDevices.DEV_9a: '6_07f_lenovo_mis_qm.xpfwext', - SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext' + SupportedDevices.DEV_9d: '6_07f_lenovo_mis_qm.xpfwext', + SupportedDevices.DEV_a2: '6_07f_lenovo_sm_qm.xpfwext' } diff --git a/validitysensor/init_flash.py b/validitysensor/init_flash.py index 61c918b..1d4bb02 100644 --- a/validitysensor/init_flash.py +++ b/validitysensor/init_flash.py @@ -53,6 +53,17 @@ ac2c08c00abf43faa5528a0a8e49b02c507b01b6f1c9abffc669d8c84d7e4a714da32aade7928eca9698b82bee6b72c642c9add80bbd7ccc4121b80220d52b8a ''') +# 06cb:00a2 uses the same partition layout as the generic flash_layout_hardcoded +# (byte-exact), but the partition table is signed with a device-model-specific +# key, so it needs its own signature. Extracted byte-exact from the Windows +# driver's reset/format capture (1780409730-usb.txt, 0x4f command). +partition_signature_a2 = unhex(''' +f52c94d3a340cd3d166516582be27d2c6f497fcf4f511b23f70f86927d48004330e4f17f3d1231fd8a0c9ff712c63759b8933a15cd7046f86c0b75d95fd54e93 +ff9e174f837eb643922d9c7bd5261f9139e40ca53e2a9735f7d472047c5eca6b463e2d1e42a147e54943e7cceba15b7f8452548a6b7f453336a3dbed6194f3a7 +705618132c3775f5906fe1baf4f87245865ae3cbe97c7a41858e87488ccd26077de5e3869b2ff22cf213377575a7a4dc5bc202bbb3539da9593df1d5616309ce +f84b3f45e1a4f2c3c4b45e856bb0eef652d916539b944da210e78537f9cb8d41b949fddb1af6ac8226b6e5763b3d570e901fa0f96d21de915fec1f945146a362 +''') + crypto_backend = default_backend() @@ -141,6 +152,10 @@ def init_flash(): if usb.usb_dev().idProduct == 0x0090: layout = flash_layout_hardcoded_0090 signature = partition_signature_0090 + elif usb.usb_dev().idVendor == 0x06cb: + if usb.usb_dev().idProduct == 0x00a2: + # same layout as generic, but a2-specific table signature + signature = partition_signature_a2 partition_flash(info, layout, signature, client_public) diff --git a/validitysensor/moh_enrollment.py b/validitysensor/moh_enrollment.py new file mode 100644 index 0000000..57ebf0e --- /dev/null +++ b/validitysensor/moh_enrollment.py @@ -0,0 +1,197 @@ +"""Match-on-Host (MoH) enrollment driver. + +Isolated from sensor.py so the generic Sensor class stays device-agnostic. +`Sensor.enroll()` delegates here for devices whose blob sets `moh_enroll = True` +(see blobs_a2.py): instead of the DLL-style 0x68/0x6b enrollment session, MoH +devices build a template with the native feature pipeline (moh_native.py) +and store it via the raw 0x47 new_record protocol. + +The single entry point is `enroll_moh(sensor, ...)`; `Sensor.enroll_moh` is a +thin wrapper that forwards `self` as `sensor`. +""" +import logging +import typing +from struct import pack, unpack +from time import sleep + +from usb import core as usb_core + +from .db import db +from .flash import write_enable, call_cleanups +from .tls import tls +from .usb import CancelledException + + +def enroll_moh(sensor, parent_dbid: int, subtype: int, + update_cb: typing.Callable[[typing.Any, typing.Optional[Exception]], None] = lambda *a, **k: None, + max_attempts: int = 6, + num_frames: int = 6): + """Enroll a finger using the native feature pipeline (no DLL). + + Captures `num_frames` placements, builds a 23136-byte template via the + native pipeline (our keypoints into the baked WS-body framing scaffold, + recompute TID), and stores it via the raw 0x47 store protocol: + typ=6 direct, storage=3, 1-byte trailer appended — NOT the + db.new_finger() / type=0xb-becomes-6 magic path which doesn't actually + work without an active 0x68/0x6b enrollment session. + + Args: + sensor: the Sensor instance (provides capture()). + parent_dbid: the existing user dbid the new finger attaches to. + Use `db.dump_raw()` to see what users exist. Storing under + a non-existent dbid succeeds at the storage layer BUT the + chip's matcher will silently fail to find the enrollment. + subtype: the WinBio subtype (= finger position) for the record. + update_cb: progress callback update_cb(progress_bytes, error) + matching enroll()'s OS contract (see scripts/prototype.py). + Called after each frame with a 1-byte percentage (0-100), or + (None, exception) on a failed attempt. + max_attempts: how many capture retries on transient errors. + num_frames: how many placements to capture (default 6). The + template has 4 v30 sections, each holding one placement; the + best 4 frames (most keypoints) fill them, so extra captures + let weak placements be dropped. + + Returns: the recid created in the chip's storage.""" + import numpy as np + + # Imported here (not at module top) to avoid a circular import: + # sensor.py imports this module lazily from enroll(), so by the time we + # run, sensor.py is fully loaded. + from .sensor import CaptureMode, glow_start_scan, glow_end_scan + from .moh_native import (extract_frame_native, _load_ws_scaffold, + NATIVE_WS_V30_REGIONS, + patch_pre_v30_near_identity, + serialize_v30_section, V30_DESC_LEN, + compute_tid, _build_envelope) + + last_err = None + for attempt in range(max_attempts): + try: + # 1. Capture N frames; multi-frame enrollment fills the WS + # body's 4 v30 sections with different per-frame data. + logging.info(f'enroll_moh: capturing {num_frames} frame(s)...') + per_frame_kps = [] + for f in range(num_frames): + # Per-frame retry: if the sensor errors mid-capture + # (e.g. "Scanning problem: 8080000" — finger lifted too + # early), retry JUST this frame instead of restarting + # the whole enrollment. + for frame_attempt in range(max_attempts): + glow_start_scan() + logging.info(f' frame {f+1}/{num_frames}: place finger') + try: + x, y, w1, w2, img_data = sensor.capture(CaptureMode.ENROLL) + break + except usb_core.USBError: + glow_end_scan() + raise + except CancelledException: + glow_end_scan() + raise + except Exception as e: + glow_end_scan() + logging.warning(f' frame {f+1} capture failed ' + f'(attempt {frame_attempt+1}/' + f'{max_attempts}): {e}') + if frame_attempt + 1 == max_attempts: + raise + sleep(0.1) + glow_end_scan() + img = np.frombuffer(img_data, dtype=np.uint8).reshape(x, y) + img_q16 = img.astype(np.int32) << 16 + + logging.info(f' frame {f+1}: extracting features...') + kps = extract_frame_native(img_q16, h=112, w=112) + logging.info(f' frame {f+1}: {len(kps)} kp(s)') + per_frame_kps.append(kps) + # Report percentage complete after each frame is processed, + # via the OS update_cb(progress_bytes, error) contract (see + # scripts/prototype.py) — the percent is a single byte. + # Fires before the store, so a raising callback retries a + # capture (harmless) rather than duplicating a stored record. + update_cb(bytes([int((f + 1) * 100 / num_frames)]), None) + + # 2. Build envelope. Distribute frames across the v30 sections + # (round-robin if num_frames != #sections). The baked scaffold's + # WS framing bytes stay (header, anchors, section counts). + logging.info('enroll_moh: building envelope...') + ws_body = bytearray(_load_ws_scaffold()) + regions = list(NATIVE_WS_V30_REGIONS) + # sec0_pre must be NEAR-identity (load-bearing): the matcher skips + # pure-identity records as the 'unmatched' sentinel, so near-identity + # (tx=ty=1) makes each section a valid candidate alignment at verify. + ws_body = bytearray( + patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) + # Keep the best len(regions) frames (most keypoints — a frame- + # quality proxy) in capture order; each section then holds one + # geometrically consistent placement. + # + # NOTE: this is a structural APPROXIMATION of the Windows DLL, not + # a reproduction of it. The DLL (EnrollmentUpdate → commit) folds + # every placement into a persistent session accumulator, culls + # keypoints by cross-frame CONSENSUS (sub_180008ec0 coord + # histograms — the source of the per-tile survivor counts), and + # builds each v30 section from a frame chosen by a learned QUALITY + # regression (sub_180008980 score vs the 0x699=1689 gate), not by + # keypoint count. That regression's coefficients live in a runtime + # ctx object and are not statically portable, and we have no + # cross-frame consensus step, so we substitute: distinct placement + # per section, ranked by keypoint count. The chip's voting matcher + # tolerates this (enroll + recognize confirmed on hardware), but the + # exact DLL section<->frame mapping was never RE-confirmed. + if len(per_frame_kps) > len(regions): + best = sorted(range(len(per_frame_kps)), + key=lambda i: len(per_frame_kps[i]), + reverse=True)[:len(regions)] + per_frame_kps = [per_frame_kps[i] for i in sorted(best)] + for idx, base in enumerate(regions): + src_frame = per_frame_kps[idx % len(per_frame_kps)] + # v30 records are [16B desc][x][y]; the record area starts + # V30_DESC_LEN before the (x,y) anchor. The per-section trailer + # is enroll-only bookkeeping the matcher ignores — leave it. + section = serialize_v30_section( + [(gx, gy, desc) for (gx, gy, _o, desc) in src_frame[:250]]) + start = base - V30_DESC_LEN + ws_body[start:start + len(section)] = section + ws_body_bytes = bytes(ws_body) + tid = compute_tid(ws_body_bytes) + envelope = _build_envelope(subtype, ws_body_bytes, tid) + logging.info(f' envelope: {len(envelope)} bytes') + + # 3. Store via the proven replay protocol. No wait_int() + # — the typ=6-direct path doesn't emit an interrupt the + # way db.new_finger's typ=0xb-magic path does. + logging.info('enroll_moh: storing on chip...') + db.db_info() + write_enable() + try: + msg = (pack(' sy0 and sx1 > sx0: + tile[sy0 - oy:sy1 - oy, sx0 - ox:sx1 - ox] = img[sy0:sy1, sx0:sx1] + yield i, j, tile + + +# ─── orientation weighting (dword_180120C00, dumped from the DLL) ──────── +# 7×7 quarter of a 13×13 window; weight[dy][dx] = GAUSS_Q[|dy|][|dx|]. Kept as +# data (it is the DLL's learned spatial weighting, not arithmetic). +GAUSS_Q = np.array([ + [1669, 1541, 1212, 812, 464, 226, 94], + [1541, 1422, 1119, 750, 428, 208, 86], + [1212, 1119, 880, 590, 337, 164, 68], + [ 812, 750, 590, 395, 226, 110, 46], + [ 464, 428, 337, 226, 129, 63, 26], + [ 226, 208, 164, 110, 63, 31, 13], + [ 94, 86, 68, 46, 26, 13, 0], +], dtype=np.float64) + + +# ─── separable convolution kernels (native float) ────────────────────── +# DERIV_3TAP: central difference; SMOOTH_3TAP: unity [1,2,1]/4 smoother. These +# replace the DLL's sub_180010280 magic-constant 3-tap builder. +DERIV_3TAP = [(-1, 1.0), (0, 0.0), (1, -1.0)] +SMOOTH_3TAP = [(-1, 0.25), (0, 0.5), (1, 0.25)] + + +def build_gaussian(n=5): + """Unity-normalized 1-D Gaussian as [(offset, tap)]. sigma comes from the + DLL's size→sigma relation (≈1.10 px for n=5); the bit-exact EXP_TABLE LUT + is replaced by a real exp().""" + sigma = (0x26600 * n + 0x59acd) / float(1 << 20) # ≈ 1.102 for n = 5 + half = n // 2 + xs = np.arange(n) - half + g = np.exp(-(xs ** 2) / (2.0 * sigma * sigma)) + g = g / g.sum() + return [(i - half, float(g[i])) for i in range(n)] + + +def _conv_axis(img, kernel, axis, fill=None): + """Separable 1-D convolution along `axis`. `kernel` = [(offset, tap), …]. + fill=None → replicate-clamp borders; fill=v → out-of-bounds reads = v + (the DLL fills off-tile reads with 0 in the descriptor-gradient path).""" + n = img.shape[axis] + idx = np.arange(n) + acc = np.zeros(img.shape, dtype=np.float64) + for off, tap in kernel: + if tap == 0: + continue + src_idx = idx - off + clipped = np.clip(src_idx, 0, n - 1) + vals = np.take(img, clipped, axis=axis).astype(np.float64) + if fill is not None: + in_bounds = (src_idx >= 0) & (src_idx < n) + shape = [1] * img.ndim + shape[axis] = -1 + vals = np.where(in_bounds.reshape(shape), vals, float(fill)) + acc += vals * tap + return acc + + +def apply_sep(img, kx, ky, fill=None): + """Apply kx along x (axis 1) then ky along y (axis 0).""" + return _conv_axis(_conv_axis(img, kx, 1, fill=fill), ky, 0, fill=fill) + + +# ─── DoH detector ─────────────────────────────────────────────────────── +# response = Ixx·Iyy − Ixy² of the pre-smoothed image, in natural pixel units, +# rescaled by RESP_SCALE so the NMS thresholds (t_lo/t_hi) — which were tuned +# to the DLL's fixed-point response magnitude — still apply. RESP_SCALE was +# fit by least-squares against the DLL's fixed-point doh() on synthetic tiles +# (median 17.04, std/mean 2.4%). +RESP_SCALE = 17.04 + + +def presmooth(tile_q16, size=5): + """Gaussian pre-smooth of a Q16 tile → natural-units float image + (replicate-clamp borders), matching the DoH front-end.""" + img = np.asarray(tile_q16, dtype=np.float64) / 65536.0 + gk = build_gaussian(size) + return apply_sep(img, gk, gk) + + +def doh(tile_q16, size=5): + """Determinant-of-Hessian on a Q16 tile → (Ixx, Iyy, Ixy, response). + response = (Ixx·Iyy − Ixy²)·RESP_SCALE.""" + sm = presmooth(tile_q16, size) + Dx = apply_sep(sm, DERIV_3TAP, SMOOTH_3TAP) + Dy = apply_sep(sm, SMOOTH_3TAP, DERIV_3TAP) + Ixx = apply_sep(Dx, DERIV_3TAP, SMOOTH_3TAP) + Iyy = apply_sep(Dy, SMOOTH_3TAP, DERIV_3TAP) + Ixy = apply_sep(Dx, SMOOTH_3TAP, DERIV_3TAP) + resp = (Ixx * Iyy - Ixy ** 2) * RESP_SCALE + return Ixx, Iyy, Ixy, resp + + +# ─── keypoints — NMS (sub_18000CF90) ───────────────────────────────────── +def nms(resp, t_lo=671, t_hi=168, dedup_q=72064, margin=10): + """8-neighbour non-maximum suppression on the response map. A pixel is a + keypoint iff `resp > t_lo`, `resp >= t_hi`, and strictly greater than all 8 + neighbours; score = |resp|. Returns `(score, x, y)` in raster-scan order. + + Both thresholds are kept separate for parity with the DLL's NMS + (sub_18000CF90), even though t_hi < t_lo makes the second test redundant + at the default values.""" + h, w = resp.shape + r2 = ((dedup_q >> 6) ** 2) >> 20 + kps = [] # (score, x, y) + for y in range(margin, h - margin): + for x in range(margin, w - margin): + v = float(resp[y, x]) + if v <= t_lo or v < t_hi: + continue + nb = resp[y - 1:y + 2, x - 1:x + 2] + nbmax = float(nb.max()) + if v <= nbmax and not (v == nbmax and int((nb == v).sum()) == 1): + continue + s = abs(v) + dup = next((i for i, (_, kx, ky) in enumerate(kps) + if (kx - x) ** 2 + (ky - y) ** 2 <= r2), None) + if dup is None: + kps.append((s, x, y)) + elif s > kps[dup][0]: + kps[dup] = (s, x, y) + return kps + + +# ─── subpixel refinement — Hessian-Newton on the response map ──────────── +# Replaces the DLL's bit-exact Cramer/SAR solver (sub_18000D4C0 / D5D0) with a +# standard 2×2 Newton step. The keypoint is dropped if the Hessian is singular +# or the offset exceeds 1 pixel. Coordinates are returned in Q16 — the +# representation the merge / orientation / descriptor stages expect. +def subpix_refine_kp(resp, x_int, y_int): + """Refine an integer keypoint to subpixel. Returns (x_q16, y_q16) or None + if the keypoint should be removed.""" + h, w = resp.shape + if not (1 <= x_int <= w - 2 and 1 <= y_int <= h - 2): + return None + C = float(resp[y_int, x_int]) + L = float(resp[y_int, x_int - 1]) + R = float(resp[y_int, x_int + 1]) + T = float(resp[y_int - 1, x_int]) + B = float(resp[y_int + 1, x_int]) + TL = float(resp[y_int - 1, x_int - 1]) + TR = float(resp[y_int - 1, x_int + 1]) + BL = float(resp[y_int + 1, x_int - 1]) + BR = float(resp[y_int + 1, x_int + 1]) + dxx = L + R - 2.0 * C + dyy = T + B - 2.0 * C + dxy = (BR - BL - TR + TL) / 4.0 + gx = (R - L) / 2.0 + gy = (B - T) / 2.0 + det = dxx * dyy - dxy * dxy + if det == 0.0: + return None + H = np.array([[dxx, dxy], [dxy, dyy]], dtype=np.float64) + try: + dx, dy = np.linalg.solve(H, np.array([-gx, -gy], dtype=np.float64)) + except np.linalg.LinAlgError: + return None + if abs(dx) > 1.0 or abs(dy) > 1.0: + return None + x_q16 = int(round((x_int + dx) * 65536.0)) + y_q16 = int(round((y_int + dy) * 65536.0)) + return x_q16, y_q16 + + +# ─── descriptor gradient pair ───────────────────────────────────────────── +def descriptor_gradient(tile_q16): + """Compute (gradX, gradY) float arrays for orientation + BRIEF sampling. + gradX = d/dx of the smoothed tile, gradY = d/dy. Out-of-bounds reads → 0 + (matches the DLL's descriptor-gradient border handling).""" + img = np.asarray(tile_q16, dtype=np.float64) / 65536.0 + gk = build_gaussian(5) + sm = apply_sep(img, gk, gk, fill=0.0) + gradX = apply_sep(sm, DERIV_3TAP, SMOOTH_3TAP, fill=0.0) + gradY = apply_sep(sm, SMOOTH_3TAP, DERIV_3TAP, fill=0.0) + return gradX, gradY + + +# ─── orientation (sub_18000D920) — dominant gradient angle ──────────────── +def orient_d920(gradX, gradY, subpix_x_q16, subpix_y_q16): + """Dominant-gradient orientation at a keypoint, in radians [0, 2π). + + Samples a 13×13 circular patch (radius 6) centred on the rounded subpixel + coordinate, weights each gradient by GAUSS_Q, accumulates two 42-bin + histograms with a 7-bin left-smear, picks the bin of greatest magnitude, + and returns atan2 of that bin's accumulated (gy, gx).""" + H, W = gradX.shape + cx = (subpix_x_q16 + 0x8000) >> 16 # round to nearest pixel + cy = (subpix_y_q16 + 0x8000) >> 16 + H_gx = np.zeros(42, dtype=np.float64) + H_gy = np.zeros(42, dtype=np.float64) + for dy in range(-6, 7): + for dx in range(-6, 7): + if dy * dy + dx * dx >= 36: # circular mask, radius 6 + continue + y = cy + dy + x = cx + dx + if not (0 <= y < H and 0 <= x < W): + continue + wgt = float(GAUSS_Q[abs(dy), abs(dx)]) + ggx = float(gradX[y, x]) * wgt + ggy = float(gradY[y, x]) * wgt + ang = math.atan2(ggy, ggx) % TWO_PI + b = int(ang / TWO_PI * 42) % 42 + for k in range(7): # 7-bin smear: bin-6 .. bin + sb = (b - 6 + k) % 42 + H_gx[sb] += ggx + H_gy[sb] += ggy + mags = H_gx ** 2 + H_gy ** 2 + maxbin = int(np.argmax(mags)) + return math.atan2(H_gy[maxbin], H_gx[maxbin]) % TWO_PI + + +# ─── E090 oriented-BRIEF descriptor ─────────────────────────────────────── +def desc_sample_rotate(grad_x, grad_y, subpix_x_q16, subpix_y_q16, orient, N=7): + """Rotation + sampling (stage 1). Returns two float arrays of length + (2N+2)² (= 256 for N=7): the orientation-rotated gradient samples. + + Storage is COLUMN-MAJOR (xL outer, yL inner) to match the aggregation + table: index = (xL+N)*(2N+2) + (yL+N). `orient` is in radians.""" + H, W = grad_x.shape + cos_o = math.cos(orient) + sin_o = math.sin(orient) + sx = subpix_x_q16 / 65536.0 + sy = subpix_y_q16 / 65536.0 + span = 2 * N + 2 # = 16 for N = 7 + rgx = np.zeros(span * span, dtype=np.float64) + rgy = np.zeros(span * span, dtype=np.float64) + for xi in range(span): # outer = xL + xL = xi - N + for yi in range(span): # inner = yL + yL = yi - N + px = sx + xL * cos_o - yL * sin_o + py = sy + xL * sin_o + yL * cos_o + ix = int(math.floor(px)) + iy = int(math.floor(py)) + if 0 <= ix < W and 0 <= iy < H: + gx = float(grad_x[iy, ix]) + gy = float(grad_y[iy, ix]) + else: + # Off-tile samples: zero gradient (a flat extension has no + # gradient). The DLL filled these with mid-gray 0x800000 in + # its Q16 gradient domain; 0.0 is the natural-units equivalent. + gx = gy = 0.0 + idx = xi * span + yi + rgx[idx] = cos_o * gx + sin_o * gy + rgy[idx] = cos_o * gy - sin_o * gx + return rgx, rgy + + +def desc_aggregate(rgx, rgy, aggr_table, win_sizes, N=7): + """Aggregation (stage 2). For each table entry (size_idx, dx_off, dy_off), + sum the rotated gradients over a w×w window (w = win_sizes[size_idx]). + Returns the 2·len(aggr_table) BRIEF input buffer [gx0, gy0, gx1, gy1, …].""" + span = 2 * N + 2 + out = np.zeros(2 * len(aggr_table), dtype=np.float64) + for i, (size_idx, dx_off, dy_off) in enumerate(aggr_table): + w = int(win_sizes[size_idx]) + if w <= 0: + continue + x0 = dx_off + N + y0 = dy_off + N + sx = sy = 0.0 + for xx in range(w): + base = (x0 + xx) * span + y0 + sx += float(rgx[base:base + w].sum()) + sy += float(rgy[base:base + w].sum()) + out[2 * i + 0] = sx + out[2 * i + 1] = sy + return out + + +# ─── BRIEF bit-pack — bit[j] = samples[idx1] > samples[idx2] ────────────── +# The index-pair table is the DLL's runtime-generated BRIEF table, snapshotted +# in blobs_a2.BRIEF_TABLE (128 pairs → 16-byte descriptor). +_BRIEF_TABLE = None + + +def _load_brief_table(): + global _BRIEF_TABLE + if _BRIEF_TABLE is None: + from .blobs_a2 import BRIEF_TABLE + _BRIEF_TABLE = np.array(BRIEF_TABLE, dtype=np.int32).reshape(-1, 2) + return _BRIEF_TABLE + + +def brief_pack(samples, table=None, count=128): + """BRIEF bit-pack: 128 binary comparisons → 16-byte little-endian descriptor.""" + if table is None: + table = _load_brief_table() + a = samples[table[:count, 0]] + b = samples[table[:count, 1]] + bits = (a > b).astype(np.uint8) + return np.packbits(bits, bitorder='little') # → 16-byte descriptor + + +# ─── tile→global merge + v30 assembly ────────────────────────────────── +def merge_tile_kps_to_global(per_tile_kps, h, w, margin=3): + """Merge per-tile keypoint lists into one global list, applying the DLL's + bound check (margin ≤ global_xy < dim-margin). + + `per_tile_kps`: iterable of (i, j, kp_list) where kp_list items have their + first two fields = (subpix_x_q16, subpix_y_q16). Remaining fields are + preserved. Returns list of (gx_int, gy_int, *rest); out-of-bounds dropped.""" + out = [] + for i, j, kp_list in per_tile_kps: + oy, ox = tile_origin(i, j, h, w) + for kp in kp_list: + sx_q16, sy_q16 = kp[0], kp[1] + gx = ox + (sx_q16 >> 16) + gy = oy + (sy_q16 >> 16) + if margin <= gx < w - margin and margin <= gy < h - margin: + out.append((gx, gy, *kp[2:])) + return out + + +# ─── WS-body v30 SECTION serializer ────────────────────────────────────── +# A v30 record area of one ws-body section: n_slots × 18-byte records, no +# header/lead-in/trailer. Record layout = [16B descriptor][x:u8][y:u8]. +V30_SECTION_RECORDS = 250 +V30_SECTION_BYTES = V30_SECTION_RECORDS * 18 # 4500 +V30_DESC_LEN = 16 # record = [desc:16][x:u8][y:u8]; (x,y) anchor is +16 in +WS_SIZE = 23056 # chip-view WS body size +DEFAULT_SUBTYPE = 0x00f7 # default WinBio finger subtype + + +def serialize_v30_section(records, n_slots=V30_SECTION_RECORDS): + """Serialize one ws-body section's v30 record area as + [16B descriptor][x:u8][y:u8] × n_slots. Short tail zero-padded; the buffer + must be written at (the v30-region anchor − V30_DESC_LEN).""" + out = bytearray(n_slots * 18) + for i, rec in enumerate(records): + if i >= n_slots: + break + x, y, desc = rec[0], rec[1], rec[2] + o = i * 18 + d = bytes(desc[:16]) + out[o:o + len(d)] = d + out[o + 16] = x & 0xFF + out[o + 17] = y & 0xFF + return bytes(out) + + +# ─── End-to-end frame extraction (single image → kp list with descriptors) ─ +_AGGR_TABLE = None +_WIN_SIZES = [7, 5, 3] # the small local window-size table in E090 + + +def _load_aggr_table(): + global _AGGR_TABLE + if _AGGR_TABLE is None: + from .blobs_a2 import AGGR_TABLE + _AGGR_TABLE = np.array(AGGR_TABLE, dtype=np.int32).reshape(29, 3) + return _AGGR_TABLE + + +def _descriptor_at(gradX, gradY, subpix_x_q16, subpix_y_q16, orient): + """16-byte BRIEF descriptor at a keypoint via the rotate→aggregate→pack + chain. `orient` is in radians.""" + rgx, rgy = desc_sample_rotate(gradX, gradY, subpix_x_q16, subpix_y_q16, + orient, N=7) + samples = desc_aggregate(rgx, rgy, _load_aggr_table(), _WIN_SIZES, N=7) + return brief_pack(samples) + + +FRAME_KP_CAP = 250 +"""sub_18000AAB0 caps the per-frame kp_array to 250 (0xfa).""" + + +def _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti=None, tj=None): + """Project (subpix_x_q16, subpix_y_q16) into the global frame via the tile + origin and return True iff 3 ≤ gx < w-3 and 3 ≤ gy < h-3, with one + corner-tile tightening observed in captures of the Windows driver's + enrollment (bottom-right tile caps gy at oy + last-row-step).""" + gx = ((ox << 16) + sx_q16) >> 16 + gy = ((oy << 16) + sy_q16) >> 16 + gx_hi = w - 3 + gy_hi = h - 3 + if ti is not None and tj is not None: + if ti == GRID - 1 and tj == GRID - 1: + step_y = h - (GRID - 1) * (h // GRID) + gy_hi = oy + step_y # exclusive + return 3 <= gx < gx_hi and 3 <= gy < gy_hi + + +def extract_frame_native(image_q16, h=112, w=112, + t_lo=671, t_hi=168, dedup_q=72064, nms_margin=10, + subpix_refine=True, frame_kp_cap=FRAME_KP_CAP): + """Single-frame native feature extractor. + + Per-tile: DoH → NMS → subpixel refine → global-edge cull. Then a global + sort by |resp| descending, cap to 250, re-sort by (tile_id, |resp| desc), + and run orientation + descriptor per keypoint. Returns a list of + (gx_int, gy_int, orient_rad, desc_16B) after the global merge. + + `image_q16`: (h, w) int array, mid-gray = 0x800000 (uint8 image << 16).""" + image_q16 = np.asarray(image_q16, dtype=np.int64) + if image_q16.shape != (h, w): + raise ValueError(f"expected ({h}, {w}), got {image_q16.shape}") + + # Phase 1: per-tile detect + subpix + edge filter into a tile-tagged pool. + per_tile_grads = {} + pool = [] # (score, tile_id, ti, tj, sx_q16, sy_q16) + for ti, tj, tile in tile_image(image_q16): + gradX, gradY = descriptor_gradient(tile) + per_tile_grads[(ti, tj)] = (gradX, gradY) + _, _, _, resp = doh(tile) + oy, ox = tile_origin(ti, tj, h, w) + tile_id = ti * GRID + tj + for score, lx, ly in nms(resp, t_lo=t_lo, t_hi=t_hi, + dedup_q=dedup_q, margin=nms_margin): + if subpix_refine: + r = subpix_refine_kp(resp, lx, ly) + if r is None: + continue + sx_q16, sy_q16 = r + else: + sx_q16 = lx * 65536 + sy_q16 = ly * 65536 + if not _a960_passes_global_edge(sx_q16, sy_q16, oy, ox, h, w, ti, tj): + continue + pool.append((score, tile_id, ti, tj, sx_q16, sy_q16)) + + # Phase 2: global sort by |resp| desc, cap to frame_kp_cap. + pool.sort(key=lambda r: -r[0]) + pool = pool[:frame_kp_cap] + + # Phase 3: re-sort by (tile_id asc, |resp| desc). + pool.sort(key=lambda r: (r[1], -r[0])) + + # Phase 4: per-kp orient + descriptor, grouped back into per-tile lists. + per_tile_kps = [] + current_key = None + current_records = None + current_tij = None + for score, tile_id, ti, tj, sx_q16, sy_q16 in pool: + if tile_id != current_key: + if current_records is not None: + per_tile_kps.append((current_tij[0], current_tij[1], current_records)) + current_key = tile_id + current_tij = (ti, tj) + current_records = [] + gradX, gradY = per_tile_grads[(ti, tj)] + orient = orient_d920(gradX, gradY, sx_q16, sy_q16) + desc = _descriptor_at(gradX, gradY, sx_q16, sy_q16, orient) + current_records.append((sx_q16, sy_q16, orient, bytes(desc))) + if current_records is not None: + per_tile_kps.append((current_tij[0], current_tij[1], current_records)) + + return merge_tile_kps_to_global(per_tile_kps, h, w) + + +# ─── Baked WS-body scaffold — makes native enrollment REFERENCE-FREE ─────── +# A genuine chip-accepted Windows-driver template with its v30 RECORD areas zeroed — +# i.e. the TLV framing only (header, per-section counts, sec0_pre inter-section +# pose table, section markers, tail). Finger-INDEPENDENT RE-derived constant +# data; we overlay OUR v30 records onto it and recompute the TID. +NATIVE_WS_V30_REGIONS = (309, 4913, 9453, 13993) +_NATIVE_WS_SCAFFOLD = None + + +def _load_ws_scaffold(): + """Return the 23056-byte baked WS-body framing scaffold (v30 zeroed).""" + global _NATIVE_WS_SCAFFOLD + if _NATIVE_WS_SCAFFOLD is None: + from .blobs_a2 import build_ws_scaffold + _NATIVE_WS_SCAFFOLD = build_ws_scaffold() + return _NATIVE_WS_SCAFFOLD + + +def patch_pre_v30_near_identity(ws_body, regions): + """Set the WS body's inter-section rigid transforms to NEAR-identity so a + single-frame template (same records in every section) is self-consistent. + + The matcher's candidate-validity filter (sub_18000bfb0) skips pure-identity + {0x10000,0,0,0} as the 'unmatched' sentinel, so we write near-identity + a=0x10000, b=0, tx=1, ty=1: geometrically identity yet tx != 0. + Returns (patched_ws_body_bytes, n_records_patched).""" + import struct as _struct + ONE = 0x10000 + ws = bytearray(ws_body) + span = V30_SECTION_BYTES + zones, prev = [], 0 + for b in sorted(regions): + zones.append((prev, b)) + prev = b + span + zones.append((prev, len(ws))) + + def rigid(a, b): + return abs(a * a + b * b - ONE * ONE) < ONE * ONE * 0.05 + + ident = _struct.pack('<4i', ONE, 0, 1, 1) # a, b, tx, ty (near-identity) + patched = 0 + for z0, z1 in zones: + best = (0, 0) + for start in range(z0, z1): + o, n = start, 0 + while o + 18 <= z1: + a, b, tx, ty = _struct.unpack_from('<4i', ws, o + 2) + if not rigid(a, b): + break + n += 1 + o += 18 + if n > best[0]: + best = (n, start) + n, start = best + if n >= 2: + o = start + for _ in range(n): + ws[o + 2:o + 18] = ident # keep [x][y] at o, o+1 + o += 18 + patched += 1 + return bytes(ws), patched + + +def native_template(image_q16, subtype=None, fill_all_sections=True): + """End-to-end REFERENCE-FREE native enrollment template. + + Detects keypoints in `image_q16` with the native pipeline, formats them + into 18-byte v30 records [16B desc][x][y], overwrites every v30 region of + the baked WS-body scaffold, recomputes the TID, and returns the envelope + ready for db.new_finger() (chip cmd 0x47). + + `image_q16`: (h, w) int Q16 image (mid-gray = 0x800000). The sensor returns + uint8; convert via `img.astype(np.int32) << 16`.""" + ws_body = bytearray(_load_ws_scaffold()) + regions = list(NATIVE_WS_V30_REGIONS) + if subtype is None: + subtype = DEFAULT_SUBTYPE + assert len(ws_body) == WS_SIZE, f"WS body must be {WS_SIZE}B, got {len(ws_body)}" + + # Inter-section sec0_pre transforms must be NEAR-identity (load-bearing). + ws_body = bytearray(patch_pre_v30_near_identity(bytes(ws_body), regions)[0]) + + # 1. Detect OUR keypoints + descriptors from OUR image. + kps = extract_frame_native(image_q16, h=image_q16.shape[0], + w=image_q16.shape[1]) + + # 2-3. Serialize into [16B desc][x][y] × 250 and overwrite every v30 region. + section = serialize_v30_section( + [(gx, gy, desc) for (gx, gy, _orient, desc) in kps]) + target_regions = regions if fill_all_sections else regions[:1] + for base in target_regions: + start = base - V30_DESC_LEN + ws_body[start:start + len(section)] = section + + # 4. Recompute TID over the new WS body, wrap in the envelope. + ws_body_bytes = bytes(ws_body) + tid = compute_tid(ws_body_bytes) + return _build_envelope(subtype, ws_body_bytes, tid) + + +# ══════════════════════════════════════════════════════════════════════ +# Envelope + TID (byte-format; used by native_template and moh_enrollment) +# ══════════════════════════════════════════════════════════════════════ + +def _build_envelope(subtype, ws_body, template_id, version=3): + """Wire-exact envelope for new_record type=6 (byte-identical to the DLL's + sub_180036840). Layout: 8-byte outer header, TLV1 (tag=1) ws_body, TLV2 + (tag=2) template_id, 32 trailing zeros. Caller passes the chip-view WS + body (NOT including the TLV2 header).""" + assert len(template_id) == 32 + ws_size = len(ws_body) + tid_size = len(template_id) + trailing = 32 + payload_size = 4 + ws_size + 4 + tid_size # TLV1 hdr + ws + TLV2 hdr + TID + total = 8 + payload_size + trailing + + buf = bytearray(total) + buf[0:2] = pack(' typing.Tuple[int, int, int, int]: + def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int, bytes]: try: assert_status(tls.app(self.build_cmd_02(mode))) @@ -718,6 +719,7 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: res = get_prg_status2() + # 0000 (status) 00200000 (sz) 7000 (x) 7000 (y) 4d01 (?) 0800 (?) 00000000 (err_code) xxxx (img_from_sensor) assert_status(res) res = res[2:] @@ -727,15 +729,45 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]: if l != len(res): raise Exception('Response size does not match %d != %d', l, len(res)) - x, y, w1, w2, error = unpack(' 12. + img_data = b'' + if l > 12: + img_data = res[12:] + # The feature frame is x*y bytes. Each get_prg_status2 returns up + # to 8192 bytes, so pull chunks until we have the whole frame. + # Cap the number of follow-up reads so a sensor that reports + # l > 12 but never streams x*y bytes errors out instead of + # looping forever (8 reads ≈ 64 KB, well over any frame here). + expected = x * y + max_reads = 8 + while len(img_data) < expected: + if max_reads <= 0: + raise Exception('capture: image underrun, got %d of %d bytes' + % (len(img_data), expected)) + max_reads -= 1 + res = get_prg_status2() + assert_status(res) + res = res[2:] + l, res = res[:4], res[4:] + l, = unpack(' int: rsp = tls.app(pack('