From ab2d3fefcece57d716b0e1ff38b557fc9e91d5ca Mon Sep 17 00:00:00 2001 From: Jiri Gabrys Date: Wed, 14 Jan 2026 11:41:10 +0100 Subject: [PATCH] improved formatting and AGENTS.md --- AGENTS.md | 3 + backend/.style.yapf | 3 +- backend/app.py | 431 +++++++++++++++++++++++++------------------- backend/config.py | 59 +++--- backend/decoder.py | 241 +++++++++++++++---------- backend/history.py | 88 +++++---- backend/los.py | 37 ++-- 7 files changed, 498 insertions(+), 364 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a143bd7..9b1cbab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/backend/.style.yapf b/backend/.style.yapf index ad1cef8..1c085ea 100644 --- a/backend/.style.yapf +++ b/backend/.style.yapf @@ -1,5 +1,6 @@ [style] based_on_style = facebook indent_width = 2 -column_limit = 200 +column_limit = 80 continuation_indent_width = 2 + diff --git a/backend/app.py b/backend/app.py index 0c4b675..8fa74da 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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'\n' f' \n' f' \n' - f' ') + f' ' + ) twitter_image_tag = f'' # 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'' twitter_image_tag = ( - f'') + f'' + ) elif SITE_OG_IMAGE: safe_image = html.escape(str(SITE_OG_IMAGE), quote=True) og_image_tag = f'' @@ -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: diff --git a/backend/config.py b/backend/config.py index 40b95ef..2dd2d36 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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")) diff --git a/backend/decoder.py b/backend/decoder.py index ea28c0b..71bbe7d 100644 --- a/backend/decoder.py +++ b/backend/decoder.py @@ -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) diff --git a/backend/history.py b/backend/history.py index bbbd052..33b710f 100644 --- a/backend/history.py +++ b/backend/history.py @@ -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 diff --git a/backend/los.py b/backend/los.py index 6d5b3a7..54db278 100644 --- a/backend/los.py +++ b/backend/los.py @@ -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