English | 日本語
Tap a Japanese Individual Number Card (マイナンバーカード) on a PC/SC reader and the matching prefecture fills in on a map of Japan. The name is short for 「県これくしょん」, a collection of prefectures.
- A prefecture not yet seen → blown up on screen and filled in (
NEW!) - One already filled → blown up and flagged as a duplicate (
重複) - An expired card →
有効期限切れblown up, and nothing is filled in (even for an already filled prefecture, this takes precedence over the duplicate message)
Every outcome has its own sound. Errors such as an unplugged reader or a failed read are shown on screen.
The user-facing interface is in Japanese; code comments are in English.
The app reads EF 0001 of the card's common card AP (AID D3921000310001010100).
SELECT DF (AID) 00 A4 04 0C 0A D3 92 10 00 31 00 01 01 01 00
SELECT EF (FID) 00 A4 02 0C 02 00 01
READ RECORD 00 B2 01 0C 00
The response is 01 1C (tag=0x01, len=28) followed by 28 ASCII digits, laid out from
the right as:
... card serial | municipality code (5) | expiry date (8)
The prefecture code is the first two digits of the municipality code. This AP needs no PIN, so nobody has to type anything. A card stays valid through its expiry date and lapses the day after.
The municipality code is rewritten when the holder registers a move, so it names the municipality they currently live in.
The app never touches the text-entry-assistance AP (券面事項入力補助AP) or the My Number itself.
Only the prefecture code, the time it was first filled, and a hit count are stored. The
card serial, which could identify a card uniquely, never leaves app/common_card_ap.py
and is never logged. The expiry date is not sent to the browser either: it is never
displayed, and it narrows down the holder's birth date.
State lives in a SQLite database with a single table, one row per prefecture. Reading it
back needs the sqlite3 command-line tool, which is not otherwise required:
$ sudo apt install sqlite3
$ sqlite3 state.db 'SELECT * FROM filled ORDER BY filled_at'
13|2026-07-10T12:44:07+09:00|2
27|2026-07-10T12:51:33+09:00|1You need pcscd and a CCID driver that covers your reader.
sudo apt install pcscd pcsc-tools
pcsc_scan -r # check that the reader shows up
uv sync # add --no-dev to leave the test dependencies outDependencies live in pyproject.toml and are pinned in uv.lock, which is committed:
uv sync reproduces the exact environment. Without uv, pip install -e . --group dev
works too, but resolves fresh rather than from the lock.
uv run kencolle # real reader
uv run kencolle --fake --port 8000 # try the UI with no carduv run python -m app does the same thing.
Open http://127.0.0.1:8000/ and go full screen. One click starts it: browsers will not play audio before a user gesture, so that click is what enables the sounds.
r— reset every filled prefecturem— mute or unmute the sounds--state PATH— where to keep the state database (defaults tostate.db)
Under --fake you can drive the card and the reader from outside.
curl -X POST 'http://127.0.0.1:8000/api/debug/tap/13' # read Tokyo
curl -X POST 'http://127.0.0.1:8000/api/debug/tap/13?expired=true' # an expired card
curl -X POST 'http://127.0.0.1:8000/api/debug/reader/false' # reader unpluggedSynthesised with Web Audio, with no external assets (app/static/sounds.js).
| Outcome | Sound |
|---|---|
new |
A rising arpeggio (C5 E5 G5 C6) |
dup |
The same pitch twice over |
expired |
A falling arpeggio (B4 G#4 F4) |
error |
A dirty buzzer. Also fires when the reader is unplugged |
If nothing is audible, check Sounds.state() in the browser console. uninitialized
means the page has not been clicked yet; suspended means the browser is holding
playback back.
| File | Role |
|---|---|
app/common_card_ap.py |
APDU exchange, record parsing, expiry check |
app/reader.py |
The PC/SC polling loop, on its own thread |
app/prefectures.py |
Prefecture codes and names |
app/map_shapes.py |
The outline, label anchor and type size of each prefecture |
app/store.py |
Persistence to the state.db SQLite database |
app/clock.py |
Japan Standard Time |
app/main.py |
FastAPI and the WebSocket |
app/static/ |
The deformed map UI and the sounds |
disconnect() defaults to the SCARD_UNPOWER_CARD disposition, which cuts power to a
contactless card on every disconnect. That makes the presence check immediately
afterwards flaky, and a single tap gets read twice, so the app uses SCARD_LEAVE_CARD
instead. On top of that it will not read again until it has observed "no card" three
times in a row (ABSENT_CONFIRM). A card already resting on the reader at startup is
ignored until it is lifted, and unplugging the reader disarms the loop too, so the card
has to be presented again.
tests/test_reader.py pins these transitions down without touching PC/SC.
uv run pytest -q- Ubuntu, pcsc-lite 2.4.1, the ccid driver
- SONY FeliCa RC-S300/P (PaSoRi 4.0)
- Python 3.14