From e7c835427cab6ddb742c20db18dbcb2234a3edfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Wed, 1 Apr 2026 20:16:45 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=97=A8=E7=A6=81=E5=8D=95=E6=B5=8B=E8=AF=AF=E4=BC=A4?= =?UTF-8?q?=20asyncio=20=E4=B8=8E=E9=97=B4=E9=9A=94=E6=8A=96=E5=8A=A8=20/?= =?UTF-8?q?=20Fix=20realtime=20timeout=20test=20breaking=20asyncio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不再 patch 全局 time.monotonic(会破坏 wait_for 截止时间)/ Avoid patching global monotonic used by asyncio.wait_for - 慢路径 sleep 长于外层下限,避免与 0.2s 超时竞态 / Use slower worker than outer min timeout - 第二次请求前重置 file_upload 门禁 last_finished,避免依赖墙钟间隔 / Reset gate timestamp for deterministic follow-up Made-with: Cursor --- tests/unit/test_analysis_api.py | 40 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index 02132ec..068ee98 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -438,7 +438,9 @@ def test_analysis_realtime_timeout_releases_gate( gate.last_finished_mono = 0.0 def _slow(*_args, **_kwargs): - time.sleep(0.2) + # 外层 wait_for 下限为 0.2s;线程若仅睡 0.2s 可能与超时边界竞态 / Outer wait_for is + # at least 0.2s; a 0.2s worker sleep can race the deadline. + time.sleep(0.5) return { "frame_index": 0, "status": "MATCH_FOUND", @@ -458,6 +460,15 @@ def _slow(*_args, **_kwargs): data = resp.json() assert data.get("gate_status") == "TIMEOUT_RELEASED" + # 勿 patch 全局 time.monotonic:asyncio.wait_for 依赖它推进截止时间 / Do not patch + # time.monotonic globally — asyncio.wait_for uses it for deadlines. + # 仅重置门禁时间戳,使第二次请求不依赖「墙钟已过最小间隔」/ Reset gate timestamp so + # the follow-up request does not rely on wall-clock interval elapsed. + gate_after = analysis_service._realtime_gate_states.get("file_upload") + assert gate_after is not None + assert gate_after.in_flight is False + gate_after.last_finished_mono = 0.0 + def _fast(*_args, **_kwargs): return { "frame_index": 0, @@ -468,25 +479,14 @@ def _fast(*_args, **_kwargs): } monkeypatch.setattr(analysis_service, "_solve_bgr_to_row", _fast) - deadline = time.time() + 1.0 - final_status = None - while time.time() < deadline: - with image_path.open("rb") as f: - resp2 = client.post( - "/api/analysis/solve/frame_upload", - files={"file": ("frame.jpg", f, "image/jpeg")}, - data={"payload": json.dumps({"solve_interval_ms": 50})}, - ) - assert resp2.status_code == 200 - final_status = resp2.json().get("gate_status") - if final_status == "SOLVED": - break - if final_status == "SKIPPED_INTERVAL": - time.sleep(0.02) - continue - break - - assert final_status == "SOLVED" + with image_path.open("rb") as f: + resp2 = client.post( + "/api/analysis/solve/frame_upload", + files={"file": ("frame.jpg", f, "image/jpeg")}, + data={"payload": json.dumps({"solve_interval_ms": 50})}, + ) + assert resp2.status_code == 200 + assert resp2.json().get("gate_status") == "SOLVED" @pytest.mark.unit From 7a18b1c9d69d7e3c4900b5dea9c79aaf0e10837a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Wed, 1 Apr 2026 23:17:08 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E8=B0=83=E8=AF=95=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20MJPEG=20=E4=B8=8E=20native=20=E7=9B=B4=E9=87=87?= =?UTF-8?q?=E9=99=8D=E5=86=85=E5=AD=98=20/=20Debug=20MJPEG=20preview=20and?= =?UTF-8?q?=20native=20capture=20to=20cut=20RAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - native 模式按输出分辨率采集,默认关闭超采样路径大帧缓冲 / Native mode captures at output size; avoid full-sensor buffers when supersample off - 共享帧默认不常驻 raw,分析按需同步抓帧 / Shared bus skips retaining raw by default; analysis sync-grabs when needed - 预览接口仅 JPEG 缓存,移除 raw 再编码兜底 / Preview uses JPEG cache only; remove raw re-encode fallback - 调试页改用 MJPEG 单连接,stream 与共享 FPS 对齐 / Debug UI uses single MJPEG; stream paced to shared preview FPS - 调试单帧预览按客户端限流(可配间隔)/ Per-client throttle for debug single-frame preview - 补充 systemd/OOM 与相关环境变量文档 / Add systemd/OOM and env var notes Made-with: Cursor --- docs/development/ogscope-service-hardening.md | 36 +++++++ ogscope/hardware/camera.py | 19 +++- ogscope/web/api/debug/routes.py | 49 ++++++++- ogscope/web/api/debug/services.py | 33 +++---- ogscope/web/camera_shared.py | 35 +++++-- web/static/js/debug.js | 99 +++++-------------- 6 files changed, 160 insertions(+), 111 deletions(-) create mode 100644 docs/development/ogscope-service-hardening.md diff --git a/docs/development/ogscope-service-hardening.md b/docs/development/ogscope-service-hardening.md new file mode 100644 index 0000000..4aa6eef --- /dev/null +++ b/docs/development/ogscope-service-hardening.md @@ -0,0 +1,36 @@ +# OGScope 服务稳定性与内存防护 / Service stability and memory guard + +## systemd 建议(示例,按板子内存调整)/ systemd suggestions (tune MemoryMax per board) + +在单元文件 `[Service]` 段可考虑: + +- `Restart=always`:进程异常退出后自动拉起 / Auto-restart after exit +- `RestartSec=3`:避免崩溃重启风暴 / Back off between restarts +- `MemoryMax=400M`(示例):限制单服务 RSS,降低拖死整机的概率(Zero2W 请按实际调)/ Cap service RSS to reduce OOM risk + +示例片段 / Example snippet: + +```ini +[Service] +Restart=always +RestartSec=3 +# MemoryMax=400M +``` + +## OOM 观测 / OOM observation + +在设备上可快速确认是否被 OOM killer 终止: + +```bash +sudo journalctl -k -b | grep -i -E 'oom|killed process|Out of memory' +sudo dmesg -T | grep -i -E 'oom|killed process' +``` + +## 与预览相关的环境变量 / Preview-related environment variables + +| 变量 / Variable | 含义 / Meaning | +|-----------------|----------------| +| `OGSCOPE_PREVIEW_JPEG_QUALITY` | 共享预览 JPEG 质量(与调试 MJPEG 默认质量一致)/ Shared preview JPEG quality | +| `OGSCOPE_SHARED_PREVIEW_FPS` | 共享抓帧与 MJPEG 推送目标帧率 / Shared grabber and MJPEG pacing FPS | +| `OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS` | 调试「单帧预览」接口每客户端最小间隔(毫秒);过短返回 304 / Min interval for `/api/debug/camera/preview` per client | +| `OGSCOPE_KEEP_RAW_CACHE` | `1` 时在共享管理器中常驻 `_latest_raw`;默认 `0` 以省内存 / Retain raw frame cache when `1` | diff --git a/ogscope/hardware/camera.py b/ogscope/hardware/camera.py index 3ad7931..219a21a 100644 --- a/ogscope/hardware/camera.py +++ b/ogscope/hardware/camera.py @@ -89,7 +89,7 @@ def __init__(self, config: dict[str, Any]): self.white_balance_gain_b = config.get("white_balance_gain_b", 1.0) # 采样模式与尺寸(supersample: 采集分辨率可高于输出分辨率) / Sampling mode and size (supersample: acquisition resolution can be higher than output resolution) self.sampling_mode = config.get( - "sampling_mode", "supersample" + "sampling_mode", "native" ) # supersample | native | crop ( self.sampling_mode, @@ -245,13 +245,22 @@ def _resolve_sampling_layout( ) if mode not in {"supersample", "native", "crop"}: mode = "native" - capture_w = self.SENSOR_MAX_WIDTH - capture_h = self.SENSOR_MAX_HEIGHT + if mode == "supersample": + capture_w = self.SENSOR_MAX_WIDTH + capture_h = self.SENSOR_MAX_HEIGHT + else: + # 关闭超采样时按输出尺寸采集,减少大帧常驻与重采样开销 + # Capture at output size when supersample is off to reduce RAM and resize cost. + capture_w = output_width + capture_h = output_height if mode == "supersample" and ( - output_width >= capture_w or output_height >= capture_h + output_width >= self.SENSOR_MAX_WIDTH + or output_height >= self.SENSOR_MAX_HEIGHT ): logger.warning("当前分辨率下超采样无有效增益,自动切换为 native 模式") mode = "native" + capture_w = output_width + capture_h = output_height return mode, capture_w, capture_h, output_width, output_height def _resize_preserve_fov( @@ -425,7 +434,7 @@ def capture_image(self) -> Optional[np.ndarray]: # 暂时返回原始数据 / Temporarily return to original data pass - # 输出重采样(保持最大视野) / Output resampling (preserve maximum field of view) + # 输出重采样(仅当采集与输出不一致) / Output resampling only when capture/output differ try: if (self.output_width, self.output_height) != ( image.shape[1], diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index d81614e..fd74fb0 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -3,9 +3,11 @@ """ import asyncio +import os +import time -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse, StreamingResponse +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import FileResponse, Response, StreamingResponse from ogscope.core.realtime import realtime_solve_service from ogscope.web.api.debug.services import ( @@ -17,6 +19,13 @@ router = APIRouter() +_DEFAULT_PREVIEW_JPEG_QUALITY = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) +_PREVIEW_CLIENT_LAST_TS: dict[str, float] = {} +_DEBUG_PREVIEW_MIN_INTERVAL_SEC = max( + 0.0, + float(os.getenv("OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS", "150") or "150") / 1000.0, +) + # ==================== 相机控制 ==================== / ==================== Camera Control ==================== @@ -67,13 +76,19 @@ async def start_debug_camera(): @router.get("/debug/camera/stream") -async def stream_debug_camera(quality: int = Query(70, ge=10, le=100)): +async def stream_debug_camera( + quality: int = Query(_DEFAULT_PREVIEW_JPEG_QUALITY, ge=10, le=100), +): """MJPEG 实时流 - 可配置压缩质量 / MJPEG live streaming - configurable compression quality""" try: boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): last_frame_id = -1 + last_emit_mono = 0.0 while True: code, data, frame_id = await DebugCameraService.get_stream_frame_bytes( "jpeg", quality @@ -84,7 +99,12 @@ async def frame_generator(): if frame_id == last_frame_id: await asyncio.sleep(0.03) continue + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) last_frame_id = frame_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/jpeg\r\n" @@ -110,9 +130,13 @@ async def stream_debug_camera_lossless(): """无损质量实时流 - 使用PNG格式展示超采样效果 / Lossless quality live streaming - using PNG format to demonstrate supersampling effects""" try: boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): last_frame_id = -1 + last_emit_mono = 0.0 while True: code, data, frame_id = await DebugCameraService.get_stream_frame_bytes( "png", 100 @@ -123,7 +147,12 @@ async def frame_generator(): if frame_id == last_frame_id: await asyncio.sleep(0.03) continue + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) last_frame_id = frame_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/png\r\n" @@ -166,10 +195,22 @@ async def set_camera_rotation(rotation: int): @router.get("/debug/camera/preview") -async def get_debug_camera_preview(since_frame_id: int | None = Query(default=None)): +async def get_debug_camera_preview( + request: Request, + since_frame_id: int | None = Query(default=None), +): """获取调试相机预览 / Get debug camera preview""" try: + if _DEBUG_PREVIEW_MIN_INTERVAL_SEC > 0: + client_host = request.client.host if request.client else "unknown" + now = time.monotonic() + last = _PREVIEW_CLIENT_LAST_TS.get(client_host, 0.0) + if now - last < _DEBUG_PREVIEW_MIN_INTERVAL_SEC: + return Response(status_code=304) + _PREVIEW_CLIENT_LAST_TS[client_host] = now return await DebugCameraService.get_preview(since_frame_id=since_frame_id) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index 2927f3a..2f6799b 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import Any, Optional +from fastapi import HTTPException + from ogscope.web.camera_shared import get_camera_manager # 调试控制台相关 / Debug console related @@ -333,21 +335,11 @@ async def get_preview(since_frame_id: int | None = None): if code == 304: return Response(status_code=304) if code != 200 or frame is None or frame.jpeg_frame is None: - # 首帧兜底:直接抓一帧并编码,避免前端启动后长时间黑屏 - # First-frame fallback: grab one frame immediately to avoid prolonged black screen. - raw, frame_id, frame_ts = await manager.get_raw_frame() - jpeg = await asyncio.to_thread(manager.encode_frame, raw, "jpeg", 75) - if jpeg is None: - raise Exception("暂无预览帧") - return Response( - content=jpeg, - media_type="image/jpeg", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "X-Frame-Id": str(frame_id), - "X-Frame-Ts": str(frame_ts), - }, + # 预览仅消费共享 JPEG 缓存,避免触发 raw 抓取与二次编码导致内存尖峰 + # Preview consumes shared JPEG cache only; avoid raw grab + re-encode spikes. + raise HTTPException( + status_code=503, + detail="暂无预览帧,请稍后重试 / No preview frame yet, retry shortly", ) return Response( content=frame.jpeg_frame, @@ -370,16 +362,19 @@ async def get_stream_frame_bytes( manager = get_camera_manager() await manager.ensure_started() snap = await manager.get_cached_frame_snapshot() - if snap is None or snap.raw_frame is None: + if snap is None: return 503, None, 0 if image_format.lower() == "jpeg" and snap.jpeg_frame is not None: return 200, snap.jpeg_frame, snap.frame_id + # PNG 或自定义质量 JPEG:按需从相机同步一帧,避免依赖常驻 raw 缓存 + # PNG or custom-quality JPEG: sync grab one frame on demand. + raw, fid, _ts = await manager.get_raw_frame() encoded = await asyncio.to_thread( - manager.encode_frame, snap.raw_frame, image_format, int(quality) + manager.encode_frame, raw, image_format, int(quality) ) if encoded is None: - return 500, None, snap.frame_id - return 200, encoded, snap.frame_id + return 500, None, int(fid) + return 200, encoded, int(fid) @staticmethod async def capture_image(): diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index a5d0ea2..cdce981 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -43,6 +43,11 @@ def __init__(self) -> None: self._runtime_overrides: dict[str, Any] = {} self._jpeg_quality = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) self._target_fps = max(1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8"))) + # 是否常驻 raw 帧缓存;默认关闭以降低内存占用(分析路径可同步抓帧) + # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). + self._keep_raw_cache = bool( + int(os.getenv("OGSCOPE_KEEP_RAW_CACHE", "0") or "0") + ) self._logger = logging.getLogger(__name__) def _build_base_config(self) -> dict[str, Any]: @@ -203,7 +208,9 @@ async def _grabber_loop(self) -> None: w = int(getattr(frame, "shape", [0, 0])[1] or 0) with self._frame_lock: self._frame_id += 1 - self._latest_raw = frame + # 默认不保留 raw,避免与 JPEG 双份常驻;需要时设 OGSCOPE_KEEP_RAW_CACHE=1 + # By default do not retain raw to avoid dual large buffers; set env to keep. + self._latest_raw = frame if self._keep_raw_cache else None self._latest_jpeg = jpeg self._latest_ts = time.time() self._latest_w = w @@ -271,13 +278,25 @@ async def get_raw_frame(self) -> tuple[Any, int, float]: """读取分析帧 / Get frame for analysis.""" await self.ensure_started() with self._frame_lock: - if self._latest_raw is None: - raise RuntimeError("无可用视频帧 / No frame available") - try: - frame = self._latest_raw.copy() - except Exception: - frame = self._latest_raw - return frame, self._frame_id, self._latest_ts + if self._latest_raw is not None: + try: + frame = self._latest_raw.copy() + except Exception: + frame = self._latest_raw + return frame, self._frame_id, self._latest_ts + # 无常驻 raw 时同步抓一帧,供解算使用(不写入 _latest_raw,除非开启 keep cache) + # Sync-grab when raw cache is disabled; avoids breaking analysis while saving RAM. + frame = await asyncio.to_thread(self._read_frame_sync) + if frame is None: + raise RuntimeError("无可用视频帧 / No frame available") + with self._frame_lock: + fid = self._frame_id + ts = self._latest_ts + try: + out = frame.copy() + except Exception: + out = frame + return out, fid, ts async def get_cached_frame_snapshot(self) -> SharedFrame | None: """读取当前缓存帧快照(不触发 ensure)/ Read cached snapshot without ensure.""" diff --git a/web/static/js/debug.js b/web/static/js/debug.js index ca26969..362696a 100644 --- a/web/static/js/debug.js +++ b/web/static/js/debug.js @@ -39,6 +39,8 @@ class DebugConsole { this.statusInterval = null; this.systemInfoInterval = null; this.previewObjectUrl = null; + /** 上一帧 X-Frame-Id,用于 since_frame_id 去重 / Last X-Frame-Id for conditional GET */ + this.lastPreviewFrameId = null; this.systemInfo = null; this.statusErrorCount = 0; this.statusLastNotifyTs = 0; @@ -1536,93 +1538,38 @@ class DebugConsole { overlay.classList.add('hidden'); this.resetStreamStats(); - // 使用单次请求循环(避免并发取消):每次等上一帧 onload / Use a single request loop (to avoid concurrent cancellation): wait for the previous frame onload each time + // 单连接 MJPEG 流,避免每帧 HTTP 轮询带来的内存抖动(Zero2W) + // Single MJPEG connection avoids per-frame HTTP polling jitter on Zero2W. this.previewActive = true; - // 提高预览帧率到15fps以获得更流畅的体验 / Increase the preview frame rate to 15fps for a smoother experience - const fps = 15; - const intervalMs = Math.max(1000 / fps, 50); + this.lastPreviewFrameId = null; let consecutiveFailures = 0; - let frameToken = 0; - let firstFrameAttempts = 0; - const maxFirstFrameAttempts = 10; // 前10次请求使用更短间隔 / Use shorter intervals for the first 10 requests - - const loop = async () => { + const attachStream = () => { if (!this.previewActive) return; - const startedAt = performance.now(); - const myToken = ++frameToken; - - // 增加第一帧尝试计数 / Increase first frame attempt count - if (firstFrameAttempts < maxFirstFrameAttempts) { - firstFrameAttempts++; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1000); - try { - const response = await fetch(`/api/debug/camera/preview?t=${Date.now()}`, { - cache: 'no-store', - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`preview status=${response.status}`); - } - - const frameIdRaw = response.headers.get('X-Frame-Id'); - const frameId = frameIdRaw !== null ? parseInt(frameIdRaw, 10) : null; - const frameTsRaw = response.headers.get('X-Frame-Ts'); - const frameTs = frameTsRaw !== null ? parseFloat(frameTsRaw) : null; - const blob = await response.blob(); - - const objectUrl = URL.createObjectURL(blob); - const loader = new Image(); - loader.onload = () => { - if (!this.previewActive || myToken !== frameToken) { - URL.revokeObjectURL(objectUrl); - return; - } - - if (this.previewObjectUrl) { - URL.revokeObjectURL(this.previewObjectUrl); - } - this.previewObjectUrl = objectUrl; - previewImg.src = objectUrl; - - this.analyzeStreamData(loader, { - frameId: Number.isFinite(frameId) ? frameId : null, - frameTs: Number.isFinite(frameTs) ? frameTs : null, - sizeBytes: blob.size, + previewImg.onload = () => { + if (!this.previewActive) return; + this.analyzeStreamData(previewImg, { + frameId: null, + frameTs: null, + sizeBytes: 0, }); - this.updateHistogramFromImage(loader); - + this.updateHistogramFromImage(previewImg); consecutiveFailures = 0; - - if (firstFrameAttempts < maxFirstFrameAttempts) { - firstFrameAttempts = maxFirstFrameAttempts; - console.log(`[Preview] 第一帧获取成功,耗时 ${(performance.now() - startedAt).toFixed(1)}ms`); - } - - const elapsed = performance.now() - startedAt; - const currentInterval = firstFrameAttempts < maxFirstFrameAttempts ? 100 : intervalMs; - const delay = Math.max(0, currentInterval - elapsed); - this.previewTimer = setTimeout(loop, delay); }; - loader.onerror = () => { - URL.revokeObjectURL(objectUrl); + previewImg.onerror = () => { + if (!this.previewActive) return; consecutiveFailures++; - const retryDelay = Math.min(1000, 200 + consecutiveFailures * 200); - this.previewTimer = setTimeout(loop, retryDelay); + const retryDelay = Math.min(2000, 300 + consecutiveFailures * 300); + this.previewTimer = setTimeout(attachStream, retryDelay); }; - loader.src = objectUrl; - } catch (error) { + previewImg.src = `/api/debug/camera/stream?t=${Date.now()}`; + } catch (_e) { consecutiveFailures++; - const retryDelay = Math.min(1000, 200 + consecutiveFailures * 200); - this.previewTimer = setTimeout(loop, retryDelay); - } finally { - clearTimeout(timeoutId); + const retryDelay = Math.min(2000, 300 + consecutiveFailures * 300); + this.previewTimer = setTimeout(attachStream, retryDelay); } }; - loop(); + attachStream(); // 看门狗:若2秒未收到帧,强制刷新状态(更敏感的检测) / Watchdog: If no frame is received for 2 seconds, force refresh status (more sensitive detection) this.previewWatchdog = setInterval(() => { @@ -1655,10 +1602,12 @@ class DebugConsole { URL.revokeObjectURL(this.previewObjectUrl); this.previewObjectUrl = null; } + this.lastPreviewFrameId = null; // 复位预览图片 / Reset preview image const previewImg = document.getElementById('preview-image'); if (previewImg) { try { previewImg.onload = null; previewImg.onerror = null; } catch(_){} + previewImg.removeAttribute('src'); previewImg.src = '/static/images/placeholder-camera.png'; } // 显示覆盖层 / Show overlay From 9bb7c56fbbe450f762444186c4799d3330d586b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Wed, 1 Apr 2026 23:18:36 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20WiFi=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=8E=E8=B0=83=E8=AF=95=E7=B3=BB=E7=BB=9F=E9=A1=B5=E5=8F=8A?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=20API=20/=20WiFi=20switch,=20debug=20system?= =?UTF-8?q?=20page,=20network=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NetworkManager WiFi STA/AP 切换脚本与 API / Add nmcli WiFi switch script and REST API - 紧急 GPIO 与 gpio_config 扩展 / Emergency GPIO hook and gpio_config extensions - 调试系统监控静态页与模板 / Debug system monitor assets and template - 配置、路由、schemas 与安装/镜像脚本更新 / Config, routes, schemas, install/mirror scripts - wifi-nm 文档与 CLAUDE 说明 / wifi-nm doc and CLAUDE notes - 分析 API 与 WiFi 单元测试 / Analysis API and WiFi unit tests Made-with: Cursor --- .gitignore | 2 +- CLAUDE.md | 12 ++- Makefile | 6 +- docs/development/README.md | 6 +- docs/development/README_EN.md | 4 +- docs/development/plate-solve-data.md | 2 +- docs/development/wifi-nm.md | 45 +++++++++ ogscope/__init__.py | 2 +- ogscope/config.py | 50 ++++++++++ ogscope/hardware/gpio_config.py | 13 ++- ogscope/hardware/wifi_emergency_gpio.py | 124 ++++++++++++++++++++++++ ogscope/hardware/wifi_switch.py | 115 ++++++++++++++++++++++ ogscope/web/api/main.py | 2 + ogscope/web/api/models/schemas.py | 20 ++++ ogscope/web/api/network/__init__.py | 3 + ogscope/web/api/network/routes.py | 65 +++++++++++++ ogscope/web/app.py | 33 +++++++ scripts/install.sh | 4 +- scripts/mirror.sh | 2 +- scripts/ogscope-wifi-switch.sh | 84 ++++++++++++++++ tests/unit/test_analysis_api.py | 12 +-- tests/unit/test_wifi_switch.py | 121 +++++++++++++++++++++++ web/static/css/debug-system.css | 77 +++++++++++++++ web/static/js/debug-system.js | 79 +++++++++++++++ web/templates/debug.html | 1 + web/templates/debug_system.html | 39 ++++++++ 26 files changed, 896 insertions(+), 27 deletions(-) create mode 100644 docs/development/wifi-nm.md create mode 100644 ogscope/hardware/wifi_emergency_gpio.py create mode 100644 ogscope/hardware/wifi_switch.py create mode 100644 ogscope/web/api/network/__init__.py create mode 100644 ogscope/web/api/network/routes.py create mode 100755 scripts/ogscope-wifi-switch.sh create mode 100644 tests/unit/test_wifi_switch.py create mode 100644 web/static/css/debug-system.css create mode 100644 web/static/js/debug-system.js create mode 100644 web/templates/debug_system.html diff --git a/.gitignore b/.gitignore index 94b2093..e6f85af 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ web/analysis-ui/node_modules/ # ==================== # Poetry # ==================== -poetry.lock # 在开发机和 Orange Pi 上可能不一致,建议忽略 +poetry.lock # 在开发机与树莓派上可能不一致,建议忽略 .poetry/ # ==================== diff --git a/CLAUDE.md b/CLAUDE.md index 3f7eed5..ca4ddaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ ## 项目概述 -OGScope 是一个基于 Orange Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 +OGScope 是一个基于 Raspberry Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 ## 技术栈 -- **硬件**: Orange Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD +- **硬件**: Raspberry Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD - **语言**: Python 3.9+ - **包管理**: Poetry - **Web 框架**: FastAPI + Uvicorn @@ -93,7 +93,7 @@ ogscope/ - **虚拟环境**: Poetry 管理 ### 部署配置 -- **生产环境**: Orange Pi Zero 2W 开发板 +- **生产环境**: Raspberry Pi Zero 2W 开发板 - **测试环境**: [与生产环境相同] - **虚拟环境目录**: [用户自定义] @@ -123,6 +123,12 @@ WantedBy=multi-user.target 在本地运行时,使用虚拟环境,因为有些硬件只能在开发板上调用,所以本地只是代码编写,远程测试 +### WiFi 模式(NetworkManager) + +- 项目提供 `scripts/ogscope-wifi-switch.sh`,通过 `nmcli` 在 **STA(连路由器)** 与 **AP(热点 + 固定网关如 192.168.4.1)** 间切换;需在板上创建两个 NM 连接并配置 `OGSCOPE_WIFI_STA_CONNECTION`、`OGSCOPE_WIFI_AP_CONNECTION` 等环境变量。 +- 将脚本安装到 `/usr/local/bin/ogscope-wifi-switch` 后,在 **sudoers** 中为运行用户配置免密执行该绝对路径(见脚本头部注释)。 +- AP 与 STA **互斥**;STA 模式使用 DHCP,不会残留 AP 的静态地址。 + **重要说明**: - 系统库(如 `libcamera`、`picamera2`)安装在系统环境中,通过 `PYTHONPATH` 环境变量注入到虚拟环境 - `LD_LIBRARY_PATH` 确保系统库的链接库路径正确 diff --git a/Makefile b/Makefile index 7b5509a..522dd3c 100644 --- a/Makefile +++ b/Makefile @@ -54,8 +54,8 @@ lock: ## 锁定依赖版本 build: ## 构建包 poetry build -deploy: ## 部署到 Orange Pi(需要配置 SSH) - @echo "同步代码到 Orange Pi..." +deploy: ## 部署到 Raspberry Pi(需要配置 SSH 主机别名) + @echo "同步代码到 Raspberry Pi..." rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ --exclude '.venv' --exclude 'PiFinder-release' \ . orangepi:~/OGScope/ @@ -69,7 +69,7 @@ logs: ## 查看日志 status: ## 查看服务状态 ssh orangepi "sudo systemctl status ogscope" -ssh: ## SSH 到 Orange Pi +ssh: ## SSH 到 Raspberry Pi(主机名见 Makefile 中 rsync 目标) ssh orangepi docs: ## 生成文档(如果使用 Sphinx) diff --git a/docs/development/README.md b/docs/development/README.md index dd98f80..04746be 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -2,7 +2,7 @@ 中文 | [English](README_EN.md) -本文档面向项目成员与协作者,说明 OGScope 在开发板(Raspberry Pi / Orange Pi)环境中的实际运行方式、依赖要求与标准调试流程。 +本文档面向项目成员与协作者,说明 OGScope 在 **Raspberry Pi Zero 2W** 等开发板环境中的实际运行方式、依赖要求与标准调试流程。 测试实践请见:[测试指南](testing-guide.md)。 @@ -15,7 +15,7 @@ ### 0.1 系统要求 -- 单板:**ARM**(`aarch64` 或 `armhf`),如 Raspberry Pi / Orange Pi +- 单板:**ARM**(`aarch64` 或 `armhf`),推荐 **Raspberry Pi Zero 2W** - 系统:**Debian/apt** 系镜像(与 `picamera2`/`libcamera` 文档一致;脚本会读 `/etc/os-release`,见 **§1.4**) - Python:**3.10+**(以 `pyproject.toml` 为准) - 网络:首次安装需拉取依赖;浏览器访问 Web 需可达设备 **TCP 8000**(按需防火墙放行) @@ -99,7 +99,7 @@ poetry install 仓库提供 `scripts/install.sh`,用于在开发板执行一次性环境准备。脚本会: -- 读取 `/etc/os-release` 识别发行版,**仅支持 Debian/Ubuntu 系**(含 **Raspberry Pi OS**、Orange Pi Debian 等);非该系将退出,避免误改软件源 +- 读取 `/etc/os-release` 识别发行版,**仅支持 Debian/Ubuntu 系**(含 **Raspberry Pi OS**);非该系将退出,避免误改软件源 - 安装系统依赖与 Poetry - 配置 Poetry 使用项目 `.venv` 与 `system-site-packages`(Poetry 版本支持时) - 默认执行 `poetry install --only main`(设 `OGSCOPE_INSTALL_DEV=1` 可装 dev) diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 8f830fc..8e01781 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -3,7 +3,7 @@ [中文](README.md) | English This document explains how OGScope is actually run on development boards -(Raspberry Pi / Orange Pi), including dependency requirements, service startup, +(primarily **Raspberry Pi Zero 2W**), including dependency requirements, service startup, and the team-standard debug workflow. For testing workflow, see [Testing Guide](testing-guide.md). @@ -18,7 +18,7 @@ This section lists **common commands and checks only**. For Poetry/PEP 668, mirr ### 0.1 Requirements -- Board: **ARM** (`aarch64` or `armhf`), e.g. Raspberry Pi / Orange Pi +- Board: **ARM** (`aarch64` or `armhf`), e.g. Raspberry Pi Zero 2W - OS: **Debian/apt**-based images (compatible with `picamera2`/`libcamera`; install script reads `/etc/os-release`, see **§1.4**) - Python: **3.10+** (see `pyproject.toml`) - Network: first install downloads dependencies; Web UI needs **TCP 8000** reachable (configure firewall as needed) diff --git a/docs/development/plate-solve-data.md b/docs/development/plate-solve-data.md index 25caac8..9936d05 100644 --- a/docs/development/plate-solve-data.md +++ b/docs/development/plate-solve-data.md @@ -55,5 +55,5 @@ ## 7. 性能提示 / Performance -- Orange Pi 等资源受限设备:可适当**降低分辨率**、限制 `solver_max_stars`、拉大 `solver_fullsolve_interval_frames`(实时模式)。 +- Raspberry Pi Zero 2W 等资源受限设备:可适当**降低分辨率**、限制 `solver_max_stars`、拉大 `solver_fullsolve_interval_frames`(实时模式)。 - Tetra 解算在后台线程执行,避免阻塞事件循环(见 `asyncio.to_thread`)。 diff --git a/docs/development/wifi-nm.md b/docs/development/wifi-nm.md new file mode 100644 index 0000000..170c7eb --- /dev/null +++ b/docs/development/wifi-nm.md @@ -0,0 +1,45 @@ +# WiFi:STA / AP 与 NetworkManager + +中文说明如何在 **Raspberry Pi OS**(已启用 **NetworkManager**)上为 OGScope 准备两个互斥连接,并与 `scripts/ogscope-wifi-switch.sh` 配合使用。 + +## 1. 环境变量 + +在 `ogscope.service` 的 `Environment=` 或 `.env` 中设置(与 [ogscope/config.py](../../ogscope/config.py) 中 `OGSCOPE_` 前缀一致): + +| 变量 | 含义 | +|------|------| +| `OGSCOPE_WIFI_STA_CONNECTION` | 连家中路由器的连接名(DHCP) | +| `OGSCOPE_WIFI_AP_CONNECTION` | 热点连接名(建议网关 `192.168.4.1/24`) | +| `OGSCOPE_WIFI_INTERFACE` | 可选,默认 `wlan0` | + +## 2. 安装脚本与 sudoers + +```bash +sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +sudo visudo -f /etc/sudoers.d/ogscope-wifi +``` + +`ogscope-wifi` 示例(将用户名与路径改为实际值): + +``` +ogstartech ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +``` + +## 3. 首次创建连接(示例) + +名称需与 `OGSCOPE_WIFI_*_CONNECTION` 一致。具体选项以你现场 `nmcli` 版本为准。 + +- **STA**:可用 `nmcli device wifi connect password ` 生成连接后,用 `nmcli connection modify "" connection.id ` 统一命名。 +- **AP**:可用 `nmcli device wifi hotspot` 创建热点,再 `nmcli connection modify "" ipv4.addresses 192.168.4.1/24 ipv4.method manual`(或按发行版文档使用 `shared`);并设置 `wifi-sec`、国家码等。 + +AP 与 STA 为两个独立 **connection**;切换时脚本对一方 `down`、另一方 `up`,STA 模式不会保留 AP 的静态地址。 + +## 4. 验证 + +```bash +sudo -E /usr/local/bin/ogscope-wifi-switch status +sudo -E /usr/local/bin/ogscope-wifi-switch ap +sudo -E /usr/local/bin/ogscope-wifi-switch sta +``` + +`-E` 保留当前 shell 中的 `OGSCOPE_*` 环境变量。 diff --git a/ogscope/__init__.py b/ogscope/__init__.py index 7508b6a..3657edc 100644 --- a/ogscope/__init__.py +++ b/ogscope/__init__.py @@ -1,7 +1,7 @@ """ OGScope - 电子极轴镜 -基于 Orange Pi Zero 2W 和 IMX327 的智能极轴校准系统 +基于 Raspberry Pi Zero 2W 和 IMX327 的智能极轴校准系统 """ # 使 vendored tetra3 可被 import / Make vendored tetra3 importable diff --git a/ogscope/config.py b/ogscope/config.py index 031da9f..f098af0 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -166,6 +166,56 @@ class Settings(BaseSettings): description="实时解算慢请求阈值(毫秒)/ Slow realtime solve threshold in ms", ) + # WiFi(nmcli + scripts/ogscope-wifi-switch.sh)/ WiFi (NetworkManager helper script) + wifi_switch_script: Path = Field( + default=Path("/usr/local/bin/ogscope-wifi-switch"), + description="WiFi 切换脚本路径 / Path to ogscope-wifi-switch script", + ) + wifi_switch_use_sudo: bool = Field( + default=True, + description="调用脚本时是否使用 sudo -n / sudo -n when invoking script", + ) + wifi_switch_timeout_seconds: int = Field( + default=90, + ge=10, + le=600, + description="nmcli 切换超时(秒)/ Timeout for nmcli switch", + ) + wifi_sta_connection: str = Field( + default="", + description="STA 模式 NM 连接名(空则禁用 WiFi API)/ STA connection name (empty disables API)", + ) + wifi_ap_connection: str = Field( + default="", + description="AP 模式 NM 连接名 / AP connection name", + ) + wifi_interface: str = Field( + default="wlan0", + description="无线接口名 / Wireless interface name", + ) + wifi_ap_url_host: str = Field( + default="192.168.4.1", + description="AP 模式下前端提示用的主机地址(不含端口)/ AP URL hint host without port", + ) + wifi_emergency_gpio_enabled: bool = Field( + default=False, + description="启用短接 GPIO 强制切 STA / Enable GPIO short-to-STA recovery", + ) + wifi_emergency_pin_out_bcm: int = Field( + default=22, + description="应急检测:输出低电平(BCM)/ Emergency: output LOW (BCM)", + ) + wifi_emergency_pin_in_bcm: int = Field( + default=23, + description="应急检测:上拉输入(BCM)/ Emergency: input with pull-up (BCM)", + ) + wifi_emergency_hold_seconds: float = Field( + default=2.0, + ge=0.5, + le=30.0, + description="短接持续多久触发 STA / Hold time before forcing STA", + ) + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/ogscope/hardware/gpio_config.py b/ogscope/hardware/gpio_config.py index c3b4041..956c19a 100644 --- a/ogscope/hardware/gpio_config.py +++ b/ogscope/hardware/gpio_config.py @@ -110,6 +110,13 @@ class RaspberryPiZero2WGPIO: "error_led_pin": 21, # 错误 LED / Error LED } + # WiFi 应急短接(BCM):输出低 + 上拉输入,短接 ≥2s 切 STA;物理排针 15–16 相邻 + # WiFi emergency short (BCM): OUT low + pull-up IN; hold ≥2s forces STA; physical pins 15–16 adjacent + WIFI_EMERGENCY_SHORT_PINS = { + "out_bcm": 22, + "in_bcm": 23, + } + class GPIOConfig: """GPIO 配置管理类 / GPIO configuration management class""" @@ -186,7 +193,11 @@ def get_pin_number(self, pin_name: str) -> Optional[int]: return self.gpio_config.GPIO_PINS.get(pin_name) def get_all_used_pins(self) -> list: - """获取所有已使用的引脚 / Get all used pins""" + """获取所有已使用的引脚 / Get all used pins + + 注:WiFi 应急短接使用 BCM22/23,启用 `OGSCOPE_WIFI_EMERGENCY_GPIO_ENABLED` 时勿占用。 + Note: WiFi emergency uses BCM 22/23; avoid conflicts when emergency GPIO is enabled. + """ used_pins = [] # 显示屏引脚 / Display pins diff --git a/ogscope/hardware/wifi_emergency_gpio.py b/ogscope/hardware/wifi_emergency_gpio.py new file mode 100644 index 0000000..bcd7632 --- /dev/null +++ b/ogscope/hardware/wifi_emergency_gpio.py @@ -0,0 +1,124 @@ +""" +WiFi 应急 GPIO 监控:短接 2s 强制切回 STA +WiFi emergency GPIO watcher: short pins to force STA. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass + +from loguru import logger + +from ogscope.config import Settings, get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service +from ogscope.utils.environment import is_raspberry_pi + + +@dataclass +class _WatcherState: + low_since: float | None = None + last_trigger_at: float = 0.0 + + +class WifiEmergencyGpioMonitor: + """应急 GPIO 监控器 / Emergency GPIO monitor.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._gpio = None + self._state = _WatcherState() + + def start(self) -> None: + """启动监控线程 / Start monitor thread.""" + if not self._settings.wifi_emergency_gpio_enabled: + logger.info("应急 GPIO 未启用 / Emergency GPIO disabled by config") + return + if self._thread and self._thread.is_alive(): + return + if not is_raspberry_pi(): + logger.info("非树莓派环境,跳过应急 GPIO / Skip emergency GPIO on non-RPi") + return + try: + import RPi.GPIO as gpio # type: ignore + except Exception as e: + logger.warning("未安装 RPi.GPIO,无法启用应急短接 / RPi.GPIO unavailable: {}", e) + return + + self._gpio = gpio + self._setup_gpio() + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_loop, + name="wifi-emergency-gpio", + daemon=True, + ) + self._thread.start() + logger.info( + "应急 GPIO 已启动 / Emergency GPIO monitor started: out={}, in={}, hold={}s", + self._settings.wifi_emergency_pin_out_bcm, + self._settings.wifi_emergency_pin_in_bcm, + self._settings.wifi_emergency_hold_seconds, + ) + + def stop(self) -> None: + """停止监控线程并释放 GPIO / Stop monitor and cleanup GPIO.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.5) + self._thread = None + if self._gpio: + try: + self._gpio.cleanup( + [ + self._settings.wifi_emergency_pin_out_bcm, + self._settings.wifi_emergency_pin_in_bcm, + ] + ) + except Exception: + pass + self._gpio = None + logger.info("应急 GPIO 已停止 / Emergency GPIO monitor stopped") + + def _setup_gpio(self) -> None: + assert self._gpio is not None + g = self._gpio + g.setwarnings(False) + g.setmode(g.BCM) + g.setup(self._settings.wifi_emergency_pin_out_bcm, g.OUT, initial=g.LOW) + g.setup(self._settings.wifi_emergency_pin_in_bcm, g.IN, pull_up_down=g.PUD_UP) + + def _run_loop(self) -> None: + assert self._gpio is not None + g = self._gpio + interval = 0.05 + hold = self._settings.wifi_emergency_hold_seconds + while not self._stop_event.is_set(): + now = time.monotonic() + pin_low = g.input(self._settings.wifi_emergency_pin_in_bcm) == g.LOW + if pin_low: + if self._state.low_since is None: + self._state.low_since = now + if (now - self._state.low_since) >= hold: + if (now - self._state.last_trigger_at) >= hold: + self._state.last_trigger_at = now + self._force_sta() + else: + self._state.low_since = None + time.sleep(interval) + + def _force_sta(self) -> None: + logger.warning("检测到应急短接,强制切换 STA / Emergency short detected, forcing STA") + if not wifi_switch_service.is_configured(): + logger.error("WiFi 未配置,无法应急切 STA / WiFi not configured, cannot force STA") + return + try: + wifi_switch_service.switch("sta") + except Exception as e: + logger.error("应急切 STA 失败 / Failed to force STA: {}", e) + + +wifi_emergency_gpio_monitor = WifiEmergencyGpioMonitor() diff --git a/ogscope/hardware/wifi_switch.py b/ogscope/hardware/wifi_switch.py new file mode 100644 index 0000000..d4f1787 --- /dev/null +++ b/ogscope/hardware/wifi_switch.py @@ -0,0 +1,115 @@ +""" +WiFi 模式切换(调用 ogscope-wifi-switch.sh + nmcli) +WiFi mode switch via external NetworkManager helper script. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from pathlib import Path +from typing import Literal + +from loguru import logger + +from ogscope.config import Settings, get_settings + +WifiMode = Literal["ap", "sta", "unknown"] + + +def _wifi_env(settings: Settings) -> dict[str, str]: + """合并当前环境与 WiFi 相关 OGSCOPE_* 变量 / Merge env with WiFi OGSCOPE_* vars.""" + env = {k: v for k, v in os.environ.items() if v is not None} + env["OGSCOPE_WIFI_STA_CONNECTION"] = settings.wifi_sta_connection + env["OGSCOPE_WIFI_AP_CONNECTION"] = settings.wifi_ap_connection + env["OGSCOPE_WIFI_INTERFACE"] = settings.wifi_interface + return env + + +def _parse_status_output(text: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + out[k.strip()] = v.strip() + return out + + +class WifiSwitchService: + """封装脚本调用 / Script invocation wrapper.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + + def is_configured(self) -> bool: + """是否已配置连接名与脚本 / Whether connection names and script are set.""" + s = self._settings + if not s.wifi_sta_connection or not s.wifi_ap_connection: + return False + p = Path(s.wifi_switch_script) + return p.is_file() + + def get_status(self) -> dict[str, str | None]: + """执行 status,返回解析后的键值 / Run status subcommand.""" + if not self.is_configured(): + return { + "MODE": "unknown", + "error": "wifi_not_configured", + } + raw = self._run_script("status", check=False) + data = _parse_status_output(raw) + if not data.get("MODE"): + data["MODE"] = "unknown" + return data + + def switch(self, mode: Literal["ap", "sta"]) -> None: + """切换模式;失败抛 subprocess.CalledProcessError / Switch mode.""" + if not self.is_configured(): + raise RuntimeError("wifi_not_configured") + self._run_script(mode, check=True) + + def _run_script(self, subcommand: str, *, check: bool) -> str: + s = self._settings + script = Path(s.wifi_switch_script) + cmd: list[str] = [] + if s.wifi_switch_use_sudo: + cmd.extend(["sudo", "-n", "-E", str(script), subcommand]) + else: + cmd.extend([str(script), subcommand]) + logger.info( + "WiFi script: {} / Running WiFi script", + " ".join(shlex.quote(c) for c in cmd), + ) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=s.wifi_switch_timeout_seconds, + env=_wifi_env(s), + check=check, + ) + except subprocess.TimeoutExpired as e: + logger.error("WiFi 脚本超时 / WiFi script timeout: {}", e) + raise + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + combined = "\n".join(x for x in (out, err) if x) + if err: + logger.debug("WiFi script stderr / stderr: {}", err) + if proc.returncode != 0 and check: + logger.warning( + "WiFi 脚本失败 rc={} / script failed: {}\n{}", + proc.returncode, + out, + err, + ) + raise subprocess.CalledProcessError( + proc.returncode, cmd, output=out, stderr=err + ) + return combined + + +wifi_switch_service = WifiSwitchService() diff --git a/ogscope/web/api/main.py b/ogscope/web/api/main.py index 91b23f4..b0abaa3 100644 --- a/ogscope/web/api/main.py +++ b/ogscope/web/api/main.py @@ -9,6 +9,7 @@ from ogscope.web.api.analysis.routes import router as analysis_router from ogscope.web.api.camera.routes import router as camera_router from ogscope.web.api.debug.routes import router as debug_router +from ogscope.web.api.network.routes import router as network_router from ogscope.web.api.system.routes import router as system_router # 创建主路由器 / Create the main router @@ -18,5 +19,6 @@ router.include_router(camera_router, tags=["Camera - 相机"]) router.include_router(alignment_router, tags=["Alignment - 极轴校准"]) router.include_router(system_router, tags=["System - 系统"]) +router.include_router(network_router, tags=["Network - 网络"]) router.include_router(debug_router, tags=["Debug - 调试"]) router.include_router(analysis_router, tags=["Analysis - 分析"]) diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index e19c646..540d949 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -77,6 +77,26 @@ class SystemInfo(BaseModel): load_average_1m: float = 0.0 +class WifiStatus(BaseModel): + """WiFi 模式状态 / WiFi mode status (STA vs AP).""" + + mode: Literal["ap", "sta", "unknown"] + active_connection: Optional[str] = None + wireless_interface: str = "wlan0" + sta_connection: str = "" + ap_connection: str = "" + ap_ipv4: Optional[str] = None + ap_url_hint: Optional[str] = None + configured: bool = True + message: Optional[str] = None + + +class WifiModeRequest(BaseModel): + """切换 WiFi 模式 / Switch WiFi mode.""" + + mode: Literal["ap", "sta"] + + class AlignmentStatus(BaseModel): """校准状态 / calibration status""" diff --git a/ogscope/web/api/network/__init__.py b/ogscope/web/api/network/__init__.py new file mode 100644 index 0000000..92b242c --- /dev/null +++ b/ogscope/web/api/network/__init__.py @@ -0,0 +1,3 @@ +""" +网络 API 模块 / Network API module. +""" diff --git a/ogscope/web/api/network/routes.py b/ogscope/web/api/network/routes.py new file mode 100644 index 0000000..0da4a2f --- /dev/null +++ b/ogscope/web/api/network/routes.py @@ -0,0 +1,65 @@ +""" +网络相关 API 路由(WiFi AP/STA) / Network API routes for WiFi AP/STA. +""" + +from __future__ import annotations + +import subprocess + +from fastapi import APIRouter, HTTPException + +from ogscope.config import get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service +from ogscope.web.api.models.schemas import WifiModeRequest, WifiStatus + +router = APIRouter() + + +def _build_wifi_status() -> WifiStatus: + settings = get_settings() + configured = wifi_switch_service.is_configured() + data = wifi_switch_service.get_status() + mode = data.get("MODE", "unknown") + active_connection = data.get("ACTIVE_CONNECTION") or None + wireless_interface = data.get("WIRELESS_INTERFACE", settings.wifi_interface) + sta_connection = data.get("STA_CONNECTION", settings.wifi_sta_connection) + ap_connection = data.get("AP_CONNECTION", settings.wifi_ap_connection) + ap_ipv4 = data.get("AP_IPV4") or None + ap_url_hint = ( + f"http://{settings.wifi_ap_url_host}:{settings.port}" + if mode == "ap" + else None + ) + message = data.get("error") + return WifiStatus( + mode=mode if mode in {"ap", "sta"} else "unknown", + active_connection=active_connection, + wireless_interface=wireless_interface, + sta_connection=sta_connection, + ap_connection=ap_connection, + ap_ipv4=ap_ipv4, + ap_url_hint=ap_url_hint, + configured=configured, + message=message, + ) + + +@router.get("/network/wifi", response_model=WifiStatus) +async def get_wifi_status() -> WifiStatus: + """获取 WiFi 模式状态 / Get WiFi mode status.""" + return _build_wifi_status() + + +@router.post("/network/wifi", response_model=WifiStatus) +async def switch_wifi_mode(payload: WifiModeRequest) -> WifiStatus: + """切换 WiFi 模式(AP/STA)/ Switch WiFi mode.""" + try: + wifi_switch_service.switch(payload.mode) + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except subprocess.TimeoutExpired as e: + raise HTTPException(status_code=504, detail=f"wifi_switch_timeout: {e}") from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"wifi_switch_failed: {err}") from e + return _build_wifi_status() diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 12fd1ca..fa41cf2 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -60,10 +60,23 @@ async def _warm_solver() -> None: # Warm the solver synchronously during startup to avoid first-request cold-start race. await _warm_solver() + try: + from ogscope.hardware.wifi_emergency_gpio import wifi_emergency_gpio_monitor + + wifi_emergency_gpio_monitor.start() + except Exception as e: + logger.warning("应急 GPIO 启动失败 / Emergency GPIO start failed: {}", e) + yield # 关闭时执行 / Execute on shutdown logger.info("清理资源...") + try: + from ogscope.hardware.wifi_emergency_gpio import wifi_emergency_gpio_monitor + + wifi_emergency_gpio_monitor.stop() + except Exception as e: + logger.warning("应急 GPIO 停止异常 / Emergency GPIO stop error: {}", e) try: from ogscope.utils.environment import should_use_simulation_mode @@ -97,6 +110,10 @@ async def _warm_solver() -> None: "name": "Analysis - 分析", "description": "素材分析与任务管理 / Asset analysis and job management", }, + { + "name": "Network - 网络", + "description": "WiFi AP/STA 切换 / WiFi AP vs STA switching", + }, { "name": "Catalog - 星表", "description": "星表下载、索引与状态 / Catalog download, indexing, and status", @@ -177,6 +194,21 @@ async def debug_console(request: Request): ) +@app.get("/debug/system", response_class=HTMLResponse) +async def debug_system_console(request: Request): + """系统调试控制台(WiFi 等)/ System debug console (WiFi, etc.).""" + ds_js = settings.static_dir / "js" / "debug-system.js" + return templates.TemplateResponse( + "debug_system.html", + { + "request": request, + "version": __version__, + "app_name": "OGScope System Debug", + "debug_system_assets_version": _asset_stamp(ds_js), + }, + ) + + @app.get("/debug/analysis", response_class=HTMLResponse) async def debug_analysis_console(request: Request): """星空解算控制台(Vite 构建 SPA)或回退旧模板 / Plate solve console SPA or legacy template.""" @@ -209,6 +241,7 @@ async def api_root(): "camera": "/api/camera/", "alignment": "/api/alignment/", "system": "/api/system/", + "network": "/api/network/", "analysis": "/api/analysis/", }, } diff --git a/scripts/install.sh b/scripts/install.sh index 2e15f5a..10522aa 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,6 +1,6 @@ #!/bin/bash # OGScope 安装脚本 / OGScope installation script -# 适用于 Raspberry Pi / Orange Pi 等嵌入式板 / For Raspberry Pi, Orange Pi, etc. +# 适用于 Raspberry Pi Zero 2W 等嵌入式板 / For Raspberry Pi Zero 2W, etc. # # 环境变量 / Environment: # OGSCOPE_INSTALL_DEV=1 — 安装含 dev 依赖(开发机);默认仅 main / Install dev deps; default main only @@ -84,7 +84,7 @@ sudo apt install -y \ libfreetype6-dev _apt_pause -# 树莓派常见;Orange Pi 若无此包可忽略 / Raspberry Pi; skip if unavailable on Orange Pi +# 树莓派常见;若无此包可忽略 / Common on Raspberry Pi OS; skip if unavailable if apt-cache show python3-picamera2 >/dev/null 2>&1; then echo "📦 安装 python3-picamera2..." sudo apt install -y python3-picamera2 || echo "⚠️ picamera2 安装跳过 / picamera2 install skipped" diff --git a/scripts/mirror.sh b/scripts/mirror.sh index 7deea83..bd1204c 100644 --- a/scripts/mirror.sh +++ b/scripts/mirror.sh @@ -50,7 +50,7 @@ ogscope_is_debian_family() { # 安装脚本入口:非 Debian 系则退出,避免误改软件源 / Abort install on non-Debian systems (safety) ogscope_require_debian_family_apt() { if ! ogscope_is_debian_family; then - echo "❌ 本脚本仅支持 Debian/Ubuntu 系发行版(含 Raspberry Pi OS、Orange Pi Debian 镜像)。" >&2 + echo "❌ 本脚本仅支持 Debian/Ubuntu 系发行版(含 Raspberry Pi OS)。" >&2 echo "❌ This installer only supports Debian/Ubuntu family (incl. Raspberry Pi OS, Armbian Debian)." >&2 echo " 当前 ID=${OGSCOPE_OS_ID:-?} ID_LIKE=${OGSCOPE_OS_ID_LIKE:-?} / Current OS ID shown above." >&2 return 1 diff --git a/scripts/ogscope-wifi-switch.sh b/scripts/ogscope-wifi-switch.sh new file mode 100755 index 0000000..57ad480 --- /dev/null +++ b/scripts/ogscope-wifi-switch.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# OGScope WiFi 模式切换(NetworkManager / nmcli) +# OGScope WiFi mode switch (NetworkManager) — STA (client) vs AP (hotspot) +# +# 用法 / Usage: +# ogscope-wifi-switch.sh status|ap|sta +# +# 环境变量(与 systemd 或 .env 中 OGSCOPE_* 对齐)/ Environment (match OGSCOPE_* in systemd/.env): +# OGSCOPE_WIFI_STA_CONNECTION — STA 连接名(连路由器)/ connection name for DHCP client mode +# OGSCOPE_WIFI_AP_CONNECTION — AP 热点连接名 / connection name for access point mode +# OGSCOPE_WIFI_INTERFACE — 无线接口,默认 wlan0 / wireless iface, default wlan0 +# +# 部署 / Deployment: +# sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +# sudoers(将 %USER% 与路径替换为实际值)/ sudoers example: +# %USER% ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +# +# 首次需在板上用 nmcli 创建两个 Profile(STA 与 AP),名称与上述变量一致。 +# Create both profiles once on the board with nmcli; names must match the variables above. + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +CMD="${1:-}" + +STA="${OGSCOPE_WIFI_STA_CONNECTION:-}" +AP="${OGSCOPE_WIFI_AP_CONNECTION:-}" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" + +if ! command -v nmcli >/dev/null 2>&1; then + echo "ERROR=nmcli_not_found" >&2 + exit 127 +fi + +if [[ -z "$STA" || -z "$AP" ]]; then + echo "ERROR=missing_env_OGSCOPE_WIFI_STA_CONNECTION_or_OGSCOPE_WIFI_AP_CONNECTION" >&2 + exit 2 +fi + +# 当前活动连接名(指定接口)/ Active connection name for the wireless interface +_active_connection() { + nmcli -t -f GENERAL.CONNECTION device show "$IFACE" 2>/dev/null | sed -n 's/^GENERAL.CONNECTION://p' | head -n1 || true +} + +# AP 配置的 IPv4 地址(用于 status 展示)/ IPv4 addresses from AP profile +_ap_ipv4() { + nmcli -g ipv4.addresses connection show "$AP" 2>/dev/null | head -n1 || true +} + +case "$CMD" in +status) + ACTIVE="$(_active_connection)" + MODE="unknown" + if [[ "$ACTIVE" == "$AP" ]]; then + MODE="ap" + elif [[ "$ACTIVE" == "$STA" ]]; then + MODE="sta" + elif [[ -z "$ACTIVE" || "$ACTIVE" == "--" ]]; then + MODE="unknown" + fi + echo "MODE=${MODE}" + echo "ACTIVE_CONNECTION=${ACTIVE:-}" + echo "WIRELESS_INTERFACE=${IFACE}" + echo "STA_CONNECTION=${STA}" + echo "AP_CONNECTION=${AP}" + if [[ "$MODE" == "ap" ]]; then + echo "AP_IPV4=$(_ap_ipv4)" + else + echo "AP_IPV4=" + fi + ;; +ap) + nmcli connection down "$STA" 2>/dev/null || true + nmcli connection up "$AP" ifname "$IFACE" + ;; +sta) + nmcli connection down "$AP" 2>/dev/null || true + nmcli connection up "$STA" ifname "$IFACE" + ;; +*) + echo "Usage: ${SCRIPT_NAME} status|ap|sta" >&2 + exit 1 + ;; +esac diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index 068ee98..bf2b8f3 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -432,6 +432,9 @@ def test_analysis_realtime_timeout_releases_gate( monkeypatch.setattr(settings, "star_analysis_request_timeout_ms", 80, raising=False) monkeypatch.setattr(settings, "star_analysis_min_interval_ms", 50, raising=False) monkeypatch.setattr(settings, "star_analysis_max_interval_ms", 20000, raising=False) + # 固定 monotonic,避免 CI 上墙钟流逝导致「最小间隔」二次请求抖动 / Pin monotonic so + # wall-clock elapsed time does not trigger SKIPPED_INTERVAL on the follow-up request. + monkeypatch.setattr("ogscope.web.api.analysis.services.time.monotonic", lambda: 0.0) gate = analysis_service._realtime_gate_states.get("file_upload") if gate is not None: gate.in_flight = False @@ -460,15 +463,6 @@ def _slow(*_args, **_kwargs): data = resp.json() assert data.get("gate_status") == "TIMEOUT_RELEASED" - # 勿 patch 全局 time.monotonic:asyncio.wait_for 依赖它推进截止时间 / Do not patch - # time.monotonic globally — asyncio.wait_for uses it for deadlines. - # 仅重置门禁时间戳,使第二次请求不依赖「墙钟已过最小间隔」/ Reset gate timestamp so - # the follow-up request does not rely on wall-clock interval elapsed. - gate_after = analysis_service._realtime_gate_states.get("file_upload") - assert gate_after is not None - assert gate_after.in_flight is False - gate_after.last_finished_mono = 0.0 - def _fast(*_args, **_kwargs): return { "frame_index": 0, diff --git a/tests/unit/test_wifi_switch.py b/tests/unit/test_wifi_switch.py new file mode 100644 index 0000000..c692da4 --- /dev/null +++ b/tests/unit/test_wifi_switch.py @@ -0,0 +1,121 @@ +""" +WiFi 切换服务与 API 单元测试 / Unit tests for WiFi switch service and API. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from ogscope.config import Settings +from ogscope.hardware.wifi_switch import WifiSwitchService, _parse_status_output + + +@pytest.mark.unit +def test_parse_status_output() -> None: + """解析脚本状态输出 / Parse key=value status output.""" + text = "\n".join( + [ + "MODE=ap", + "ACTIVE_CONNECTION=OGScope-AP", + "WIRELESS_INTERFACE=wlan0", + "AP_IPV4=192.168.4.1/24", + ] + ) + data = _parse_status_output(text) + assert data["MODE"] == "ap" + assert data["ACTIVE_CONNECTION"] == "OGScope-AP" + assert data["AP_IPV4"] == "192.168.4.1/24" + + +@pytest.mark.unit +def test_wifi_service_not_configured(tmp_path: Path) -> None: + """未配置时返回 unknown / Return unknown when not configured.""" + settings = Settings( + wifi_sta_connection="", + wifi_ap_connection="", + wifi_switch_script=tmp_path / "missing-script", + ) + service = WifiSwitchService(settings) + assert service.is_configured() is False + status = service.get_status() + assert status["MODE"] == "unknown" + assert status["error"] == "wifi_not_configured" + + +@pytest.mark.unit +def test_wifi_service_status_and_switch(monkeypatch, tmp_path: Path) -> None: + """配置后可执行 status/switch / Run status and switch when configured.""" + script = tmp_path / "ogscope-wifi-switch" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + settings = Settings( + wifi_sta_connection="HOME-STA", + wifi_ap_connection="OGScope-AP", + wifi_switch_script=script, + wifi_switch_use_sudo=False, + wifi_interface="wlan0", + ) + service = WifiSwitchService(settings) + + calls: list[list[str]] = [] + + def _fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd[-1] == "status": + return subprocess.CompletedProcess( + cmd, + 0, + stdout="\n".join( + [ + "MODE=sta", + "ACTIVE_CONNECTION=HOME-STA", + "WIRELESS_INTERFACE=wlan0", + "STA_CONNECTION=HOME-STA", + "AP_CONNECTION=OGScope-AP", + ] + ), + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", _fake_run) + status = service.get_status() + assert status["MODE"] == "sta" + service.switch("ap") + service.switch("sta") + assert calls[0][-1] == "status" + assert calls[1][-1] == "ap" + assert calls[2][-1] == "sta" + + +@pytest.mark.unit +def test_network_api(client, monkeypatch) -> None: + """网络 API 状态与切换 / Network API status and switch.""" + from ogscope.web.api.network import routes as network_routes + + monkeypatch.setattr(network_routes.wifi_switch_service, "is_configured", lambda: True) + monkeypatch.setattr( + network_routes.wifi_switch_service, + "get_status", + lambda: { + "MODE": "ap", + "ACTIVE_CONNECTION": "OGScope-AP", + "WIRELESS_INTERFACE": "wlan0", + "STA_CONNECTION": "HOME-STA", + "AP_CONNECTION": "OGScope-AP", + "AP_IPV4": "192.168.4.1/24", + }, + ) + monkeypatch.setattr(network_routes.wifi_switch_service, "switch", lambda mode: None) + + response = client.get("/api/network/wifi") + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "ap" + assert data["active_connection"] == "OGScope-AP" + + response2 = client.post("/api/network/wifi", json={"mode": "sta"}) + assert response2.status_code == 200 diff --git a/web/static/css/debug-system.css b/web/static/css/debug-system.css new file mode 100644 index 0000000..e1cda38 --- /dev/null +++ b/web/static/css/debug-system.css @@ -0,0 +1,77 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #111827; + color: #f9fafb; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 20px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.card { + margin-top: 16px; + padding: 16px; + border: 1px solid #374151; + border-radius: 12px; + background: #1f2937; +} + +.status { + margin: 12px 0; + padding: 12px; + border: 1px solid #4b5563; + border-radius: 8px; + background: #111827; + word-break: break-all; +} + +.buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 12px 0; +} + +.btn { + background: #374151; + color: #f9fafb; + border: 1px solid #4b5563; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; +} + +.btn:hover { + background: #4b5563; +} + +.btn-primary { + background: #2563eb; + border-color: #3b82f6; +} + +.btn-primary:hover { + background: #1d4ed8; +} + +.hint { + color: #d1d5db; + font-size: 14px; +} diff --git a/web/static/js/debug-system.js b/web/static/js/debug-system.js new file mode 100644 index 0000000..d112429 --- /dev/null +++ b/web/static/js/debug-system.js @@ -0,0 +1,79 @@ +/* OGScope 系统调试控制台 / OGScope system debug console */ + +async function requestJson(url, options = {}) { + const response = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + let data = {}; + try { + data = await response.json(); + } catch (_e) { + // ignore + } + if (!response.ok) { + const detail = data.detail || `HTTP ${response.status}`; + throw new Error(detail); + } + return data; +} + +function renderStatus(data) { + const box = document.getElementById("wifi-status"); + const apUrlHint = document.getElementById("ap-url-hint"); + const mode = data.mode || "unknown"; + const active = data.active_connection || "-"; + const iface = data.wireless_interface || "wlan0"; + const apIpv4 = data.ap_ipv4 || "-"; + const configured = data.configured ? "是" : "否"; + const message = data.message ? `,消息: ${data.message}` : ""; + if (data.ap_url_hint) { + apUrlHint.textContent = data.ap_url_hint; + } + box.textContent = + `模式: ${mode} | 活动连接: ${active} | 接口: ${iface} | AP地址: ${apIpv4} | 已配置: ${configured}${message}`; +} + +async function refreshStatus() { + const data = await requestJson("/api/network/wifi"); + renderStatus(data); +} + +async function switchMode(mode) { + const box = document.getElementById("wifi-status"); + box.textContent = `正在切换到 ${mode.toUpperCase()}...`; + const data = await requestJson("/api/network/wifi", { + method: "POST", + body: JSON.stringify({ mode }), + }); + renderStatus(data); +} + +function boot() { + document.getElementById("refresh").addEventListener("click", async () => { + try { + await refreshStatus(); + } catch (err) { + document.getElementById("wifi-status").textContent = `刷新失败: ${err.message}`; + } + }); + document.getElementById("to-ap").addEventListener("click", async () => { + try { + await switchMode("ap"); + } catch (err) { + document.getElementById("wifi-status").textContent = `切换失败: ${err.message}`; + } + }); + document.getElementById("to-sta").addEventListener("click", async () => { + try { + await switchMode("sta"); + } catch (err) { + document.getElementById("wifi-status").textContent = `切换失败: ${err.message}`; + } + }); + refreshStatus().catch((err) => { + document.getElementById("wifi-status").textContent = `获取状态失败: ${err.message}`; + }); +} + +window.addEventListener("DOMContentLoaded", boot); diff --git a/web/templates/debug.html b/web/templates/debug.html index 297ef59..c156178 100644 --- a/web/templates/debug.html +++ b/web/templates/debug.html @@ -40,6 +40,7 @@

🔧 OGScope 相机调试控制台

+
diff --git a/web/templates/debug_system.html b/web/templates/debug_system.html new file mode 100644 index 0000000..08ea01b --- /dev/null +++ b/web/templates/debug_system.html @@ -0,0 +1,39 @@ + + + + + + OGScope 系统调试控制台 + + + +
+
+

🔧 OGScope 系统调试控制台

+
+ + + +
+
+ +
+

WiFi 模式

+
加载中...
+
+ + + +
+

+ AP 模式固定访问地址:http://192.168.4.1:8000 +

+

+ 注意:从 STA 切到 AP 时,当前页面会断开连接,需要先连接热点后再访问固定地址。 +

+
+
+ + + + From 7036030dd9d881659b699aa367bd9fb1997349cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=91=E6=98=AF=E5=B0=8F=E4=B8=80=E7=81=B0?= Date: Thu, 2 Apr 2026 11:42:45 +0800 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=E8=A7=A3=E7=AE=97=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E6=B1=A0=E4=B8=8E=E8=B0=83=E8=AF=95=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=8C=E4=BF=AE=E5=A4=8D=20WiFi=20?= =?UTF-8?q?=E6=97=A0=E7=BA=BF=E8=A7=A3=E6=9E=90=20/=20Solver=20pool,=20deb?= =?UTF-8?q?ug/lab=20UX,=20WiFi=20wireless=20parse=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解算服务 ThreadPoolExecutor 默认 max_workers=1,降低低内存设备峰值内存 / Default solver executor to 1 worker for lower peak RAM on small boards - 修正 /proc/net/wireless 列索引并补充单元测试 / Fix link/signal column indices and add unit test - 调试控制台、系统调试页与解算控制台前端及构建产物更新 / Update debug pages, analysis-ui build, and static assets Made-with: Cursor --- ogscope/web/api/analysis/services.py | 4 +- ogscope/web/api/system/services.py | 6 +- tests/unit/test_system_wifi_parse.py | 40 ++++++ web/analysis-ui/src/App.tsx | 127 +++++++++--------- web/analysis-ui/src/index.css | 28 ++++ .../analysis-lab/assets/index-BTbVhXi4.js | 125 ----------------- ...{index-CiLgcict.css => index-BYflL5dV.css} | 2 +- .../analysis-lab/assets/index-C7Lk1iyA.js | 125 +++++++++++++++++ web/static/analysis-lab/index.html | 4 +- web/static/css/debug-system.css | 30 +++++ web/static/css/debug.css | 31 +++++ web/static/js/debug-system.js | 66 +++++++++ web/static/js/debug.js | 6 +- web/templates/debug.html | 59 ++------ web/templates/debug_system.html | 6 + 15 files changed, 416 insertions(+), 243 deletions(-) create mode 100644 tests/unit/test_system_wifi_parse.py delete mode 100644 web/static/analysis-lab/assets/index-BTbVhXi4.js rename web/static/analysis-lab/assets/{index-CiLgcict.css => index-BYflL5dV.css} (91%) create mode 100644 web/static/analysis-lab/assets/index-C7Lk1iyA.js diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index e00b402..e244442 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -158,9 +158,9 @@ def __init__(self) -> None: self.upload_root.mkdir(parents=True, exist_ok=True) self.jobs_root.mkdir(parents=True, exist_ok=True) self.results_root.mkdir(parents=True, exist_ok=True) - # 解算专用线程池(避免与相机预览等争用默认线程池)/ Dedicated executor for solving tasks + # 解算专用线程池(避免与相机预览等争用默认线程池);默认单 worker 降低 Zero 2W 等低内存设备上并发解算的内存峰值 / Dedicated executor for solving; default 1 worker to reduce peak RAM on low-memory boards self._solver_executor = ThreadPoolExecutor( - max_workers=2, thread_name_prefix="solver" + max_workers=1, thread_name_prefix="solver" ) self._solver_max_stars = settings.solver_max_stars self.extractor = StarExtractor(max_stars=settings.solver_max_stars) diff --git a/ogscope/web/api/system/services.py b/ogscope/web/api/system/services.py index 616260f..6cca899 100644 --- a/ogscope/web/api/system/services.py +++ b/ogscope/web/api/system/services.py @@ -158,6 +158,8 @@ def _read_wifi_metrics(self) -> tuple[float | None, float | None, str | None]: except OSError: return None, None, None + # /proc/net/wireless 列顺序:iface、status、link、level、noise / Columns: iface, status, link, level, noise + # 示例 / Example: wlan0: 0000 45. -50. -256 (link 在 status 之后) for line in lines[2:]: if ":" not in line: continue @@ -166,8 +168,8 @@ def _read_wifi_metrics(self) -> tuple[float | None, float | None, str | None]: if len(values) < 3: continue try: - link_quality = float(values[0].rstrip(".")) - signal_level = float(values[1].rstrip(".")) + link_quality = float(values[1].rstrip(".")) + signal_level = float(values[2].rstrip(".")) quality_percent = max(0.0, min(100.0, (link_quality / 70.0) * 100.0)) return ( round(quality_percent, 2), diff --git a/tests/unit/test_system_wifi_parse.py b/tests/unit/test_system_wifi_parse.py new file mode 100644 index 0000000..b7a8252 --- /dev/null +++ b/tests/unit/test_system_wifi_parse.py @@ -0,0 +1,40 @@ +"""WiFi 指标解析:/proc/net/wireless 列顺序 / WiFi metrics column order.""" + +from pathlib import Path + +import pytest + +from ogscope.web.api.system import services as system_services +from ogscope.web.api.system.services import SystemInfoService + +_WIRELESS_SAMPLE = """Inter-| sta-| Quality | Discarded packets + face | tus | link level noise | nwid crypt frag + wlan0: 0000 45. -50. -256 0 0 0 +""" + + +@pytest.mark.unit +def test_read_wifi_metrics_link_not_status_column(monkeypatch: pytest.MonkeyPatch) -> None: + """第一列为 status(0000),link 在 values[1] / First column is status, not link quality.""" + real_path = Path + + def path_new(arg: str) -> object: + if arg == "/proc/net/wireless": + + class _W: + def exists(self) -> bool: + return True + + def read_text(self, encoding: str | None = None) -> str: + return _WIRELESS_SAMPLE + + return _W() + return real_path(arg) + + monkeypatch.setattr(system_services, "Path", path_new) + svc = SystemInfoService(cache_ttl_seconds=0.0) + q, sig, iface = svc._read_wifi_metrics() + assert iface == "wlan0" + assert sig == -50.0 + assert q is not None + assert abs(float(q) - (45.0 / 70.0) * 100.0) < 0.01 diff --git a/web/analysis-ui/src/App.tsx b/web/analysis-ui/src/App.tsx index 9b0c5f7..586b601 100644 --- a/web/analysis-ui/src/App.tsx +++ b/web/analysis-ui/src/App.tsx @@ -159,12 +159,12 @@ export default function App() { const [cameraPreviewNatural, setCameraPreviewNatural] = useState({ w: 0, h: 0 }); /** 视频台:文件预览或设备相机 / Video lab: pool file vs device camera */ const [videoPreviewMode, setVideoPreviewMode] = useState<"file" | "camera">("file"); - /** 设备相机预览图 blob URL(与 X-Frame-Id 去重)/ Camera preview blob URL, deduped by frame id */ - const [cameraPreviewUrl, setCameraPreviewUrl] = useState(null); + /** MJPEG 流时间戳参数,与相机调试台 /api/debug/camera/stream 一致 / Same stream URL as debug console */ + const [cameraStreamNonce, setCameraStreamNonce] = useState(() => Date.now()); const [videoPreviewError, setVideoPreviewError] = useState(null); const [cameraSolveRunning, setCameraSolveRunning] = useState(false); const [fileSolveRunning, setFileSolveRunning] = useState(false); - const [autoHoldEnabled, setAutoHoldEnabled] = useState(true); + const [autoHoldEnabled, setAutoHoldEnabled] = useState(false); const [isFrozen, setIsFrozen] = useState(false); const [frozenFrameId, setFrozenFrameId] = useState(null); const [frozenImageUrl, setFrozenImageUrl] = useState(null); @@ -191,12 +191,34 @@ export default function App() { const imgRef = useRef(null); const videoRef = useRef(null); const cameraPreviewImgRef = useRef(null); - const lastCameraFrameIdRef = useRef(null); const cameraSolveTimerRef = useRef(null); const fileSolveTimerRef = useRef(null); const cameraSolveInFlightRef = useRef(false); const fileSolveInFlightRef = useRef(false); const cvRef = useRef(null); + + /** 从当前预览 img 截一帧为 JPEG blob URL(用于冻结与星点同帧)/ Snapshot current preview frame for freeze */ + const captureCameraFrameAsBlobUrl = useCallback(async (): Promise => { + const el = cameraPreviewImgRef.current; + if (!el || el.naturalWidth < 2) return null; + const canvas = document.createElement("canvas"); + canvas.width = el.naturalWidth; + canvas.height = el.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + try { + ctx.drawImage(el, 0, 0); + } catch { + return null; + } + return await new Promise((resolve) => { + canvas.toBlob( + (b) => resolve(b ? URL.createObjectURL(b) : null), + "image/jpeg", + 0.92, + ); + }); + }, []); const [sysOverview, setSysOverview] = useState(null); const [labSettings, setLabSettings] = useState(null); @@ -366,45 +388,13 @@ export default function App() { const draw = () => drawSolveOverlay(cv, img, overlay, layers); if (img.complete) draw(); else img.onload = draw; - }, [overlay, layers, view, videoPreviewMode, lastResult, cameraPreviewUrl]); + }, [overlay, layers, view, videoPreviewMode, lastResult, cameraStreamNonce, isFrozen]); - /** 共享预览缓存轮询:仅当 X-Frame-Id 变化时更新图像,减少解码与重绘 / Poll shared cache; update img only on new frame id */ + /** 进入设备相机模式时重连 MJPEG(与相机调试台同一路径)/ Reconnect MJPEG like debug console */ useEffect(() => { if (view !== "lab_video" || videoPreviewMode !== "camera") return; - let cancelled = false; - const poll = async () => { - if (cancelled || isFrozen) return; - try { - const qs = lastCameraFrameIdRef.current - ? `?since_frame_id=${encodeURIComponent(lastCameraFrameIdRef.current)}` - : ""; - const r = await fetch(`/api/camera/preview${qs}`, { cache: "no-store" }); - if (r.status === 304) return; - if (!r.ok) return; - const fid = r.headers.get("X-Frame-Id"); - if (fid != null) lastCameraFrameIdRef.current = fid; - const blob = await r.blob(); - const url = URL.createObjectURL(blob); - setCameraPreviewUrl((prev) => { - if (prev) URL.revokeObjectURL(prev); - return url; - }); - } catch { - /* 忽略单次失败 / Ignore transient errors */ - } - }; - void poll(); - const id = window.setInterval(() => void poll(), 180); - return () => { - cancelled = true; - clearInterval(id); - setCameraPreviewUrl((prev) => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - lastCameraFrameIdRef.current = null; - }; - }, [view, videoPreviewMode, isFrozen]); + setCameraStreamNonce(Date.now()); + }, [view, videoPreviewMode]); useEffect(() => { const img = imgRef.current; @@ -533,7 +523,11 @@ export default function App() { ? String((out as { frame_id?: number }).frame_id) : null, ); - setFrozenImageUrl(cameraPreviewUrl); + const snap = await captureCameraFrameAsBlobUrl(); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return snap; + }); stopCameraSolveLoop(); } setLastRoundTripMs(performance.now() - t0); @@ -745,7 +739,11 @@ export default function App() { const resumeLivePreview = () => { setIsFrozen(false); setFrozenFrameId(null); - setFrozenImageUrl(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCameraStreamNonce(Date.now()); }; const togglePreset = (id: string) => { @@ -877,7 +875,10 @@ export default function App() { stopCameraSolveLoop(); setIsFrozen(false); setFrozenFrameId(null); - setFrozenImageUrl(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); } if (view !== "lab_video" || videoPreviewMode !== "file") { stopFileSolveLoop(); @@ -1007,7 +1008,7 @@ export default function App() { > {t("sidebar.refresh")} -
+
{sidebarUploads.map((u) => (
{t("sidebar.debugEmpty")}

) : ( <> -
+
{debugPagedFiles.map((f) => ( {rawOpen && ( -
+                                  
                                     {JSON.stringify(r, null, 2)}
                                   
)} @@ -1793,7 +1798,7 @@ export default function App() { ) : (
{String(r.error)}
)} - {r.success && selected && ( + {r.success === true && selected ? ( - )} + ) : null}
); })} @@ -1831,7 +1836,7 @@ export default function App() { {singleFooterRawOpen ? t("results.hideRaw") : t("results.viewRaw")} {singleFooterRawOpen && ( -
+                            
                               {JSON.stringify(lastResult, null, 2)}
                             
)} @@ -1954,7 +1959,7 @@ export default function App() {
)} -
+
{t("sidebar.batchHint")}

-
+
{[...official, ...userPresets].map((p) => (
)} diff --git a/web/analysis-ui/src/DebugShell.tsx b/web/analysis-ui/src/DebugShell.tsx new file mode 100644 index 0000000..a8d8229 --- /dev/null +++ b/web/analysis-ui/src/DebugShell.tsx @@ -0,0 +1,177 @@ +import type { ReactNode } from "react"; +import { + Activity, + Bolt, + Camera, + Cpu, + LayoutDashboard, + Network, + Sparkles, + Touchpad, + Wifi, +} from "lucide-react"; +import { useSystemInfo } from "./context/SystemInfoContext"; +import { useI18n } from "./i18n/I18nProvider"; + +type SystemRoute = "overview" | "network" | "sensors" | "hmi" | "power"; + +const navClass = (active: boolean) => + `flex items-center gap-3 rounded-lg px-3 py-2.5 font-headline text-sm tracking-tight transition-colors ${ + active + ? "border-r-2 border-primary bg-white/5 font-semibold text-primary" + : "text-on-surface-variant hover:bg-white/5 hover:text-on-surface" + }`; + +export function DebugShell({ + route, + onRouteChange, + children, +}: { + route: SystemRoute; + onRouteChange: (route: SystemRoute) => void; + children: ReactNode; +}) { + const { t, locale, setLocale } = useI18n(); + const { info } = useSystemInfo(); + + const cpu = info?.cpu_usage != null ? Number(info.cpu_usage).toFixed(1) : "—"; + const mem = info?.memory_usage != null ? Number(info.memory_usage).toFixed(1) : "—"; + const temp = info?.temperature != null ? Number(info.temperature).toFixed(1) : "—"; + const wifiQ = + info?.wifi_quality != null && !Number.isNaN(Number(info.wifi_quality)) + ? `${Number(info.wifi_quality).toFixed(0)}%` + : "—"; + + const routeTitle: Record = { + overview: t("sys.shell.top.overview"), + network: t("sys.shell.top.network"), + sensors: t("sys.shell.top.sensors"), + hmi: t("sys.shell.top.hmi"), + power: t("sys.shell.top.power"), + }; + const externalLinkClass = navClass(false); + const openNamedWindow = (url: string, name: string) => { + const win = window.open(url, name); + if (win) win.focus(); + }; + + return ( +
+ + +
+
+
+ + {routeTitle[route]} + +
+
+
+ + +
+
+ + CPU {cpu}% + + + MEM {mem}% + + + °C {temp} + + + {wifiQ} + +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/web/analysis-ui/src/SystemConsoleApp.tsx b/web/analysis-ui/src/SystemConsoleApp.tsx new file mode 100644 index 0000000..335013a --- /dev/null +++ b/web/analysis-ui/src/SystemConsoleApp.tsx @@ -0,0 +1,49 @@ +import { useEffect, useMemo, useState } from "react"; +import { DebugShell } from "./DebugShell"; +import { OverviewPage } from "./pages/OverviewPage"; +import { NetworkPage } from "./pages/NetworkPage"; +import { PlaceholderPage } from "./pages/PlaceholderPage"; + +type SystemRoute = "overview" | "network" | "sensors" | "hmi" | "power"; + +const routeSet = new Set(["overview", "network", "sensors", "hmi", "power"]); + +function readRouteFromHash(): SystemRoute { + const raw = window.location.hash.replace(/^#\/?/, "").trim().toLowerCase(); + if (routeSet.has(raw as SystemRoute)) return raw as SystemRoute; + return "overview"; +} + +function setHashRoute(route: SystemRoute) { + window.location.hash = `/${route}`; +} + +export function SystemConsoleApp() { + const [route, setRoute] = useState(() => readRouteFromHash()); + + useEffect(() => { + const onHashChange = () => setRoute(readRouteFromHash()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + const page = useMemo(() => { + if (route === "network") return ; + if (route === "sensors") return ; + if (route === "hmi") return ; + if (route === "power") return ; + return ; + }, [route]); + + return ( + { + if (next === route) return; + setHashRoute(next); + }} + > + {page} + + ); +} diff --git a/web/analysis-ui/src/camera-main.tsx b/web/analysis-ui/src/camera-main.tsx new file mode 100644 index 0000000..af34581 --- /dev/null +++ b/web/analysis-ui/src/camera-main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { SystemInfoProvider } from "./context/SystemInfoContext"; +import { CameraConsoleApp } from "./camera/CameraConsoleApp"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/analysis-ui/src/camera/CameraConsoleApp.tsx b/web/analysis-ui/src/camera/CameraConsoleApp.tsx new file mode 100644 index 0000000..e193aba --- /dev/null +++ b/web/analysis-ui/src/camera/CameraConsoleApp.tsx @@ -0,0 +1,1289 @@ +import { useEffect, useRef, useState } from "react"; +import { + Camera, + Circle, + Download, + FileText, + FolderOpen, + Info, + Moon, + Play, + Save, + Settings2, + SlidersHorizontal, + Square, + Sun, + Trash2, +} from "lucide-react"; +import { useI18n } from "../i18n/I18nProvider"; +import { useSystemInfo } from "../context/SystemInfoContext"; +import { requestJson } from "../systemApi"; + +type CameraInfo = { + exposure_us?: number; + analogue_gain?: number; + digital_gain?: number; + auto_exposure?: boolean; + contrast?: number; + brightness?: number; + saturation?: number; + sharpness?: number; + noise_reduction?: number; + white_balance_mode?: string; + white_balance_gain_r?: number; + white_balance_gain_b?: number; + color_mode?: string; + rotation?: number; + width?: number; + height?: number; + fps?: number; + sampling_mode?: string; + sensor?: string; + [key: string]: unknown; +}; + +type CameraStatus = { + streaming?: boolean; + recording?: boolean; + camera_ready?: boolean; + info?: CameraInfo; + runtime_overrides?: Record; +}; + +type StreamStats = { + requestCount: number; + frameCount: number; + requestFps: number; + effectiveFps: number; + lastRequestTime: number | null; + lastFrameTime: number | null; + requestSamples: number[]; + frameSamples: number[]; +}; + +type CameraForm = { + exposure: number; + gain: number; + digitalGain: number; + autoExposure: boolean; + contrast: number; + brightness: number; + saturation: number; + sharpness: number; + noiseReduction: number; + whiteBalanceMode: string; + whiteBalanceGainR: number; + whiteBalanceGainB: number; + colorMode: string; +}; + +type CameraPreset = { + name: string; + description?: string; + exposure_us: number; + analogue_gain: number; + digital_gain?: number; + auto_exposure?: boolean; + contrast?: number; + brightness?: number; + saturation?: number; + sharpness?: number; + noise_reduction?: number; + white_balance_mode?: string; + white_balance_gain_r?: number; + white_balance_gain_b?: number; + rotation?: number; + color_mode?: string; +}; + +type DebugFileItem = { + name: string; + size: number; + modified: string; + type: "image" | "video"; +}; + +type DebugFileInfo = { + filename: string; + size: number; + modified: string; + type: "image" | "video"; + exposure_us?: number; + analogue_gain?: number; + digital_gain?: number; + resolution?: string; + duration_s?: number; + fps?: number; +}; + +const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1080"] as const; +const ROTATION_PRESETS = [0, 90, 180, 270] as const; + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +function toNum(v: unknown, fallback: number): number { + if (v == null || Number.isNaN(Number(v))) return fallback; + return Number(v); +} + +function formatSize(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let idx = 0; + let val = bytes; + while (val >= 1024 && idx < units.length - 1) { + val /= 1024; + idx += 1; + } + return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; +} + +function ParamSlider({ + label, + value, + min, + max, + step, + onChange, + disabled = false, + unit = "", +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + disabled?: boolean; + unit?: string; +}) { + return ( + + ); +} + +export function CameraConsoleApp() { + const { t, locale, setLocale } = useI18n(); + const { info: sysInfo } = useSystemInfo(); + const [status, setStatus] = useState(null); + const [previewActive, setPreviewActive] = useState(false); + const [streamNonce, setStreamNonce] = useState(() => Date.now()); + const [err, setErr] = useState(null); + const [notice, setNotice] = useState(null); + const [previewBusy, setPreviewBusy] = useState(false); + const [recordBusy, setRecordBusy] = useState(false); + const [captureBusy, setCaptureBusy] = useState(false); + const [stopPending, setStopPending] = useState(false); + const [fpsValue, setFpsValue] = useState("5"); + const [resValue, setResValue] = useState("1280x720"); + const [samplingMode, setSamplingMode] = useState("supersample"); + const [runtimeDirty, setRuntimeDirty] = useState(false); + const [showHistogram, setShowHistogram] = useState(true); + const [showRgb, setShowRgb] = useState(true); + const [showLuminance, setShowLuminance] = useState(false); + const [showOverExposure, setShowOverExposure] = useState(false); + const [histCollapsed, setHistCollapsed] = useState(false); + const [histStats, setHistStats] = useState({ mean: 0, std: 0, over: 0 }); + const [actualFps, setActualFps] = useState(0); + const [recordElapsed, setRecordElapsed] = useState(0); + const [rotationValue, setRotationValue] = useState(180); + const [form, setForm] = useState({ + exposure: 5000, + gain: 1.0, + digitalGain: 1.0, + autoExposure: true, + contrast: 1.0, + brightness: 0.0, + saturation: 1.0, + sharpness: 1.0, + noiseReduction: 0, + whiteBalanceMode: "auto", + whiteBalanceGainR: 1.0, + whiteBalanceGainB: 1.0, + colorMode: "color", + }); + const [formDirty, setFormDirty] = useState(false); + const [presetName, setPresetName] = useState(""); + const [presetDesc, setPresetDesc] = useState(""); + const [presets, setPresets] = useState([]); + const [presetBusy, setPresetBusy] = useState(false); + const [files, setFiles] = useState([]); + const [fileBusy, setFileBusy] = useState(false); + const [fileInfo, setFileInfo] = useState(null); + const [fileInfoBusy, setFileInfoBusy] = useState(false); + + const imgRef = useRef(null); + const histogramCanvasRef = useRef(null); + const offscreenCanvasRef = useRef(null); + const offscreenCtxRef = useRef(null); + const histogramCtxRef = useRef(null); + const recordTickRef = useRef(null); + const reconnectTimerRef = useRef(null); + const previewActiveRef = useRef(false); + const streamStartedAtRef = useRef(null); + const fpsSampleRef = useRef<{ ts: number; frames: number }>({ + ts: performance.now(), + frames: 0, + }); + const statsRef = useRef({ + requestCount: 0, + frameCount: 0, + requestFps: 0, + effectiveFps: 0, + lastRequestTime: null, + lastFrameTime: null, + requestSamples: [], + frameSamples: [], + }); + + const resetStreamStats = () => { + statsRef.current = { + requestCount: 0, + frameCount: 0, + requestFps: 0, + effectiveFps: 0, + lastRequestTime: null, + lastFrameTime: null, + requestSamples: [], + frameSamples: [], + }; + fpsSampleRef.current = { ts: performance.now(), frames: 0 }; + streamStartedAtRef.current = null; + setActualFps(0); + }; + + const updateCameraStatus = async () => { + try { + const next = await requestJson("/api/debug/camera/status", { cache: "no-store" }); + setStatus(next); + if (!next.streaming) setPreviewActive(false); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current != null) { + window.clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const startPreview = async () => { + if (previewBusy) return; + setPreviewBusy(true); + setErr(null); + try { + clearReconnectTimer(); + if (!status?.streaming) { + await requestJson("/api/debug/camera/start", { method: "POST" }); + } + setPreviewActive(true); + setNotice(t("cam.notice.previewStart")); + resetStreamStats(); + streamStartedAtRef.current = performance.now(); + setStreamNonce(Date.now()); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPreviewBusy(false); + } + }; + + const stopPreview = async () => { + if (previewBusy) return; + setPreviewBusy(true); + setStopPending(true); + setErr(null); + try { + // 先卸载预览,释放长连接,再通知后端停止 / Release stream before stop API + clearReconnectTimer(); + setPreviewActive(false); + resetStreamStats(); + setStatus((prev) => (prev ? { ...prev, streaming: false, recording: false } : prev)); + setStreamNonce(Date.now()); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + await requestJson("/api/debug/camera/stop", { method: "POST" }); + setNotice(t("cam.notice.previewStop")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setStopPending(false); + setPreviewBusy(false); + } + }; + + const capture = async () => { + if (!previewActive && !status?.streaming) return; + setCaptureBusy(true); + setErr(null); + try { + const data = await requestJson<{ filename?: string }>("/api/debug/camera/capture", { method: "POST" }); + setNotice(t("cam.notice.captureSaved", { name: data.filename || "capture" })); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setCaptureBusy(false); + } + }; + + const toggleRecord = async () => { + if (recordBusy) return; + setRecordBusy(true); + setErr(null); + try { + if (status?.recording) { + await requestJson("/api/debug/camera/record/stop", { method: "POST" }); + setNotice(t("cam.notice.recordStop")); + } else { + const data = await requestJson<{ filename?: string }>("/api/debug/camera/record/start", { method: "POST" }); + setNotice(t("cam.notice.recordStart", { name: data.filename || "video.avi" })); + } + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setRecordBusy(false); + } + }; + + + const applyRuntimeSettings = async () => { + setErr(null); + try { + const fps = clamp(parseInt(fpsValue, 10) || 5, 1, 60); + await requestJson(`/api/debug/camera/fps?fps=${fps}`, { method: "POST" }); + const [w, h] = resValue.split("x").map((x) => parseInt(x, 10)); + if (w && h) { + await requestJson(`/api/debug/camera/size?width=${w}&height=${h}`, { method: "POST" }); + } + await requestJson(`/api/debug/camera/sampling?mode=${encodeURIComponent(samplingMode)}`, { + method: "POST", + }); + setNotice(t("cam.notice.runtimeApplied")); + setRuntimeDirty(false); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const applyCoreSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + setNotice(t("cam.notice.settingsApplied")); + setFormDirty(false); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const applyModeSettings = async () => { + setErr(null); + try { + await requestJson(`/api/debug/camera/auto-exposure?enabled=${form.autoExposure ? "true" : "false"}`, { + method: "POST", + }); + await requestJson( + `/api/debug/camera/white-balance?mode=${encodeURIComponent(form.whiteBalanceMode)}&gain_r=${form.whiteBalanceGainR}&gain_b=${form.whiteBalanceGainB}`, + { method: "POST" }, + ); + await requestJson(`/api/debug/camera/color-mode?color_mode=${encodeURIComponent(form.colorMode)}`, { + method: "POST", + }); + setNotice(t("cam.notice.modeApplied")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const syncFormFromStatus = (info: CameraInfo | undefined) => { + if (!info) return; + setForm({ + exposure: clamp(Math.round(toNum(info.exposure_us, 5000)), 100, 120000), + gain: clamp(toNum(info.analogue_gain, 1.0), 1.0, 24.0), + digitalGain: clamp(toNum(info.digital_gain, 1.0), 1.0, 8.0), + autoExposure: Boolean(info.auto_exposure ?? true), + contrast: clamp(toNum(info.contrast, 1.0), 0, 2), + brightness: clamp(toNum(info.brightness, 0.0), -1, 1), + saturation: clamp(toNum(info.saturation, 1.0), 0, 2), + sharpness: clamp(toNum(info.sharpness, 1.0), 0, 2), + noiseReduction: clamp(Math.round(toNum(info.noise_reduction, 0)), 0, 4), + whiteBalanceMode: String(info.white_balance_mode ?? "auto"), + whiteBalanceGainR: clamp(toNum(info.white_balance_gain_r, 1.0), 0.1, 3.0), + whiteBalanceGainB: clamp(toNum(info.white_balance_gain_b, 1.0), 0.1, 3.0), + colorMode: String(info.color_mode ?? "color"), + }); + setFpsValue(String(Math.round(toNum(info.fps, 5)))); + setResValue(`${Math.round(toNum(info.width, 1280))}x${Math.round(toNum(info.height, 720))}`); + setSamplingMode(String(info.sampling_mode ?? "supersample")); + setRuntimeDirty(false); + setRotationValue(clamp(Math.round(toNum(info.rotation, 180)), 0, 270)); + setFormDirty(false); + }; + + const applyRotation = async (rotation: number) => { + setErr(null); + try { + await requestJson(`/api/debug/camera/rotation/${rotation}`, { method: "POST" }); + setRotationValue(rotation); + setNotice(t("cam.notice.rotationApplied", { value: rotation })); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const toggleNightMode = async (enabled: boolean) => { + setErr(null); + try { + await requestJson(`/api/debug/camera/night-mode?enabled=${enabled ? "true" : "false"}`, { + method: "POST", + }); + setNotice(enabled ? t("cam.notice.nightOn") : t("cam.notice.nightOff")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const resetSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/reset", { method: "POST" }); + setNotice(t("cam.notice.reset")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const backupSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/backup-settings", { method: "POST" }); + setNotice(t("cam.notice.backup")); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const restoreSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/restore-settings", { method: "POST" }); + setNotice(t("cam.notice.restore")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const loadPresets = async () => { + setPresetBusy(true); + try { + const data = await requestJson<{ presets?: CameraPreset[] }>("/api/debug/camera/presets", { cache: "no-store" }); + setPresets(data.presets ?? []); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const savePreset = async () => { + const name = presetName.trim(); + if (!name) return; + setPresetBusy(true); + setErr(null); + try { + await requestJson("/api/debug/camera/presets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description: presetDesc.trim(), + exposure_us: form.exposure, + analogue_gain: form.gain, + digital_gain: form.digitalGain, + auto_exposure: form.autoExposure, + contrast: form.contrast, + brightness: form.brightness, + saturation: form.saturation, + sharpness: form.sharpness, + noise_reduction: form.noiseReduction, + white_balance_mode: form.whiteBalanceMode, + white_balance_gain_r: form.whiteBalanceGainR, + white_balance_gain_b: form.whiteBalanceGainB, + rotation: rotationValue, + color_mode: form.colorMode, + }), + }); + setNotice(t("cam.notice.presetSaved", { name })); + setPresetName(""); + setPresetDesc(""); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const applyPreset = async (name: string) => { + setPresetBusy(true); + setErr(null); + try { + await requestJson(`/api/debug/camera/presets/${encodeURIComponent(name)}/apply`, { method: "POST" }); + setNotice(t("cam.notice.presetApplied", { name })); + await updateCameraStatus(); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const deletePreset = async (name: string) => { + if (!window.confirm(t("cam.confirm.deletePreset", { name }))) return; + setPresetBusy(true); + setErr(null); + try { + await requestJson(`/api/debug/camera/presets/${encodeURIComponent(name)}`, { method: "DELETE" }); + setNotice(t("cam.notice.presetDeleted", { name })); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const loadFiles = async () => { + setFileBusy(true); + try { + const data = await requestJson<{ files?: DebugFileItem[] }>("/api/debug/files", { cache: "no-store" }); + setFiles(data.files ?? []); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setFileBusy(false); + } + }; + + const showFileInfo = async (name: string) => { + setFileInfoBusy(true); + setErr(null); + try { + const data = await requestJson(`/api/debug/files/${encodeURIComponent(name)}/info`, { cache: "no-store" }); + setFileInfo(data); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setFileInfoBusy(false); + } + }; + + const downloadFile = (name: string) => { + const triggerDownload = (filename: string, href: string) => { + const a = document.createElement("a"); + a.href = href; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + triggerDownload(name, `/api/debug/files/${encodeURIComponent(name)}`); + const mediaMatch = name.match(/\.(jpe?g|png|bmp|tiff?|webp|mp4|avi|mov|mkv|wmv|flv|webm|m4v)$/i); + if (mediaMatch) { + const stem = name.slice(0, -mediaMatch[0].length); + const sidecar = `${stem}.txt`; + void (async () => { + try { + const res = await fetch(`/api/debug/files/${encodeURIComponent(sidecar)}`); + if (!res.ok) return; + triggerDownload(sidecar, `/api/debug/files/${encodeURIComponent(sidecar)}`); + setNotice(t("cam.notice.downloadWithSidecar", { name, sidecar })); + } catch { + setNotice(t("cam.notice.download", { name })); + } + })(); + return; + } + setNotice(t("cam.notice.download", { name })); + }; + + const deleteFile = async (name: string) => { + if (!window.confirm(t("cam.confirm.deleteFile", { name }))) return; + setErr(null); + try { + await requestJson(`/api/debug/files/${encodeURIComponent(name)}`, { method: "DELETE" }); + setNotice(t("cam.notice.fileDeleted", { name })); + if (fileInfo?.filename === name) setFileInfo(null); + await loadFiles(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const analyzeStreamData = () => { + const now = performance.now(); + const s = statsRef.current; + s.requestCount += 1; + if (s.lastRequestTime != null) { + const diff = now - s.lastRequestTime; + if (diff > 10) { + s.requestSamples.push(1000 / diff); + if (s.requestSamples.length > 10) s.requestSamples.shift(); + s.requestFps = s.requestSamples.reduce((a, b) => a + b, 0) / s.requestSamples.length; + } + } + s.lastRequestTime = now; + + s.frameCount += 1; + if (s.lastFrameTime != null) { + const diff = now - s.lastFrameTime; + if (diff > 10) { + let fps = 1000 / diff; + const reported = Number(status?.info?.fps ?? 5) || 5; + fps = Math.min(fps, Math.max(10, reported * 2)); + s.frameSamples.push(fps); + if (s.frameSamples.length > 10) s.frameSamples.shift(); + s.effectiveFps = s.frameSamples.reduce((a, b) => a + b, 0) / s.frameSamples.length; + } + } + s.lastFrameTime = now; + }; + + const updateHistogramFromImage = () => { + if (!showHistogram || !imgRef.current || !histogramCanvasRef.current) return; + const imageElement = imgRef.current; + if (!imageElement.naturalWidth || !imageElement.naturalHeight) return; + + if (!offscreenCanvasRef.current) { + offscreenCanvasRef.current = document.createElement("canvas"); + offscreenCtxRef.current = offscreenCanvasRef.current.getContext("2d", { willReadFrequently: true }); + } + if (!histogramCtxRef.current) { + histogramCtxRef.current = histogramCanvasRef.current.getContext("2d"); + } + if (!offscreenCtxRef.current || !histogramCtxRef.current) return; + + const maxSampleWidth = 320; + const scale = Math.min(1, maxSampleWidth / imageElement.naturalWidth); + const sampleWidth = Math.max(1, Math.round(imageElement.naturalWidth * scale)); + const sampleHeight = Math.max(1, Math.round(imageElement.naturalHeight * scale)); + offscreenCanvasRef.current.width = sampleWidth; + offscreenCanvasRef.current.height = sampleHeight; + offscreenCtxRef.current.drawImage(imageElement, 0, 0, sampleWidth, sampleHeight); + const imageData = offscreenCtxRef.current.getImageData(0, 0, sampleWidth, sampleHeight).data; + + const histR = new Array(256).fill(0); + const histG = new Array(256).fill(0); + const histB = new Array(256).fill(0); + const histL = new Array(256).fill(0); + let lumSum = 0; + let lumSq = 0; + let over = 0; + const pixelsCount = sampleWidth * sampleHeight; + + for (let i = 0; i < imageData.length; i += 4) { + const r = imageData[i]; + const g = imageData[i + 1]; + const b = imageData[i + 2]; + const lum = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); + histR[r] += 1; + histG[g] += 1; + histB[b] += 1; + histL[lum] += 1; + lumSum += lum; + lumSq += lum * lum; + if (lum >= 250) over += 1; + } + + const mean = pixelsCount ? lumSum / pixelsCount : 0; + const variance = pixelsCount ? lumSq / pixelsCount - mean * mean : 0; + setHistStats({ mean, std: Math.sqrt(Math.max(0, variance)), over: pixelsCount ? (over / pixelsCount) * 100 : 0 }); + + const canvas = histogramCanvasRef.current; + const ctx = histogramCtxRef.current; + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, canvas.clientWidth || 240); + const h = Math.max(1, canvas.clientHeight || 120); + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const peak = Math.max( + 1, + ...(showRgb ? [Math.max(...histR), Math.max(...histG), Math.max(...histB)] : [0]), + ...(showLuminance ? [Math.max(...histL)] : [0]), + ); + + const draw = (hist: number[], color: string) => { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.2; + for (let i = 0; i < 256; i += 1) { + const x = (i / 255) * w; + const y = h - (hist[i] / peak) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }; + + if (showRgb) { + draw(histR, "rgba(255,80,80,0.85)"); + draw(histG, "rgba(80,255,80,0.85)"); + draw(histB, "rgba(80,160,255,0.85)"); + } + if (showLuminance) draw(histL, "rgba(255,255,255,0.95)"); + if (showOverExposure) { + const warnX = (250 / 255) * w; + ctx.fillStyle = "rgba(255,100,100,0.12)"; + ctx.fillRect(warnX, 0, w - warnX, h); + } + }; + + useEffect(() => { + void updateCameraStatus(); + void loadPresets(); + void loadFiles(); + const statusPoll = window.setInterval(() => { + if (!document.hidden) { + void updateCameraStatus(); + } + }, 2000); + return () => window.clearInterval(statusPoll); + }, [status?.streaming]); + + useEffect(() => { + if (!status?.info || formDirty) return; + syncFormFromStatus(status.info); + }, [status?.info, formDirty]); + + useEffect(() => { + if (!status?.recording) { + if (recordTickRef.current) { + window.clearInterval(recordTickRef.current); + recordTickRef.current = null; + } + setRecordElapsed(0); + return; + } + if (recordTickRef.current) return; + const start = Date.now(); + recordTickRef.current = window.setInterval(() => { + setRecordElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000))); + }, 1000); + return () => { + if (!recordTickRef.current) return; + window.clearInterval(recordTickRef.current); + recordTickRef.current = null; + }; + }, [status?.recording]); + + useEffect(() => { + previewActiveRef.current = previewActive; + if (!previewActive) { + clearReconnectTimer(); + } + }, [previewActive]); + + useEffect(() => { + if (!previewActive) return; + const watchdog = window.setInterval(() => { + const last = statsRef.current.lastFrameTime; + if (last != null && performance.now() - last > 2000) { + void updateCameraStatus(); + setStreamNonce(Date.now()); + } + }, 1000); + return () => window.clearInterval(watchdog); + }, [previewActive]); + + useEffect(() => { + if (!previewActive) { + setActualFps(0); + fpsSampleRef.current = { ts: performance.now(), frames: statsRef.current.frameCount }; + return; + } + const timer = window.setInterval(() => { + const now = performance.now(); + const currentFrames = statsRef.current.frameCount; + const dt = (now - fpsSampleRef.current.ts) / 1000; + const df = currentFrames - fpsSampleRef.current.frames; + if (dt > 0.2) { + setActualFps(Math.max(0, df / dt)); + fpsSampleRef.current = { ts: now, frames: currentFrames }; + } + }, 1000); + return () => window.clearInterval(timer); + }, [previewActive]); + + useEffect(() => { + if (!notice) return; + const timer = window.setTimeout(() => setNotice(null), 3200); + return () => window.clearTimeout(timer); + }, [notice]); + + useEffect(() => () => clearReconnectTimer(), []); + + const streamSrc = previewActive ? `/api/debug/camera/stream?t=${streamNonce}` : ""; + const s = statsRef.current; + const exposureLocked = form.autoExposure; + const wbManual = form.whiteBalanceMode === "manual"; + const nightModeEnabled = Boolean(status?.info?.night_mode); + const isStreaming = previewActive || (Boolean(status?.streaming) && !stopPending); + const canStartPreview = !previewBusy && !previewActive && !Boolean(status?.recording); + const canStopPreview = !previewBusy && isStreaming; + const canCapture = !previewBusy && !captureBusy && isStreaming; + const canRecordToggle = !previewBusy && !recordBusy && isStreaming; + return ( +
+
+
+
+

{`OGScope ${t("cam.title")}`}

+
+
+ + + + {t("cam.btn.system")} + +
+
+
+ +
+ + +
+
+
+
+
+ CPU: {Number(sysInfo?.cpu_usage ?? 0).toFixed(1)}% +
+
+ MEM: {Number(sysInfo?.memory_usage ?? 0).toFixed(1)}% +
+
+ TEMP: {Number(sysInfo?.temperature ?? 0).toFixed(1)}°C +
+
+
+

{t("cam.preview.title")}

+
+ {t("cam.preview.state")}: {status?.streaming ? t("cam.state.streaming") : t("cam.state.idle")} +
+
+
+ {previewActive ? ( + camera-preview { + analyzeStreamData(); + updateHistogramFromImage(); + }} + onError={() => { + clearReconnectTimer(); + if (!previewActiveRef.current) return; + reconnectTimerRef.current = window.setTimeout(() => { + if (previewActiveRef.current) { + setStreamNonce(Date.now()); + } + }, 400); + }} + /> + ) : ( +
+ +
{t("cam.preview.emptyTitle")}
+
{t("cam.preview.emptyDesc")}
+ +
+ )} + {status?.recording && ( +
+ {t("cam.state.rec")} + {`${Math.floor(recordElapsed / 60).toString().padStart(2, "0")}:${(recordElapsed % 60).toString().padStart(2, "0")}`} +
+ )} +
+ +
+ {!histCollapsed && ( +
+
+ + + + +
+ +
+
mean: {histStats.mean.toFixed(1)}
+
std: {histStats.std.toFixed(1)}
+
over: {histStats.over.toFixed(2)}%
+
+
+ )} +
+
+
+ {t("cam.stats.frameFps")}: + {actualFps.toFixed(2)} +
+
+ {t("cam.stats.targetFps")}: + {Number(status?.info?.fps ?? 0).toFixed(2)} +
+
+ {t("cam.stats.frameCount")}: + {s.frameCount} +
+
+ {t("cam.stats.uptime")}: + {streamStartedAtRef.current != null ? `${Math.max(0, Math.round((performance.now() - streamStartedAtRef.current) / 1000))}s` : "0s"} +
+
+ {t("cam.system.sensor")}: + {String(status?.info?.sensor ?? "—")} +
+
+ {t("cam.controls.resolution")}: + {`${status?.info?.width ?? "—"}x${status?.info?.height ?? "—"}`} +
+
+ {t("cam.preview.mode")}: + {status?.info?.auto_exposure ? t("cam.controls.auto") : t("cam.controls.manual")} +
+
+
+ + + + +
+
+ +
+

{t("cam.files.title")}

+
+ +
+
+ {files.length === 0 && !fileBusy &&
{t("cam.files.empty")}
} + {files.map((f) => ( +
+
+
+
{f.name}
+
{formatSize(f.size)} | {new Date(f.modified).toLocaleString()}
+
+
{f.type}
+
+
+ + + +
+
+ ))} +
+ {fileInfoBusy &&
{t("cam.files.loadingInfo")}
} + {fileInfo && ( +
+
{fileInfo.filename}
+
{t("cam.files.size")}: {formatSize(fileInfo.size)}
+
{t("cam.files.type")}: {fileInfo.type}
+
{t("cam.files.modified")}: {new Date(fileInfo.modified).toLocaleString()}
+ {fileInfo.exposure_us != null &&
{t("cam.controls.exposure")}: {fileInfo.exposure_us}us
} + {fileInfo.analogue_gain != null &&
{t("cam.controls.gain")}: {fileInfo.analogue_gain}
} + {fileInfo.resolution &&
{t("cam.controls.resolution")}: {fileInfo.resolution}
} +
+ )} +
+
+ +
+
+

{t("cam.controls.title")}

+
+
+ + +
+ + +
+
+ +
+
+ {t("cam.controls.core")} +
+ {formDirty && ( +
+ {t("cam.controls.pendingChanges")} +
+ )} + {exposureLocked &&

{t("cam.controls.lockedByAe")}

} +
+ { setFormDirty(true); setForm((p) => ({ ...p, exposure: v })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, gain: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, digitalGain: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, noiseReduction: Math.round(v) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, contrast: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, brightness: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, saturation: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, sharpness: Number(v.toFixed(1)) })); }} /> +
+
+ +
+
+ +
+
{t("cam.controls.mode")}
+
+ + + + { setForm((p) => ({ ...p, whiteBalanceGainR: Number(v.toFixed(1)) })); setFormDirty(true); }} /> + { setForm((p) => ({ ...p, whiteBalanceGainB: Number(v.toFixed(1)) })); setFormDirty(true); }} /> +
+ {!wbManual &&

{t("cam.controls.lockedByWb")}

} +
+ +
+
+
+
+
+ + {(err || notice) && ( +
+ {err &&
{err}
} + {notice &&
{notice}
} +
+ )} +
+ ); +} diff --git a/web/analysis-ui/src/context/SystemInfoContext.tsx b/web/analysis-ui/src/context/SystemInfoContext.tsx new file mode 100644 index 0000000..21f6369 --- /dev/null +++ b/web/analysis-ui/src/context/SystemInfoContext.tsx @@ -0,0 +1,70 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +/** 与 /api/system/info 对齐的宽松类型 / Loose shape aligned with system info API */ +export type SystemInfoRecord = Record; + +type Ctx = { + info: SystemInfoRecord | null; + error: string | null; + refresh: () => Promise; +}; + +const SystemInfoContext = createContext(null); + +const POLL_MS = 8000; + +async function fetchInfo(): Promise { + const r = await fetch("/api/system/info", { cache: "no-store" }); + let data: unknown = {}; + try { + data = await r.json(); + } catch { + // ignore + } + if (!r.ok) { + const d = data as { detail?: string }; + throw new Error(d.detail || `HTTP ${r.status}`); + } + return data as SystemInfoRecord; +} + +export function SystemInfoProvider({ children }: { children: ReactNode }) { + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setError(null); + const j = await fetchInfo(); + setInfo(j); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => void refresh(), POLL_MS); + return () => window.clearInterval(id); + }, [refresh]); + + const value = useMemo(() => ({ info, error, refresh }), [info, error, refresh]); + + return ( + {children} + ); +} + +export function useSystemInfo(): Ctx { + const c = useContext(SystemInfoContext); + if (!c) throw new Error("useSystemInfo must be used within SystemInfoProvider"); + return c; +} diff --git a/web/analysis-ui/src/i18n/I18nProvider.tsx b/web/analysis-ui/src/i18n/I18nProvider.tsx index dc8d0ae..8808f88 100644 --- a/web/analysis-ui/src/i18n/I18nProvider.tsx +++ b/web/analysis-ui/src/i18n/I18nProvider.tsx @@ -25,12 +25,23 @@ const BUNDLED: Record> = { en: enDict as Record, }; +const LOCALE_KEY = "ogscope.analysis.locale"; + +function detectInitialLocale(): Locale { + const saved = window.localStorage.getItem(LOCALE_KEY); + if (saved === "zh" || saved === "en") return saved; + const navLang = (navigator.language || "zh").toLowerCase(); + return navLang.startsWith("en") ? "en" : "zh"; +} + export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocale] = useState("zh"); - const [dict, setDict] = useState>(BUNDLED.zh); + const [locale, setLocale] = useState(detectInitialLocale); + const [dict, setDict] = useState>(BUNDLED[detectInitialLocale()]); useEffect(() => { setDict(BUNDLED[locale]); + window.localStorage.setItem(LOCALE_KEY, locale); + document.documentElement.lang = locale === "en" ? "en" : "zh-CN"; }, [locale]); const t = useMemo( diff --git a/web/analysis-ui/src/pages/CameraPage.tsx b/web/analysis-ui/src/pages/CameraPage.tsx new file mode 100644 index 0000000..9bffbd2 --- /dev/null +++ b/web/analysis-ui/src/pages/CameraPage.tsx @@ -0,0 +1,16 @@ +import { useI18n } from "../i18n/I18nProvider"; + +/** 嵌入相机调试页(逻辑在 debug.js)/ Embedded camera debug (logic in debug.js) */ +export function CameraPage() { + const { t } = useI18n(); + return ( +
+

{t("console.camera.hint")}

+