Major app overhaul: MSFS data import, lookup-only Find tab, OSRM nav, cleanup
This commit is contained in:
@@ -21,7 +21,12 @@ from contextlib import asynccontextmanager
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytesseract
|
||||
try:
|
||||
import pytesseract
|
||||
_HAS_TESSERACT = True
|
||||
except ImportError:
|
||||
pytesseract = None
|
||||
_HAS_TESSERACT = False
|
||||
import piexif
|
||||
from PIL import Image as PILImage
|
||||
|
||||
@@ -1123,13 +1128,23 @@ def get_asset(asset_id: int):
|
||||
# ─── Navigation endpoint ────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/assets/{asset_id}/navigation")
|
||||
def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(...)):
|
||||
"""Return distance, bearing, and coordinates for navigation from GPS to asset."""
|
||||
def get_navigation(
|
||||
asset_id: int,
|
||||
lat: float = Query(...),
|
||||
lng: float = Query(...),
|
||||
mode: str = Query("driving", pattern="^(driving|walking)$"),
|
||||
):
|
||||
"""Return navigation info from GPS to asset using OSRM (falls back to Haversine).
|
||||
|
||||
Accepts optional mode: 'driving' (default) or 'walking'.
|
||||
On success returns route_coords, route_duration_s, walking_directions, route_mode
|
||||
plus the standard distance/bearing fields.
|
||||
"""
|
||||
import math
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT id, name, latitude, longitude, machine_id, address FROM assets WHERE id = ?",
|
||||
"SELECT id, name, latitude, longitude, machine_id, address, walking_directions FROM assets WHERE id = ?",
|
||||
(asset_id,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
@@ -1145,7 +1160,47 @@ def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(..
|
||||
|
||||
dest_lat, dest_lng = row["latitude"], row["longitude"]
|
||||
|
||||
# Haversine distance
|
||||
# ── Try OSRM first ──────────────────────────────────────────────────
|
||||
osrm_profile = "driving" if mode == "driving" else "foot"
|
||||
route_coords = None
|
||||
route_duration_s = None
|
||||
route_distance_m = None
|
||||
walking_directions = None
|
||||
|
||||
try:
|
||||
url = (
|
||||
f"https://router.project-osrm.org/route/v1/{osrm_profile}/{lng},{lat};{dest_lng},{dest_lat}"
|
||||
"?overview=full&geometries=geojson&steps=true&alternatives=false"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "CanteenAssetTracker/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = _json.loads(resp.read().decode())
|
||||
|
||||
if data.get("code") == "Ok" and data.get("routes"):
|
||||
route = data["routes"][0]
|
||||
coords_raw = route.get("geometry", {}).get("coordinates", [])
|
||||
route_coords = [{"lat": c[1], "lng": c[0]} for c in coords_raw]
|
||||
route_duration_s = round(route.get("duration", 0))
|
||||
route_distance_m = round(route.get("distance", 0))
|
||||
|
||||
# Build step-by-step walking directions (only for walking mode)
|
||||
walking_directions = None
|
||||
if mode == "walking":
|
||||
steps = []
|
||||
for leg in route.get("legs", []):
|
||||
for step in leg.get("steps", []):
|
||||
steps.append({
|
||||
"instruction": step.get("maneuver", {}).get("instruction", step.get("name", "")),
|
||||
"distance_m": round(step.get("distance", 0)),
|
||||
"duration_s": round(step.get("duration", 0)),
|
||||
})
|
||||
if steps:
|
||||
walking_directions = steps
|
||||
except Exception:
|
||||
# OSRM failed — fall through to Haversine below
|
||||
pass
|
||||
|
||||
# ── Haversine distance (always computed for fallback / reference) ────
|
||||
R = 6371000 # Earth radius in meters
|
||||
phi1 = math.radians(lat)
|
||||
phi2 = math.radians(dest_lat)
|
||||
@@ -1154,7 +1209,7 @@ def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(..
|
||||
|
||||
a_val = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
c_val = 2 * math.atan2(math.sqrt(a_val), math.sqrt(1 - a_val))
|
||||
distance_m = R * c_val
|
||||
haversine_distance_m = R * c_val
|
||||
|
||||
# Bearing
|
||||
y = math.sin(dlambda) * math.cos(phi2)
|
||||
@@ -1166,7 +1221,7 @@ def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(..
|
||||
idx = round(bearing / 45) % 8
|
||||
cardinal = dirs[idx]
|
||||
|
||||
return {
|
||||
result = {
|
||||
"asset_id": asset_id,
|
||||
"asset_name": row["name"],
|
||||
"asset_lat": dest_lat,
|
||||
@@ -1174,10 +1229,11 @@ def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(..
|
||||
"asset_address": row["address"] or None,
|
||||
"origin_lat": lat,
|
||||
"origin_lng": lng,
|
||||
"distance_meters": round(distance_m, 1),
|
||||
"distance_km": round(distance_m / 1000, 2),
|
||||
"distance_meters": round(route_distance_m if route_distance_m else haversine_distance_m, 1),
|
||||
"distance_km": round((route_distance_m if route_distance_m else haversine_distance_m) / 1000, 2),
|
||||
"bearing": round(bearing, 1),
|
||||
"cardinal": cardinal,
|
||||
"route_mode": mode,
|
||||
"google_maps_url": (
|
||||
f"https://www.google.com/maps/dir/?api=1"
|
||||
f"&origin={lat},{lng}"
|
||||
@@ -1185,6 +1241,21 @@ def get_navigation(asset_id: int, lat: float = Query(...), lng: float = Query(..
|
||||
),
|
||||
}
|
||||
|
||||
if route_coords:
|
||||
result["route_coords"] = route_coords
|
||||
result["route_duration_s"] = route_duration_s
|
||||
result["route_distance_m"] = route_distance_m
|
||||
result["route_mode"] = mode
|
||||
result["osrm_source"] = "osrm"
|
||||
|
||||
if mode == "walking" and walking_directions:
|
||||
result["walking_directions"] = walking_directions
|
||||
else:
|
||||
result["osrm_source"] = "haversine"
|
||||
result["walking_directions"] = row["walking_directions"] or None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ─── Task 6: PUT / DELETE /api/assets/{id} ─────────────────────────────────
|
||||
|
||||
@@ -1468,11 +1539,6 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
class GeofencePointCheck(BaseModel):
|
||||
lat: float
|
||||
lng: float
|
||||
|
||||
|
||||
# ─── Phase C: Auth API ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1554,26 +1620,7 @@ def logout(request: Request):
|
||||
|
||||
|
||||
|
||||
# ─── Phase 0: Proximity & Geofence Check ─────────────────────────────────────
|
||||
|
||||
|
||||
def _point_in_polygon(lat: float, lng: float, polygon: list) -> bool:
|
||||
"""Ray-casting algorithm for point-in-polygon test."""
|
||||
inside = False
|
||||
n = len(polygon)
|
||||
if n < 3:
|
||||
return False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
yi = polygon[i]["lat"] if isinstance(polygon[i], dict) else polygon[i][0]
|
||||
xi = polygon[i]["lng"] if isinstance(polygon[i], dict) else polygon[i][1]
|
||||
yj = polygon[j]["lat"] if isinstance(polygon[j], dict) else polygon[j][0]
|
||||
xj = polygon[j]["lng"] if isinstance(polygon[j], dict) else polygon[j][1]
|
||||
|
||||
if ((yi > lat) != (yj > lat)) and (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
# ─── Phase 0: Proximity Check ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/proximity")
|
||||
@@ -1608,24 +1655,6 @@ def proximity_check(
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/api/geofences/check")
|
||||
def check_geofence_point(body: GeofencePointCheck):
|
||||
"""
|
||||
Check if a GPS point falls inside any geofence polygon.
|
||||
Returns list of matching geofences.
|
||||
"""
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall()
|
||||
conn.close()
|
||||
|
||||
matches = []
|
||||
for row in rows:
|
||||
points = _json.loads(row["points"])
|
||||
if _point_in_polygon(body.lat, body.lng, points):
|
||||
matches.append(row_to_dict(row))
|
||||
return matches
|
||||
|
||||
|
||||
# ─── Phase C: Visits API & Auto-visit Logging ────────────────────────────────
|
||||
|
||||
|
||||
@@ -2050,11 +2079,26 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
||||
img = PILImage.open(ocr_path)
|
||||
# Preprocess: convert to grayscale and increase contrast for better OCR
|
||||
img_gray = img.convert("L")
|
||||
if not _HAS_TESSERACT:
|
||||
raise RuntimeError(
|
||||
"Tesseract OCR library (pytesseract) is not installed. "
|
||||
"Run: pip install pytesseract && apt-get install -y tesseract-ocr"
|
||||
)
|
||||
text = pytesseract.image_to_string(img_gray, config="--psm 6")
|
||||
except (RuntimeError, ImportError) as e:
|
||||
if not saved_path:
|
||||
ocr_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"OCR service unavailable: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
if not saved_path:
|
||||
ocr_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"OCR processing error: {str(e)}. Tesseract binary may not be installed system-wide. Run: apt-get install -y tesseract-ocr",
|
||||
)
|
||||
|
||||
# Clean up temp file (but keep permanent photos)
|
||||
if not saved_path:
|
||||
|
||||
Reference in New Issue
Block a user