Initializare dupa curatare folder
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
# rdm-card-reader
|
||||
|
||||
Read 125 kHz RFID cards (RDM6300 reader, EM4100 cards) on a serial port and
|
||||
print what happens: **card inserted** with the card data, then **card removed**.
|
||||
|
||||
Built for a Raspberry Pi workstation, but it runs anywhere with a serial port.
|
||||
|
||||
> Starting it on a machine with no reader attached? See **[LAUNCH.md](LAUNCH.md)**
|
||||
> — it explains the startup sequence, why it exits `1` with no hardware, and how
|
||||
> to exercise the reader path anyway.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
```
|
||||
$ rdm-card-reader
|
||||
|
||||
-> Opening /dev/ttyAMA0 ...
|
||||
OK reader ready on /dev/ttyAMA0 - waiting for cards (Ctrl+C to stop)
|
||||
|
||||
----------------------------------------------------------
|
||||
CARD INSERTED - 2026-10-01 14:56:54
|
||||
id : 12886197 (0x00C4A0B5)
|
||||
type : 0x0F
|
||||
checksum : 222
|
||||
valid : True
|
||||
port : /dev/ttyAMA0
|
||||
----------------------------------------------------------
|
||||
CARD REMOVED - 2026-10-01 14:56:54 id: 12886197 (0x00C4A0B5)
|
||||
```
|
||||
|
||||
`id` is the number printed on the card. A card is reported once on insert
|
||||
(the reader library de-duplicates repeated reads while the card sits on the
|
||||
antenna) and once on removal.
|
||||
|
||||
### Startup sequence
|
||||
|
||||
1. **Check dependencies.** If `rdm6300` / `pyserial` cannot be imported, install
|
||||
them automatically and carry on. A fresh device needs no manual `pip` work.
|
||||
2. **Find the reader.** Resolve the serial port and open it, identifying the
|
||||
adapter by serial number or `by-id` rather than trusting `ttyUSB0`.
|
||||
3. **Start reading.** Read frames in a loop; each valid one becomes a card
|
||||
event.
|
||||
4. **Publish it.** In `--serve` mode the event is latched so a web page can
|
||||
claim it whenever it gets around to asking.
|
||||
|
||||
Steps 1 and 2 both fail *loudly with a reason* rather than starting up
|
||||
half-working, because a reader that silently picks the wrong port or runs
|
||||
without its library is worse than one that refuses to start.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
On a machine **with internet**:
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
`rdm6300` and `pyserial` are pulled in automatically. On Raspberry Pi OS with
|
||||
an externally-managed Python, add `--break-system-packages` or use a venv:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && . .venv/bin/activate && pip install .
|
||||
```
|
||||
|
||||
### On a device with no internet
|
||||
|
||||
`pip install .` will **not** work offline. It builds the project, which makes
|
||||
pip download `setuptools` into an isolated build environment, and that fails
|
||||
with:
|
||||
|
||||
```
|
||||
ERROR: Could not find a version that satisfies the requirement setuptools>=68
|
||||
(from versions: none)
|
||||
```
|
||||
|
||||
The runtime dependencies are bundled in `src/rdm_card_reader/wheels/`, so
|
||||
install those directly and put `src` on the path — no build step, no network:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --no-index \
|
||||
--find-links src/rdm_card_reader/wheels rdm6300 pyserial
|
||||
echo "$PWD/src" > "$( .venv/bin/python -c \
|
||||
'import sysconfig; print(sysconfig.get_paths()["purelib"])' )/rdm_card_reader_src.pth"
|
||||
.venv/bin/python -m rdm_card_reader --demo # verify, no hardware needed
|
||||
```
|
||||
|
||||
`deploy/install.sh` does exactly this, plus the service setup. Running the
|
||||
app directly also works: it installs anything missing from the same bundled
|
||||
wheels on first start.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
rdm-card-reader # auto-detect the serial port
|
||||
rdm-card-reader --port /dev/ttyUSB0 # use a specific port
|
||||
rdm-card-reader --json # one JSON object per event
|
||||
rdm-card-reader --heartbeat 1.5 # slower card-removed detection
|
||||
rdm-card-reader --list-ports # show which ports exist, then exit
|
||||
rdm-card-reader --serve # serve card data to a web app (see below)
|
||||
rdm-card-reader --demo # simulate reads, no hardware needed
|
||||
```
|
||||
|
||||
Also works as a module: `python -m rdm_card_reader`.
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--port` | auto-detect | Serial port: device path, by-id symlink, serial number or `vid:pid` |
|
||||
| `--autodetect` | off | Pick the only USB adapter; refuse if there is more than one |
|
||||
| `--heartbeat` | `0.5` | Seconds without a read before the card counts as removed |
|
||||
| `--json` | off | Machine-readable output instead of the text block |
|
||||
| `--list-ports` | – | List candidate and identified ports, then exit |
|
||||
| `--verbose-ports` | off | With `--list-ports`, show by-id / by-path detail |
|
||||
| `--demo` | – | Simulated reads; needs no hardware and no `rdm6300` |
|
||||
| `--demo-count` | `3` | Number of simulated reads in `--demo` |
|
||||
| `--no-install-deps` | off | Never install missing libraries automatically |
|
||||
| `--wheel-dir` | bundled | Install from local wheels in this DIR instead of the bundled ones |
|
||||
| `--version` | – | Print the version |
|
||||
|
||||
With `--serve`: `--host` (`127.0.0.1`), `--bridge-port` (`8765`), `--mode`
|
||||
(`login`), `--tap-ttl` (`10`), `--allow-origin` (repeatable), `--token`,
|
||||
`--allow-remote`, `--verbose`.
|
||||
|
||||
When `--port` is omitted the ports are tried in this order, the same order the
|
||||
workstation app uses:
|
||||
|
||||
```
|
||||
/dev/ttyS0 -> /dev/ttyAMA0 -> /dev/ttyUSB0 -> /dev/ttyACM0
|
||||
```
|
||||
|
||||
### Output modes
|
||||
|
||||
Text mode is for humans and logs. `--json` prints one object per event, which
|
||||
is what you want when piping into another program:
|
||||
|
||||
```json
|
||||
{"event": "card_inserted", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}
|
||||
{"event": "card_removed", "timestamp": "2026-10-01 14:56:54", "id": 12886197, "hex": "0x00C4A0B5", "type": 15, "checksum": 222, "is_valid": true, "port": "/dev/ttyAMA0"}
|
||||
```
|
||||
|
||||
### Automatic dependency install
|
||||
|
||||
The wheels for `rdm6300` and `pyserial` **ship inside this package**
|
||||
(`rdm_card_reader/wheels/`), so a device with no internet can still set itself
|
||||
up. On first start the reader installs whatever is missing from those wheels:
|
||||
|
||||
```
|
||||
Reader library not found - checking dependencies...
|
||||
Missing reader dependencies: rdm6300
|
||||
Using the wheels bundled with this package:
|
||||
/opt/rdm-card-reader/.venv/lib/python3.13/site-packages/rdm_card_reader/wheels
|
||||
Installing from .../rdm_card_reader/wheels
|
||||
Dependencies ready: rdm6300
|
||||
```
|
||||
|
||||
No network access is needed. They ship with the package rather than being
|
||||
fetched at build time, because the build machine and the target device are not
|
||||
necessarily the same.
|
||||
|
||||
Both wheels are **pure Python** (`py3-none-any` / `py2.py3-none-any`), which is
|
||||
what makes this possible: the same files install on armv7l, aarch64 and x86_64.
|
||||
This differs from the workstation app's `Files/repository/` cache, which held
|
||||
architecture-specific binaries (aarch64 manylinux wheels) and so could not be
|
||||
committed — that cache only ever existed on the Pi devices themselves.
|
||||
|
||||
* Nothing is installed when the libraries are already present, so a normal start
|
||||
does no network work at all.
|
||||
* The bundled install runs with `--no-index`, so it works with no route to PyPI
|
||||
at all — no proxy or DNS needed.
|
||||
* Wheels are verified against a bundled `SHA256SUMS` before installation. **A
|
||||
wheel that fails its checksum is refused, not installed** — then it falls back
|
||||
to PyPI, and if that is unreachable it reports `exit 2` rather than proceeding
|
||||
with something unverified.
|
||||
* Outside a virtualenv pip is given `--break-system-packages`, which Debian and
|
||||
Raspberry Pi OS require before they will touch the system Python.
|
||||
* A failed install does not crash: it reports `exit 2` and tells you the exact
|
||||
command to run by hand.
|
||||
|
||||
To install from your own wheel directory instead (for example a newer version
|
||||
than the bundled one), pass `--wheel-dir`. An explicit directory that is
|
||||
incomplete or fails verification is reported and then PyPI is used, rather than
|
||||
failing outright. To manage dependencies yourself, pass `--no-install-deps`.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | Ran normally, or stopped with Ctrl+C |
|
||||
| `1` | No serial port could be opened |
|
||||
| `2` | The `rdm6300` library is missing and could not be installed |
|
||||
| `3` | The port selector matched more than one adapter (refused to guess) |
|
||||
|
||||
---
|
||||
|
||||
## Serving card data to a web app (`--serve`)
|
||||
|
||||
This is the mode for **authenticating a web page**: the reader runs as a service
|
||||
on the device, and the page asks it for the card that was just tapped.
|
||||
|
||||
```bash
|
||||
rdm-card-reader --serve --port /dev/rdm-reader --allow-origin https://your.web.app
|
||||
```
|
||||
|
||||
The bridge reports **card ids only**. Deciding which user a card belongs to is
|
||||
your server's job, not this program's.
|
||||
|
||||
### The flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Card
|
||||
participant B as rdm-card-reader (Pi)
|
||||
participant K as Web page
|
||||
participant W as Your server
|
||||
|
||||
C->>B: tap
|
||||
B->>B: latch the tap (tap-ttl seconds)
|
||||
K->>B: GET /api/card
|
||||
B-->>K: {claimable: true, card: {id: ...}}
|
||||
K->>B: POST /api/card/claim
|
||||
B-->>K: {card: {id: ...}} (once)
|
||||
K->>W: POST /login {card_id}
|
||||
W->>W: card_id -> user
|
||||
W-->>K: session
|
||||
```
|
||||
|
||||
The point of the *latch* is that the tap stays available for `--tap-ttl`
|
||||
seconds (default 10), **including after the card is lifted**. A quick tap
|
||||
therefore cannot be lost because the page happened to poll a moment later.
|
||||
|
||||
The point of *claim* is single use. Once a tap is spent, a page reload or a
|
||||
second tab gets `409 Conflict` instead of a second login, so one physical tap
|
||||
can never authenticate two sessions.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/card` | The latched tap: `present`, `claimable`, `consumed`, `age_seconds`, `card` |
|
||||
| `POST` | `/api/card/claim` | Spend the tap on one login. `200`, or `409` if used/none, `410` if expired |
|
||||
| `GET` | `/api/events` | Server-Sent Events stream, pushes each event as it happens |
|
||||
| `GET` | `/api/last` | Most recent card event, or `204` |
|
||||
| `GET` | `/api/taps` | Recent tap history (`?limit=N`) |
|
||||
| `GET` | `/api/health` | Reader state, port, uptime, tap counts — for monitoring |
|
||||
| `POST` | `/api/mode` | `{"mode": "login"}` or `{"mode": "monitor"}` |
|
||||
|
||||
`login` mode (default) publishes taps only — a tap is an authentication
|
||||
attempt. `monitor` mode also publishes removals, which is what you want for
|
||||
watching a reader rather than logging people in.
|
||||
|
||||
### Browser example
|
||||
|
||||
```js
|
||||
const state = await (await fetch("http://127.0.0.1:8765/api/card")).json();
|
||||
if (state.claimable) {
|
||||
const res = await fetch("http://127.0.0.1:8765/api/card/claim", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const { card } = await res.json();
|
||||
await fetch("/login/card", { // your server decides who card.id is
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ card_id: card.id, hex: card.hex }),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or push instead of poll, so a tap logs in immediately:
|
||||
|
||||
```js
|
||||
const events = new EventSource("http://127.0.0.1:8765/api/events");
|
||||
events.onmessage = (e) => { const tap = JSON.parse(e.data); /* tap.id */ };
|
||||
```
|
||||
|
||||
### Security, and its limits
|
||||
|
||||
Read :ref:`the warning below <card-ids-are-not-secrets>`: an EM4100 id is a
|
||||
*username*, not a password. These controls bound who can *read* the bridge;
|
||||
they do not make the card itself convincing as a sole credential.
|
||||
|
||||
| Control | Behaviour |
|
||||
| --- | --- |
|
||||
| Bind address | `127.0.0.1` by default. Anything else needs `--allow-remote`, so an open card-event endpoint cannot appear on the LAN by accident. |
|
||||
| Origins | Cross-origin browser requests are **refused** unless `--allow-origin` lists that exact origin. Stops another site from reading your taps. |
|
||||
| Token | `--token` (or `RDM_BRIDGE_TOKEN`) is required from non-loopback callers. |
|
||||
| Single use | A tap can only ever produce one successful claim. |
|
||||
| Expiry | A tap older than `--tap-ttl` is refused with `410`. |
|
||||
|
||||
Add a second factor — a PIN after the tap — wherever a cloned card would
|
||||
actually cost you something.
|
||||
|
||||
<a name="card-ids-are-not-secrets"></a>
|
||||
> **Card ids are not secrets.** EM4100 cards broadcast a fixed 40-bit id with no
|
||||
> challenge-response, and clones are cheap. If holding an id is enough to log
|
||||
> in, the security is "whoever knows the number". Treat card auth as
|
||||
> identification, and require something else for anything sensitive.
|
||||
|
||||
---
|
||||
|
||||
## Running at boot (Raspberry Pi)
|
||||
|
||||
```bash
|
||||
sudo ./deploy/install.sh --origin https://your.web.app
|
||||
```
|
||||
|
||||
That installs a service account, a virtualenv, a systemd unit and a udev rule,
|
||||
then enables the service. Afterwards:
|
||||
|
||||
```bash
|
||||
systemctl status rdm-card-reader # state
|
||||
journalctl -u rdm-card-reader -f # live log
|
||||
curl http://127.0.0.1:8765/api/card # tap a card, then run this
|
||||
```
|
||||
|
||||
### Identifying the right ttyUSB
|
||||
|
||||
`/dev/ttyUSB0` is **not** a stable identity — unplug something and the number
|
||||
changes. Point this at the wrong adapter and you authenticate the wrong reader,
|
||||
so the tooling is deliberately strict about it.
|
||||
|
||||
```bash
|
||||
rdm-card-reader --list-ports --verbose-ports
|
||||
```
|
||||
|
||||
```
|
||||
Identified serial ports (best candidate first):
|
||||
[score 100] /dev/ttyUSB0 :: CH340 :: 1a86:7523 :: S/N AB0XYZ12
|
||||
by-id : /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
|
||||
by-path : /dev/serial/by-path/pci-0000:01:00.0-usb-0:1.2:1.0-port0
|
||||
```
|
||||
|
||||
Then pin it, most specific first:
|
||||
|
||||
```bash
|
||||
--port /dev/rdm-reader # udev alias
|
||||
--port /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 # by device
|
||||
--port AB0XYZ12 # by serial number
|
||||
--port 1a86:7523 # by chip
|
||||
```
|
||||
|
||||
`--autodetect` picks the only USB adapter, and **refuses** when there is more
|
||||
than one — silently choosing between two readers is exactly the failure this
|
||||
avoids. Verify the wiring in `deploy/99-rdm-reader.rules`.
|
||||
|
||||
Exit codes: `0` ok, `1` no port, `2` library missing, `3` port ambiguous.
|
||||
|
||||
---
|
||||
|
||||
## Status LED
|
||||
|
||||
An LED driven by card events, on **GPIO 23** by default — the same pin the
|
||||
workstation app used.
|
||||
|
||||
```bash
|
||||
rdm-card-reader # lit while running, blinks on a bad read
|
||||
rdm-card-reader --led-pin 17 # a different pin
|
||||
rdm-card-reader --led-mode presence # lit only while a card is on the reader
|
||||
rdm-card-reader --no-led # no LED at all
|
||||
```
|
||||
|
||||
| Flag | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `--no-led` | off | Do not drive a LED |
|
||||
| `--led-pin` | `23` | GPIO pin (matches the workstation app) |
|
||||
| `--led-mode` | `error` | See the two readings below |
|
||||
| `--led-no-error-blink` | off | Do not blink on a bad read |
|
||||
|
||||
Two modes, because the light can mean two different things:
|
||||
|
||||
| Mode | Behaviour | Use when |
|
||||
| --- | --- | --- |
|
||||
| `error` (default) | **Lit whenever the reader is running.** Dark while a card is being read, blinks on a bad read | The light should mean "the reader is alive and ready" |
|
||||
| `presence` | **Lit only while a card is on the reader** | The light should mean "a card is here" |
|
||||
|
||||
Wiring: LED anode to the GPIO pin through a ~330 Ω resistor, cathode to GND.
|
||||
|
||||
### It cannot break the reader
|
||||
|
||||
Three deliberate choices, because a LED is a convenience and the reader is the
|
||||
point:
|
||||
|
||||
* **Never blocks.** The workstation app toggled the LED directly on the reader
|
||||
thread, which stalls card detection. Here every change is queued to a worker
|
||||
thread, so a toggle returns immediately even with slow hardware.
|
||||
* **Never fatal.** On a machine with no GPIO, `gpiozero` raises
|
||||
`BadPinFactory`. The app falls back to a no-op LED and says why, rather than
|
||||
refusing to run.
|
||||
* **Never crashes on a dead pin.** A failed toggle is logged and ignored.
|
||||
|
||||
Startup reports which of the three states it is in:
|
||||
|
||||
```
|
||||
LED on GPIO 23 (error mode, gpiozero) # working
|
||||
LED unavailable: gpiozero not installed # library missing
|
||||
LED unavailable: no usable GPIO on pin 23: ... # no pin factory / pin busy
|
||||
```
|
||||
|
||||
That distinction matters: `BadPinFactory` subclasses `ImportError`, so a naive
|
||||
check reports "not installed" when the library is present but has no usable pin
|
||||
provider — which would send you to the wrong fix.
|
||||
|
||||
> **If the workstation app also runs on this device, use a different pin** — two
|
||||
> processes driving GPIO 23 will fight, and the LED will flicker.
|
||||
|
||||
`gpiozero` and `colorzero` are installed automatically on first use, from the
|
||||
bundled wheels, so this works offline. They add ~180 KB. `setuptools`, which
|
||||
`gpiozero` pulls in as a *packaging* dependency, is deliberately not vendored:
|
||||
it is never imported at runtime and would add 818 KB for nothing.
|
||||
|
||||
---
|
||||
|
||||
## Wiring
|
||||
|
||||
The RDM6300 talks plain serial at **9600 baud, 8N1**. Connect it to the Pi's
|
||||
UART (pins 8/10 for TXD/RXD, 5 V and GND) or through a USB-to-serial adapter.
|
||||
|
||||
On a Raspberry Pi you usually need to free up the primary UART first:
|
||||
|
||||
```bash
|
||||
sudo raspi-config # Interface Options -> Serial Port
|
||||
# "login shell over serial?" -> No
|
||||
# "serial port hardware?" -> Yes
|
||||
sudo usermod -aG dialout $USER # then log out and back in
|
||||
```
|
||||
|
||||
The GPIO UART appears as `/dev/serial0` (a symlink to `/dev/ttyAMA0` or
|
||||
`/dev/ttyS0`).
|
||||
|
||||
---
|
||||
|
||||
## The protocol
|
||||
|
||||
Worth knowing if you ever decode the bytes yourself. The reader emits a frame
|
||||
between `STX` (0x02) and `ETX` (0x03) as **12 ASCII hex nibbles**:
|
||||
|
||||
```
|
||||
0x02 0F 00C4A0B5 DE 0x03
|
||||
| | |
|
||||
| | +-- checksum
|
||||
| +----------- card id (4 bytes, big-endian)
|
||||
+-------------- type byte
|
||||
```
|
||||
|
||||
Two details that are easy to get wrong:
|
||||
|
||||
1. **Field order is type, id, checksum** — the id is *not* first.
|
||||
2. **The checksum is the XOR of the type byte *and* all four id bytes.** XOR-ing
|
||||
only the id bytes produces a mismatch on every card.
|
||||
|
||||
```
|
||||
checksum = type ^ b0 ^ b1 ^ b2 ^ b3
|
||||
= 0x0F ^ 0x00 ^ 0xC4 ^ 0xA0 ^ 0xB5
|
||||
= 0xDE (= 222)
|
||||
```
|
||||
|
||||
A frame whose checksum does not match is reported as `INVALID CARD` and is
|
||||
never treated as a card insert.
|
||||
|
||||
There is also a subtle failure mode: a frame with the wrong number of nibbles
|
||||
is not rejected outright. Two stray nibbles stay in the buffer and corrupt the
|
||||
*next* read instead of the current one, so a single glitch can look like a
|
||||
problem with the following card.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
| --- | --- |
|
||||
| `device not found` on every port | Check the wiring; run `rdm-card-reader --list-ports` |
|
||||
| `permission denied` | `sudo usermod -aG dialout $USER`, then log out and back in |
|
||||
| Reader opens but no cards | Card must be EM4100 / 125 kHz; hold it ~2 cm from the coil |
|
||||
| `INVALID CARD` repeatedly | Noisy supply or long/unshielded wiring; shorten it |
|
||||
| Every card reported twice | Lower `--heartbeat` if the card is lifted quickly |
|
||||
|
||||
---
|
||||
|
||||
## Check it works
|
||||
|
||||
With no RFID hardware attached, `--demo` exercises the formatting path and
|
||||
`--list-ports` shows what serial ports exist:
|
||||
|
||||
```bash
|
||||
rdm-card-reader --demo # simulated reads, no hardware needed
|
||||
rdm-card-reader --list-ports # candidate and identified ports
|
||||
```
|
||||
|
||||
To drive the real reader path with no hardware, feed RDM6300 frames into a
|
||||
pseudo-terminal — see the snippet in **[LAUNCH.md](LAUNCH.md)**.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
src/rdm_card_reader/
|
||||
card.py data model, constants, output formatting (no hardware)
|
||||
reader.py serial port handling, event callbacks
|
||||
cli.py argument parsing and program flow
|
||||
demo.py simulated reads
|
||||
ports.py stable serial port identification
|
||||
bridge.py the HTTP bridge behind --serve
|
||||
led.py status LED
|
||||
bootstrap.py offline dependency install
|
||||
wheels/ the bundled wheels (offline install source)
|
||||
```
|
||||
|
||||
### Using it as a library
|
||||
|
||||
```python
|
||||
from rdm_card_reader import open_reader
|
||||
|
||||
def on_event(event):
|
||||
if event.event == "card_inserted":
|
||||
print("card", event.value, event.hex_value)
|
||||
|
||||
reader = open_reader(on_event)
|
||||
if reader is None:
|
||||
raise SystemExit("no reader found")
|
||||
|
||||
reader.start() # blocks; call reader.close() to stop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user