tabule nabídne všechny adresy, ne jednu

Detekce vracela jedinou adresu podle výchozí trasy. Když ta nevede tam,
kam je připojený telefon, QR kód míří do prázdna a nezbývá než sahat do
konfigurace. Při ladění se ukázalo i to, že záložní větev může vrátit
127.0.0.1, tedy QR kód na localhost.

- net.py hledá adresy všech rozhraní přes if_nameindex a ioctl, se zálohou
  přes getaddrinfo; DICE_HOST má poslední slovo
- řadí se podle rozsahu adresy a obecného typu rozhraní, ne podle seznamu
  konkrétních jmen: výchozí trasa, místní sítě, tailnet, virtuální sítě
- loopback a link-local vypadnou, nic dalšího se nezahazuje — o tom, co
  funguje, rozhoduje člověk u stolu
- tailnet adresu vědomě necháváme: funguje i napříč sítěmi
- adresy se hledají při každém načtení; když se notebook přesune na jinou
  síť, tabule to pozná podle data-address a načte se znovu
- když se nenajde nic, tabule řekne, co s tím, místo QR kódu naslepo
- 20 testů nad klasifikací a řazením, nezávislých na aktuální síti
- README doplněn o řešení potíží s připojením

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gitea-actionsandClaude Opus 5 committed 2026-08-20 19:52:32 +02:00
1 parent cd10db1a72
commit 5052a48611
10 files changed
+374 -35

No files matched your search

+29 -3
View File
@@ -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 |
+151
View File
@@ -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)
+26
View File
@@ -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);
}
+2
View File
@@ -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>
+22
View File
@@ -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=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+1 -1
View File
@@ -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
View File
@@ -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);
+1 -4
View File
@@ -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 %}
+92
View File
@@ -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)
+40 -21
View File
@@ -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)