Skip to content
Open
35 changes: 27 additions & 8 deletions bin/validity-sensors-firmware
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,23 @@ def download_and_extract_fw(dev_type, fwdir, fwuri=None):
raise Exception('Hash mismatch for driver download! Expected {}, got {}'.format(
expected_hash, actual_hash))

subprocess.check_call([
'innoextract', '--output-dir', fwdir, '--include', fwname, '--collisions', 'overwrite',
fwarchive
])
# Lenovo softpaqs are Inno Setup installers; HP softpaqs are CAB-wrapped
# self-extracting exes. Try innoextract first, fall back to cabextract.
try:
subprocess.check_call([
'innoextract', '--output-dir', fwdir, '--include', fwname,
'--collisions', 'overwrite', fwarchive
], stderr=subprocess.DEVNULL)
except (subprocess.CalledProcessError, FileNotFoundError):
try:
# No -F filter: HP softpaqs nest the target under e.g. src/driver/INF/x64/,
# and cabextract -F matches the full path. Extract everything; the find
# call below locates the target file regardless of subdirectory.
subprocess.check_call(['cabextract', '-q', '-d', fwdir, fwarchive])
except (subprocess.CalledProcessError, FileNotFoundError) as e:
raise Exception(
'Failed to extract {} from {}: neither innoextract nor cabextract '
'could handle the archive ({}).'.format(fwname, fwarchive, e))

fwpath = subprocess.check_output(['find', fwdir, '-name', fwname]).decode('utf-8').strip()
print('Found firmware at {}'.format(fwpath))
Expand Down Expand Up @@ -91,10 +104,16 @@ if __name__ == "__main__":
if not dev_type:
raise Exception('No supported validity device found')

try:
subprocess.check_call(['innoextract', '--version'], stdout=subprocess.DEVNULL)
except Exception as e:
print('Impossible to run innoextract: {}'.format(e))
have_extractor = False
for tool in ('innoextract', 'cabextract'):
try:
subprocess.check_call([tool, '--version'], stdout=subprocess.DEVNULL)
have_extractor = True
break
except (subprocess.CalledProcessError, FileNotFoundError):
continue
if not have_extractor:
print('Need at least one of innoextract or cabextract installed.')
sys.exit(1)

with tempfile.TemporaryDirectory() as fwdir:
Expand Down
21 changes: 20 additions & 1 deletion dbus_service/dbus-service
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,27 @@ class Device(dbus.service.Object):

self.VerifyFingerSelected('any')

# pam_fprintd re-prints "Place your finger on the reader" for every
# verify-retry-scan signal, which on this chip's chatty capture loop
# can fire 30+ times per verify cycle and floods the terminal during
# sudo. Emit it at most once per VerifyStart so the user gets one
# initial prompt + one early retry hint, then silence until match
# or timeout. Enroll keeps its per-stage signals — those are useful
# because each stage is a discrete user action.
retry_emitted = [False]
retry_count = [0]

def update_cb(e):
self.VerifyStatus('verify-retry-scan', False)
# Always log every chip retry to journal — useful for the
# task #17 capture-quality benchmark. The D-Bus emit below
# is still throttled to once per cycle (avoids spamming
# pam_fprintd which re-prints the prompt on each signal).
retry_count[0] += 1
logging.info('Chip capture retry-scan #%d (user=%s)',
retry_count[0], user)
if not retry_emitted[0]:
self.VerifyStatus('verify-retry-scan', False)
retry_emitted[0] = True

def run():
try:
Expand Down
99 changes: 99 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,102 @@
python-validity (0.16~hp7) noble; urgency=medium

* usb.py: defensive USB reset at the start of open_dev(). The 0xd51-
family chips (138a:00ab, 06cb:00b7) can be left in a "stuck"
protocol state across cold boot, unclean exit of the daemon, or
sudden suspend/resume — in that state the chip accepts the
bulk-OUT but never replies on bulk-IN, so the first cleartext
command (get_flash_info, cmd 3e) times out and the daemon enters
a 15-second restart loop. The reset is the in-driver equivalent
of the manual `udevadm trigger --attr-match=idVendor=...
--attr-match=idProduct=...` workaround multiple users were
running to recover after every reboot. Reported by Killersparrow1
(uunicorn/python-validity#238, Fedora 44, sensor vanishes on
reboot) and a separate Arch / ZBook G5 user (USBTimeoutError on
cmd 3e). Also observed intermittently on the maintainer's
machine.
* After reset, re-find the device by vid/pid because the USB
address can shift across the reset.

