improved formatting and AGENTS.md

This commit is contained in:
Jiri Gabrys 2026-01-14 11:41:10 +01:00
parent 99c9961bab
commit ab2d3fefce
7 changed files with 498 additions and 364 deletions

View file

@ -28,6 +28,9 @@ Current version: `1.0.7` (see `VERSIONS.md`).
## Coding Style & Naming Conventions
- Python in `backend/*.py` uses **2-space indentation**; keep it consistent.
- The project enforces an **80-character column limit** for Python code to maintain readability.
- Formatting is handled by `yapf` using the config in `backend/.style.yapf`.
- To format code manually: `yapf --in-place --recursive --style backend/.style.yapf backend/`
- HTML/CSS/JS in `backend/static/index.html` uses 2 spaces as well.
- Use lowercase, underscore-separated names for Python variables/functions.
- Prefer small helper functions for parsing/normalization; keep logging concise.

View file

@ -1,5 +1,6 @@
[style]
based_on_style = facebook
indent_width = 2
column_limit = 200
column_limit = 80
continuation_indent_width = 2

View file

@ -239,8 +239,10 @@ def _load_neighbor_overrides() -> None:
_add_pair(src.strip(), targets.strip())
elif isinstance(data, list):
for item in data:
if (isinstance(item, (list, tuple)) and len(item) >= 2 and
isinstance(item[0], str) and isinstance(item[1], str)):
if (
isinstance(item, (list, tuple)) and len(item) >= 2 and
isinstance(item[0], str) and isinstance(item[1], str)
):
_add_pair(item[0].strip(), item[1].strip())
elif isinstance(item, dict):
src = item.get("from") or item.get("src") or item.get("a")
@ -249,13 +251,14 @@ def _load_neighbor_overrides() -> None:
_add_pair(src.strip(), dst.strip())
if added:
print(f"[neighbors] loaded {added} override pairs from {NEIGHBOR_OVERRIDES_FILE}")
print(
f"[neighbors] loaded {added} override pairs from {NEIGHBOR_OVERRIDES_FILE}"
)
def _touch_neighbor(src_id: str,
dst_id: str,
ts: float,
manual: bool = False) -> None:
def _touch_neighbor(
src_id: str, dst_id: str, ts: float, manual: bool = False
) -> None:
if not src_id or not dst_id or src_id == dst_id:
return
neighbors = neighbor_edges.setdefault(src_id, {})
@ -270,8 +273,7 @@ def _touch_neighbor(src_id: str,
entry["last_seen"] = max(float(entry.get("last_seen", 0.0)), float(ts))
def _record_neighbors(point_ids: List[Optional[str]],
ts: float) -> None:
def _record_neighbors(point_ids: List[Optional[str]], ts: float) -> None:
if not point_ids or len(point_ids) < 2:
return
for idx in range(len(point_ids) - 1):
@ -302,7 +304,8 @@ def _serialize_state() -> Dict[str, Any]:
"version": 1,
"saved_at": time.time(),
"devices": {
k: asdict(v) for k, v in devices.items()
k: asdict(v)
for k, v in devices.items()
},
"trails": trails,
"seen_devices": seen_devices,
@ -338,7 +341,8 @@ def _check_git_updates() -> None:
stderr=subprocess.DEVNULL,
)
inside = _run_git(
["git", "-C", GIT_CHECK_PATH, "rev-parse", "--is-inside-work-tree"])
["git", "-C", GIT_CHECK_PATH, "rev-parse", "--is-inside-work-tree"]
)
if inside.lower() != "true":
git_update_info["error"] = "not_git_repo"
return
@ -404,8 +408,8 @@ def _iso_from_ts(ts: Optional[float]) -> Optional[str]:
if ts is None:
return None
try:
return datetime.fromtimestamp(
float(ts), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return datetime.fromtimestamp(float(ts), tz=timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ")
except Exception:
return None
@ -540,8 +544,8 @@ def _require_prod_token(request: Request) -> None:
return
if not PROD_TOKEN:
raise HTTPException(status_code=503, detail="prod_token_not_set")
token = request.query_params.get("token") or request.query_params.get(
"access_token")
token = request.query_params.get("token"
) or request.query_params.get("access_token")
if not token:
token = _extract_token(request.headers)
if token != PROD_TOKEN:
@ -579,8 +583,8 @@ def _load_state() -> None:
state = DeviceState(**value)
except Exception:
continue
if _coords_are_zero(
state.lat, state.lon) or not _within_map_radius(state.lat, state.lon):
if _coords_are_zero(state.lat, state.lon
) or not _within_map_radius(state.lat, state.lon):
dropped_ids.add(str(key))
continue
loaded_devices[key] = state
@ -631,9 +635,12 @@ def _load_state() -> None:
raw_names = data.get("device_names") or {}
if isinstance(raw_names, dict):
device_names.clear()
device_names.update({
str(k): str(v) for k, v in raw_names.items() if str(v).strip()
})
device_names.update(
{
str(k): str(v)
for k, v in raw_names.items() if str(v).strip()
}
)
else:
device_names.clear()
if dropped_ids:
@ -642,9 +649,12 @@ def _load_state() -> None:
raw_role_sources = data.get("device_role_sources") or {}
if isinstance(raw_role_sources, dict):
device_role_sources.clear()
device_role_sources.update({
str(k): str(v) for k, v in raw_role_sources.items() if str(v).strip()
})
device_role_sources.update(
{
str(k): str(v)
for k, v in raw_role_sources.items() if str(v).strip()
}
)
else:
device_role_sources.clear()
if dropped_ids:
@ -703,12 +713,9 @@ def mqtt_on_connect(client, userdata, flags, reason_code, properties=None):
client.subscribe(topic, qos=0)
def mqtt_on_disconnect(client,
userdata,
reason_code,
properties=None,
*args,
**kwargs):
def mqtt_on_disconnect(
client, userdata, reason_code, properties=None, *args, **kwargs
):
print(f"[mqtt] disconnected reason_code={reason_code}")
@ -762,8 +769,9 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage):
role_target_id = origin_id
if device_role and result.startswith("decoded"):
role_target_id = None
loc_meta = (decoder_meta.get("location")
if isinstance(decoder_meta, dict) else None)
loc_meta = (
decoder_meta.get("location") if isinstance(decoder_meta, dict) else None
)
loc_pubkey = loc_meta.get("pubkey") if isinstance(loc_meta, dict) else None
if isinstance(loc_pubkey, str) and loc_pubkey.strip():
role_target_id = loc_pubkey
@ -788,15 +796,17 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage):
}
debug_last.append(debug_entry)
if msg.topic.endswith("/status"):
status_last.append({
"ts": debug_entry["ts"],
"topic": msg.topic,
"device_name": debug.get("device_name"),
"device_role": debug.get("device_role"),
"origin_id": origin_id,
"json_keys": debug_entry.get("json_keys"),
"payload_preview": debug_entry["payload_preview"],
})
status_last.append(
{
"ts": debug_entry["ts"],
"topic": msg.topic,
"device_name": debug.get("device_name"),
"device_role": debug.get("device_role"),
"origin_id": origin_id,
"json_keys": debug_entry.get("json_keys"),
"payload_preview": debug_entry["payload_preview"],
}
)
result_counts[result] = result_counts.get(result, 0) + 1
@ -844,8 +854,8 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage):
direction = debug.get("direction")
receiver_id = _device_id_from_topic(msg.topic)
route_origin_id = None
loc_meta = decoder_meta.get("location") if isinstance(decoder_meta,
dict) else None
loc_meta = decoder_meta.get("location"
) if isinstance(decoder_meta, dict) else None
if isinstance(loc_meta, dict):
decoded_pubkey = loc_meta.get("pubkey")
if decoded_pubkey:
@ -932,12 +942,15 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage):
)
route_emitted = True
if (not route_emitted and direction_value == "rx" and
msg.topic.endswith("/packets") and receiver_id and route_origin_id and
receiver_id != route_origin_id and
payload_type in ROUTE_PAYLOAD_TYPES_SET):
fallback_id = (message_hash or
f"{route_origin_id}-{receiver_id}-{int(time.time() * 1000)}")
if (
not route_emitted and direction_value == "rx" and
msg.topic.endswith("/packets") and receiver_id and route_origin_id and
receiver_id != route_origin_id and payload_type in ROUTE_PAYLOAD_TYPES_SET
):
fallback_id = (
message_hash or
f"{route_origin_id}-{receiver_id}-{int(time.time() * 1000)}"
)
loop.call_soon_threadsafe(
update_queue.put_nowait,
{
@ -972,10 +985,12 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage):
f"[mqtt] PARSED topic={msg.topic} device={parsed['device_id']} lat={parsed['lat']} lon={parsed['lon']}"
)
loop.call_soon_threadsafe(update_queue.put_nowait, {
"type": "device",
"data": parsed
})
loop.call_soon_threadsafe(
update_queue.put_nowait, {
"type": "device",
"data": parsed
}
)
# =========================
@ -986,8 +1001,8 @@ async def broadcaster():
event = await update_queue.get()
if isinstance(event, dict) and event.get("type") in (
"device_name",
"device_role",
"device_name",
"device_role",
):
device_id = event.get("device_id")
device_state = devices.get(device_id)
@ -1066,29 +1081,36 @@ async def broadcaster():
)
if not points and route_mode == "fanout":
points = _route_points_from_device_ids(event.get("origin_id"),
event.get("receiver_id"))
if (points and event.get("origin_id") and event.get("receiver_id") and
len(points) == 2):
points = _route_points_from_device_ids(
event.get("origin_id"), event.get("receiver_id")
)
if (
points and event.get("origin_id") and event.get("receiver_id") and
len(points) == 2
):
point_ids = [event.get("origin_id"), event.get("receiver_id")]
# Fallback: if path hashes are missing/unknown, draw a direct link when possible.
if not points:
points = _route_points_from_device_ids(event.get("origin_id"),
event.get("receiver_id"))
points = _route_points_from_device_ids(
event.get("origin_id"), event.get("receiver_id")
)
if points:
route_mode = "direct"
if (event.get("origin_id") and event.get("receiver_id") and
len(points) == 2):
if (
event.get("origin_id") and event.get("receiver_id") and
len(points) == 2
):
point_ids = [event.get("origin_id"), event.get("receiver_id")]
if not points:
continue
if MAP_RADIUS_KM > 0:
outside = any(not _within_map_radius(point[0], point[1])
for point in points
if isinstance(point, (list, tuple)) and len(point) >= 2)
outside = any(
not _within_map_radius(point[0], point[1]) for point in points
if isinstance(point, (list, tuple)) and len(point) >= 2
)
if outside:
continue
@ -1156,8 +1178,10 @@ async def broadcaster():
clients.discard(ws)
continue
upd = (event.get("data") if isinstance(event, dict) and
event.get("type") == "device" else event)
upd = (
event.get("data")
if isinstance(event, dict) and event.get("type") == "device" else event
)
device_id = upd["device_id"]
if not _within_map_radius(upd.get("lat"), upd.get("lon")):
@ -1196,11 +1220,13 @@ async def broadcaster():
if device_state.role:
device_roles[device_id] = device_state.role
if TRAIL_LEN > 0 and not _coords_are_zero(device_state.lat,
device_state.lon):
if TRAIL_LEN > 0 and not _coords_are_zero(
device_state.lat, device_state.lon
):
trails.setdefault(device_id, [])
trails[device_id].append(
[device_state.lat, device_state.lon, device_state.ts])
[device_state.lat, device_state.lon, device_state.ts]
)
if len(trails[device_id]) > TRAIL_LEN:
trails[device_id] = trails[device_id][-TRAIL_LEN:]
elif device_id in trails:
@ -1255,9 +1281,9 @@ async def reaper():
if not isinstance(points, list):
continue
if any(
_coords_are_zero(p[0], p[1])
for p in points
if isinstance(p, list) and len(p) >= 2):
_coords_are_zero(p[0], p[1])
for p in points if isinstance(p, list) and len(p) >= 2
):
bad_routes.append(route_id)
if bad_routes:
payload = {"type": "route_remove", "route_ids": bad_routes}
@ -1299,13 +1325,17 @@ async def reaper():
json.dumps({
"type": "history_edges",
"edges": history_updates
}))
})
)
if history_removed:
await ws.send_text(
json.dumps({
"type": "history_edges_remove",
"edge_ids": history_removed,
}))
json.dumps(
{
"type": "history_edges_remove",
"edge_ids": history_removed,
}
)
)
except Exception:
dead.append(ws)
for ws in dead:
@ -1324,8 +1354,9 @@ async def reaper():
_prune_neighbors(now)
prune_after = (max(DEVICE_TTL_SECONDS *
3, 900) if DEVICE_TTL_SECONDS > 0 else 86400)
prune_after = (
max(DEVICE_TTL_SECONDS * 3, 900) if DEVICE_TTL_SECONDS > 0 else 86400
)
for dev_id, last in list(seen_devices.items()):
if now - last > prune_after:
seen_devices.pop(dev_id, None)
@ -1348,8 +1379,10 @@ def root(request: Request):
# Check for lat/lon parameters for dynamic preview image
query_params = request.query_params
lat_param = query_params.get("lat") or query_params.get("latitude")
lon_param = (query_params.get("lon") or query_params.get("lng") or
query_params.get("long") or query_params.get("longitude"))
lon_param = (
query_params.get("lon") or query_params.get("lng") or
query_params.get("long") or query_params.get("longitude")
)
zoom_param = query_params.get("zoom")
og_image_tag = ""
@ -1367,13 +1400,15 @@ def root(request: Request):
# Generate preview image URL pointing to our own server
# Use absolute URL for better compatibility with Discord and other platforms
base_url = str(request.url).split("?")[0].rstrip("/")
preview_params = urlencode({
"lat": lat,
"lon": lon,
"zoom": zoom,
"marker": "blue",
"theme": "dark",
})
preview_params = urlencode(
{
"lat": lat,
"lon": lon,
"zoom": zoom,
"marker": "blue",
"theme": "dark",
}
)
preview_url = f"{base_url}/preview.png?{preview_params}"
# Ensure absolute URL (use SITE_URL if available, otherwise construct from request)
@ -1394,7 +1429,8 @@ def root(request: Request):
f'<meta property="og:image" content="{safe_image}" />\n'
f' <meta property="og:image:width" content="1200" />\n'
f' <meta property="og:image:height" content="630" />\n'
f' <meta property="og:image:type" content="image/png" />')
f' <meta property="og:image:type" content="image/png" />'
)
twitter_image_tag = f'<meta name="twitter:image" content="{safe_image}" />'
# If static image is configured, add it as a fallback
@ -1413,7 +1449,8 @@ def root(request: Request):
safe_image = html.escape(str(SITE_OG_IMAGE), quote=True)
og_image_tag = f'<meta property="og:image" content="{safe_image}" />'
twitter_image_tag = (
f'<meta name="twitter:image" content="{safe_image}" />')
f'<meta name="twitter:image" content="{safe_image}" />'
)
elif SITE_OG_IMAGE:
safe_image = html.escape(str(SITE_OG_IMAGE), quote=True)
og_image_tag = f'<meta property="og:image" content="{safe_image}" />'
@ -1498,11 +1535,11 @@ def root(request: Request):
@app.get("/preview.png")
async def preview_image(
lat: Optional[float] = Query(None, alias="lat"),
lon: Optional[float] = Query(None, alias="lon"),
zoom: Optional[int] = Query(13, alias="zoom"),
marker: Optional[str] = Query("blue", alias="marker"),
theme: Optional[str] = Query("dark", alias="theme"),
lat: Optional[float] = Query(None, alias="lat"),
lon: Optional[float] = Query(None, alias="lon"),
zoom: Optional[int] = Query(13, alias="zoom"),
marker: Optional[str] = Query("blue", alias="marker"),
theme: Optional[str] = Query("dark", alias="theme"),
):
"""
Generate a preview image of the map with a pin marker at the specified coordinates.
@ -1572,8 +1609,9 @@ async def preview_image(
start_tile_y = center_tile_y - tiles_y // 2
# Create blank image with theme-appropriate background
bg_color = ((18, 18, 18) if theme_str == "dark" else
(242, 239, 233)) # Dark or light background
bg_color = (
(18, 18, 18) if theme_str == "dark" else (242, 239, 233)
) # Dark or light background
final_image = Image.new("RGB", (width, height), bg_color)
# Fetch and composite tiles
@ -1599,10 +1637,14 @@ async def preview_image(
tile_img = Image.open(BytesIO(response.content))
# Calculate position: center the marker at the center of the image
# The center tile should place the marker at the center pixel position
x_offset = ((tx - tiles_x // 2) * tile_size + width // 2 -
center_tile_pixel_x)
y_offset = ((ty - tiles_y // 2) * tile_size + height // 2 -
center_tile_pixel_y)
x_offset = (
(tx - tiles_x // 2) * tile_size + width // 2 -
center_tile_pixel_x
)
y_offset = (
(ty - tiles_y // 2) * tile_size + height // 2 -
center_tile_pixel_y
)
final_image.paste(
tile_img,
(x_offset, y_offset),
@ -1629,8 +1671,9 @@ async def preview_image(
lat_rad = math.radians(lat_deg)
n = 2.0**zoom_level
x_px = (lon_deg + 180.0) / 360.0 * n * tile_size
y_px = ((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n *
tile_size)
y_px = (
(1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n * tile_size
)
return (x_px, y_px)
draw = ImageDraw.Draw(final_image)
@ -1644,14 +1687,16 @@ async def preview_image(
dev_lon = float(state.lon)
except Exception:
continue
if _coords_are_zero(
dev_lat, dev_lon) or not _within_map_radius(dev_lat, dev_lon):
if _coords_are_zero(dev_lat, dev_lon
) or not _within_map_radius(dev_lat, dev_lon):
continue
dev_px_x, dev_px_y = latlon_to_global_px(dev_lat, dev_lon, zoom_val)
img_x = width / 2 + (dev_px_x - center_px_x)
img_y = height / 2 + (dev_px_y - center_px_y)
if (img_x < -node_radius or img_x > width + node_radius or
img_y < -node_radius or img_y > height + node_radius):
if (
img_x < -node_radius or img_x > width + node_radius or
img_y < -node_radius or img_y > height + node_radius
):
continue
draw.ellipse(
[
@ -1674,8 +1719,9 @@ async def preview_image(
"black": (0, 0, 0),
"white": (255, 255, 255),
}
marker_color = marker_color_map.get(marker_str,
(0, 123, 255)) # Default to blue
marker_color = marker_color_map.get(
marker_str, (0, 123, 255)
) # Default to blue
# Calculate marker position (center of image)
marker_x = width // 2
@ -1748,7 +1794,8 @@ async def preview_image(
img_bytes.seek(0)
print(
f"[preview] Returning fallback image with marker (tile fetch failed)")
f"[preview] Returning fallback image with marker (tile fetch failed)"
)
return Response(
content=img_bytes.getvalue(),
media_type="image/png",
@ -1756,7 +1803,8 @@ async def preview_image(
)
except Exception as fallback_error:
print(
f"[preview] Fallback image generation also failed: {fallback_error}")
f"[preview] Fallback image generation also failed: {fallback_error}"
)
# Only redirect to static image if even fallback fails
if SITE_OG_IMAGE and SITE_OG_IMAGE.startswith("http"):
from fastapi.responses import RedirectResponse
@ -1829,13 +1877,13 @@ def snapshot(request: Request):
_require_prod_token(request)
return {
"devices": {
k: _device_payload(k, v) for k, v in devices.items()
k: _device_payload(k, v)
for k, v in devices.items()
},
"trails": trails,
"routes": [_route_payload(r) for r in routes.values()],
"history_edges": [
_history_edge_payload(e) for e in route_history_edges.values()
],
"history_edges":
[_history_edge_payload(e) for e in route_history_edges.values()],
"history_window_seconds": int(max(0, ROUTE_HISTORY_HOURS * 3600)),
"heat": _serialize_heat_events(),
"update": git_update_info,
@ -1847,13 +1895,14 @@ def snapshot(request: Request):
def get_stats():
if PROD_MODE:
return {
"stats": {
"received_total": stats.get("received_total"),
"parsed_total": stats.get("parsed_total"),
"unparsed_total": stats.get("unparsed_total"),
"last_rx_ts": stats.get("last_rx_ts"),
"last_parsed_ts": stats.get("last_parsed_ts"),
},
"stats":
{
"received_total": stats.get("received_total"),
"parsed_total": stats.get("parsed_total"),
"unparsed_total": stats.get("unparsed_total"),
"last_rx_ts": stats.get("last_rx_ts"),
"last_parsed_ts": stats.get("last_parsed_ts"),
},
"result_counts": result_counts,
"mapped_devices": len(devices),
"route_count": len(routes),
@ -1883,19 +1932,21 @@ def get_stats():
sorted(seen_devices.items(), key=lambda kv: kv[1], reverse=True)[:20],
"top_topics":
top_topics,
"decoder": {
"decode_with_node": DECODE_WITH_NODE,
"node_ready": _node_ready_once,
"node_unavailable": _node_unavailable_once,
},
"decoder":
{
"decode_with_node": DECODE_WITH_NODE,
"node_ready": _node_ready_once,
"node_unavailable": _node_unavailable_once,
},
"route_payload_types":
sorted(ROUTE_PAYLOAD_TYPES_SET),
"direct_coords": {
"mode": DIRECT_COORDS_MODE,
"topic_regex": DIRECT_COORDS_TOPIC_REGEX,
"regex_valid": DIRECT_COORDS_TOPIC_RE is not None,
"allow_zero": DIRECT_COORDS_ALLOW_ZERO,
},
"direct_coords":
{
"mode": DIRECT_COORDS_MODE,
"topic_regex": DIRECT_COORDS_TOPIC_REGEX,
"regex_valid": DIRECT_COORDS_TOPIC_RE is not None,
"allow_zero": DIRECT_COORDS_ALLOW_ZERO,
},
"server_time":
time.time(),
}
@ -1954,18 +2005,20 @@ def get_peers(device_id: str, request: Request, limit: int = 8):
if state and not _coords_are_zero(state.lat, state.lon):
payload["lat"] = float(state.lat)
payload["lon"] = float(state.lon)
payload["name"] = ((state.name if state else None) or
device_names.get(device_id) or "")
payload["name"] = (
(state.name if state else None) or device_names.get(device_id) or ""
)
payload["role"] = (state.role
if state else None) or device_roles.get(device_id)
payload["last_seen_ts"] = seen_devices.get(device_id) or (state.ts
if state else None)
payload["last_seen_ts"] = seen_devices.get(device_id
) or (state.ts if state else None)
payload["server_time"] = time.time()
return payload
def _peer_device_payload(peer_id: str, count: int, total: int,
last_ts: Optional[float]) -> Dict[str, Any]:
def _peer_device_payload(
peer_id: str, count: int, total: int, last_ts: Optional[float]
) -> Dict[str, Any]:
state = devices.get(peer_id)
name = None
role = None
@ -2034,14 +2087,14 @@ def _peer_stats_for_device(device_id: str, limit: int) -> Dict[str, Any]:
outbound_total = sum(outbound.values())
inbound_items = [
_peer_device_payload(peer_id, count, inbound_total,
inbound_last.get(peer_id))
for peer_id, count in inbound.items()
_peer_device_payload(
peer_id, count, inbound_total, inbound_last.get(peer_id)
) for peer_id, count in inbound.items()
]
outbound_items = [
_peer_device_payload(peer_id, count, outbound_total,
outbound_last.get(peer_id))
for peer_id, count in outbound.items()
_peer_device_payload(
peer_id, count, outbound_total, outbound_last.get(peer_id)
) for peer_id, count in outbound.items()
]
inbound_items.sort(key=lambda item: item.get("count", 0), reverse=True)
outbound_items.sort(key=lambda item: item.get("count", 0), reverse=True)
@ -2061,11 +2114,9 @@ def _peer_stats_for_device(device_id: str, limit: int) -> Dict[str, Any]:
@app.get("/los")
def line_of_sight(lat1: float,
lon1: float,
lat2: float,
lon2: float,
profile: bool = False):
def line_of_sight(
lat1: float, lon1: float, lat2: float, lon2: float, profile: bool = False
):
include_points = bool(profile)
start = _normalize_lat_lon(lat1, lon1)
end = _normalize_lat_lon(lat2, lon2)
@ -2091,11 +2142,13 @@ def line_of_sight(lat1: float,
if distance_m > 0:
for (lat, lon, t), elev in zip(points, elevations):
line_elev = start_elev + (end_elev - start_elev) * t
profile_samples.append([
round(distance_m * t, 2),
round(float(elev), 2),
round(float(line_elev), 2),
])
profile_samples.append(
[
round(distance_m * t, 2),
round(float(elev), 2),
round(float(line_elev), 2),
]
)
peaks = _find_los_peaks(points, elevations, distance_m)
response = {
@ -2106,11 +2159,12 @@ def line_of_sight(lat1: float,
"distance_km": round(distance_m / 1000.0, 3),
"distance_mi": round(distance_m / 1609.344, 3),
"samples": len(points),
"elevation_m": {
"start": round(start_elev, 2),
"end": round(end_elev, 2),
"max_terrain": round(max_terrain, 2),
},
"elevation_m":
{
"start": round(start_elev, 2),
"end": round(end_elev, 2),
"max_terrain": round(max_terrain, 2),
},
"provider": LOS_ELEVATION_URL,
"note": "Straight-line LOS using SRTM90m. No curvature/refraction.",
"suggested": suggestion,
@ -2118,12 +2172,13 @@ def line_of_sight(lat1: float,
"peaks": peaks,
}
if include_points:
response["profile_points"] = [[
round(lat, 6),
round(lon, 6),
round(t, 4),
round(float(elev), 2)
] for (lat, lon, t), elev in zip(points, elevations)]
response["profile_points"] = [
[round(lat, 6),
round(lon, 6),
round(t, 4),
round(float(elev), 2)]
for (lat, lon, t), elev in zip(points, elevations)
]
return response
@ -2143,8 +2198,10 @@ async def get_coverage():
response.raise_for_status()
data = response.json()
# /get-samples returns { keys: [...] }, extract the keys array
samples = (data.get("keys", []) if isinstance(data, dict) else
(data if isinstance(data, list) else []))
samples = (
data.get("keys", []) if isinstance(data, dict) else
(data if isinstance(data, list) else [])
)
print(
f"[coverage] Received {len(samples) if isinstance(samples, list) else 'non-list'} items from coverage API"
)
@ -2164,8 +2221,9 @@ async def get_coverage():
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"coverage_api_error: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500,
detail=f"coverage_fetch_error: {str(e)}")
raise HTTPException(
status_code=500, detail=f"coverage_fetch_error: {str(e)}"
)
@app.get("/debug/last")
@ -2200,20 +2258,23 @@ async def ws_endpoint(ws: WebSocket):
clients.add(ws)
await ws.send_text(
json.dumps({
"type": "snapshot",
"devices": {
k: _device_payload(k, v) for k, v in devices.items()
},
"trails": trails,
"routes": [_route_payload(r) for r in routes.values()],
"history_edges": [
_history_edge_payload(e) for e in route_history_edges.values()
],
"history_window_seconds": int(max(0, ROUTE_HISTORY_HOURS * 3600)),
"heat": _serialize_heat_events(),
"update": git_update_info,
}))
json.dumps(
{
"type": "snapshot",
"devices": {
k: _device_payload(k, v)
for k, v in devices.items()
},
"trails": trails,
"routes": [_route_payload(r) for r in routes.values()],
"history_edges":
[_history_edge_payload(e) for e in route_history_edges.values()],
"history_window_seconds": int(max(0, ROUTE_HISTORY_HOURS * 3600)),
"heat": _serialize_heat_events(),
"update": git_update_info,
}
)
)
try:
while True:

View file

@ -23,8 +23,9 @@ MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", "")
STATE_DIR = os.getenv("STATE_DIR", "/data")
STATE_FILE = os.getenv("STATE_FILE", os.path.join(STATE_DIR, "state.json"))
DEVICE_ROLES_FILE = os.getenv("DEVICE_ROLES_FILE",
os.path.join(STATE_DIR, "device_roles.json"))
DEVICE_ROLES_FILE = os.getenv(
"DEVICE_ROLES_FILE", os.path.join(STATE_DIR, "device_roles.json")
)
NEIGHBOR_OVERRIDES_FILE = os.getenv(
"NEIGHBOR_OVERRIDES_FILE",
os.path.join(STATE_DIR, "neighbor_overrides.json"),
@ -42,26 +43,34 @@ ROUTE_HISTORY_ENABLED = os.getenv("ROUTE_HISTORY_ENABLED",
"true").lower() == "true"
ROUTE_HISTORY_HOURS = float(os.getenv("ROUTE_HISTORY_HOURS", "24"))
ROUTE_HISTORY_MAX_SEGMENTS = int(
os.getenv("ROUTE_HISTORY_MAX_SEGMENTS", "40000"))
ROUTE_HISTORY_FILE = os.getenv("ROUTE_HISTORY_FILE",
os.path.join(STATE_DIR, "route_history.jsonl"))
ROUTE_HISTORY_PAYLOAD_TYPES = os.getenv("ROUTE_HISTORY_PAYLOAD_TYPES",
ROUTE_PAYLOAD_TYPES)
os.getenv("ROUTE_HISTORY_MAX_SEGMENTS", "40000")
)
ROUTE_HISTORY_FILE = os.getenv(
"ROUTE_HISTORY_FILE", os.path.join(STATE_DIR, "route_history.jsonl")
)
ROUTE_HISTORY_PAYLOAD_TYPES = os.getenv(
"ROUTE_HISTORY_PAYLOAD_TYPES", ROUTE_PAYLOAD_TYPES
)
ROUTE_HISTORY_ALLOWED_MODES = os.getenv("ROUTE_HISTORY_ALLOWED_MODES", "path")
ROUTE_HISTORY_COMPACT_INTERVAL = float(
os.getenv("ROUTE_HISTORY_COMPACT_INTERVAL", "120"))
os.getenv("ROUTE_HISTORY_COMPACT_INTERVAL", "120")
)
HISTORY_EDGE_SAMPLE_LIMIT = 3
MESSAGE_ORIGIN_TTL_SECONDS = int(os.getenv("MESSAGE_ORIGIN_TTL_SECONDS", "300"))
HEAT_TTL_SECONDS = int(os.getenv("HEAT_TTL_SECONDS", "600"))
MQTT_ONLINE_SECONDS = int(os.getenv("MQTT_ONLINE_SECONDS", "300"))
MQTT_SEEN_BROADCAST_MIN_SECONDS = float(
os.getenv("MQTT_SEEN_BROADCAST_MIN_SECONDS", "5"))
MQTT_ONLINE_TOPIC_SUFFIXES = tuple(s.strip() for s in os.getenv(
"MQTT_ONLINE_TOPIC_SUFFIXES", "/status,/internal").split(",") if s.strip())
os.getenv("MQTT_SEEN_BROADCAST_MIN_SECONDS", "5")
)
MQTT_ONLINE_TOPIC_SUFFIXES = tuple(
s.strip()
for s in os.getenv("MQTT_ONLINE_TOPIC_SUFFIXES", "/status,/internal"
).split(",") if s.strip()
)
MQTT_ONLINE_FORCE_NAMES = tuple(
s.strip()
for s in os.getenv("MQTT_ONLINE_FORCE_NAMES", "").split(",")
if s.strip())
for s in os.getenv("MQTT_ONLINE_FORCE_NAMES", "").split(",") if s.strip()
)
MQTT_ONLINE_FORCE_NAMES_SET = {s.lower() for s in MQTT_ONLINE_FORCE_NAMES}
DEBUG_PAYLOAD = os.getenv("DEBUG_PAYLOAD", "false").lower() == "true"
@ -69,18 +78,22 @@ DEBUG_PAYLOAD_MAX = int(os.getenv("DEBUG_PAYLOAD_MAX", "400"))
DECODE_WITH_NODE = os.getenv("DECODE_WITH_NODE", "true").lower() == "true"
NODE_DECODE_TIMEOUT_SECONDS = float(
os.getenv("NODE_DECODE_TIMEOUT_SECONDS", "2.0"))
os.getenv("NODE_DECODE_TIMEOUT_SECONDS", "2.0")
)
DEBUG_LAST_MAX = int(os.getenv("DEBUG_LAST_MAX", "50"))
DEBUG_STATUS_MAX = int(os.getenv("DEBUG_STATUS_MAX", "50"))
PAYLOAD_PREVIEW_MAX = int(os.getenv("PAYLOAD_PREVIEW_MAX", "800"))
DIRECT_COORDS_MODE = os.getenv("DIRECT_COORDS_MODE", "topic").strip().lower()
DIRECT_COORDS_TOPIC_REGEX = os.getenv("DIRECT_COORDS_TOPIC_REGEX",
r"(position|location|gps|coords)")
DIRECT_COORDS_ALLOW_ZERO = (os.getenv("DIRECT_COORDS_ALLOW_ZERO",
"false").lower() == "true")
DIRECT_COORDS_TOPIC_REGEX = os.getenv(
"DIRECT_COORDS_TOPIC_REGEX", r"(position|location|gps|coords)"
)
DIRECT_COORDS_ALLOW_ZERO = (
os.getenv("DIRECT_COORDS_ALLOW_ZERO", "false").lower() == "true"
)
ROUTE_HISTORY_ALLOWED_MODES_SET = {
s.strip() for s in ROUTE_HISTORY_ALLOWED_MODES.split(",") if s.strip()
s.strip()
for s in ROUTE_HISTORY_ALLOWED_MODES.split(",") if s.strip()
}
SITE_TITLE = os.getenv("SITE_TITLE", "Greater Boston Mesh Live Map")
@ -98,7 +111,8 @@ GIT_CHECK_FETCH = os.getenv("GIT_CHECK_FETCH", "false").lower() == "true"
GIT_CHECK_PATH = os.getenv("GIT_CHECK_PATH", os.getcwd()).strip()
try:
GIT_CHECK_INTERVAL_SECONDS = float(
os.getenv("GIT_CHECK_INTERVAL_SECONDS", "43200"))
os.getenv("GIT_CHECK_INTERVAL_SECONDS", "43200")
)
except ValueError:
GIT_CHECK_INTERVAL_SECONDS = 43200.0
DISTANCE_UNITS = os.getenv("DISTANCE_UNITS", "km").strip().lower()
@ -140,8 +154,9 @@ MAP_RADIUS_SHOW = os.getenv("MAP_RADIUS_SHOW", "false").lower() == "true"
PROD_MODE = os.getenv("PROD_MODE", "false").lower() == "true"
PROD_TOKEN = os.getenv("PROD_TOKEN", "").strip()
LOS_ELEVATION_URL = os.getenv("LOS_ELEVATION_URL",
"https://api.opentopodata.org/v1/srtm90m")
LOS_ELEVATION_URL = os.getenv(
"LOS_ELEVATION_URL", "https://api.opentopodata.org/v1/srtm90m"
)
LOS_SAMPLE_MIN = int(os.getenv("LOS_SAMPLE_MIN", "10"))
LOS_SAMPLE_MAX = int(os.getenv("LOS_SAMPLE_MAX", "80"))
LOS_SAMPLE_STEP_METERS = int(os.getenv("LOS_SAMPLE_STEP_METERS", "250"))

View file

@ -286,8 +286,9 @@ def _rebuild_node_hash_map() -> None:
node_hash_to_device.update(mapping)
def _choose_closest_device(node_hash: str, ref_lat: float, ref_lon: float,
ts: float) -> Optional[str]:
def _choose_closest_device(
node_hash: str, ref_lat: float, ref_lon: float, ts: float
) -> Optional[str]:
"""
If we have multiple candidates for a hash, pick the one physically closest
to (ref_lat, ref_lon). If only one, return it.
@ -304,8 +305,9 @@ def _choose_closest_device(node_hash: str, ref_lat: float, ref_lon: float,
continue
# If infrastructure-only mode is active, only allow repeaters and rooms
if ROUTE_INFRA_ONLY and (not state.role or
state.role not in ("repeater", "room")):
if ROUTE_INFRA_ONLY and (
not state.role or state.role not in ("repeater", "room")
):
continue
# skip invalid coords
@ -345,8 +347,9 @@ def _choose_device_for_hash(node_hash: str, ts: float) -> Optional[str]:
continue
# If infrastructure-only mode is active, only allow repeaters and rooms
if ROUTE_INFRA_ONLY and (not state.role or
state.role not in ("repeater", "room")):
if ROUTE_INFRA_ONLY and (
not state.role or state.role not in ("repeater", "room")
):
continue
if _coords_are_zero(state.lat, state.lon):
@ -385,8 +388,9 @@ def _choose_neighbor_device(
continue
# If infrastructure-only mode is active, only allow repeaters and rooms
if ROUTE_INFRA_ONLY and (not state.role or
state.role not in ("repeater", "room")):
if ROUTE_INFRA_ONLY and (
not state.role or state.role not in ("repeater", "room")
):
continue
if _coords_are_zero(state.lat, state.lon):
@ -428,16 +432,21 @@ def _route_points_from_hashes(
return None, [], []
receiver_hash = _node_hash_from_device_id(
receiver_id) if receiver_id else None
receiver_id
) if receiver_id else None
origin_hash = _node_hash_from_device_id(origin_id) if origin_id else None
if receiver_hash and receiver_hash in normalized:
if (normalized and normalized[0] == receiver_hash and
normalized[-1] != receiver_hash):
if (
normalized and normalized[0] == receiver_hash and
normalized[-1] != receiver_hash
):
normalized.reverse()
elif origin_hash and origin_hash in normalized:
if (normalized and normalized[-1] == origin_hash and
normalized[0] != origin_hash):
if (
normalized and normalized[-1] == origin_hash and
normalized[0] != origin_hash
):
normalized.reverse()
points: List[List[float]] = []
@ -452,8 +461,9 @@ def _route_points_from_hashes(
if origin_id:
origin_state = devices.get(origin_id)
if origin_state and not _coords_are_zero(origin_state.lat,
origin_state.lon):
if origin_state and not _coords_are_zero(
origin_state.lat, origin_state.lon
):
try:
current_lat = float(origin_state.lat)
current_lon = float(origin_state.lon)
@ -526,11 +536,13 @@ def _route_points_from_hashes(
origin_point = None
if origin_id:
origin_state = devices.get(origin_id)
if origin_state and not _coords_are_zero(origin_state.lat,
origin_state.lon):
if origin_state and not _coords_are_zero(
origin_state.lat, origin_state.lon
):
# If infrastructure-only, only add infra nodes
if ROUTE_INFRA_ONLY and (not origin_state.role or
origin_state.role not in ("repeater", "room")):
if ROUTE_INFRA_ONLY and (
not origin_state.role or origin_state.role not in ("repeater", "room")
):
pass # skip
else:
try:
@ -547,11 +559,14 @@ def _route_points_from_hashes(
receiver_point = None
if receiver_id:
receiver_state = devices.get(receiver_id)
if receiver_state and not _coords_are_zero(receiver_state.lat,
receiver_state.lon):
if receiver_state and not _coords_are_zero(
receiver_state.lat, receiver_state.lon
):
# If infrastructure-only, only add infra nodes
if ROUTE_INFRA_ONLY and (not receiver_state.role or
receiver_state.role not in ("repeater", "room")):
if ROUTE_INFRA_ONLY and (
not receiver_state.role or
receiver_state.role not in ("repeater", "room")
):
pass # skip
else:
try:
@ -574,8 +589,8 @@ def _route_points_from_hashes(
def _route_points_from_device_ids(
origin_id: Optional[str],
receiver_id: Optional[str]) -> Optional[List[List[float]]]:
origin_id: Optional[str], receiver_id: Optional[str]
) -> Optional[List[List[float]]]:
if not origin_id or not receiver_id or origin_id == receiver_id:
return None
origin_state = devices.get(origin_id)
@ -586,12 +601,16 @@ def _route_points_from_device_ids(
# If infrastructure-only mode is active, only allow repeaters and rooms
if ROUTE_INFRA_ONLY and (
(not origin_state.role or origin_state.role not in ("repeater", "room")) or
(not receiver_state.role or
receiver_state.role not in ("repeater", "room"))):
(
not receiver_state.role or
receiver_state.role not in ("repeater", "room")
)
):
return None
if _coords_are_zero(origin_state.lat, origin_state.lon) or _coords_are_zero(
receiver_state.lat, receiver_state.lon):
receiver_state.lat, receiver_state.lon
):
return None
points = [
[origin_state.lat, origin_state.lon],
@ -602,29 +621,34 @@ def _route_points_from_device_ids(
return points
def _append_heat_points(points: List[List[float]], ts: float,
payload_type: Optional[int]) -> None:
def _append_heat_points(
points: List[List[float]], ts: float, payload_type: Optional[int]
) -> None:
if HEAT_TTL_SECONDS <= 0:
return
for point in points:
heat_events.append({
"lat": float(point[0]),
"lon": float(point[1]),
"ts": float(ts),
"weight": 0.7,
})
heat_events.append(
{
"lat": float(point[0]),
"lon": float(point[1]),
"ts": float(ts),
"weight": 0.7,
}
)
def _serialize_heat_events() -> List[List[float]]:
if HEAT_TTL_SECONDS <= 0:
return []
cutoff = time.time() - HEAT_TTL_SECONDS
return [[
entry.get("lat"),
entry.get("lon"),
entry.get("ts"),
entry.get("weight", 0.7)
] for entry in heat_events if entry.get("ts", 0) >= cutoff]
return [
[
entry.get("lat"),
entry.get("lon"),
entry.get("ts"),
entry.get("weight", 0.7)
] for entry in heat_events if entry.get("ts", 0) >= cutoff
]
def _extract_device_name(obj: Any, topic: str) -> Optional[str]:
@ -632,15 +656,15 @@ def _extract_device_name(obj: Any, topic: str) -> Optional[str]:
return None
for key in (
"name",
"device_name",
"deviceName",
"node_name",
"nodeName",
"display_name",
"displayName",
"callsign",
"label",
"name",
"device_name",
"deviceName",
"node_name",
"nodeName",
"display_name",
"displayName",
"callsign",
"label",
):
value = obj.get(key)
if isinstance(value, str) and value.strip():
@ -672,17 +696,17 @@ def _extract_device_role(obj: Any, topic: str) -> Optional[str]:
return None
for key in (
"role",
"device_role",
"deviceRole",
"node_role",
"nodeRole",
"node_type",
"nodeType",
"device_type",
"deviceType",
"class",
"profile",
"role",
"device_role",
"deviceRole",
"node_role",
"nodeRole",
"node_type",
"nodeType",
"device_type",
"deviceType",
"class",
"profile",
):
value = obj.get(key)
if isinstance(value, str):
@ -693,8 +717,9 @@ def _extract_device_role(obj: Any, topic: str) -> Optional[str]:
return None
def _apply_meta_role(debug: Dict[str, Any], meta: Optional[Dict[str,
Any]]) -> None:
def _apply_meta_role(
debug: Dict[str, Any], meta: Optional[Dict[str, Any]]
) -> None:
if debug.get("device_role"):
return
if not isinstance(meta, dict):
@ -720,14 +745,14 @@ def _has_location_hints(obj: Any) -> bool:
for k, v in obj.items():
key = str(k).lower()
if key in (
"location",
"gps",
"position",
"coords",
"coordinate",
"geo",
"geolocation",
"latlon",
"location",
"gps",
"position",
"coords",
"coordinate",
"geo",
"geolocation",
"latlon",
):
return True
if isinstance(v, (dict, list)) and _has_location_hints(v):
@ -915,7 +940,7 @@ try {
def _decode_meshcore_hex(
hex_str: str,
) -> Tuple[Optional[float], Optional[float], Optional[str], Optional[str], Dict[
str, Any]]:
str, Any]]:
if not _ensure_node_decoder():
return (
None,
@ -941,10 +966,12 @@ def _decode_meshcore_hex(
out = (proc.stdout or "").strip()
if not out:
return (None, None, None, None, {
"ok": False,
"error": "empty_decoder_output"
})
return (
None, None, None, None, {
"ok": False,
"error": "empty_decoder_output"
}
)
try:
data = json.loads(out)
@ -1002,8 +1029,9 @@ def _device_id_from_topic(topic: str) -> Optional[str]:
def _find_packet_blob(
obj: Any,
path: str = "root") -> Tuple[Optional[str], Optional[str], Optional[str]]:
obj: Any,
path: str = "root"
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
if isinstance(obj, str):
if _looks_like_hex(obj):
return (obj.strip(), path, "hex")
@ -1039,8 +1067,10 @@ def _find_packet_blob(
b64hex = _try_base64_to_hex(v)
if b64hex:
return (b64hex, sub_path, "base64")
if (isinstance(v, list) and v and
all(isinstance(x, int) for x in v[:min(20, len(v))])):
if (
isinstance(v, list) and v and
all(isinstance(x, int) for x in v[:min(20, len(v))])
):
try:
raw = bytes(v)
if len(raw) >= 10:
@ -1055,13 +1085,16 @@ def _find_packet_blob(
return (None, None, None)
def _extract_device_id(obj: Any, topic: str,
decoded_pubkey: Optional[str]) -> str:
def _extract_device_id(
obj: Any, topic: str, decoded_pubkey: Optional[str]
) -> str:
if decoded_pubkey:
return str(decoded_pubkey)
if isinstance(obj, dict):
device_id = (obj.get("device_id") or obj.get("id") or obj.get("from") or
obj.get("origin_id"))
device_id = (
obj.get("device_id") or obj.get("id") or obj.get("from") or
obj.get("origin_id")
)
if device_id:
return str(device_id)
jwt = obj.get("jwt_payload")
@ -1071,8 +1104,8 @@ def _extract_device_id(obj: Any, topic: str,
def _try_parse_payload(
topic: str,
payload_bytes: bytes) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any]]:
topic: str, payload_bytes: bytes
) -> Tuple[Optional[Dict[str, Any]], Dict[str, Any]]:
debug: Dict[str, Any] = {
"result": "no_coords",
"found_path": None,
@ -1105,10 +1138,12 @@ def _try_parse_payload(
debug["device_name"] = _extract_device_name(obj, topic)
debug["device_role"] = _extract_device_role(obj, topic)
debug["direction"] = obj.get("direction")
debug["packet_hash"] = (obj.get("hash") or obj.get("message_hash") or
obj.get("messageHash"))
debug["packet_type"] = (obj.get("packet_type") or
obj.get("packetType") or obj.get("type"))
debug["packet_hash"] = (
obj.get("hash") or obj.get("message_hash") or obj.get("messageHash")
)
debug["packet_type"] = (
obj.get("packet_type") or obj.get("packetType") or obj.get("type")
)
except Exception as exc:
debug["parse_error"] = str(exc)
@ -1173,7 +1208,8 @@ def _try_parse_payload(
debug["result"] = "direct_blocked"
return (None, debug)
if not DIRECT_COORDS_ALLOW_ZERO and _coords_are_zero(
got2[0], got2[1]):
got2[0], got2[1]
):
debug["result"] = "direct_zero_coords"
return (None, debug)
device_id = _extract_device_id(obj, topic, None)
@ -1213,8 +1249,9 @@ def _try_parse_payload(
},
debug,
)
debug["result"] = ("decoded_no_location"
if meta.get("ok") else "decode_failed")
debug["result"] = (
"decoded_no_location" if meta.get("ok") else "decode_failed"
)
return (None, debug)
debug["result"] = "json_no_packet_blob"
@ -1261,8 +1298,9 @@ def _try_parse_payload(
},
debug,
)
debug["result"] = ("decoded_no_location"
if meta.get("ok") else "decode_failed")
debug["result"] = (
"decoded_no_location" if meta.get("ok") else "decode_failed"
)
return (None, debug)
b64hex = _try_base64_to_hex(text)
@ -1286,15 +1324,17 @@ def _try_parse_payload(
},
debug,
)
debug["result"] = ("decoded_no_location"
if meta.get("ok") else "decode_failed")
debug["result"] = (
"decoded_no_location" if meta.get("ok") else "decode_failed"
)
return (None, debug)
if _is_probably_binary(payload_bytes) and len(payload_bytes) >= 10:
debug["found_path"] = "payload_bytes"
debug["found_hint"] = "raw_bytes"
lat, lon, decoded_pubkey, name, meta = _decode_meshcore_hex(
payload_bytes.hex())
payload_bytes.hex()
)
debug["decoded_pubkey"] = decoded_pubkey
debug["decoder_meta"] = meta
_apply_meta_role(debug, meta)
@ -1312,7 +1352,8 @@ def _try_parse_payload(
debug,
)
debug["result"] = "decoded_no_location" if meta.get(
"ok") else "decode_failed"
"ok"
) else "decode_failed"
return (None, debug)
return (None, debug)

View file

@ -63,9 +63,8 @@ def _normalize_history_point(point: Any) -> Optional[Tuple[float, float]]:
def _history_edge_key(
a: Tuple[float, float],
b: Tuple[float,
float]) -> Tuple[str, Tuple[float, float], Tuple[float, float]]:
a: Tuple[float, float], b: Tuple[float, float]
) -> Tuple[str, Tuple[float, float], Tuple[float, float]]:
if a <= b:
key = f"{a[0]:.6f},{a[1]:.6f}|{b[0]:.6f},{b[1]:.6f}"
return key, a, b
@ -86,8 +85,9 @@ def _history_sample_from_route(route: Dict[str, Any],
}
def _update_history_edge_recent(edge: Dict[str, Any],
sample: Dict[str, Any]) -> None:
def _update_history_edge_recent(
edge: Dict[str, Any], sample: Dict[str, Any]
) -> None:
if not edge or not sample:
return
recent = edge.get("recent")
@ -101,7 +101,8 @@ def _update_history_edge_recent(edge: Dict[str, Any],
def _record_route_history(
route: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[str]]:
route: Dict[str, Any]
) -> Tuple[List[Dict[str, Any]], List[str]]:
if not ROUTE_HISTORY_ENABLED:
return [], []
if ROUTE_HISTORY_ALLOWED_MODES_SET:
@ -112,8 +113,8 @@ def _record_route_history(
if not _history_payload_allowed(payload_type):
return [], []
points = route.get("points")
point_ids = route.get("point_ids") if isinstance(route.get("point_ids"),
list) else None
point_ids = route.get("point_ids"
) if isinstance(route.get("point_ids"), list) else None
if not isinstance(points, list) or len(points) < 2:
return [], []
@ -133,19 +134,21 @@ def _record_route_history(
a_id = point_ids[idx]
b_id = point_ids[idx + 1]
key, first, second = _history_edge_key(a, b)
new_entries.append({
"ts": float(ts),
"a": [first[0], first[1]],
"b": [second[0], second[1]],
"a_id": a_id,
"b_id": b_id,
"message_hash": sample.get("message_hash"),
"payload_type": sample.get("payload_type"),
"origin_id": sample.get("origin_id"),
"receiver_id": sample.get("receiver_id"),
"route_mode": sample.get("route_mode"),
"topic": sample.get("topic"),
})
new_entries.append(
{
"ts": float(ts),
"a": [first[0], first[1]],
"b": [second[0], second[1]],
"a_id": a_id,
"b_id": b_id,
"message_hash": sample.get("message_hash"),
"payload_type": sample.get("payload_type"),
"origin_id": sample.get("origin_id"),
"receiver_id": sample.get("receiver_id"),
"route_mode": sample.get("route_mode"),
"topic": sample.get("topic"),
}
)
edge = state.route_history_edges.get(key)
if not edge:
edge = {
@ -169,12 +172,12 @@ def _record_route_history(
updates = [
state.route_history_edges[key]
for key in updated_keys
if key in state.route_history_edges
for key in updated_keys if key in state.route_history_edges
]
removed: List[str] = []
if ROUTE_HISTORY_MAX_SEGMENTS > 0 and len(
state.route_history_segments) > ROUTE_HISTORY_MAX_SEGMENTS:
state.route_history_segments
) > ROUTE_HISTORY_MAX_SEGMENTS:
extra_updates, extra_removed = _prune_route_history(force_limit=True)
updates.extend(extra_updates)
removed.extend(extra_removed)
@ -183,7 +186,8 @@ def _record_route_history(
def _prune_route_history(
force_limit: bool = False) -> Tuple[List[Dict[str, Any]], List[str]]:
force_limit: bool = False
) -> Tuple[List[Dict[str, Any]], List[str]]:
if not ROUTE_HISTORY_ENABLED or not state.route_history_segments:
return [], []
@ -204,7 +208,8 @@ def _prune_route_history(
if not force_limit and ts >= cutoff:
break
if force_limit and ROUTE_HISTORY_MAX_SEGMENTS > 0 and len(
state.route_history_segments) <= ROUTE_HISTORY_MAX_SEGMENTS:
state.route_history_segments
) <= ROUTE_HISTORY_MAX_SEGMENTS:
break
state.route_history_segments.popleft()
a = entry.get("a")
@ -291,19 +296,21 @@ def _load_route_history() -> None:
"topic": entry.get("topic"),
}
key, first, second = _history_edge_key(a_point, b_point)
state.route_history_segments.append({
"ts": float(ts),
"a": [first[0], first[1]],
"b": [second[0], second[1]],
"a_id": entry.get("a_id"),
"b_id": entry.get("b_id"),
"message_hash": sample.get("message_hash"),
"payload_type": sample.get("payload_type"),
"origin_id": sample.get("origin_id"),
"receiver_id": sample.get("receiver_id"),
"route_mode": sample.get("route_mode"),
"topic": sample.get("topic"),
})
state.route_history_segments.append(
{
"ts": float(ts),
"a": [first[0], first[1]],
"b": [second[0], second[1]],
"a_id": entry.get("a_id"),
"b_id": entry.get("b_id"),
"message_hash": sample.get("message_hash"),
"payload_type": sample.get("payload_type"),
"origin_id": sample.get("origin_id"),
"receiver_id": sample.get("receiver_id"),
"route_mode": sample.get("route_mode"),
"topic": sample.get("topic"),
}
)
edge = state.route_history_edges.get(key)
if not edge:
edge = {
@ -326,7 +333,8 @@ def _load_route_history() -> None:
return
if ROUTE_HISTORY_MAX_SEGMENTS > 0 and len(
state.route_history_segments) > ROUTE_HISTORY_MAX_SEGMENTS:
state.route_history_segments
) > ROUTE_HISTORY_MAX_SEGMENTS:
_prune_route_history(force_limit=True)
state.route_history_compact = True

View file

@ -22,8 +22,8 @@ def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(
dphi / 2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2)**2
a = math.sin(dphi / 2
)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2)**2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return radius * c
@ -105,9 +105,10 @@ def _sample_los_points(lat1: float, lon1: float, lat2: float,
return points
def _los_max_obstruction(points: List[Tuple[float, float,
float]], elevations: List[float],
start_idx: int, end_idx: int) -> float:
def _los_max_obstruction(
points: List[Tuple[float, float, float]], elevations: List[float],
start_idx: int, end_idx: int
) -> float:
if end_idx <= start_idx + 1:
return 0.0
start_t = points[start_idx][2]
@ -126,8 +127,9 @@ def _los_max_obstruction(points: List[Tuple[float, float,
return max_obstruction
def _find_los_suggestion(points: List[Tuple[float, float, float]],
elevations: List[float]) -> Optional[Dict[str, Any]]:
def _find_los_suggestion(
points: List[Tuple[float, float, float]], elevations: List[float]
) -> Optional[Dict[str, Any]]:
if len(points) < 3:
return None
best_idx = None
@ -189,18 +191,21 @@ def _find_los_peaks(
except ValueError:
return []
peak_indices = sorted(peak_indices, key=lambda i: elevations[i],
reverse=True)[:LOS_PEAKS_MAX]
peak_indices = sorted(
peak_indices, key=lambda i: elevations[i], reverse=True
)[:LOS_PEAKS_MAX]
peak_indices = sorted(peak_indices, key=lambda i: points[i][2])
peaks = []
for i, idx in enumerate(peak_indices, start=1):
t = points[idx][2]
peaks.append({
"index": i,
"lat": round(points[idx][0], 6),
"lon": round(points[idx][1], 6),
"elevation_m": round(float(elevations[idx]), 2),
"distance_m": round(distance_m * t, 2),
})
peaks.append(
{
"index": i,
"lat": round(points[idx][0], 6),
"lon": round(points[idx][1], 6),
"elevation_m": round(float(elevations[idx]), 2),
"distance_m": round(distance_m * t, 2),
}
)
return peaks