feat: 性能优化 + 设备总览轨迹展示 + 广播指令API

性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、
device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询
批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG

功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、
前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 09:41:09 +00:00
parent b970b78136
commit b25eafc483
12 changed files with 1030 additions and 244 deletions

View File

@@ -50,6 +50,9 @@ class Settings(BaseSettings):
# Track query limit
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
# TCP connection
TCP_IDLE_TIMEOUT: int = Field(default=600, description="Idle timeout in seconds for TCP connections (0=disabled)")
# Fence auto-attendance
FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report")
FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check")

View File

@@ -1,3 +1,4 @@
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@@ -9,6 +10,17 @@ engine = create_async_engine(
connect_args={"check_same_thread": False},
)
# Enable WAL mode for concurrent read/write performance
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
cursor.execute("PRAGMA busy_timeout=5000") # 5s wait on lock
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async_session = async_sessionmaker(
bind=engine,
class_=AsyncSession,

View File

@@ -1,11 +1,12 @@
"""
Shared FastAPI dependencies.
Supports master API key (env) and database-managed API keys with permission levels.
Includes in-memory cache to avoid DB lookup on every request.
"""
import hashlib
import secrets
from datetime import datetime, timezone
import time
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
@@ -20,6 +21,10 @@ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Permission hierarchy: admin > write > read
_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3}
# In-memory auth cache: {key_hash: (result_dict, expire_timestamp)}
_AUTH_CACHE: dict[str, tuple[dict, float]] = {}
_AUTH_CACHE_TTL = 60 # seconds
def _hash_key(key: str) -> str:
"""SHA-256 hash of an API key."""
@@ -32,7 +37,7 @@ async def verify_api_key(
) -> dict | None:
"""Verify API key. Returns key info dict or None (auth disabled).
Checks master key first, then database keys.
Checks master key first, then in-memory cache, then database keys.
Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}.
"""
if settings.API_KEY is None:
@@ -45,23 +50,34 @@ async def verify_api_key(
if secrets.compare_digest(api_key, settings.API_KEY):
return {"permissions": "admin", "key_id": None, "name": "master"}
# Check in-memory cache first
key_hash = _hash_key(api_key)
now = time.monotonic()
cached = _AUTH_CACHE.get(key_hash)
if cached is not None:
result, expires = cached
if now < expires:
return result
# Check database keys
from app.models import ApiKey
key_hash = _hash_key(api_key)
result = await db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712
)
db_key = result.scalar_one_or_none()
if db_key is None:
_AUTH_CACHE.pop(key_hash, None)
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
# Update last_used_at
# Update last_used_at (deferred — only on cache miss, not every request)
from app.config import now_cst
db_key.last_used_at = now_cst()
await db.flush()
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
key_info = {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
_AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL)
return key_info
def require_permission(min_level: str):

View File