-- SimpleX-T <ntmark2004@gmail.com> Sun, 25 May 2026 00:30:00 +0100

python-validity (0.16~hp6) noble; urgency=medium

* Diagnostic logging (no behavior change):
- sensor.py: log resolved capture geometry once at chip open
(real_type, spoofed_type, lines_per_frame, bytes_per_line,
line_width, calibration data lines). Makes future "is the
spoofed-to-0x199 profile right for this chip?" questions
answerable from journal alone.
- dbus-service: log every internal chip retry-scan during
VerifyStart, independent of the D-Bus signal throttle.
Lets users / support tickets quantify capture quality.
* Both changes are INFO-level log lines only. ~1 log per chip
open + ~0-3 lines per verify in normal use.

-- SimpleX-T <ntmark2004@gmail.com> Thu, 21 May 2026 23:55:00 +0100

python-validity (0.16~hp5) noble; urgency=medium

* debian/source/options: tar-ignore .pybuild, build, *.egg-info,
__pycache__, *.pyc. Without this, dpkg-source for 3.0 (native)
packages bundles leftover local build artifacts (cached pybuild
state with absolute paths from the developer's machine) into the
source tarball, which caused per-series Launchpad builds after
the first one to fail with "Permission denied" trying to write
into a baked-in /home/<dev>/... path.

-- SimpleX-T <ntmark2004@gmail.com> Thu, 21 May 2026 13:30:00 +0100

python-validity (0.16~hp4) noble; urgency=medium

* VerifyStart: emit verify-retry-scan at most once per verify cycle so
pam_fprintd doesn't repeat "Place your finger on the reader" for every
one of the chip's chatty capture-quality retries. On the 0xd51 chip the
capture loop fires the signal 20+ times during a 10s timeout window,
which flooded the terminal during sudo. Enrollment is unchanged —
per-stage retry-scans there are useful because each stage is a discrete
user action.

-- SimpleX-T <ntmark2004@gmail.com> Thu, 21 May 2026 23:30:00 +0100

python-validity (0.16~hp3) noble; urgency=medium

* sensor.enroll: replace existing finger record when re-enrolling the same
subtype for the same user. Without this, the chip's db rejects creating
a duplicate, surfacing as enroll-failed at the final stage after all
captures pass. The delete is performed inside do_create_finger (right
before db.new_finger) so the chip's enroll session isn't disrupted —
pre-deleting before EnrollStart caused subsequent captures to return
retry-scan indefinitely until the daemon was restarted.

-- SimpleX-T <ntmark2004@gmail.com> Thu, 21 May 2026 23:00:00 +0100

python-validity (0.16~hp2) noble; urgency=medium

* validity-sensors-firmware: fall back to cabextract when innoextract
cannot handle the archive. HP softpaqs (e.g. sp135736.exe used for
138a:00ab and 06cb:00b7) are CAB-wrapped self-extracting exes, not
Inno Setup installers, so innoextract rejected them and the postinst
surfaced a noisy Python traceback. With the fallback, HP softpaq
extraction now works, and either extractor satisfies the runtime
requirement.
* Recommends: cabextract (so the fallback path is available by default).

-- SimpleX-T <ntmark2004@gmail.com> Thu, 21 May 2026 11:00:00 +0100

python-validity (0.16~hp1) noble; urgency=medium

* Add support for sensor type 0xd51 (138a:00ab, 06cb:00b7) — HP EliteBook
840 G5 and related models. Fixes a chip-specific interrupt protocol
that caused fprintd-verify to hang forever on these devices.
* Add FIRMWARE_URIS entries for DEV_AB and DEV_B7 so that
validity-sensors-firmware no longer crashes with KeyError on these PIDs.
* Enable libpam-fprintd via pam-auth-update in postinst so that sudo /
screen-unlock prompt for fingerprint immediately after install.
* Recommend libpam-fprintd.

-- SimpleX-T <ntmark2004@gmail.com> Wed, 20 May 2026 22:25:34 +0100

python-validity (0.15~ppa2) noble; urgency=medium

* Change all write paths to /var/run/python-validity
Expand Down
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Depends: ${python3:Depends},
dbus,
open-fprintd (>= 0.6~),
innoextract (>= 1.6~)
Recommends: libpam-fprintd, cabextract
Description: Validity Fingerprint Sensor DBus Driver
This package adds support to some Validity sensors.
.
Expand Down
3 changes: 3 additions & 0 deletions debian/python3-validity.postinst
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ if [ "$1" = "configure" ]; then
systemctl daemon-reload || true
udevadm control --reload-rules || true
udevadm trigger || true
if [ -x /usr/sbin/pam-auth-update ]; then
pam-auth-update --package --enable fprintd || true
fi
fi

2 changes: 2 additions & 0 deletions debian/python3-validity.udev
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ ENV{DEVTYPE}!="usb_device", GOTO="python_validity_end"
ATTRS{idVendor}=="138a", ATTRS{idProduct}=="0090", GOTO="python_validity_match"
ATTRS{idVendor}=="138a", ATTRS{idProduct}=="0097", GOTO="python_validity_match"
ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="009a", GOTO="python_validity_match"
ATTRS{idVendor}=="138a", ATTRS{idProduct}=="00ab", GOTO="python_validity_match"
ATTRS{idVendor}=="06cb", ATTRS{idProduct}=="00b7", GOTO="python_validity_match"

GOTO="python_validity_end"

Expand Down
5 changes: 5 additions & 0 deletions debian/source/options
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
tar-ignore = ".pybuild"
tar-ignore = "build"
tar-ignore = "*.egg-info"
tar-ignore = "__pycache__"
tar-ignore = "*.pyc"
4 changes: 4 additions & 0 deletions validitysensor/blobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ def __load_blob(blob: str) -> bytes:
from . import blobs_97 as blobs
elif usb.usb_dev().idProduct == 0x009d:
from . import blobs_9d as blobs
elif usb.usb_dev().idProduct == 0x00ab:
from . import blobs_97 as blobs # HP EliteBook 840 G5; verified
elif usb.usb_dev().idVendor == 0x06cb:
if usb.usb_dev().idProduct == 0x009a:
from . import blobs_9a as blobs
elif usb.usb_dev().idProduct == 0x00b7:
from . import blobs_9a as blobs # HP G6 series; same sensor type as 0x00ab

globals()[blob] = getattr(blobs, blob)
return globals()[blob]
Expand Down
17 changes: 16 additions & 1 deletion validitysensor/firmware_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,27 @@
'driver': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe',
'referral': 'https://download.lenovo.com/pccbbs/mobiles/nz3gf07w.exe',
'sha512': 'a4a4e6058b1ea8ab721953d2cfd775a1e7bc589863d160e5ebbb90344858f147d695103677a8df0b2de0c95345df108bda97196245b067f45630038fb7c807cd'
},
SupportedDevices.DEV_AB: {
'driver': 'https://ftp.hp.com/pub/softpaq/sp135501-136000/sp135736.exe',
'referral': 'https://support.hp.com/us-en/drivers',
'sha512': 'f9a91e2796a5070f1f40099e2318aa9716e2e6a31b9ba6a93986c450eedbfb0b323dff55c5e4536466946da3e01985f367b1db27bbd7b65f4c333ce0cd47b78c'
},
SupportedDevices.DEV_B7: {
'driver': 'https://ftp.hp.com/pub/softpaq/sp135501-136000/sp135736.exe',
'referral': 'https://support.hp.com/us-en/drivers',
'sha512': 'f9a91e2796a5070f1f40099e2318aa9716e2e6a31b9ba6a93986c450eedbfb0b323dff55c5e4536466946da3e01985f367b1db27bbd7b65f4c333ce0cd47b78c'
}
}

