Add a status LED driven by card events

The workstation app lit an LED on GPIO 23 with the card. This brings that
across, with three deliberate differences: it never blocks the reader, it
cannot be fatal, and its state is reported.

WMT toggled the LED directly inside card_inserted, on the reader thread, where
a slow write stalls card detection. Here every change is queued to a worker, so
insert() returns immediately (tested with a deliberately slow pin).

Two modes, because the light can mean two things:
  error    (default) lit while the reader runs, dark while reading, blinks on a
                     bad read - a health indicator
  presence           lit only while a card is on the reader

gpiozero raises BadPinFactory on a machine with no GPIO, and that subclasses
ImportError - so a single except ImportError reports 'gpiozero is not
installed' when the library is present but has no usable pin provider. The
library check and the pin construction are therefore separate.

Vendors gpiozero + colorzero (~180 KB) so the LED installs offline too, and
deliberately not setuptools: gpiozero declares it via colorzero, but it is only
a packaging requirement and is never imported at runtime. Installing to the
bundled wheels therefore uses --no-deps, saving 818 KB.

Verified end to end over a pseudo-terminal with a fake pin: a real RDM6300
frame drives the LED through the app's own CLI in both modes.
This commit is contained in:
2026-10-01 16:43:29 +03:00
parent 2ded37e190
commit 031cf0f25a
14 changed files with 1036 additions and 17 deletions
+4 -4
View File
@@ -142,7 +142,7 @@ def test_bundled_wheels_used_automatically(monkeypatch):
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
@@ -168,7 +168,7 @@ def test_tampered_bundle_falls_back_to_pypi(monkeypatch, tmp_path):
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
@@ -192,7 +192,7 @@ def test_explicit_wheel_dir_overrides_bundled(monkeypatch, tmp_path):
lambda packages=None: [] if seen.get("done") else [("rdm6300", "rdm6300>=0.1.1")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["done"] = True
return True
@@ -216,7 +216,7 @@ def test_bundle_not_covering_a_requirement_falls_back(monkeypatch, tmp_path):
else [("rdm6300", "rdm6300>=0.1.1"), ("serial", "pyserial>=3.5")],
)
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300):
def fake_install(requirements, wheel_dir=None, logger=None, timeout=300, no_deps=False):
seen["wheel_dir"] = wheel_dir
seen["wheel_dir_at_call"] = wheel_dir
seen["done"] = True