Панель: • шесть страниц с маршрутизацией по хэшу: Обзор, 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
197 строки
8.6 KiB
Python
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)
|