FIRMWARE_NAMES = {
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',
# 0xd51-sensor variants ship with firmware pre-loaded; xpfwext upload is
# only needed for factory-reset / unprovisioned chips. The HP softpaq
# filename matches what extracted from HP's Windows driver (sp135736.exe).
SupportedDevices.DEV_AB: '6_07f_hp_cmit_mis_qm.xpfwext', # HP EliteBook 840 G5
SupportedDevices.DEV_B7: '6_07f_hp_cmit_mis_qm.xpfwext', # HP G6 series (same chip family)
}
55 changes: 50 additions & 5 deletions validitysensor/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
calib_data_path = PYTHON_VALIDITY_DATA_DIR + 'calib-data.bin'

line_update_type1_devices = [
0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199
0xB5, 0x885, 0xB3, 0x143B, 0x1055, 0xE1, 0x8B1, 0xEA, 0xE4, 0xED, 0x1825, 0x1FF5, 0x199,
0xD51, # HP EliteBook 840 G5 (138a:00ab) / HP G6 series (06cb:00b7)
]


Expand Down Expand Up @@ -224,6 +225,17 @@ class Sensor:

def open(self):
self.device_info = identify_sensor()
self.real_device_type = self.device_info.type

# Sensor type 0xd51 (HP EliteBook 840 G5 138a:00ab, HP G6 series 06cb:00b7)
# has no native SensorTypeInfo / SensorCaptureProg entry. Empirically the
# 0x199 profile produces images that the on-chip matcher accepts after
# enrollment/verify; the 0xdb profile does not. Spoofing keeps the rest
# of this method (calibration switch, capture program lookup) on a code
# path that works.
if self.device_info.type == 0xd51:
logging.info('Sensor type 0xd51 — aliasing to 0x199 profile')
self.device_info.type = 0x199

