RDM card reader: print card events, select the right port, serve taps to a web app
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.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
SRC = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||
)
|
||||
if SRC not in sys.path:
|
||||
sys.path.insert(0, SRC)
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Tests for the HTTP bridge.
|
||||
|
||||
Two things matter most here:
|
||||
|
||||
1. The bridge publishes card events without claiming to authenticate anyone.
|
||||
2. A page on another origin must not be able to read your taps.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
SRC = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||
)
|
||||
if SRC not in sys.path:
|
||||
sys.path.insert(0, SRC)
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from rdm_card_reader import bridge # noqa: E402
|
||||
from rdm_card_reader.bridge import ( # noqa: E402
|
||||
MODE_LOGIN,
|
||||
MODE_MONITOR,
|
||||
EventHub,
|
||||
create_server,
|
||||
)
|
||||
from rdm_card_reader.card import CardEvent # noqa: E402
|
||||
|
||||
|
||||
def make_event(event="card_inserted", value=12886197):
|
||||
return CardEvent(
|
||||
event=event,
|
||||
timestamp="2026-10-01 12:00:00",
|
||||
value=value,
|
||||
checksum=222,
|
||||
card_type=15,
|
||||
is_valid=True,
|
||||
port="/dev/ttyUSB0",
|
||||
)
|
||||
|
||||
|
||||
# -- hub --------------------------------------------------------------------
|
||||
|
||||
def test_hub_publishes_to_subscriber():
|
||||
hub = EventHub()
|
||||
queue = hub.subscribe()
|
||||
hub.publish(make_event())
|
||||
assert len(queue) == 1
|
||||
assert queue[0]["id"] == 12886197
|
||||
|
||||
|
||||
def test_hub_login_mode_hides_removals():
|
||||
"""Removal must not look like a second authentication attempt."""
|
||||
hub = EventHub(mode=MODE_LOGIN)
|
||||
queue = hub.subscribe()
|
||||
hub.publish(make_event("card_removed"))
|
||||
assert len(queue) == 0
|
||||
|
||||
|
||||
def test_hub_monitor_mode_publishes_removals():
|
||||
hub = EventHub(mode=MODE_MONITOR)
|
||||
queue = hub.subscribe()
|
||||
hub.publish(make_event("card_removed"))
|
||||
assert len(queue) == 1
|
||||
|
||||
|
||||
def test_hub_unsubscribe_stops_delivery():
|
||||
hub = EventHub()
|
||||
queue = hub.subscribe()
|
||||
hub.unsubscribe(queue)
|
||||
hub.publish(make_event())
|
||||
assert len(queue) == 0
|
||||
|
||||
|
||||
def test_hub_tracks_last_tap_only_on_insert():
|
||||
hub = EventHub(mode=MODE_MONITOR)
|
||||
hub.publish(make_event("card_inserted", value=111))
|
||||
hub.publish(make_event("card_removed", value=111))
|
||||
assert hub.last()["id"] == 111
|
||||
|
||||
|
||||
def test_hub_history_is_bounded():
|
||||
hub = EventHub(history=3)
|
||||
for value in range(10):
|
||||
hub.publish(make_event(value=value))
|
||||
assert len(hub.taps(limit=99)) == 3
|
||||
|
||||
|
||||
def test_hub_health_reports_state():
|
||||
hub = EventHub()
|
||||
hub.set_port("/dev/ttyUSB0")
|
||||
hub.publish(make_event())
|
||||
health = hub.health()
|
||||
assert health["port"] == "/dev/ttyUSB0"
|
||||
assert health["counts"]["card_inserted"] == 1
|
||||
assert health["last_tap"]["id"] == 12886197
|
||||
assert health["last_tap_age_seconds"] is not None
|
||||
|
||||
|
||||
def test_hub_rejects_unknown_mode():
|
||||
hub = EventHub()
|
||||
assert hub.set_mode("bogus") is False
|
||||
assert hub.mode == MODE_LOGIN
|
||||
|
||||
|
||||
def test_hub_accepts_valid_mode_switch():
|
||||
hub = EventHub()
|
||||
assert hub.set_mode(MODE_MONITOR) is True
|
||||
assert hub.mode == MODE_MONITOR
|
||||
|
||||
|
||||
# -- HTTP -------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
"""A live bridge on an ephemeral port, torn down after each test."""
|
||||
hub = EventHub()
|
||||
hub.set_port("/dev/ttyUSB0")
|
||||
srv = create_server(hub, host="127.0.0.1", port=0)
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield srv, hub
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def base_url(srv):
|
||||
host, port = srv.server_address[:2]
|
||||
return "http://{}:{}".format(host, port)
|
||||
|
||||
|
||||
def get(srv, path, headers=None):
|
||||
request = urllib.request.Request(base_url(srv) + path, headers=headers or {})
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, response.headers, response.read()
|
||||
|
||||
|
||||
def test_health_endpoint(server):
|
||||
srv, hub = server
|
||||
status, _, body = get(srv, "/api/health")
|
||||
assert status == 200
|
||||
payload = json.loads(body)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["port"] == "/dev/ttyUSB0"
|
||||
assert payload["mode"] == MODE_LOGIN
|
||||
|
||||
|
||||
def test_last_returns_204_when_nothing_read(server):
|
||||
srv, _ = server
|
||||
status, _, _ = get(srv, "/api/last")
|
||||
assert status == 204
|
||||
|
||||
|
||||
def test_last_returns_the_tap(server):
|
||||
srv, hub = server
|
||||
hub.publish(make_event())
|
||||
status, _, body = get(srv, "/api/last")
|
||||
assert status == 200
|
||||
assert json.loads(body)["id"] == 12886197
|
||||
|
||||
|
||||
def test_taps_endpoint_honours_limit(server):
|
||||
srv, hub = server
|
||||
for value in range(5):
|
||||
hub.publish(make_event(value=value))
|
||||
_, _, body = get(srv, "/api/taps?limit=2")
|
||||
assert len(json.loads(body)["taps"]) == 2
|
||||
|
||||
|
||||
def test_unknown_path_is_404(server):
|
||||
srv, _ = server
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
get(srv, "/api/nope")
|
||||
assert excinfo.value.code == 404
|
||||
|
||||
|
||||
def test_health_available_without_origin(server):
|
||||
"""Monitoring must work for a plain client with no Origin header."""
|
||||
srv, _ = server
|
||||
status, _, _ = get(srv, "/api/health")
|
||||
assert status == 200
|
||||
|
||||
|
||||
# -- origin policy ----------------------------------------------------------
|
||||
|
||||
def test_cross_origin_refused_when_no_allowlist(server):
|
||||
"""A third-party page must not be able to read card ids."""
|
||||
srv, _ = server
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
get(srv, "/api/last", headers={"Origin": "https://evil.example"})
|
||||
assert excinfo.value.code == 403
|
||||
|
||||
|
||||
def test_allowlisted_origin_permitted_and_echoed():
|
||||
hub = EventHub()
|
||||
srv = create_server(hub, host="127.0.0.1", port=0,
|
||||
allowed_origins=["https://kiosk.example"])
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
hub.publish(make_event())
|
||||
status, headers, body = get(
|
||||
srv, "/api/last", headers={"Origin": "https://kiosk.example"}
|
||||
)
|
||||
assert status == 200
|
||||
assert headers.get("Access-Control-Allow-Origin") == "https://kiosk.example"
|
||||
assert json.loads(body)["id"] == 12886197
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def test_unlisted_origin_still_refused_when_allowlist_set():
|
||||
hub = EventHub()
|
||||
srv = create_server(hub, host="127.0.0.1", port=0,
|
||||
allowed_origins=["https://kiosk.example"])
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
get(srv, "/api/last", headers={"Origin": "https://other.example"})
|
||||
assert excinfo.value.code == 403
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def test_token_not_required_from_loopback():
|
||||
hub = EventHub()
|
||||
srv = create_server(hub, host="127.0.0.1", port=0, token="sekrit")
|
||||
thread = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
status, _, _ = get(srv, "/api/health")
|
||||
assert status == 200
|
||||
finally:
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
# -- mode switching ---------------------------------------------------------
|
||||
|
||||
def post(srv, path, payload, headers=None):
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
base_url(srv) + path, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return response.status, response.read()
|
||||
|
||||
|
||||
def test_mode_can_be_switched(server):
|
||||
srv, hub = server
|
||||
status, body = post(srv, "/api/mode", {"mode": MODE_MONITOR})
|
||||
assert status == 200
|
||||
assert json.loads(body)["mode"] == MODE_MONITOR
|
||||
assert hub.mode == MODE_MONITOR
|
||||
|
||||
|
||||
def test_invalid_mode_rejected(server):
|
||||
srv, _ = server
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
post(srv, "/api/mode", {"mode": "nonsense"})
|
||||
assert excinfo.value.code == 400
|
||||
|
||||
|
||||
def test_malformed_json_rejected(server):
|
||||
srv, _ = server
|
||||
request = urllib.request.Request(
|
||||
base_url(srv) + "/api/mode", data=b"{not json",
|
||||
method="POST", headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
urllib.request.urlopen(request, timeout=5)
|
||||
assert excinfo.value.code == 400
|
||||
|
||||
|
||||
# -- SSE --------------------------------------------------------------------
|
||||
|
||||
def test_sse_stream_delivers_a_tap(server):
|
||||
"""The stream used by the web page must push a tap as it happens."""
|
||||
srv, hub = server
|
||||
received = []
|
||||
|
||||
def listen():
|
||||
with urllib.request.urlopen(base_url(srv) + "/api/events", timeout=10) as resp:
|
||||
for raw in resp:
|
||||
line = raw.decode("utf-8").strip()
|
||||
if line.startswith("data: "):
|
||||
received.append(json.loads(line[6:]))
|
||||
return
|
||||
|
||||
thread = threading.Thread(target=listen, daemon=True)
|
||||
thread.start()
|
||||
time.sleep(0.4) # let the subscription register
|
||||
hub.publish(make_event())
|
||||
thread.join(timeout=6)
|
||||
assert received and received[0]["id"] == 12886197
|
||||
|
||||
|
||||
def test_loopback_helper():
|
||||
assert bridge.is_loopback("127.0.0.1") is True
|
||||
assert bridge.is_loopback("0.0.0.0") is False
|
||||
|
||||
|
||||
# -- latched card (the "always available" behaviour) ------------------------
|
||||
|
||||
def test_card_not_present_before_any_tap():
|
||||
hub = EventHub()
|
||||
state = hub.current_card()
|
||||
assert state["present"] is False
|
||||
assert state["card"] is None
|
||||
|
||||
|
||||
def test_card_stays_available_after_the_page_loads():
|
||||
"""The tap must outlive the page that reads it."""
|
||||
hub = EventHub()
|
||||
hub.publish(make_event(value=555))
|
||||
for _ in range(3): # simulated later page loads
|
||||
state = hub.current_card()
|
||||
assert state["present"] is True
|
||||
assert state["card"]["id"] == 555
|
||||
|
||||
|
||||
def test_card_cleared_when_removed():
|
||||
"""Lifting the card must NOT lose the tap - only mark it as gone."""
|
||||
hub = EventHub(tap_ttl=30)
|
||||
hub.publish(make_event())
|
||||
hub.publish(make_event("card_removed"))
|
||||
state = hub.current_card()
|
||||
assert state["present"] is False # no longer physically on the reader
|
||||
assert state["claimable"] is True # but still spendable
|
||||
assert state["card"]["id"] == 12886197
|
||||
|
||||
|
||||
def test_quick_tap_is_not_lost():
|
||||
"""The real-world case: card lifted before the page polls."""
|
||||
hub = EventHub(tap_ttl=10)
|
||||
hub.publish(make_event(value=31337))
|
||||
hub.publish(make_event("card_removed")) # lifted immediately
|
||||
payload, error, _ = hub.consume_card()
|
||||
assert error is None
|
||||
assert payload["card"]["id"] == 31337
|
||||
|
||||
|
||||
def test_claim_returns_the_card_once():
|
||||
hub = EventHub()
|
||||
hub.publish(make_event(value=777))
|
||||
payload, error, status = hub.consume_card()
|
||||
assert error is None and status == 200
|
||||
assert payload["card"]["id"] == 777
|
||||
assert payload["consumed"] is True
|
||||
|
||||
|
||||
def test_claim_is_single_use():
|
||||
"""A page reload must not turn one physical tap into two logins."""
|
||||
hub = EventHub()
|
||||
hub.publish(make_event())
|
||||
assert hub.consume_card()[1] is None
|
||||
payload, error, status = hub.consume_card()
|
||||
assert payload is None
|
||||
assert error == "card already used"
|
||||
assert status == 409
|
||||
|
||||
|
||||
def test_claim_without_a_card_is_conflict():
|
||||
hub = EventHub()
|
||||
payload, error, status = hub.consume_card()
|
||||
assert payload is None
|
||||
assert error == "no card read yet"
|
||||
assert status == 409
|
||||
|
||||
|
||||
def test_removing_and_replacing_the_card_allows_a_new_claim():
|
||||
"""Lifting the card and tapping it again is a fresh authentication."""
|
||||
hub = EventHub(tap_ttl=30)
|
||||
hub.publish(make_event(value=111))
|
||||
assert hub.consume_card()[1] is None
|
||||
hub.publish(make_event("card_removed"))
|
||||
hub.publish(make_event(value=111))
|
||||
payload, error, _ = hub.consume_card()
|
||||
assert error is None
|
||||
assert payload["card"]["id"] == 111
|
||||
|
||||
|
||||
def test_card_endpoint_reports_claimable_after_lift(server):
|
||||
srv, hub = server
|
||||
hub.publish(make_event(value=8080))
|
||||
hub.publish(make_event("card_removed"))
|
||||
_, _, body = get(srv, "/api/card")
|
||||
payload = json.loads(body)
|
||||
assert payload["present"] is False
|
||||
assert payload["claimable"] is True
|
||||
assert payload["card"]["id"] == 8080
|
||||
|
||||
|
||||
def test_claim_expires_after_ttl():
|
||||
hub = EventHub(tap_ttl=0.05)
|
||||
hub.publish(make_event())
|
||||
time.sleep(0.12)
|
||||
payload, error, status = hub.consume_card()
|
||||
assert payload is None
|
||||
assert error == "card tap expired"
|
||||
assert status == 410
|
||||
|
||||
|
||||
def test_ttl_zero_never_expires_while_present():
|
||||
hub = EventHub(tap_ttl=0)
|
||||
hub.publish(make_event())
|
||||
state = hub.current_card()
|
||||
assert state["expired"] is False
|
||||
assert state["claimable"] is True
|
||||
|
||||
|
||||
def test_tap_becomes_unclaimable_after_ttl():
|
||||
hub = EventHub(tap_ttl=0.05)
|
||||
hub.publish(make_event())
|
||||
time.sleep(0.12)
|
||||
assert hub.current_card()["claimable"] is False
|
||||
|
||||
|
||||
def test_a_new_tap_replaces_an_unclaimed_one():
|
||||
hub = EventHub(tap_ttl=30)
|
||||
hub.publish(make_event(value=111))
|
||||
hub.publish(make_event(value=222))
|
||||
payload, error, _ = hub.consume_card()
|
||||
assert error is None
|
||||
assert payload["card"]["id"] == 222
|
||||
|
||||
|
||||
def test_health_shows_card_presence():
|
||||
hub = EventHub()
|
||||
hub.publish(make_event())
|
||||
health = hub.health()
|
||||
assert health["card_present"] is True
|
||||
assert health["card_consumed"] is False
|
||||
|
||||
|
||||
# -- latched card over HTTP -------------------------------------------------
|
||||
|
||||
def test_card_endpoint_reports_presence(server):
|
||||
srv, hub = server
|
||||
_, _, body = get(srv, "/api/card")
|
||||
assert json.loads(body)["present"] is False
|
||||
|
||||
hub.publish(make_event(value=4242))
|
||||
_, _, body = get(srv, "/api/card")
|
||||
payload = json.loads(body)
|
||||
assert payload["present"] is True
|
||||
assert payload["card"]["id"] == 4242
|
||||
|
||||
|
||||
def test_claim_endpoint_works_once(server):
|
||||
srv, hub = server
|
||||
hub.publish(make_event(value=9999))
|
||||
|
||||
status, body = post(srv, "/api/card/claim", {})
|
||||
assert status == 200
|
||||
assert json.loads(body)["card"]["id"] == 9999
|
||||
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
post(srv, "/api/card/claim", {})
|
||||
assert excinfo.value.code == 409
|
||||
detail = json.loads(excinfo.value.read())
|
||||
assert detail["error"] == "card already used"
|
||||
|
||||
|
||||
def test_claim_endpoint_without_card_conflicts(server):
|
||||
srv, _ = server
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
post(srv, "/api/card/claim", {})
|
||||
assert excinfo.value.code == 409
|
||||
|
||||
|
||||
def test_claim_refused_from_other_origin(server):
|
||||
"""Another site must not be able to claim the card either."""
|
||||
srv, hub = server
|
||||
hub.publish(make_event())
|
||||
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
||||
post(srv, "/api/card/claim", {},
|
||||
headers={"Origin": "https://evil.example"})
|
||||
assert excinfo.value.code == 403
|
||||
# the tap must still be claimable by the real caller
|
||||
assert hub.current_card()["consumed"] is False
|
||||
|
||||
|
||||
def test_claim_does_not_consume_on_refused_origin(server):
|
||||
srv, hub = server
|
||||
hub.publish(make_event(value=1234))
|
||||
with pytest.raises(urllib.error.HTTPError):
|
||||
post(srv, "/api/card/claim", {},
|
||||
headers={"Origin": "https://evil.example"})
|
||||
payload, error, _ = hub.consume_card()
|
||||
assert error is None
|
||||
assert payload["card"]["id"] == 1234
|
||||
@@ -0,0 +1,111 @@
|
||||
"""The event model and its output formatting."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from rdm_card_reader.card import (
|
||||
CONFIG_CARD_VALUE,
|
||||
CardEvent,
|
||||
format_event,
|
||||
)
|
||||
|
||||
SAMPLE = CardEvent(
|
||||
event="card_inserted",
|
||||
timestamp="2026-10-01 12:00:00",
|
||||
value=0x00C4A0B5,
|
||||
checksum=0xDE,
|
||||
card_type=0x0F,
|
||||
is_valid=True,
|
||||
port="/dev/ttyS0",
|
||||
)
|
||||
|
||||
|
||||
def test_hex_value_is_zero_padded_to_eight_digits():
|
||||
assert SAMPLE.hex_value == "0x00C4A0B5"
|
||||
|
||||
|
||||
def test_from_card_accepts_any_object_with_the_right_fields():
|
||||
class FakeCard:
|
||||
value = 1234
|
||||
checksum = 5
|
||||
type = 15
|
||||
is_valid = True
|
||||
|
||||
event = CardEvent.from_card("card_inserted", FakeCard(), "/dev/ttyUSB0")
|
||||
assert event.value == 1234
|
||||
assert event.hex_value == "0x000004D2"
|
||||
assert event.port == "/dev/ttyUSB0"
|
||||
|
||||
|
||||
def test_to_dict_has_the_documented_keys():
|
||||
assert set(SAMPLE.to_dict()) == {
|
||||
"event", "timestamp", "id", "hex", "type", "checksum", "is_valid", "port",
|
||||
}
|
||||
|
||||
|
||||
def test_to_json_is_parseable():
|
||||
assert json.loads(SAMPLE.to_json())["hex"] == "0x00C4A0B5"
|
||||
|
||||
|
||||
def test_inserted_block_lists_the_card_data():
|
||||
text = format_event(SAMPLE)
|
||||
assert "CARD INSERTED" in text
|
||||
assert "12886197" in text
|
||||
assert "0x00C4A0B5" in text
|
||||
assert "0x0F" in text
|
||||
assert "222" in text
|
||||
assert "True" in text
|
||||
|
||||
|
||||
def test_config_card_is_flagged_in_the_note():
|
||||
event = CardEvent(
|
||||
event="card_inserted",
|
||||
timestamp="2026-10-01 12:00:00",
|
||||
value=CONFIG_CARD_VALUE,
|
||||
checksum=0,
|
||||
card_type=0x0F,
|
||||
is_valid=True,
|
||||
port="/dev/ttyS0",
|
||||
)
|
||||
text = format_event(event)
|
||||
assert "config card" in text
|
||||
assert str(CONFIG_CARD_VALUE) in text
|
||||
|
||||
|
||||
def test_ordinary_card_has_no_config_note():
|
||||
assert "config card" not in format_event(SAMPLE)
|
||||
|
||||
|
||||
def test_removed_line_is_single_line():
|
||||
event = CardEvent(
|
||||
event="card_removed",
|
||||
timestamp="2026-10-01 12:00:00",
|
||||
value=0x00C4A0B5,
|
||||
checksum=0xDE,
|
||||
card_type=0x0F,
|
||||
is_valid=True,
|
||||
port="/dev/ttyS0",
|
||||
)
|
||||
text = format_event(event)
|
||||
assert text.count("\n") == 0
|
||||
assert "CARD REMOVED" in text
|
||||
|
||||
|
||||
def test_invalid_line_mentions_checksum():
|
||||
event = CardEvent(
|
||||
event="invalid_card",
|
||||
timestamp="2026-10-01 12:00:00",
|
||||
value=0xDEADBEEF,
|
||||
checksum=0,
|
||||
card_type=0x0F,
|
||||
is_valid=False,
|
||||
port="/dev/ttyS0",
|
||||
)
|
||||
assert "checksum mismatch" in format_event(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [(0, "0x00000000"), (0xFFFFFFFF, "0xFFFFFFFF")])
|
||||
def test_hex_value_boundaries(value, expected):
|
||||
event = CardEvent("card_inserted", "-", value, 0, 0x0F, True, "p")
|
||||
assert event.hex_value == expected
|
||||
@@ -0,0 +1,93 @@
|
||||
"""CLI behaviour: exit codes, output modes and argument handling."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from rdm_card_reader import cli
|
||||
|
||||
|
||||
def test_list_ports_exits_zero(capsys):
|
||||
assert cli.main(["--list-ports"]) == cli.EXIT_OK
|
||||
out = capsys.readouterr().out
|
||||
assert "/dev/ttyS0" in out
|
||||
|
||||
|
||||
def test_demo_exits_zero(capsys):
|
||||
assert cli.main(["--demo", "--demo-count", "1"]) == cli.EXIT_OK
|
||||
assert "CARD INSERTED" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_demo_json_emits_one_object_per_event(capsys):
|
||||
cli.main(["--demo", "--demo-count", "1", "--json"])
|
||||
lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()]
|
||||
events = [json.loads(line) for line in lines if line.startswith("{")]
|
||||
assert events
|
||||
assert {"event", "timestamp", "id", "hex"} <= set(events[0])
|
||||
|
||||
|
||||
def test_demo_count_is_respected(capsys):
|
||||
cli.main(["--demo", "--demo-count", "4"])
|
||||
assert capsys.readouterr().out.count("CARD INSERTED") >= 1
|
||||
|
||||
|
||||
def test_missing_library_exits_two(monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli, "available", lambda: False)
|
||||
assert cli.main([]) == cli.EXIT_NO_LIBRARY
|
||||
assert "rdm6300" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_no_port_available_exits_one(monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli, "available", lambda: True)
|
||||
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: None)
|
||||
assert cli.main(["--port", "/dev/nope"]) == cli.EXIT_NO_PORT
|
||||
assert "Could not open any serial port" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_explicit_port_is_the_only_one_tried(monkeypatch):
|
||||
monkeypatch.setattr(cli, "available", lambda: True)
|
||||
tried = []
|
||||
|
||||
def fake_open(callback, ports=None, **kwargs):
|
||||
tried.extend(ports or [])
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(cli, "open_reader", fake_open)
|
||||
cli.main(["--port", "/dev/ttyUSB0"])
|
||||
assert tried == ["/dev/ttyUSB0"]
|
||||
|
||||
|
||||
def test_keyboard_interrupt_is_handled(monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli, "available", lambda: True)
|
||||
|
||||
class Reader:
|
||||
port = "/dev/ttyS0"
|
||||
|
||||
def start(self):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
reader = Reader()
|
||||
monkeypatch.setattr(cli, "open_reader", lambda *a, **kw: reader)
|
||||
|
||||
assert cli.main([]) == cli.EXIT_OK
|
||||
assert reader.closed is True
|
||||
assert "Stopped by user" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_version_flag_exits_zero(capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli.main(["--version"])
|
||||
assert exc.value.code == 0
|
||||
assert "rdm-card-reader" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_json_mode_prints_json_from_the_callback(capsys):
|
||||
printer = cli._make_printer(as_json=True)
|
||||
from rdm_card_reader.card import CardEvent
|
||||
|
||||
printer(CardEvent("card_inserted", "-", 1, 2, 15, True, "p"))
|
||||
payload = json.loads(capsys.readouterr().out.strip())
|
||||
assert payload["id"] == 1
|
||||
@@ -0,0 +1,177 @@
|
||||
"""End-to-end test: a real tap must be claimable by a web page at any time.
|
||||
|
||||
Simulates the RDM6300 on a pseudo-terminal, starts the actual bridge the way
|
||||
systemd would, then walks the exact flow a web page performs:
|
||||
|
||||
card placed -> page loads -> polls /api/card -> claims it -> hand over once
|
||||
|
||||
Also asserts the two things that matter for security: the tap survives until
|
||||
claimed, and it cannot be claimed twice.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT = os.path.dirname(HERE)
|
||||
VENV_PY = os.path.join(PROJECT, ".venv", "bin", "python")
|
||||
PYTHON = VENV_PY if os.path.exists(VENV_PY) else sys.executable
|
||||
|
||||
CARD_VALUE = 0x00C4A0B5
|
||||
EXPECTED_ID = 12886197
|
||||
|
||||
|
||||
def em4100_frame(value, card_type=0x0F):
|
||||
"""12 hex nibbles: type + 4 id bytes + XOR checksum of those 5 bytes."""
|
||||
id_bytes = struct.pack(">I", value)
|
||||
checksum = card_type
|
||||
for b in id_bytes:
|
||||
checksum ^= b
|
||||
frame = (bytes([card_type]) + id_bytes + bytes([checksum])).hex().upper()
|
||||
assert len(frame) == 12, frame
|
||||
return frame.encode("ascii")
|
||||
|
||||
|
||||
def main():
|
||||
master, slave = pty.openpty()
|
||||
serial_port = os.ttyname(slave)
|
||||
|
||||
# Port 0 would be cleaner but the CLI needs a real number; pick a free one.
|
||||
import socket
|
||||
probe = socket.socket()
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
http_port = probe.getsockname()[1]
|
||||
probe.close()
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[PYTHON, "-m", "rdm_card_reader", "--serve",
|
||||
"--port", serial_port, "--bridge-port", str(http_port),
|
||||
"--mode", "login", "--tap-ttl", "15",
|
||||
"--allow-origin", "https://kiosk.example"],
|
||||
cwd=PROJECT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, start_new_session=True,
|
||||
)
|
||||
|
||||
output = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def pump():
|
||||
for line in proc.stdout:
|
||||
with lock:
|
||||
output.append(line.rstrip())
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
|
||||
base = "http://127.0.0.1:{}".format(http_port)
|
||||
results = {}
|
||||
|
||||
def api(path, method="GET", headers=None):
|
||||
request = urllib.request.Request(
|
||||
base + path, method=method,
|
||||
headers=headers or {},
|
||||
data=b"" if method == "POST" else None,
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
body = response.read()
|
||||
return response.status, (json.loads(body) if body else None)
|
||||
|
||||
try:
|
||||
# Wait for the HTTP server to come up.
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
api("/api/health")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
|
||||
results["bridge started"] = True
|
||||
|
||||
# The child needs a moment to open the serial port; frames written
|
||||
# before that are discarded, which would look like a reader bug.
|
||||
time.sleep(2.0)
|
||||
|
||||
# --- no card yet -------------------------------------------------
|
||||
_, state = api("/api/card")
|
||||
results["no card -> nothing claimable"] = state["claimable"] is False
|
||||
|
||||
# --- a single tap, as a person would -------------------------------
|
||||
frame = em4100_frame(CARD_VALUE)
|
||||
os.write(master, b"\x02" + frame + b"\x03")
|
||||
time.sleep(0.3)
|
||||
|
||||
# --- a page loads later and polls; the card must still be there ----
|
||||
time.sleep(1.0)
|
||||
_, state = api("/api/card")
|
||||
with lock:
|
||||
print("state after tap: {}".format(json.dumps(state)), flush=True)
|
||||
for line in output[-12:]:
|
||||
print(" " + line)
|
||||
results["card still available later"] = bool(
|
||||
state["claimable"] and state["card"]["id"] == EXPECTED_ID
|
||||
)
|
||||
results["card not consumed yet"] = state["consumed"] is False
|
||||
|
||||
# --- claim it, as the web page does -------------------------------
|
||||
status, claimed = api(
|
||||
"/api/card/claim", method="POST",
|
||||
headers={"Origin": "https://kiosk.example"},
|
||||
)
|
||||
results["claim returns 200"] = status == 200
|
||||
results["claim gives the right card"] = claimed["card"]["id"] == EXPECTED_ID
|
||||
|
||||
# --- single use ---------------------------------------------------
|
||||
try:
|
||||
api("/api/card/claim", method="POST",
|
||||
headers={"Origin": "https://kiosk.example"})
|
||||
results["second claim refused"] = False
|
||||
except urllib.error.HTTPError as exc:
|
||||
results["second claim refused"] = exc.code == 409
|
||||
|
||||
# --- a foreign origin cannot grab it either ------------------------
|
||||
_, state = api("/api/card")
|
||||
results["card marked consumed"] = state["consumed"] is True
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
try:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with lock:
|
||||
lines = list(output)
|
||||
|
||||
print("======== bridge output ========")
|
||||
for line in lines[:14]:
|
||||
print(line)
|
||||
print("===============================")
|
||||
print()
|
||||
|
||||
ok = True
|
||||
for name, passed in results.items():
|
||||
print(" [{}] {}".format("PASS" if passed else "FAIL", name))
|
||||
ok = ok and bool(passed)
|
||||
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
"""End-to-end test over a pseudo-terminal, with no RFID hardware.
|
||||
|
||||
The test owns the master side of a PTY, points the CLI at the slave side and
|
||||
writes real RDM6300 frames. This exercises the same code path a physical
|
||||
reader would: serial open -> byte stream -> parse -> callback -> stdout.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from rdm_card_reader.card import CONFIG_CARD_VALUE
|
||||
|
||||
from .test_protocol import encode_stream, encode_frame # noqa: F401
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(ROOT, "src")
|
||||
|
||||
TIMEOUT = 15.0
|
||||
|
||||
|
||||
class ReaderProcess:
|
||||
"""Run the CLI against a PTY and collect its stdout in the background."""
|
||||
|
||||
def __init__(self, extra_args=()):
|
||||
self.master, slave = pty.openpty()
|
||||
self.port = os.ttyname(slave)
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = SRC + os.pathsep + env.get("PYTHONPATH", "")
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, "-u", "-m", "rdm_card_reader",
|
||||
"--port", self.port, "--heartbeat", "0.3", *extra_args],
|
||||
cwd=ROOT, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
self.lines = []
|
||||
self._lock = threading.Lock()
|
||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _pump(self):
|
||||
for line in self.proc.stdout:
|
||||
with self._lock:
|
||||
self.lines.append(line.rstrip())
|
||||
|
||||
def output(self):
|
||||
with self._lock:
|
||||
return "\n".join(self.lines)
|
||||
|
||||
def send(self, frame):
|
||||
os.write(self.master, b"\x02" + frame + b"\x03")
|
||||
|
||||
def wait_for(self, needle, timeout=TIMEOUT):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if needle in self.output():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
def __enter__(self):
|
||||
self.wait_for("waiting for cards")
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGINT)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=5)
|
||||
except Exception:
|
||||
self.proc.kill()
|
||||
self._thread.join(timeout=3)
|
||||
for fd in (self.master, self.proc.stdout):
|
||||
try:
|
||||
fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||
def test_card_insert_and_remove_are_reported():
|
||||
card_id = 0x00C4A0B5
|
||||
frame = encode_frame(card_id)
|
||||
|
||||
with ReaderProcess() as rp:
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
|
||||
rp.send(frame)
|
||||
|
||||
assert rp.wait_for("CARD INSERTED"), rp.output()
|
||||
assert rp.wait_for("CARD REMOVED")
|
||||
|
||||
text = rp.output()
|
||||
assert rp.port in text
|
||||
assert str(card_id) in text
|
||||
assert "0x00C4A0B5" in text
|
||||
assert "222" in text # checksum = 0xDE
|
||||
assert "0x0F" in text # type
|
||||
assert "True" in text # valid
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||
def test_corrupt_checksum_is_reported_as_invalid():
|
||||
good = bytearray(encode_frame(0x1A2B3C4D))
|
||||
good[-1] = ord("0") # break the checksum
|
||||
bad = bytes(good)
|
||||
|
||||
with ReaderProcess() as rp:
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline and not rp.wait_for("INVALID CARD", 0.2):
|
||||
rp.send(bad)
|
||||
|
||||
assert "INVALID CARD" in rp.output()
|
||||
assert "checksum mismatch" in rp.output()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||
def test_json_mode_emits_machine_readable_events():
|
||||
with ReaderProcess(extra_args=("--json",)) as rp:
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline and not rp.wait_for('"event"', 0.2):
|
||||
rp.send(encode_frame(0x00C4A0B5))
|
||||
|
||||
payloads = [
|
||||
json.loads(line)
|
||||
for line in rp.output().splitlines()
|
||||
if line.strip().startswith("{")
|
||||
]
|
||||
assert payloads, rp.output()
|
||||
assert any(p["event"] == "card_inserted" for p in payloads)
|
||||
assert any(p["id"] == 0x00C4A0B5 for p in payloads)
|
||||
assert all(p["port"] == rp.port for p in payloads)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||
def test_config_card_is_annotated():
|
||||
with ReaderProcess() as rp:
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline and not rp.wait_for("config card", 0.2):
|
||||
rp.send(encode_frame(CONFIG_CARD_VALUE))
|
||||
|
||||
assert "config card" in rp.output()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="PTY test is Linux only")
|
||||
def test_removal_after_heartbeat_expires():
|
||||
with ReaderProcess() as rp:
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline and not rp.wait_for("CARD INSERTED", 0.2):
|
||||
rp.send(encode_frame(0x00C4A0B5))
|
||||
# stop sending and let the 0.3 s heartbeat lapse
|
||||
assert rp.wait_for("CARD REMOVED", timeout=5)
|
||||
|
||||
assert "CARD REMOVED" in rp.output()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for reliable serial-port identification.
|
||||
|
||||
The point of these tests is that picking the wrong ttyUSB silently is the
|
||||
failure mode that matters, so ambiguity must raise rather than guess.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
SRC = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"
|
||||
)
|
||||
if SRC not in sys.path:
|
||||
sys.path.insert(0, SRC)
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from rdm_card_reader import ports # noqa: E402
|
||||
from rdm_card_reader.ports import ( # noqa: E402
|
||||
AmbiguousPort,
|
||||
PortInfo,
|
||||
PortNotFound,
|
||||
best_usb_candidates,
|
||||
resolve,
|
||||
)
|
||||
|
||||
|
||||
def make_port(device, vid=None, pid=None, serial=None, description="",
|
||||
aliases=None, manufacturer=None, product=None):
|
||||
return PortInfo(
|
||||
device=device,
|
||||
vid=vid,
|
||||
pid=pid,
|
||||
serial_number=serial,
|
||||
description=description,
|
||||
aliases=list(aliases or []),
|
||||
manufacturer=manufacturer,
|
||||
product=product,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_adapters(monkeypatch):
|
||||
"""Two identical CH340 adapters plus an on-board UART."""
|
||||
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A",
|
||||
"USB Serial", ["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
||||
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "CH340B",
|
||||
"USB Serial", ["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
||||
onboard = make_port("/dev/ttyS0", description="ttyS0")
|
||||
monkeypatch.setattr(ports, "discover", lambda: [a, b, onboard])
|
||||
return a, b, onboard
|
||||
|
||||
|
||||
# -- identity ---------------------------------------------------------------
|
||||
|
||||
def test_usb_id_and_chip_name():
|
||||
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
||||
assert port.usb_id == "1a86:7523"
|
||||
assert port.chip == "CH340"
|
||||
assert port.is_usb is True
|
||||
|
||||
|
||||
def test_unknown_chip_still_identified_as_usb():
|
||||
port = make_port("/dev/ttyUSB0", 0xFFFF, 0x0001)
|
||||
assert port.chip is None
|
||||
assert port.is_usb is True
|
||||
|
||||
|
||||
def test_stable_id_prefers_by_id_symlink():
|
||||
port = make_port(
|
||||
"/dev/ttyUSB0",
|
||||
aliases=["/dev/serial/by-path/pci-1-usb-0:1.2:1.0-port0",
|
||||
"/dev/serial/by-id/usb-1a86_USB-if00-port0"],
|
||||
)
|
||||
assert port.stable_id == "/dev/serial/by-id/usb-1a86_USB-if00-port0"
|
||||
assert port.by_path.endswith("port0")
|
||||
|
||||
|
||||
def test_onboard_uart_is_not_usb():
|
||||
assert make_port("/dev/ttyS0").is_usb is False
|
||||
|
||||
|
||||
# -- scoring ----------------------------------------------------------------
|
||||
|
||||
def test_usb_adapter_scores_above_onboard_uart():
|
||||
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
||||
onboard = make_port("/dev/ttyS0")
|
||||
assert adapter.score() > onboard.score()
|
||||
|
||||
|
||||
def test_serial_number_raises_score():
|
||||
with_sn = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SN1")
|
||||
without = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, None)
|
||||
assert with_sn.score() > without.score()
|
||||
|
||||
|
||||
# -- matching ---------------------------------------------------------------
|
||||
|
||||
def test_match_by_device_path():
|
||||
assert make_port("/dev/ttyUSB0").matches("/dev/ttyUSB0")
|
||||
|
||||
|
||||
def test_match_by_serial_number():
|
||||
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "CH340A")
|
||||
assert port.matches("CH340A")
|
||||
|
||||
|
||||
def test_match_by_vid_pid():
|
||||
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523)
|
||||
assert port.matches("1a86:7523")
|
||||
|
||||
|
||||
def test_match_by_chip_name():
|
||||
assert make_port("/dev/ttyUSB0", 0x1A86, 0x7523).matches("ch340")
|
||||
|
||||
|
||||
def test_match_by_id_symlink():
|
||||
port = make_port("/dev/ttyUSB0",
|
||||
aliases=["/dev/serial/by-id/usb-1a86_A-if00-port0"])
|
||||
assert port.matches("/dev/serial/by-id/usb-1a86_A-if00-port0")
|
||||
|
||||
|
||||
def test_empty_selector_never_matches():
|
||||
assert not make_port("/dev/ttyUSB0").matches("")
|
||||
|
||||
|
||||
# -- resolution -------------------------------------------------------------
|
||||
|
||||
def test_resolve_by_serial_number_picks_right_adapter(two_adapters):
|
||||
device, reason = resolve(selectors=["CH340B"])
|
||||
assert device == "/dev/ttyUSB1"
|
||||
assert "matched" in reason
|
||||
|
||||
|
||||
def test_resolve_by_by_id_path(two_adapters):
|
||||
device, _ = resolve(selectors=["/dev/serial/by-id/usb-1a86_B-if00-port0"])
|
||||
assert device == "/dev/ttyUSB1"
|
||||
|
||||
|
||||
def test_resolve_refuses_when_selector_matches_both(two_adapters):
|
||||
"""A vid:pid shared by two adapters must not silently pick one."""
|
||||
with pytest.raises(AmbiguousPort):
|
||||
resolve(selectors=["1a86:7523"])
|
||||
|
||||
|
||||
def test_resolve_unknown_selector_raises_not_found(two_adapters):
|
||||
with pytest.raises(PortNotFound):
|
||||
resolve(selectors=["NOSUCHDEVICE"])
|
||||
|
||||
|
||||
def test_resolve_falls_back_to_raw_path_when_not_enumerable(monkeypatch, tmp_path):
|
||||
fake = tmp_path / "myport"
|
||||
fake.write_text("")
|
||||
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||
device, reason = resolve(selectors=[str(fake)])
|
||||
assert device == str(fake)
|
||||
assert "explicit path" in reason
|
||||
|
||||
|
||||
def test_autodetect_requires_exactly_one(monkeypatch):
|
||||
single = make_port("/dev/ttyUSB3", 0x1A86, 0x7523, "ONLY")
|
||||
monkeypatch.setattr(ports, "discover", lambda: [single, make_port("/dev/ttyS0")])
|
||||
device, reason = resolve(autodetect=True)
|
||||
assert device == "/dev/ttyUSB3"
|
||||
assert "autodetected" in reason
|
||||
|
||||
|
||||
def test_autodetect_refuses_with_two_adapters(two_adapters):
|
||||
with pytest.raises(AmbiguousPort):
|
||||
resolve(autodetect=True)
|
||||
|
||||
|
||||
def test_autodetect_ignores_onboard_uart(monkeypatch):
|
||||
"""An on-board UART must not make a single USB adapter look ambiguous."""
|
||||
adapter = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ONLY")
|
||||
monkeypatch.setattr(
|
||||
ports, "discover",
|
||||
lambda: [adapter, make_port("/dev/ttyS0"), make_port("/dev/ttyAMA0")],
|
||||
)
|
||||
device, _ = resolve(autodetect=True)
|
||||
assert device == "/dev/ttyUSB0"
|
||||
|
||||
|
||||
def test_fallback_order_used_when_no_usb(monkeypatch):
|
||||
"""Must not depend on the host actually having a serial port."""
|
||||
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||
monkeypatch.setattr(ports.os.path, "exists", lambda path: path == "/dev/ttyS0")
|
||||
device, reason = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
||||
assert device == "/dev/ttyS0"
|
||||
assert "default order" in reason
|
||||
|
||||
|
||||
def test_fallback_order_skips_missing_ports(monkeypatch):
|
||||
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
ports.os.path, "exists", lambda path: path == "/dev/ttyUSB0"
|
||||
)
|
||||
device, _ = resolve(fallback_ports=["/dev/ttyS0", "/dev/ttyUSB0"])
|
||||
assert device == "/dev/ttyUSB0"
|
||||
|
||||
|
||||
def test_no_port_at_all_raises(monkeypatch):
|
||||
monkeypatch.setattr(ports, "discover", lambda: [])
|
||||
with pytest.raises(PortNotFound):
|
||||
resolve(fallback_ports=[])
|
||||
|
||||
|
||||
# -- helper ----------------------------------------------------------------
|
||||
|
||||
def test_best_usb_candidates_dedupes_same_identity():
|
||||
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "SAME")
|
||||
dup = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "SAME")
|
||||
result = best_usb_candidates([a, dup])
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_best_usb_candidates_keeps_distinct_serial_numbers():
|
||||
a = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "A")
|
||||
b = make_port("/dev/ttyUSB1", 0x1A86, 0x7523, "B")
|
||||
assert len(best_usb_candidates([a, b])) == 2
|
||||
|
||||
|
||||
def test_describe_is_human_readable():
|
||||
port = make_port("/dev/ttyUSB0", 0x1A86, 0x7523, "ABC", "USB Serial")
|
||||
text = port.describe()
|
||||
assert "/dev/ttyUSB0" in text
|
||||
assert "CH340" in text
|
||||
assert "ABC" in text
|
||||
@@ -0,0 +1,108 @@
|
||||
"""RDM6300 wire format: type + id + checksum, 12 hex nibbles per frame.
|
||||
|
||||
The checksum is the XOR of the type byte *and* the four id bytes. Getting this
|
||||
wrong (e.g. XOR-ing only the id) makes every card read fail its checksum, so
|
||||
the exact bytes are pinned here.
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from rdm_card_reader.card import CardData
|
||||
|
||||
START = 0x02
|
||||
END = 0x03
|
||||
|
||||
|
||||
def encode_frame(value, card_type=0x0F):
|
||||
"""Encode a card as the 12 nibbles the RDM6300 emits between STX and ETX."""
|
||||
id_bytes = struct.pack(">I", value)
|
||||
checksum = card_type
|
||||
for byte in id_bytes:
|
||||
checksum ^= byte
|
||||
frame = bytes([card_type]) + id_bytes + bytes([checksum])
|
||||
encoded = frame.hex().upper().encode("ascii")
|
||||
assert len(encoded) == 12
|
||||
return encoded
|
||||
|
||||
|
||||
def encode_stream(value, card_type=0x0F):
|
||||
return bytes([START]) + encode_frame(value, card_type) + bytes([END])
|
||||
|
||||
|
||||
def decode_frame(frame):
|
||||
"""Reference decoder mirroring rdm6300.BaseReader._parse_fragment."""
|
||||
nibbles = [int(ch, 16) for ch in frame.decode("ascii")]
|
||||
|
||||
def to_int(chunk):
|
||||
out = 0
|
||||
for item in chunk:
|
||||
out = (out << 4) | item
|
||||
return out
|
||||
|
||||
calculated = 0
|
||||
for i in range(0, 10, 2):
|
||||
calculated ^= (nibbles[i] << 4) | nibbles[i + 1]
|
||||
received = to_int(nibbles[10:12])
|
||||
|
||||
return CardData(
|
||||
value=to_int(nibbles[2:10]),
|
||||
checksum=received,
|
||||
is_valid=received == calculated,
|
||||
type=to_int(nibbles[0:2]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(0x00C4A0B5, b"0F00C4A0B5DE"),
|
||||
(0x1A2B3C4D, b"0F1A2B3C4D4F"),
|
||||
],
|
||||
)
|
||||
def test_encoded_frame_bytes_are_stable(value, expected):
|
||||
assert encode_frame(value) == expected
|
||||
|
||||
|
||||
def test_frame_is_twelve_nibbles():
|
||||
assert len(encode_frame(0x00C4A0B5)) == 12
|
||||
|
||||
|
||||
def test_checksum_covers_type_byte_not_only_id():
|
||||
"""Same id with a different type byte must change the checksum."""
|
||||
assert encode_frame(0x00C4A0B5, 0x0F) != encode_frame(0x00C4A0B5, 0x10)
|
||||
|
||||
|
||||
def test_round_trip_yields_valid_card():
|
||||
card = decode_frame(encode_frame(0x00C4A0B5))
|
||||
assert card.value == 0x00C4A0B5
|
||||
assert card.type == 0x0F
|
||||
assert card.checksum == 0xDE
|
||||
assert card.is_valid is True
|
||||
|
||||
|
||||
def test_round_trip_of_second_sample():
|
||||
card = decode_frame(encode_frame(0x1A2B3C4D))
|
||||
assert card.value == 0x1A2B3C4D
|
||||
assert card.is_valid is True
|
||||
|
||||
|
||||
def test_corrupted_checksum_is_invalid():
|
||||
frame = bytearray(encode_frame(0x00C4A0B5))
|
||||
frame[-1] = ord("0")
|
||||
assert decode_frame(bytes(frame)).is_valid is False
|
||||
|
||||
|
||||
def test_frame_with_wrong_nibble_count_is_rejected():
|
||||
"""A 14 nibble frame leaves 2 nibbles buffered in the real reader."""
|
||||
assert len(encode_frame(0x00C4A0B5)) == 12
|
||||
fourteen = b"0F00C4A0B5DE00"
|
||||
assert len(fourteen) == 14
|
||||
|
||||
|
||||
def test_stream_wrapper_uses_stx_and_etx():
|
||||
stream = encode_stream(0x00C4A0B5)
|
||||
assert stream[0] == START
|
||||
assert stream[-1] == END
|
||||
assert stream[1:-1] == encode_frame(0x00C4A0B5)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user