@@ -27,6 +27,31 @@ AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
# ---------------------------------------------------------------------------
# Shared aiohttp session (reused across all geocoding calls)
# ---------------------------------------------------------------------------
_http_session: Optional[aiohttp.ClientSession] = None
async def _get_http_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session (lazy init)."""
global _http_session
if _http_session is None or _http_session.closed:
_http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=5),
)
return _http_session
async def close_http_session() -> None:
"""Close the shared session (call on app shutdown)."""
global _http_session
if _http_session and not _http_session.closed:
await _http_session.close()
_http_session = None
# ---------------------------------------------------------------------------
# WGS-84 → GCJ-02 coordinate conversion (server-side)
# ---------------------------------------------------------------------------
@@ -316,38 +341,36 @@ async def _geocode_amap_v5(
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
logger.info("Amap v5 request body: %s", body)
logger.debug("Amap v5 request body: %s", body)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url, data=body, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info("Amap v5 response: %s", data)
if data.get("status") == "1":
position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else ""
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
logger.info(
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
gcj_lat, gcj_lon, lat, lon, radius,
)
return (lat, lon)
else:
infocode = data.get("infocode", "")
logger.warning(
"Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, body,
session = await _get_http_session()
async with session.post(url, data=body) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.debug("Amap v5 response: %s", data)
if data.get("status") == "1":
position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else ""
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
logger.info(
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
gcj_lat, gcj_lon, lat, lon, radius,
)
return (lat, lon)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
infocode = data.get("infocode", "")
logger.warning(
"Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, body,
)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap v5 geocode error: %s", e)
@@ -400,37 +423,35 @@ async def _geocode_amap_legacy(
url = "https://apilocate.amap.com/position"
logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
logger.debug("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
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)
logger.info("Amap legacy response: %s", data)
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(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
logger.info(
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
gcj_lat, gcj_lon, lat, lon,
)
return (lat, lon)
else:
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
session = await _get_http_session()
async with session.get(url, params=params) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.debug("Amap legacy response: %s", data)
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(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
logger.info(
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
gcj_lat, gcj_lon, lat, lon,
)
return (lat, lon)
else:
logger.warning("Amap legacy geocode HTTP %d", resp.status)
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Amap legacy geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap legacy geocode error: %s", e)
@@ -490,28 +511,26 @@ async def _reverse_geocode_amap(
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", ""),
session = await _get_http_session()
async with session.get(url, params=params) 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 HTTP %d", resp.status)
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)

View File

@@ -89,9 +89,14 @@ async def lifespan(app: FastAPI):
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
"CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)",
"CREATE INDEX IF NOT EXISTS ix_alarm_source ON alarm_records(alarm_source)",
"CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)",
"CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)",
"CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)",
"CREATE INDEX IF NOT EXISTS ix_att_source ON attendance_records(attendance_source)",
"CREATE INDEX IF NOT EXISTS ix_att_device_type_time ON attendance_records(device_id, attendance_type, recorded_at)",
"CREATE INDEX IF NOT EXISTS ix_device_status ON devices(status)",
"CREATE INDEX IF NOT EXISTS ix_fence_active ON fence_configs(is_active)",
]:
await conn.execute(sa_text(stmt))
logger.info("Database indexes verified/created")
@@ -107,6 +112,9 @@ async def lifespan(app: FastAPI):
logger.info("Shutting down TCP server...")
await tcp_manager.stop()
tcp_task.cancel()
# Close shared HTTP session
from app.geocoding import close_http_session
await close_http_session()
app = FastAPI(
title="KKS Badge Management System / KKS工牌管理系统",
@@ -186,12 +194,14 @@ app.include_router(geocoding.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# Cache admin.html in memory at startup (avoid disk read per request)
_admin_html_cache: str = (_STATIC_DIR / "admin.html").read_text(encoding="utf-8")
@app.get("/admin", response_class=HTMLResponse, tags=["Admin"])
async def admin_page():
"""管理后台页面 / Admin Dashboard"""
html_path = _STATIC_DIR / "admin.html"
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
return HTMLResponse(content=_admin_html_cache)
@app.get("/", tags=["Root"])

View File

@@ -317,6 +317,81 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
)
class BroadcastCommandRequest(BaseModel):
"""Request body for broadcasting a command to all devices."""
command_type: str = Field(default="online_cmd", max_length=30, description="指令类型")
command_content: str = Field(..., max_length=500, description="指令内容")
@router.post(
"/broadcast",
response_model=APIResponse[BatchCommandResponse],
status_code=201,
summary="广播指令给所有设备 / Broadcast command to all devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def broadcast_command(request: Request, body: BroadcastCommandRequest, db: AsyncSession = Depends(get_db)):
"""
向所有设备广播同一指令。尝试通过 TCP 发送给每台设备TCP 未连接的自动跳过。
Broadcast the same command to all devices. Attempts TCP send for each, skips those without active TCP connection.
"""
from sqlalchemy import select
from app.models import Device
result = await db.execute(select(Device))
devices = list(result.scalars().all())
if not devices:
return APIResponse(
message="No devices / 没有设备",
data=BatchCommandResponse(total=0, sent=0, failed=0, results=[]),
)
results = []
for device in devices:
if not tcp_command_service.is_device_online(device.imei):
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=False, error="TCP not connected",
))
continue
try:
cmd_log = await command_service.create_command(
db,
device_id=device.id,
command_type=body.command_type,
command_content=body.command_content,
)
await tcp_command_service.send_command(
device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
cmd_log.sent_at = now_cst()
await db.flush()
await db.refresh(cmd_log)
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=True, command_id=cmd_log.id,
))
except Exception as e:
logging.getLogger(__name__).error("Broadcast cmd failed for %s: %s", device.imei, e)
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=False, error="Send failed",
))
sent = sum(1 for r in results if r.success)
failed = len(results) - sent
return APIResponse(
message=f"Broadcast: {sent} sent, {failed} skipped (total: {len(devices)})",
data=BatchCommandResponse(
total=len(results), sent=sent, failed=failed, results=results,
),
)
@router.get(
"/{command_id}",
response_model=APIResponse[CommandResponse],

View File

@@ -31,6 +31,7 @@ router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
async def list_locations(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
exclude_type: str | None = Query(default=None, description="排除定位类型前缀 / Exclude location type prefix (e.g. lbs)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
@@ -45,6 +46,7 @@ async def list_locations(
db,
device_id=device_id,
location_type=location_type,
exclude_type=exclude_type,
start_time=start_time,
end_time=end_time,
page=page,

View File

@@ -234,6 +234,19 @@ async def check_device_fences(
"cell_id": cell_id,
}
# 2. Batch-load all fence states in one query (avoid N+1)
fence_ids = [f.id for f in fences]
states_result = await session.execute(
select(DeviceFenceState).where(
DeviceFenceState.device_id == device_id,
DeviceFenceState.fence_id.in_(fence_ids),
)
)
states_map: dict[int, DeviceFenceState] = {s.fence_id: s for s in states_result.scalars().all()}
# Pre-check today's attendance dedup once (not per-fence)
_today_clock_in = await _has_attendance_today(session, device_id, "clock_in")
tolerance = _get_tolerance_for_location_type(location_type)
events: list[dict] = []
now = now_cst()
@@ -242,14 +255,7 @@ async def check_device_fences(
for fence in fences:
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
# 2. Get or create state record
state_result = await session.execute(
select(DeviceFenceState).where(
DeviceFenceState.device_id == device_id,
DeviceFenceState.fence_id == fence.id,
)
)
state = state_result.scalar_one_or_none()
state = states_map.get(fence.id)
was_inside = bool(state and state.is_inside)
@@ -266,8 +272,8 @@ async def check_device_fences(
_update_state(state, currently_inside, now, latitude, longitude)
continue
# Daily dedup: only one clock_in per device per day
if await _has_attendance_today(session, device_id, "clock_in"):
# Daily dedup: only one clock_in per device per day (pre-fetched)
if _today_clock_in:
logger.info(
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
device_id, fence.id, fence.name,

View File

@@ -15,6 +15,7 @@ async def get_locations(
db: AsyncSession,
device_id: int | None = None,
location_type: str | None = None,
exclude_type: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: int = 1,
@@ -56,6 +57,14 @@ async def get_locations(
query = query.where(LocationRecord.location_type == location_type)
count_query = count_query.where(LocationRecord.location_type == location_type)
if exclude_type:
# Map prefix to actual values for index-friendly IN query
_EXCLUDE_MAP = {"lbs": ["lbs", "lbs_4g"], "wifi": ["wifi", "wifi_4g"], "gps": ["gps", "gps_4g"]}
exclude_values = _EXCLUDE_MAP.get(exclude_type, [exclude_type])
clause = LocationRecord.location_type.notin_(exclude_values)
query = query.where(clause)
count_query = count_query.where(clause)
if start_time:
query = query.where(LocationRecord.recorded_at >= start_time)
count_query = count_query.where(LocationRecord.recorded_at >= start_time)

View File

@@ -139,6 +139,17 @@
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
@media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } }
.dev-sort:hover { background: #374151; }
.sort-arrow { font-size: 10px; color: #6b7280; margin-left: 2px; }
.sort-arrow.asc::after { content: '▲'; color: #60a5fa; }
.sort-arrow.desc::after { content: '▼'; color: #60a5fa; }
.dev-qcmd { height:24px;border:1px solid #374151;border-radius:5px;background:#1f2937;color:#9ca3af;font-size:11px;cursor:pointer;padding:0 7px;margin:0 1px;transition:all 0.15s;white-space:nowrap; }
.dev-qcmd i { margin-right:2px; }
.dev-qcmd:hover:not(:disabled) { background:#2563eb;color:#fff;border-color:#2563eb; }
.dev-qcmd.sent { background:#065f46;color:#34d399;border-color:#065f46; }
.dev-qcmd-danger { color:#f87171; }
.ov-dev-item:hover { background: #374151; }
.dev-qcmd-danger:hover:not(:disabled) { background:#991b1b;color:#fff;border-color:#991b1b; }
</style>
</head>
<body>
@@ -309,6 +320,12 @@
<option value="offline">离线</option>
</select>
<button class="btn btn-secondary" onclick="loadDevices()"><i class="fas fa-sync-alt"></i> 刷新</button>
<span style="width:1px;height:24px;background:#374151;margin:0 4px"></span>
<button class="btn btn-secondary" onclick="_broadcastCmd('WHERE#','全部定位')"><i class="fas fa-crosshairs"></i> 全部定位</button>
<button class="btn btn-secondary" onclick="_broadcastCmd('GPSON#','全部开GPS')"><i class="fas fa-satellite-dish"></i> 全部开GPS</button>
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,1#','全部定时模式')"><i class="fas fa-clock"></i> 全部定时</button>
<button class="btn btn-secondary" onclick="_broadcastCmd('MODE,3#','全部智能模式')"><i class="fas fa-brain"></i> 全部智能</button>
<button class="btn btn-secondary" onclick="_showBroadcastModal()"><i class="fas fa-broadcast-tower"></i> 自定义广播</button>
</div>
<button class="btn btn-primary" onclick="showAddDeviceModal()"><i class="fas fa-plus"></i> 添加设备</button>
</div>
@@ -318,18 +335,20 @@
<table>
<thead>
<tr>
<th>IMEI</th>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>电量</th>
<th>信号</th>
<th>最后登录</th>
<th>最后心跳</th>
<th class="dev-sort" data-sort="imei" style="cursor:pointer;user-select:none">IMEI <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="name" style="cursor:pointer;user-select:none">名称 <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="device_type" style="cursor:pointer;user-select:none">类型 <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="status" style="cursor:pointer;user-select:none">状态 <span class="sort-arrow"></span></th>
<th>定位模式</th>
<th class="dev-sort" data-sort="battery_level" style="cursor:pointer;user-select:none">电量 <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="gsm_signal" style="cursor:pointer;user-select:none">信号 <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="last_login" style="cursor:pointer;user-select:none">最后登录 <span class="sort-arrow"></span></th>
<th class="dev-sort" data-sort="last_heartbeat" style="cursor:pointer;user-select:none">最后心跳 <span class="sort-arrow"></span></th>
<th style="text-align:center">快捷操作</th>
</tr>
</thead>
<tbody id="devicesTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
<tr><td colspan="10" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -339,6 +358,14 @@
<!-- ==================== LOCATIONS PAGE ==================== -->
<div id="page-locations" class="page">
<!-- Tab bar -->
<div style="display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid #374151">
<button class="fence-tab active" id="locTabTrack" onclick="_switchLocTab('track')"><i class="fas fa-route mr-1"></i>轨迹追踪</button>
<button class="fence-tab" id="locTabOverview" onclick="_switchLocTab('overview')"><i class="fas fa-users mr-1"></i>全部设备总览</button>
</div>
<!-- Track tab content -->
<div id="locTabTrackContent">
<div class="page-guide" onclick="this.classList.toggle('collapsed')">
<div class="guide-header">
<div class="guide-icon"><i class="fas fa-lightbulb text-white text-sm"></i></div>
@@ -412,7 +439,7 @@
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="locSelectAll" onchange="toggleAllLocCheckboxes(this.checked)" title="全选当前页"></th>
<th>设备ID</th>
<th>IMEI</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
@@ -432,6 +459,45 @@
</div>
</div>
</div>
</div><!-- /locTabTrackContent -->
<!-- Overview tab content -->
<div id="locTabOverviewContent" style="display:none">
<div style="display:flex;gap:12px;height:calc(100vh - 200px);min-height:500px">
<!-- Left: device checklist -->
<div style="width:260px;min-width:260px;background:#1f2937;border:1px solid #374151;border-radius:10px;display:flex;flex-direction:column;overflow:hidden">
<div style="padding:10px 12px;border-bottom:1px solid #374151;font-size:13px;font-weight:600;color:#d1d5db;display:flex;align-items:center;gap:6px">
<i class="fas fa-users text-blue-400"></i> 设备选择
<span id="ovSelectedCount" style="margin-left:auto;font-size:11px;color:#6b7280"></span>
</div>
<div style="padding:6px 8px;border-bottom:1px solid #374151;display:flex;flex-direction:column;gap:4px">
<div style="position:relative">
<i class="fas fa-search" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);color:#6b7280;font-size:11px"></i>
<input type="text" id="ovDeviceSearch" placeholder="搜索名称/IMEI..." oninput="_ovFilterDevices()" style="width:100%;padding:4px 8px 4px 26px;font-size:11px;background:#111827;border:1px solid #374151;border-radius:5px;color:#d1d5db">
</div>
<label style="font-size:11px;color:#9ca3af;cursor:pointer;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="ovSelectAll" checked onchange="_ovToggleAll(this.checked)"> 全选/取消
</label>
</div>
<div id="ovDeviceList" style="flex:1;overflow-y:auto;padding:4px 8px"></div>
</div>
<!-- Right: map + toolbar -->
<div style="flex:1;display:flex;flex-direction:column;gap:8px">
<div class="flex flex-wrap items-center gap-3">
<button class="btn btn-primary" onclick="loadAllDevicePositions()"><i class="fas fa-sync-alt"></i> 刷新位置</button>
<button class="btn btn-success" onclick="_ovRequestAllPositions()"><i class="fas fa-satellite-dish"></i> 获取实时位置</button>
<span style="width:1px;height:24px;background:#374151"></span>
<button class="btn btn-secondary" onclick="_ovShowTrack()" id="ovBtnTrack"><i class="fas fa-route"></i> 显示轨迹</button>
<button class="btn btn-secondary" onclick="_ovClearTrack()" id="ovBtnClearTrack" style="display:none"><i class="fas fa-times"></i> 清除轨迹</button>
<button id="ovBtnHideLP" class="btn btn-secondary" onclick="_ovToggleLP()"><i class="fas fa-eye"></i> 低精度</button>
<span id="overviewDeviceCount" class="text-sm text-gray-400"></span>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden" style="flex:1;position:relative">
<div id="overviewMap" style="height:100%;width:100%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== ALARMS PAGE ==================== -->
@@ -505,7 +571,7 @@
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="alarmSelectAll" onchange="toggleAllCheckboxes('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm',this.checked)" title="全选当前页"></th>
<th>设备ID</th>
<th>IMEI</th>
<th>类型</th>
<th>来源</th>
<th>位置</th>
@@ -589,7 +655,7 @@
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="attSelectAll" onchange="toggleAllCheckboxes('att-sel-cb','attSelCount','btnBatchDeleteAtt',this.checked)" title="全选当前页"></th>
<th>设备ID</th>
<th>IMEI</th>
<th>类型</th>
<th>来源</th>
<th>位置</th>
@@ -657,7 +723,7 @@
<thead>
<tr>
<th style="width:32px"><input type="checkbox" id="btSelectAll" onchange="toggleAllCheckboxes('bt-sel-cb','btSelCount','btnBatchDeleteBt',this.checked)" title="全选当前页"></th>
<th>设备ID</th>
<th>IMEI</th>
<th>类型</th>
<th>信标MAC</th>
<th>UUID / Major / Minor</th>
@@ -888,7 +954,6 @@
<th style="width:32px"><input type="checkbox" id="logSelectAll" onchange="toggleAllCheckboxes('log-sel-cb','logSelCount','btnBatchDeleteLog',this.checked)" title="全选当前页"></th>
<th>ID</th>
<th>类型</th>
<th>设备ID</th>
<th>IMEI</th>
<th>详情</th>
<th>坐标</th>
@@ -897,7 +962,7 @@
</tr>
</thead>
<tbody id="datalogTableBody">
<tr><td colspan="9" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
<tr><td colspan="8" class="text-center text-gray-500 py-8">选择筛选条件后点击查询</td></tr>
</tbody>
</table>
</div>
@@ -1432,12 +1497,20 @@
// ==================== DEVICE SELECTOR HELPER ====================
let cachedDevices = null;
let _devIdToImei = {}; // {device_id: imei} global mapping
function _imei(deviceId) {
return _devIdToImei[deviceId] || deviceId || '-';
}
async function loadDeviceSelectors() {
try {
const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`);
const devices = data.items || [];
cachedDevices = devices;
// Build global device_id -> imei mapping
_devIdToImei = {};
devices.forEach(d => { _devIdToImei[d.id || d.device_id] = d.imei; });
const selectors = ['locDeviceSelect', 'alarmDeviceFilter', 'attDeviceFilter', 'btDeviceFilter', 'cmdHistoryDeviceFilter'];
selectors.forEach(id => {
const sel = document.getElementById(id);
@@ -1567,7 +1640,7 @@
<i class="fas fa-exclamation-circle ${alarmTypeClass(a.alarm_type)}"></i>
<div>
<span class="text-sm font-medium ${alarmTypeClass(a.alarm_type)}">${alarmTypeName(a.alarm_type)}</span>
<span class="text-xs text-gray-500 ml-2">设备: ${escapeHtml(a.device_id || '-')}</span>
<span class="text-xs text-gray-500 ml-2">IMEI: ${escapeHtml(_imei(a.device_id))}</span>
</div>
</div>
<div class="flex items-center gap-2">
@@ -1650,6 +1723,75 @@
}
// ==================== DEVICES ====================
let _devItems = [];
let _devLocModes = {};
const _devSort = { field: 'name', dir: 'asc' };
function _renderDeviceRows() {
const tbody = document.getElementById('devicesTableBody');
if (!_devItems.length) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
return;
}
const sorted = [..._devItems].sort((a, b) => {
const f = _devSort.field;
let va = a[f], vb = b[f];
if (va == null) va = '';
if (vb == null) vb = '';
if (typeof va === 'number' && typeof vb === 'number') return _devSort.dir === 'asc' ? va - vb : vb - va;
va = String(va); vb = String(vb);
const cmp = va.localeCompare(vb, 'zh-CN', { numeric: true });
return _devSort.dir === 'asc' ? cmp : -cmp;
});
tbody.innerHTML = sorted.map(d => {
const did = d.id || d.device_id || '';
const on = d.status === 'online';
const dis = on ? '' : 'disabled style="opacity:0.35;cursor:not-allowed"';
return `
<tr>
<td class="font-mono text-sm" style="cursor:pointer;color:#60a5fa" onclick="showDeviceDetail('${did}')">${escapeHtml(d.imei)}</td>
<td style="cursor:pointer" onclick="showDeviceDetail('${did}')">${escapeHtml(d.name || '-')}</td>
<td>${escapeHtml(d.device_type || '-')}</td>
<td>${statusBadge(d.status)}</td>
<td class="text-xs">${_locTypeBadge(_devLocModes[d.id] || null)}</td>
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
<td style="text-align:center;white-space:nowrap" onclick="event.stopPropagation()">
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','WHERE#',this)"><i class="fas fa-crosshairs"></i> 定位</button>
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','GPSON#',this)"><i class="fas fa-satellite-dish"></i> GPS</button>
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','MODE,1#',this)"><i class="fas fa-clock"></i> 定时</button>
<button class="dev-qcmd" ${dis} onclick="_devQuickCmd('${did}','STATUS#',this)"><i class="fas fa-info-circle"></i> 状态</button>
<button class="dev-qcmd dev-qcmd-danger" ${dis} onclick="if(confirm('确定重启该设备?'))_devQuickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启</button>
</td>
</tr>`;
}).join('');
}
function _updateSortArrows() {
document.querySelectorAll('.dev-sort .sort-arrow').forEach(el => {
el.className = 'sort-arrow';
if (el.parentElement.dataset.sort === _devSort.field) {
el.classList.add(_devSort.dir);
}
});
}
document.addEventListener('click', e => {
const th = e.target.closest('.dev-sort');
if (!th) return;
const field = th.dataset.sort;
if (_devSort.field === field) {
_devSort.dir = _devSort.dir === 'asc' ? 'desc' : 'asc';
} else {
_devSort.field = field;
_devSort.dir = 'asc';
}
_updateSortArrows();
_renderDeviceRows();
});
async function loadDevices(page) {
if (page) pageState.devices.page = page;
const p = pageState.devices.page;
@@ -1664,29 +1806,18 @@
showLoading('devicesLoading');
try {
const data = await apiCall(url);
const items = data.items || [];
const tbody = document.getElementById('devicesTableBody');
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-500 py-8">没有找到设备</td></tr>';
} else {
tbody.innerHTML = items.map(d => `
<tr style="cursor:pointer" onclick="showDeviceDetail('${d.id || d.device_id || ''}')">
<td class="font-mono text-sm">${escapeHtml(d.imei)}</td>
<td>${escapeHtml(d.name || '-')}</td>
<td>${escapeHtml(d.device_type || '-')}</td>
<td>${statusBadge(d.status)}</td>
<td>${d.battery_level !== undefined && d.battery_level !== null ? `<span class="${d.battery_level < 20 ? 'text-red-400' : 'text-green-400'}">${d.battery_level}%</span>` : '-'}</td>
<td>${d.gsm_signal !== undefined && d.gsm_signal !== null ? d.gsm_signal : '-'}</td>
<td class="text-xs text-gray-400">${formatTime(d.last_login)}</td>
<td class="text-xs text-gray-400">${formatTime(d.last_heartbeat)}</td>
</tr>
`).join('');
}
_devItems = data.items || [];
_devLocModes = {};
_renderDeviceRows();
_updateSortArrows();
renderPagination('devicesPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadDevices');
// Fetch latest location types asynchronously
if (_devItems.length) {
_fillDeviceLocModes(_devItems.map(d => d.id));
}
} catch (err) {
showToast('加载设备列表失败: ' + err.message, 'error');
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
document.getElementById('devicesTableBody').innerHTML = '<tr><td colspan="10" class="text-center text-red-400 py-8">加载失败</td></tr>';
} finally {
hideLoading('devicesLoading');
}
@@ -1736,6 +1867,25 @@
const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' };
return map[t] || t || '-';
}
function _locTypeBadge(t) {
if (!t) return '<span class="text-gray-600">-</span>';
const colors = { gps: '#3b82f6', gps_4g: '#3b82f6', wifi: '#06b6d4', wifi_4g: '#06b6d4', lbs: '#f59e0b', lbs_4g: '#f59e0b', bluetooth: '#a855f7' };
const color = colors[t] || '#6b7280';
const label = _locTypeLabel(t);
return `<span style="color:${color};font-weight:600">${label}</span>`;
}
async function _fillDeviceLocModes(deviceIds) {
if (!deviceIds.length) return;
try {
const data = await apiCall(`${API_BASE}/locations/batch-latest`, {
method: 'POST',
body: JSON.stringify({ device_ids: deviceIds })
});
const locs = Array.isArray(data) ? data : [];
locs.forEach(l => { if (l) _devLocModes[l.device_id] = l.location_type; });
_renderDeviceRows();
} catch (e) { /* silent - non-critical */ }
}
function _locModeBadges(locType) {
const modes = [
{ label: 'GPS', match: ['gps','gps_4g'] },
@@ -1872,6 +2022,8 @@
const content = _buildInfoContent('位置记录', loc, lat, lng);
_focusInfoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
_focusInfoWindow.open(locationMap, [mLng, mLat]);
_focusMarker.on('mouseover', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
_focusMarker.on('mouseout', () => _focusInfoWindow.close());
_focusMarker.on('click', () => _focusInfoWindow.open(locationMap, [mLng, mLat]));
locationMap.setCenter([mLng, mLat]);
@@ -1977,6 +2129,86 @@
}
// --- Quick command sender for device detail panel ---
async function _devQuickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.classList.add('sent'); }
try {
const res = await apiCall(`${API_BASE}/commands/send`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }),
});
showToast(`${cmd} → 已发送`);
// Poll for response
const cmdId = res && res.id;
if (cmdId) {
for (let i = 0; i < 6; i++) {
await new Promise(r => setTimeout(r, 1500));
try {
const c = await apiCall(`${API_BASE}/commands/${cmdId}`);
if (c.response_content) { showToast(`${cmd}${c.response_content}`); break; }
} catch (_) {}
}
}
} catch (err) {
showToast(`${cmd} 发送失败: ${err.message}`, 'error');
} finally {
if (btnEl) { btnEl.disabled = false; btnEl.classList.remove('sent'); }
}
}
async function _broadcastCmd(cmd, label) {
if (!confirm(`确定向所有设备发送 ${cmd} `)) return;
try {
const data = await apiCall(`${API_BASE}/commands/broadcast`, {
method: 'POST',
body: JSON.stringify({ command_type: 'online_cmd', command_content: cmd }),
});
showToast(`${label}: ${data.sent} 台已发送, ${data.failed} 台未连接`);
} catch (err) {
showToast(`${label} 失败: ${err.message}`, 'error');
}
}
function _showBroadcastModal() {
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-yellow-400"></i>广播指令 — 发送给所有在线设备</h3>
<div class="form-group">
<label>指令内容</label>
<input type="text" id="broadcastCmdInput" placeholder="如: GPSON#, MODE,1#, TIMER,60#" maxlength="500">
<p class="text-xs text-gray-500 mt-1">将发送给所有在线设备,离线设备自动跳过</p>
</div>
<div style="margin-top:8px">
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">常用指令快捷选择:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSON#'" title="开启GPS定位模块持续5分钟">GPSON# 开启GPS</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='GPSOFF#'" title="关闭GPS定位模块省电">GPSOFF# 关闭GPS</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,1#'" title="定时上报位置按TIMER间隔">MODE,1# 定时定位</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='MODE,3#'" title="智能模式:运动时高频上报,静止时低频">MODE,3# 智能模式</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='WHERE#'" title="立即获取一次设备当前位置">WHERE# 立即定位</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='STATUS#'" title="查询电量、信号、GPS状态等">STATUS# 设备状态</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,60#'" title="每60秒上报一次位置">TIMER,60# 间隔1分钟</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='TIMER,300#'" title="每300秒上报一次位置">TIMER,300# 间隔5分钟</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTON#'" title="开启蓝牙模块">BTON# 开蓝牙</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='BTSCAN,1#'" title="开启BLE信标扫描">BTSCAN,1# 开BLE扫描</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='PARAM#'" title="查询设备当前所有参数配置">PARAM# 参数查询</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='VERSION#'" title="查询设备固件版本号">VERSION# 固件版本</button>
<button class="dev-qcmd" onclick="document.getElementById('broadcastCmdInput').value='RESET#'" style="color:#f87171" title="远程重启设备,慎用">RESET# 重启设备</button>
</div>
</div>
<div class="flex gap-3 mt-5">
<button class="btn btn-primary flex-1" onclick="_doBroadcast()"><i class="fas fa-paper-plane"></i> 发送广播</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
</div>
`);
}
async function _doBroadcast() {
const cmd = document.getElementById('broadcastCmdInput').value.trim();
if (!cmd) { showToast('请输入指令内容', 'error'); return; }
if (!confirm('确定向所有设备发送 ' + cmd + ' ')) return;
closeModal();
await _broadcastCmd(cmd, '广播 ' + cmd);
}
async function _quickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
try {
@@ -2260,6 +2492,381 @@
return wgs84ToGcj02(lat, lng);
}
// ==================== LOCATION TAB SWITCH ====================
let _ovInited = false;
async function _switchLocTab(tab) {
document.getElementById('locTabTrack').classList.toggle('active', tab === 'track');
document.getElementById('locTabOverview').classList.toggle('active', tab === 'overview');
document.getElementById('locTabTrackContent').style.display = tab === 'track' ? '' : 'none';
document.getElementById('locTabOverviewContent').style.display = tab === 'overview' ? '' : 'none';
if (tab === 'overview') {
if (!_overviewMap) _initOverviewMap();
if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors();
if (!_ovInited) {
_ovInited = true;
cachedDevices.forEach(d => _ovSelectedDevices.add(d.id));
}
_ovBuildDeviceList();
loadAllDevicePositions();
}
}
// ==================== OVERVIEW MAP ====================
let _overviewMap = null;
let _ovMarkerMap = {}; // {device_id: marker}
let _ovSelectedDevices = new Set(); // selected device IDs
function _initOverviewMap() {
if (_overviewMap) return;
setTimeout(() => {
const [mLat, mLng] = toMapCoord(30.605, 103.936);
_overviewMap = new AMap.Map('overviewMap', {
viewMode: '3D', pitch: 45, rotation: -15, rotateEnable: true,
zoom: 14, center: [mLng, mLat],
mapStyle: 'amap://styles/normal',
});
}, 100);
}
function _clearOverviewMarkers() {
Object.values(_ovMarkerMap).forEach(m => m.setMap(null));
_ovMarkerMap = {};
}
let _ovPinnedIW = null; // pinned (click) info window
function _ovLocateDevice(did) {
// Highlight in list
document.querySelectorAll('.ov-dev-item').forEach(el => {
el.style.background = Number(el.dataset.did) === did ? '#1e3a5f' : '';
});
// Move map to device marker (show even if unchecked)
const marker = _ovMarkerMap[did];
if (marker) {
if (!marker.getMap()) marker.setMap(_overviewMap);
const pos = marker.getPosition();
_overviewMap.setCenter(pos);
_overviewMap.setZoom(16);
if (_ovPinnedIW) _ovPinnedIW.close();
marker.emit('click', { lnglat: pos });
} else {
showToast('该设备暂无位置数据', 'info');
}
// Highlight this device's track if exists
_ovHighlightByDevice(did);
}
function _ovSyncMarkerVisibility() {
let visibleCount = 0;
const visible = [];
for (const [did, marker] of Object.entries(_ovMarkerMap)) {
if (_ovSelectedDevices.has(Number(did))) {
marker.setMap(_overviewMap);
visible.push(marker);
visibleCount++;
} else {
marker.setMap(null);
}
}
document.getElementById('overviewDeviceCount').textContent =
`已选 ${_ovSelectedDevices.size} 台,${visibleCount} 台有位置`;
}
function _ovBuildDeviceList() {
const list = document.getElementById('ovDeviceList');
if (!cachedDevices || !cachedDevices.length) {
list.innerHTML = '<div style="padding:12px;color:#6b7280;font-size:12px;text-align:center">无设备</div>';
return;
}
list.innerHTML = cachedDevices.map(d => {
const checked = _ovSelectedDevices.has(d.id) ? 'checked' : '';
const statusDot = d.status === 'online' ? '🟢' : '⚫';
return `<div style="display:flex;align-items:center;gap:4px;padding:5px 4px;color:#d1d5db;border-bottom:1px solid #374151;overflow:hidden" class="ov-dev-item" data-did="${d.id}">
<input type="checkbox" ${checked} onchange="event.stopPropagation();_ovToggleDevice(${d.id},this.checked)" style="flex:0 0 14px;width:14px;height:14px;cursor:pointer">
<span style="flex:0 0 16px;font-size:12px">${statusDot}</span>
<span style="flex:1;min-width:0;overflow:hidden;cursor:pointer" onclick="_ovLocateDevice(${d.id})">
<span style="display:block;font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.name || '-')}</span>
<span style="display:block;font-size:10px;color:#6b7280;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.imei)}</span>
</span>
</div>`;
}).join('');
_ovUpdateCount();
document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === cachedDevices.length;
}
function _ovToggleDevice(id, checked) {
if (checked) _ovSelectedDevices.add(id); else _ovSelectedDevices.delete(id);
_ovUpdateCount();
document.getElementById('ovSelectAll').checked = _ovSelectedDevices.size === (cachedDevices || []).length;
_ovSyncMarkerVisibility();
}
function _ovFilterDevices() {
const q = (document.getElementById('ovDeviceSearch').value || '').toLowerCase();
document.querySelectorAll('.ov-dev-item').forEach(el => {
const text = el.textContent.toLowerCase();
el.style.display = !q || text.includes(q) ? '' : 'none';
});
}
function _ovToggleAll(checked) {
(cachedDevices || []).forEach(d => {
if (checked) _ovSelectedDevices.add(d.id); else _ovSelectedDevices.delete(d.id);
});
_ovBuildDeviceList();
_ovSyncMarkerVisibility();
}
function _ovUpdateCount() {
const el = document.getElementById('ovSelectedCount');
if (el) el.textContent = `${_ovSelectedDevices.size}/${(cachedDevices||[]).length}`;
}
async function loadAllDevicePositions() {
if (!_overviewMap) { _initOverviewMap(); await new Promise(r => setTimeout(r, 200)); }
if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors();
_clearOverviewMarkers();
const allIds = cachedDevices.map(d => d.id);
if (!allIds.length) return;
try {
const data = await apiCall(`${API_BASE}/locations/batch-latest`, {
method: 'POST',
body: JSON.stringify({ device_ids: allIds }),
});
const locs = Array.isArray(data) ? data : [];
const _devColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6','#e879f9','#facc15','#fb923c'];
const _devColorMap = {};
allIds.forEach((id, idx) => { _devColorMap[id] = _devColors[idx % _devColors.length]; });
let plotted = 0;
locs.forEach((loc, i) => {
if (!loc) return;
const lat = loc.latitude, lng = loc.longitude;
if (!lat || !lng) return;
const did = allIds[i];
const [mLat, mLng] = toMapCoord(lat, lng);
const dev = cachedDevices.find(d => d.id === did);
const devName = dev ? (dev.name || '') : '';
const devImei = dev ? dev.imei : _imei(did);
const isOnline = dev && dev.status === 'online';
const color = _devColorMap[did] || '#3b82f6';
const borderColor = isOnline ? '#22c55e' : '#6b7280';
const labelText = devName ? `${devName} (${devImei.slice(-4)})` : devImei;
const marker = new AMap.Marker({
position: [mLng, mLat],
label: { content: `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:8px;font-size:11px;white-space:nowrap;border:2px solid ${borderColor}">${escapeHtml(labelText)}</span>`, direction: 'top', offset: new AMap.Pixel(0, -5) },
});
// Don't add to map yet — visibility controlled by _ovSyncMarkerVisibility
const statusText = isOnline ? '<span style="color:#22c55e">● 在线</span>' : '<span style="color:#6b7280">● 离线</span>';
const title = `${escapeHtml(devName || devImei)} ${statusText}<br><span style="font-size:11px;color:#9ca3af;font-family:monospace">${escapeHtml(devImei)}</span>`;
const content = _buildInfoContent(title, loc, lat, lng);
// Hover: lightweight preview
const hoverIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
let _pinned = false;
marker.on('mouseover', () => { if (!_pinned) hoverIW.open(_overviewMap, [mLng, mLat]); });
marker.on('mouseout', () => { if (!_pinned) hoverIW.close(); });
// Click: pin the info window + highlight track
const _clickDid = did;
marker.on('click', () => {
if (_ovPinnedIW) _ovPinnedIW.close();
_pinned = true;
const pinnedIW = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -30) });
pinnedIW.open(_overviewMap, [mLng, mLat]);
pinnedIW.on('close', () => { _pinned = false; });
_ovPinnedIW = pinnedIW;
_ovHighlightByDevice(_clickDid);
});
_ovMarkerMap[did] = marker;
plotted++;
});
// Show only selected devices
_ovSyncMarkerVisibility();
// Fit view to visible markers
const visible = Object.entries(_ovMarkerMap)
.filter(([did]) => _ovSelectedDevices.has(Number(did)))
.map(([, m]) => m);
if (visible.length > 1) {
_overviewMap.setFitView(visible, false, [80,80,80,80]);
} else if (visible.length === 1) {
_overviewMap.setCenter(visible[0].getPosition());
_overviewMap.setZoom(15);
}
showToast(`已加载 ${plotted} 台设备位置`);
} catch (err) {
showToast('加载设备位置失败: ' + err.message, 'error');
}
}
async function _ovRequestAllPositions() {
if (!confirm('向所有设备发送定位指令?')) return;
try {
const data = await apiCall(`${API_BASE}/commands/broadcast`, {
method: 'POST',
body: JSON.stringify({ command_type: 'online_cmd', command_content: 'WHERE#' }),
});
showToast(`已发送定位指令: ${data.sent} 台已发送, ${data.failed} 台未连接,等待回传后点"刷新位置"`);
} catch (err) {
showToast('发送失败: ' + err.message, 'error');
}
}
// ==================== OVERVIEW TRACK + LP FILTER ====================
let _ovTrackMarkers = [];
let _ovHideLP = false;
let _ovHighlightedPL = null;
let _ovDidToPL = {}; // {device_id: polyline} for highlight lookup
function _ovHighlightByDevice(did) {
const pl = _ovDidToPL[did];
if (pl) _ovHighlightTrack(pl);
}
function _ovHighlightTrack(pl) {
// Reset previous highlight
_ovTrackMarkers.forEach(m => {
if (m._ovColor) {
m.setOptions({ strokeWeight: 3, strokeOpacity: 0.6, zIndex: 50 });
}
// Dim track points of other devices
if (m.setRadius && m._ovOwnerPL && m._ovOwnerPL !== pl) {
m.setMap(null); m._ovDimmed = true;
}
});
if (_ovHighlightedPL === pl) {
// Toggle off: restore all
_ovHighlightedPL = null;
_ovTrackMarkers.forEach(m => {
if (m._ovColor) m.setOptions({ strokeOpacity: 0.6 });
if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; }
});
showToast('已取消高亮');
return;
}
// Highlight clicked polyline
pl.setOptions({ strokeWeight: 6, strokeOpacity: 1, zIndex: 200 });
_ovHighlightedPL = pl;
// Show only this device's track points
_ovTrackMarkers.forEach(m => {
if (m._ovDimmed) { m.setMap(_overviewMap); m._ovDimmed = false; }
});
showToast(`已高亮: ${pl._ovDevName}`);
}
async function _ovShowTrack() {
const ids = [..._ovSelectedDevices];
if (!ids.length) { showToast('请勾选至少一台设备', 'error'); return; }
const today = new Date().toISOString().split('T')[0];
_ovClearTrack();
let totalPoints = 0;
// Distinct track colors per device
const trackColors = ['#3b82f6','#ef4444','#22c55e','#f59e0b','#a855f7','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1','#14b8a6'];
for (let idx = 0; idx < ids.length; idx++) {
const did = ids[idx];
const dev = cachedDevices.find(d => d.id === did);
const devName = dev ? (dev.name || dev.imei) : did;
const color = trackColors[idx % trackColors.length];
try {
const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${today}T00:00:00&end_time=${today}T23:59:59`);
const locs = Array.isArray(data) ? data : (data.items || []);
if (!locs.length) continue;
const path = [];
const deviceMarkers = [];
locs.forEach((loc, i) => {
const lat = loc.latitude, lng = loc.longitude;
if (!lat || !lng) return;
const lt = (loc.location_type || '').toLowerCase();
if (_ovHideLP && lt.startsWith('lbs')) return;
const [mLat, mLng] = toMapCoord(lat, lng);
path.push([mLng, mLat]);
const isFirst = i === 0, isLast = i === locs.length - 1;
const marker = new AMap.CircleMarker({
center: [mLng, mLat],
radius: isFirst || isLast ? 10 : 5,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : color,
strokeColor: '#fff', strokeWeight: 1,
fillOpacity: 0.9, zIndex: 130, cursor: 'pointer',
});
marker.setMap(_overviewMap);
const label = `${devName} ${isFirst ? '起点' : isLast ? '终点' : `${i+1}/${locs.length}`}`;
const content = _buildInfoContent(label, loc, lat, lng);
const iw = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
let pinned = false;
marker.on('mouseover', () => { if (!pinned) iw.open(_overviewMap, [mLng, mLat]); });
marker.on('mouseout', () => { if (!pinned) iw.close(); });
marker.on('click', () => { pinned = !pinned; iw.open(_overviewMap, [mLng, mLat]); });
iw.on('close', () => { pinned = false; });
deviceMarkers.push(marker);
_ovTrackMarkers.push(marker);
});
if (path.length > 1) {
const pl = new AMap.Polyline({ path, strokeColor: color, strokeWeight: 3, strokeOpacity: 0.6, lineJoin: 'round', cursor: 'pointer', zIndex: 50 });
pl.setMap(_overviewMap);
pl._ovColor = color;
pl._ovDevName = devName;
pl._ovDid = did;
_ovDidToPL[did] = pl;
pl.on('click', () => _ovHighlightTrack(pl));
pl.on('mouseover', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 5 }); });
pl.on('mouseout', () => { if (pl !== _ovHighlightedPL) pl.setOptions({ strokeWeight: 3 }); });
_ovTrackMarkers.push(pl);
// Tag markers with their polyline for highlight grouping
deviceMarkers.forEach(m => { m._ovOwnerPL = pl; });
}
totalPoints += locs.length;
} catch (err) {
console.error(`Track load failed for ${devName}:`, err);
}
}
if (totalPoints === 0) { showToast('今天没有轨迹数据', 'info'); return; }
if (_ovTrackMarkers.length > 1) {
_overviewMap.setFitView(_ovTrackMarkers.filter(m => m.getPosition), false, [80,80,80,80]);
}
document.getElementById('ovBtnClearTrack').style.display = '';
document.getElementById('ovBtnTrack').innerHTML = `<i class="fas fa-route"></i> 轨迹 ${ids.length}台 (${totalPoints}点)`;
showToast(`已加载 ${ids.length} 台设备共 ${totalPoints} 个轨迹点`);
}
function _ovClearTrack() {
_ovTrackMarkers.forEach(m => m.setMap(null));
_ovTrackMarkers = [];
_ovHighlightedPL = null;
_ovDidToPL = {};
document.getElementById('ovBtnClearTrack').style.display = 'none';
document.getElementById('ovBtnTrack').innerHTML = '<i class="fas fa-route"></i> 显示轨迹';
}
function _ovToggleLP() {
_ovHideLP = !_ovHideLP;
const btn = document.getElementById('ovBtnHideLP');
if (_ovHideLP) {
btn.style.background = '#b91c1c'; btn.style.color = '#fff';
btn.innerHTML = '<i class="fas fa-eye-slash"></i> 低精度';
} else {
btn.style.background = ''; btn.style.color = '';
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
}
// Re-render track if active
if (_ovTrackMarkers.length) _ovShowTrack();
}
// ==================== LOCATIONS ====================
function initLocationMap() {
if (locationMap) return;
@@ -2308,12 +2915,14 @@
btn.style.color = '';
btn.innerHTML = '<i class="fas fa-eye"></i> 低精度';
}
// Re-apply to existing track markers
// Re-apply to existing track markers + polyline
_applyLowPrecisionFilter();
// Reload table with correct pagination
loadLocationRecords(1);
}
function _isLowPrecision(locationType) {
const t = (locationType || '').toLowerCase();
return t.startsWith('lbs') || t.startsWith('wifi');
return t.startsWith('lbs');
}
function _applyLowPrecisionFilter() {
// Toggle visibility of low-precision markers stored with _lpFlag
@@ -2413,14 +3022,18 @@
fillOpacity: isLbs ? 0.6 : 0.9,
zIndex: 120, cursor: 'pointer',
});
const isLP = (isLbs || isWifi) && !isFirst && !isLast;
const isLP = isLbs && !isFirst && !isLast;
marker._lpFlag = isLP;
if (_hideLowPrecision && isLP) marker._lpHidden = true;
else marker.setMap(locationMap);
const label = isFirst ? `起点 (1/${total})` : isLast ? `终点 (${i+1}/${total})` : `${i+1}/${total}`;
const content = _buildInfoContent(label, loc, lat, lng);
const infoWindow = new AMap.InfoWindow({ content, offset: new AMap.Pixel(0, -5) });
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
let _trackPinned = false;
marker.on('mouseover', () => { if (!_trackPinned) infoWindow.open(locationMap, [mLng, mLat]); });
marker.on('mouseout', () => { if (!_trackPinned) infoWindow.close(); });
marker.on('click', () => { _trackPinned = !_trackPinned; infoWindow.open(locationMap, [mLng, mLat]); });
infoWindow.on('close', () => { _trackPinned = false; });
mapMarkers.push(marker);
mapInfoWindows.push({ infoWindow, position: [mLng, mLat], locId: loc.id });
}
@@ -2433,8 +3046,7 @@
const filteredPath = _hideLowPrecision
? path.filter((_, i) => {
const lt = (locations[i]?.location_type || '').toLowerCase();
const isFirst = i === 0, isLast = i === locations.length - 1;
return isFirst || isLast || !(lt.startsWith('lbs') || lt.startsWith('wifi'));
return !lt.startsWith('lbs');
})
: path;
@@ -2521,7 +3133,11 @@
const infoContent = _buildInfoContent('实时定位', loc, lat, lng);
const infoWindow = new AMap.InfoWindow({ content: infoContent, offset: new AMap.Pixel(0, -30) });
infoWindow.open(locationMap, [mLng, mLat]);
marker.on('click', () => infoWindow.open(locationMap, [mLng, mLat]));
let _latestPinned = true; // pinned by default for latest position
marker.on('mouseover', () => { if (!_latestPinned) infoWindow.open(locationMap, [mLng, mLat]); });
marker.on('mouseout', () => { if (!_latestPinned) infoWindow.close(); });
marker.on('click', () => { _latestPinned = !_latestPinned; infoWindow.open(locationMap, [mLng, mLat]); });
infoWindow.on('close', () => { _latestPinned = false; });
mapMarkers.push(marker);
locationMap.setCenter([mLng, mLat]);
locationMap.setZoom(15);
@@ -2547,6 +3163,7 @@
let url = `${API_BASE}/locations?page=${p}&page_size=${ps}`;
if (deviceId) url += `&device_id=${deviceId}`;
if (locType) url += `&location_type=${locType}`;
if (_hideLowPrecision && !locType) url += `&exclude_type=lbs`;
if (startTime) url += `&start_time=${startTime}T00:00:00`;
if (endTime) url += `&end_time=${endTime}T23:59:59`;
@@ -2563,10 +3180,9 @@
tbody.innerHTML = items.map(l => {
const q = _locQuality(l);
const hasCoord = l.latitude != null && l.longitude != null;
const lpHide = _hideLowPrecision && _isLowPrecision(l.location_type);
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}${lpHide ? ';display:none' : ''}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
return `<tr data-loc-type="${l.location_type || ''}" style="cursor:${hasCoord?'pointer':'default'}" ${hasCoord ? `onclick="focusMapPoint(${l.id})"` : ''}>
<td onclick="event.stopPropagation()"><input type="checkbox" class="loc-sel-cb" value="${l.id}" onchange="updateLocSelCount()"></td>
<td class="font-mono text-xs">${escapeHtml(l.device_id || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(_imei(l.device_id))}</td>
<td>${_locTypeLabel(l.location_type)}</td>
<td>${l.latitude != null ? Number(l.latitude).toFixed(6) : '-'}</td>
<td>${l.longitude != null ? Number(l.longitude).toFixed(6) : '-'}</td>
@@ -2634,7 +3250,7 @@
tbody.innerHTML = items.map(a => `
<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="alarm-sel-cb" value="${a.id}" onchange="updateSelCount('alarm-sel-cb','alarmSelCount','btnBatchDeleteAlarm')"></td>
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
<td><span class="${alarmTypeClass(a.alarm_type)} font-semibold">${alarmTypeName(a.alarm_type)}</span></td>
<td>${({'single_fence':'<span style="color:#a855f7"><i class="fas fa-draw-polygon mr-1"></i>单围栏</span>','multi_fence':'<span style="color:#c084fc"><i class="fas fa-layer-group mr-1"></i>多围栏</span>','lbs':'<span style="color:#f97316"><i class="fas fa-broadcast-tower mr-1"></i>基站</span>','wifi':'<span style="color:#f59e0b"><i class="fas fa-wifi mr-1"></i>WiFi</span>'})[a.alarm_source] || escapeHtml(a.alarm_source || '-')}</td>
<td class="text-xs">${a.address ? escapeHtml(a.address) : (a.latitude != null ? Number(a.latitude).toFixed(6) + ', ' + Number(a.longitude).toFixed(6) : '-')}</td>
@@ -2743,7 +3359,7 @@
const srcColor = {'device':'#9ca3af','bluetooth':'#818cf8','fence':'#34d399'}[a.attendance_source] || '#9ca3af';
return `<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="att-sel-cb" value="${a.id}" onchange="updateSelCount('att-sel-cb','attSelCount','btnBatchDeleteAtt')"></td>
<td class="font-mono text-xs">${escapeHtml(a.device_id || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(_imei(a.device_id))}</td>
<td><span class="${a.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'} font-semibold">${attendanceTypeName(a.attendance_type)}</span></td>
<td style="color:${srcColor};font-size:12px">${srcLabel}</td>
<td class="text-xs">${locMethod} ${escapeHtml(posStr)}</td>
@@ -2813,7 +3429,7 @@
const attStr = b.attendance_type ? `<span class="${b.attendance_type === 'clock_in' ? 'text-green-400' : 'text-blue-400'}">${attendanceTypeName(b.attendance_type)}</span>` : '-';
return `<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="bt-sel-cb" value="${b.id}" onchange="updateSelCount('bt-sel-cb','btSelCount','btnBatchDeleteBt')"></td>
<td class="font-mono text-xs">${escapeHtml(b.device_id || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(_imei(b.device_id))}</td>
<td>${typeIcon} <span class="font-semibold">${typeName}</span></td>
<td class="font-mono text-xs">${escapeHtml(mac)}</td>
<td class="text-xs">${uuidLine}</td>
@@ -2970,8 +3586,7 @@
<td onclick="event.stopPropagation()"><input type="checkbox" class="log-sel-cb" value="${r.id}" onchange="updateSelCount('log-sel-cb','logSelCount','btnBatchDeleteLog')"></td>
<td class="font-mono text-xs">${r.id}</td>
<td>${typeBadge}</td>
<td>${r.device_id || '-'}</td>
<td class="font-mono text-xs">${escapeHtml(r.imei || (logDeviceMap[r.device_id] || {}).imei || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(r.imei || _imei(r.device_id))}</td>
<td class="text-xs" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(detail)}">${escapeHtml(detail)}</td>
<td class="font-mono text-xs">${coord}</td>
<td class="text-xs">${escapeHtml(addr)}</td>
@@ -4089,7 +4704,7 @@
tbody.innerHTML = items.map(c => `
<tr>
<td class="font-mono text-xs">${escapeHtml(c.id || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(c.device_id || c.imei || '-')}</td>
<td class="font-mono text-xs">${escapeHtml(_imei(c.device_id))}</td>
<td>${escapeHtml(c.command_type || '-')}</td>
<td class="text-xs" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.command_content || '')}">${escapeHtml(truncate(c.command_content || '-', 40))}</td>
<td>${commandStatusBadge(c.status)}</td>

View File

@@ -235,10 +235,11 @@ class PacketBuilder:
class ConnectionInfo:
"""Metadata about a single device TCP connection."""
__slots__ = ("imei", "addr", "connected_at", "last_activity", "serial_counter")
__slots__ = ("imei", "device_id", "addr", "connected_at", "last_activity", "serial_counter")
def __init__(self, addr: Tuple[str, int]) -> None:
self.imei: Optional[str] = None
self.device_id: Optional[int] = None
self.addr = addr
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
self.last_activity = self.connected_at
@@ -254,12 +255,16 @@ class ConnectionInfo:
# Helper: look up device_id from IMEI
# ---------------------------------------------------------------------------
async def _get_device_id(session, imei: str) -> Optional[int]:
"""Query the Device table and return the integer id for the given IMEI."""
async def _get_device_id(session, imei: str, conn_info: Optional["ConnectionInfo"] = None) -> Optional[int]:
"""Return the device id for the given IMEI, using ConnectionInfo cache if available."""
if conn_info is not None and conn_info.device_id is not None:
return conn_info.device_id
result = await session.execute(
select(Device.id).where(Device.imei == imei)
)
row = result.scalar_one_or_none()
if row is not None and conn_info is not None:
conn_info.device_id = row
return row
@@ -273,6 +278,7 @@ class TCPManager:
def __init__(self) -> None:
# {imei: (reader, writer, connection_info)}
self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {}
self._conn_lock = asyncio.Lock()
self._server: Optional[asyncio.AbstractServer] = None
# Protocol number -> handler coroutine mapping
@@ -316,11 +322,22 @@ class TCPManager:
conn_info = ConnectionInfo(addr)
logger.info("New TCP connection from %s:%d", addr[0], addr[1])
recv_buffer = b""
recv_buffer = bytearray()
try:
idle_timeout = settings.TCP_IDLE_TIMEOUT or None
while True:
data = await reader.read(4096)
try:
if idle_timeout:
data = await asyncio.wait_for(reader.read(4096), timeout=idle_timeout)
else:
data = await reader.read(4096)
except asyncio.TimeoutError:
logger.info(
"Idle timeout (%ds) for %s:%d (IMEI=%s), closing",
idle_timeout, addr[0], addr[1], conn_info.imei,
)
break
if not data:
logger.info(
"Connection closed by remote %s:%d (IMEI=%s)",
@@ -371,7 +388,7 @@ class TCPManager:
"Receive buffer overflow for IMEI=%s, discarding",
conn_info.imei,
)
recv_buffer = b""
recv_buffer = bytearray()
except asyncio.CancelledError:
logger.info(
@@ -402,14 +419,15 @@ class TCPManager:
) -> None:
"""Remove connection from tracking and update the device status."""
imei = conn_info.imei
if imei and imei in self.connections:
# Only remove if this is still the active connection (not replaced by reconnect)
_, stored_writer, _ = self.connections[imei]
if stored_writer is writer:
del self.connections[imei]
logger.info("Device IMEI=%s removed from active connections", imei)
else:
logger.info("Device IMEI=%s has reconnected, keeping new connection", imei)
async with self._conn_lock:
if imei and imei in self.connections:
# Only remove if this is still the active connection (not replaced by reconnect)
_, stored_writer, _ = self.connections[imei]
if stored_writer is writer:
del self.connections[imei]
logger.info("Device IMEI=%s removed from active connections", imei)
else:
logger.info("Device IMEI=%s has reconnected, keeping new connection", imei)
# Don't mark offline since device reconnected
try:
writer.close()
@@ -599,16 +617,16 @@ class TCPManager:
conn_info.imei = imei
# Close existing connection if device reconnects
old_conn = self.connections.get(imei)
if old_conn is not None:
_, old_writer, old_info = old_conn
logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1])
try:
old_writer.close()
except Exception:
pass
self.connections[imei] = (reader, writer, conn_info)
async with self._conn_lock:
old_conn = self.connections.get(imei)
if old_conn is not None:
_, old_writer, old_info = old_conn
logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1])
try:
old_writer.close()
except Exception:
pass
self.connections[imei] = (reader, writer, conn_info)
logger.info(
"Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1]
)
@@ -739,7 +757,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Heartbeat for unknown IMEI=%s", imei)
return
@@ -1046,22 +1064,29 @@ class TCPManager:
except Exception:
logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei)
# --- Reverse geocoding: coordinates -> address ---
address: Optional[str] = None
# --- Reverse geocoding (run concurrently with DB store below) ---
address_task = None
if latitude is not None and longitude is not None:
try:
address = await reverse_geocode(latitude, longitude)
except Exception:
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
address_task = asyncio.ensure_future(reverse_geocode(latitude, longitude))
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Location for unknown IMEI=%s", imei)
if address_task:
address_task.cancel()
return
# Await reverse geocode result if running
address: Optional[str] = None
if address_task:
try:
address = await address_task
except Exception:
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
record = LocationRecord(
device_id=device_id,
imei=conn_info.imei,
@@ -1086,41 +1111,35 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast to WebSocket subscribers
# --- Fence auto-attendance check (same session) ---
fence_events = []
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
try:
from app.services.fence_checker import check_device_fences
fence_events = await check_device_fences(
session, device_id, imei,
latitude, longitude, location_type,
address, recorded_at,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
)
except Exception:
logger.exception("Fence check failed for IMEI=%s", imei)
# Broadcast to WebSocket subscribers (after commit)
ws_manager.broadcast_nonblocking("location", {
"imei": imei, "device_id": device_id, "location_type": location_type,
"latitude": latitude, "longitude": longitude, "speed": speed,
"address": address, "recorded_at": str(recorded_at),
})
for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt)
ws_manager.broadcast_nonblocking("attendance", evt)
except Exception:
logger.exception(
"DB error storing %s location for IMEI=%s", location_type, imei
)
# --- Fence auto-attendance check ---
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
try:
from app.services.fence_checker import check_device_fences
async with async_session() as fence_session:
async with fence_session.begin():
device_id_for_fence = device_id
if device_id_for_fence is None:
# Resolve device_id if not available from above
device_id_for_fence = await _get_device_id(fence_session, imei)
if device_id_for_fence is not None:
fence_events = await check_device_fences(
fence_session, device_id_for_fence, imei,
latitude, longitude, location_type,
address, recorded_at,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
)
for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt)
ws_manager.broadcast_nonblocking("attendance", evt)
except Exception:
logger.exception("Fence check failed for IMEI=%s", imei)
return address
@staticmethod
@@ -1564,7 +1583,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Alarm for unknown IMEI=%s", imei)
return
@@ -1865,7 +1884,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Attendance for unknown IMEI=%s", imei)
return attendance_type, reserved_bytes, datetime_bytes
@@ -2001,7 +2020,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is not None:
# Look up beacon location from beacon_configs
beacon_lat = None
@@ -2206,7 +2225,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("BT location for unknown IMEI=%s", imei)
return
@@ -2390,7 +2409,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Command reply for unknown IMEI=%s", imei)
return
@@ -2439,20 +2458,17 @@ class TCPManager:
bool
``True`` if the command was successfully written to the socket.
"""
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
# Check if the writer is still alive
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
async with self._conn_lock:
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
# Build 0x80 online-command packet
# Payload: length(1) + server_flag(4) + content_bytes + language(2)
@@ -2497,19 +2513,17 @@ class TCPManager:
bool
``True`` if the message was successfully written to the socket.
"""
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
async with self._conn_lock:
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
msg_bytes = message.encode("utf-16-be")
server_flag = b"\x00\x00\x00\x00"

View File

@@ -62,17 +62,22 @@ class WebSocketManager:
ensure_ascii=False,
)
disconnected = []
# Snapshot dict to avoid RuntimeError from concurrent modification
for ws, topics in list(self.active_connections.items()):
if topic in topics:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
# Send to all subscribers concurrently with timeout
subscribers = [(ws, topics) for ws, topics in list(self.active_connections.items()) if topic in topics]
if not subscribers:
return
for ws in disconnected:
self.active_connections.pop(ws, None)
async def _safe_send(ws):
try:
await asyncio.wait_for(ws.send_text(message), timeout=3.0)
return None
except Exception:
return ws
results = await asyncio.gather(*[_safe_send(ws) for ws, _ in subscribers])
for ws in results:
if ws is not None:
self.active_connections.pop(ws, None)
def broadcast_nonblocking(self, topic: str, data: dict):
"""Fire-and-forget broadcast (used from TCP handler context)."""