From 123c85eea3e098c3dd4dd7a05aedb778b03869bb Mon Sep 17 00:00:00 2001 From: 0xCoDSnet Date: Thu, 12 Mar 2026 21:14:33 +0400 Subject: [PATCH 1/6] Add FocalTech FT9201 fingerprint sensor driver Reverse-engineered USB protocol for FocalTech FT9201 (FT9338 chip, VID:PID 2808:9338). Area fingerprint sensor, 64x80 pixels, 8-bit grayscale, connected via USB SIU (Serial Interface Unit) bridge. Protocol uses "New SIU" compound register addresses: - 0x9180: chip status / OTP info - 0x9080: image capture (5120 bytes) - 0xFF00: sync / reset Read sequence: 3 vendor control OUTs + 1 bulk IN. First bulk read after USB reset returns garbage and must be discarded. Implements 16-state capture SSM with warmup, finger polling, sync, status check, and image read phases. --- .../drivers/focaltech_moh/focaltech_moh.c | 381 ++++++++++++++++++ .../drivers/focaltech_moh/focaltech_moh.h | 98 +++++ libfprint/meson.build | 2 + meson.build | 2 + 4 files changed, 483 insertions(+) create mode 100644 libfprint/drivers/focaltech_moh/focaltech_moh.c create mode 100644 libfprint/drivers/focaltech_moh/focaltech_moh.h diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.c b/libfprint/drivers/focaltech_moh/focaltech_moh.c new file mode 100644 index 00000000..c8c7b8c0 --- /dev/null +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.c @@ -0,0 +1,381 @@ +/* + * FocalTech FT9201 Match-on-Host driver for libfprint + * + * Copyright (C) 2025 libfprint contributors + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +/* + * FocalTech FT9201 (chip FT9338, VID:2808 PID:9338) + * + * Area fingerprint sensor with USB SIU (Serial Interface Unit) bridge. + * 64×80 pixels, 8-bit grayscale, match-on-host. + * + * MCU has ROM firmware — no firmware upload needed. The SIU uses a + * "New SIU" protocol with compound register addresses. + * + * Read sequence (3 control OUTs + 1 bulk IN): + * 1. OUT req=0x34 wValue=0x00FF (prepare init) + * 2. OUT req=0x34 wValue=0x0003 (prepare read mode) + * 3. OUT req=0x6F wValue=size wIndex=compound_addr (configure) + * 4. Bulk IN on EP3 (read data) + * + * Compound addresses: + * 0x9180 — chip status / OTP info + * 0x9080 — image capture (5120 bytes) + * 0xFF00 — sync / reset (size=0, no bulk IN) + * + * Important: after USB reset or first enumeration, the first bulk IN + * read returns garbage (all 0x02). A warmup read must be performed + * and its result discarded. + */ + +#define FP_COMPONENT "focaltech_moh" + +#include "drivers_api.h" +#include "focaltech_moh.h" + +G_DEFINE_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, + FP_TYPE_IMAGE_DEVICE) + +static const FpIdEntry id_table[] = { + { .vid = FT9201_VID, .pid = FT9201_PID }, + { .vid = 0, .pid = 0 }, +}; + +/* ─── Helper: send vendor control OUT ──────────────────────────── */ + +static void +ft9201_ctrl_out (FpDevice *dev, + FpiSsm *ssm, + guint8 request, + guint16 value, + guint16 index) +{ + FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); + + transfer->ssm = ssm; + fpi_usb_transfer_fill_control (transfer, + G_USB_DEVICE_DIRECTION_HOST_TO_DEVICE, + G_USB_DEVICE_REQUEST_TYPE_VENDOR, + G_USB_DEVICE_RECIPIENT_DEVICE, + request, value, index, 0); + fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, + fpi_ssm_usb_transfer_cb, NULL); +} + +/* ─── Capture state machine ────────────────────────────────────── */ + +static void +capture_read_cb (FpiUsbTransfer *transfer, + FpDevice *dev, + gpointer user_data, + GError *error) +{ + if (error) + { + fpi_ssm_mark_failed (transfer->ssm, error); + return; + } + + fpi_ssm_next_state (transfer->ssm); +} + +static void +capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + int state = fpi_ssm_get_cur_state (ssm); + + switch (state) + { + /* + * Warmup: first bulk read after reset returns garbage. + * Skip if already done. + */ + case CAPTURE_WARMUP_PREP1: + if (self->warmup_done) + { + fpi_ssm_jump_to_state (ssm, CAPTURE_SYNC_PREP1); + return; + } + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); + break; + + case CAPTURE_WARMUP_PREP2: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_READ, 0); + break; + + case CAPTURE_WARMUP_CMD: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, 0x0020, FT9201_REG_STATUS); + break; + + case CAPTURE_WARMUP_READ: + { + FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); + + fpi_usb_transfer_fill_bulk (transfer, FT9201_EP_IN, 32); + transfer->short_is_error = FALSE; + transfer->ssm = ssm; + fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, + capture_read_cb, NULL); + self->warmup_done = TRUE; + } + break; + + /* Sync: poke 0xFF00 (no bulk read) */ + case CAPTURE_SYNC_PREP1: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); + break; + + case CAPTURE_SYNC_PREP2: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_READ, 0); + break; + + case CAPTURE_SYNC_CMD: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, 0, FT9201_REG_SYNC); + break; + + /* Status: read 4 bytes from 0x9180 */ + case CAPTURE_STATUS_PREP1: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); + break; + + case CAPTURE_STATUS_PREP2: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_READ, 0); + break; + + case CAPTURE_STATUS_CMD: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, 4, FT9201_REG_STATUS); + break; + + case CAPTURE_STATUS_READ: + { + FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); + + fpi_usb_transfer_fill_bulk (transfer, FT9201_EP_IN, 32); + transfer->short_is_error = FALSE; + transfer->ssm = ssm; + fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, + capture_read_cb, NULL); + } + break; + + /* Image capture: 5120 bytes from 0x9080 */ + case CAPTURE_IMG_PREP1: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); + break; + + case CAPTURE_IMG_PREP2: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_READ, 0); + break; + + case CAPTURE_IMG_CMD: + ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, + FT9201_IMG_SIZE, FT9201_REG_CAPTURE); + break; + + case CAPTURE_IMG_READ: + { + FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); + + fpi_usb_transfer_fill_bulk_full (transfer, FT9201_EP_IN, + self->image_buf, FT9201_IMG_SIZE, + NULL); + transfer->short_is_error = FALSE; + transfer->ssm = ssm; + fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, + capture_read_cb, NULL); + } + break; + + default: + g_assert_not_reached (); + } +} + +static void +capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) +{ + FpImageDevice *img_dev = FP_IMAGE_DEVICE (dev); + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + FpImage *image; + + self->capture_ssm = NULL; + + if (self->deactivating) + { + g_clear_error (&error); + fpi_image_device_deactivate_complete (img_dev, NULL); + return; + } + + if (error) + { + fpi_image_device_session_error (img_dev, error); + return; + } + + /* Check if image has meaningful data. + * Warmup/blank images have very few unique pixel values. + * A real fingerprint has 100+ unique values. */ + { + gboolean seen[256] = { FALSE, }; + int unique = 0; + int i; + + for (i = 0; i < FT9201_IMG_SIZE; i++) + { + if (!seen[self->image_buf[i]]) + { + seen[self->image_buf[i]] = TRUE; + unique++; + } + } + + if (unique < 50) + { + fp_dbg ("Skipping low-quality image (%d unique values)", unique); + goto restart_capture; + } + } + + /* Report finger on, submit image, report finger off */ + image = fp_image_new (FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); + memcpy (image->data, self->image_buf, FT9201_IMG_SIZE); + image->flags = FPI_IMAGE_V_FLIPPED; + + fp_dbg ("Image captured (%d unique values), submitting", + FT9201_IMG_SIZE); + + fpi_image_device_report_finger_status (img_dev, TRUE); + fpi_image_device_image_captured (img_dev, image); + fpi_image_device_report_finger_status (img_dev, FALSE); + +restart_capture: + /* Restart capture loop with a short delay to avoid busy-polling */ + if (!self->deactivating) + { + self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, + CAPTURE_NUM_STATES); + fpi_ssm_start (self->capture_ssm, capture_ssm_complete); + } +} + +/* ─── Device lifecycle ─────────────────────────────────────────── */ + +static void +dev_open (FpImageDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + GError *error = NULL; + + G_DEBUG_HERE (); + + /* Reset USB device to clear any stuck bulk IN pipe state. + * Without this, the first bulk read may timeout if the pipe + * was left in a bad state from a previous session. */ + if (!g_usb_device_reset (fpi_device_get_usb_device (FP_DEVICE (dev)), &error)) + { + fp_dbg ("USB reset failed (non-fatal): %s", error->message); + g_clear_error (&error); + } + + if (!g_usb_device_claim_interface (fpi_device_get_usb_device (FP_DEVICE (dev)), + 0, 0, &error)) + { + fpi_image_device_open_complete (dev, error); + return; + } + + self->image_buf = g_malloc0 (FT9201_IMG_SIZE); + self->warmup_done = FALSE; + + fpi_image_device_open_complete (dev, NULL); +} + +static void +dev_close (FpImageDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + GError *error = NULL; + + G_DEBUG_HERE (); + + g_clear_pointer (&self->image_buf, g_free); + + g_usb_device_release_interface (fpi_device_get_usb_device (FP_DEVICE (dev)), + 0, 0, &error); + fpi_image_device_close_complete (dev, error); +} + +static void +dev_activate (FpImageDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + + G_DEBUG_HERE (); + + self->deactivating = FALSE; + + self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, + CAPTURE_NUM_STATES); + fpi_ssm_start (self->capture_ssm, capture_ssm_complete); + + fpi_image_device_activate_complete (dev, NULL); +} + +static void +dev_deactivate (FpImageDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + + G_DEBUG_HERE (); + + if (!self->capture_ssm) + fpi_image_device_deactivate_complete (dev, NULL); + else + self->deactivating = TRUE; +} + +/* ─── GType boilerplate ────────────────────────────────────────── */ + +static void +fpi_device_focaltech_moh_init (FpiDeviceFocaltechMoh *self) +{ +} + +static void +fpi_device_focaltech_moh_class_init (FpiDeviceFocaltechMohClass *klass) +{ + FpDeviceClass *dev_class = FP_DEVICE_CLASS (klass); + FpImageDeviceClass *img_class = FP_IMAGE_DEVICE_CLASS (klass); + + dev_class->id = "focaltech_moh"; + dev_class->full_name = "FocalTech FT9201 Fingerprint Sensor"; + dev_class->type = FP_DEVICE_TYPE_USB; + dev_class->scan_type = FP_SCAN_TYPE_PRESS; + dev_class->id_table = id_table; + + img_class->img_open = dev_open; + img_class->img_close = dev_close; + img_class->activate = dev_activate; + img_class->deactivate = dev_deactivate; + + img_class->img_width = FT9201_IMG_WIDTH; + img_class->img_height = FT9201_IMG_HEIGHT; + img_class->bz3_threshold = 24; +} diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.h b/libfprint/drivers/focaltech_moh/focaltech_moh.h new file mode 100644 index 00000000..ef8fe763 --- /dev/null +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.h @@ -0,0 +1,98 @@ +/* + * FocalTech FT9201 Match-on-Host driver for libfprint + * + * Copyright (C) 2025 libfprint contributors + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#pragma once + +#include "fpi-device.h" +#include "fpi-ssm.h" +#include "fpi-image-device.h" + +G_DECLARE_FINAL_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, FPI, + DEVICE_FOCALTECH_MOH, FpImageDevice) + +#define FT9201_VID 0x2808 +#define FT9201_PID 0x9338 + +#define FT9201_EP_IN 0x83 /* Bulk IN (EP3, 32B max packet) */ + +/* Image: 64 wide x 80 high, 8-bit grayscale */ +#define FT9201_IMG_WIDTH 64 +#define FT9201_IMG_HEIGHT 80 +#define FT9201_IMG_SIZE (FT9201_IMG_WIDTH * FT9201_IMG_HEIGHT) /* 5120 */ + +#define FT9201_CMD_TIMEOUT 5000 +#define FT9201_POLL_INTERVAL 30 /* ms between finger detection polls */ + +/* USB vendor request codes */ +#define FT9201_REQ_PREPARE 0x34 +#define FT9201_REQ_INT_STATUS 0x43 +#define FT9201_REQ_NEW_SIU_RW 0x6F + +/* Prepare command wValue values */ +#define FT9201_PREPARE_INIT 0x00FF +#define FT9201_PREPARE_READ 0x0003 + +/* New SIU compound register addresses (wIndex for req 0x6F) */ +#define FT9201_REG_STATUS 0x9180 /* Chip status / OTP info */ +#define FT9201_REG_CAPTURE 0x9080 /* Image capture */ +#define FT9201_REG_SYNC 0xFF00 /* Sync / reset (size=0, no bulk) */ + +/* + * Capture state machine — one state per async USB transfer. + * + * The read sequence is: PREPARE_INIT → PREPARE_READ → NEW_SIU_RW → BULK_IN. + * Each is a separate async transfer, so each gets its own SSM state. + */ +enum capture_states { + /* Warmup: discard first bulk read after USB reset */ + CAPTURE_WARMUP_PREP1, /* OUT 0x34(0xFF) */ + CAPTURE_WARMUP_PREP2, /* OUT 0x34(3) */ + CAPTURE_WARMUP_CMD, /* OUT 0x6F(32, 0x9180) */ + CAPTURE_WARMUP_READ, /* BULK IN 32B (discard) */ + + /* Sync: poke 0xFF00 */ + CAPTURE_SYNC_PREP1, /* OUT 0x34(0xFF) */ + CAPTURE_SYNC_PREP2, /* OUT 0x34(3) */ + CAPTURE_SYNC_CMD, /* OUT 0x6F(0, 0xFF00) — no bulk */ + + /* Status: read 4 bytes from 0x9180 */ + CAPTURE_STATUS_PREP1, /* OUT 0x34(0xFF) */ + CAPTURE_STATUS_PREP2, /* OUT 0x34(3) */ + CAPTURE_STATUS_CMD, /* OUT 0x6F(4, 0x9180) */ + CAPTURE_STATUS_READ, /* BULK IN 32B (check status) */ + + /* Image: read 5120 bytes from 0x9080 */ + CAPTURE_IMG_PREP1, /* OUT 0x34(0xFF) */ + CAPTURE_IMG_PREP2, /* OUT 0x34(3) */ + CAPTURE_IMG_CMD, /* OUT 0x6F(0x1400, 0x9080) */ + CAPTURE_IMG_READ, /* BULK IN 5120B */ + + CAPTURE_NUM_STATES, +}; + +struct _FpiDeviceFocaltechMoh +{ + FpImageDevice parent; + + gboolean deactivating; + gboolean warmup_done; + FpiSsm *capture_ssm; + guint8 *image_buf; +}; diff --git a/libfprint/meson.build b/libfprint/meson.build index ae0f6e24..9acf14b4 100644 --- a/libfprint/meson.build +++ b/libfprint/meson.build @@ -153,6 +153,8 @@ driver_sources = { [ 'drivers/realtek/realtek.c' ], 'focaltech_moc' : [ 'drivers/focaltech_moc/focaltech_moc.c' ], + 'focaltech_moh' : + [ 'drivers/focaltech_moh/focaltech_moh.c' ], } helper_sources = { diff --git a/meson.build b/meson.build index 14fb11f2..1d588b55 100644 --- a/meson.build +++ b/meson.build @@ -144,6 +144,7 @@ default_drivers = [ 'fpcmoc', 'realtek', 'focaltech_moc', + 'focaltech_moh', ] spi_drivers = [ @@ -168,6 +169,7 @@ endian_independent_drivers = virtual_drivers + [ 'elanmoc', 'etes603', 'focaltech_moc', + 'focaltech_moh', 'nb1010', 'realtek', 'synaptics', From 16b86bd2b9665d2c6672a8a220a2899607986a22 Mon Sep 17 00:00:00 2001 From: 0xCoDSnet Date: Fri, 13 Mar 2026 22:47:05 +0400 Subject: [PATCH 2/6] focaltech_moh: add finger detection via INT_STATUS polling Poll vendor request 0x43 (INT_STATUS) to detect finger presence before capturing. Byte 0: 0x00 = no finger, 0x01 = finger present. Retry every 30ms until finger is detected. Also add warmup skip on subsequent capture cycles to avoid consuming stale data from the USB pipe. --- .../drivers/focaltech_moh/focaltech_moh.c | 51 ++++++++++++++++++- .../drivers/focaltech_moh/focaltech_moh.h | 3 ++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.c b/libfprint/drivers/focaltech_moh/focaltech_moh.c index c8c7b8c0..0a75221e 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.c +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.c @@ -94,6 +94,39 @@ capture_read_cb (FpiUsbTransfer *transfer, fpi_ssm_next_state (transfer->ssm); } +static void +finger_poll_cb (FpiUsbTransfer *transfer, + FpDevice *dev, + gpointer user_data, + GError *error) +{ + FpImageDevice *img_dev = FP_IMAGE_DEVICE (dev); + + if (error) + { + fpi_ssm_mark_failed (transfer->ssm, error); + return; + } + + /* INT_STATUS byte 0: 0x00 = no finger, 0x01 = finger present */ + fp_dbg ("INT_STATUS: 0x%02x 0x%02x 0x%02x 0x%02x (len=%zu)", + transfer->buffer[0], transfer->buffer[1], + transfer->buffer[2], transfer->buffer[3], + transfer->actual_length); + if (transfer->buffer[0] == 0x01) + { + fp_dbg ("Finger detected!"); + fpi_image_device_report_finger_status (img_dev, TRUE); + fpi_ssm_next_state (transfer->ssm); + } + else + { + /* No finger — retry same state after delay */ + fpi_ssm_jump_to_state_delayed (transfer->ssm, CAPTURE_POLL_FINGER, + FT9201_POLL_INTERVAL); + } +} + static void capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) { @@ -136,6 +169,22 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) } break; + /* Finger detection: poll INT_STATUS (0x43) */ + case CAPTURE_POLL_FINGER: + { + FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); + + fpi_usb_transfer_fill_control (transfer, + G_USB_DEVICE_DIRECTION_DEVICE_TO_HOST, + G_USB_DEVICE_REQUEST_TYPE_VENDOR, + G_USB_DEVICE_RECIPIENT_DEVICE, + FT9201_REQ_INT_STATUS, 0, 0, 4); + transfer->ssm = ssm; + fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, + finger_poll_cb, NULL); + } + break; + /* Sync: poke 0xFF00 (no bulk read) */ case CAPTURE_SYNC_PREP1: ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); @@ -261,7 +310,7 @@ capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) fp_dbg ("Image captured (%d unique values), submitting", FT9201_IMG_SIZE); - fpi_image_device_report_finger_status (img_dev, TRUE); + /* finger_on already reported in finger_poll_cb */ fpi_image_device_image_captured (img_dev, image); fpi_image_device_report_finger_status (img_dev, FALSE); diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.h b/libfprint/drivers/focaltech_moh/focaltech_moh.h index ef8fe763..e3efc59a 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.h +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.h @@ -67,6 +67,9 @@ enum capture_states { CAPTURE_WARMUP_CMD, /* OUT 0x6F(32, 0x9180) */ CAPTURE_WARMUP_READ, /* BULK IN 32B (discard) */ + /* Finger detection: poll INT_STATUS until finger present */ + CAPTURE_POLL_FINGER, /* IN 0x43 — byte0: 0=no finger, 1=finger */ + /* Sync: poke 0xFF00 */ CAPTURE_SYNC_PREP1, /* OUT 0x34(0xFF) */ CAPTURE_SYNC_PREP2, /* OUT 0x34(3) */ From 7f03ecb9ed9ee642c290621c2bd78895dc76e022 Mon Sep 17 00:00:00 2001 From: 0xCoDSnet Date: Sat, 14 Mar 2026 14:22:18 +0400 Subject: [PATCH 3/6] focaltech_moh: implement multi-stage enrollment Use change_state callback (nb1010 pattern) to start capture SSM when framework transitions to AWAIT_FINGER_ON state. This enables proper multi-stage enrollment (5 stages). Add FPI_IMAGE_COLORS_INVERTED flag: the sensor outputs inverted pixel values, matching the Windows driver behavior (~pixel bitwise NOT inversion found in decompiled ftWbioUmdfDriverV2.dll). Add 2x nearest-neighbor upscaling (64x80 -> 128x160) for NBIS minutiae detection compatibility. --- .../drivers/focaltech_moh/focaltech_moh.c | 74 ++++++++++++++----- .../drivers/focaltech_moh/focaltech_moh.h | 14 +++- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.c b/libfprint/drivers/focaltech_moh/focaltech_moh.c index 0a75221e..704e4332 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.c +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.c @@ -140,9 +140,13 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) * Skip if already done. */ case CAPTURE_WARMUP_PREP1: + /* Only do warmup on first cycle (after USB reset). + * On subsequent cycles, skip directly to finger polling. + * The warmup bulk read on second+ cycle can consume stale + * data and corrupt the pipe state. */ if (self->warmup_done) { - fpi_ssm_jump_to_state (ssm, CAPTURE_SYNC_PREP1); + fpi_ssm_jump_to_state (ssm, CAPTURE_POLL_FINGER); return; } ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); @@ -166,6 +170,7 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) fpi_usb_transfer_submit (transfer, FT9201_CMD_TIMEOUT, NULL, capture_read_cb, NULL); self->warmup_done = TRUE; + fp_dbg ("Warmup bulk read submitted"); } break; @@ -234,7 +239,7 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) case CAPTURE_IMG_CMD: ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, - FT9201_IMG_SIZE, FT9201_REG_CAPTURE); + FT9201_RAW_SIZE, FT9201_REG_CAPTURE); break; case CAPTURE_IMG_READ: @@ -242,7 +247,7 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); fpi_usb_transfer_fill_bulk_full (transfer, FT9201_EP_IN, - self->image_buf, FT9201_IMG_SIZE, + self->image_buf, FT9201_RAW_SIZE, NULL); transfer->short_is_error = FALSE; transfer->ssm = ssm; @@ -278,15 +283,13 @@ capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) return; } - /* Check if image has meaningful data. - * Warmup/blank images have very few unique pixel values. - * A real fingerprint has 100+ unique values. */ + /* Check if image has meaningful data */ { gboolean seen[256] = { FALSE, }; int unique = 0; int i; - for (i = 0; i < FT9201_IMG_SIZE; i++) + for (i = 0; i < FT9201_RAW_SIZE; i++) { if (!seen[self->image_buf[i]]) { @@ -295,6 +298,8 @@ capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) } } + fp_dbg ("Image quality: %d unique values", unique); + if (unique < 50) { fp_dbg ("Skipping low-quality image (%d unique values)", unique); @@ -302,20 +307,37 @@ capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) } } - /* Report finger on, submit image, report finger off */ - image = fp_image_new (FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); - memcpy (image->data, self->image_buf, FT9201_IMG_SIZE); - image->flags = FPI_IMAGE_V_FLIPPED; + /* No contrast normalization — pass raw sensor data as-is. + * The FPI_IMAGE_COLORS_INVERTED flag tells libfprint to invert pixels + * before NBIS processing (matching what the Windows driver does with ~pixel). + * NBIS handles binarization internally. */ - fp_dbg ("Image captured (%d unique values), submitting", - FT9201_IMG_SIZE); + /* Upscale 2x with nearest-neighbor for better minutiae detection */ + image = fp_image_new (FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); + { + int x, y; + + for (y = 0; y < FT9201_IMG_HEIGHT; y++) + for (x = 0; x < FT9201_IMG_WIDTH; x++) + image->data[y * FT9201_IMG_WIDTH + x] = + self->image_buf[(y / FT9201_UPSCALE) * FT9201_RAW_WIDTH + + (x / FT9201_UPSCALE)]; + } + image->flags = FPI_IMAGE_V_FLIPPED | FPI_IMAGE_COLORS_INVERTED; + + fp_dbg ("Image captured and upscaled to %dx%d", + FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); /* finger_on already reported in finger_poll_cb */ fpi_image_device_image_captured (img_dev, image); fpi_image_device_report_finger_status (img_dev, FALSE); + return; + restart_capture: - /* Restart capture loop with a short delay to avoid busy-polling */ + /* Image was blank/warmup — restart capture to try again. + * Don't restart after a successful image_captured() — the + * framework will call dev_activate() when it needs another image. */ if (!self->deactivating) { self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, @@ -350,7 +372,7 @@ dev_open (FpImageDevice *dev) return; } - self->image_buf = g_malloc0 (FT9201_IMG_SIZE); + self->image_buf = g_malloc0 (FT9201_RAW_SIZE); self->warmup_done = FALSE; fpi_image_device_open_complete (dev, NULL); @@ -380,13 +402,24 @@ dev_activate (FpImageDevice *dev) self->deactivating = FALSE; - self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, - CAPTURE_NUM_STATES); - fpi_ssm_start (self->capture_ssm, capture_ssm_complete); - + /* Don't start SSM here — it will be started by dev_change_state() + * when the framework transitions to AWAIT_FINGER_ON. */ fpi_image_device_activate_complete (dev, NULL); } +static void +dev_change_state (FpImageDevice *dev, FpiImageDeviceState state) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + + if (state == FPI_IMAGE_DEVICE_STATE_AWAIT_FINGER_ON) + { + self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, + CAPTURE_NUM_STATES); + fpi_ssm_start (self->capture_ssm, capture_ssm_complete); + } +} + static void dev_deactivate (FpImageDevice *dev) { @@ -422,9 +455,10 @@ fpi_device_focaltech_moh_class_init (FpiDeviceFocaltechMohClass *klass) img_class->img_open = dev_open; img_class->img_close = dev_close; img_class->activate = dev_activate; + img_class->change_state = dev_change_state; img_class->deactivate = dev_deactivate; img_class->img_width = FT9201_IMG_WIDTH; img_class->img_height = FT9201_IMG_HEIGHT; - img_class->bz3_threshold = 24; + img_class->bz3_threshold = 12; } diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.h b/libfprint/drivers/focaltech_moh/focaltech_moh.h index e3efc59a..069ca978 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.h +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.h @@ -32,10 +32,16 @@ G_DECLARE_FINAL_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, FPI, #define FT9201_EP_IN 0x83 /* Bulk IN (EP3, 32B max packet) */ -/* Image: 64 wide x 80 high, 8-bit grayscale */ -#define FT9201_IMG_WIDTH 64 -#define FT9201_IMG_HEIGHT 80 -#define FT9201_IMG_SIZE (FT9201_IMG_WIDTH * FT9201_IMG_HEIGHT) /* 5120 */ +/* Raw sensor image: 64 wide x 80 high, 8-bit grayscale */ +#define FT9201_RAW_WIDTH 64 +#define FT9201_RAW_HEIGHT 80 +#define FT9201_RAW_SIZE (FT9201_RAW_WIDTH * FT9201_RAW_HEIGHT) /* 5120 */ + +/* Upscaled image for NBIS minutiae detection (2x) */ +#define FT9201_UPSCALE 2 +#define FT9201_IMG_WIDTH (FT9201_RAW_WIDTH * FT9201_UPSCALE) /* 128 */ +#define FT9201_IMG_HEIGHT (FT9201_RAW_HEIGHT * FT9201_UPSCALE) /* 160 */ +#define FT9201_IMG_SIZE (FT9201_IMG_WIDTH * FT9201_IMG_HEIGHT) /* 20480 */ #define FT9201_CMD_TIMEOUT 5000 #define FT9201_POLL_INTERVAL 30 /* ms between finger detection polls */ From 77e20b538e8b5be15f2731d5442e2849cd422dfd Mon Sep 17 00:00:00 2001 From: 0xCoDSnet Date: Sun, 15 Mar 2026 15:45:36 +0400 Subject: [PATCH 4/6] focaltech_moh: switch to custom NCC matching for verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NBIS/bozorth3 cannot reliably detect minutiae at the sensor's native resolution (64x80 pixels, ~250 DPI vs 500 DPI required). The Windows driver solves this with a proprietary "mayflower" matching engine. Replace FpImageDevice (NBIS-based) with FpDevice implementing custom pixel-correlation matching: - Preprocessing: bitwise NOT + local mean subtraction (7x7 high-pass filter) to enhance ridge/valley contrast - Enrollment: store 5 preprocessed images as GVariant templates - Verification: Normalized Cross-Correlation (NCC) with translation search (±3 pixels, 49 positions per template) - NCC threshold: 0.30 (same finger: 0.31-0.47, different: 0.05-0.29) Tested with fprintd-enroll, fprintd-verify, and GNOME lock screen. --- .../drivers/focaltech_moh/focaltech_moh.c | 556 +++++++++++++----- .../drivers/focaltech_moh/focaltech_moh.h | 52 +- 2 files changed, 433 insertions(+), 175 deletions(-) diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.c b/libfprint/drivers/focaltech_moh/focaltech_moh.c index 704e4332..3088b5ea 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.c +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.c @@ -1,7 +1,7 @@ /* * FocalTech FT9201 Match-on-Host driver for libfprint * - * Copyright (C) 2025 libfprint contributors + * Copyright (C) 2025-2026 0xCoDSnet * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -22,25 +22,14 @@ * FocalTech FT9201 (chip FT9338, VID:2808 PID:9338) * * Area fingerprint sensor with USB SIU (Serial Interface Unit) bridge. - * 64×80 pixels, 8-bit grayscale, match-on-host. + * 64x80 pixels, 8-bit grayscale, match-on-host. * - * MCU has ROM firmware — no firmware upload needed. The SIU uses a - * "New SIU" protocol with compound register addresses. + * The sensor resolution (~250 DPI) is too low for NBIS/bozorth3 minutiae + * matching, so this driver implements custom pixel-correlation matching + * using Normalized Cross-Correlation (NCC) with translation search. * - * Read sequence (3 control OUTs + 1 bulk IN): - * 1. OUT req=0x34 wValue=0x00FF (prepare init) - * 2. OUT req=0x34 wValue=0x0003 (prepare read mode) - * 3. OUT req=0x6F wValue=size wIndex=compound_addr (configure) - * 4. Bulk IN on EP3 (read data) - * - * Compound addresses: - * 0x9180 — chip status / OTP info - * 0x9080 — image capture (5120 bytes) - * 0xFF00 — sync / reset (size=0, no bulk IN) - * - * Important: after USB reset or first enumeration, the first bulk IN - * read returns garbage (all 0x02). A warmup read must be performed - * and its result discarded. + * The Windows driver uses a similar approach: proprietary "mayflower" + * matching engine with Gabor filter preprocessing. */ #define FP_COMPONENT "focaltech_moh" @@ -48,15 +37,142 @@ #include "drivers_api.h" #include "focaltech_moh.h" +#include + G_DEFINE_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, - FP_TYPE_IMAGE_DEVICE) + FP_TYPE_DEVICE) static const FpIdEntry id_table[] = { { .vid = FT9201_VID, .pid = FT9201_PID }, { .vid = 0, .pid = 0 }, }; -/* ─── Helper: send vendor control OUT ──────────────────────────── */ +/* ------------------------------------------------------------------ */ +/* Image preprocessing */ +/* ------------------------------------------------------------------ */ + +static void +ft9201_preprocess (const guint8 *src, guint8 *dst) +{ + int w = FT9201_RAW_WIDTH; + int h = FT9201_RAW_HEIGHT; + int half = FT9201_LOCAL_MEAN_WINDOW / 2; + int x, y, kx, ky; + + for (y = 0; y < h; y++) + { + for (x = 0; x < w; x++) + { + /* Bitwise NOT — matches Windows driver ~pixel inversion */ + int val = ~src[y * w + x] & 0xFF; + + /* Local mean subtraction (high-pass filter) */ + int sum = 0; + int count = 0; + + for (ky = MAX (0, y - half); ky <= MIN (h - 1, y + half); ky++) + for (kx = MAX (0, x - half); kx <= MIN (w - 1, x + half); kx++) + { + sum += ~src[ky * w + kx] & 0xFF; + count++; + } + + int diff = val - sum / count + 128; + + dst[y * w + x] = (guint8) CLAMP (diff, 0, 255); + } + } +} + +static int +count_unique_values (const guint8 *data, int size) +{ + gboolean seen[256] = { FALSE, }; + int unique = 0; + int i; + + for (i = 0; i < size; i++) + { + if (!seen[data[i]]) + { + seen[data[i]] = TRUE; + unique++; + } + } + + return unique; +} + +/* ------------------------------------------------------------------ */ +/* NCC matching */ +/* ------------------------------------------------------------------ */ + +static double +ft9201_ncc (const guint8 *a, const guint8 *b, int dx, int dy) +{ + int w = FT9201_RAW_WIDTH; + int h = FT9201_RAW_HEIGHT; + int x0 = MAX (0, -dx), x1 = MIN (w, w - dx); + int y0 = MAX (0, -dy), y1 = MIN (h, h - dy); + int n = (x1 - x0) * (y1 - y0); + double sum_a = 0, sum_b = 0; + double mean_a, mean_b; + double num = 0, denom_a = 0, denom_b = 0, denom; + int x, y; + + if (n < w * h / 2) + return -1.0; + + for (y = y0; y < y1; y++) + for (x = x0; x < x1; x++) + { + sum_a += a[y * w + x]; + sum_b += b[(y + dy) * w + (x + dx)]; + } + + mean_a = sum_a / n; + mean_b = sum_b / n; + + for (y = y0; y < y1; y++) + for (x = x0; x < x1; x++) + { + double da = a[y * w + x] - mean_a; + double db = b[(y + dy) * w + (x + dx)] - mean_b; + + num += da * db; + denom_a += da * da; + denom_b += db * db; + } + + denom = sqrt (denom_a * denom_b); + if (denom < 1e-6) + return 0.0; + + return num / denom; +} + +static double +ft9201_match_score (const guint8 *tmpl, const guint8 *probe) +{ + int r = FT9201_SEARCH_RADIUS; + double best = -1.0; + int dx, dy; + + for (dy = -r; dy <= r; dy++) + for (dx = -r; dx <= r; dx++) + { + double score = ft9201_ncc (tmpl, probe, dx, dy); + + if (score > best) + best = score; + } + + return best; +} + +/* ------------------------------------------------------------------ */ +/* USB helper: send vendor control OUT */ +/* ------------------------------------------------------------------ */ static void ft9201_ctrl_out (FpDevice *dev, @@ -77,7 +193,9 @@ ft9201_ctrl_out (FpDevice *dev, fpi_ssm_usb_transfer_cb, NULL); } -/* ─── Capture state machine ────────────────────────────────────── */ +/* ------------------------------------------------------------------ */ +/* Capture state machine (used as sub-SSM) */ +/* ------------------------------------------------------------------ */ static void capture_read_cb (FpiUsbTransfer *transfer, @@ -96,32 +214,31 @@ capture_read_cb (FpiUsbTransfer *transfer, static void finger_poll_cb (FpiUsbTransfer *transfer, - FpDevice *dev, - gpointer user_data, - GError *error) + FpDevice *dev, + gpointer user_data, + GError *error) { - FpImageDevice *img_dev = FP_IMAGE_DEVICE (dev); - if (error) { fpi_ssm_mark_failed (transfer->ssm, error); return; } - /* INT_STATUS byte 0: 0x00 = no finger, 0x01 = finger present */ fp_dbg ("INT_STATUS: 0x%02x 0x%02x 0x%02x 0x%02x (len=%zu)", transfer->buffer[0], transfer->buffer[1], transfer->buffer[2], transfer->buffer[3], transfer->actual_length); + if (transfer->buffer[0] == 0x01) { fp_dbg ("Finger detected!"); - fpi_image_device_report_finger_status (img_dev, TRUE); + fpi_device_report_finger_status_changes (dev, + FP_FINGER_STATUS_PRESENT, + FP_FINGER_STATUS_NONE); fpi_ssm_next_state (transfer->ssm); } else { - /* No finger — retry same state after delay */ fpi_ssm_jump_to_state_delayed (transfer->ssm, CAPTURE_POLL_FINGER, FT9201_POLL_INTERVAL); } @@ -135,15 +252,7 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) switch (state) { - /* - * Warmup: first bulk read after reset returns garbage. - * Skip if already done. - */ case CAPTURE_WARMUP_PREP1: - /* Only do warmup on first cycle (after USB reset). - * On subsequent cycles, skip directly to finger polling. - * The warmup bulk read on second+ cycle can consume stale - * data and corrupt the pipe state. */ if (self->warmup_done) { fpi_ssm_jump_to_state (ssm, CAPTURE_POLL_FINGER); @@ -174,7 +283,6 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) } break; - /* Finger detection: poll INT_STATUS (0x43) */ case CAPTURE_POLL_FINGER: { FpiUsbTransfer *transfer = fpi_usb_transfer_new (dev); @@ -190,7 +298,6 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) } break; - /* Sync: poke 0xFF00 (no bulk read) */ case CAPTURE_SYNC_PREP1: ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); break; @@ -203,7 +310,6 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) ft9201_ctrl_out (dev, ssm, FT9201_REQ_NEW_SIU_RW, 0, FT9201_REG_SYNC); break; - /* Status: read 4 bytes from 0x9180 */ case CAPTURE_STATUS_PREP1: ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); break; @@ -228,7 +334,6 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) } break; - /* Image capture: 5120 bytes from 0x9080 */ case CAPTURE_IMG_PREP1: ft9201_ctrl_out (dev, ssm, FT9201_REQ_PREPARE, FT9201_PREPARE_INIT, 0); break; @@ -261,125 +366,274 @@ capture_ssm_handler (FpiSsm *ssm, FpDevice *dev) } } +/* ------------------------------------------------------------------ */ +/* Enroll state machine */ +/* ------------------------------------------------------------------ */ + static void -capture_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) +enroll_ssm_handler (FpiSsm *ssm, FpDevice *dev) { - FpImageDevice *img_dev = FP_IMAGE_DEVICE (dev); FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); - FpImage *image; + int state = fpi_ssm_get_cur_state (ssm); - self->capture_ssm = NULL; - - if (self->deactivating) + switch (state) { - g_clear_error (&error); - fpi_image_device_deactivate_complete (img_dev, NULL); - return; - } - - if (error) - { - fpi_image_device_session_error (img_dev, error); - return; - } - - /* Check if image has meaningful data */ - { - gboolean seen[256] = { FALSE, }; - int unique = 0; - int i; - - for (i = 0; i < FT9201_RAW_SIZE; i++) + case ENROLL_CAPTURE: { - if (!seen[self->image_buf[i]]) + FpiSsm *capture = fpi_ssm_new (dev, capture_ssm_handler, + CAPTURE_NUM_STATES); + + fpi_device_report_finger_status_changes (dev, + FP_FINGER_STATUS_NEEDED, + FP_FINGER_STATUS_NONE); + fpi_ssm_start_subsm (ssm, capture); + } + break; + + case ENROLL_STORE_IMAGE: + { + int unique; + guint8 preprocessed[FT9201_RAW_SIZE]; + + fpi_device_report_finger_status_changes (dev, + FP_FINGER_STATUS_NONE, + FP_FINGER_STATUS_PRESENT); + + unique = count_unique_values (self->image_buf, FT9201_RAW_SIZE); + fp_dbg ("Enroll stage %d: %d unique values", self->enroll_stage, unique); + + if (unique < FT9201_MIN_UNIQUE_VALUES) { - seen[self->image_buf[i]] = TRUE; - unique++; + fp_dbg ("Low quality image, retrying"); + fpi_device_enroll_progress (dev, self->enroll_stage, NULL, + fpi_device_retry_new (FP_DEVICE_RETRY_CENTER_FINGER)); + fpi_ssm_jump_to_state (ssm, ENROLL_CAPTURE); + return; } + + ft9201_preprocess (self->image_buf, preprocessed); + memcpy (self->enroll_images[self->enroll_stage], preprocessed, + FT9201_RAW_SIZE); + + self->enroll_stage++; + fp_dbg ("Enroll stage %d/%d completed", + self->enroll_stage, FT9201_NUM_ENROLL_STAGES); + + fpi_device_enroll_progress (dev, self->enroll_stage, NULL, NULL); + + if (self->enroll_stage < FT9201_NUM_ENROLL_STAGES) + fpi_ssm_jump_to_state (ssm, ENROLL_CAPTURE); + else + fpi_ssm_next_state (ssm); } + break; - fp_dbg ("Image quality: %d unique values", unique); - - if (unique < 50) + case ENROLL_COMMIT: { - fp_dbg ("Skipping low-quality image (%d unique values)", unique); - goto restart_capture; + FpPrint *print = NULL; + GVariantBuilder builder; + GVariant *data; + int i; + + fpi_device_get_enroll_data (dev, &print); + + g_variant_builder_init (&builder, G_VARIANT_TYPE ("a(ay)")); + for (i = 0; i < FT9201_NUM_ENROLL_STAGES; i++) + { + GVariant *img = g_variant_new_fixed_array ( + G_VARIANT_TYPE_BYTE, + self->enroll_images[i], FT9201_RAW_SIZE, 1); + + g_variant_builder_add (&builder, "(@ay)", img); + } + data = g_variant_new ("(ya(ay))", (guint8) 1, &builder); + + fpi_print_set_type (print, FPI_PRINT_RAW); + g_object_set (print, "fpi-data", data, NULL); + + fp_info ("Enrollment complete, %d templates stored", + FT9201_NUM_ENROLL_STAGES); + + fpi_device_enroll_complete (dev, g_object_ref (print), NULL); + fpi_ssm_mark_completed (ssm); } - } + break; - /* No contrast normalization — pass raw sensor data as-is. - * The FPI_IMAGE_COLORS_INVERTED flag tells libfprint to invert pixels - * before NBIS processing (matching what the Windows driver does with ~pixel). - * NBIS handles binarization internally. */ - - /* Upscale 2x with nearest-neighbor for better minutiae detection */ - image = fp_image_new (FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); - { - int x, y; - - for (y = 0; y < FT9201_IMG_HEIGHT; y++) - for (x = 0; x < FT9201_IMG_WIDTH; x++) - image->data[y * FT9201_IMG_WIDTH + x] = - self->image_buf[(y / FT9201_UPSCALE) * FT9201_RAW_WIDTH + - (x / FT9201_UPSCALE)]; - } - image->flags = FPI_IMAGE_V_FLIPPED | FPI_IMAGE_COLORS_INVERTED; - - fp_dbg ("Image captured and upscaled to %dx%d", - FT9201_IMG_WIDTH, FT9201_IMG_HEIGHT); - - /* finger_on already reported in finger_poll_cb */ - fpi_image_device_image_captured (img_dev, image); - fpi_image_device_report_finger_status (img_dev, FALSE); - - return; - -restart_capture: - /* Image was blank/warmup — restart capture to try again. - * Don't restart after a successful image_captured() — the - * framework will call dev_activate() when it needs another image. */ - if (!self->deactivating) - { - self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, - CAPTURE_NUM_STATES); - fpi_ssm_start (self->capture_ssm, capture_ssm_complete); + default: + g_assert_not_reached (); } } -/* ─── Device lifecycle ─────────────────────────────────────────── */ +static void +enroll_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + + self->task_ssm = NULL; + + if (error) + fpi_device_enroll_complete (dev, NULL, error); +} + +/* ------------------------------------------------------------------ */ +/* Verify state machine */ +/* ------------------------------------------------------------------ */ static void -dev_open (FpImageDevice *dev) +verify_ssm_handler (FpiSsm *ssm, FpDevice *dev) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + int state = fpi_ssm_get_cur_state (ssm); + + switch (state) + { + case VERIFY_CAPTURE: + { + FpiSsm *capture = fpi_ssm_new (dev, capture_ssm_handler, + CAPTURE_NUM_STATES); + + fpi_device_report_finger_status_changes (dev, + FP_FINGER_STATUS_NEEDED, + FP_FINGER_STATUS_NONE); + fpi_ssm_start_subsm (ssm, capture); + } + break; + + case VERIFY_MATCH: + { + FpPrint *print = NULL; + g_autoptr(GVariant) var_data = NULL; + g_autoptr(GVariant) var_images = NULL; + guint8 preprocessed[FT9201_RAW_SIZE]; + guint8 version; + double best_score = -1.0; + GVariantIter iter; + GVariant *img_var; + int unique; + int tmpl_idx = 0; + + fpi_device_report_finger_status_changes (dev, + FP_FINGER_STATUS_NONE, + FP_FINGER_STATUS_PRESENT); + + unique = count_unique_values (self->image_buf, FT9201_RAW_SIZE); + fp_dbg ("Verify: %d unique values", unique); + + if (unique < FT9201_MIN_UNIQUE_VALUES) + { + fp_dbg ("Low quality verify image, retrying"); + fpi_device_verify_report (dev, FPI_MATCH_ERROR, NULL, + fpi_device_retry_new (FP_DEVICE_RETRY_CENTER_FINGER)); + fpi_device_verify_complete (dev, NULL); + fpi_ssm_mark_completed (ssm); + return; + } + + ft9201_preprocess (self->image_buf, preprocessed); + + fpi_device_get_verify_data (dev, &print); + g_object_get (print, "fpi-data", &var_data, NULL); + + if (!g_variant_check_format_string (var_data, "(ya(ay))", FALSE)) + { + fpi_device_verify_report (dev, FPI_MATCH_ERROR, NULL, + fpi_device_error_new (FP_DEVICE_ERROR_DATA_INVALID)); + fpi_device_verify_complete (dev, NULL); + fpi_ssm_mark_completed (ssm); + return; + } + + g_variant_get (var_data, "(y@a(ay))", &version, &var_images); + fp_dbg ("Template version: %d", version); + + g_variant_iter_init (&iter, var_images); + while ((img_var = g_variant_iter_next_value (&iter)) != NULL) + { + g_autoptr(GVariant) inner = NULL; + const guint8 *tmpl_data; + gsize tmpl_len; + + g_variant_get (img_var, "(@ay)", &inner); + tmpl_data = g_variant_get_fixed_array (inner, &tmpl_len, 1); + + if (tmpl_len == FT9201_RAW_SIZE) + { + double score = ft9201_match_score (tmpl_data, preprocessed); + + fp_dbg ("NCC template %d: %.4f", tmpl_idx, score); + if (score > best_score) + best_score = score; + } + + g_variant_unref (img_var); + tmpl_idx++; + } + + fp_info ("Best NCC score: %.4f (threshold: %.2f)", + best_score, FT9201_NCC_THRESHOLD); + + if (best_score >= FT9201_NCC_THRESHOLD) + fpi_device_verify_report (dev, FPI_MATCH_SUCCESS, print, NULL); + else + fpi_device_verify_report (dev, FPI_MATCH_FAIL, NULL, NULL); + + fpi_device_verify_complete (dev, NULL); + fpi_ssm_mark_completed (ssm); + } + break; + + default: + g_assert_not_reached (); + } +} + +static void +verify_ssm_complete (FpiSsm *ssm, FpDevice *dev, GError *error) +{ + FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); + + self->task_ssm = NULL; + + if (error) + { + fpi_device_verify_report (dev, FPI_MATCH_ERROR, NULL, error); + fpi_device_verify_complete (dev, NULL); + } +} + +/* ------------------------------------------------------------------ */ +/* Device lifecycle */ +/* ------------------------------------------------------------------ */ + +static void +dev_open (FpDevice *dev) { FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); GError *error = NULL; G_DEBUG_HERE (); - /* Reset USB device to clear any stuck bulk IN pipe state. - * Without this, the first bulk read may timeout if the pipe - * was left in a bad state from a previous session. */ - if (!g_usb_device_reset (fpi_device_get_usb_device (FP_DEVICE (dev)), &error)) + if (!g_usb_device_reset (fpi_device_get_usb_device (dev), &error)) { fp_dbg ("USB reset failed (non-fatal): %s", error->message); g_clear_error (&error); } - if (!g_usb_device_claim_interface (fpi_device_get_usb_device (FP_DEVICE (dev)), + if (!g_usb_device_claim_interface (fpi_device_get_usb_device (dev), 0, 0, &error)) { - fpi_image_device_open_complete (dev, error); + fpi_device_open_complete (dev, error); return; } self->image_buf = g_malloc0 (FT9201_RAW_SIZE); self->warmup_done = FALSE; - fpi_image_device_open_complete (dev, NULL); + fpi_device_open_complete (dev, NULL); } static void -dev_close (FpImageDevice *dev) +dev_close (FpDevice *dev) { FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); GError *error = NULL; @@ -388,52 +642,37 @@ dev_close (FpImageDevice *dev) g_clear_pointer (&self->image_buf, g_free); - g_usb_device_release_interface (fpi_device_get_usb_device (FP_DEVICE (dev)), + g_usb_device_release_interface (fpi_device_get_usb_device (dev), 0, 0, &error); - fpi_image_device_close_complete (dev, error); + fpi_device_close_complete (dev, error); } +/* ------------------------------------------------------------------ */ +/* Enroll / Verify entry points */ +/* ------------------------------------------------------------------ */ + static void -dev_activate (FpImageDevice *dev) +focaltech_moh_enroll (FpDevice *dev) { FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); - G_DEBUG_HERE (); - - self->deactivating = FALSE; - - /* Don't start SSM here — it will be started by dev_change_state() - * when the framework transitions to AWAIT_FINGER_ON. */ - fpi_image_device_activate_complete (dev, NULL); + self->enroll_stage = 0; + self->task_ssm = fpi_ssm_new (dev, enroll_ssm_handler, ENROLL_NUM_STATES); + fpi_ssm_start (self->task_ssm, enroll_ssm_complete); } static void -dev_change_state (FpImageDevice *dev, FpiImageDeviceState state) +focaltech_moh_verify (FpDevice *dev) { FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); - if (state == FPI_IMAGE_DEVICE_STATE_AWAIT_FINGER_ON) - { - self->capture_ssm = fpi_ssm_new (FP_DEVICE (dev), capture_ssm_handler, - CAPTURE_NUM_STATES); - fpi_ssm_start (self->capture_ssm, capture_ssm_complete); - } + self->task_ssm = fpi_ssm_new (dev, verify_ssm_handler, VERIFY_NUM_STATES); + fpi_ssm_start (self->task_ssm, verify_ssm_complete); } -static void -dev_deactivate (FpImageDevice *dev) -{ - FpiDeviceFocaltechMoh *self = FPI_DEVICE_FOCALTECH_MOH (dev); - - G_DEBUG_HERE (); - - if (!self->capture_ssm) - fpi_image_device_deactivate_complete (dev, NULL); - else - self->deactivating = TRUE; -} - -/* ─── GType boilerplate ────────────────────────────────────────── */ +/* ------------------------------------------------------------------ */ +/* GType boilerplate */ +/* ------------------------------------------------------------------ */ static void fpi_device_focaltech_moh_init (FpiDeviceFocaltechMoh *self) @@ -444,21 +683,18 @@ static void fpi_device_focaltech_moh_class_init (FpiDeviceFocaltechMohClass *klass) { FpDeviceClass *dev_class = FP_DEVICE_CLASS (klass); - FpImageDeviceClass *img_class = FP_IMAGE_DEVICE_CLASS (klass); dev_class->id = "focaltech_moh"; dev_class->full_name = "FocalTech FT9201 Fingerprint Sensor"; dev_class->type = FP_DEVICE_TYPE_USB; dev_class->scan_type = FP_SCAN_TYPE_PRESS; dev_class->id_table = id_table; + dev_class->nr_enroll_stages = FT9201_NUM_ENROLL_STAGES; - img_class->img_open = dev_open; - img_class->img_close = dev_close; - img_class->activate = dev_activate; - img_class->change_state = dev_change_state; - img_class->deactivate = dev_deactivate; + dev_class->open = dev_open; + dev_class->close = dev_close; + dev_class->enroll = focaltech_moh_enroll; + dev_class->verify = focaltech_moh_verify; - img_class->img_width = FT9201_IMG_WIDTH; - img_class->img_height = FT9201_IMG_HEIGHT; - img_class->bz3_threshold = 12; + fpi_device_class_auto_initialize_features (dev_class); } diff --git a/libfprint/drivers/focaltech_moh/focaltech_moh.h b/libfprint/drivers/focaltech_moh/focaltech_moh.h index 069ca978..31161544 100644 --- a/libfprint/drivers/focaltech_moh/focaltech_moh.h +++ b/libfprint/drivers/focaltech_moh/focaltech_moh.h @@ -1,7 +1,7 @@ /* * FocalTech FT9201 Match-on-Host driver for libfprint * - * Copyright (C) 2025 libfprint contributors + * Copyright (C) 2025-2026 0xCoDSnet * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -22,10 +22,9 @@ #include "fpi-device.h" #include "fpi-ssm.h" -#include "fpi-image-device.h" G_DECLARE_FINAL_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, FPI, - DEVICE_FOCALTECH_MOH, FpImageDevice) + DEVICE_FOCALTECH_MOH, FpDevice) #define FT9201_VID 0x2808 #define FT9201_PID 0x9338 @@ -37,15 +36,16 @@ G_DECLARE_FINAL_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, FPI, #define FT9201_RAW_HEIGHT 80 #define FT9201_RAW_SIZE (FT9201_RAW_WIDTH * FT9201_RAW_HEIGHT) /* 5120 */ -/* Upscaled image for NBIS minutiae detection (2x) */ -#define FT9201_UPSCALE 2 -#define FT9201_IMG_WIDTH (FT9201_RAW_WIDTH * FT9201_UPSCALE) /* 128 */ -#define FT9201_IMG_HEIGHT (FT9201_RAW_HEIGHT * FT9201_UPSCALE) /* 160 */ -#define FT9201_IMG_SIZE (FT9201_IMG_WIDTH * FT9201_IMG_HEIGHT) /* 20480 */ - #define FT9201_CMD_TIMEOUT 5000 #define FT9201_POLL_INTERVAL 30 /* ms between finger detection polls */ +/* Enrollment and matching */ +#define FT9201_NUM_ENROLL_STAGES 5 +#define FT9201_NCC_THRESHOLD 0.30 +#define FT9201_SEARCH_RADIUS 3 /* pixels, each direction */ +#define FT9201_LOCAL_MEAN_WINDOW 7 /* 7x7 window for high-pass */ +#define FT9201_MIN_UNIQUE_VALUES 50 /* minimum unique pixel values for quality */ + /* USB vendor request codes */ #define FT9201_REQ_PREPARE 0x34 #define FT9201_REQ_INT_STATUS 0x43 @@ -63,8 +63,10 @@ G_DECLARE_FINAL_TYPE (FpiDeviceFocaltechMoh, fpi_device_focaltech_moh, FPI, /* * Capture state machine — one state per async USB transfer. * - * The read sequence is: PREPARE_INIT → PREPARE_READ → NEW_SIU_RW → BULK_IN. + * The read sequence is: PREPARE_INIT -> PREPARE_READ -> NEW_SIU_RW -> BULK_IN. * Each is a separate async transfer, so each gets its own SSM state. + * + * This SSM is used as a sub-SSM within enroll and verify SSMs. */ enum capture_states { /* Warmup: discard first bulk read after USB reset */ @@ -74,12 +76,12 @@ enum capture_states { CAPTURE_WARMUP_READ, /* BULK IN 32B (discard) */ /* Finger detection: poll INT_STATUS until finger present */ - CAPTURE_POLL_FINGER, /* IN 0x43 — byte0: 0=no finger, 1=finger */ + CAPTURE_POLL_FINGER, /* IN 0x43 -- byte0: 0=no finger, 1=finger */ /* Sync: poke 0xFF00 */ CAPTURE_SYNC_PREP1, /* OUT 0x34(0xFF) */ CAPTURE_SYNC_PREP2, /* OUT 0x34(3) */ - CAPTURE_SYNC_CMD, /* OUT 0x6F(0, 0xFF00) — no bulk */ + CAPTURE_SYNC_CMD, /* OUT 0x6F(0, 0xFF00) -- no bulk */ /* Status: read 4 bytes from 0x9180 */ CAPTURE_STATUS_PREP1, /* OUT 0x34(0xFF) */ @@ -96,12 +98,32 @@ enum capture_states { CAPTURE_NUM_STATES, }; +/* Enroll SSM: captures 5 images, stores as template */ +enum enroll_states { + ENROLL_CAPTURE, /* Sub-SSM: full capture cycle */ + ENROLL_STORE_IMAGE, /* Preprocess + store in template array */ + ENROLL_COMMIT, /* Serialize to GVariant, complete enrollment */ + ENROLL_NUM_STATES, +}; + +/* Verify SSM: captures 1 image, matches against stored template */ +enum verify_states { + VERIFY_CAPTURE, /* Sub-SSM: full capture cycle */ + VERIFY_MATCH, /* NCC matching against stored templates */ + VERIFY_NUM_STATES, +}; + struct _FpiDeviceFocaltechMoh { - FpImageDevice parent; + FpDevice parent; - gboolean deactivating; gboolean warmup_done; - FpiSsm *capture_ssm; guint8 *image_buf; + + /* Enroll state */ + int enroll_stage; + guint8 enroll_images[FT9201_NUM_ENROLL_STAGES][FT9201_RAW_SIZE]; + + /* Top-level SSM */ + FpiSsm *task_ssm; }; From a442f3fbb5273bea32a6c393fd7423879c4bd44a Mon Sep 17 00:00:00 2001 From: 0xCoDSnet Date: Sun, 15 Mar 2026 17:52:42 +0400 Subject: [PATCH 5/6] focaltech_moh: add umockdev test for enroll and verify --- tests/focaltech_moh/custom.pcapng | Bin 0 -> 61620 bytes tests/focaltech_moh/custom.py | 54 +++++ tests/focaltech_moh/device | 336 ++++++++++++++++++++++++++++++ tests/meson.build | 1 + 4 files changed, 391 insertions(+) create mode 100644 tests/focaltech_moh/custom.pcapng create mode 100755 tests/focaltech_moh/custom.py create mode 100644 tests/focaltech_moh/device diff --git a/tests/focaltech_moh/custom.pcapng b/tests/focaltech_moh/custom.pcapng new file mode 100644 index 0000000000000000000000000000000000000000..bbb4eb0b73fdb52a9f5cec6943e9670f7117b029 GIT binary patch literal 61620 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%0DaY?EHVGIKfBG*P_+| z`|uE?Nkt;*c=C5Ha*g$V^U;4-RuNSAWSbqGTVEUEe=CvAVjvxwAMk z-kcrhVr8YM$VyFk|Hl1iz@;L)AT=q%R`xOGRSk=l-GYLgW zS&`>21cZgz+2}cB4IBbeD|*MKfh*tshvnJfrrP3ScXw%yM|d}`Qb;;SrR74i{Ox3T zY4GmfxrK>GLqtGAL5O|r@(s)fH?Ca3CSVX$Hw(^h?U`QN-rfGVJvZLp-JPE9`dWbY z@e^uEo3JbhG}X@tY@6j}d(KSrl#P~z2>14tql^M!{I3(Th#CavcMQyK@9yq^l}NBt zwW%r3-&Ti*n@8bo5Llwj4Ad2Ssq{u$PhC|>M?sdKmG%iP2`@r{-oAH)3{M=q^DU&Lir zcP;3CzYP2#_qMjb?Cy?_cegcG7w0A>L8_WMyP7JJ6C$FMqLR|ma|((e@i88@dapQ$ zFC2;Z3xi(OHM@Ceacgr6xO7ggt~NB(!{M#%jrH)>&aTdZrkaw3KtEqs-=NTtENE_K zTC}~1mN@79#zVLo^!qitektRX9^rls`qrG%s;FDX93 zqo;gE!H}x@_Tk~l@tM_$2@MT#SxH@Ud+($aSQ7{q)s*L_&P9O!^G|=_(+G&X;^kzf z6OuPDc6wuJtjW$qKu9BD99mr6()NCEe06hsBT{TP_DxJJEGQ{)KBFLb{NVP3r{Zqu6-{l8b+FpvQdn75ypM~9 z7}H~75Ka)CoP}RO-^9*JQ%d2DOGrp?kZ)>HQ|ACk-@rgxn3?_Y^*{c4z-JwoTT%>x zWJafFm()~LLP6-Ew{P^-q(vE6C}=o^)igB?)eUXzU7T#Z9K55UU44^FOOcV@4u(oE zDAE5W;Ij_R%Fm4pcX4rw%YeOW?`W&9$WBTEE-PmI&!3BmN{NZ7=)SeGHr6*Vc;gaR z+%vYcwUv{T5$k5IKzr{FA-AD-WJZ*yjS`=rfqPs@ePbht4Om&8pXP6=CN9jwM04*U zC9i^t6tAF&l$LQI2xv7uH&<1ao8fD!#`NGCHp^?z=-5za9nL2NtV*^)iN#H=z&*IF zDL*yPPD@Ram-^w|C%i8uxhaWh*~HbH;tRTlr>EiY)G#k2^%wMyo(k*wMkI!N>F|)< zz$E5Vb_hw%t7>U#X=p5rkMgs7BPa5LgHuXDnVS~l7Cx1bu0u+7%j6_zaw6OebmRni zC3Rf`k`p7`HMs8m`V-n!0*04H?xE=wu(qD&>Y}357;k46ySEN57P?&6*Dqj_a>!Vv zw+s%#;c3ynw)(odx)!b>X|bvCZW>&eU@7sZzi-~BlTde#gF>6y>KodtD~ikV^O94N zB3+fJu3S8iewR_vH>0hst}ZV+(8vLJE|W@{24xqOmsi57S_k|42Kt*|39eck)I|4b_;h?Bki^7bD@|ExX)O@r z&pj?PGbvDCkO;Yp;a9Z3@wp9MLQ|4rk}`5i>)#EJ4vloyK)g&OdD#Wz&HW&dkPtUb zNzNC%+|Rj04E?hq*BvzcDdx+{Ghim5@`Ec*)7YENvQA zSlib(K0ef0Uzi;39UPii)Yhh@r6MjSM0fw%Wo%*ri_rAkv=DP?8uSa7uVdidxr1@@ zA)SD@6c^Rqt5}2r2GON$J^e!yBcrvj()^^$I zsIw4aqN8IyxP9a5Eiw*iS$XbfSXZxO(5iZ*SJZS3O$_(;bT+~Y3d?IdIt)QzA1mXR zjQ1`RP)J#MC8k8jdgzLi-hYURiE$JAA<+w2Q$rnP5e7m$A{Hf^=*(hRQ)f^2KzGNx z=DOCt-d-;+M^7(%3u#6|N*;Ze=NBa48nj+tLXSd8~u>o=yldgdlZ+KO_@y5@GKZ-KLHZb5x_PtW}P?D#}aXIp)B zSy}D7;gO+{{^PXP>~x#N?uayo`hh4_7xQ2Pbm_ut|W0OG2EFQ%KIhB{aLT zqho1lWo&Syue-as>0L{I&(O@+cvpK_dQ@~=2DCV@rlt;FU7Q^o782+iZ``ZWTYR|<&oH#0NU-PiT5vZx4JSqE>MdOtBVP?_OjWfzbFsjX@09BFUsEGbBj zjfsf}3A51>q#_}FaQ*zRzx{=U_vpD=NJwvQQ+-KRMx?ugwP##rY4^ZjXK9|Vf#gd~ zm)MN5`iicOp4Qr$s=|Vz-1G=f&om$hUu(Q3UD%nYzgOQP6Uf01dst8t7U*Fi&*3w#8mgH+DBgn?Z z%Su6f598)N%!lk+TA`sfYBCaBkFb9G<4DNy{iLuBfjsha`F#OE5j5WPMJHdHdG&zka)nK_e;}7w4oQ{+#CVy*m$| zDmx{^n%>pGvcjx1M1{De6b)>B!eg^C^D`>p@XD-MXA^m0c9s_)0@|%>SFith4F|;B zjE!|xnorcp)US@ zkvY)X=67B1I$AqgySjTCo9pTu%Zmy#!hGJSfB`up<dwyq9@D{O1)eAn33)l}D1Rh%AYWuh!4Auc0rWR#m5 zVyYs-LUtA75u=QSUwUN?G%L*Q%}W*{I#IK*w!m0#F&Pad8EFYA zQ7&$tSFf6z3lm~P$?$M6 z?%@((;65OI`jm-VMpBwzL|VF`0hX7MnF`5GOUq74DXDDgXlyIaPK^rm3yMk3D}ol~ zmzO}Id>vj(G0@=OeeekL@5@&)?xGXXF!QM>tLW%-cQ=+-7FN`P4S_|_g39{#zK))z zs-md0OyGx~T~ty~R#BFd9AKd%NdJiJ{@vRbfBEB=%h;De*FYj5t!`m4FwhL#YU^s= zHIxIl`*L_^YyVJZb7e_UNm*VYNIfSfzced8+CdBW1U~^g0`6SDgoQ(N8v_fQnEr*F zp56cmo!r;e+TYyWS=ZFq*xNTSGBz^QR#{hBoS7S+l8_Rg4oQvndaK4oLrTHK`t&LI zmu-|xY;5!_EG(>oy1HXy6GMGN13iNSBYo}f`#O7vr-z5S!6u2^^pr?ncUODAu-I^a zOCuRxdg6OenD_+P=^45B<&{)5Rpk`)?d;~}W=4mGdq>7bC%T45#(GEIkB|3t*1^&e zg1xOx)s$6??Y+D#K4F9+uJKRDQR(WX?k*gd~|wYX<}-A zWOQtHa;&ElUYZRYskP-qSotJD_+u4O5Db};2#=JRpN)m=0qF~6OZTAAn7AY;bai!O zZgqNfeQ{}Zb#iWgdTwEEW^AClG&#`KQcpsFl}EzVT2F)t7ZZ<+hMG-CoRf`?go<5K z&)UV$GblbccXxMdV|969esg_gX>oaKVtjrKcp0}=rG$A{X)1{ch`+Y8RTCk(cZZTq z{H3(A`b$n$TDs=~ugyV#ZcC5&_?;c_Ai2K0w7I!7KLeg9CTCZMCr94ZWJU&A8W`$m z>lwYV(hz5QLjFutMO#lpMVyoL5hc@e8ADxl4I@KOPY|>C)7IAd$_D5N=Vm7+#>N&W zW~TaU@={{_tu3sLt?gW#%r(ThUI?oj+nVXC2~ZFb+$Ln=mzPsj(|zmX19sPfFu+SI zqf2Y^b93Y1kz{URab&c+qA(%M-QCUE#nHjZ-N9U4N6*yW#nD_&kl_LL9h@i3FZJ}y zj9kLQzkKn7xHLO6v9PwZFg8Ef+nAdg;~y9q2;v_4di%J#**n`i1bW!W ziLz2Yyp4-X!Xt0s>;d+De))p%Y2DsgTise(nVnw-_FSBto9e92OOKC92oH(~3kryf z@kboKV0R7}sB`w=1OBm#(?joSbJKuN zcTz@LTx?=WT10qwL{xl`pNo}&ItUynC@QUE6%ZVhl!S=6wz0AOWqW5EM6X?6TLmFp zx4;gFme#5gXnKA&1e%qfpOcr7n^%~TnU)e3=I!$Kt%ae9sfU|yP((}&2v@ed`QgLP z{^r&;@addgU0+|?2YbiB01Z}BRt6gU^1AY(%pypBVPQ#SF(e^2Br+6;3U>AK_YaMY z{rGYB|f*gGI1Dm(koClv&Q{|Lh3?;Y&y0H@Z^8|(A4Q$xLNP0dxX zmWFrD)r~bxm1Pa^+S>AxoZMVUT4H2)cz8%qTuM$3xF19>{Rm$7zpQ=Q-`L#wu)Vah zJl)^g{jRCDw6YckZ>)k<)m9c(6=#+gmqAm(28)=80N-#BVm|Ef{zD31UqA%%58I$V ztSo_%Wk*j}E4-(ty`}YCb4^tVtQJ;NURMDegyUo5qobn|Gcutt*zwIDzwCTC*x6Xy z+}+&$0NRA1&bCId+qk8@t_EIPUJ6Fr@S@t1^1O`Hgur0mK>q;$@Ti2$%)|RZ^wy7` zLFl~APat~Z*6RGy%Jg_|*E_J;psud07z_=wvy#%YA^AD!$(eCsVP1B2dgi89Htvq0 zq4HpE5Ow~we&=NV66rIGx*p9T|Mh=99L+NRE588V|5w^oz(tjIZ@>Mvb#+}6k(QE> zMjE6+x|^Z9ySs<3p*w~cYK9)Vy9Q~H1_?WMb^kBwz&g7B{T8pk5d`O%^W5{^_s092 z=e#|6hL6R^T?ZX7{DAzA&+rAmtjRTZkUv=`!}=oNtmW>`pPqxYyZrGw#IY~J$DWwj zb%=x2uj6p*>0dY;dsFzl@8O00j699g`@Tv2@fQxq{ysE zug@s3R6Nc@>#^6*ryM9Sr|0m}N%1%jSbSU#&P!j%p=tl0IdH3--uI38tXIb05osvkUOPIG?Pd3C}8 zi;v5}4D#V2-D%GcB{s^(c{uU?`Sf_8nK?a&AvfjYJYey0IY_O39fylg{=(sJ&T%p7 zr>{FneDfC$AMtTHT-E=&x|0f9mE$~MuLW?&62t82Ig~l89OnUxkIR8>`Rh28ADnPF zUhl=Z)}U8CeccJ>)d>eIJ}w7k$k)}K4BMz4=iy|{{nO(?dgk;T+}u=;^MJ+2C%4|7aKPf@a>z6My1J7J2lbOY9P6+?9ZL)gr|0mK zm-%H(I#(%?}*jUtEH}{*4y&An;d!~S0-XdmlVs>_ZZFxB) z#6e9+^e(TsfsJoMaTO4m6_w=rSx9rRQ8BP_-`DZZC~K+*4r8vm3Q}@~M2Wt<( zn$^TNUlr* zQs51>C6)OwKL?1Igov_dK5A%j_36uJuYZ5}=<(ynz#r8@nE2YctAzL@boaee^C~JT zN|IeAm~Wh?VWwjd)Cq{q&xXZBMuocAKonGs+>_zmV@vDX&)$IbhbM2|3=I{Bnu}5r z;a$SJLMdqtEiNuA%}chIr6agbc;oVKDv?}c$dh5Lsd>uWnNaKwIVwu>9t>f{FRWDnT7Kn2jlHq^i1pw zbo4wD^49(^L>FfH@HOZ>_vfG6+cW)D2_BlF?6mY$Li%1=i1PBhXlrF*-n*;9wxVaSm%ac;?JmLLr@?;k3ykJ(!DGpUIBg~u1-E~?)K(-(xSZ7M;$CbY~Pa#7{QR3ji-m8i(Pef4GOfw z?P;$^6{p0dmo&5vclIKI9ZWJTGb1%M7haf}7V2qZAje5~3~-&-D7LzPaU1kS{xc#X zI5sU8+0fb9)!kIr(cV4sU}&tPuCOR82?i9gRSn3>^1N7YPa{d@t4HSpuw5k=FpEGA zFYkb+zV9q7AbJKa!AYpr?%siso}sCEupr!0k>ulKV`b$Q11kgqFIc+jorgj%Yn=5IA&>fuBS2G zMSv9V{P`QK;-;PvP~bzXuCJyj!ObVa$qsA>Z?T9NKubFZrq@?j)zyXXFy3P4SA*CG zr4%920~7OO6D{d}^7L2Eesh6e=k{^CJdzFTUg`eXtEfb%rwXb&=Xym9w zLqd2*)Hc1oXLM>|byZW7kA)cTJm_XkN5v+r?wVUQG&?cc+K}dKB*}60%GuxWf4gw` z+I2k2dz!ZHz*NUkgOi+;O5PjZ-rfITWo=DQk010ly8(JvQd2N73+aU;+Q%p7$2)7` z5%#L0OawPU>GXfT`R>elA~rc)Jx3R7BPk|2c1TD8s(AUSox~?4AKRat_ zDa}SpM@~ph!pybqn@IQ zZDeYGNq$M==%}NkJUa~~GbJ6}Z8{PHasl(;y!sBHa73Y+n_62@sM4e$8+~PAK@L_% zdU}2tV@q2bOA}kC5NKimut!3n0s|ppYyyH(N^(MM#6MqP&>Yf=eC@5JxX6L**e4!Y zniuPDs%;aV-P{KJ${zG}RiVmrqAe9fMDB|VatSCwoNdj`AbMtAag~)NC22r46rUO! zY_Gvaaf4CbBRVT3&R0`}gHzKr6%Is9!^2=LwzVKV++0yzT7dQ@@f~3;WhquherW?g zIJ~2yv8E1DmX;juq9(?~BCZpZl3SP*X3Eb%!6R)Fo?h8BGBl1E80)PnNbz&g(-CB& zxhri95n%>i#oGQb7|`N%qCq3qXm2ZB6?qjykLbM8lAK6W$y?w4ihoD!r(zBssv09uHj4 z{PzYew~Cuzc6Hz2=rm?(psl_%r#LGmH!aLnm>lmqqoj=u1~V|&iALn42e>*pd-;c_ z<|8UfG6VH^FTI~Dez-)-rR5Nv*V;3Rc`z~6*9+9F?PwG{-jwGiAvvp(()|2jTUQ4v zKQq+d)6U5|I6jW>TKYxjz$**FO^(!^?*Tw>jY~A@PKj9sKxbLi ziJq98o?Yr2s809P*V8mJ3kq_#F?V#Z*0?V&C9kY3XY7-dSCSQAElhXrdjRmCa~IBD zxXGen9T*l3h5AHi!CU)gW>)4goi*_25Z};HOG^bMAsO&SA$~S?&bzEq#xW&DB?&?5 zEH}>n_ox4Ue+BO*At|ebj)A46j-sr#b5u$DIIuBW06i(|ON)z&)YOD{fMc702tOAc z#Vw-S!iE8Ppl2A=M4aN{ZS(Gc#L012+1W3#u&lXrcx-xMbN%7N%*+^1U;m)c_@uZ1TNMd) z3$Fr1HL5Zb=C3D0f9cBgt5j^N`fg$2(SaUjT5@74W`Usz(CDn38e~`Z*zD3$Z*NC+ zC9)FL+|XE_6dN9z0ITZh8R}{TeeA(S80($8_Y}?S{F77C!aTj4oy@E(eZ3tW>;uB0 zlGC#hUBknxtMl{YQ}YXRGn2#JRTYQ^(7YA6VvhGTRHpcN*_*h!`-Nv@!;7;MBmDwB z13dkG98EP;wLqPPg^fon9KOE3JiocP0{k_imc@1z?jg`Ko1KUb~Z*Kc^M%A2}p48+S=Ok>hi+e!p!87RmVZ5tlJOb)lVwN_OC9Yt+>@FEp(Kn)y0@NxbA6dM@Rekd;57h*qG@^^OD}Y#w;CJ1eyT1)nx&FG`py% zhMiYpYDraVYgd2&F(NmldYRg?VVJ z-{%1~>&y~nktNOj-7ToXU`u5lUNJ=jb0BCfFD@ymZtv`AKty^O%H6-mBO}d8MR%J| zOl)|#5ru$-*eVM#)83}Pb5FxFrMjhgc(50d8|iLhWNc~Y5s^}esH~`I=<4ilEzO8< zGl4)1Ou^A~o0?Nwdupn$t~?>gdL{7M`t!r4=*che{RS@F| zk-x`qi3p!v#55=!^rdJ*!6IFZ6vU;SBTHHvzvB zgoMS#iLRz<&_=+;PCh_+AC8agBWicUE^7pv!saOPMW#;B* z<-iKl;=_XNy`U+@RZUIJ9d%{tG0_}F-* z6c}4l)r3Zr9T&yo4Y}JsEz1`|?^4V`Fny7js)PdoM3ASBwtT z7v*N;z_MXc(1^&`jEaVqu8!KgtXMw}mjJ)$qMG_9L|t}HYN)e;C>I+86B`c)CC5Ec z88I!O#hja)92@KfR}e*rD&PbQFD|J;4-SuZ4FFr$jMS9m3^)qi)`SL0&q)lhkmshS zrKF%Cz{ew^qi49M;_AA(Iy(W>({=TY=#G{q(Dc5kxp!!CZVE{7QK;(L>iW*Eme&5B zzBXi0e2|&=J(}BO6u{;6+>Kj=WGwfrt=HF=W~Qe`2EgIj)7wALJJ3BiF|j!JaAkD3 zudTVQtr>Xxc69XiG&PhadK<{n)3e;6puBqhmn%2O+1dFuHFtI%&d!ccVStu(WOTG2 zjIPmvsRxTQ3lA})gTwu89bhOm0zobcnHA-(Bg9BU%gD+>2P|&yuyXQBKp^Yud!U8# z1JG_7cwvK{(ZFbXdVXbncWG;OdZfFhxwfpJFuxd%C@am5cQTh|rDEZekrv~-$9G>< zUk~{EJ_AzF!;Q7+)rFO{xz&eYCZAj107YWUOVgNvrs}eiyu{dWXnJ;Tb|mQNswT)t zBdDaVsUR(>YTyMW++5q-T79$&Y(^h#0FTJ^nUVg6>U>y;n}?-$ zczQ-kkh2l!oWaS-Bd?*Qz$-4P>780wS^4J8;fsUEo6j~^w)cUOa(idz(bm%1!=3HL znX!(l;`GQM4|8p^(D?M202@s$MOj5@DNQvQF=1|g1-tO9%1U5P`Nzv=dj}hv2ag^D zcTMm}VP^}}n5``?&9$N6X;43RXAkd?q=Y07OML@lV|Z*@Oj<@JsIUx) z3=azr4>8vi=VlcUQL^w!OaxlUci=Yi@agmC2XA&C@2zdG@9eA{9PVvxE>E`BROEw8 zb69$AeqnJI5ORX9_ORG6dmVA6JHWA4-7zr{eEc8J4<9{!c?hJO2YVY^JA2zZ+uM6P z8@mst``c^FGxD+#`9%eYn%eT*w1S+hw3G-36#=e0v|Iw3Zt3Zuee54kp1j)IezbS+ z6!bmWUfJK--P(Hk{BVD9rn{r1yto`(PZm^ElsBWmcO~%R^h7rcbp?TY{IZH(@BK|d z3*Q%jI_MYj?CFbL;IazTs4reVeX=?}*wcinK$U}Tu{EfAG`hB?suEFJm>%TiqN8uD zrePGAm38zC;SJ~$0_3Q`ob~YmU;&(hL7C_N+QU^$-#}e0x)I%uZs}}BcXibRi8#2Y z%8pNqi3|?(4*~gq`}R-JzWMo+C$A4)02w9lIDG;J^!BUQFAr8%mqvRBJDYkshP#G( zS_is2K|x!4YilE_G&e6JEvc}q{Am0fji5i@JOM_xuV4M~X8$SBrM`Ry#J#|{cz%3h z1T@g?A0HbS8)^p+CPv3cJBPcv8tW?x5r`^e?ME%=i+3-62VLl2KY8=|;Pu|i{pU~j z-aXw~USFQ>8|d#F=s{*KN*po~Qn!z(ILV4?-%@6UH{-@F5XzkUAt z^1~U!}vwQmoM|*}ldb`@YySsWjyXqQSDvR^WDk^K6nn3tq z`~ifQe}F!|FJHZW{^Hr;!K0mngN^Owv7x@cmJXn)1!CG(bYnw(DYCY{770Ax^K$dD z3yKl7wO`VLVgkYQMR4KZ z3}5ieTF{H%X`XO6UPt+KEjPyU^uBMl=QK|^{Ee^AV~4NvecSOn;c&8+^(hCl-P3c> z2|3|_#m8M!Upo9c4u|hfIGlL%!Pi;v48F7N9& zh`u=CfYn6datOZp!LQ{s=eVn9bdNb;@o_mQQ-EibUokJWJLw+h0jo{Jv@(WW4KEZE2uKhXl zUPp$-k1NBC|HEAV0p;XxUGOa~J|6UQeBAies!t!k^%wAQ_df#Q<7j}+oHbV&Gv=S*V{376+t@#`uHwPB8r_aH!$P;|*br){@vU9lc|L@1Z z+s4oFapUKCc>4I4n@;et*S@&%6R+aL|1WfuLmi*vO8^6-hW_ql-M!) z1mM4)LrsE7%F>TMjQ^mcEGQ_3ne z?pAMUkfjvAtgNG>rUaj)L11ZZcSCCv0%mcajFg65KvKgd8c|(`tgKH@3UIX60)}(h zO_;^)r$Bx9`rRL|Hs`u>Japt06^)Ek#F+0$x}<|H^X=%WEJsNeN@_-WVp>_N(B#yN z#0YQyFfThJEBioLMHgTIJ|Aq)wl^oy$w4j_7FJeTyey1jw$PG}uI|pV3{PDyI!X$H zi}F@)caMqbo7848_8#H+^G7RFgWcUQm^0+Q zprlS%ZgYE2OIehSlB}GJC^H8a7dtsI=|#Mwr=TAy)y`2%=(y!HOl~~}^2?*0`0O$0 zlit{v7wT$c>JpWQ?&@zY3pdu*v#@gl#vOVFN(%gJY@p}!hX+Sz&YUA=Quioret7tS z!s|c(06NI!<*E9bgv6xMn)XS|WG6fh8l4%R3T!uE>9MhnriQX&)Oa5%7SEjdg_zkq zwtjvCyzTu4{8Zi?92`uJfligZeVCQim4%MR^2)NZs>&L$gjrpgn;7b?f1lyf#|XYT z$Drw1HNJHC>iyfFzk2!fDOmJc+Z>x4nOs-vQiOVzj%|1Mbb2`ylZ-K|H<>`uV3$N&d$Q& zrN#BNt%Gw*n}-|gqa7*k(hN5)6VdZ)_y$Boc-xv-I9VD32PJti&|R03QCurHzhh`& zapUQecTXOH1+&mlFZYP}?5c***_Hk6*?}@|O{VK-zx$SuPg+~wOi@Wh)56NlJJ`p~ zN?nNY)*VS>Fi_`~R(2olKYerfXnR{dZfFH`@*YFydKxkkUHI-7Qbta8 zb{0w!CMip=(CGLWUsEw^0xCXp7@{9DwYvED$susl+1k?9782v;7gUBM)D5h!0Y9Lc z+)z_tTEZ(AiLc@R@*4<*NfBb}0-6%qt1y#M2|1?L^n>N0l}*s1`PsqYAq1kx!^p{X z&oCa|v$D0hur%9K8|Q5-!^J^^_ubJc>D#MxQkvE-UOuko;;hWF&Uy9S{X?CDc=bx|R9W^yuN)AWYXso8~r(c#H{U}93573ioaNk>CWaD#w_ zM^?|)&D9MeFRkemlbxNFQGo1O+;|MEbYA-Sn5pqlUp>d9<^gX5{jYo4&`o_)(-SjY zO{IxJ_9oWW%A)KXLXsNRwhlH{j-H{hiP13;nWY^Qi`y^%eEKvmFVaT#E+Ia(icflN zGrF!QCoTavtBp-A%}w_vBzI{CSIIypGHLbIwzHg=u^C3Ag! z3DjMRm6%rAGO-3#Sq1Yow)BD3^^Rc{=V#`|fJ<*_n6rkgqBuW2tC%9h%357U9b)2| z&@eE&3z|E=ZD}cq_f+5`rNLK_@Vk1yd2w;UszMDhi1h`sY+tSHVNAgxQ%zfstilZmV*MZ248Daz?OIUA`kk+Dge`6btnY&-#zX-i91l&`aqiM4NBX*F1P57!qUxOjn( zU(V1Wt`a>yKMx$5Mtd4cGZLY}zR<*24{0{So3wng4k6V&+mBzp>gqzm;(Wuxq6fT*z(@e#@6zKvEHt_hT6uO7-xBEVj$5Mb4aYith{{L z*qEIZ!DsRpoep;{5i<3nVOx&WR;m^E2z)%d^XqnCTfH6oZHAv*TU9 zc%6*bBBOq2bF-zTEHN4wH^ro*dOEw&d69-Z1Rt*)F5odM+QcA%+-+@X>*3bY_Uh&= zrlT}Ok&WyYIT43;C}LuJ8-+>=a`E=}42{ceYVB#Lh%n+JzVYqR1?rDKfAb5YW?)8n zYxn5*!tBb%9?)EDjvE3Ys((C=hL8S`HM4ZQuE zAAh*;!zC&qn~6sM43oQ?g$wLA!@tOMN#HLRM!ZIt`pNxkdqNJOK3^o=V7B` zb!gxp z7X%SuA*ZLKA*Z>+Ca3S@>u9N}B*ae3Yv7;S*fKP;vbMRo^>A%rdJ40)y}u9MpXzTd z!NVeLA5(;GsYT^SnoHavyG}|(fSJauFE1}H zuPp;7vW||y#*##LQ*)=#@|M01;J2C;Y0OVXL_|uH*XJicONd#%q?%N0RhRu z>S8Yno|lhos&5}30e+h8wWUe9S+E#rs3#CWQnPXhFxtB1SGW2hw1!4}>MoE+!omS@KY+l$NKCFww^Y^}#mM@vS03pjED4YQ;+ zw0dj|xPP@b!eL=y0U0RF(jI6M-IHyvu3{3MR@po>HoLJrH#2@@EQPE_WJEcs3-Qt4 zCc^vW{MG9el>AmHwY9*LI2&pYk>ck!4nqyhZp==0B{<6PO6d3|}hB!Q)-cHPr6&a?=oAzIpQ^8MAIgSy^^=q>HJtAQe85n0ad3+``IaYm%)vE1R^2 zeMn|;eedY>)birw?DTkBQ*p4pwv3jFG#?W&8UFR(t}?3nmX?Nwx>^|svk{U|iMeFf zk3F0iXpS?NWTIq~&^85)ple!yD06IbdZE9qG}~8KT2x9=lA8%!4qm*3Pp4|0nHd@B zY%0r2M@PscACTWR@?g5JCecEeftmwIZfygS3M$L$yLv_@rn*~m!ki#t0+LePj8x>L zmo8sAOTwZZ7#I{}t02xn%g7>O98fkmJ2x}cRuXD1EiSHR;t&>FSX@|-tREkrne0ZU zM+O+HYRReJW240Za!ArnVl;>{Lf%VKOw(!_-Dci=7hx2JyMyzCTaRV`1UztIm22kA(P^prU_4^T^!X z%vg7OWqEc+Zc!z=cVc8_baHfgW_4q3tfRTMFe@V|+CxKXBy#oYFKruJe*4&U6>|~~JUyy?g zT#Z;+d3dO@-nww^mus{lhCxMb?fost($p9a3k$z2V0k+M)b|s>d4CEszX)8?Cgvt5 z`YJLKT=fm)f$1)rq?41oJMhD1A-RrEC1M#ah*U{A8*xS|G z)mmTO)H^sffteb^^fu=t`Fq$q+BjNzMnohec!M7E{B%s*_w|BvD_fBz>A{{F!i?1X z8ve;;_2s2i)fHt$z{4(~2uzQCBZI?(4TUK|!67kGi7^QU1;xcFsR>@%_gGj2AntLM zjkS$Afv!4|{QN@dju9!j<;7Wr8CeP7Ld4c9JgTx1)zjQll@=4^?c?ne6c(M5lA0PB z9OkYgMo-5rZxxL|HCN<7Z8XK0xCA5(q3OlBsp;9U2sZ;oVF_tveVYJiA}l}7)zLs- zT}n|-O~c6vRK@_`LkA;8F$GOGSWQ9fiw>@(EUot zORAb#TbV+XwAHjkgaxHcOgud;t=xk=ob4@bZ2fcU2fLb(g;DMnhNjm3iDk{LUCres zdC9)UN*pxgB&4*U$vD+*5k4_~B~5kB`;x#D7YgBg zBa!v+@}dT0Rat&Ub^XBj?9B9F8@dt&&bXEJD0E*}XG?ufjF%4o9Xi@OR3tzBbonMe z5k3_e6YqU>VB>8&IM~vJZtQMtuS1q0yTKjm*6QeZdwX4VWl3Rf5xl;!zNIlI))OMg z$;i)tpO=A&ij_-%ho4hSL{eVc(9yA_rKhF6bGUm92*kSk#@9F2*QY1DN{bWYVhi3ZN|+VawhhJK0Z!A{1CYJ7b3^xR4cG6DhuGWuR&3E{yJp)s(eBwz;JH$E}B zu=a3nYGiR{YioUFsH-T_U0?CO01G3VjA^K!kFl(TBp=(Iy8_a{iX2=FL)@VmDMcCi zz~`}cX=!d^WMg@02WaQ#=jI^01{Bpq<$PTJUKDG zu&}f|(Oey4FF|(;)Rl>;=vi3mY6_T&MmzHKH zrbnmeW@iT5TG}T-&yw|}(T0KmEq)FrUIBhpMTm*Dt+AGlmAhXQ@MM-`r63}{!zHC@ zZe``_8HYdsPwdW-vB?L(2DTO`4IfObZA=WKvz^qr7--lyg}8;q44hqUO-yZEgQESd z)Wn!?l2Z}Va?6`I`?yEK>g&hGM$sMUL7?<)ZLDwXo0wZz9Pg`&v=Cz?x=zi`DK4sL zVC`vZ>g4PL4GVNMkP>8JV`63%QnmF74UJ7HFUMdefZz4l#OPReUw7Z=(#-Dq>Tp?t zxhx&oP3k)$3R*^%jsXF7e*Vy;jOZXoV~C8DxP+LHvLiG)C@`+5XmWCVXk>nRc5Jk_ zy{mnEW_fLCv9B`PMO%)Aky}Q`%GUbmJ{UOA#^e^~qy>39S~%ERnmafK2lxbsLo+hg z*THr9{Oa=B9A;=>Y-Me0e{G`=ogd|4uCAb{V-*-49TDLl3QbMTMpl>SXT?SOhet$5 zc=&kudAj>Vz+f92bJMf)i`(lf;LI~VyR^Kqy3k#p6Xl|#Dxsul6&e&69unf4m{SZ| z-XjsY@Py#_gsAv%dlL^YCojL`h~5AX>|$tbLZ7nY<(_l$DjWg~6&6 zXC(o$UwBY;Vu2?J=1nB7#?%L;^T6-&GvPkX%0ar9L_|Xk)8WO9%@QY&tWs-gaZ~I zmjh+>*KzRs*6277*w4TnOYOH$@0qsnv(a%Lu=uzf2pGT4GtJodgag0dm-A3qczO=| zK_?ur__!SC%f60-%exZ}t)XA$P}}Q8ox0)&I1-7HxIubwR{5GKdz4oep#b_ z-_7JW57=W4mqSF>={e+rC8MMD-J=&4AD6>w@z-%+ee=&89Em^pMPb*F|4l#X>@%kS z$YF)zgWuI@^C0VNdYlJru5t4aoql=_p1vm>u=u!nXfFIZ4&JX%IQ-o)-9mKwnhlz7 z%#QPb#mD7fPW5#)8~v_k$9ceh25ugH&N@8@QU8DD5M2Cq9P(eEaKPpom%}>I>1#HG z{ygD;#mCJ<0QuL|Y%n>SALju(Z{TvUOFuma24C~zv4q9PR{xgTi1Q$-N zXC-|1&m6RGeP{}H+Igu0bb{muQ}_E9&h^sa$J&_+)zjCVoOQQ2&I9gP!s4IR0Re#R zv>b|`oN&NBrm>$ECHVd%m$e&hrU}BYZkuEPl@h7Wx1GstbO7v~G6v^=LcNdY%982|hN@SRD`+|NJIy z{6~w%pKrS0N4WUaXFtcs>VUBL)d!zred#A(!Y@8|f{&dqaN{pN#EIV;p#P6r(5OqF z}Nm6H-5$+u=1HNMSb$KFYSM_TPOI~;|q8Hll_Pj|G&_JKD+%n zK5qQa&YwR1J(|z)u{t>H@wIp1L;S=iyW-RDj+Vl~rVCz2`85EqqwQ#ZV4<}32kAVzAocRBR7BuSK=lHns zM}4gS1nK*KH(l^s-24U#p5PzPK_B(d*fAPN`7!=eXh9!;Yjvyz#p2^01CO^p?*D(# zf)*D?2ZcxaLsN1pThJ|4xzPb$_7)~)Zr&cQ{!uAeX>n0*ax6E9ZV4LtrPj1zHkalW z##$SzI|gRfmVswNPh6a}2?X>Wjm)oXZ)>Y6&x87U>dT5t3iI(uX}Cc1(u>1w6j{hA z83c7b(#l5`);D*TmoUAPbBoJ!lQm^!0Rftd;z~-!KB;h2e|JYSq9P^CT$YcEmi*T3 zd)k57s781S)ZJWBj7LhxHyJg)z5jR*SVtUe9?q=JH0S3#IVlJ*@$gFOIwb-rNJ}dM zksW6v&P+-~&A=~eyArMn^^|WSt1w8KNso;%!A~uTgOc@QG-9XO^duVo-5ydp#1K#n^)U62inUYIVrsTtXiK1RZ8*RSKz(9w|+5|H4ZyKw#dg$w7;UL)ZUg?Pi72RB~4S7U;|9z19& zjJMR65@zI;&A=-R1+S4X=L zY>ytz_0{LbyV|KpC>X|8b_}5r!6wpjmYy!=#(K7Pwz@K6e9T06$0p(5USv`YE*n|< z9qfXmZEI_O2GiM67VqcnA5qjkFf!Iv6cZMdn3kTFkda-Omm22erZ2_+v1$^;@FR(W zQ)=JJ%a8A$oW#uZO%3)nHvqv&b021IW~igCs<1F04zI38wKmqlQzCrK75T1V1+BkQ ziP^<8Pi{Z{<>kfI)jeRHf26y6WOR9Hd1Yo8nV${y3lED+&94Ey&C(*h9kr$C zuie1A@XeW@h?sd5U1OU1*Pp$5uV{64cJYf(uWaido1dRtUteEXooy+KwA2t0Q8jak z%+81BC;8jy8{TKeC&0UY^Xd(98b)#Lz>JR3)q}&|K@uSlaXDwVgsNJg_*mFn*jbx@ z(2(dLOMT@k5vQ1)e?mGe4D3lmJwbj}QP3KfhLrpcud-u$Q~w<1(Nm!1Q&;B~Q_!;Z z4a=`?>+M=t-JPB8%ykhWyMFQFC0YpxXuIuTpr~bNV`6M=Zf>F?c$btGv>(rE>wmDg zw*BPKKQ%OXIDq-CqP{bbi#3kSEi5ez6? zs@#OvE}Z-6m+ybQeCZ}Ji=45cy|bs2CO;iHuYDAvXJBR)v$6a8?=mu+tmNc`cvr7n zC1g?wsqC1a*_a+`sY>#JNV9N|Tzx-k&-{Fu0@zgr&NIM2&$T@^*q&h}bL3QD%#S!H#I)NmUSk}JPl zA!Cxza|q0-YZ;%O0$sB^n-GOLDX_TA#7Iki!poP5`4w&R@_?sUZ+&T^pQpKnqfb~y z4Z5Ws8LB7p{;d8B9<`{pOClUKI=%)xrpAXy`#L(?TI;f-bh+_=yG+fl5EQhqFxKCM zNQv{Yw*rQmaCBEsQ*or70Ktcq&|29M`O@1*3K;^r@FbmGSNVY?BksM?YW-`1#RQtwT(?3 z-F-bXb4$y67)(!9l${(O6RV7|aX~=>G%Va+TS?ng2cqwk3~z3$f|`qveVi`O{q!Gv z9v#o5%uL|Qm0#U7zOcNrvpwC9i1WA5({c$94+?T|va-|?eg;{uFf{Lj=@=t-HWSRJ4=gWmHE-Z9?AA8C}?nE{EBxG7_A?S!rgSeWeqA0^Hd}3yeh_asX znaxM5t83F^ZM_{`T{1Gfyu1>M2J-BL-~CL$V;EW1jIJyVHIkquCZnJryAE_eymBU% zI-COLQcXUsAd!F<)AMo%tcKv>lBrnQ`^upIXySA1k`C88`jols^9h{Jo zl2=&N+BdnfGBh;M*Ei7DKhV)woRFT5Xz!nynjPz|%JeZ&kduT!Y+Pb8ih-XSG{D>6 z)x$Ty)ml?t)568g)xjsev~GO-@#Foi)xAZ~6@I9vtGjJ%ZWB}#EKQ9z6(>f9gr}wz z)*#!O+A6Z*Vj`enVS%1@ilE9xQeIw!Pgctw3WpyaKHfPz*j?INn46fKoS9$W+&b9W z#`FWD#k``j#_qxH@sWw%&Z-QcY0FH8dfKUS+@_`FrlGz~$0e=j;UENz4sm~8`^@WwU_KZv}&dg1WwWDjxi;4@=VLtZq!d$nhNp4=dNzWps zsW}2B5HN+b*5oIJ1I2Vv-&SFoQpMtoORa9zO>)_b*6tK)2X>F-%M3$GtyBW#J z3bE1P5fjng;a6AB$|{5x#H0m5omFM-GRxX$G)_KvFx6Y=C&he6P{%zq71=U8GCDCo zJvlVc*3wuV=VmM;D<>ty$VAO>hetud*ViAoyxAIS2;C<6?N>ah0C?-{>~L$Qn-~o- zDQF<=kyL_i>lqrFUYvR`+J(xBc2X4*lMw^&XP~BJl9KWVo1317q$oQj!G-hGih;T9 z6C-0yd3JKQ2`SlxHOxGtOOeezeWRFhVBVOM>tQI!Bp?ZzEZ<>dq+yqkNKK87_SaPt z=VPL}bp8e#B(VgXznY&mDnjrZ?g%gsI<3NrFj^NQ{mshBO+pu^oXqhdRWj5z8n_jPmnpjw01uo%- z1_#H-2Rj?H;(;@8xVH^(;H6~dxu>Mm-CddOYAix|_WVWs%Txk}(BjSs%!B@c=KB29 zxb&QoI&}XeW_SWKxiGi9IM$0s*T4%4V9|ai3jBOR63WUQ9aW&6JOA~w|M~B4R|uFP z0r~Btm9LW)k)Cd3V_62Mu~U?VKp>r+1?e6- z+{Bm8{P69MR~htN6KmRg+Z(DgBkddwOv6&^hrnrLc&LA1U|{Qp{=cD$u6qmywntze)<`YRo*HIQQd(m%LsK*7o=xZ^-V|BwY1dN)il>3t7M@LzLudShujywn6RZ?CBtC;-8T0~Z?hmj~HHMOV-G_?!~ zdio-O_iRE~I22Zn?Ct~QPpE=WZ&$yV+}x3on(T-GS6@#XZ7xz08YRo1LS$WC*--}r zZU$OmEq7REWoAxJN_I@Bm4T|3wHGuwyR4=-!{1CrO$~TD0*!SF)Wg)t%hy)oE*XQE ziGN;gZBtIHw~5j{ZcYImp9EkEn4X>$?QWwj#LK`h0Wo!S3h)ZB)ex1DRkgHiYpcu) zveYwmbh89ji}%$mf#ScXu{<@<0iva*q;C^l+<+|0FHBDjuu$Y##tv1oe*%V^w4)n_|4$k4JmF*pa&1DH;F1B`-J`wOLbYn|(Q&l?DR$Z8$ z6rTW}`X>HWata1YARcxM4Q+3)1jQ^)u0Da0u!L+_Tt*?fcW9`k9+46QO#s5#y1K58 zj*j-moD?4eaXK=Bo8*-EXTQIUk9X}BtAu56aCdigNltQ7QgUWV84_Myj%Xg3Sy-6v zX$9@!i>n&zySn;^`g?lns#5)|#hGr?(B2|Bd*P?^SMUhQ=!6wqUHkev8Y|)OlA5a4 z{?^(CR9)Nf{Nlp=bbo6T8vMJVwz8?dt-TqU5A)U&=AdSzW(AYi6>>`Q+tgemD3F@(UlFoLb&kTptIQptUung}_ZYzXXY_$&K-|lH>+9pyFa;VjP?z zQj)R?CbpiQ%gb}%0nz4~Bku%kRN!rf6{ zTSdnzG$}sV&B;Pro*NYLC@YC_u?R^S`X^-6A(4lN2OFUNYjO}X|8ReOZEay={a|fx zZEL)%t{}ulTY-m@o?F8$G}yyjPg`5{{ylL`2t=BfnMu?lATg_~r3KstZ|<&dJe*(I z1YVT;J6mfjo12ezX2*dCs-LzDu!bk$)v*uucG6K*H?=U*H`g~dk!HQa#INBV4ofX9 z{R6b~KG@vY*j{;h@O~t(#VutPk5%#7!)^0BT9`1H7 zdNN!rY+Uljo}m$miErP&d49ONwYsr=2>$FXuWl^w?d%?GV0vp4{d7e5c{nAMt?h&S zoE&YP-2GFb(eAbo5jGBbb}@A;N1yn3a0~kK56~d~aR0@@lfA8_1#qFbw!6JPH_}rW zYNaiGUq;K!(aQ%Ke+7{3;FJYEf*Vgx+Z*NSFSEsv~YOCp37~6u0DmfmO3QbN=%J8>Vmlon?W#bT3 zu!=7!`Th4N&-Zsg;Cn^r{$gHe<3JUTLHh_=n-r?@v_VzhJ#c<Xhp%^^ zY-~N=-+%QQAS{fxpmH%vkUrhp+1!8j^6A0u;y@3mGlIo~Kk(8bcy>V z!O7X#@7}$6y1Bi*{AgfItZl5WuWxUxEe>{8XM_ZKxqABhMgz@uR9sX9G%hP8 XBQHHE(k Date: Sun, 15 Mar 2026 18:21:36 +0400 Subject: [PATCH 6/6] focaltech_moh: update udev hwdb for supported device --- data/autosuspend.hwdb | 6 +++++- libfprint/fprint-list-udev-hwdb.c | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/data/autosuspend.hwdb b/data/autosuspend.hwdb index 9d1fd351..5e7cc997 100644 --- a/data/autosuspend.hwdb +++ b/data/autosuspend.hwdb @@ -190,6 +190,11 @@ usb:v2808p079A* ID_AUTOSUSPEND=1 ID_PERSIST=0 +# Supported by libfprint driver focaltech_moh +usb:v2808p9338* + ID_AUTOSUSPEND=1 + ID_PERSIST=0 + # Supported by libfprint driver fpcmoc usb:v10A5pFFE0* usb:v10A5pA305* @@ -480,7 +485,6 @@ usb:v27C6p589A* usb:v27C6p5F10* usb:v27C6p5F91* usb:v27C6p6382* -usb:v2808p9338* usb:v2808p9348* usb:v2808p93A9* usb:v2808pA658* diff --git a/libfprint/fprint-list-udev-hwdb.c b/libfprint/fprint-list-udev-hwdb.c index 6e2adb04..eb0f9d87 100644 --- a/libfprint/fprint-list-udev-hwdb.c +++ b/libfprint/fprint-list-udev-hwdb.c @@ -158,7 +158,6 @@ static const FpIdEntry allowlist_id_table[] = { { .vid = 0x27c6, .pid = 0x5f10 }, { .vid = 0x27c6, .pid = 0x5f91 }, { .vid = 0x27c6, .pid = 0x6382 }, - { .vid = 0x2808, .pid = 0x9338 }, { .vid = 0x2808, .pid = 0x9348 }, { .vid = 0x2808, .pid = 0x93a9 }, { .vid = 0x2808, .pid = 0xa658 },