From 9d7a846c7abc4d9758dbfe3c83ddcc3ac68da05e Mon Sep 17 00:00:00 2001 From: chrisdavis2110 Date: Mon, 12 Jan 2026 20:28:42 -0800 Subject: [PATCH 1/4] create preview.png for embed --- backend/app.py | 299 +++++++++++++++++++++++++++++++++++++-- backend/requirements.txt | 3 +- 2 files changed, 293 insertions(+), 9 deletions(-) diff --git a/backend/app.py b/backend/app.py index 441cbb5..6e8a0d7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,9 +10,13 @@ from typing import Any, Dict, Optional, Set, List 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 ( @@ -1120,7 +1124,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: @@ -1128,12 +1132,56 @@ 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: - safe_image = html.escape(str(SITE_OG_IMAGE), quote=True) - og_image_tag = f'' - twitter_image_tag = f'' + 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] + 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 ' content = content.replace("{{OG_IMAGE_TAG}}", og_image_tag) content = content.replace("{{TWITTER_IMAGE_TAG}}", twitter_image_tag) @@ -1142,10 +1190,13 @@ 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, + "SITE_URL": SAFE_OG_URL, "SITE_ICON": SITE_ICON, "SITE_FEED_NOTE": SITE_FEED_NOTE, "CUSTOM_LINK_URL": CUSTOM_LINK_URL, @@ -1179,6 +1230,238 @@ 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 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 = ImageDraw.Draw(final_image) + # 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(): 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 From 4a2cf6b8aa69792ff01a23dec1f00c8e5982dfb0 Mon Sep 17 00:00:00 2001 From: chrisdavis2110 Date: Mon, 12 Jan 2026 20:42:15 -0800 Subject: [PATCH 2/4] fixed try except error --- backend/app.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/app.py b/backend/app.py index 6e8a0d7..15bb542 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1183,6 +1183,22 @@ def root(request: Request): 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'' + content = content.replace("{{OG_IMAGE_TAG}}", og_image_tag) content = content.replace("{{TWITTER_IMAGE_TAG}}", twitter_image_tag) From 896ee149dab3970bf46883c4e70f26454f3fda68 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Tue, 13 Jan 2026 14:47:58 +0000 Subject: [PATCH 3/4] Fix OG preview URL double-slash --- backend/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app.py b/backend/app.py index 15bb542..0fdfa14 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1152,7 +1152,7 @@ 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] + 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}" From bc58dff73f05ddc8a9ca24d66104c8d2358944e0 Mon Sep 17 00:00:00 2001 From: yellowcooln Date: Tue, 13 Jan 2026 15:52:28 +0000 Subject: [PATCH 4/4] Render devices in preview image --- backend/app.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/app.py b/backend/app.py index 0fdfa14..dd3dc32 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6,7 +6,7 @@ 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 @@ -1366,6 +1366,45 @@ async def preview_image( 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), @@ -1383,7 +1422,6 @@ async def preview_image( marker_x = width // 2 marker_y = height // 2 - draw = ImageDraw.Draw(final_image) # Draw a circle marker marker_radius = 12 draw.ellipse(