576f22268f
Card reading (rdm6300 + pyserial) printing card inserted/removed with the full card data, plus a --serve mode that publishes taps over HTTP so a web page can authenticate a tap. The bridge reports card ids only - mapping a card to a user stays on the server. The tap is latched for --tap-ttl seconds, including after the card is lifted, and is single use, so a page reload cannot replay one tap into two logins. Port selection identifies the reader by serial number or by-id path instead of /dev/ttyUSBn, and refuses to guess when a selector matches several adapters. deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and install.sh. Covered by unit tests plus PTY end-to-end tests.
150 lines
4.3 KiB
Python
150 lines
4.3 KiB
Python
"""Port selection and error explanation, using a fake reader library."""
|
|
|
|
import types
|
|
|
|
import pytest
|
|
|
|
import rdm_card_reader.reader as reader_module
|
|
from rdm_card_reader.card import CANDIDATE_PORTS
|
|
from rdm_card_reader.reader import explain_error, list_ports, open_reader
|
|
|
|
|
|
class FakeBaseReader:
|
|
"""Stands in for rdm6300.BaseReader; records the port it was built with."""
|
|
|
|
fail_on = ()
|
|
|
|
def __init__(self, port, heartbeat_interval=0.5):
|
|
if port in type(self).fail_on:
|
|
raise OSError("could not open port {}".format(port))
|
|
self.port = port
|
|
self.heartbeat_interval = heartbeat_interval
|
|
self.started = False
|
|
self.closed = False
|
|
|
|
def start(self):
|
|
self.started = True
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_library(monkeypatch):
|
|
module = types.SimpleNamespace(BaseReader=FakeBaseReader)
|
|
monkeypatch.setattr(reader_module, "rdm6300", module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def restore_library():
|
|
original = reader_module.rdm6300
|
|
yield
|
|
reader_module.rdm6300 = original
|
|
|
|
|
|
def test_default_port_order_matches_the_workstation_app():
|
|
assert CANDIDATE_PORTS == (
|
|
"/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyACM0",
|
|
)
|
|
|
|
|
|
def test_first_working_port_wins(fake_library):
|
|
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
|
|
reader = open_reader(lambda event: None,
|
|
ports=["/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0"])
|
|
assert reader is not None
|
|
assert reader.port == "/dev/ttyUSB0"
|
|
|
|
|
|
def test_returns_none_when_no_port_works(fake_library):
|
|
FakeBaseReader.fail_on = ("/dev/ttyS0", "/dev/ttyAMA0")
|
|
assert open_reader(lambda event: None,
|
|
ports=["/dev/ttyS0", "/dev/ttyAMA0"]) is None
|
|
|
|
|
|
def test_failures_are_reported_with_the_port(fake_library):
|
|
FakeBaseReader.fail_on = ("/dev/ttyS0",)
|
|
seen = []
|
|
open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
|
on_error=lambda port, message: seen.append((port, message)))
|
|
assert seen and seen[0][0] == "/dev/ttyS0"
|
|
|
|
|
|
def test_heartbeat_interval_is_passed_through(fake_library):
|
|
FakeBaseReader.fail_on = ()
|
|
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
|
heartbeat_interval=1.5)
|
|
assert reader.heartbeat_interval == 1.5
|
|
|
|
|
|
def test_heartbeat_none_disables_removal_detection(fake_library):
|
|
FakeBaseReader.fail_on = ()
|
|
reader = open_reader(lambda event: None, ports=["/dev/ttyS0"],
|
|
heartbeat_interval=None)
|
|
assert reader.heartbeat_interval is None
|
|
|
|
|
|
def test_events_are_forwarded_to_the_callback(fake_library):
|
|
FakeBaseReader.fail_on = ()
|
|
received = []
|
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
|
|
|
class Card:
|
|
value = 7
|
|
checksum = 1
|
|
type = 15
|
|
is_valid = True
|
|
|
|
reader.card_inserted(Card())
|
|
assert len(received) == 1
|
|
assert received[0].value == 7
|
|
assert received[0].event == "card_inserted"
|
|
|
|
|
|
def test_card_removed_with_none_is_ignored(fake_library):
|
|
FakeBaseReader.fail_on = ()
|
|
received = []
|
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
|
reader.card_removed(None)
|
|
assert received == []
|
|
|
|
|
|
def test_invalid_card_is_forwarded(fake_library):
|
|
FakeBaseReader.fail_on = ()
|
|
received = []
|
|
reader = open_reader(received.append, ports=["/dev/ttyS0"])
|
|
|
|
class Card:
|
|
value = 1
|
|
checksum = 2
|
|
type = 15
|
|
is_valid = False
|
|
|
|
reader.invalid_card(Card())
|
|
assert received[0].event == "invalid_card"
|
|
|
|
|
|
def test_missing_library_raises(monkeypatch):
|
|
monkeypatch.setattr(reader_module, "rdm6300", None)
|
|
with pytest.raises(reader_module.ReaderUnavailable):
|
|
open_reader(lambda event: None)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message,expected_fragment",
|
|
[
|
|
("[Errno 13] Permission denied: '/dev/ttyS0'", "dialout"),
|
|
("could not open port /dev/nope", "device not found"),
|
|
("something else", "something else"),
|
|
],
|
|
)
|
|
def test_explain_error(message, expected_fragment):
|
|
assert expected_fragment in explain_error("/dev/ttyS0", message)
|
|
|
|
|
|
def test_list_ports_shape():
|
|
info = list_ports()
|
|
assert set(info) == {"candidates", "detected"}
|
|
assert [port for port, _ in info["candidates"]] == list(CANDIDATE_PORTS)
|