logging.info('Opening sensor: %s' % self.device_info.name)
self.type_info = SensorTypeInfo.get_by_type(self.device_info.type)
Expand Down Expand Up @@ -254,6 +266,21 @@ def open(self):
self.lines_per_frame = lines_2d * self.type_info.repeat_multiplier
self.bytes_per_line = self.type_info.bytes_per_line

# Diagnostic (task #17): log resolved capture geometry so we can
# tell whether the 0x199-spoofed profile matches what the 0xd51
# chip actually expects. lines_2d is extracted from the capture
# program's 0x2f chunk; if the chip is producing a different
# frame size, this is where the mismatch first shows up.
logging.info(
'Capture geometry: real_type=0x%x spoofed_type=0x%x '
'lines_2d=%d repeat_multiplier=%d lines_per_frame=%d '
'bytes_per_line=0x%x line_width=%d '
'lines_per_calibration_data=%d',
self.real_device_type, self.device_info.type, lines_2d,
self.type_info.repeat_multiplier, self.lines_per_frame,
self.bytes_per_line, self.type_info.line_width,
self.type_info.lines_per_calibration_data)

factory_bits = get_factory_bits(0x0e00)
self.factory_calibration_values = factory_bits[3][4:]

Expand Down Expand Up @@ -702,14 +729,23 @@ def capture(self, mode: CaptureMode) -> typing.Tuple[int, int, int, int]:
raise Exception('wait_start: Unexpected interrupt type %s' % hexlify(b).decode())

# wait for finger
# Sensor type 0xd51 (138a:00ab, 06cb:00b7) does not emit the
# b[0]=2 "finger detected" interrupt — it jumps directly from the
# start ack to b[0]=3 capture events. Accept b[0]=3 as a substitute
# and pass the interrupt through to the wait-capture-complete loop.
saved_b = None
while True:
b = usb.wait_int()
if b[0] == 2:
break
if b[0] == 3 and getattr(self, 'real_device_type', None) == 0xd51:
saved_b = b
break

# wait capture complete
while True:
b = usb.wait_int()
b = saved_b if saved_b is not None else usb.wait_int()
saved_b = None
if b[0] != 3:
raise Exception('Unexpected interrupt type %s' % hexlify(b).decode())

Expand Down Expand Up @@ -811,11 +847,20 @@ def enroll(self, identity: SidIdentity, subtype: int,
def do_create_finger(final_template: bytes, tid: bytes):
tinfo = self.make_finger_data(subtype, final_template, tid)

usr = db.lookup_user(identity)
if usr is None:
existing = db.lookup_user(identity)
if existing is None:
usr = db.new_user(identity)
else:
usr = usr.dbid
# Replace any existing enrollment for this finger slot. The chip
# rejects creating a second record with the same subtype for the
# same user. Deleting here (after all captures are done) keeps
# the chip's enroll session uninterrupted — earlier attempts to
# pre-delete before EnrollStart left the chip in a state where
# subsequent captures kept returning retry-scan indefinitely.
for f in existing.fingers:
if f['subtype'] == subtype:
db.del_record(f['dbid'])
usr = existing.dbid

recid = db.new_finger(usr, tinfo)
usb.wait_int()
Expand Down
Loading