File size: 12,807 Bytes
d646dd7
 
 
edae745
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edae745
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edae745
 
 
 
 
 
 
d646dd7
 
edae745
 
 
 
 
 
 
 
 
 
d646dd7
 
 
edae745
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
edae745
 
 
d646dd7
 
 
edae745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""Device heartbeat & polling API — 本地 JSON 存储(无数据库)。

设备存 data/state/devices.json;采集端轮询 /device/tasks 获取
active 项目列表(任务概念已移除,项目即采集任务);采集端「录制完成」的
增量上报存 data/state/device_task_progress.json(见 app/device_progress.py)。
"""

from __future__ import annotations

import asyncio
from datetime import datetime, timezone
from uuid import uuid4

from fastapi import APIRouter, Depends, Query

from app.localstore import (
    STATE_ROOT,
    cached_sessions_for_tasks,
    list_projects,
    scan_sessions,
)
from app import device_progress
from app.device_progress import reported_totals
from app.security import verify_api_key
from app.device_naming import decorate_device_sources, display_names_for_sources

router = APIRouter(prefix="/api/v1", tags=["devices"])

OFFLINE_THRESHOLD_SECONDS = 120
DEVICES_FILE = STATE_ROOT / "devices.json"


def utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _load_devices() -> list[dict]:
    from app.localstore import _read_json
    return _read_json(DEVICES_FILE, [])


def _save_devices(devices: list[dict]) -> None:
    from app.localstore import _write_json
    _write_json(DEVICES_FILE, devices)


def _device_status(dev: dict) -> str:
    last = dev.get("last_seen_at")
    if not last:
        return "unknown"
    try:
        elapsed = (utcnow() - datetime.fromisoformat(last)).total_seconds()
    except Exception:
        return "unknown"
    return "online" if elapsed <= OFFLINE_THRESHOLD_SECONDS else "offline"


# ── Heartbeat ─────────────────────────────────────────

@router.post("/devices/heartbeat")
async def device_heartbeat(body: dict, _: str = Depends(verify_api_key)):
    """采集端心跳。可携带采集能力(capabilities)驱动前端输入卡片:

    {
      "device_name": "DAQ-01",
      "capabilities": {"cameras": ["stereo_left", "stereo_right", "head_fisheye_rgb"],
                       "sensors": ["left_glove", "right_glove"]}
    }

    capabilities 可选:带则更新(采集端当前实际连接的硬件),不带则保持旧值。
    心跳间隔必须 < 120s,否则设备判定 offline、输入卡片不再显示。
    """
    devices = _load_devices()
    incoming_id = str(body.get("device_id") or "").strip()
    incoming_name = str(body.get("device_name") or "").strip()
    if not incoming_id and not incoming_name:
        from fastapi import HTTPException
        raise HTTPException(status_code=400, detail="device_id or device_name required")
    # device_id 是稳定身份;旧采集端只有 name 时继续兼容按 name 识别。
    dev = next((d for d in devices if incoming_id and d.get("device_id") == incoming_id), None)
    if dev is None:
        dev = next((d for d in devices if not incoming_id and d.get("name") == incoming_name), None)
    now = utcnow()
    if not dev:
        dev = {"id": str(uuid4()), "device_id": incoming_id or str(uuid4()),
               "name": incoming_name or incoming_id,
               "meta": body.get("meta"), "first_seen_at": now.isoformat()}
        devices.append(dev)
    elif incoming_name:
        # 改名不产生第二台逻辑设备,历史能力与项目绑定继续归属于同一 id。
        dev["name"] = incoming_name
    dev.setdefault("device_id", dev.get("id") or str(uuid4()))
    dev["last_seen_at"] = now.isoformat()
    if body.get("meta"):
        dev["meta"] = body.get("meta")
    # "capabilities" in body 判断:允许空 dict({} 表示拔光硬件,清空能力)
    if "capabilities" in body and body.get("capabilities") is not None:
        caps = body["capabilities"]
        if isinstance(caps, dict):
            dev["capabilities"] = {
                "cameras": _norm_cap_list(caps.get("cameras")),
                "sensors": _norm_cap_list(caps.get("sensors")),
            }
        else:
            dev["capabilities"] = {"cameras": _norm_cap_list(caps), "sensors": []}
    dev["status"] = "online"
    _save_devices(devices)
    return {"ok": True, "device_id": dev["device_id"],
            "device_name": dev["name"], "status": "online"}


# ── Online device capabilities → Studio 输入卡片 ──────

def _norm_cap_list(value) -> list[str]:
    """归一化能力列表:list[str] 直接返回;逗号分隔字符串按逗号拆分
    (采集端可能上报字符串而非列表,避免逐字符迭代)。"""
    if isinstance(value, str):
        return [v.strip() for v in value.split(",") if v.strip()]
    if isinstance(value, list):
        return [v for v in value if isinstance(v, str)]
    return []


def _online_device_capabilities() -> dict:
    """在线设备上报的采集能力 union(cameras + sensors)。"""
    cameras: set[str] = set()
    sensors: set[str] = set()
    devices: list[dict] = []
    for d in _load_devices():
        if _device_status(d) != "online":
            continue
        caps = d.get("capabilities")
        if isinstance(caps, dict):
            device_cameras = _norm_cap_list(caps.get("cameras"))
            device_sensors = _norm_cap_list(caps.get("sensors"))
            cameras.update(device_cameras)
            sensors.update(device_sensors)
        elif isinstance(caps, list):  # 兼容扁平列表
            device_cameras = _norm_cap_list(caps)
            device_sensors = []
            cameras.update(device_cameras)
        else:
            device_cameras, device_sensors = [], []
        devices.append({
            "device_id": d.get("device_id") or d.get("id"),
            "name": d.get("name"),
            "kind": d.get("kind") or (d.get("meta") or {}).get("kind"),
            "cameras": device_cameras,
            "sensors": device_sensors,
            "last_seen_at": d.get("last_seen_at"),
        })
    return {"cameras": sorted(cameras), "sensors": sorted(sensors), "devices": devices}


@router.get("/devices/input-sources")
def device_input_sources():
    """在线采集设备的可用输入源 → 驱动 Studio 输入面板(无 ?project= 时)。

    只聚合在线设备(120s 内心跳过)上报的 capabilities;模块匹配与
    项目 input-sources 共用 match_input_modules。
    """
    from app.processing.batch import match_input_modules
    from app.api.projects import _device_input_sources, _online_input_sources
    caps = _online_device_capabilities()
    episodes = scan_sessions()
    historical_sources = _device_input_sources(episodes)
    online_sources = _online_input_sources(caps.get("devices", []))
    sources_by_id = {str(source.get("id")): source for source in historical_sources}
    for source in online_sources:
        sources_by_id[str(source.get("id"))] = source
    device_sources = list(sources_by_id.values())
    decorate_device_sources(device_sources)
    camera_names = {
        str(camera).lower()
        for episode in episodes
        for camera in (episode.get("camera_names") or [])
        if not str(camera).lower().endswith("_aux")
    }
    sensors = {
        str(sensor).lower()
        for episode in episodes
        for sensor in (episode.get("sensors") or [])
    }
    available = camera_names | sensors
    available |= {c.lower() for c in caps["cameras"] if not c.lower().endswith("_aux")}
    available |= {s.lower() for s in caps["sensors"]}
    device_names = {
        str(stream): str(name)
        for episode in episodes
        for stream, name in (episode.get("device_names") or {}).items()
        if stream and name
    }
    device_display_names = display_names_for_sources(device_sources)
    devices_by_id = {
        str(device.get("key") or device.get("name")): device
        for episode in episodes
        for device in (episode.get("devices") or [])
        if isinstance(device, dict) and (device.get("key") or device.get("name"))
    }
    for device in caps.get("devices", []):
        devices_by_id[str(device.get("device_id") or device.get("name"))] = device
    return {
        "has_episodes": bool(episodes),
        "has_online_devices": bool(caps["cameras"] or caps["sensors"]),
        "cameras": caps["cameras"],
        "sensors": caps["sensors"],
        "device_names": device_names,
        "device_display_names": device_display_names,
        "devices": list(devices_by_id.values()),
        "device_sources": device_sources,
        "modules": match_input_modules(available),
    }


# ── Device list (Web UI) ──────────────────────────────

@router.get("/devices")
async def list_devices():
    devices = _load_devices()
    devices.sort(key=lambda d: d.get("last_seen_at") or "", reverse=True)
    return {"devices": [
        {
            "id": d.get("id"),
            "device_id": d.get("device_id") or d.get("id"),
            "name": d.get("name"),
            "status": _device_status(d),
            "meta": d.get("meta"),
            "first_seen_at": d.get("first_seen_at"),
            "last_seen_at": d.get("last_seen_at"),
            "capabilities": d.get("capabilities") or {"cameras": [], "sensors": []},
        }
        for d in devices
    ]}


# ── Device task polling(项目即采集任务)───────────────

@router.get("/device/tasks")
async def device_tasks(
    device_name: str = Query(..., min_length=1),
    _: str = Depends(verify_api_key),
):
    """采集端轮询此接口获取 active 项目(DAQ client 格式)。

    current_count 口径:各设备上报的录制完成数之和(见 app/device_progress.py);
    只有从未收到过上报的项目才回退到「项目目录下的 session 数」,让升级前就
    存在的历史项目不归零。session 是**上传**的产物,早先直接拿它当进度会让
    同一条数据被算两次(录制一次、上传一次)。
    """
    project_rows = await asyncio.to_thread(list_projects)
    projects = [p for p in project_rows if p.get("status", "active") == "active"]
    reported = await asyncio.to_thread(reported_totals)
    # 只有需要 session 兜底时才扫目录(用了上报口径的项目不再触发远程遍历)
    needs_sessions = device_progress.needs_session_fallback(projects, reported)
    episodes: list = []
    if needs_sessions:
        # Prefer the last complete snapshot.  A cache miss only happens during a
        # cold start, and even that first remote scan is kept off the event loop.
        episodes = cached_sessions_for_tasks()
        if episodes is None:
            episodes = await asyncio.to_thread(scan_sessions)

    tasks = []
    for p in projects:
        sessions = (sum(1 for e in episodes if e.get("project") == p.get("name"))
                    if needs_sessions else None)
        cur, source = device_progress.resolve_count(p, reported, sessions or 0)
        params = p.get("params") or {}
        if not isinstance(params, dict):
            params = {}
        tasks.append({
            "id": p.get("id"),
            "name": p.get("name"),
            "description": p.get("description"),
            "status": p.get("status", "active"),
            "total_required": int(params.get("target_episodes") or 0),
            "current_count": cur,
            "assigned_at": p.get("created_at"),
            "params": params,
            # 供 Web UI/排查用:进度口径与该项目实际已上传的 session 数
            "progress_source": source,
            "session_count": sessions,
        })

    return {"tasks": tasks, "updated_at": utcnow().isoformat()}


@router.post("/device/tasks/progress")
async def device_task_progress(body: dict | None = None,
                               _: str = Depends(verify_api_key)):
    """采集端「录制完成」增量上报(幂等)。

    body::

        {"task_id": "<项目 id>", "device_name": "EGO_001",
         "session_id": "EGO_001:<task_id>:<本地水位>", "increment": 1}

    返回 ``{"completed_count": <该项目最新进度>}`` —— 采集端把这个数当后端
    权威数落进自己的分账(backend_count)。同一 (device_name, session_id)
    重放不重复计数:采集端崩溃/断网后重发同一个水位是安全的。

    错误:参数缺失/非法、任务不存在 → 400。**刻意不用 404** —— 采集端把 404
    当成「后端还没实现该端点」并永久降级(整机不再上报任何进度)。
    """
    from fastapi import HTTPException

    projects = await asyncio.to_thread(list_projects)
    known = {str(p.get("id") or "") for p in projects}
    try:
        return await asyncio.to_thread(
            device_progress.apply_report, body or {}, known)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))