Files
viktorиClaude Opus 4.8 cb4e8a9bb1 панель: многостраничная, Last Heard, SSE, контроль доступа
Панель:
 • шесть страниц с маршрутизацией по хэшу: Обзор, Last Heard, Хотспоты,
   Группы, Доступ, Настройки;
 • журнал приходит живым потоком (SSE) вместо опроса раз в две секунды;
 • Last Heard: кто, куда, когда, сколько, через какой хотспот;
 • в таблице хотспотов — ВСЁ, что устройство сообщает в RPTC.

Контроль доступа (два независимых уровня, их легко перепутать):
 • хотспот пускается по паролю — у каждого может быть свой, плюс режим
   «только из списка» и отключение конкретного устройства;
 • абонент (поле src, владелец рации) допускается белым/чёрным списком с
   поддержкой диапазонов. Встроено в штатный хук guard ядра.

Два бага по дороге:
 • поля RPTC читались как colorcode/latitude/longitude, а разборщик отдаёт
   color_code/lat/lon — половина таблицы молча пустовала;
 • столбец «описание» на деле содержит СТРАНУ: именно туда Pi-Star кладёт
   country. Переименован.

tests/test_access.py 20/20, tests/test_panel.py 31/31 (включая наполнение
Last Heard настоящим рукопожатием и голосовым вызовом по UDP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvX94Vwy7vXWEuiFm1g9Av
2026-07-29 01:32:46 +09:00

197 строки
8.6 KiB
Python

"""Проверка панели: страницы, API, SSE и Last Heard.
Поднимает настоящий сервер и говорит с ним по HTTP, как браузер.
Запуск: python3 tests/test_panel.py
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from base64 import b64encode
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
CFG = "/tmp/dmrsrv-panel-test.json"
PORT = 18080
UDP = 18031
ok_n = fail_n = 0
def ok(m):
global ok_n
ok_n += 1
print(f" \033[32mOK\033[0m {m}")
def bad(m):
global fail_n
fail_n += 1
print(f" \033[31mFAIL\033[0m {m}")
def check(c, g, b):
ok(g) if c else bad(b)
Path(CFG).write_text(json.dumps({
"bind": "127.0.0.1", "port": UDP, "passphrase": "testpass",
"panel": {"bind": "127.0.0.1", "port": PORT, "user": "admin", "password": "sekret"},
"groups": [{"tg": 9, "name": "Локальная", "slot": 2}],
"parrot": {"enabled": False, "tg": 9990},
"hotspots": [], "allow_unknown_hotspots": True,
"subscribers": {"mode": "off", "list": []},
}, ensure_ascii=False))
env = dict(os.environ, DMRSRV_CONFIG=CFG)
srv = subprocess.Popen([sys.executable, "-m", "dmrsrv"], cwd=ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
AUTH = "Basic " + b64encode(b"admin:sekret").decode()
def req(path, data=None, auth=True, timeout=6):
r = urllib.request.Request(f"http://127.0.0.1:{PORT}{path}")
if auth:
r.add_header("Authorization", AUTH)
if data is not None:
r.data = json.dumps(data).encode()
r.add_header("Content-Type", "application/json")
return urllib.request.urlopen(r, timeout=timeout)
try:
for _ in range(60):
try:
req("/api/state").read()
break
except Exception:
time.sleep(0.25)
else:
print("сервер не поднялся:", srv.stdout.read() if srv.stdout else "")
sys.exit(1)
print("==> авторизация")
try:
req("/", auth=False)
bad("панель отдаётся без пароля!")
except urllib.error.HTTPError as e:
check(e.code == 401, "без пароля — 401", f"код {e.code}")
check(req("/").status == 200, "с паролем — 200", "с паролем не пускает")
print("==> страницы")
html = req("/").read().decode()
for pid, name in [("p-overview", "Обзор"), ("p-lastheard", "Last Heard"),
("p-hotspots", "Хотспоты"), ("p-groups", "Группы"),
("p-access", "Доступ"), ("p-settings", "Настройки")]:
check(f'id={pid}' in html, f"страница «{name}» есть", f"нет страницы {pid}")
check("EventSource" in html, "журнал подключён живым потоком (SSE)", "SSE не подключён")
print("==> управление хотспотами")
req("/api/hotspots/add", {"peer_id": 2501001, "name": "Гараж", "password": "hotpass"})
st = json.load(req("/api/state"))
check(any(h["peer_id"] == 2501001 for h in st["hotspots"]), "хотспот добавлен", "не добавился")
try:
req("/api/hotspots/add", {"peer_id": 2501001})
bad("дубль хотспота принят")
except urllib.error.HTTPError as e:
check(e.code == 400, "повторный номер отклонён", f"код {e.code}")
req("/api/hotspots/toggle", {"peer_id": 2501001})
st = json.load(req("/api/state"))
check(st["hotspots"][0]["enabled"] is False, "хотспот отключается", "не отключился")
print("==> списки абонентов")
req("/api/subscribers", {"mode": "deny", "list": "2501111 2502000-2502005"})
st = json.load(req("/api/state"))
check(st["subscribers"]["mode"] == "deny", "режим сохранён", "режим не сохранён")
check(len(st["subscribers"]["list"]) == 2, "список сохранён", "список не сохранён")
try:
req("/api/subscribers", {"mode": "allow", "list": "не-число"})
bad("мусор в списке принят — оператор не увидит опечатку")
except urllib.error.HTTPError as e:
check(e.code == 400, "мусор в списке отклонён с объяснением", f"код {e.code}")
try:
req("/api/subscribers", {"mode": "странно", "list": ""})
bad("неизвестный режим принят")
except urllib.error.HTTPError as e:
check(e.code == 400, "неизвестный режим отклонён", f"код {e.code}")
print("==> Last Heard наполняется из эфира")
# шлём настоящее рукопожатие и голосовой вызов
import hashlib
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
PEER = 2509999
def rt(p):
s.sendto(p, ("127.0.0.1", UDP))
return s.recv(2048)
salt = rt(b"RPTL" + PEER.to_bytes(4, "big"))[6:10]
rt(b"RPTK" + PEER.to_bytes(4, "big") + hashlib.sha256(salt + b"testpass").digest())
body = (f"{'LHTEST':<8.8s}{438000000:09d}{430000000:09d}{5:02d}{1:02d}"
f"{'51.66600':8.8s}{'-1.170000':9.9s}{10:03d}{'Irkutsk':<20.20s}"
f"{'Russia':<19.19s}4{'https://x':<124.124s}{'MMDVM_v1':<40.40s}{'MMDVM_HS':<40.40s}")
rt(b"RPTC" + PEER.to_bytes(4, "big") + body.encode())
def frame(seq, dtype, ftype=0b10):
bits = 0x80 | (ftype << 4) | dtype
return (b"DMRD" + bytes([seq]) + (2501234).to_bytes(3, "big") + (9).to_bytes(3, "big")
+ PEER.to_bytes(4, "big") + bytes([bits]) + b"\xaa\xbb\xcc\xdd"
+ bytes(range(33)) + b"\x00\x00")
s.sendto(frame(0, 0x01), ("127.0.0.1", UDP)) # начало
for i in range(1, 6):
s.sendto(frame(i, i % 6, 0b00), ("127.0.0.1", UDP))
s.sendto(frame(6, 0x02), ("127.0.0.1", UDP)) # терминатор
time.sleep(1.5)
st = json.load(req("/api/state"))
calls = st.get("calls", [])
check(len(calls) >= 1, "вызов попал в Last Heard", "Last Heard пуст после реального вызова")
if calls:
c = calls[0]
check(c["src_id"] == 2501234, "источник записан верно", f"src={c['src_id']}")
check(c["dst_id"] == 9, "назначение записано верно", f"dst={c['dst_id']}")
check(c["peer_id"] == PEER, "видно, через какой хотспот", f"peer={c['peer_id']}")
check(not c["active"], "вызов закрыт по терминатору", "вызов остался активным")
check(c["frames"] >= 6, "кадры посчитаны", f"кадров {c['frames']}")
print("==> поля RPTC доходят до панели")
p = st["peers"][0] if st["peers"] else {}
check(p.get("country") == "Russia", "страна (Pi-Star кладёт её в description)",
f"country={p.get('country')!r}")
check(p.get("location") == "Irkutsk", "местоположение", f"location={p.get('location')!r}")
check(p.get("color_code") == 1, "color code (раньше читался как colorcode и был пуст)",
f"color_code={p.get('color_code')!r}")
check(p.get("tx_power") == 5, "мощность", f"tx_power={p.get('tx_power')!r}")
check(p.get("rx_freq") == 438000000, "частота RX", f"rx={p.get('rx_freq')!r}")
check(p.get("lat") == 51.666, "широта", f"lat={p.get('lat')!r}")
check(p.get("package_id") == "MMDVM_HS", "модель устройства", f"pkg={p.get('package_id')!r}")
print("==> живой поток событий (SSE)")
r = urllib.request.Request(f"http://127.0.0.1:{PORT}/api/events")
r.add_header("Authorization", AUTH)
resp = urllib.request.urlopen(r, timeout=6)
check(resp.headers.get("Content-Type", "").startswith("text/event-stream"),
"заголовок text/event-stream", f"тип {resp.headers.get('Content-Type')}")
line = resp.readline().decode()
check(line.startswith("data: "), "события приходят в формате SSE", f"строка {line!r}")
resp.close()
finally:
srv.terminate()
try:
srv.wait(timeout=5)
except subprocess.TimeoutExpired:
srv.kill()
print(f"\n \033[1m{ok_n} OK, {fail_n} FAIL\033[0m\n")
sys.exit(0 if fail_n == 0 else 1)