统一 Docker Compose 部署,并将客户端拆分为独立子项目。
移除宿主机/conda 启动脚本与 dev 联调工具,后端仅通过 docker compose 部署并默认启用 GPU。模拟客户端与语音确认页迁入 clients/ 下自包含目录,切断对后端源码路径的依赖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
72
clients/demo-client/README.md
Executable file
72
clients/demo-client/README.md
Executable file
@@ -0,0 +1,72 @@
|
||||
# Demo Client
|
||||
|
||||
独立浏览器联调页,用于手动触发监控 API 的部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用同级的 [`../voice-confirmation/`](../voice-confirmation/) 或其它专用客户端。
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
clients/demo-client/
|
||||
start.sh # 启动入口(默认 0.0.0.0:38081)
|
||||
server.py # stdlib 静态服务器;额外暴露 /labels.json
|
||||
index.html # 单文件页面(原生 JS,零构建依赖)
|
||||
labels.yaml # 耗材类名快照(与后端 app/resources/consumable_classifier_labels.yaml 同步)
|
||||
fake_rtsp_from_file.py # 无真摄像头时:本地视频推 RTSP(ffmpeg + Docker MediaMTX)
|
||||
```
|
||||
|
||||
**`labels.yaml`**:本目录自带副本,与后端解耦。后端类名变更时,请同步更新此文件。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 1) 在仓库根目录启动 Docker 后端
|
||||
docker compose up -d --build
|
||||
|
||||
# 2) 在本目录启动 Demo 页
|
||||
./start.sh
|
||||
# 或:python server.py -p 38081 --host 0.0.0.0
|
||||
|
||||
# 3) 浏览器访问
|
||||
open http://127.0.0.1:38081/
|
||||
```
|
||||
|
||||
页面「服务端 Base URL」默认指向同主机 `:38080`;后端在其他机器时手动改为 `http://<GPU服务器IP>:38080`。
|
||||
|
||||
## 调试:无真实摄像头,用录好的视频模拟 RTSP
|
||||
|
||||
监控服务**只从 RTSP URL 拉流**,没有视频上传 HTTP 接口。推荐在宿主机用 **ffmpeg + MediaMTX** 推假流:
|
||||
|
||||
**单路**:
|
||||
|
||||
```bash
|
||||
python3 fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
|
||||
```
|
||||
|
||||
**两路**:
|
||||
|
||||
```bash
|
||||
python3 fake_rtsp_from_file.py --port 18554 \
|
||||
--stream 'or-cam-01|./a.mp4|demo1' \
|
||||
--stream 'or-cam-02|./b.mp4|demo2'
|
||||
```
|
||||
|
||||
`--stream` 格式:`CAMERA_ID|文件路径|RTSP_PATH`。脚本会在 stderr 打印站点 JSON 片段,可合并进后端的 `OR_SITE_CONFIG_JSON_FILE`。
|
||||
|
||||
### API 在 Docker、假 RTSP 在宿主机
|
||||
|
||||
容器内访问宿主机 RTSP 应使用 `rtsp://host.docker.internal:<端口>/<路径>`(compose 已配置 `extra_hosts`)。详见 [`../../docs/video-backends.md`](../../docs/video-backends.md)。
|
||||
|
||||
## 一键开录
|
||||
|
||||
Demo 页勾选「一键联调」后上传视频,调用 `POST /internal/demo/orchestrate-and-start`。需后端 `.env` 中 `DEMO_ORCHESTRATOR_ENABLED=true`,且 API 容器能执行 `docker`/`ffmpeg`(通常需挂载 docker.sock)。
|
||||
|
||||
## 页面功能
|
||||
|
||||
- `GET /health` 连通性检查
|
||||
- `POST /client/surgeries/start|end`、`GET /client/surgeries/{id}/result`
|
||||
- 调试区:多路视频、假 RTSP、`camera_id` 同步
|
||||
|
||||
不含语音确认 UI(见 [`../voice-confirmation/`](../voice-confirmation/))。
|
||||
|
||||
## CORS
|
||||
|
||||
跨域访问 API 时,后端需 `DEMO_CORS_ENABLED=true`;生产环境收窄 `DEMO_CORS_ORIGINS`。
|
||||
281
clients/demo-client/fake_rtsp_from_file.py
Executable file
281
clients/demo-client/fake_rtsp_from_file.py
Executable file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish local video file(s) to RTSP once per file (fake camera) for local dev.
|
||||
|
||||
The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API.
|
||||
This script does NOT change the application backend: it runs ffmpeg + a small
|
||||
RTSP server (MediaMTX); put the printed ``video_rtsp_urls`` into ``OR_SITE_CONFIG_JSON_FILE``.
|
||||
|
||||
Requires:
|
||||
- ffmpeg in PATH
|
||||
- Docker; 默认拉取 ``MEDIAMTX_DOCKER_IMAGE``(DaoCloud 前缀的 bluenviron/mediamtx),
|
||||
或本地已拉取的镜像;也可用 PATH 中的 ``mediamtx`` 二进制(高级)。
|
||||
|
||||
Single stream (legacy)::
|
||||
python3 fake_rtsp_from_file.py /path/to/video.mp4
|
||||
python3 fake_rtsp_from_file.py video.mp4 --port 18554 --path demo
|
||||
|
||||
Multiple streams (one MediaMTX, one ffmpeg per camera; different RTSP path per stream)::
|
||||
|
||||
python3 fake_rtsp_from_file.py --port 18554 \\
|
||||
--stream 'or-cam-01|./a.mp4|demo1' \\
|
||||
--stream 'or-cam-02|./b.mp4|demo2'
|
||||
|
||||
--stream format: ``CAMERA_ID|FILE|RTSP_PATH`` (use quotes in shell; RTSP path is
|
||||
the last segment, e.g. ``demo1`` -> ``rtsp://127.0.0.1:<port>/demo1``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 默认 DaoCloud 镜像前缀;可设 MEDIAMTX_DOCKER_IMAGE=bluenviron/mediamtx:latest 直连 Docker Hub
|
||||
MEDIAMTX_IMAGE = os.environ.get(
|
||||
"MEDIAMTX_DOCKER_IMAGE",
|
||||
"m.daocloud.io/docker.io/bluenviron/mediamtx:latest",
|
||||
)
|
||||
CONTAINER_NAME = "orm-fake-rtsp-mediamtx"
|
||||
|
||||
|
||||
def _has_docker() -> bool:
|
||||
return shutil.which("docker") is not None
|
||||
|
||||
|
||||
def _has_ffmpeg() -> bool:
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
def _stop_mediamtx_container() -> None:
|
||||
if not _has_docker():
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def _start_mediamtx_docker(host_port: int) -> bool:
|
||||
_stop_mediamtx_container()
|
||||
cmd = [
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
CONTAINER_NAME,
|
||||
"-p",
|
||||
f"127.0.0.1:{host_port}:8554",
|
||||
MEDIAMTX_IMAGE,
|
||||
]
|
||||
print("[fake-rtsp] Starting MediaMTX:", " ".join(cmd), file=sys.stderr)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"[fake-rtsp] docker run failed: {exc}", file=sys.stderr)
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip()
|
||||
print(f"[fake-rtsp] docker run exit {proc.returncode}: {err}", file=sys.stderr)
|
||||
return False
|
||||
atexit.register(_stop_mediamtx_container)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_stream_arg(spec: str) -> tuple[str, Path, str]:
|
||||
parts = spec.split("|", 2)
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid --stream {spec!r}; expected CAM|FILE|RTSP_PATH (three fields separated by |)")
|
||||
cam = parts[0].strip()
|
||||
fpath = Path(parts[1].strip()).expanduser()
|
||||
rpath = parts[2].strip().strip("/")
|
||||
if not cam:
|
||||
raise ValueError("empty camera id in --stream")
|
||||
if not rpath:
|
||||
rpath = "demo"
|
||||
return cam, fpath, rpath
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Play each video file once to an RTSP URL (dev fake camera; no backend code change).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"video",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="(single-stream mode) Path to a video file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default="demo",
|
||||
help="(single-stream mode) RTSP path segment (rtsp://host:port/<path>)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=18554,
|
||||
help="Host port mapped to MediaMTX RTSP (container internal 8554). Default: 18554",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stream",
|
||||
action="append",
|
||||
default=None,
|
||||
help=("Multi-stream mode. Repeat for each camera. Format: CAM|FILE|RTSP_PATH e.g. or-cam-01|./a.mp4|demo1"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-docker",
|
||||
action="store_true",
|
||||
help="Do not start Docker; run MediaMTX yourself on the host port mapping.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not _has_ffmpeg():
|
||||
print("ffmpeg not found in PATH. Install ffmpeg and retry.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
streams: list[tuple[str, Path, str]] = []
|
||||
if args.stream:
|
||||
for s in args.stream:
|
||||
try:
|
||||
streams.append(_parse_stream_arg(s))
|
||||
except ValueError as exc:
|
||||
print(f"[fake-rtsp] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
elif args.video is not None:
|
||||
fpath = args.video.resolve()
|
||||
sp = (args.path or "demo").strip().strip("/") or "demo"
|
||||
streams = [("or-cam-01", fpath, sp)]
|
||||
else:
|
||||
parser.error("Provide a video file (single mode) or one or more --stream CAM|FILE|RTSP_PATH")
|
||||
|
||||
for cam, fpath, rpath in streams:
|
||||
rp_file = fpath.resolve()
|
||||
if not rp_file.is_file():
|
||||
print(f"File not found: {rp_file} (camera {cam!r})", file=sys.stderr)
|
||||
return 1
|
||||
for ch in rpath:
|
||||
if ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-":
|
||||
print(
|
||||
f"[fake-rtsp] RTSP path segment {rpath!r} for {cam!r} should be "
|
||||
r"[a-zA-Z0-9_.-] only; adjust --path/--stream",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
host_port: int = args.port
|
||||
if not args.no_docker:
|
||||
if not _has_docker():
|
||||
print("Docker not found. Use --no-docker and start MediaMTX manually.", file=sys.stderr)
|
||||
return 1
|
||||
if not _start_mediamtx_docker(host_port):
|
||||
return 1
|
||||
print("[fake-rtsp] MediaMTX container started. Waiting for RTSP…", file=sys.stderr)
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
print(
|
||||
f"[fake-rtsp] --no-docker: ensure an RTSP server is listening for publish on port {host_port}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
procs: list[subprocess.Popen] = []
|
||||
url_map: dict[str, str] = {}
|
||||
|
||||
for cam, fpath, stream_path in streams:
|
||||
fp = fpath.resolve()
|
||||
dest_url = f"rtsp://127.0.0.1:{host_port}/{stream_path}"
|
||||
url_map[cam] = dest_url
|
||||
publish_cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"info",
|
||||
"-re",
|
||||
"-i",
|
||||
str(fp),
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"rtsp",
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
dest_url,
|
||||
]
|
||||
print("---", file=sys.stderr)
|
||||
print(f"Publish {cam} -> {dest_url}", file=sys.stderr)
|
||||
print(" " + " ".join(publish_cmd), file=sys.stderr)
|
||||
p = subprocess.Popen(publish_cmd) # noqa: S603
|
||||
procs.append(p)
|
||||
|
||||
site_doc = {"video_rtsp_urls": url_map, "voice_or_room_bindings": []}
|
||||
print("---", file=sys.stderr)
|
||||
print("RTSP mapping (per camera):", file=sys.stderr)
|
||||
for k, u in url_map.items():
|
||||
print(f" {k}: {u}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(
|
||||
"OR site config (merge video_rtsp_urls into OR_SITE_CONFIG_JSON_FILE; add voice_or_room_bindings as needed):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(json.dumps(site_doc, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("If the server runs in Docker on Mac/Win, use host.docker.internal, e.g.:", file=sys.stderr)
|
||||
for cam, u in url_map.items():
|
||||
h = u.replace("127.0.0.1", "host.docker.internal", 1)
|
||||
print(f" {cam}: {h}", file=sys.stderr)
|
||||
print("---", file=sys.stderr)
|
||||
print(
|
||||
"Fake RTSP running: each file plays once; script exits when ffmpeg ends "
|
||||
"(Ctrl+C to stop early; MediaMTX container removed on exit).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def on_sigint(_sig: int, _frame) -> None:
|
||||
for p in procs:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
_stop_mediamtx_container()
|
||||
raise SystemExit(130)
|
||||
|
||||
signal.signal(signal.SIGINT, on_sigint)
|
||||
signal.signal(signal.SIGTERM, on_sigint)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
for p in procs:
|
||||
if p.poll() is not None:
|
||||
print(
|
||||
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise KeyboardInterrupt
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for p in procs:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
_stop_mediamtx_container()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1070
clients/demo-client/index.html
Executable file
1070
clients/demo-client/index.html
Executable file
File diff suppressed because it is too large
Load Diff
87
clients/demo-client/labels.yaml
Normal file
87
clients/demo-client/labels.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
# 与训练 data.yaml 类名一致;label_id 来自 refs/商品信息表.xlsx(商品名称→产品编码;同名多规格以 / 连接)。
|
||||
# 推理时以权重内嵌 names 为准;本文件供业务 label_id 与开录空 candidate 全量类名。
|
||||
names:
|
||||
0: MCuⅡ功能性宫内节育器
|
||||
1: 一次性中性电极板
|
||||
2: 一次性使用乳胶导尿管
|
||||
3: 一次性使用冲洗袋
|
||||
4: 一次性使用医疗卫生用品
|
||||
5: 一次性使用单极手术电极
|
||||
6: 一次性使用导尿管
|
||||
7: 一次性使用手术单
|
||||
8: 一次性使用无菌敷贴
|
||||
9: 一次性使用无菌气管插管Tracheal Tube
|
||||
10: 一次性使用无菌注射器带针
|
||||
11: 一次性使用无菌采样拭子
|
||||
12: 一次性使用气管插管
|
||||
13: 一次性使用灭菌橡胶外科手套
|
||||
14: 一次性使用牙垫
|
||||
15: 一次性使用精密过滤输液器 带针
|
||||
16: 一次性使用肛门管
|
||||
17: 一次性使用胃管
|
||||
18: 一次性使用血液透析管路
|
||||
19: 一次性使用输卵管导管
|
||||
20: 一次性使用雾化器
|
||||
21: 一次性使用静脉留置针
|
||||
22: 一次性使用静脉输液针
|
||||
23: 一次性使用麻醉面罩
|
||||
24: 一次性内窥镜护套
|
||||
25: 一次性医用灭菌棉签
|
||||
26: 一次性无菌喉罩
|
||||
27: 医用凡士林敷料
|
||||
28: 医用纱布敷料
|
||||
29: 医用缝合针
|
||||
30: 医用脱脂棉纱布块
|
||||
31: 可吸收性外科缝线
|
||||
32: 密闭式防针刺伤型静脉留置针
|
||||
33: 导管固定器
|
||||
34: 气管切开插管
|
||||
35: 结扎夹Ligating Clips
|
||||
36: 自粘性薄膜敷料
|
||||
37: 血液净化装置的体外循环血路
|
||||
38: 负压引流器
|
||||
39: 非吸收性外科缝线
|
||||
40: 非吸收性外科缝线(蚕丝线)
|
||||
label_id:
|
||||
0: "740-2-14"
|
||||
1: "4787-2-55"
|
||||
2: "7386-10-89"
|
||||
3: "1644-37-3"
|
||||
4: "2241272"
|
||||
5: "4805-2-50"
|
||||
6: "735592/14556-4-18"
|
||||
7: "14764-2-4"
|
||||
8: "215-93-1"
|
||||
9: "14780-3-5"
|
||||
10: "1531-3-2/1531-3-1/1174-42-4/1174-42-1"
|
||||
11: "15026-1-1"
|
||||
12: "21444-1-2"
|
||||
13: "10362-1-4"
|
||||
14: "975961"
|
||||
15: "2295950"
|
||||
16: "1518-22-4"
|
||||
17: "1518-34-17"
|
||||
18: "14730-10-10"
|
||||
19: "1380-15-1"
|
||||
20: "5019-4-43"
|
||||
21: "12591-1-184"
|
||||
22: "129-5-30"
|
||||
23: "2003984"
|
||||
24: "521-31-1"
|
||||
25: "2237844/10183-1-29"
|
||||
26: "7386-61-46"
|
||||
27: "10870-25-16"
|
||||
28: "19246-3-14"
|
||||
29: "583039/11207-1-64"
|
||||
30: "8028-4-39"
|
||||
31: "11765-1-101/1330-49-185"
|
||||
32: "1281-39-3"
|
||||
33: "1441340"
|
||||
34: "10869-30-7"
|
||||
35: "14780-2-12"
|
||||
36: "1819-4-1"
|
||||
37: "739-2-1"
|
||||
38: "1518-20-8"
|
||||
39: "4142-1-46"
|
||||
40: "654032"
|
||||
nc: 41
|
||||
131
clients/demo-client/server.py
Executable file
131
clients/demo-client/server.py
Executable file
@@ -0,0 +1,131 @@
|
||||
"""Tiny stdlib HTTP server for the demo client page.
|
||||
|
||||
- Serves `index.html` (and other sibling files) from this directory.
|
||||
- Exposes `GET /labels.json`, which parses local `labels.yaml` and returns its
|
||||
label list so the page can prefill the candidate-consumables input.
|
||||
|
||||
Run:
|
||||
python server.py # 0.0.0.0:38081
|
||||
python server.py -p 9000 # custom port
|
||||
./start.sh
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
LABELS_YAML = SCRIPT_DIR / "labels.yaml"
|
||||
|
||||
|
||||
def _load_labels_with_pyyaml(path: Path) -> list[str] | None:
|
||||
try:
|
||||
import yaml # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
data: Any = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
names = data.get("names")
|
||||
if isinstance(names, dict):
|
||||
try:
|
||||
items = sorted(names.items(), key=lambda kv: int(kv[0]))
|
||||
except (TypeError, ValueError):
|
||||
items = list(names.items())
|
||||
return [str(v) for _, v in items]
|
||||
if isinstance(names, list):
|
||||
return [str(v) for v in names]
|
||||
return None
|
||||
|
||||
|
||||
def _load_labels_fallback(path: Path) -> list[str]:
|
||||
"""Minimal parser for the known labels.yaml shape: `<int>: <text>` under `names:`."""
|
||||
labels: list[tuple[int, str]] = []
|
||||
in_names = False
|
||||
pattern = re.compile(r"^\s+(\d+)\s*:\s*(.+?)\s*$")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith("names:"):
|
||||
in_names = True
|
||||
continue
|
||||
if in_names and not line.startswith((" ", "\t")):
|
||||
in_names = False
|
||||
if not in_names:
|
||||
continue
|
||||
match = pattern.match(line)
|
||||
if match:
|
||||
labels.append((int(match.group(1)), match.group(2)))
|
||||
labels.sort(key=lambda kv: kv[0])
|
||||
return [name for _, name in labels]
|
||||
|
||||
|
||||
def load_labels() -> list[str]:
|
||||
if not LABELS_YAML.is_file():
|
||||
return []
|
||||
labels = _load_labels_with_pyyaml(LABELS_YAML)
|
||||
if labels is None:
|
||||
labels = _load_labels_fallback(LABELS_YAML)
|
||||
return labels
|
||||
|
||||
|
||||
class DemoHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 (stdlib override)
|
||||
if self.path.split("?", 1)[0] == "/labels.json":
|
||||
self._send_labels()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def _send_labels(self) -> None:
|
||||
body = json.dumps({"labels": load_labels()}, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
sys.stderr.write("[demo-client] %s - %s\n" % (self.address_string(), format % args))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Operation room demo client server")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("-p", "--port", type=int, default=38081)
|
||||
args = parser.parse_args()
|
||||
|
||||
server = ThreadingHTTPServer((args.host, args.port), DemoHandler)
|
||||
url = f"http://{args.host}:{args.port}/"
|
||||
print(f"Demo client serving at {url}")
|
||||
print(f" static dir : {SCRIPT_DIR}")
|
||||
print(f" labels yaml: {LABELS_YAML}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down.")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
clients/demo-client/start.sh
Executable file
36
clients/demo-client/start.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the standalone demo client page for LAN access.
|
||||
# Backend must be running first: docker compose up -d --build (in repo root)
|
||||
# Usage: ./start.sh [port]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
PORT="${1:-${DEMO_PORT:-38081}}"
|
||||
HOST="${DEMO_HOST:-0.0.0.0}"
|
||||
API_PORT="${API_PORT:-38080}"
|
||||
|
||||
LAN_IP="$(python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
print(s.getsockname()[0])
|
||||
except OSError:
|
||||
print("")
|
||||
finally:
|
||||
s.close()
|
||||
PY
|
||||
)"
|
||||
|
||||
echo "Demo client listening on ${HOST}:${PORT}"
|
||||
echo "Local: http://127.0.0.1:${PORT}/"
|
||||
if [[ -n "${LAN_IP}" && ( "${HOST}" == "0.0.0.0" || "${HOST}" == "::" ) ]]; then
|
||||
echo "LAN: http://${LAN_IP}:${PORT}/"
|
||||
echo "Base URL: http://${LAN_IP}:${API_PORT}"
|
||||
fi
|
||||
|
||||
exec python3 server.py --host "$HOST" -p "$PORT"
|
||||
Reference in New Issue
Block a user