Webové počitadlo na LAN s historií her #2
No files matched your search
@@ -14,18 +14,43 @@ python -m venv .venv
|
||||
.venv/bin/python web.py
|
||||
```
|
||||
|
||||
Server vypíše adresu, na které poslouchá. Na notebooku otevři `/board`,
|
||||
naskenuj QR kód telefonem a hraj.
|
||||
Server vypíše všechny adresy, na kterých je dostupný:
|
||||
|
||||
```
|
||||
Počitadlo je dostupné na:
|
||||
→ http://10.186.234.182:8000 místní síť
|
||||
http://100.91.0.24:8000 přes Tailscale
|
||||
http://172.17.0.1:8000 virtuální síť
|
||||
```
|
||||
|
||||
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
|
||||
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další.
|
||||
|
||||
| Proměnná | K čemu je | Výchozí |
|
||||
|---|---|---|
|
||||
| `DICE_DB` | soubor s databází | `dice.db` |
|
||||
| `DICE_PORT` | port | `8000` |
|
||||
| `DICE_HOST` | adresa v QR kódu, když ji nemá hledat sám | zjistí se za běhu |
|
||||
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
|
||||
|
||||
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
||||
může zapisovat.
|
||||
|
||||
## Když se telefon nepřipojí
|
||||
|
||||
Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na
|
||||
hotspot a zpátky si tabule pohlídá sama a načte se znovu.
|
||||
|
||||
Zbývají tři důvody, proč se telefon nedostane na notebook:
|
||||
|
||||
- **Telefon je na mobilních datech**, notebook na WiFi. Různé sítě, nepotkají
|
||||
se. Připoj telefon na stejnou WiFi, nebo notebook na hotspot telefonu —
|
||||
to funguje taky.
|
||||
- **Zapnutý hotspot na telefonu shodil jeho WiFi.** Většina Androidů to dělá.
|
||||
Pak je řešením připojit na ten hotspot i notebook.
|
||||
- **VPN nebo Tailscale exit node na telefonu** posílá veškerý provoz mimo
|
||||
místní síť. V Tailscale to řeší přepínač „Allow local network access";
|
||||
případně použij tailnet adresu notebooku, ta funguje odkudkoli.
|
||||
|
||||
## Pravidla
|
||||
|
||||
Skóre se zadává ručně po tazích, hází se doopravdy. Tři nulové tahy za sebou
|
||||
@@ -38,6 +63,7 @@ při remíze na prvním místě se hraje dál.
|
||||
| Soubor | Role |
|
||||
|---|---|
|
||||
| `core.py` | pravidla hry, žádný vstup ani výstup |
|
||||
| `net.py` | hledání adres, na kterých je počitadlo dostupné |
|
||||
| `storage.py` | SQLite: hráči, hry, tahy |
|
||||
| `stats.py` | síň slávy a kariérní statistiky |
|
||||
| `web.py` | Flask aplikace |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Zjištění adres, na kterých je počitadlo dostupné.
|
||||
|
||||
Telefon a notebook se musí potkat na stejné síti a která to je, se během
|
||||
večera mění — domácí WiFi, hotspot z telefonu, tailnet. Proto se adresy
|
||||
hledají za běhu, hledá se jich víc a řadí se podle toho, jak pravděpodobně
|
||||
povedou k cíli. Vybrat si může člověk; my jen nabídneme.
|
||||
|
||||
Nic se tu nezakazuje podle jména konkrétního rozhraní — rozhoduje rozsah
|
||||
adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
|
||||
TAILNET = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
# Obvyklé předpony virtuálních rozhraní. Adresy z nich nezahazujeme, jen je
|
||||
# řadíme nakonec — dostat se přes ně k počitadlu jde jen výjimečně.
|
||||
VIRTUAL = ("docker", "br-", "virbr", "veth", "vmnet", "vboxnet", "tun", "tap")
|
||||
|
||||
KIND_LABELS = {
|
||||
"lan": "místní síť",
|
||||
"tailnet": "přes Tailscale",
|
||||
"public": "veřejná adresa",
|
||||
"virtual": "virtuální síť",
|
||||
}
|
||||
|
||||
ORDER = {"lan": 0, "tailnet": 1, "public": 2, "virtual": 3}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Address:
|
||||
ip: str
|
||||
interface: str
|
||||
kind: str
|
||||
primary: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return KIND_LABELS.get(self.kind, self.kind)
|
||||
|
||||
def url(self, port: int) -> str:
|
||||
return f"http://{self.ip}:{port}"
|
||||
|
||||
|
||||
def classify(ip: str, interface: str) -> str | None:
|
||||
"""Typ adresy podle jejího rozsahu. None znamená nepoužitelná."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if addr.is_loopback or addr.is_link_local or addr.is_unspecified:
|
||||
return None
|
||||
|
||||
if addr in TAILNET:
|
||||
return "tailnet"
|
||||
|
||||
if interface.startswith(VIRTUAL):
|
||||
return "virtual"
|
||||
|
||||
return "lan" if addr.is_private else "public"
|
||||
|
||||
|
||||
def rank(found: list[tuple[str, str]], primary: str | None) -> list[Address]:
|
||||
"""Seřadí nalezené dvojice (rozhraní, adresa).
|
||||
|
||||
První je adresa, přes kterou vede výchozí trasa — na té síti je telefon
|
||||
nejspíš taky. Zbytek podle typu. Čistá funkce, ať jde otestovat i to,
|
||||
co zrovna není v systému.
|
||||
"""
|
||||
seen: dict[str, Address] = {}
|
||||
|
||||
for interface, ip in found:
|
||||
kind = classify(ip, interface)
|
||||
if kind is None or ip in seen:
|
||||
continue
|
||||
seen[ip] = Address(ip=ip, interface=interface, kind=kind,
|
||||
primary=(ip == primary))
|
||||
|
||||
return sorted(seen.values(),
|
||||
key=lambda a: (not a.primary, ORDER.get(a.kind, 9), a.ip))
|
||||
|
||||
|
||||
def default_route_address() -> str | None:
|
||||
"""Adresa rozhraní, kudy vede výchozí trasa.
|
||||
|
||||
Připojení UDP socketu nic neposílá, jen si vyžádá směrování.
|
||||
"""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("10.255.255.255", 1))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _from_interfaces() -> list[tuple[str, str]]:
|
||||
"""Adresy všech rozhraní. Linux, jen standardní knihovna."""
|
||||
try:
|
||||
import fcntl
|
||||
import struct
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
siocgifaddr = 0x8915
|
||||
found = []
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
for _index, name in socket.if_nameindex():
|
||||
try:
|
||||
packed = struct.pack("256s", name.encode()[:15])
|
||||
info = fcntl.ioctl(sock.fileno(), siocgifaddr, packed)
|
||||
found.append((name, socket.inet_ntoa(info[20:24])))
|
||||
except OSError:
|
||||
continue # rozhraní bez IPv4 adresy
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def _from_hostname() -> list[tuple[str, str]]:
|
||||
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
return [("", info[4][0]) for info in infos]
|
||||
|
||||
|
||||
def addresses() -> list[Address]:
|
||||
"""Kde všude je počitadlo k dispozici, od nejpravděpodobnější adresy.
|
||||
|
||||
`DICE_HOST` má poslední slovo — když je nastavená, platí jen ona.
|
||||
"""
|
||||
forced = os.environ.get("DICE_HOST")
|
||||
if forced:
|
||||
return [Address(ip=forced, interface="", kind="lan", primary=True)]
|
||||
|
||||
primary = default_route_address()
|
||||
|
||||
found = _from_interfaces() or _from_hostname()
|
||||
if primary and primary not in [ip for _name, ip in found]:
|
||||
found.append(("", primary))
|
||||
|
||||
return rank(found, primary)
|
||||
@@ -467,3 +467,29 @@ button {
|
||||
font-size: 1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* --- další cesty k počitadlu --------------------------------------------- */
|
||||
|
||||
.alt {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.alt li { white-space: nowrap; }
|
||||
|
||||
.alt em {
|
||||
font-family: var(--font-ui);
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.address code {
|
||||
font-family: var(--font-num);
|
||||
font-size: 0.85em;
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
{# data-address si hlídá tabule: když se notebook přesune na jinou síť,
|
||||
je v QR kódu stará adresa a stránka se sama načte znovu. #}
|
||||
<p class="eyebrow">Zápisník · hra {{ game_id }}</p>
|
||||
<h1>{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}</h1>
|
||||
<p class="meta">do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}</p>
|
||||
@@ -0,0 +1,22 @@
|
||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
|
||||
{% if primary %}
|
||||
<div class="join">
|
||||
{{ target|qr }}
|
||||
<div class="address">
|
||||
<span>Zapisuje se z telefonu</span>
|
||||
{{ target }}
|
||||
{% if others %}
|
||||
<ul class="alt">
|
||||
{% for address in others %}
|
||||
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="join">
|
||||
<p class="address"><span>Adresu se nepodařilo zjistit</span>
|
||||
Spusť server s <code>DICE_HOST=<adresa></code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,3 +1,3 @@
|
||||
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
||||
<div id="head">{% include "_head.html" %}</div>
|
||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
||||
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||
+10
-6
@@ -4,11 +4,8 @@
|
||||
{% block body %}
|
||||
<div class="board">
|
||||
<header class="masthead">
|
||||
<div id="head">{% include "_head.html" %}</div>
|
||||
<div class="join">
|
||||
{{ qr }}
|
||||
<p class="address"><span>Zapisuje se z telefonu</span>{{ address }}</p>
|
||||
</div>
|
||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
||||
{% include "_join.html" %}
|
||||
</header>
|
||||
|
||||
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||
@@ -16,7 +13,6 @@
|
||||
|
||||
<script>
|
||||
// Tabule se doptává serveru; není co udržovat na klientovi.
|
||||
// QR kód a adresa se needitují, takže zůstávají mimo obnovovanou část.
|
||||
const url = "{{ url_for('board_panel', game_id=game_id) }}";
|
||||
|
||||
setInterval(async () => {
|
||||
@@ -25,6 +21,14 @@
|
||||
if (!response.ok) return;
|
||||
|
||||
const fresh = new DOMParser().parseFromString(await response.text(), "text/html");
|
||||
|
||||
// Notebook se přesunul na jinou síť — adresa v QR kódu už neplatí.
|
||||
const head = fresh.getElementById("head");
|
||||
if (head && head.dataset.address !== document.getElementById("head").dataset.address) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of ["head", "sheet"]) {
|
||||
const updated = fresh.getElementById(id);
|
||||
if (updated) document.getElementById(id).replaceWith(updated);
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<p class="eyebrow">Zápisník</p>
|
||||
<h1>Zatím se nehraje</h1>
|
||||
<p class="hint">Naskenuj kód telefonem a založ hru.</p>
|
||||
<div class="join">
|
||||
{{ qr }}
|
||||
<p class="address"><span>Adresa počitadla</span>{{ address }}</p>
|
||||
</div>
|
||||
{% include "_join.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
import pytest
|
||||
|
||||
import net
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip, interface, expected", [
|
||||
("127.0.0.1", "lo", None), # loopback
|
||||
("169.254.3.7", "wlp44s0", None), # link-local, adresa bez DHCP
|
||||
("0.0.0.0", "eth0", None),
|
||||
("nesmysl", "eth0", None),
|
||||
("192.168.1.20", "wlp44s0", "lan"),
|
||||
("10.186.234.182", "wlp44s0", "lan"), # hotspot z telefonu
|
||||
("172.20.10.3", "en0", "lan"), # hotspot z iPhonu
|
||||
("100.91.0.24", "tailscale0", "tailnet"),
|
||||
("172.17.0.1", "docker0", "virtual"),
|
||||
("192.168.122.1", "virbr0", "virtual"),
|
||||
("10.0.0.5", "br-2f1a798d", "virtual"),
|
||||
("93.184.216.34", "eth0", "public"),
|
||||
])
|
||||
def test_klasifikace_podle_rozsahu(ip, interface, expected):
|
||||
assert net.classify(ip, interface) == expected
|
||||
|
||||
|
||||
def test_tailnet_pozna_i_jine_rozhrani():
|
||||
"""Rozhoduje rozsah adresy, ne jméno rozhraní."""
|
||||
assert net.classify("100.64.0.1", "eth0") == "tailnet"
|
||||
assert net.classify("100.127.255.254", "") == "tailnet"
|
||||
assert net.classify("100.128.0.1", "eth0") == "public" # už mimo rozsah
|
||||
|
||||
|
||||
def test_poradi_adres():
|
||||
found = [
|
||||
("docker0", "172.17.0.1"),
|
||||
("tailscale0", "100.91.0.24"),
|
||||
("lo", "127.0.0.1"),
|
||||
("wlp44s0", "10.186.234.182"),
|
||||
("eth0", "192.168.1.20"),
|
||||
]
|
||||
|
||||
ranked = net.rank(found, primary="10.186.234.182")
|
||||
|
||||
assert [a.ip for a in ranked] == [
|
||||
"10.186.234.182", # výchozí trasa
|
||||
"192.168.1.20", # ostatní místní sítě
|
||||
"100.91.0.24", # tailnet funguje i napříč sítěmi
|
||||
"172.17.0.1", # virtuální až nakonec
|
||||
]
|
||||
assert ranked[0].primary
|
||||
assert not ranked[1].primary
|
||||
|
||||
|
||||
def test_bez_vychozi_trasy_se_poradi_drzi_typu():
|
||||
found = [("docker0", "172.17.0.1"), ("wlp44s0", "192.168.1.20")]
|
||||
|
||||
ranked = net.rank(found, primary=None)
|
||||
|
||||
assert [a.ip for a in ranked] == ["192.168.1.20", "172.17.0.1"]
|
||||
assert not any(a.primary for a in ranked)
|
||||
|
||||
|
||||
def test_duplicity_se_slucuji():
|
||||
found = [("wlp44s0", "10.0.0.5"), ("", "10.0.0.5")]
|
||||
|
||||
assert len(net.rank(found, primary="10.0.0.5")) == 1
|
||||
|
||||
|
||||
def test_zadna_pouzitelna_adresa():
|
||||
assert net.rank([("lo", "127.0.0.1")], primary=None) == []
|
||||
|
||||
|
||||
def test_dice_host_ma_posledni_slovo(monkeypatch):
|
||||
monkeypatch.setenv("DICE_HOST", "192.168.5.5")
|
||||
|
||||
found = net.addresses()
|
||||
|
||||
assert [a.ip for a in found] == ["192.168.5.5"]
|
||||
assert found[0].primary
|
||||
|
||||
|
||||
def test_url_a_popisek():
|
||||
address = net.Address(ip="10.0.0.5", interface="wlp44s0", kind="tailnet")
|
||||
|
||||
assert address.url(8000) == "http://10.0.0.5:8000"
|
||||
assert address.label == "přes Tailscale"
|
||||
|
||||
|
||||
def test_v_systemu_se_neco_najde():
|
||||
"""Na stroji s libovolnou sítí musí vyjít aspoň jedna adresa."""
|
||||
found = net.addresses()
|
||||
|
||||
assert all(net.classify(a.ip, a.interface) for a in found)
|
||||
assert len({a.ip for a in found}) == len(found)
|
||||
@@ -12,6 +12,7 @@ from flask import (Flask, abort, g, redirect, render_template, request,
|
||||
url_for)
|
||||
from markupsafe import Markup
|
||||
|
||||
import net
|
||||
import stats
|
||||
import storage
|
||||
from core import FINAL_SCORE, Game, GameOver
|
||||
@@ -42,23 +43,27 @@ def close_db(_exception):
|
||||
|
||||
# --- adresa a QR kód --------------------------------------------------------
|
||||
|
||||
def lan_address() -> str:
|
||||
"""Adresa, na kterou se dá z telefonu připojit.
|
||||
def primary_ip() -> str:
|
||||
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
|
||||
found = net.addresses()
|
||||
return found[0].ip if found else ""
|
||||
|
||||
IP notebooku se podle DHCP mění, proto ji hledáme za běhu. Připojení
|
||||
UDP socketu nic neposílá, jen si vyžádá směrování k výchozí bráně.
|
||||
|
||||
def links(path: str = "/") -> dict:
|
||||
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
|
||||
|
||||
Hledá se při každém načtení — notebook se během večera může přesunout
|
||||
z domácí WiFi na hotspot a zpátky.
|
||||
"""
|
||||
host = os.environ.get("DICE_HOST")
|
||||
if not host:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("10.255.255.255", 1))
|
||||
host = sock.getsockname()[0]
|
||||
except OSError:
|
||||
host = socket.gethostbyname(socket.gethostname())
|
||||
finally:
|
||||
sock.close()
|
||||
return f"http://{host}:{PORT}"
|
||||
found = net.addresses()
|
||||
primary = found[0] if found else None
|
||||
|
||||
return {
|
||||
"primary": primary,
|
||||
"others": found[1:],
|
||||
"port": PORT,
|
||||
"target": primary.url(PORT) + path if primary else "",
|
||||
}
|
||||
|
||||
|
||||
def qr_svg(data: str) -> Markup:
|
||||
@@ -91,6 +96,11 @@ def datum(value: str) -> str:
|
||||
return f"{int(den[2])}. {int(den[1])}. {den[0]}"
|
||||
|
||||
|
||||
@app.template_filter("qr")
|
||||
def qr_filter(data: str) -> Markup:
|
||||
return qr_svg(data) if data else Markup("")
|
||||
|
||||
|
||||
@app.template_filter("kola")
|
||||
def kola(count: int) -> str:
|
||||
if count == 1:
|
||||
@@ -156,6 +166,7 @@ def view(game: Game, game_id: int, row) -> dict:
|
||||
"standings": game.standings(),
|
||||
"winner": winner,
|
||||
"current": "" if winner else game.current_player,
|
||||
"primary_ip": primary_ip(),
|
||||
}
|
||||
|
||||
|
||||
@@ -244,15 +255,14 @@ def board_redirect():
|
||||
if game_id:
|
||||
return redirect(url_for("board", game_id=game_id))
|
||||
|
||||
return render_template("board_idle.html", address=lan_address(),
|
||||
qr=qr_svg(lan_address()))
|
||||
return render_template("board_idle.html", **links(url_for("index")))
|
||||
|
||||
|
||||
@app.route("/board/<int:game_id>")
|
||||
def board(game_id: int):
|
||||
game, row = load(game_id)
|
||||
address = lan_address() + url_for("game_view", game_id=game_id)
|
||||
return render_template("board.html", address=address, qr=qr_svg(address),
|
||||
return render_template("board.html",
|
||||
**links(url_for("game_view", game_id=game_id)),
|
||||
**view(game, game_id, row))
|
||||
|
||||
|
||||
@@ -273,6 +283,15 @@ def hall():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Počitadlo běží na {lan_address()}")
|
||||
print(f"Tabule: {lan_address()}/board")
|
||||
found = net.addresses()
|
||||
|
||||
if found:
|
||||
print("Počitadlo je dostupné na:")
|
||||
for address in found:
|
||||
mark = "→" if address.primary else " "
|
||||
print(f" {mark} {address.url(PORT):30} {address.label}")
|
||||
print(f"\nTabule na notebook: {found[0].url(PORT)}/board")
|
||||
else:
|
||||
print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.")
|
||||
|
||||
app.run(host="0.0.0.0", port=PORT, threaded=True)
|
||||
Reference in new issue
Block a user