diff --git a/backend/app.py b/backend/app.py index 8d8f237..e31f21e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6,13 +6,24 @@ import time import subprocess from datetime import datetime, timezone from dataclasses import asdict -from typing import Any, Dict, Optional, Set, List +from typing import Any, Dict, Optional, Set, List, Tuple import httpx import paho.mqtt.client as mqtt -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi import ( + FastAPI, + WebSocket, + WebSocketDisconnect, + Request, + HTTPException, + Query, +) +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles +from urllib.parse import urlencode +from io import BytesIO +from PIL import Image, ImageDraw +import math import state from decoder import ( @@ -626,12 +637,14 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): if now - last_sent >= MQTT_SEEN_BROADCAST_MIN_SECONDS: last_seen_broadcast[dev_guess] = now loop.call_soon_threadsafe( - update_queue.put_nowait, { + update_queue.put_nowait, + { "type": "device_seen", "device_id": dev_guess, "last_seen_ts": now, "mqtt_seen_ts": now, - }) + }, + ) parsed, debug = _try_parse_payload(msg.topic, msg.payload) device_id_hint = parsed.get("device_id") if parsed else None @@ -642,11 +655,14 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): debug["result"] = "filtered_radius" parsed = None if device_id_hint: - loop.call_soon_threadsafe(update_queue.put_nowait, { - "type": "device_remove", - "device_id": device_id_hint, - "reason": "radius", - }) + loop.call_soon_threadsafe( + update_queue.put_nowait, + { + "type": "device_remove", + "device_id": device_id_hint, + "reason": "radius", + }, + ) origin_id = debug.get("origin_id") or _device_id_from_topic(msg.topic) decoder_meta = debug.get("decoder_meta") or {} result = debug.get("result") or "unknown" @@ -654,8 +670,8 @@ 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 @@ -702,10 +718,13 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): if device_state: device_state.name = device_name loop: asyncio.AbstractEventLoop = userdata["loop"] - loop.call_soon_threadsafe(update_queue.put_nowait, { - "type": "device_name", - "device_id": origin_id, - }) + loop.call_soon_threadsafe( + update_queue.put_nowait, + { + "type": "device_name", + "device_id": origin_id, + }, + ) if device_role and role_target_id: existing_role = device_roles.get(role_target_id) if existing_role != device_role: @@ -716,10 +735,13 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): if device_state: device_state.role = device_role loop: asyncio.AbstractEventLoop = userdata["loop"] - loop.call_soon_threadsafe(update_queue.put_nowait, { - "type": "device_role", - "device_id": role_target_id, - }) + loop.call_soon_threadsafe( + update_queue.put_nowait, + { + "type": "device_role", + "device_id": role_target_id, + }, + ) path_hashes = decoder_meta.get("pathHashes") payload_type = decoder_meta.get("payloadType") @@ -744,7 +766,7 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): "origin_id": None, "first_rx": None, "receivers": set(), - "ts": time.time() + "ts": time.time(), } message_origins[message_hash] = cache cache["ts"] = time.time() @@ -784,7 +806,8 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): route_emitted = False if route_hashes and payload_type in ROUTE_PAYLOAD_TYPES_SET: loop.call_soon_threadsafe( - update_queue.put_nowait, { + update_queue.put_nowait, + { "type": "route", "path_hashes": route_hashes, "payload_type": payload_type, @@ -795,12 +818,14 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): "route_type": route_type, "ts": time.time(), "topic": msg.topic, - }) + }, + ) route_emitted = True elif message_hash and route_origin_id and receiver_id: if direction_value == "rx" and msg.topic.endswith("/packets"): loop.call_soon_threadsafe( - update_queue.put_nowait, { + update_queue.put_nowait, + { "type": "route", "route_mode": "fanout", "route_id": f"{message_hash}-{receiver_id}", @@ -811,16 +836,19 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): "payload_type": payload_type, "ts": time.time(), "topic": msg.topic, - }) + }, + ) 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)}" + fallback_id = (message_hash or + f"{route_origin_id}-{receiver_id}-{int(time.time() * 1000)}") loop.call_soon_threadsafe( - update_queue.put_nowait, { + update_queue.put_nowait, + { "type": "route", "route_mode": "direct", "route_id": f"direct-{fallback_id}", @@ -831,7 +859,8 @@ def mqtt_on_message(client, userdata, msg: mqtt.MQTTMessage): "payload_type": payload_type, "ts": time.time(), "topic": msg.topic, - }) + }, + ) if not parsed: stats["unparsed_total"] += 1 @@ -864,8 +893,10 @@ async def broadcaster(): while True: event = await update_queue.get() - if isinstance(event, - dict) and event.get("type") in ("device_name", "device_role"): + if isinstance(event, dict) and event.get("type") in ( + "device_name", + "device_role", + ): device_id = event.get("device_id") device_state = devices.get(device_id) if device_state: @@ -876,7 +907,7 @@ async def broadcaster(): payload = { "type": "update", "device": _device_payload(device_id, device_state), - "trail": trails.get(device_id, []) + "trail": trails.get(device_id, []), } dead = [] for ws in list(clients): @@ -945,8 +976,8 @@ 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: + 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. @@ -955,8 +986,8 @@ async def broadcaster(): 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: @@ -969,9 +1000,10 @@ async def broadcaster(): if outside: continue - route_id = event.get("route_id") or event.get( - "message_hash" - ) or f"{event.get('origin_id', 'route')}-{int(event.get('ts', time.time()) * 1000)}" + route_id = ( + event.get("route_id") or event.get("message_hash") or + f"{event.get('origin_id', 'route')}-{int(event.get('ts', time.time()) * 1000)}" + ) expires_at = (event.get("ts") or time.time()) + ROUTE_TTL_SECONDS route = { "id": route_id, @@ -1012,7 +1044,7 @@ async def broadcaster(): if history_removed: history_payload_remove = { "type": "history_edges_remove", - "edge_ids": history_removed + "edge_ids": history_removed, } else: history_payload_remove = None @@ -1029,8 +1061,8 @@ 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")): @@ -1082,7 +1114,7 @@ async def broadcaster(): payload = { "type": "update", "device": _device_payload(device_id, device_state), - "trail": trails.get(device_id, []) + "trail": trails.get(device_id, []), } dead = [] @@ -1177,7 +1209,7 @@ async def reaper(): await ws.send_text( json.dumps({ "type": "history_edges_remove", - "edge_ids": history_removed + "edge_ids": history_removed, })) except Exception: dead.append(ws) @@ -1195,8 +1227,8 @@ async def reaper(): if now - info.get("ts", 0) > MESSAGE_ORIGIN_TTL_SECONDS: message_origins.pop(msg_hash, None) - 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) @@ -1208,7 +1240,7 @@ async def reaper(): # FastAPI routes # ========================= @app.get("/") -def root(): +def root(request: Request): html_path = os.path.join(APP_DIR, "static", "index.html") try: with open(html_path, "r", encoding="utf-8") as handle: @@ -1216,9 +1248,76 @@ def root(): except Exception: return FileResponse("static/index.html") + # 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")) + zoom_param = query_params.get("zoom") + og_image_tag = "" twitter_image_tag = "" - if SITE_OG_IMAGE: + og_url = SITE_URL + + # Generate dynamic preview image if coordinates are provided + if lat_param and lon_param: + try: + lat = float(lat_param) + lon = float(lon_param) + zoom = int(zoom_param) if zoom_param and zoom_param.isdigit() else 13 + zoom = max(1, min(18, zoom)) # Clamp zoom between 1-18 + + # 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_url = f"{base_url}/preview.png?{preview_params}" + + # Ensure absolute URL (use SITE_URL if available, otherwise construct from request) + if SITE_URL and SITE_URL.startswith("http"): + site_base = SITE_URL.rstrip("/") + preview_url = f"{site_base}/preview.png?{preview_params}" + elif not preview_url.startswith("http"): + # Fallback: construct from request + scheme = request.url.scheme + host = request.headers.get("host", request.url.hostname or "localhost") + preview_url = f"{scheme}://{host}/preview.png?{preview_params}" + + safe_image = html.escape(preview_url, quote=True) + # Add image dimensions for better Discord/social media compatibility + # Note: Preview image may fail if container can't reach external services + # In that case, fall back to static SITE_OG_IMAGE if available + og_image_tag = ( + f'\n' + f' \n' + f' \n' + f' ') + twitter_image_tag = f'' + + # If static image is configured, add it as a fallback + if SITE_OG_IMAGE: + safe_static_image = html.escape(str(SITE_OG_IMAGE), quote=True) + og_image_tag += f'\n ' + + # Update og:url to include query parameters + base_url = str(request.url).split("?")[0] + og_url = f"{base_url}?lat={lat}&lon={lon}" + if zoom_param: + og_url += f"&zoom={zoom}" + except (ValueError, TypeError): + # Invalid coordinates, fall back to static image + if SITE_OG_IMAGE: + safe_image = html.escape(str(SITE_OG_IMAGE), quote=True) + og_image_tag = f'' + twitter_image_tag = ( + f'') + elif SITE_OG_IMAGE: safe_image = html.escape(str(SITE_OG_IMAGE), quote=True) og_image_tag = f'' twitter_image_tag = f'' @@ -1230,13 +1329,16 @@ def root(): if TRAIL_LEN > 0: trail_info_suffix = f" Trails show last ~{TRAIL_LEN} points." + # Escape og_url for HTML + SAFE_OG_URL = html.escape(str(og_url), quote=True) + replacements = { "SITE_TITLE": SITE_TITLE, "SITE_DESCRIPTION": SITE_DESCRIPTION, "SITE_URL": - SITE_URL, + SAFE_OG_URL, "SITE_ICON": SITE_ICON, "SITE_FEED_NOTE": @@ -1297,21 +1399,311 @@ def root(): return HTMLResponse(content) +@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"), +): + """ + Generate a preview image of the map with a pin marker at the specified coordinates. + Returns a PNG image suitable for Open Graph/Twitter card previews. + + Marker options: + - red-pin, blue-pin, green-pin, yellow-pin, orange-pin, purple-pin, black-pin, white-pin + - red, blue, green, yellow, orange, purple, black, white (simple circle markers) + - Custom format: color-pin or color (e.g., "blue-pin", "green") + """ + if lat is None or lon is None: + # Return a default/error image if coordinates not provided + return Response(content=b"", status_code=400, media_type="image/png") + + try: + zoom_val = max(1, min(18, int(zoom) if zoom else 14)) + + # Image dimensions for social media previews (Open Graph standard) + width = 1200 + height = 630 + + # Validate and sanitize marker option + marker_str = str(marker).lower().strip() if marker else "blue" + if not marker_str or marker_str == "none": + marker_str = "blue" + + # Validate theme option (light or dark) + theme_str = str(theme).lower().strip() if theme else "dark" + if theme_str not in ("light", "dark"): + theme_str = "dark" + + # Generate map image server-side using OSM tiles + try: + # Convert lat/lon to tile coordinates + def deg2num(lat_deg, lon_deg, zoom_level): + lat_rad = math.radians(lat_deg) + n = 2.0**zoom_level + xtile = int((lon_deg + 180.0) / 360.0 * n) + ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) + return (xtile, ytile) + + def num2deg(xtile, ytile, zoom_level): + n = 2.0**zoom_level + lon_deg = xtile / n * 360.0 - 180.0 + lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n))) + lat_deg = math.degrees(lat_rad) + return (lat_deg, lon_deg) + + # Calculate which tiles we need + center_tile_x, center_tile_y = deg2num(lat, lon, zoom_val) + tile_size = 256 + tiles_x = math.ceil(width / tile_size) + 2 + tiles_y = math.ceil(height / tile_size) + 2 + + # Calculate pixel position of center point within its tile + # Get the northwest corner of the center tile + nw_lat, nw_lon = num2deg(center_tile_x, center_tile_y, zoom_val) + # Get the southeast corner of the center tile + se_lat, se_lon = num2deg(center_tile_x + 1, center_tile_y + 1, zoom_val) + + # Calculate pixel offset within the center tile + center_tile_pixel_x = int((lon - nw_lon) / (se_lon - nw_lon) * tile_size) + center_tile_pixel_y = int((nw_lat - lat) / (nw_lat - se_lat) * tile_size) + + # Calculate which tiles to fetch + start_tile_x = center_tile_x - tiles_x // 2 + 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 + final_image = Image.new("RGB", (width, height), bg_color) + + # Fetch and composite tiles + tiles_fetched = 0 + tiles_failed = 0 + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + for ty in range(tiles_y): + for tx in range(tiles_x): + tile_x = start_tile_x + tx + tile_y = start_tile_y + ty + + # Use theme-appropriate tile server + if theme_str == "dark": + # CartoDB Dark Matter tiles + tile_url = f"https://a.basemaps.cartocdn.com/dark_all/{zoom_val}/{tile_x}/{tile_y}.png" + else: + # Standard OSM light tiles + tile_url = f"https://tile.openstreetmap.org/{zoom_val}/{tile_x}/{tile_y}.png" + + try: + response = await client.get(tile_url) + if response.status_code == 200: + 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) + final_image.paste( + tile_img, + (x_offset, y_offset), + tile_img if tile_img.mode == "RGBA" else None, + ) + tiles_fetched += 1 + else: + tiles_failed += 1 + print( + f"[preview] Tile {tile_x}/{tile_y} returned status {response.status_code}" + ) + except Exception as tile_error: + tiles_failed += 1 + print( + f"[preview] Failed to fetch tile {tile_x}/{tile_y} from {tile_url}: {tile_error}" + ) + continue + + print(f"[preview] Fetched {tiles_fetched} tiles, {tiles_failed} failed") + + # Draw current devices (all in-bounds, no cap) + def latlon_to_global_px(lat_deg: float, lon_deg: float, + zoom_level: int) -> Tuple[float, float]: + 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) + return (x_px, y_px) + + draw = ImageDraw.Draw(final_image) + center_px_x, center_px_y = latlon_to_global_px(lat, lon, zoom_val) + node_radius = 5 + node_color = (86, 198, 255) if theme_str == "dark" else (25, 83, 170) + node_outline = (15, 15, 15) if theme_str == "dark" else (255, 255, 255) + for state in list(devices.values()): + try: + dev_lat = float(state.lat) + 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): + 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): + continue + draw.ellipse( + [ + (img_x - node_radius, img_y - node_radius), + (img_x + node_radius, img_y + node_radius), + ], + fill=node_color, + outline=node_outline, + width=2, + ) + + # Draw marker + marker_color_map = { + "red": (220, 53, 69), + "blue": (0, 123, 255), + "green": (40, 167, 69), + "yellow": (255, 193, 7), + "orange": (255, 152, 0), + "purple": (108, 117, 125), + "black": (0, 0, 0), + "white": (255, 255, 255), + } + marker_color = marker_color_map.get(marker_str, + (0, 123, 255)) # Default to blue + + # Calculate marker position (center of image) + marker_x = width // 2 + marker_y = height // 2 + + # Draw a circle marker + marker_radius = 12 + draw.ellipse( + [ + (marker_x - marker_radius, marker_y - marker_radius), + (marker_x + marker_radius, marker_y + marker_radius), + ], + fill=marker_color, + outline=(255, 255, 255), + width=2, + ) + + # Convert to PNG bytes + img_bytes = BytesIO() + final_image.save(img_bytes, format="PNG") + img_bytes.seek(0) + + return Response( + content=img_bytes.getvalue(), + media_type="image/png", + headers={ + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + }, + ) + + except Exception as e: + print(f"[preview] Error generating map image: {e}") + import traceback + + traceback.print_exc() + + # Even if tile fetching fails, try to return a simple map with marker + try: + bg_color = (18, 18, 18) if theme_str == "dark" else (242, 239, 233) + fallback_image = Image.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(fallback_image) + + # Draw marker + marker_color_map = { + "red": (220, 53, 69), + "blue": (0, 123, 255), + "green": (40, 167, 69), + "yellow": (255, 193, 7), + "orange": (255, 152, 0), + "purple": (108, 117, 125), + "black": (0, 0, 0), + "white": (255, 255, 255), + } + marker_color = marker_color_map.get(marker_str, (0, 123, 255)) + marker_x = width // 2 + marker_y = height // 2 + marker_radius = 12 + draw.ellipse( + [ + (marker_x - marker_radius, marker_y - marker_radius), + (marker_x + marker_radius, marker_y + marker_radius), + ], + fill=marker_color, + outline=(255, 255, 255), + width=2, + ) + + img_bytes = BytesIO() + fallback_image.save(img_bytes, format="PNG") + img_bytes.seek(0) + + print( + f"[preview] Returning fallback image with marker (tile fetch failed)") + return Response( + content=img_bytes.getvalue(), + media_type="image/png", + headers={"Cache-Control": "public, max-age=300"}, + ) + except Exception as fallback_error: + print( + 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 + + print( + f"[preview] All image generation failed, redirecting to static OG image: {SITE_OG_IMAGE}" + ) + return RedirectResponse(url=SITE_OG_IMAGE, status_code=302) + + # Return transparent PNG as last resort + transparent_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82" + return Response( + content=transparent_png, + media_type="image/png", + headers={"Cache-Control": "public, max-age=300"}, + ) + except Exception as e: + # Log error for debugging + print(f"[preview] Error generating preview image: {e}") + import traceback + + traceback.print_exc() + # Return empty image on error + return Response(content=b"", status_code=500, media_type="image/png") + + @app.get("/manifest.webmanifest") def manifest(): icons = [] if SITE_ICON: - icons = [{ - "src": SITE_ICON, - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, { - "src": SITE_ICON, - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - }] + icons = [ + { + "src": SITE_ICON, + "sizes": "192x192", + "type": "image/png", + "purpose": "any", + }, + { + "src": SITE_ICON, + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable", + }, + ] short_name = SITE_TITLE if len(SITE_TITLE) <= 12 else SITE_TITLE[:12] return JSONResponse( { @@ -1324,7 +1716,7 @@ def manifest(): "display_override": ["standalone", "minimal-ui"], "background_color": "#0f172a", "theme_color": "#0f172a", - "icons": icons + "icons": icons, }, media_type="application/manifest+json", ) @@ -1413,10 +1805,12 @@ def get_stats(): @app.get("/api/nodes") -def api_nodes(request: Request, - updated_since: Optional[str] = None, - mode: Optional[str] = None, - format: Optional[str] = None): +def api_nodes( + request: Request, + updated_since: Optional[str] = None, + mode: Optional[str] = None, + format: Optional[str] = None, +): _require_prod_token(request) cutoff = _parse_updated_since(updated_since) mode_value = (mode or "").strip().lower() @@ -1463,8 +1857,8 @@ 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 @@ -1642,7 +2036,7 @@ async def get_coverage(): raise HTTPException( status_code=503, detail= - "coverage_api_not_configured: Set COVERAGE_API_URL in .env (e.g., http://localhost:3000)" + "coverage_api_not_configured: Set COVERAGE_API_URL in .env (e.g., http://localhost:3000)", ) try: url = f"{COVERAGE_API_URL}/get-samples" @@ -1652,8 +2046,8 @@ 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" ) @@ -1668,7 +2062,7 @@ async def get_coverage(): raise HTTPException( status_code=502, detail= - f"coverage_api_error: HTTP {e.response.status_code} from {COVERAGE_API_URL}" + f"coverage_api_error: HTTP {e.response.status_code} from {COVERAGE_API_URL}", ) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"coverage_api_error: {str(e)}") @@ -1752,7 +2146,7 @@ async def startup(): topics_str = ", ".join(MQTT_TOPICS) print( - f"[mqtt] connecting host={MQTT_HOST} port={MQTT_PORT} tls={MQTT_TLS} transport={transport} ws_path={MQTT_WS_PATH if transport=='websockets' else '-'} topics={topics_str}" + f"[mqtt] connecting host={MQTT_HOST} port={MQTT_PORT} tls={MQTT_TLS} transport={transport} ws_path={MQTT_WS_PATH if transport == 'websockets' else '-'} topics={topics_str}" ) mqtt_client = mqtt.Client( diff --git a/backend/requirements.txt b/backend/requirements.txt index 07fe508..d58cd0a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 paho-mqtt==2.1.0 -httpx==0.27.2 \ No newline at end of file +httpx==0.27.2 +Pillow==10.4.0 \ No newline at end of file