Narzędzie DIY · Grywalizacja

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.

Poziom: ProstyCzas: 2–4 h
2komputery Linux
2czytniki ACR122U
0usług chmurowych

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

Ważne: to demonstrator grywalizacji i pomiaru wyniku. Nie jest systemem bezpieczeństwa, kontroli dostępu ani urządzeniem do oficjalnego pomiaru czasu zawodów. Nie należy od niego uzależniać funkcji safety-critical.

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.

STARTPC LinuxACR122U + głośnik
HTTP →LAN / Wi-Fi
METAPC Linux + serwerACR122U + SQLite + głośnik

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
Test: z komputera START wykonaj 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.

Test: polecenie 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
Test: przyłóż i zdejmij opaskę kilka razy. 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
Test: nie przechodź dalej, dopóki na komputerze META nie słyszysz zarówno pliku WAV, jak i komunikatu z espeak-ng.

9. Serwer na komputerze META

Serwer robi trzy rzeczy:

  1. zapisuje rozpoczęcie próby,
  2. kończy próbę i liczy czas,
  3. 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
Test: /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.

Test: trzymaj tę samą opaskę nad czytnikiem przez kilka sekund. W bazie nie powinny powstać kolejne próby. Ponowne przyłożenie opaski po rozpoczęciu próby zwróci 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ę:

  1. przyłóż opaskę na START,
  2. poczekaj kilka sekund,
  3. 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.”

Test: przyłóż najpierw losową opaskę tylko na META — powinno pojawić się 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.

Test: po restarcie START powinien przyjąć opaskę, META zakończyć próbę i przeczytać wynik. Jeżeli ręczny test dźwięku działał, a usługa nie odtwarza audio, problem dotyczy dostępu usługi do urządzenia audio — nie logiki timera.

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.

STARTACR122UHTTP
METASerwer + SQLite/results
DASHBOARDPrzeglądarkamonitor / TV

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.