391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""Vehicle Map static server and server-side proxy for the vehicle open platform."""
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import hashlib
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
from socketserver import ThreadingMixIn
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode, urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
def _static_asset_version():
|
|
"""Content fingerprint used to bypass upstream static-asset caches on release."""
|
|
digest = hashlib.sha256()
|
|
for filename in ("app.js", "styles.css"):
|
|
digest.update((ROOT / filename).read_bytes())
|
|
return digest.hexdigest()[:16]
|
|
|
|
|
|
STATIC_ASSET_VERSION = _static_asset_version()
|
|
HOST = os.getenv("VEHICLE_MAP_HOST", "0.0.0.0")
|
|
PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800"))
|
|
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
|
|
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
|
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
|
|
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "120"))
|
|
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "120"))
|
|
AMAP_REGEOCODE_KEY = os.getenv("AMAP_REGEOCODE_KEY", "").strip()
|
|
STATION_GEOCODE_CACHE_SECONDS = int(os.getenv("STATION_GEOCODE_CACHE_SECONDS", "604800"))
|
|
STATION_GEOCODE_CACHE_PATH = Path(os.getenv(
|
|
"STATION_GEOCODE_CACHE_PATH", str(ROOT / ".station-geocode-cache.json")
|
|
))
|
|
_cache_lock = threading.Lock()
|
|
_cache = {}
|
|
_cache_load_locks = {}
|
|
_station_geocode_cache_lock = threading.Lock()
|
|
_station_geocode_cache = None
|
|
_station_geocode_cache_dirty = False
|
|
|
|
|
|
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
allow_reuse_address = True
|
|
|
|
|
|
def _json_bytes(value):
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
|
|
|
|
def _post_open_platform(path, body):
|
|
if not OPEN_PLATFORM_APP_KEY:
|
|
raise RuntimeError("OPEN_PLATFORM_APP_KEY is not configured")
|
|
request = Request(
|
|
OPEN_PLATFORM_BASE_URL + path,
|
|
data=_json_bytes(body),
|
|
method="POST",
|
|
headers={
|
|
"Authorization": "Bearer " + OPEN_PLATFORM_APP_KEY,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
"User-Agent": "lingniu-vehicle-map/1.0",
|
|
},
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
|
payload = json.load(response)
|
|
except HTTPError as exc:
|
|
detail = exc.read(2048).decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"open platform returned HTTP {exc.code}: {detail}") from exc
|
|
except URLError as exc:
|
|
raise RuntimeError(f"open platform unavailable: {exc.reason}") from exc
|
|
if payload.get("code") != "SUCCESS":
|
|
raise RuntimeError(f"open platform rejected request: {payload.get('code')} {payload.get('message')}")
|
|
return payload.get("data") or []
|
|
|
|
|
|
def _cached(key, ttl_seconds, loader):
|
|
now = time.time()
|
|
with _cache_lock:
|
|
cached = _cache.get(key)
|
|
if cached and now - cached[0] < ttl_seconds:
|
|
return cached[1]
|
|
load_lock = _cache_load_locks.setdefault(key, threading.Lock())
|
|
# Startup prewarming and the first browser request can arrive together.
|
|
# Coalesce them so a cold geocode cache is filled only once.
|
|
with load_lock:
|
|
now = time.time()
|
|
with _cache_lock:
|
|
cached = _cache.get(key)
|
|
if cached and now - cached[0] < ttl_seconds:
|
|
return cached[1]
|
|
value = loader()
|
|
with _cache_lock:
|
|
_cache[key] = (now, value)
|
|
return value
|
|
|
|
|
|
def _station_coordinate_key(station):
|
|
try:
|
|
return "{:.6f},{:.6f}".format(float(station.get("longitude")), float(station.get("latitude")))
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
|
|
|
|
def _component_value(value):
|
|
if isinstance(value, list):
|
|
return str(value[0]).strip() if value else ""
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _load_station_geocode_cache():
|
|
global _station_geocode_cache
|
|
with _station_geocode_cache_lock:
|
|
if _station_geocode_cache is not None:
|
|
return _station_geocode_cache
|
|
try:
|
|
with STATION_GEOCODE_CACHE_PATH.open("r", encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
_station_geocode_cache = payload if isinstance(payload, dict) else {}
|
|
except (OSError, ValueError, TypeError):
|
|
_station_geocode_cache = {}
|
|
return _station_geocode_cache
|
|
|
|
|
|
def _persist_station_geocode_cache():
|
|
try:
|
|
STATION_GEOCODE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = STATION_GEOCODE_CACHE_PATH.with_suffix(STATION_GEOCODE_CACHE_PATH.suffix + ".tmp")
|
|
with temporary.open("w", encoding="utf-8") as handle:
|
|
json.dump(_station_geocode_cache, handle, ensure_ascii=False, separators=(",", ":"))
|
|
os.replace(str(temporary), str(STATION_GEOCODE_CACHE_PATH))
|
|
except OSError as exc:
|
|
print("station geocode cache write deferred: {}".format(exc))
|
|
|
|
|
|
def _reverse_geocode_station(longitude, latitude):
|
|
if not AMAP_REGEOCODE_KEY:
|
|
raise RuntimeError("AMAP_REGEOCODE_KEY is not configured")
|
|
query = urlencode({
|
|
"key": AMAP_REGEOCODE_KEY,
|
|
"location": "{:.6f},{:.6f}".format(longitude, latitude),
|
|
"extensions": "base",
|
|
"radius": "1000",
|
|
"batch": "false",
|
|
})
|
|
request = Request(
|
|
"https://restapi.amap.com/v3/geocode/regeo?" + query,
|
|
headers={"Accept": "application/json", "User-Agent": "lingniu-station-geocoder/1.0"},
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=UPSTREAM_TIMEOUT_SECONDS) as response:
|
|
payload = json.load(response)
|
|
except (HTTPError, URLError, ValueError) as exc:
|
|
raise RuntimeError("AMap reverse geocode unavailable: {}".format(exc)) from exc
|
|
if payload.get("status") != "1":
|
|
raise RuntimeError("AMap reverse geocode rejected request: {} {}".format(payload.get("infocode"), payload.get("info")))
|
|
component = (payload.get("regeocode") or {}).get("addressComponent") or {}
|
|
province = _component_value(component.get("province"))
|
|
city = _component_value(component.get("city")) or province
|
|
district = _component_value(component.get("district"))
|
|
if not province:
|
|
raise RuntimeError("AMap reverse geocode returned no province")
|
|
return {"province": province, "city": city, "district": district, "adcode": _component_value(component.get("adcode"))}
|
|
|
|
|
|
def _station_region_from_gps(station, now=None):
|
|
global _station_geocode_cache_dirty
|
|
coordinate_key = _station_coordinate_key(station)
|
|
if not coordinate_key:
|
|
return {"province": "", "city": "", "district": ""}
|
|
now = time.time() if now is None else now
|
|
cache = _load_station_geocode_cache()
|
|
cached = cache.get(coordinate_key) or {}
|
|
if now - float(cached.get("updatedAt") or 0) < STATION_GEOCODE_CACHE_SECONDS:
|
|
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
|
try:
|
|
longitude, latitude = (float(value) for value in coordinate_key.split(","))
|
|
region = _reverse_geocode_station(longitude, latitude)
|
|
except Exception as exc:
|
|
print("station reverse geocode deferred for {}: {}".format(coordinate_key, exc))
|
|
return cached.get("region") or {"province": "", "city": "", "district": ""}
|
|
with _station_geocode_cache_lock:
|
|
cache[coordinate_key] = {"updatedAt": now, "region": region}
|
|
_station_geocode_cache_dirty = True
|
|
return region
|
|
|
|
|
|
def _stations_with_gps_regions(stations):
|
|
global _station_geocode_cache_dirty
|
|
def enrich(station):
|
|
item = dict(station)
|
|
region = _station_region_from_gps(item)
|
|
# Never fall back to data-table administrative fields. The raw address
|
|
# remains untouched and is the only directly displayed location text.
|
|
item.update({key: region.get(key, "") for key in ("province", "city", "district")})
|
|
return item
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
enriched = list(executor.map(enrich, stations))
|
|
with _station_geocode_cache_lock:
|
|
if _station_geocode_cache_dirty:
|
|
_persist_station_geocode_cache()
|
|
_station_geocode_cache_dirty = False
|
|
return enriched
|
|
|
|
|
|
def _load_stations():
|
|
return _cached(
|
|
"stations",
|
|
STATION_CACHE_SECONDS,
|
|
lambda: _stations_with_gps_regions(_post_open_platform("/api/v1/hydrogen-stations/query", {})),
|
|
)
|
|
|
|
|
|
def _public_station_directory():
|
|
stations = _load_stations()
|
|
fields = ("id", "name", "shortName", "province", "city", "district", "address", "longitude", "latitude", "cooperative")
|
|
directory = [{field: station.get(field) for field in fields if station.get(field) is not None} for station in stations]
|
|
return {
|
|
"status": "ok",
|
|
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
|
"summary": {
|
|
"totalStations": len(directory),
|
|
"cooperativeStations": sum(1 for station in directory if station.get("cooperative")),
|
|
},
|
|
"stations": directory,
|
|
}
|
|
|
|
|
|
def _load_dashboard():
|
|
today = time.strftime("%Y-%m-%d", time.localtime())
|
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
realtime_future = executor.submit(
|
|
_post_open_platform, "/api/v1/vehicles/realtime/query", {}
|
|
)
|
|
mileage_future = executor.submit(
|
|
_post_open_platform, "/api/v1/vehicles/mileage/query", {"date": today}
|
|
)
|
|
stations_future = executor.submit(_load_stations)
|
|
vehicles = realtime_future.result()
|
|
mileage_rows = mileage_future.result()
|
|
stations = stations_future.result()
|
|
|
|
mileage_by_vin = {
|
|
row.get("vin"): row
|
|
for row in mileage_rows
|
|
if row.get("vin") and row.get("status") == "NORMAL"
|
|
}
|
|
# The daily activity view places every vehicle that reported
|
|
# today is placed in exactly one bucket using its latest selected speed.
|
|
active_vehicles = [item for item in vehicles if item.get("activeToday")]
|
|
driving = sum(1 for item in active_vehicles if float(item.get("speedKmh") or 0) > 3)
|
|
idle = len(active_vehicles) - driving
|
|
offline = sum(1 for item in vehicles if item.get("motionStatus") == "offline")
|
|
active_today = len(active_vehicles)
|
|
daily_mileage = round(
|
|
sum(float(row.get("dailyMileageKm") or 0) for row in mileage_rows), 3
|
|
)
|
|
monthly_hydrogen = round(
|
|
sum(float(station.get("monthlyHydrogenKg") or 0) for station in stations), 3
|
|
)
|
|
for vehicle in vehicles:
|
|
mileage = mileage_by_vin.get(vehicle.get("vin"), {})
|
|
vehicle["dailyMileageKm"] = mileage.get("dailyMileageKm", 0)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"asOf": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
|
"date": today,
|
|
"summary": {
|
|
"totalVehicles": len(vehicles),
|
|
"onlineVehicles": active_today,
|
|
"drivingVehicles": driving,
|
|
"idleVehicles": idle,
|
|
"offlineVehicles": offline,
|
|
"todayMileageKm": daily_mileage,
|
|
"monthlyHydrogenKg": monthly_hydrogen,
|
|
"totalStations": len(stations),
|
|
"cooperativeStations": sum(1 for item in stations if item.get("cooperative")),
|
|
},
|
|
"vehicles": vehicles,
|
|
"stations": stations,
|
|
}
|
|
|
|
|
|
def _prewarm_dashboard_cache():
|
|
"""Warm the first dashboard snapshot after a process restart.
|
|
|
|
A warm snapshot lets the first visitor receive the cached response instead
|
|
of waiting for the three upstream requests to complete.
|
|
"""
|
|
try:
|
|
_cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
|
except Exception as exc: # A later request can retry; startup must stay available.
|
|
print(f"vehicle map cache warmup deferred: {exc}")
|
|
|
|
|
|
class VehicleMapHandler(SimpleHTTPRequestHandler):
|
|
server_version = "LingniuVehicleMap/1.0"
|
|
|
|
def do_GET(self):
|
|
path = urlparse(self.path).path
|
|
if path in {"/", "/index.html"}:
|
|
self._serve_index()
|
|
return
|
|
if path == "/api/health":
|
|
self._write_json(200, {
|
|
"status": "ok",
|
|
"service": "vehicle-map",
|
|
"openPlatformConfigured": bool(OPEN_PLATFORM_APP_KEY),
|
|
})
|
|
return
|
|
if path == "/api/dashboard":
|
|
try:
|
|
payload = _cached("dashboard", DASHBOARD_CACHE_SECONDS, _load_dashboard)
|
|
self._write_json(200, payload)
|
|
except Exception as exc: # keep upstream details server-side only
|
|
self.log_error("dashboard refresh failed: %s", exc)
|
|
self._write_json(502, {
|
|
"status": "error",
|
|
"code": "UPSTREAM_UNAVAILABLE",
|
|
"message": "地图数据暂时不可用,请稍后重试",
|
|
})
|
|
return
|
|
if path == "/api/stations":
|
|
try:
|
|
self._write_json(200, _public_station_directory())
|
|
except Exception as exc:
|
|
self.log_error("station directory refresh failed: %s", exc)
|
|
self._write_json(502, {
|
|
"status": "error",
|
|
"code": "UPSTREAM_UNAVAILABLE",
|
|
"message": "加氢站数据暂时不可用,请稍后重试",
|
|
})
|
|
return
|
|
super().do_GET()
|
|
|
|
def _serve_index(self):
|
|
try:
|
|
template = (ROOT / "index.html").read_text(encoding="utf-8")
|
|
body = template.replace("__ASSET_VERSION__", STATIC_ASSET_VERSION).encode("utf-8")
|
|
except OSError as exc:
|
|
self.log_error("index template unavailable: %s", exc)
|
|
self.send_error(500, "Map application is temporarily unavailable")
|
|
return
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def end_headers(self):
|
|
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
|
super().end_headers()
|
|
|
|
def _write_json(self, status, payload, headers=None):
|
|
body = _json_bytes(payload)
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
for name, value in (headers or {}).items():
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
server = ThreadingHTTPServer((HOST, PORT), VehicleMapHandler)
|
|
threading.Thread(target=_prewarm_dashboard_cache, daemon=True).start()
|
|
print(f"Lingniu Vehicle Map listening on http://{HOST}:{PORT}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|