mirror of
https://github.com/yellowcooln/meshcore-mqtt-live-map.git
synced 2026-04-20 23:23:36 +00:00
Merge pull request #7 from chrisdavis2110/embed-preview
Feature: Preview URL + Render live devices in preview image
This commit is contained in:
commit
e36fffec94
2 changed files with 345 additions and 7 deletions
349
backend/app.py
349
backend/app.py
|
|
@ -6,13 +6,17 @@ 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 (
|
||||
|
|
@ -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,9 +1132,69 @@ 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'<meta property="og:image" content="{safe_image}" />\n'
|
||||
f' <meta property="og:image:width" content="1200" />\n'
|
||||
f' <meta property="og:image:height" content="630" />\n'
|
||||
f' <meta property="og:image:type" content="image/png" />'
|
||||
)
|
||||
twitter_image_tag = f'<meta name="twitter:image" content="{safe_image}" />'
|
||||
|
||||
# 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 <meta property="og:image:secure_url" content="{safe_static_image}" />'
|
||||
|
||||
# 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'<meta property="og:image" content="{safe_image}" />'
|
||||
twitter_image_tag = f'<meta name="twitter:image" content="{safe_image}" />'
|
||||
elif SITE_OG_IMAGE:
|
||||
safe_image = html.escape(str(SITE_OG_IMAGE), quote=True)
|
||||
og_image_tag = f'<meta property="og:image" content="{safe_image}" />'
|
||||
twitter_image_tag = f'<meta name="twitter:image" content="{safe_image}" />'
|
||||
|
|
@ -1142,10 +1206,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 +1246,276 @@ 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():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
paho-mqtt==2.1.0
|
||||
httpx==0.27.2
|
||||
httpx==0.27.2
|
||||
Pillow==10.4.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue