"""Card data model, protocol constants and output formatting. Nothing in this module touches hardware or the serial port, which keeps the data model easy to unit test. """ from __future__ import annotations import json import time from collections import namedtuple from dataclasses import dataclass from typing import Any, Dict try: # pragma: no cover - depends on the environment from rdm6300 import CardData except ImportError: # pragma: no cover - fallback so the model stays importable CardData = namedtuple("CardData", ["value", "checksum", "type", "is_valid"]) #: Serial devices tried in order. ``/dev/ttyS0`` first matches the Raspberry Pi #: default, the others cover USB / GPIO UART dongles. CANDIDATE_PORTS = ( "/dev/ttyS0", "/dev/ttyAMA0", "/dev/ttyUSB0", "/dev/ttyACM0", ) #: A special card id used by the WMT workstation app to open its config screen. #: Reported as a note only - this package never acts on it. CONFIG_CARD_VALUE = 12886709 #: Seconds without a read before a card is considered removed. Set to ``None`` #: to disable removal detection entirely. DEFAULT_HEARTBEAT_INTERVAL = 0.5 BAR = "-" * 58 EVENT_INSERTED = "card_inserted" EVENT_REMOVED = "card_removed" EVENT_INVALID = "invalid_card" def now_stamp() -> str: """Local timestamp in the same format the workstation app logs.""" return time.strftime("%Y-%m-%d %H:%M:%S") @dataclass(frozen=True) class CardEvent: """A single card read event, decoupled from the reader library.""" event: str timestamp: str value: int checksum: int card_type: int is_valid: bool port: str # -- constructors ----------------------------------------------------- @classmethod def from_card(cls, event: str, card: Any, port: str) -> "CardEvent": """Build an event from an ``rdm6300.CardData`` (or anything alike).""" return cls( event=event, timestamp=now_stamp(), value=int(card.value), checksum=int(card.checksum), card_type=int(card.type), is_valid=bool(card.is_valid), port=str(port), ) # -- derived values --------------------------------------------------- @property def hex_value(self) -> str: """Card id as an 8 digit hex string, e.g. ``0x00C4A0B5``.""" return "0x{:08X}".format(self.value) @property def is_config_card(self) -> bool: return self.value == CONFIG_CARD_VALUE # -- serialisation ---------------------------------------------------- def to_dict(self) -> Dict[str, Any]: """Machine-readable form, used by ``--json``.""" return { "event": self.event, "timestamp": self.timestamp, "id": self.value, "hex": self.hex_value, "type": self.card_type, "checksum": self.checksum, "is_valid": self.is_valid, "port": self.port, } def to_json(self) -> str: return json.dumps(self.to_dict()) def format_event(event: CardEvent) -> str: """Render an event as the human-readable block printed on stdout.""" if event.event == EVENT_INSERTED: lines = [ "", BAR, " CARD INSERTED - {}".format(event.timestamp), " id : {} ({})".format(event.value, event.hex_value), " type : 0x{:02X}".format(event.card_type), " checksum : {}".format(event.checksum), " valid : {}".format(event.is_valid), " port : {}".format(event.port), ] if event.is_config_card: lines.append( " note : this is the config card (id {})".format( CONFIG_CARD_VALUE ) ) lines.append(BAR) return "\n".join(lines) if event.event == EVENT_REMOVED: return " CARD REMOVED - {} id: {} ({})".format( event.timestamp, event.value, event.hex_value ) return " INVALID CARD - {} raw: {} (checksum mismatch)".format( event.timestamp, event.hex_value )