Prosty system grywalizacji toru ninja z NFC
Dwa stare komputery, dwa czytniki ACR122U i kilkadziesiąt linii Pythona: budujemy lokalny timer toru ninja z opaskami NFC, dźwiękiem startu, głosowym wynikiem i opcjonalnym ekranem wyników.
Mamy prosty tor ninja. Chcemy dodać jedną rzecz: uczestnik przykłada opaskę przed wejściem na tor, przebiega trasę, przykłada ją ponownie na końcu i od razu słyszy swój czas.
Nie potrzebujemy do tego dużego systemu RFID, specjalistycznych sterowników ani infrastruktury w chmurze. W podstawowej wersji wystarczą dwa komputery, dwa czytniki NFC ACR122U, dwa małe głośniki i lokalna sieć.
Pobierz kompletny kod przykładowy ZIP
1. Co budujemy
System ma dwa podstawowe stanowiska.
START stoi przy wejściu na tor. Uczestnik przykłada opaskę NFC. Komputer odczytuje UID opaski i wysyła do serwera prostą informację: „ta opaska rozpoczęła próbę”. Serwer zapisuje swój aktualny czas. Dopiero po poprawnej odpowiedzi z serwera stanowisko odtwarza krótki sygnał.
META stoi przy końcu toru i jednocześnie pełni rolę serwera. Uczestnik przykłada tę samą opaskę. Serwer odnajduje rozpoczętą próbę, zapisuje czas zakończenia, liczy różnicę i zwraca wynik. Głośnik mówi na przykład:
„Twój czas to czterdzieści trzy sekundy i pięćdziesiąt osiem setnych.”
Opcjonalnie możemy postawić trzeci komputer z monitorem lub telewizorem. Nie liczy czasu i nie potrzebuje własnego programu. Otwiera tylko stronę WWW z ostatnimi wynikami.
2. Najważniejsze uproszczenie: tylko jeden zegar
Najłatwiej byłoby uruchomić stoper na komputerze START i drugi na komputerze META. Tego właśnie nie robimy.
Pomiar wykonuje wyłącznie serwer znajdujący się na komputerze META.
START wysyła tylko:
opaska ABC rozpoczęła próbę
Serwer zapisuje własny czas. Na mecie ten sam serwer dostaje:
opaska ABC zakończyła próbę
i oblicza różnicę. Dzięki temu nie musimy synchronizować zegarów dwóch komputerów.
3. Czego potrzebujemy
| Stanowisko | Minimalny sprzęt |
|---|---|
| START | dowolny mały PC, Linux, ACR122U, opaska NFC, mały głośnik USB/jack, Ethernet lub Wi-Fi |
| META | dowolny PC, Linux, ACR122U, głośnik, sieć, trochę miejsca na plik SQLite |
| DASHBOARD — opcjonalnie | dowolny komputer z przeglądarką + monitor lub TV |
Komputery nie muszą być nowe. W takim prototypie obciążenie jest minimalne.
Obudowa stanowiska
Na początku nie projektowałbym specjalistycznego terminala. Wystarczy niewielkie pudełko:
┌───────────────────────┐
│ │
│ PRZYŁÓŻ OPASKĘ │
│ │
│ [ NFC ] │
│ │
│ [ głośnik ] │
│ │
└───────────────────────┘
W środku znajduje się ACR122U i mały głośnik, a przewód USB prowadzi do komputera. Obudowę można wydrukować w 3D, wykorzystać gotową puszkę ABS albo na etapie prototypu użyć tego, co jest pod ręką.
4. Sieć
Zakładamy zwykłą lokalną sieć. Internet nie jest potrzebny.
Przykładowo:
| Urządzenie | Adres |
|---|---|
| META / SERVER | 192.168.1.100 |
| START | 192.168.1.101 |
| DASHBOARD | 192.168.1.102 |
Najprościej zarezerwować te adresy w routerze po DHCP. START będzie komunikował się z:
http://192.168.1.100:8000
a dashboard otworzy:
http://192.168.1.100:8000/results
ping 192.168.1.100. Jeżeli META odpowiada, sieć między stanowiskami działa.5. Instalujemy potrzebne pakiety
Dla prostoty na obu komputerach instalujemy ten sam zestaw. Przykłady poniżej zakładają Ubuntu lub Debiana.
sudo apt update
sudo apt install -y python3 pcscd pcsc-tools libccid python3-pyscard python3-requests python3-flask alsa-utils espeak-ng
sudo systemctl restart pcscd
Nie używamy Dockera, Node.js, MQTT ani zewnętrznej bazy danych. Port 8000 zostaje w sieci lokalnej — nie wystawiaj tego prototypu bezpośrednio do Internetu.
python3 --version powinno pokazać zainstalowanego Pythona, a which pcsc_scan ścieżkę do narzędzia PC/SC.6. Sprawdzamy ACR122U
Podłącz czytnik ACR122U przez USB i uruchom:
pcsc_scan
Program powinien wykryć czytnik. Po przyłożeniu opaski powinien również pokazać zmianę stanu karty i jej ATR.
Na tym etapie nie przechodź dalej, jeśli pcsc_scan nie reaguje na opaskę. Najpierw trzeba mieć sprawnie działający czytnik PC/SC.
W naszym kodzie UID opaski odczytujemy standardowym dla ACR122U poleceniem GET DATA:
FF CA 00 00 00
pcsc_scan powinien za każdym razem reagować.7. Tworzymy katalog projektu
Na obu komputerach przygotuj:
mkdir -p ~/ninja-timer/server ~/ninja-timer/start ~/ninja-timer/finish ~/ninja-timer/sounds
Docelowo projekt wygląda tak:
ninja-timer/
│
├── server/
│ ├── server.py
│ └── results.db # utworzy się automatycznie
│
├── start/
│ └── start.py
│
├── finish/
│ └── finish.py
│
└── sounds/
├── make_start_wav.py
└── start.wav # utworzymy w następnym kroku
Jeżeli korzystasz z ZIP-a dołączonego do wpisu, po prostu skopiuj katalog ninja-timer do katalogu domowego na obu komputerach.
8. Przygotowujemy dźwięk
Żeby tutorial był kompletny, nie pobieramy gotowego pliku z Internetu. Ten mały skrypt wygeneruje krótki sygnał start.wav.
Utwórz ~/ninja-timer/sounds/make_start_wav.py:
#!/usr/bin/env python3
"""
Generated: 2026-08-31T02:25:47+02:00
Generation notes:
- Generuje lokalnie krótki sygnał start.wav z użyciem wyłącznie biblioteki standardowej Pythona.
- Plik WAV powstaje obok tego skryptu; nie jest pobierany z Internetu.
"""
from __future__ import annotations
import math
import struct
import wave
from pathlib import Path
OUTPUT = Path(__file__).with_name("start.wav")
SAMPLE_RATE = 44100
DURATION = 0.16
FREQUENCY = 1100.0
AMPLITUDE = 0.32
frames = int(SAMPLE_RATE * DURATION)
with wave.open(str(OUTPUT), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
for index in range(frames):
envelope = min(1.0, index / 300, (frames - index) / 500)
sample = AMPLITUDE * envelope * math.sin(2 * math.pi * FREQUENCY * index / SAMPLE_RATE)
wav.writeframesraw(struct.pack("<h", int(sample * 32767)))
print(f"Utworzono: {OUTPUT}")
Uruchom:
cd ~/ninja-timer
python3 sounds/make_start_wav.py
aplay sounds/start.wav
Powinien być słyszalny krótki sygnał.
Sprawdź też syntezę mowy:
espeak-ng -v pl --stdout "Test głośnika" | aplay -q
espeak-ng.9. Serwer na komputerze META
Serwer robi trzy rzeczy:
- zapisuje rozpoczęcie próby,
- kończy próbę i liczy czas,
- pokazuje ostatnie wyniki.
Baza to jeden lokalny plik SQLite: results.db.
Tabela ma tylko pięć pól:
id
tag_uid
started_at
finished_at
duration
Utwórz ~/ninja-timer/server/server.py:
#!/usr/bin/env python3
"""
Generated: 2026-08-31T02:25:47+02:00
Generation notes:
- Minimalny lokalny serwer timera toru ninja.
- Flask + SQLite; brak usług chmurowych i zewnętrznej bazy.
- Ten sam proces serwera zapisuje start i metę, więc nie synchronizujemy zegarów komputerów.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, render_template_string, request
APP_DIR = Path(__file__).resolve().parent
DB_PATH = APP_DIR / "results.db"
app = Flask(__name__)
def get_db() -> sqlite3.Connection:
connection = sqlite3.connect(DB_PATH, timeout=5)
connection.row_factory = sqlite3.Row
return connection
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")
def normalize_tag(value: object) -> str:
return str(value or "").strip().upper()
def init_db() -> None:
with get_db() as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag_uid TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
duration REAL
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_runs_tag_active "
"ON runs(tag_uid, finished_at)"
)
@app.post("/api/start")
def start_run():
payload = request.get_json(silent=True) or {}
tag = normalize_tag(payload.get("tag"))
if not tag:
return jsonify(status="error", error="missing_tag"), 400
with get_db() as db:
active = db.execute(
"""
SELECT id
FROM runs
WHERE tag_uid = ? AND finished_at IS NULL
ORDER BY id DESC
LIMIT 1
""",
(tag,),
).fetchone()
if active:
return jsonify(status="already_started")
db.execute(
"INSERT INTO runs(tag_uid, started_at) VALUES (?, ?)",
(tag, now_iso()),
)
return jsonify(status="started")
@app.post("/api/finish")
def finish_run():
payload = request.get_json(silent=True) or {}
tag = normalize_tag(payload.get("tag"))
if not tag:
return jsonify(status="error", error="missing_tag"), 400
with get_db() as db:
active = db.execute(
"""
SELECT id, started_at
FROM runs
WHERE tag_uid = ? AND finished_at IS NULL
ORDER BY id DESC
LIMIT 1
""",
(tag,),
).fetchone()
if not active:
return jsonify(status="no_active_run")
finished_at = now_iso()
started = datetime.fromisoformat(active["started_at"])
finished = datetime.fromisoformat(finished_at)
duration = round((finished - started).total_seconds(), 2)
db.execute(
"""
UPDATE runs
SET finished_at = ?, duration = ?
WHERE id = ?
""",
(finished_at, duration, active["id"]),
)
return jsonify(status="finished", time=duration)
@app.get("/api/results")
def api_results():
with get_db() as db:
rows = db.execute(
"""
SELECT id, tag_uid, started_at, finished_at, duration
FROM runs
WHERE finished_at IS NOT NULL
ORDER BY id DESC
LIMIT 20
"""
).fetchall()
return jsonify([dict(row) for row in rows])
RESULTS_PAGE = """
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="3">
<title>Tor Ninja — ostatnie wyniki</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; background: #160b20; color: #fff; font-family: system-ui, sans-serif; }
main { width: min(1100px, calc(100% - 48px)); margin: 0 auto; padding: 48px 0; }
h1 { font-size: clamp(38px, 6vw, 76px); margin: 0 0 32px; letter-spacing: -0.04em; }
table { width: 100%; border-collapse: collapse; font-size: clamp(18px, 2.2vw, 30px); }
th, td { padding: 16px 12px; border-bottom: 1px solid #3b2945; text-align: left; }
th { color: #ffb23d; font-size: 0.55em; text-transform: uppercase; letter-spacing: 0.08em; }
td:last-child { font-weight: 800; color: #ffb23d; }
.empty { color: #bcb2c2; font-size: 24px; }
</style>
</head>
<body>
<main>
<h1>TOR NINJA — OSTATNIE WYNIKI</h1>
{% if rows %}
<table>
<thead><tr><th>#</th><th>Opaska</th><th>Czas</th></tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row['tag_uid'] }}</td>
<td>{{ '%.2f'|format(row['duration']) }} s</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak ukończonych prób.</p>
{% endif %}
</main>
</body>
</html>
"""
@app.get("/results")
def results_page():
with get_db() as db:
rows = db.execute(
"""
SELECT tag_uid, duration
FROM runs
WHERE finished_at IS NOT NULL
ORDER BY id DESC
LIMIT 20
"""
).fetchall()
return render_template_string(RESULTS_PAGE, rows=rows)
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=8000, debug=False)
Uruchom na komputerze META:
cd ~/ninja-timer/server
python3 server.py
Powinieneś zobaczyć informację, że Flask nasłuchuje na porcie 8000.
W drugim terminalu META wykonaj:
curl http://127.0.0.1:8000/api/results
Na początku odpowiedzią powinno być:
[]
Teraz sprawdź połączenie z komputera START:
curl http://192.168.1.100:8000/api/results
Jeśli zobaczysz tę samą odpowiedź, START dociera do serwera. Jeżeli masz aktywny firewall UFW i połączenie jest blokowane, otwórz tylko lokalny port:
sudo ufw allow 8000/tcp
/api/results musi działać zarówno lokalnie na META, jak i z komputera START.10. Jak działa API
Nie potrzebujemy rozbudowanego API.
Start
POST /api/start
Dane:
{
"tag": "04A82F31B91280"
}
Jeśli próba zaczęła się poprawnie:
{
"status": "started"
}
Jeśli ta sama opaska ma już niezakończoną próbę:
{
"status": "already_started"
}
Meta
POST /api/finish
Dane:
{
"tag": "04A82F31B91280"
}
Odpowiedź:
{
"status": "finished",
"time": 43.58
}
Jeśli ktoś przyłoży opaskę do mety bez startu:
{
"status": "no_active_run"
}
To wszystko.
11. Stanowisko START
START nie ma własnego stopera. Czyta UID i wywołuje /api/start.
Utwórz ~/ninja-timer/start/start.py:
#!/usr/bin/env python3
"""
Generated: 2026-08-31T02:25:47+02:00
Generation notes:
- Klient stanowiska START dla ACR122U przez PC/SC / pyscard.
- Po odczycie UID wysyła tylko POST /api/start; czasu nie mierzy lokalnie.
- Ten sam UID jest ignorowany podczas ciągłego trzymania opaski przy czytniku.
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import requests
from smartcard.Exceptions import CardConnectionException, NoCardException
from smartcard.System import readers
SERVER_URL = os.environ.get("NINJA_SERVER", "http://192.168.1.100:8000").rstrip("/")
SOUND_FILE = Path(__file__).resolve().parents[1] / "sounds" / "start.wav"
COOLDOWN_SECONDS = 3.0
def read_uid() -> str | None:
available = readers()
if not available:
raise RuntimeError("Nie znaleziono czytnika PC/SC.")
connection = available[0].createConnection()
try:
connection.connect()
except (NoCardException, CardConnectionException):
return None
data, sw1, sw2 = connection.transmit([0xFF, 0xCA, 0x00, 0x00, 0x00])
if (sw1, sw2) != (0x90, 0x00):
return None
return "".join(f"{byte:02X}" for byte in data)
def beep() -> None:
subprocess.run(["aplay", "-q", str(SOUND_FILE)], check=False)
def main() -> None:
print(f"START: serwer {SERVER_URL}")
print("Przyłóż opaskę do ACR122U.")
last_uid: str | None = None
last_seen = 0.0
while True:
try:
uid = read_uid()
now = time.monotonic()
if uid is None:
time.sleep(0.20)
continue
if uid == last_uid and now - last_seen < COOLDOWN_SECONDS:
last_seen = now
time.sleep(0.20)
continue
last_uid = uid
last_seen = now
print(f"Odczytano: {uid}")
try:
response = requests.post(
f"{SERVER_URL}/api/start",
json={"tag": uid},
timeout=2,
)
response.raise_for_status()
payload = response.json()
except (requests.RequestException, ValueError) as exc:
print(f"Błąd połączenia z serwerem: {exc}")
time.sleep(1)
continue
if payload.get("status") == "started":
beep()
print("START zapisany.")
elif payload.get("status") == "already_started":
print("Ta opaska ma już rozpoczętą próbę.")
else:
print(f"Nieoczekiwana odpowiedź: {payload}")
time.sleep(0.20)
except RuntimeError as exc:
print(exc)
time.sleep(2)
if __name__ == "__main__":
main()
Na komputerze START uruchom:
cd ~/ninja-timer/start
export NINJA_SERVER=http://192.168.1.100:8000
python3 start.py
Przyłóż opaskę. W terminalu powinien pojawić się UID, serwer powinien odpowiedzieć started, a głośnik powinien zrobić BEEP.
Kod ma prostą ochronę przed wielokrotnym odczytem opaski trzymanej nad czytnikiem. Ten sam UID obserwowany bez przerwy odnawia trzysekundowy cooldown, więc nie generuje kolejnych startów.
already_started.12. Stanowisko META
META czyta tę samą opaskę, ale wywołuje /api/finish. To serwer wylicza wynik. Dopiero później komputer META odtwarza BEEP i mówi czas.
Utwórz ~/ninja-timer/finish/finish.py:
#!/usr/bin/env python3
"""
Generated: 2026-08-31T02:25:47+02:00
Generation notes:
- Klient stanowiska META dla ACR122U przez PC/SC / pyscard.
- Wysyła UID do serwera, który oblicza czas.
- Wynik jest odczytywany lokalnie przez espeak-ng i aplay.
"""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import requests
from smartcard.Exceptions import CardConnectionException, NoCardException
from smartcard.System import readers
SERVER_URL = os.environ.get("NINJA_SERVER", "http://127.0.0.1:8000").rstrip("/")
SOUND_FILE = Path(__file__).resolve().parents[1] / "sounds" / "start.wav"
COOLDOWN_SECONDS = 3.0
def read_uid() -> str | None:
available = readers()
if not available:
raise RuntimeError("Nie znaleziono czytnika PC/SC.")
connection = available[0].createConnection()
try:
connection.connect()
except (NoCardException, CardConnectionException):
return None
data, sw1, sw2 = connection.transmit([0xFF, 0xCA, 0x00, 0x00, 0x00])
if (sw1, sw2) != (0x90, 0x00):
return None
return "".join(f"{byte:02X}" for byte in data)
def beep() -> None:
subprocess.run(["aplay", "-q", str(SOUND_FILE)], check=False)
def seconds_word(value: int) -> str:
if value == 1:
return "sekunda"
if 12 <= value % 100 <= 14:
return "sekund"
if value % 10 in (2, 3, 4):
return "sekundy"
return "sekund"
def speak(text: str) -> None:
synthesizer = subprocess.Popen(
["espeak-ng", "-v", "pl", "--stdout", text],
stdout=subprocess.PIPE,
)
if synthesizer.stdout is not None:
subprocess.run(["aplay", "-q"], stdin=synthesizer.stdout, check=False)
synthesizer.stdout.close()
synthesizer.wait()
def speak_time(value: float) -> None:
rounded = round(value, 2)
seconds = int(rounded)
hundredths = int(round((rounded - seconds) * 100))
if hundredths == 100:
seconds += 1
hundredths = 0
text = f"Twój czas to {seconds} {seconds_word(seconds)}"
if hundredths:
text += f" i {hundredths} setnych sekundy"
speak(text)
def main() -> None:
print(f"META: serwer {SERVER_URL}")
print("Przyłóż opaskę do ACR122U.")
last_uid: str | None = None
last_seen = 0.0
while True:
try:
uid = read_uid()
now = time.monotonic()
if uid is None:
time.sleep(0.20)
continue
if uid == last_uid and now - last_seen < COOLDOWN_SECONDS:
last_seen = now
time.sleep(0.20)
continue
last_uid = uid
last_seen = now
print(f"Odczytano: {uid}")
try:
response = requests.post(
f"{SERVER_URL}/api/finish",
json={"tag": uid},
timeout=2,
)
response.raise_for_status()
payload = response.json()
except (requests.RequestException, ValueError) as exc:
print(f"Błąd połączenia z serwerem: {exc}")
time.sleep(1)
continue
status = payload.get("status")
if status == "finished":
result = float(payload["time"])
beep()
print(f"Wynik: {result:.2f} s")
speak_time(result)
elif status == "no_active_run":
print("Nie znaleziono rozpoczętej próby.")
speak("Nie znaleziono rozpoczętej próby")
else:
print(f"Nieoczekiwana odpowiedź: {payload}")
time.sleep(0.20)
except RuntimeError as exc:
print(exc)
time.sleep(2)
if __name__ == "__main__":
main()
Serwer powinien już działać. W drugim terminalu META uruchom:
cd ~/ninja-timer/finish
python3 finish.py
Teraz wykonaj pełną próbę:
- przyłóż opaskę na START,
- poczekaj kilka sekund,
- przyłóż tę samą opaskę na META.
Powinieneś usłyszeć komunikat z wynikiem.
Jeżeli opaska nie ma rozpoczętej próby, META mówi:
„Nie znaleziono rozpoczętej próby.”
no_active_run. Następnie wykonaj poprawny START → META i sprawdź, czy usłyszany wynik odpowiada rzeczywistemu czasowi oczekiwania.13. Co dzieje się w bazie
Po pierwszej próbie w server/results.db znajdzie się rekord podobny do tego:
| id | tag_uid | started_at | finished_at | duration |
|---|---|---|---|---|
| 1 | 04A82F31B91280 |
2026-08-31T14:31:04.231+02:00 |
2026-08-31T14:31:47.812+02:00 |
43.58 |
Ważna jest jedna rzecz: oba znaczniki czasu utworzył komputer META. START nie przesyła własnego czasu.
14. Test całego systemu
Przykładowa próba:
START
opaska: 04A82F31B91280
12:00:00.000
BEEP
↓ tor ninja ↓
META
opaska: 04A82F31B91280
12:00:42.530
BEEP
wynik:
42.53 s
Głośnik:
„Twój czas to czterdzieści dwie sekundy i pięćdziesiąt trzy setne sekundy.”
Jeżeli to działa, podstawowa wersja systemu jest gotowa.
15. Automatyczny start po włączeniu komputerów
Na końcu warto usunąć konieczność ręcznego otwierania terminali. Wykorzystamy zwykły systemd.
START
Na komputerze START wykonaj:
PROJECT_DIR="$HOME/ninja-timer"
USER_NAME="$(whoami)"
sudo tee /etc/systemd/system/ninja-start.service >/dev/null <<EOF
[Unit]
Description=Ninja Timer START
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$USER_NAME
WorkingDirectory=$PROJECT_DIR/start
Environment=NINJA_SERVER=http://192.168.1.100:8000
ExecStart=/usr/bin/python3 $PROJECT_DIR/start/start.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ninja-start.service
Sprawdź:
systemctl status ninja-start.service
META — serwer
Na komputerze META:
PROJECT_DIR="$HOME/ninja-timer"
USER_NAME="$(whoami)"
sudo tee /etc/systemd/system/ninja-server.service >/dev/null <<EOF
[Unit]
Description=Ninja Timer Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$USER_NAME
WorkingDirectory=$PROJECT_DIR/server
ExecStart=/usr/bin/python3 $PROJECT_DIR/server/server.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ninja-server.service
META — czytnik mety
PROJECT_DIR="$HOME/ninja-timer"
USER_NAME="$(whoami)"
sudo tee /etc/systemd/system/ninja-finish.service >/dev/null <<EOF
[Unit]
Description=Ninja Timer META
After=ninja-server.service
Requires=ninja-server.service
[Service]
Type=simple
User=$USER_NAME
WorkingDirectory=$PROJECT_DIR/finish
Environment=NINJA_SERVER=http://127.0.0.1:8000
ExecStart=/usr/bin/python3 $PROJECT_DIR/finish/finish.py
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now ninja-finish.service
Sprawdź oba procesy:
systemctl status ninja-server.service
systemctl status ninja-finish.service
Na końcu zrestartuj oba komputery i wykonaj jeszcze jedną próbę bez otwierania terminala.
16. Bonus: ekran ostatnich wyników
Serwer już udostępnia stronę:
http://192.168.1.100:8000/results
Na trzecim komputerze wystarczy uruchomić przeglądarkę w trybie pełnoekranowym.
Strona odświeża się automatycznie co trzy sekundy i pokazuje maksymalnie 20 ostatnich ukończonych prób:
TOR NINJA — OSTATNIE WYNIKI
1. 04A82F31B91280 37.40 s
2. 93FF 41.80 s
3. C512 42.10 s
4. 21B7 45.70 s
5. E801 48.30 s
Nie potrzebujemy Reacta ani osobnej aplikacji dashboardowej.
17. Co można rozbudować później
Dopiero gdy podstawowa wersja faktycznie działa i ludzie z niej korzystają, można pomyśleć o kolejnych etapach:
- imiona uczestników zamiast UID,
- ranking dnia,
- rekord toru,
- kilka torów,
- ekran startowy,
- światła LED,
- integracja z systemem sprzedaży,
- centralne zarządzanie urządzeniami.
Nie implementowałbym żadnej z tych rzeczy przed uruchomieniem najprostszej wersji.
18. Gdzie kończy się ten prototyp
Ten tutorial celowo pomija elementy potrzebne w dojrzalszym systemie komercyjnym. Jeżeli rozwiązanie zacznie być realnie używane w parku, kolejnymi tematami powinny być między innymi:
- monitoring działania stanowisk,
- kopie danych,
- watchdog procesów i urządzeń,
- poprawna obsługa awarii sieci,
- mechanizm aktualizacji,
- logowanie zdarzeń,
- kontrola konfiguracji.
To jednak jest następny etap, nie warunek zbudowania pierwszego prototypu.
Efekt końcowy
Po wykonaniu instrukcji scenariusz jest prosty:
START
Uczestnik przykłada opaskę.
BEEP
Serwer zapisuje początek próby.
META
Uczestnik przykłada tę samą opaskę.
BEEP
Głośnik mówi:
„Twój czas to trzydzieści siedem sekund i czterdzieści setnych.”
DASHBOARD
Nowy wynik pojawia się na ekranie.
To już jest działający, minimalny system grywalizacyjny — zbudowany lokalnie, z dwóch zwykłych komputerów i dwóch czytników NFC.