mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-30 18:51:57 +01:00
Add basic coverege support.
This commit is contained in:
1 parent
29da1487d4
commit
9622092c17
8 files changed
+340
-5
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
# Coverage Prediction
|
||||||
|
|
||||||
|
Meshview can display a predicted coverage boundary for a node. This is a **model**
|
||||||
|
estimate, not a guarantee of real-world performance.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The coverage boundary is computed using the Longley-Rice / ITM **area mode**
|
||||||
|
propagation model. Area mode estimates average path loss over generic terrain
|
||||||
|
and does not use a terrain profile. This means it captures general distance
|
||||||
|
effects, but **does not** account for terrain shadows, buildings, or foliage.
|
||||||
|
|
||||||
|
## What you are seeing
|
||||||
|
|
||||||
|
The UI draws a **perimeter** (not a heatmap) that represents the furthest
|
||||||
|
distance where predicted signal strength is above a threshold (default
|
||||||
|
`-120 dBm`). The model is run radially from the node in multiple directions,
|
||||||
|
and the last point above the threshold forms the outline.
|
||||||
|
|
||||||
|
## Key parameters
|
||||||
|
|
||||||
|
- **Frequency**: default `907 MHz`
|
||||||
|
- **Transmit power**: default `20 dBm`
|
||||||
|
- **Antenna heights**: default `5 m` (TX) and `1.5 m` (RX)
|
||||||
|
- **Reliability**: default `0.5` (median)
|
||||||
|
- **Terrain irregularity**: default `90 m` (average terrain)
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- No terrain or building data is used (area mode only).
|
||||||
|
- Results are sensitive to power, height, and threshold.
|
||||||
|
- Environmental factors can cause large real-world deviations.
|
||||||
|
|
||||||
@@ -189,6 +189,9 @@
|
|||||||
"times_seen": "Times seen",
|
"times_seen": "Times seen",
|
||||||
"copy_import_url": "Copy Import URL",
|
"copy_import_url": "Copy Import URL",
|
||||||
"show_qr_code": "Show QR Code",
|
"show_qr_code": "Show QR Code",
|
||||||
|
"toggle_coverage": "Toggle Coverage",
|
||||||
|
"location_required": "Location required for coverage",
|
||||||
|
"coverage_help": "Coverage Help",
|
||||||
"share_contact_qr": "Share Contact QR",
|
"share_contact_qr": "Share Contact QR",
|
||||||
"copy_url": "Copy URL",
|
"copy_url": "Copy URL",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"times_seen": "Veces visto",
|
"times_seen": "Veces visto",
|
||||||
"copy_import_url": "Copiar URL de importación",
|
"copy_import_url": "Copiar URL de importación",
|
||||||
"show_qr_code": "Mostrar código QR",
|
"show_qr_code": "Mostrar código QR",
|
||||||
|
"toggle_coverage": "Alternar cobertura",
|
||||||
|
"location_required": "Se requiere ubicación para la cobertura",
|
||||||
|
"coverage_help": "Ayuda de cobertura",
|
||||||
"share_contact_qr": "Compartir contacto QR",
|
"share_contact_qr": "Compartir contacto QR",
|
||||||
"copy_url": "Copiar URL",
|
"copy_url": "Copiar URL",
|
||||||
"copied": "¡Copiado!",
|
"copied": "¡Copiado!",
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import math
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pyitm import itm
|
||||||
|
|
||||||
|
DEFAULT_CLIMATE = 5 # Continental temperate
|
||||||
|
DEFAULT_GROUND = 0.005 # Average ground conductivity
|
||||||
|
DEFAULT_EPS_DIELECT = 15.0
|
||||||
|
DEFAULT_DELTA_H = 90.0
|
||||||
|
DEFAULT_RELIABILITY = 0.5
|
||||||
|
DEFAULT_MIN_DBM = -130.0
|
||||||
|
DEFAULT_MAX_DBM = -80.0
|
||||||
|
DEFAULT_THRESHOLD_DBM = -120.0
|
||||||
|
EARTH_RADIUS_KM = 6371.0
|
||||||
|
BEARING_STEP_DEG = 5
|
||||||
|
|
||||||
|
|
||||||
|
def destination_point(
|
||||||
|
lat: float, lon: float, bearing_deg: float, distance_km: float
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
lat1 = math.radians(lat)
|
||||||
|
lon1 = math.radians(lon)
|
||||||
|
bearing = math.radians(bearing_deg)
|
||||||
|
|
||||||
|
d = distance_km / EARTH_RADIUS_KM
|
||||||
|
|
||||||
|
lat2 = math.asin(
|
||||||
|
math.sin(lat1) * math.cos(d) + math.cos(lat1) * math.sin(d) * math.cos(bearing)
|
||||||
|
)
|
||||||
|
|
||||||
|
lon2 = lon1 + math.atan2(
|
||||||
|
math.sin(bearing) * math.sin(d) * math.cos(lat1),
|
||||||
|
math.cos(d) - math.sin(lat1) * math.sin(lat2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return math.degrees(lat2), math.degrees(lon2)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def compute_coverage(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
freq_mhz: float,
|
||||||
|
tx_dbm: float,
|
||||||
|
tx_height_m: float,
|
||||||
|
rx_height_m: float,
|
||||||
|
radius_km: float,
|
||||||
|
step_km: float,
|
||||||
|
reliability: float,
|
||||||
|
) -> list[tuple[float, float, float]]:
|
||||||
|
points = []
|
||||||
|
distance = max(step_km, 1.0)
|
||||||
|
while distance <= radius_km:
|
||||||
|
for bearing in range(0, 360, BEARING_STEP_DEG):
|
||||||
|
rx_lat, rx_lon = destination_point(lat, lon, bearing, distance)
|
||||||
|
try:
|
||||||
|
loss_db, _ = itm.area(
|
||||||
|
ModVar=2, # Mobile: pctTime=reliability, pctConf=confidence
|
||||||
|
deltaH=DEFAULT_DELTA_H,
|
||||||
|
tht_m=tx_height_m,
|
||||||
|
rht_m=rx_height_m,
|
||||||
|
dist_km=distance,
|
||||||
|
TSiteCriteria=0,
|
||||||
|
RSiteCriteria=0,
|
||||||
|
eps_dielect=DEFAULT_EPS_DIELECT,
|
||||||
|
sgm_conductivity=DEFAULT_GROUND,
|
||||||
|
eno_ns_surfref=301,
|
||||||
|
frq_mhz=freq_mhz,
|
||||||
|
radio_climate=DEFAULT_CLIMATE,
|
||||||
|
pol=1,
|
||||||
|
pctTime=reliability,
|
||||||
|
pctLoc=0.5,
|
||||||
|
pctConf=0.5,
|
||||||
|
)
|
||||||
|
rx_dbm = tx_dbm - loss_db
|
||||||
|
points.append((rx_lat, rx_lon, rx_dbm))
|
||||||
|
except itm.InputError:
|
||||||
|
continue
|
||||||
|
distance += step_km
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=512)
|
||||||
|
def compute_perimeter(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
freq_mhz: float,
|
||||||
|
tx_dbm: float,
|
||||||
|
tx_height_m: float,
|
||||||
|
rx_height_m: float,
|
||||||
|
radius_km: float,
|
||||||
|
step_km: float,
|
||||||
|
reliability: float,
|
||||||
|
threshold_dbm: float,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
perimeter = []
|
||||||
|
distance = max(step_km, 1.0)
|
||||||
|
for bearing in range(0, 360, BEARING_STEP_DEG):
|
||||||
|
last_point = None
|
||||||
|
last_rx_dbm = None
|
||||||
|
dist = distance
|
||||||
|
while dist <= radius_km:
|
||||||
|
try:
|
||||||
|
loss_db, _ = itm.area(
|
||||||
|
ModVar=2,
|
||||||
|
deltaH=DEFAULT_DELTA_H,
|
||||||
|
tht_m=tx_height_m,
|
||||||
|
rht_m=rx_height_m,
|
||||||
|
dist_km=dist,
|
||||||
|
TSiteCriteria=0,
|
||||||
|
RSiteCriteria=0,
|
||||||
|
eps_dielect=DEFAULT_EPS_DIELECT,
|
||||||
|
sgm_conductivity=DEFAULT_GROUND,
|
||||||
|
eno_ns_surfref=301,
|
||||||
|
frq_mhz=freq_mhz,
|
||||||
|
radio_climate=DEFAULT_CLIMATE,
|
||||||
|
pol=1,
|
||||||
|
pctTime=reliability,
|
||||||
|
pctLoc=0.5,
|
||||||
|
pctConf=0.5,
|
||||||
|
)
|
||||||
|
except itm.InputError:
|
||||||
|
dist += step_km
|
||||||
|
continue
|
||||||
|
|
||||||
|
rx_dbm = tx_dbm - loss_db
|
||||||
|
if rx_dbm >= threshold_dbm:
|
||||||
|
last_point = destination_point(lat, lon, bearing, dist)
|
||||||
|
last_rx_dbm = rx_dbm
|
||||||
|
dist += step_km
|
||||||
|
|
||||||
|
if last_point and last_rx_dbm is not None:
|
||||||
|
perimeter.append(last_point)
|
||||||
|
|
||||||
|
return perimeter
|
||||||
@@ -338,6 +338,12 @@
|
|||||||
<button onclick="showQrCode()" id="showQrBtn">
|
<button onclick="showQrCode()" id="showQrBtn">
|
||||||
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="toggleCoverage()" id="toggleCoverageBtn" disabled title="Location required for coverage">
|
||||||
|
<span>📡</span> <span data-translate-lang="toggle_coverage">Toggle Coverage</span>
|
||||||
|
</button>
|
||||||
|
<a class="inline-link" id="coverageHelpLink" href="/docs/COVERAGE.md" target="_blank" rel="noopener" data-translate-lang="coverage_help">
|
||||||
|
Coverage Help
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Impersonation Warning -->
|
<!-- Impersonation Warning -->
|
||||||
@@ -517,6 +523,7 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
|
||||||
<script src="/static/portmaps.js"></script>
|
<script src="/static/portmaps.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -635,6 +642,7 @@ let currentNode = null;
|
|||||||
let currentPacketRows = [];
|
let currentPacketRows = [];
|
||||||
|
|
||||||
let map, markers = {};
|
let map, markers = {};
|
||||||
|
let coverageLayer = null;
|
||||||
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
||||||
|
|
||||||
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
|
let fromNodeId = new URLSearchParams(window.location.search).get("from_node_id");
|
||||||
@@ -708,6 +716,20 @@ async function loadNodeInfo(){
|
|||||||
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
||||||
document.getElementById("info-lon").textContent =
|
document.getElementById("info-lon").textContent =
|
||||||
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
||||||
|
const coverageBtn = document.getElementById("toggleCoverageBtn");
|
||||||
|
const coverageHelp = document.getElementById("coverageHelpLink");
|
||||||
|
if (coverageBtn) {
|
||||||
|
const hasLocation = Boolean(node.last_lat && node.last_long);
|
||||||
|
coverageBtn.disabled = !hasLocation;
|
||||||
|
coverageBtn.title = hasLocation
|
||||||
|
? ""
|
||||||
|
: (nodeTranslations.location_required || "Location required for coverage");
|
||||||
|
coverageBtn.style.display = hasLocation ? "" : "none";
|
||||||
|
}
|
||||||
|
if (coverageHelp) {
|
||||||
|
const hasLocation = Boolean(node.last_lat && node.last_long);
|
||||||
|
coverageHelp.style.display = hasLocation ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
let lastSeen = "—";
|
let lastSeen = "—";
|
||||||
if (node.last_seen_us) {
|
if (node.last_seen_us) {
|
||||||
@@ -799,6 +821,43 @@ function initMap(){
|
|||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleCoverage() {
|
||||||
|
if (!map) initMap();
|
||||||
|
|
||||||
|
if (coverageLayer) {
|
||||||
|
map.removeLayer(coverageLayer);
|
||||||
|
coverageLayer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeId = currentNode?.node_id || fromNodeId;
|
||||||
|
if (!nodeId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/coverage/${encodeURIComponent(nodeId)}?mode=perimeter`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Coverage request failed", res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.perimeter || data.perimeter.length < 3) {
|
||||||
|
console.warn("Coverage perimeter missing or too small");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coverageLayer = L.polygon(data.perimeter, {
|
||||||
|
color: "#6f42c1",
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.7,
|
||||||
|
fillColor: "#000000",
|
||||||
|
fillOpacity: 0.10
|
||||||
|
}).addTo(map);
|
||||||
|
map.fitBounds(coverageLayer.getBounds(), { padding: [20, 20] });
|
||||||
|
map.invalidateSize();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Coverage request failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hideMap(){
|
function hideMap(){
|
||||||
const mapDiv = document.getElementById("map");
|
const mapDiv = document.getElementById("map");
|
||||||
if (mapDiv) {
|
if (mapDiv) {
|
||||||
|
|||||||
@@ -229,6 +229,20 @@ async def serve_page(request):
|
|||||||
return web.Response(text=content, content_type="text/html")
|
return web.Response(text=content, content_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/docs/{doc}")
|
||||||
|
async def serve_doc(request):
|
||||||
|
"""Serve documentation files from docs/ (markdown)."""
|
||||||
|
doc = request.match_info["doc"]
|
||||||
|
docs_root = pathlib.Path(__file__).parent.parent / "docs"
|
||||||
|
doc_path = (docs_root / doc).resolve()
|
||||||
|
|
||||||
|
if not doc_path.is_file() or docs_root not in doc_path.parents:
|
||||||
|
raise web.HTTPNotFound(text="Document not found")
|
||||||
|
|
||||||
|
content = doc_path.read_text(encoding="utf-8")
|
||||||
|
return web.Response(text=content, content_type="text/markdown")
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/net")
|
@routes.get("/net")
|
||||||
async def net(request):
|
async def net(request):
|
||||||
return web.Response(
|
return web.Response(
|
||||||
|
|||||||
+90
-4
@@ -6,7 +6,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from meshtastic.protobuf.portnums_pb2 import PortNum
|
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||||
from meshview import database, decode_payload, store
|
from meshview import database, decode_payload, store
|
||||||
@@ -15,6 +15,14 @@ from meshview.config import CONFIG
|
|||||||
from meshview.models import Node, NodePublicKey
|
from meshview.models import Node, NodePublicKey
|
||||||
from meshview.models import Packet as PacketModel
|
from meshview.models import Packet as PacketModel
|
||||||
from meshview.models import PacketSeen as PacketSeenModel
|
from meshview.models import PacketSeen as PacketSeenModel
|
||||||
|
from meshview.radio.coverage import (
|
||||||
|
DEFAULT_MAX_DBM,
|
||||||
|
DEFAULT_MIN_DBM,
|
||||||
|
DEFAULT_RELIABILITY,
|
||||||
|
DEFAULT_THRESHOLD_DBM,
|
||||||
|
compute_coverage,
|
||||||
|
compute_perimeter,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -636,9 +644,7 @@ async def health_check(request):
|
|||||||
# Check database connectivity
|
# Check database connectivity
|
||||||
try:
|
try:
|
||||||
async with database.async_session() as session:
|
async with database.async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(select(func.max(PacketModel.import_time_us)))
|
||||||
select(func.max(PacketModel.import_time_us))
|
|
||||||
)
|
|
||||||
last_import_time_us = result.scalar()
|
last_import_time_us = result.scalar()
|
||||||
health_status["database"] = "connected"
|
health_status["database"] = "connected"
|
||||||
if last_import_time_us is not None:
|
if last_import_time_us is not None:
|
||||||
@@ -1035,3 +1041,83 @@ async def api_node_impersonation_check(request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking impersonation for node {node_id}: {e}")
|
logger.error(f"Error checking impersonation for node {node_id}: {e}")
|
||||||
return web.json_response({"error": "Failed to check impersonation"}, status=500)
|
return web.json_response({"error": "Failed to check impersonation"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/api/coverage/{node_id}")
|
||||||
|
async def api_coverage(request):
|
||||||
|
try:
|
||||||
|
node_id = int(request.match_info["node_id"], 0)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return web.json_response({"error": "Invalid node_id"}, status=400)
|
||||||
|
|
||||||
|
def parse_float(name, default):
|
||||||
|
value = request.query.get(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise web.HTTPBadRequest(
|
||||||
|
text=json.dumps({"error": f"{name} must be a number"}),
|
||||||
|
content_type="application/json",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
freq_mhz = parse_float("freq_mhz", 907.0)
|
||||||
|
tx_dbm = parse_float("tx_dbm", 20.0)
|
||||||
|
tx_height_m = parse_float("tx_height_m", 5.0)
|
||||||
|
rx_height_m = parse_float("rx_height_m", 1.5)
|
||||||
|
radius_km = parse_float("radius_km", 40.0)
|
||||||
|
step_km = parse_float("step_km", 0.25)
|
||||||
|
reliability = parse_float("reliability", DEFAULT_RELIABILITY)
|
||||||
|
threshold_dbm = parse_float("threshold_dbm", DEFAULT_THRESHOLD_DBM)
|
||||||
|
except web.HTTPBadRequest as exc:
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
node = await store.get_node(node_id)
|
||||||
|
if not node or not node.last_lat or not node.last_long:
|
||||||
|
return web.json_response({"error": "Node not found or missing location"}, status=404)
|
||||||
|
|
||||||
|
lat = node.last_lat * 1e-7
|
||||||
|
lon = node.last_long * 1e-7
|
||||||
|
|
||||||
|
mode = request.query.get("mode", "perimeter")
|
||||||
|
if mode == "perimeter":
|
||||||
|
perimeter = compute_perimeter(
|
||||||
|
lat=round(lat, 7),
|
||||||
|
lon=round(lon, 7),
|
||||||
|
freq_mhz=round(freq_mhz, 3),
|
||||||
|
tx_dbm=round(tx_dbm, 2),
|
||||||
|
tx_height_m=round(tx_height_m, 2),
|
||||||
|
rx_height_m=round(rx_height_m, 2),
|
||||||
|
radius_km=round(radius_km, 2),
|
||||||
|
step_km=round(step_km, 3),
|
||||||
|
reliability=round(reliability, 3),
|
||||||
|
threshold_dbm=round(threshold_dbm, 1),
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter}
|
||||||
|
)
|
||||||
|
|
||||||
|
points = compute_coverage(
|
||||||
|
lat=round(lat, 7),
|
||||||
|
lon=round(lon, 7),
|
||||||
|
freq_mhz=round(freq_mhz, 3),
|
||||||
|
tx_dbm=round(tx_dbm, 2),
|
||||||
|
tx_height_m=round(tx_height_m, 2),
|
||||||
|
rx_height_m=round(rx_height_m, 2),
|
||||||
|
radius_km=round(radius_km, 2),
|
||||||
|
step_km=round(step_km, 3),
|
||||||
|
reliability=round(reliability, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
min_dbm = DEFAULT_MIN_DBM
|
||||||
|
max_dbm = DEFAULT_MAX_DBM
|
||||||
|
if points:
|
||||||
|
vals = [p[2] for p in points]
|
||||||
|
min_dbm = min(min_dbm, min(vals))
|
||||||
|
max_dbm = max(max_dbm, max(vals))
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"mode": "heatmap", "min_dbm": min_dbm, "max_dbm": max_dbm, "points": points}
|
||||||
|
)
|
||||||
+2
-1
@@ -24,6 +24,7 @@ MarkupSafe~=3.0.2
|
|||||||
|
|
||||||
# Graphs / diagrams
|
# Graphs / diagrams
|
||||||
pydot~=3.0.4
|
pydot~=3.0.4
|
||||||
|
pyitm~=0.3
|
||||||
|
|
||||||
|
|
||||||
#############################
|
#############################
|
||||||
@@ -47,4 +48,4 @@ objgraph~=3.6.2
|
|||||||
# Testing
|
# Testing
|
||||||
pytest~=8.3.4
|
pytest~=8.3.4
|
||||||
pytest-aiohttp~=1.0.5
|
pytest-aiohttp~=1.0.5
|
||||||
pytest-asyncio~=0.24.0
|
pytest-asyncio~=0.24.0
|
||||||
Reference in new issue
Block a user