Initial resurrection kit — 81 scripts, 62 references, configs, systemd units, crons, Docker compose files, Caddy config, master README
This commit is contained in:
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export FT360/Traccar device status to /var/www/ops/data for dashboard use.
|
||||
|
||||
Uses the same backend logic as the FT360 MCP so dashboard semantics match Hermes tools:
|
||||
- last_contact_time: device contacted Traccar
|
||||
- gps_fix_time: actual GPS fix time
|
||||
- is_location_fresh: GPS fix age <= 10 minutes
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import site
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Cron runs scripts with system Python. The FT360 MCP uses the shared
|
||||
# ops-portal venv for FastMCP, so add that venv's site-packages before
|
||||
# importing the MCP server module.
|
||||
site.addsitedir("/opt/ops-portal/venv/lib/python3.13/site-packages")
|
||||
|
||||
SERVER_PATH = Path("/root/docker/ft360-mcp/server.py")
|
||||
OUTPUT = Path("/var/www/ops/data/ft360-devices.json")
|
||||
GEOCODE_CACHE = Path("/var/www/ops/data/ft360-geocode-cache.json")
|
||||
GEOCODE_TTL_SECONDS = 7 * 24 * 3600
|
||||
|
||||
|
||||
def _load_geocode_cache() -> dict:
|
||||
try:
|
||||
return json.loads(GEOCODE_CACHE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_geocode_cache(cache: dict) -> None:
|
||||
GEOCODE_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = GEOCODE_CACHE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(cache, indent=2, sort_keys=True))
|
||||
tmp.replace(GEOCODE_CACHE)
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lon: float, cache: dict) -> dict:
|
||||
"""Reverse geocode lat/lon to city and country code using Nominatim.
|
||||
|
||||
Cache by ~100m cells to avoid hammering OSM from the 1-minute exporter.
|
||||
"""
|
||||
key = f"{lat:.3f},{lon:.3f}"
|
||||
now = time.time()
|
||||
cached = cache.get(key)
|
||||
if cached and now - cached.get("ts", 0) < GEOCODE_TTL_SECONDS:
|
||||
return cached.get("data", {})
|
||||
|
||||
params = urllib.parse.urlencode({
|
||||
"format": "jsonv2",
|
||||
"lat": f"{lat:.7f}",
|
||||
"lon": f"{lon:.7f}",
|
||||
"zoom": "10",
|
||||
"addressdetails": "1",
|
||||
})
|
||||
req = urllib.request.Request(
|
||||
f"https://nominatim.openstreetmap.org/reverse?{params}",
|
||||
headers={"User-Agent": "ITPP-FT360-Dashboard/1.0 (info@itpropartner.com)"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
payload = json.loads(raw)
|
||||
addr = payload.get("address", {}) or {}
|
||||
city = (
|
||||
addr.get("city") or addr.get("town") or addr.get("village") or
|
||||
addr.get("municipality") or addr.get("county") or addr.get("state") or ""
|
||||
)
|
||||
data = {
|
||||
"city": city,
|
||||
"country": addr.get("country", ""),
|
||||
"country_code": (addr.get("country_code", "") or "").upper(),
|
||||
"state": addr.get("state", ""),
|
||||
"display_name": payload.get("display_name", ""),
|
||||
}
|
||||
except Exception as exc:
|
||||
data = {"city": "", "country": "", "country_code": "", "state": "", "display_name": "", "geocode_error": str(exc)}
|
||||
|
||||
cache[key] = {"ts": now, "data": data}
|
||||
return data
|
||||
|
||||
|
||||
# ─── Saved locations for ETA computation ─────────────────────────────────────
|
||||
# Edit this dict to add/remove locations. Each entry must have name, icon,
|
||||
# lat, lon. OSRM walking ETAs are computed automatically by _compute_etas().
|
||||
SAVED_LOCATIONS = [
|
||||
{"name": "Airbnb", "icon": "🏠", "lat": 10.3981, "lon": -75.5534},
|
||||
{"name": "Old City", "icon": "🏛️", "lat": 10.4236, "lon": -75.5503},
|
||||
{"name": "Getsemaní", "icon": "🎨", "lat": 10.4203, "lon": -75.5467},
|
||||
{"name": "Bocagrande", "icon": "🏖️", "lat": 10.4053, "lon": -75.5578},
|
||||
]
|
||||
|
||||
|
||||
def _compute_etas(from_lat: float, from_lon: float) -> list[dict]:
|
||||
"""Compute walking distance and ETA to each saved location using OSRM."""
|
||||
results = []
|
||||
for loc in SAVED_LOCATIONS:
|
||||
# Straight-line distance as fallback
|
||||
from math import radians, sin, cos, sqrt, atan2
|
||||
R = 3958.8
|
||||
dlat = radians(loc["lat"] - from_lat)
|
||||
dlon = radians(loc["lon"] - from_lon)
|
||||
a = sin(dlat/2)**2 + cos(radians(from_lat)) * cos(radians(loc["lat"])) * sin(dlon/2)**2
|
||||
straight_mi = 2 * R * atan2(sqrt(a), sqrt(1-a))
|
||||
|
||||
# Try OSRM for actual road/path distance
|
||||
try:
|
||||
url = (
|
||||
f"https://router.project-osrm.org/route/v1/foot/"
|
||||
f"{from_lon},{from_lat};{loc['lon']},{loc['lat']}"
|
||||
f"?overview=false"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ITPP-FT360/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
route = json.loads(resp.read().decode())
|
||||
if route.get("code") == "Ok" and route.get("routes"):
|
||||
leg = route["routes"][0]["legs"][0]
|
||||
meters = leg["distance"]
|
||||
seconds = leg["duration"]
|
||||
miles = round(meters * 0.000621371, 1)
|
||||
minutes = max(1, round(seconds / 60))
|
||||
results.append({
|
||||
"name": loc["name"],
|
||||
"icon": loc["icon"],
|
||||
"time": f"{minutes} min",
|
||||
"time_minutes": minutes,
|
||||
"distance_mi": miles,
|
||||
"distance_str": f"{miles} mi",
|
||||
"source": "OSRM walking",
|
||||
})
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: straight-line estimate (3 mph walking)
|
||||
minutes_fallback = max(1, round((straight_mi / 3.0) * 60))
|
||||
results.append({
|
||||
"name": loc["name"],
|
||||
"icon": loc["icon"],
|
||||
"time": f"{minutes_fallback} min",
|
||||
"time_minutes": minutes_fallback,
|
||||
"distance_mi": round(straight_mi, 1),
|
||||
"distance_str": f"{straight_mi:.1f} mi",
|
||||
"source": "straight-line estimate",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
spec = importlib.util.spec_from_file_location("ft360_mcp_server", SERVER_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load {SERVER_PATH}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
devices = mod._get_devices_raw() # noqa: SLF001 - intentional shared backend
|
||||
cache = _load_geocode_cache()
|
||||
for device in devices:
|
||||
lat = device.get("latitude")
|
||||
lon = device.get("longitude")
|
||||
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
|
||||
geo = _reverse_geocode(float(lat), float(lon), cache)
|
||||
device["city"] = geo.get("city", "")
|
||||
device["country"] = geo.get("country", "")
|
||||
device["country_code"] = geo.get("country_code", "")
|
||||
device["state"] = geo.get("state", "")
|
||||
device["display_name"] = geo.get("display_name", "")
|
||||
else:
|
||||
device["city"] = ""
|
||||
device["country"] = ""
|
||||
device["country_code"] = ""
|
||||
device["state"] = ""
|
||||
device["display_name"] = ""
|
||||
_save_geocode_cache(cache)
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(devices),
|
||||
"devices": devices,
|
||||
}
|
||||
# Try to append daily stats from the companion script
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run(
|
||||
["python3", "/root/.hermes/scripts/ft360-daily-stats.py"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
if r.returncode == 0:
|
||||
payload["daily_stats"] = json.loads(r.stdout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compute ETAs from current device position to saved locations via OSRM (free, no API key)
|
||||
for device in devices:
|
||||
lat = device.get("latitude")
|
||||
lon = device.get("longitude")
|
||||
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
|
||||
payload["etas"] = _compute_etas(float(lat), float(lon))
|
||||
break
|
||||
else:
|
||||
payload["etas"] = []
|
||||
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = OUTPUT.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, default=str))
|
||||
tmp.replace(OUTPUT)
|
||||
print(json.dumps({"ok": True, "output": str(OUTPUT), "count": len(devices)}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user