43c1f45e29
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
193 lines
12 KiB
Markdown
193 lines
12 KiB
Markdown
---
|
||
type: entity
|
||
tags: [parking, hardware, readers, qr, dingtian]
|
||
sources: [dingtian-dt008]
|
||
updated: 2026-07-04
|
||
status: open
|
||
---
|
||
|
||
# Dingtian DT-008 (QR/RFID access reader)
|
||
|
||
The project's **QR / barcode / RFID reader** — Dingtian **DT-008**, the **same vendor as the
|
||
[[dingtian-relay|relay board]]** (which is why it integrates the same HTTP-push way). A static
|
||
optical + card scanner for **QR / barcode (simple)** and **ID/IC/NFC cards**. This is the
|
||
**[[ticket-encoding|QR ticket]]** scanner (read at the pay station / exit lane) and a path for **QR
|
||
or RFID [[subscription]]** credentials. Product page: dingtian-tech.com/en_us/qr_code_reader.html.
|
||
(See [[dingtian-dt008|spec summary]] / `raw/`.)
|
||
|
||
> **Naming correction (2026-06-28).** Through most of this project this reader was wrongly called
|
||
> **"GEE" / "GEE/Fondvision" / "GEE-QR-ER80"** — a bad early assumption. There is no GEE device; it
|
||
> is the **Dingtian DT-008**. The driver id was renamed `gee-qr-reader` → `dingtian-qr-reader`
|
||
> (migration 0015 rewrites existing rows). All *protocol/behaviour* facts below were observed on the
|
||
> real hardware and remain correct — only the device identity was wrong.
|
||
|
||
## What it is
|
||
|
||
- **QR/barcode + card.** Reads **QR / simple barcode** AND **ID/IC/NFC cards** (~0.2 s, 0–10 cm).
|
||
(An earlier note guessed "ER80-EM = 125 kHz EM4100 prox" — that was part of the same mis-id.)
|
||
- **Interfaces: Wiegand 26/34, TCP/IP, USB, RS485.** (NOT RS-232 — that was a fictional-datasheet
|
||
claim.) For our integration it talks **HTTP over TCP/IP**; Wiegand is the reader's *output line*
|
||
on a valid read, not the host transport.
|
||
- **Power** 9–16 V DC, 800 mA; **86×86×42 mm**; −30…70 °C. Fits the [[disk-os-hardening|appliance]].
|
||
|
||
## How it integrates — HTTP-GET push, server replies the verdict (confirmed via SDK)
|
||
|
||
The protocol is settled by the **[[qrcode-sdk|QRCode SDK v1.6.5]]** (not serial as first guessed).
|
||
The reader is configured (Windows tool `QRCode_v1_6_5.exe`) with a **server IP/port** and a "server
|
||
language" (only picks the URL path, e.g. `/qa/mcardsea.php`). **On each scan it HTTP-GETs the host:**
|
||
|
||
```
|
||
GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&time=<utc>
|
||
```
|
||
`cardid` = scanned data; `status` low digit = **direction (1=in / 0=out)**. The host replies JSON
|
||
`{data:[{cardid,cjihao,mjihao,status,time,output}],code:0}` where reply **`status` 1=valid (beep
|
||
2×) / 0=invalid (beep 1×)**, **`output` 0=Access/1=WG26/2=WG34**, `time` syncs the clock.
|
||
|
||
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
|
||
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
|
||
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
|
||
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[subscription]]) to open the
|
||
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
|
||
|
||
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
|
||
> non-JSON / missing reply ⇒ no beep even though the scan worked. So "no beep" ≠ "didn't scan."
|
||
|
||
- Pushes over plain **HTTP** to our `10.0.10.x` host (on the device subnet); no serial wiring, no
|
||
Wiegand-decode hardware. Suits the host-in-the-loop model; autonomy is moot anyway
|
||
([[dingtian-relay]] has no onboard ACL).
|
||
- Default IP `192.168.1.99` (set its server IP/port to this host in the vendor tool).
|
||
|
||
## Resolved (2026-06-16)
|
||
|
||
- Protocol = HTTP GET poll + JSON verdict (above). The earlier "serial/Wiegand, find the baud"
|
||
open questions are **moot** — it's HTTP. Wiegand is the reader's *output line* on a valid read
|
||
(the reply `output` field), not the host transport.
|
||
|
||
## As-built (2026-06-16)
|
||
|
||
- **Endpoint** `GET/POST /qa/mcardsea.php` (`apps/server/src/routes/qr-reader.ts`, public — the
|
||
reader has no auth, sits on the device subnet). Parses `cardid/mjihao/cjihao/status/time`, runs
|
||
the scan through the **read dispatcher** (permit match → permit flow; else transient exit), and
|
||
replies the **SDK verdict**: `status` 1=valid(beep 2×)/0=invalid(beep 1×), `output` 0, `time`.
|
||
- The read flows were refactored to **return a `ReadOutcome` { accepted, direction, reason }** so the
|
||
endpoint's reply reflects the real accept/reject (the dispatcher decides AND opens the barrier via
|
||
the flows). A fire-and-forget reader ignores the outcome.
|
||
- **Reader identity:** the endpoint matches the device **serial (`cjihao`)** against each reader's
|
||
`config.serial` to find its `devices` row; the dispatcher then resolves the relay that row is
|
||
**bound** to (`config.controllerId` + `relay`) and opens it. See [[entry-exit-points]].
|
||
- Verified via inject: valid permit QR → `status:1` + open; re-scan → permit exit (still valid);
|
||
unknown QR → `status:0`; reader on a barrier-less lane → `status:0`.
|
||
|
||
## Verified on hardware (2026-06-16)
|
||
|
||
Captured a real scan (vendor-emulator logger on :3000). The reader **does scan, send, and beep** —
|
||
the earlier "no beep" was simply that no server was answering on :3000 with valid JSON. Real GET:
|
||
|
||
```
|
||
GET /qa/mcardsea.jsp?cardid=52020056&mjihao=1&cjihao=H05M2AFA&status=11&time=1781634494
|
||
from 10.0.10.7 (referer: http://www.fondvision.com — the OEM is Fondvision)
|
||
```
|
||
|
||
- **PATH carries the configured "server language" EXTENSION:** this unit is set to **JSP**, so it
|
||
GETs **`/qa/mcardsea.jsp`** — NOT `.php`. Our endpoint was registered at `.php` only → it would
|
||
have 404'd the real reader. **Fixed:** the route now registers `php/jsp/asp/aspx/cgi`.
|
||
- **`cjihao` = `H05M2AFA`** is the device **serial** — the value our endpoint matches against the
|
||
reader's `config.serial`. So assign the reader with **`config.serial = "H05M2AFA"`** and bind it
|
||
to a controller relay.
|
||
- **`mjihao` = 1** (device id). `cardid` = the scanned barcode (`52020056`). `status=11`.
|
||
- The reader **beeped on the vendor reply with `status:0`** — so it acts on the reply; `0` =
|
||
invalid/1-beep as documented. A matching permit/session will return `status:1` → 2-beep accept.
|
||
|
||
## Assignment (as-built 2026-06-16)
|
||
|
||
A dedicated **`dingtian-qr-reader`** driver ([[device-registry]], reader category) models the push reader:
|
||
its one config field is **`serial`** (the `cjihao` the device reports). The admin assigns it in the
|
||
[[first-run-setup|setup wizard]] like any device (normal UUID row id), enters the serial, and binds
|
||
it to a controller relay. The QR endpoint resolves the reader by **matching `config.serial` to the
|
||
scan's `cjihao`** — not by row id — so no DB hand-editing. Set the reader's server IP/port to this
|
||
host in the **vendor tool**; assign + enter its serial + bind it here.
|
||
|
||
- Verified via inject: assign `dingtian-qr-reader` {serial:"H05M2AFA"} bound to an access relay →
|
||
a `.jsp` scan with that serial + a matching permit QR → `status:1` (2-beep accept) + open; re-scan
|
||
→ permit exit; unknown card → `status:0`; unassigned serial → `status:0` (no relay, graceful).
|
||
- Note `tcpip-reader` is the WRONG model for this device (host-connects-out, a stub) — use
|
||
`dingtian-qr-reader`.
|
||
|
||
## Open / next
|
||
|
||
- Re-test on hardware against the real app (now `.jsp`-aware + serial-resolved): scan → expect a
|
||
`status:1` 2-beep when the QR matches a permit/open session.
|
||
- `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34)
|
||
to drive its access line, vs. `0`.
|
||
|
||
## Channel tagging — output prefixes close the printed-card-clone hole (2026-07-04)
|
||
|
||
The push carries ONE opaque `cardid` whether the **optical** engine decoded a QR/barcode or the
|
||
**RF** engine read a card — the server couldn't tell. And `SubscriptionFlow.match` matched by
|
||
**value only** (the stored `rf`/`qr` kind was a label). Consequence: printing a card's UID (often
|
||
written on the card face, e.g. `86A158`) as a barcode and holding it up **cloned the RF card** —
|
||
the optical decode matched the RF credential and opened the barrier. In-threat-model and cheap.
|
||
|
||
**Fix (both sides):**
|
||
- **Reader (vendor tool, both units):** set `QRCode Output Prefix` = `Q:` and `Card Output
|
||
Prefix` = `K:` (env-overridable server-side: `READER_QR_PREFIX` / `READER_CARD_PREFIX`).
|
||
- **Server:** `routes/qr-reader.ts` strips the prefix and tags the read's confirmed channel
|
||
(`DeviceReadEvent.channel: "optical"|"rf"`; kind `qr`/`card`). Enrollment capture stores the
|
||
**bare** value. `SubscriptionFlow.match` then requires **channel agreement**: an optical read
|
||
may not claim an `rf` credential (and vice versa) — a mismatch is refused AND signs a
|
||
`sub.refused.channelMismatch` **anomaly** (a clone attempt is a fraud signal). An **unprefixed**
|
||
read keeps the legacy untagged shape and matches as before — enforcement only bites where
|
||
prefixes are deployed, so an unconfigured reader never breaks.
|
||
- Bonus: every `READ` log line now carries `ch=optical|rf`, which permanently attributes any
|
||
future phantom (see below) to its engine.
|
||
|
||
⚠️ **Two device-side settings are now part of the credential contract** (they live ON the reader,
|
||
not in our DB — re-apply after any factory reset/swap): the two **output prefixes** (must match
|
||
the server's expected `Q:`/`K:`), and **`Card Input format` (currently `6H`)** — it defines the
|
||
UID shape we enroll; changing it later silently orphans every enrolled card.
|
||
|
||
## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04)
|
||
|
||
With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader
|
||
(`cjihao=H05MA5B0`) pushed spontaneous scans** at random afternoon times (observed 16:38–18:14,
|
||
low-western-sun hours): `cardid` values like `997492`, `389861`, `024358`, `192793` — and once a
|
||
lone **`C`**. Server logs (`READ serial=` lines) confirm the pushes carry the reader's own serial
|
||
and resolve to its assigned row, so this is **the physical reader decoding, not a network source**.
|
||
|
||
**Diagnosis:** the scan engine ships with many 1D symbologies enabled, some with weak/no checksums.
|
||
Six-digit all-numeric strings are the signature of **Interleaved 2-of-5** (even-length, digits-only,
|
||
no checksum — any high-contrast stripe pattern of the right proportions "decodes"); a lone `C` is a
|
||
**Code39/Codabar** artifact (Codabar start/stop chars are A–D). Low sun creates exactly such
|
||
patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare
|
||
bands. RFID noise would instead give repeating UID-shaped values.
|
||
|
||
**Impact: noise, not risk.** Every phantom was REFUSED fail-closed; a phantom can never match a
|
||
ticket ([[ticket-encoding]] ids are 11-digit + Luhn, so a 6-digit read has nothing to match).
|
||
|
||
**Feed filter (2026-07-04 — supersedes the earlier "do not filter" position).** Initially each
|
||
phantom signed an `exit.refused.noSession` anomaly (#52–58 in the feed) and the position was to
|
||
keep it that way. The operator overruled it, correctly: red "who is trying to exit?" rows for
|
||
NOBODY train the operator to ignore the signed feed — alarm fatigue is the adversary's friend.
|
||
`read-dispatch.ts` now drops a no-match value that **cannot possibly be a credential we issue** to
|
||
UNSIGNED telemetry (`device_events`, `unrecognizedRead:true` — still auditable). "Possibly ours"
|
||
is deliberately WIDE and everything in it still signs the normal refusal anomaly: Luhn-valid
|
||
ticket shapes (a forged ticket is a real probe), `SUB-`/`SUBSESS-` prefixes, ANY read on a
|
||
confirmed-RF channel (a physical card is a real event, never sun noise), and plates (never
|
||
shape-filtered). The filter also works pre-prefix (legacy untagged reads). Still fix at the
|
||
source too:
|
||
|
||
**Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every
|
||
symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length
|
||
≥ 10** and require checksums. Apply to BOTH readers. ⚠️ A factory reset or a swapped unit silently
|
||
re-enables the phantom symbologies — re-apply after any reset/replacement. Physical fallback if any
|
||
noise survives: hood/visor the window, tilt it down, avoid facing the striped arm.
|
||
|
||
## ⚠️ Reply MUST set `Connection: close` (verified on hardware)
|
||
|
||
The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP
|
||
socket CLOSES**. Fastify's default keeps the connection alive → the reader waits out a **~10 s
|
||
keep-alive timeout before beeping**, even though the server replied in ~15 ms. Every vendor demo
|
||
replies **`Connection: close`** and shuts the socket. Fix: the endpoint sets
|
||
`reply.header("connection","close")`. Symptom if regressed: correct accept/reject but a ~10 s lag
|
||
before the beep. (The request arrives fast; the delay is entirely the reader waiting for close.)
|