feat: KKS P240/P241 蓝牙工牌管理系统初始提交
FastAPI + SQLAlchemy + asyncio TCP 服务器,支持设备管理、实时定位、 告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
This commit is contained in:
308
app/geocoding.py
Normal file
308
app/geocoding.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
|
||||
and reverse geocode coordinates to addresses.
|
||||
|
||||
All services use 高德 (Amap) API exclusively.
|
||||
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
|
||||
- Reverse geocoding (coords → address): 高德逆地理编码
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings as _settings
|
||||
|
||||
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||||
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
||||
|
||||
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WGS-84 → GCJ-02 coordinate conversion (server-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_A = 6378245.0
|
||||
_EE = 0.00669342162296594
|
||||
|
||||
|
||||
def _out_of_china(lat: float, lon: float) -> bool:
|
||||
return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
|
||||
|
||||
|
||||
def _transform_lat(x: float, y: float) -> float:
|
||||
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
|
||||
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
|
||||
def _transform_lon(x: float, y: float) -> float:
|
||||
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
|
||||
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||||
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
|
||||
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
|
||||
|
||||
def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
|
||||
"""Convert WGS-84 to GCJ-02 (used by 高德)."""
|
||||
if _out_of_china(lat, lon):
|
||||
return (lat, lon)
|
||||
d_lat = _transform_lat(lon - 105.0, lat - 35.0)
|
||||
d_lon = _transform_lon(lon - 105.0, lat - 35.0)
|
||||
rad_lat = lat / 180.0 * math.pi
|
||||
magic = math.sin(rad_lat)
|
||||
magic = 1 - _EE * magic * magic
|
||||
sqrt_magic = math.sqrt(magic)
|
||||
d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi)
|
||||
d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi)
|
||||
return (lat + d_lat, lon + d_lon)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LRU Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LRUCache(OrderedDict):
|
||||
"""Simple LRU cache based on OrderedDict."""
|
||||
|
||||
def __init__(self, maxsize: int = _CACHE_MAX_SIZE):
|
||||
super().__init__()
|
||||
self._maxsize = maxsize
|
||||
|
||||
def get_cached(self, key):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
return self[key]
|
||||
return None
|
||||
|
||||
def put(self, key, value):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
self[key] = value
|
||||
while len(self) > self._maxsize:
|
||||
self.popitem(last=False)
|
||||
|
||||
|
||||
_cell_cache: LRUCache = LRUCache()
|
||||
_wifi_cache: LRUCache = LRUCache()
|
||||
_address_cache: LRUCache = LRUCache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 高德数字签名 (AMAP_SECRET)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _amap_sign(params: dict) -> str:
|
||||
"""Generate 高德 API digital signature (MD5)."""
|
||||
if not AMAP_SECRET:
|
||||
return ""
|
||||
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
raw = sorted_str + AMAP_SECRET
|
||||
return hashlib.md5(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Forward Geocoding: cell/WiFi → lat/lon
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def geocode_location(
|
||||
mcc: Optional[int] = None,
|
||||
mnc: Optional[int] = None,
|
||||
lac: Optional[int] = None,
|
||||
cell_id: Optional[int] = None,
|
||||
wifi_list: Optional[list[dict]] = None,
|
||||
neighbor_cells: Optional[list[dict]] = None,
|
||||
imei: Optional[str] = None,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Convert cell tower and/or WiFi AP data to lat/lon.
|
||||
|
||||
Uses 高德智能硬件定位 API exclusively.
|
||||
"""
|
||||
# Check cache first
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
cache_key = (mcc, mnc or 0, lac, cell_id)
|
||||
cached = _cell_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if AMAP_KEY:
|
||||
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
|
||||
if result[0] is not None:
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
|
||||
return result
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_amap(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Use 高德智能硬件定位 API (apilocate.amap.com/position).
|
||||
|
||||
Returns coordinates (高德 returns GCJ-02).
|
||||
"""
|
||||
# Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
|
||||
bts = ""
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
|
||||
|
||||
# Build nearbts (neighbor cells)
|
||||
nearbts_parts = []
|
||||
if neighbor_cells:
|
||||
for nc in neighbor_cells:
|
||||
nc_lac = nc.get("lac", 0)
|
||||
nc_cid = nc.get("cell_id", 0)
|
||||
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
|
||||
nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}")
|
||||
|
||||
# Build macs (WiFi APs): mac,signal,ssid
|
||||
macs_parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
mac = ap.get("mac", "").lower().replace(":", "")
|
||||
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
|
||||
ssid = ap.get("ssid", "")
|
||||
macs_parts.append(f"{mac},{signal},{ssid}")
|
||||
|
||||
if not bts and not macs_parts:
|
||||
return (None, None)
|
||||
|
||||
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
|
||||
if bts:
|
||||
params["bts"] = bts
|
||||
if nearbts_parts:
|
||||
params["nearbts"] = "|".join(nearbts_parts)
|
||||
if macs_parts:
|
||||
params["macs"] = "|".join(macs_parts)
|
||||
|
||||
# Add digital signature
|
||||
sig = _amap_sign(params)
|
||||
if sig:
|
||||
params["sig"] = sig
|
||||
|
||||
url = "https://apilocate.amap.com/position"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == "1" and data.get("result"):
|
||||
result = data["result"]
|
||||
location = result.get("location", "")
|
||||
if location and "," in location:
|
||||
lon_str, lat_str = location.split(",")
|
||||
lat = float(lat_str)
|
||||
lon = float(lon_str)
|
||||
logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon)
|
||||
return (lat, lon)
|
||||
else:
|
||||
infocode = data.get("infocode", "")
|
||||
if infocode == "10012":
|
||||
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
|
||||
else:
|
||||
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||||
else:
|
||||
logger.warning("Amap geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Reverse Geocoding: coordinates → address
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def reverse_geocode(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Convert lat/lon (WGS-84) to a human-readable address.
|
||||
|
||||
Uses 高德逆地理编码 API exclusively.
|
||||
"""
|
||||
cache_key = (round(lat, 3), round(lon, 3))
|
||||
cached = _address_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if AMAP_KEY:
|
||||
result = await _reverse_geocode_amap(lat, lon)
|
||||
if result:
|
||||
_address_cache.put(cache_key, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _reverse_geocode_amap(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Use 高德逆地理编码 API.
|
||||
|
||||
API: https://restapi.amap.com/v3/geocode/regeo
|
||||
Input: GCJ-02 coordinates (need to convert from WGS-84).
|
||||
Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise).
|
||||
"""
|
||||
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
|
||||
|
||||
params = {
|
||||
"key": AMAP_KEY,
|
||||
"location": f"{gcj_lon:.6f},{gcj_lat:.6f}",
|
||||
"extensions": "base",
|
||||
"output": "json",
|
||||
}
|
||||
|
||||
sig = _amap_sign(params)
|
||||
if sig:
|
||||
params["sig"] = sig
|
||||
|
||||
url = "https://restapi.amap.com/v3/geocode/regeo"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == "1":
|
||||
regeocode = data.get("regeocode", {})
|
||||
formatted = regeocode.get("formatted_address", "")
|
||||
if formatted and formatted != "[]":
|
||||
logger.info(
|
||||
"Amap reverse geocode: %.6f,%.6f -> %s",
|
||||
lat, lon, formatted,
|
||||
)
|
||||
return formatted
|
||||
else:
|
||||
logger.warning(
|
||||
"Amap reverse geocode error: info=%s, infocode=%s",
|
||||
data.get("info", ""), data.get("infocode", ""),
|
||||
)
|
||||
else:
|
||||
logger.warning("Amap reverse geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap reverse geocode error: %s", e)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user