Compare commits
19 Commits
c81a20b2ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3895c24c | |||
| 7b9ec2e148 | |||
| fa2c90e250 | |||
| 1059850378 | |||
| 46889c0621 | |||
| 547fb6290f | |||
| 45e2cf70f7 | |||
| e919ffd561 | |||
| 04729a0fd1 | |||
| 523f928303 | |||
| 8f516fdc01 | |||
| 81f170924c | |||
| 9d791be174 | |||
| 490b3089d2 | |||
|
|
8b5bbff364 | ||
|
|
ea5f9b1b07 | ||
|
|
96f5c14a26 | ||
|
|
aff2b1828e | ||
|
|
08c5d2e955 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
logs/
|
||||||
|
*.jsonl
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
managed-portal.textClipping
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.textClipping
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.venv/
|
.venv/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
129
AGENTS.md
Normal file
129
AGENTS.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Cold Display Guard Agent Instructions
|
||||||
|
|
||||||
|
## Repository Snapshot
|
||||||
|
|
||||||
|
- Root purpose: `cold-display-guard` monitors refrigerated display food batches, tracks dwell time per configured display zone, and records disposal compliance events.
|
||||||
|
- Backend: Python 3.11+ package in `src/cold_display_guard`, using only the standard library for application code.
|
||||||
|
- Frontend: Vite + vanilla JavaScript management console in `web/`.
|
||||||
|
- Data/storage: JSONL runtime outputs under `logs/` by default; configuration is TOML in `config/example.toml`.
|
||||||
|
- Runtime services:
|
||||||
|
- Management API: `127.0.0.1:19080`, routes under `/api/manage/*`.
|
||||||
|
- Web console: `127.0.0.1:23000`, Vite proxies `/api` to the management API.
|
||||||
|
- Runtime monitor: RTSP frame sampling through `ffmpeg`, writing events and diagnostics JSONL.
|
||||||
|
- Deployment: Docker/Compose files are present; containers mount `config/` and `logs/`, use `Asia/Shanghai`, and prefer China-accessible package/image mirrors.
|
||||||
|
|
||||||
|
## Repository Map
|
||||||
|
|
||||||
|
- `src/cold_display_guard/engine.py`: pure batch state machine and compliance event generation.
|
||||||
|
- `src/cold_display_guard/models.py`: domain dataclasses and observation parsing.
|
||||||
|
- `src/cold_display_guard/config.py`: TOML loading, saving, calibration merge, and path resolution.
|
||||||
|
- `src/cold_display_guard/manage_api.py`: standard-library HTTP management API and RTSP snapshot capture.
|
||||||
|
- `src/cold_display_guard/main.py`: long-running RTSP monitor that connects frame capture, vision detection, engine, and JSONL sinks.
|
||||||
|
- `src/cold_display_guard/frame_source.py`: `ffmpeg` raw RGB frame capture.
|
||||||
|
- `src/cold_display_guard/vision.py`: heuristic ROI occupancy and trash-motion detection.
|
||||||
|
- `src/cold_display_guard/cli.py`: JSONL observation CLI for deterministic engine processing.
|
||||||
|
- `web/src/main.js` and `web/src/styles.css`: management console UI.
|
||||||
|
- `scripts/`: local launch scripts for API, web, and runtime services.
|
||||||
|
- `deploy/`, `Dockerfile`, `web/Dockerfile`: container deployment artifacts.
|
||||||
|
- `tests/`: unittest coverage for engine, CLI, config, management summary/config behavior, and vision heuristics.
|
||||||
|
- `docs/plans/`, `task_plan.md`, `progress.md`, `findings.md`: existing planning and project-history artifacts.
|
||||||
|
|
||||||
|
## Core Domain Rules
|
||||||
|
|
||||||
|
- The reliable business unit is a display-zone batch, not an individual food item.
|
||||||
|
- Default layout is 2 rows by 4 columns with zone IDs `r1c1` through `r2c4`; layout and polygons are configurable.
|
||||||
|
- A batch starts when a zone changes from empty to occupied.
|
||||||
|
- A batch ends when a zone changes from occupied to empty.
|
||||||
|
- Count decreases keep the same batch active and emit `batch_count_changed`.
|
||||||
|
- Count increases before the zone clears are mixed-batch violations and emit `mixed_batch_violation`.
|
||||||
|
- Removal before `max_dwell_seconds` emits `batch_consumed`.
|
||||||
|
- Removal at or after `max_dwell_seconds` emits `batch_pending_disposal` and waits for trash confirmation.
|
||||||
|
- A trash deposit within `trash_confirmation_seconds` emits `batch_discarded`.
|
||||||
|
- No trash deposit before the deadline emits `missing_disposal_violation`.
|
||||||
|
- Any new occupied zone while an overdue batch is pending disposal emits `overdue_return_violation`.
|
||||||
|
- The current vision layer reports binary `0/1` occupancy per zone; it does not count individual items.
|
||||||
|
- The detector learns an empty baseline from the first configured frames. If food is already present at startup, it may become baseline until the image changes.
|
||||||
|
|
||||||
|
## Change Rules
|
||||||
|
|
||||||
|
- Keep `BatchEngine` deterministic and free of camera, file, HTTP, subprocess, or wall-clock dependencies.
|
||||||
|
- Add or update focused tests when changing business rules, event names, event payloads, observation parsing, config formatting, or path resolution.
|
||||||
|
- Keep the observation contract stable: `ts`, `zone_counts`, and `trash_deposit` or `trash_deposit_count`.
|
||||||
|
- If event names or payload shapes change, update engine tests, CLI tests, runtime code, management summary behavior, frontend rendering, and README examples together.
|
||||||
|
- Keep ROI and polygon coordinates normalized to `0.0..1.0`; clamp or validate inputs at config/API boundaries.
|
||||||
|
- Keep `manage_api.py` as a small standard-library HTTP service unless the user explicitly asks to introduce a web framework.
|
||||||
|
- Preserve explicit `ffmpeg` timeout and error reporting behavior in `frame_source.py` and snapshot capture.
|
||||||
|
- Treat RTSP URLs, camera credentials, captured frames, and logs as sensitive operational data. Do not paste secrets into new docs, commits, or test fixtures.
|
||||||
|
- Do not commit generated runtime data such as `logs/`, captured snapshots, Vite `dist/`, Python caches, or ad hoc diagnostics.
|
||||||
|
- Frontend changes should preserve the current Vite single-page app, `/api/manage/*` backend contract, and 23000/19080 local development split.
|
||||||
|
- Deployment changes must keep README commands, scripts, ports, env vars, compose volumes, Docker health checks, and config paths aligned.
|
||||||
|
- Be careful with mirror settings in Dockerfiles; they are intentional for the expected deployment network.
|
||||||
|
|
||||||
|
## Local Commands
|
||||||
|
|
||||||
|
- Full Python test suite:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`
|
||||||
|
- Targeted Python tests:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_engine.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_config.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_manage_api.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_vision.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_cli.py -v`
|
||||||
|
- Management API:
|
||||||
|
- `scripts/run_manage_api.sh`
|
||||||
|
- Health check: `curl http://127.0.0.1:19080/api/manage/health`
|
||||||
|
- Web console:
|
||||||
|
- `scripts/run_web.sh`
|
||||||
|
- Build check: `cd web && pnpm build`
|
||||||
|
- Runtime monitor:
|
||||||
|
- `scripts/run_runtime.sh`
|
||||||
|
- One-frame smoke test when RTSP and `ffmpeg` are available: `PYTHONPATH=src python3 -m cold_display_guard.main --config config/example.toml --once`
|
||||||
|
- Compose config check:
|
||||||
|
- `docker compose --env-file deploy/cold-display-guard.env -f deploy/docker-compose.yml config`
|
||||||
|
|
||||||
|
## Validation Matrix
|
||||||
|
|
||||||
|
- Engine or domain behavior: run the targeted engine/CLI tests first, then the full Python test suite.
|
||||||
|
- Config or calibration behavior: run `tests/test_config.py`, `tests/test_manage_api.py`, then the full Python test suite.
|
||||||
|
- Vision or RTSP capture behavior: run `tests/test_vision.py`; use the one-frame runtime smoke test only when camera access and `ffmpeg` are available.
|
||||||
|
- Management API changes: run management API tests and, when practical, start `scripts/run_manage_api.sh` and hit `/api/manage/health`.
|
||||||
|
- Frontend changes: run `cd web && pnpm build`; if API interactions changed, also run or inspect the management API route behavior.
|
||||||
|
- Deployment changes: run the compose config check and verify Dockerfile/package mirror choices, ports, volumes, and health checks.
|
||||||
|
- Documentation-only changes: verify the documented paths, commands, ports, and business rules against the current files before reporting completion.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Read the relevant source and tests before editing; this project has tight coupling between business rules, event payloads, README examples, and UI summaries.
|
||||||
|
- Prefer small, surgical changes that preserve the current architecture.
|
||||||
|
- For non-trivial work, update or add planning notes in the existing project style (`docs/plans/`, `task_plan.md`, `progress.md`, or `findings.md`) only when useful for handoff or explicitly requested.
|
||||||
|
- Keep the final response grounded in verification evidence: say exactly which commands were run, or say when a validation step was skipped because it requires RTSP, Docker, network, or another external dependency.
|
||||||
|
|
||||||
|
## Workflow Orchestration
|
||||||
|
|
||||||
|
- Default to a plan-first workflow for any non-trivial task, especially work with 3+ steps or architecture decisions.
|
||||||
|
- Write the implementation plan to `tasks/todo.md` as a checklist before editing, and include verification steps rather than implementation steps alone.
|
||||||
|
- Start non-trivial work from a detailed spec so execution ambiguity is reduced before code or document changes begin.
|
||||||
|
- If execution diverges from the expected path, stop and re-plan instead of pushing ahead on stale assumptions.
|
||||||
|
- Prefer subagents for research, exploration, and parallel analysis whenever that helps keep the main context clean.
|
||||||
|
- Keep each subagent focused on a single line of investigation.
|
||||||
|
- Do not mark work complete without verification; run the relevant tests, inspect the pertinent logs, and compare before/after behavior when the change affects runtime behavior.
|
||||||
|
- For non-trivial changes, pause and assess whether there is a simpler or more elegant design before settling on a fix; avoid both hacky patches and unnecessary over-engineering.
|
||||||
|
- Treat bug reports and CI failures as end-to-end fix tasks: investigate logs, error output, and failing tests directly, and close the loop without asking the user for avoidable operational detail.
|
||||||
|
|
||||||
|
## Task Management
|
||||||
|
|
||||||
|
- Use `tasks/todo.md` as the default task tracker: write the plan first, verify the plan before implementation, and keep progress updated as checklist items complete.
|
||||||
|
- Record a high-level explanation of meaningful changes as the work proceeds.
|
||||||
|
- Add and maintain a review/results section in `tasks/todo.md` so verification outcomes and follow-up findings are captured in the same place as the plan.
|
||||||
|
|
||||||
|
## Lessons
|
||||||
|
|
||||||
|
- At the start of each session, review any task-relevant entries in `tasks/lessons.md` before beginning implementation work.
|
||||||
|
- After each user correction, update `tasks/lessons.md` with the mistake pattern and at least one concrete, executable prevention rule.
|
||||||
|
- Continue refining those lessons until the failure pattern stops recurring.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
- Simplicity first: prefer the smallest change that fully solves the problem.
|
||||||
|
- No laziness: trace issues to root cause and avoid temporary fixes that would fail a senior-engineer review.
|
||||||
|
- Minimal impact: only change the code, docs, and configuration that are necessary for the task.
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app/src \
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g; s|http://deb.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
ffmpeg \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README_zh.md /app/
|
||||||
|
COPY src /app/src
|
||||||
|
COPY config /app/config
|
||||||
|
COPY scripts /app/scripts
|
||||||
|
|
||||||
|
RUN chmod +x /app/scripts/*.sh && mkdir -p /app/logs
|
||||||
|
|
||||||
|
EXPOSE 19080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:19080/api/manage/health', timeout=3).read()" || exit 1
|
||||||
|
|
||||||
|
CMD ["python3", "-m", "cold_display_guard.manage_api", "--config", "/app/config/example.toml", "--host", "0.0.0.0", "--port", "19080"]
|
||||||
142
README_zh.md
142
README_zh.md
@@ -1,19 +1,22 @@
|
|||||||
# 冷藏展示柜食品批次计时报警
|
# 冷藏展示柜食品批次计时报警
|
||||||
|
|
||||||
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现 3 小时到期后的违规行为。
|
这是一个独立项目,用于单摄像头监控冷藏展示柜和同画面垃圾桶,记录每个展示区域内食品批次的放置时长,并发现超过自定义报警时间后的异常处理行为。
|
||||||
|
|
||||||
## 已确认业务规则
|
## 已确认业务规则
|
||||||
|
|
||||||
- 摄像头同时看到展示柜和垃圾桶。
|
- 摄像头同时看到展示柜和垃圾桶。
|
||||||
- 展示柜初始布局为横向 4 列、竖向 2 行。
|
- 展示柜食品区域支持 1 到 10 个自定义区域。
|
||||||
- 布局后期可以通过配置调整。
|
- 食品区域使用阿拉伯数字标注:`1`、`2`、`3` ...
|
||||||
|
- 垃圾桶 ROI 独立标定,不占用食品区域编号。
|
||||||
- 每个区域可以放多份食品,但这些食品按同一批次计时。
|
- 每个区域可以放多份食品,但这些食品按同一批次计时。
|
||||||
- 同一区域不允许混批,必须清空后才能放入新批次。
|
- 同一区域不允许混批,必须清空后才能放入新批次。
|
||||||
- 食品放入区域时记录开始时间。
|
- 食品放入区域时记录开始时间。
|
||||||
- 区域清空时记录结束时间。
|
- 区域清空时记录结束时间。
|
||||||
- 未满 3 小时清空视为正常消耗。
|
- 未达到报警阈值前清空视为正常消耗。
|
||||||
- 超过 3 小时清空后必须在确认窗口内看到垃圾桶投放动作。
|
- 食品在区域内达到 `max_dwell_seconds` 时先产生 `time_alarm`。
|
||||||
- 超过 3 小时的食品拿出后又放回展示柜,触发报警。
|
- 已报警食品从区域移出后,必须在确认窗口内看到垃圾桶投放动作。
|
||||||
|
- 如果已报警食品移出后没有丢到垃圾桶里,报警事件升级为 `warning_escalated` 警告事件。
|
||||||
|
- 已报警食品拿出后又放回展示柜,触发违规事件。
|
||||||
|
|
||||||
## 当前实现范围
|
## 当前实现范围
|
||||||
|
|
||||||
@@ -23,8 +26,8 @@
|
|||||||
{
|
{
|
||||||
"ts": "2026-04-27T10:00:00+08:00",
|
"ts": "2026-04-27T10:00:00+08:00",
|
||||||
"zone_counts": {
|
"zone_counts": {
|
||||||
"r1c1": 3,
|
"1": 1,
|
||||||
"r1c2": 0
|
"2": 0
|
||||||
},
|
},
|
||||||
"trash_deposit": false
|
"trash_deposit": false
|
||||||
}
|
}
|
||||||
@@ -33,12 +36,13 @@
|
|||||||
程序会输出 JSONL 事件,例如:
|
程序会输出 JSONL 事件,例如:
|
||||||
|
|
||||||
- `batch_started`
|
- `batch_started`
|
||||||
|
- `time_alarm`
|
||||||
- `batch_consumed`
|
- `batch_consumed`
|
||||||
- `batch_pending_disposal`
|
- `batch_pending_disposal`
|
||||||
- `batch_discarded`
|
- `batch_discarded`
|
||||||
|
- `warning_escalated`
|
||||||
- `mixed_batch_violation`
|
- `mixed_batch_violation`
|
||||||
- `overdue_return_violation`
|
- `overdue_return_violation`
|
||||||
- `missing_disposal_violation`
|
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -46,9 +50,25 @@
|
|||||||
|
|
||||||
默认阈值:
|
默认阈值:
|
||||||
|
|
||||||
- 最大放置时间:`10800` 秒,也就是 3 小时
|
- 时间报警阈值:`10800` 秒,也就是 3 小时;管理页按分钟输入,例如 20 分钟会保存为 `1200` 秒
|
||||||
- 垃圾桶投放确认窗口:`120` 秒
|
- 垃圾桶投放确认窗口:`120` 秒
|
||||||
|
|
||||||
|
食品区域配置示例:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[layout]
|
||||||
|
zone_count = 3
|
||||||
|
zone_ids = ["1", "2", "3"]
|
||||||
|
|
||||||
|
[[zones]]
|
||||||
|
id = "1"
|
||||||
|
label = "区域 1"
|
||||||
|
polygon = [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]
|
||||||
|
|
||||||
|
[trash]
|
||||||
|
roi = [[0.7, 0.7], [0.9, 0.7], [0.9, 0.9]]
|
||||||
|
```
|
||||||
|
|
||||||
## 区域标定
|
## 区域标定
|
||||||
|
|
||||||
项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`。
|
项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`。
|
||||||
@@ -73,10 +93,11 @@ http://127.0.0.1:23000
|
|||||||
|
|
||||||
- 配置 RTSP 地址和阈值
|
- 配置 RTSP 地址和阈值
|
||||||
- 从 RTSP 拉取一帧截图
|
- 从 RTSP 拉取一帧截图
|
||||||
- 标定 `r1c1` 到 `r2c4` 的 8 个格口
|
- 设置 1 到 10 个食品区域
|
||||||
- 标定垃圾桶区域
|
- 标定数字食品区域和垃圾桶 ROI
|
||||||
- 直接保存标定结果到项目配置文件
|
- 直接保存标定结果到项目配置文件
|
||||||
- 查看事件汇总和最近 JSONL 事件
|
- 查看事件汇总、区域序号、停留时间、报警和警告事件
|
||||||
|
- 查看本地处置单状态,并手工标记为已处理
|
||||||
|
|
||||||
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
|
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
|
||||||
|
|
||||||
@@ -97,6 +118,15 @@ http://127.0.0.1:19080
|
|||||||
- `PUT /api/manage/calibration`
|
- `PUT /api/manage/calibration`
|
||||||
- `GET /api/manage/summary`
|
- `GET /api/manage/summary`
|
||||||
- `GET /api/manage/events`
|
- `GET /api/manage/events`
|
||||||
|
- `GET /api/manage/cases`
|
||||||
|
- `GET /api/manage/cases/summary`
|
||||||
|
- `POST /api/manage/cases/{case_id}/handle`
|
||||||
|
- `POST /api/manage/webhooks/case-update`
|
||||||
|
- `GET /api/manage/webhooks/retries`
|
||||||
|
- `POST /api/manage/webhooks/retries/drain`
|
||||||
|
|
||||||
|
`/api/manage/webhooks/case-update` 需要请求头 `X-Webhook-Token`,并且请求体里的 `status` 目前固定为 `handled`。
|
||||||
|
`/api/manage/webhooks/retries` 用于查看最新重试状态,`/api/manage/webhooks/retries/drain` 用于手动触发一次到期重试补偿。
|
||||||
|
|
||||||
## 运行识别计时进程
|
## 运行识别计时进程
|
||||||
|
|
||||||
@@ -113,7 +143,9 @@ scripts/run_runtime.sh
|
|||||||
3. 按标定区域做占用变化检测。
|
3. 按标定区域做占用变化检测。
|
||||||
4. 判断垃圾桶区域是否有明显投放动作。
|
4. 判断垃圾桶区域是否有明显投放动作。
|
||||||
5. 调用批次计时状态机。
|
5. 调用批次计时状态机。
|
||||||
6. 写入 `logs/events.jsonl`,管理页会读取这个文件。
|
6. 将 `time_alarm`、`batch_pending_disposal`、`warning_escalated` 映射到本地处置单状态。
|
||||||
|
7. 写入 `logs/events.jsonl`、`logs/cases.jsonl`、`logs/runtime_diagnostics.jsonl`。
|
||||||
|
8. 按配置向外部系统推送事件 webhook 和处置单 webhook。
|
||||||
|
|
||||||
当前视觉版本是可运行的启发式版本:
|
当前视觉版本是可运行的启发式版本:
|
||||||
|
|
||||||
@@ -131,16 +163,92 @@ frame_width = 640
|
|||||||
frame_height = 360
|
frame_height = 360
|
||||||
capture_timeout_seconds = 12.0
|
capture_timeout_seconds = 12.0
|
||||||
baseline_frames = 3
|
baseline_frames = 3
|
||||||
sample_stride_pixels = 8
|
sample_stride_pixels = 4
|
||||||
occupancy_mean_delta = 24.0
|
occupancy_mean_delta = 55.0
|
||||||
occupancy_texture_delta = 18.0
|
occupancy_texture_delta = 18.0
|
||||||
|
occupancy_dark_luma_threshold = 80.0
|
||||||
|
occupancy_dark_fraction = 0.06
|
||||||
|
occupancy_texture_dark_fraction = 0.04
|
||||||
|
occupancy_bright_luma_threshold = 220.0
|
||||||
|
occupancy_bright_reflection_fraction = 0.18
|
||||||
|
occupancy_reflection_dark_fraction = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio = 2.0
|
||||||
|
occupancy_confirm_frames = 2
|
||||||
|
empty_confirm_frames = 2
|
||||||
trash_motion_delta = 18.0
|
trash_motion_delta = 18.0
|
||||||
trash_motion_cooldown_seconds = 8
|
trash_sustained_motion_delta = 8.0
|
||||||
|
trash_sustained_motion_frames = 2
|
||||||
|
trash_motion_cooldown_seconds = 3
|
||||||
diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
diagnostics_path = "logs/runtime_diagnostics.jsonl"
|
||||||
|
|
||||||
|
[case_sink]
|
||||||
|
path = "logs/cases.jsonl"
|
||||||
|
|
||||||
|
[alarm_snapshot_upload]
|
||||||
|
enabled = true
|
||||||
|
service_url = "https://ota.zhengxinshipin.com"
|
||||||
|
secret = "change-me-in-production"
|
||||||
|
object_key_prefix = "cold-display-guard/alarms"
|
||||||
|
connect_timeout_seconds = 5
|
||||||
|
read_timeout_seconds = 20
|
||||||
|
encode_timeout_seconds = 10
|
||||||
|
|
||||||
|
[webhook_retry_sink]
|
||||||
|
path = "logs/webhook_retry.jsonl"
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
enabled = true
|
||||||
|
event_url = "https://example.com/runtime-events"
|
||||||
|
case_url = "https://example.com/case-events"
|
||||||
|
source_id = "cold-display-guard"
|
||||||
|
callback_token = "shared-secret"
|
||||||
|
connect_timeout_seconds = 3
|
||||||
|
read_timeout_seconds = 5
|
||||||
|
retry_backoff_seconds = 30
|
||||||
|
retry_batch_limit = 20
|
||||||
|
retry_max_attempts = 5
|
||||||
|
retry_max_backoff_seconds = 1800
|
||||||
```
|
```
|
||||||
|
|
||||||
|
运行时会额外记录:
|
||||||
|
|
||||||
|
- `logs/cases.jsonl`:本地处置单状态变更
|
||||||
|
- `logs/webhook_retry.jsonl`:Webhook 重试队列状态快照
|
||||||
|
- `logs/webhook_delivery.jsonl`:Webhook 投递结果审计
|
||||||
|
|
||||||
|
当某一轮识别结果里出现 `severity=alarm` 或 `severity=warning` 的事件时,运行时会直接复用当前检测帧:
|
||||||
|
|
||||||
|
1. 用 `ffmpeg` 把当前 RGB 帧编码成 JPEG
|
||||||
|
2. 通过 `https://ota.zhengxinshipin.com` 的 chunk-upload API 上传
|
||||||
|
3. 把上传返回的 `object_key` 追加到对应 webhook payload
|
||||||
|
|
||||||
|
相关 webhook 字段:
|
||||||
|
|
||||||
|
- `event_code`:下游事件列表可直接使用的稳定编码,当前取批次 ID
|
||||||
|
- `camera_id` / `camera_ip`:来源设备和摄像头 IP
|
||||||
|
- `zone_id` / `zone_label`:所属区域
|
||||||
|
- `started_at`:开始计时时间点
|
||||||
|
- `ended_at` / `removed_at`:取出时间点
|
||||||
|
- `dwell_seconds`:当前批次累计计时时长
|
||||||
|
- `is_discarded` / `discarded_at`:是否已丢弃及丢弃时间点
|
||||||
|
- `created_at`:该条外部事件记录的创建时间
|
||||||
|
- `alerted_at` / `alarm_at`:时长告警时间点
|
||||||
|
- `updated_at`:该条外部事件记录的最新更新时间
|
||||||
|
- `snapshot_upload_status`:`uploaded` 或 `error`
|
||||||
|
- `snapshot_object_key`:上传成功后的 OSS 路径
|
||||||
|
- `snapshot_file_name`:上传文件名
|
||||||
|
- `snapshot_captured_at`:抓帧时间
|
||||||
|
- `snapshot_upload_error`:上传失败原因,仅失败时返回
|
||||||
|
|
||||||
## 本地测试
|
## 本地测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
前端测试和构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test web/test/zone-state.test.js
|
||||||
|
cd web && pnpm build
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,52 +1,90 @@
|
|||||||
camera_id = "cold_display_cam_01"
|
camera_id = "1"
|
||||||
timezone = "Asia/Shanghai"
|
timezone = "Asia/Shanghai"
|
||||||
|
|
||||||
[stream]
|
[stream]
|
||||||
rtsp_url = ""
|
rtsp_url = ""
|
||||||
|
|
||||||
[thresholds]
|
[thresholds]
|
||||||
max_dwell_seconds = 10800
|
pre_warning_seconds = 900
|
||||||
|
max_dwell_seconds = 1200
|
||||||
|
alarm_removal_seconds = 1800
|
||||||
trash_confirmation_seconds = 120
|
trash_confirmation_seconds = 120
|
||||||
|
|
||||||
[layout]
|
[layout]
|
||||||
rows = 2
|
zone_count = 4
|
||||||
cols = 4
|
zone_ids = ["1", "2", "3", "4"]
|
||||||
zone_ids = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"]
|
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c1"
|
id = "1"
|
||||||
polygon = [[0.00, 0.00], [0.25, 0.00], [0.25, 0.50], [0.00, 0.50]]
|
label = "区域 1"
|
||||||
|
polygon = [[0.241988, 0.289459], [0.323741, 0.306900], [0.319817, 0.438286], [0.256377, 0.420845]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c2"
|
id = "2"
|
||||||
polygon = [[0.25, 0.00], [0.50, 0.00], [0.50, 0.50], [0.25, 0.50]]
|
label = "区域 2"
|
||||||
|
polygon = [[0.354480, 0.320852], [0.423152, 0.330154], [0.419228, 0.470842], [0.378025, 0.454564], [0.357096, 0.446425]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c3"
|
id = "3"
|
||||||
polygon = [[0.50, 0.00], [0.75, 0.00], [0.75, 0.50], [0.50, 0.50]]
|
label = "区域 3"
|
||||||
|
polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]]
|
||||||
|
|
||||||
[[zones]]
|
[[zones]]
|
||||||
id = "r1c4"
|
id = "4"
|
||||||
polygon = [[0.75, 0.00], [1.00, 0.00], [1.00, 0.50], [0.75, 0.50]]
|
label = "区域 4"
|
||||||
|
polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]]
|
||||||
[[zones]]
|
|
||||||
id = "r2c1"
|
|
||||||
polygon = [[0.00, 0.50], [0.25, 0.50], [0.25, 1.00], [0.00, 1.00]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c2"
|
|
||||||
polygon = [[0.25, 0.50], [0.50, 0.50], [0.50, 1.00], [0.25, 1.00]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c3"
|
|
||||||
polygon = [[0.50, 0.50], [0.75, 0.50], [0.75, 1.00], [0.50, 1.00]]
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
id = "r2c4"
|
|
||||||
polygon = [[0.75, 0.50], [1.00, 0.50], [1.00, 1.00], [0.75, 1.00]]
|
|
||||||
|
|
||||||
[trash]
|
[trash]
|
||||||
roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]]
|
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
sample_stride_pixels = 4
|
||||||
|
occupancy_mean_delta = 55.0
|
||||||
|
occupancy_dark_luma_threshold = 80.0
|
||||||
|
occupancy_absolute_dark_fraction = 0.0
|
||||||
|
occupancy_dark_fraction = 0.06
|
||||||
|
occupancy_texture_dark_fraction = 0.04
|
||||||
|
occupancy_bright_luma_threshold = 220.0
|
||||||
|
occupancy_bright_reflection_fraction = 0.18
|
||||||
|
occupancy_reflection_dark_fraction = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio = 2.0
|
||||||
|
occupancy_confirm_frames = 2
|
||||||
|
empty_confirm_frames = 2
|
||||||
|
trash_motion_delta = 18.0
|
||||||
|
trash_sustained_motion_delta = 8.0
|
||||||
|
trash_sustained_motion_frames = 2
|
||||||
|
trash_motion_cooldown_seconds = 3
|
||||||
|
|
||||||
[event_sink]
|
[event_sink]
|
||||||
path = "logs/events.jsonl"
|
path = "logs/events.jsonl"
|
||||||
|
|
||||||
|
[case_sink]
|
||||||
|
path = "logs/cases.jsonl"
|
||||||
|
|
||||||
|
[alarm_snapshot_upload]
|
||||||
|
enabled = true
|
||||||
|
service_url = "https://ota.zhengxinshipin.com"
|
||||||
|
secret = "change-me-in-production"
|
||||||
|
object_key_prefix = "cold-display-guard/alarms"
|
||||||
|
connect_timeout_seconds = 5
|
||||||
|
read_timeout_seconds = 20
|
||||||
|
encode_timeout_seconds = 10
|
||||||
|
|
||||||
|
[webhook_retry_sink]
|
||||||
|
path = "logs/webhook_retry.jsonl"
|
||||||
|
|
||||||
|
[webhook_delivery_sink]
|
||||||
|
path = "logs/webhook_delivery.jsonl"
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
enabled = false
|
||||||
|
event_url = ""
|
||||||
|
case_url = ""
|
||||||
|
source_id = ""
|
||||||
|
callback_token = ""
|
||||||
|
connect_timeout_seconds = 3
|
||||||
|
read_timeout_seconds = 5
|
||||||
|
retry_backoff_seconds = 30
|
||||||
|
retry_batch_limit = 20
|
||||||
|
retry_max_attempts = 5
|
||||||
|
retry_max_backoff_seconds = 1800
|
||||||
|
|||||||
7
deploy/cold-display-guard.env
Normal file
7
deploy/cold-display-guard.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
IMAGE_VERSION=dev
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
COLD_DISPLAY_GUARD_API_PORT=19080
|
||||||
|
COLD_DISPLAY_GUARD_WEB_PORT=23000
|
||||||
|
COLD_DISPLAY_GUARD_CONFIG_DIR=../config
|
||||||
|
COLD_DISPLAY_GUARD_LOG_DIR=../logs
|
||||||
71
deploy/docker-compose.yml
Normal file
71
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: cold-display-guard
|
||||||
|
|
||||||
|
services:
|
||||||
|
cold-display-guard-api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: cold-display-guard:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: cold-display-guard-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
command:
|
||||||
|
- python3
|
||||||
|
- -m
|
||||||
|
- cold_display_guard.manage_api
|
||||||
|
- --config
|
||||||
|
- /app/config/example.toml
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "19080"
|
||||||
|
ports:
|
||||||
|
- "${COLD_DISPLAY_GUARD_API_PORT:-19080}:19080"
|
||||||
|
volumes:
|
||||||
|
- ${COLD_DISPLAY_GUARD_CONFIG_DIR:-../config}:/app/config
|
||||||
|
- ${COLD_DISPLAY_GUARD_LOG_DIR:-../logs}:/app/logs
|
||||||
|
networks:
|
||||||
|
- cold-display-guard
|
||||||
|
|
||||||
|
cold-display-guard-runtime:
|
||||||
|
image: cold-display-guard:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: cold-display-guard-runtime
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
depends_on:
|
||||||
|
cold-display-guard-api:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
command:
|
||||||
|
- python3
|
||||||
|
- -m
|
||||||
|
- cold_display_guard.main
|
||||||
|
- --config
|
||||||
|
- /app/config/example.toml
|
||||||
|
volumes:
|
||||||
|
- ${COLD_DISPLAY_GUARD_CONFIG_DIR:-../config}:/app/config
|
||||||
|
- ${COLD_DISPLAY_GUARD_LOG_DIR:-../logs}:/app/logs
|
||||||
|
networks:
|
||||||
|
- cold-display-guard
|
||||||
|
|
||||||
|
cold-display-guard-web:
|
||||||
|
build:
|
||||||
|
context: ../web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: cold-display-guard-web:${IMAGE_VERSION:-dev}
|
||||||
|
container_name: cold-display-guard-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
cold-display-guard-api:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${COLD_DISPLAY_GUARD_WEB_PORT:-23000}:80"
|
||||||
|
networks:
|
||||||
|
- cold-display-guard
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cold-display-guard:
|
||||||
|
driver: bridge
|
||||||
61
docs/plans/2026-04-29-web-industrial-console-design.md
Normal file
61
docs/plans/2026-04-29-web-industrial-console-design.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Cold Display Guard Web Industrial Console Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-29
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign the existing management frontend as an industrial operations console for refrigerated display monitoring, while preserving the current Vite single-page app, management API contract, and calibration workflow.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
Use a dense, production-oriented control-room interface. The application should feel like a tool used by operators who repeatedly calibrate zones, check runtime health, inspect alerts, and update camera settings. It should avoid a marketing-page layout and prioritize scanning, comparison, and fast action.
|
||||||
|
|
||||||
|
## Information Architecture
|
||||||
|
|
||||||
|
The app keeps the existing three views:
|
||||||
|
|
||||||
|
- `区域标定`: primary working view for RTSP capture and polygon editing.
|
||||||
|
- `事件数据`: monitoring view for runtime metrics and recent JSONL events.
|
||||||
|
- `运行配置`: configuration view for camera identity, RTSP URL, thresholds, and JSON preview.
|
||||||
|
|
||||||
|
The top bar becomes a compact console header with the product name, operational subtitle, connection/status message, and refresh action. Tabs remain visible but are styled as segmented navigation.
|
||||||
|
|
||||||
|
## Calibration View
|
||||||
|
|
||||||
|
The captured camera frame is the visual center of the page. The region selector and point editing tools sit beside it as operator controls, while calibration completeness and per-region point counts sit in a right-side inspection panel.
|
||||||
|
|
||||||
|
The canvas keeps its current behavior:
|
||||||
|
|
||||||
|
- RTSP snapshot must be captured before adding points.
|
||||||
|
- Clicks add normalized polygon points to the active region.
|
||||||
|
- Local draft storage is preserved.
|
||||||
|
- Saved calibration uses `PUT /api/manage/calibration`.
|
||||||
|
|
||||||
|
## Events View
|
||||||
|
|
||||||
|
Metrics appear as compact telemetry cards, with violation count and baseline state visually distinct. The event table remains the primary data surface, with rows styled for scanability and violation event names emphasized.
|
||||||
|
|
||||||
|
## Settings View
|
||||||
|
|
||||||
|
Configuration uses a two-column control layout with an adjacent JSON preview. The UI should make production parameters easy to inspect without changing the existing `PUT /api/manage/config` payload.
|
||||||
|
|
||||||
|
## Visual System
|
||||||
|
|
||||||
|
The console uses a cool industrial palette:
|
||||||
|
|
||||||
|
- charcoal and steel for structure
|
||||||
|
- white panels for data surfaces
|
||||||
|
- cyan/green for ready or active state
|
||||||
|
- amber for learning or pending state
|
||||||
|
- red for violations and failures
|
||||||
|
|
||||||
|
Controls use restrained radius, clear focus states, high contrast, and predictable spacing. The design avoids purple gradients, oversized hero sections, decorative cards, and purely ornamental backgrounds.
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
Modify only the web frontend:
|
||||||
|
|
||||||
|
- `web/src/main.js`
|
||||||
|
- `web/src/styles.css`
|
||||||
|
|
||||||
|
Do not change API routes, backend behavior, deployment files, or existing unrelated workspace changes.
|
||||||
61
docs/plans/2026-04-29-web-industrial-console.md
Normal file
61
docs/plans/2026-04-29-web-industrial-console.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Web Industrial Console Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Redesign the Cold Display Guard management frontend into a dense industrial control console without changing the backend API.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing Vite app and vanilla JavaScript state model. Replace the static HTML shell with a more structured console layout, then update render helpers to emit richer classes for status, calibration completeness, and event severity. Implement the visual system in `web/src/styles.css`.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite 5, vanilla JavaScript modules, HTML canvas, CSS custom properties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Restructure Console Shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/main.js`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Replace the current `app.innerHTML` shell with a console header, segmented tabs, status pill, and view-specific panels.
|
||||||
|
2. Preserve all existing element IDs used by JavaScript event handlers.
|
||||||
|
3. Keep the three views: `calibrationView`, `eventsView`, and `settingsView`.
|
||||||
|
4. Run `pnpm --dir web build`.
|
||||||
|
|
||||||
|
### Task 2: Add UI State Classes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/main.js`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Update `renderRegionList()` to include region color swatches, labels, active state, and completion state.
|
||||||
|
2. Update `renderRegionSummary()` to emit completion classes.
|
||||||
|
3. Update `renderMetrics()` to classify baseline and violation cards.
|
||||||
|
4. Update `renderEvents()` to classify violation rows.
|
||||||
|
5. Update `setStatus()` to classify success, error, and progress states.
|
||||||
|
6. Run `pnpm --dir web build`.
|
||||||
|
|
||||||
|
### Task 3: Implement Industrial Visual System
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/styles.css`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Replace the existing generic styles with CSS variables for the industrial console palette.
|
||||||
|
2. Style the header, tabs, status strip, controls, panels, calibration canvas, metrics, tables, and settings layout.
|
||||||
|
3. Add responsive behavior for tablet and mobile widths.
|
||||||
|
4. Confirm text does not overflow compact controls.
|
||||||
|
5. Run `pnpm --dir web build`.
|
||||||
|
|
||||||
|
### Task 4: Verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `web/dist/index.html`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Run `pnpm --dir web build`.
|
||||||
|
2. Confirm Vite emits `web/dist`.
|
||||||
|
3. Review `git diff -- web/src/main.js web/src/styles.css docs/plans/2026-04-29-web-industrial-console-design.md docs/plans/2026-04-29-web-industrial-console.md`.
|
||||||
84
docs/project.md
Normal file
84
docs/project.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Cold Display Guard Project Documentation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`cold-display-guard` monitors refrigerated display food batches by camera region. It tracks how long each configured food region remains occupied, raises a configurable time alarm, and escalates alarmed food to a warning if it is removed without a matching trash-bin deposit.
|
||||||
|
|
||||||
|
The `v1.1 优化改造` batch upgrades the product from a fixed 8-zone layout to a configurable 1-10 zone workflow with numeric region labels and editable trash ROI calibration. All v1.1 items are part of one batch; backend, API, frontend, and documentation are implementation workstreams inside that same batch.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Backend package: `src/cold_display_guard/`
|
||||||
|
- `models.py`: settings, observations, and batch dataclasses.
|
||||||
|
- `engine.py`: deterministic batch state machine.
|
||||||
|
- `config.py`: TOML config load/save, calibration merge, and project path resolution.
|
||||||
|
- `manage_api.py`: standard-library HTTP management API.
|
||||||
|
- `main.py`: RTSP runtime loop connecting frame capture, vision, state engine, and JSONL sinks.
|
||||||
|
- `vision.py`: heuristic ROI occupancy and trash-motion detection.
|
||||||
|
- Frontend package: `web/`
|
||||||
|
- Vite + vanilla JavaScript management console.
|
||||||
|
- Default web port `23000`.
|
||||||
|
- API proxy target `http://127.0.0.1:19080`.
|
||||||
|
- Runtime home view falls back to clearly marked demo data when no real events exist, so the operational layout still shows summary cards, dwell timers, and event rows.
|
||||||
|
- Runtime data:
|
||||||
|
- Events JSONL default path `logs/events.jsonl`.
|
||||||
|
- Diagnostics JSONL default path `logs/runtime_diagnostics.jsonl`.
|
||||||
|
- Deployment:
|
||||||
|
- Root Python Docker image for API/runtime.
|
||||||
|
- `web/Dockerfile` for static web console.
|
||||||
|
- `deploy/docker-compose.yml` wires API, runtime, and web services.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Main config path: `config/example.toml`.
|
||||||
|
- Camera identity: `camera_id`.
|
||||||
|
- Timezone default: `Asia/Shanghai`.
|
||||||
|
- RTSP input: `[stream] rtsp_url`.
|
||||||
|
- Thresholds:
|
||||||
|
- `max_dwell_seconds`: v1.1 time-alarm threshold. Default can remain 10800 seconds; users can set values such as 1200 seconds for 20 minutes.
|
||||||
|
- `trash_confirmation_seconds`: window after an alarmed batch is removed where a trash deposit must be observed before warning escalation.
|
||||||
|
- Food zones:
|
||||||
|
- v1.1 food zone IDs are numeric strings from `"1"` through `"10"`.
|
||||||
|
- The configured zone count must be between 1 and 10.
|
||||||
|
- If both `zone_count` and numeric `zone_ids` are present, they must agree.
|
||||||
|
- Each `[[zones]]` polygon must have at least 3 normalized points.
|
||||||
|
- Trash ROI:
|
||||||
|
- Stored under `[trash] roi`.
|
||||||
|
- Does not use a food zone number.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
- `batch_started`: a food region changes from empty to occupied.
|
||||||
|
- `time_alarm`: an active batch reaches `max_dwell_seconds` while still in the display region.
|
||||||
|
- `batch_count_changed`: count decreases while the region remains occupied.
|
||||||
|
- `mixed_batch_violation`: count increases before the region clears.
|
||||||
|
- `batch_consumed`: a non-alarmed batch clears before the threshold.
|
||||||
|
- `batch_pending_disposal`: an alarmed batch clears and waits for trash confirmation.
|
||||||
|
- `batch_discarded`: a pending alarmed batch is matched to a trash deposit.
|
||||||
|
- `warning_escalated`: a pending alarmed batch is not matched to a trash deposit before the confirmation deadline.
|
||||||
|
|
||||||
|
Events should include `zone_id`, `zone_index`, `zone_label`, `started_at`, `dwell_seconds`, and relevant alarm/removal/deadline timestamps when available.
|
||||||
|
|
||||||
|
## Runbook
|
||||||
|
|
||||||
|
- Python tests:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`
|
||||||
|
- Management API:
|
||||||
|
- `scripts/run_manage_api.sh`
|
||||||
|
- Health: `curl http://127.0.0.1:19080/api/manage/health`
|
||||||
|
- Web console:
|
||||||
|
- `scripts/run_web.sh`
|
||||||
|
- Build: `cd web && pnpm build`
|
||||||
|
- Frontend logic tests: `node --test web/test/zone-state.test.js`
|
||||||
|
- Docker web URL: `http://127.0.0.1:23000`
|
||||||
|
- Runtime monitor:
|
||||||
|
- `scripts/run_runtime.sh`
|
||||||
|
- One-frame smoke test when camera and `ffmpeg` are available:
|
||||||
|
- `PYTHONPATH=src python3 -m cold_display_guard.main --config config/example.toml --once`
|
||||||
|
|
||||||
|
## Known Risks
|
||||||
|
|
||||||
|
- The current vision detector is heuristic and reports binary occupancy, not item counts.
|
||||||
|
- If food is already present during baseline collection, those regions may be treated as empty baseline until visual changes occur.
|
||||||
|
- Changing calibration while the runtime process has active batches can create operational ambiguity; v1.1 should document or enforce a pause/restart expectation.
|
||||||
|
- Historical events must keep the zone index at the time of emission so later region reordering does not reinterpret old logs.
|
||||||
111
docs/superpowers/plans/2026-06-09-alarm-snapshot-upload.md
Normal file
111
docs/superpowers/plans/2026-06-09-alarm-snapshot-upload.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Alarm Snapshot Upload Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Capture one current-frame snapshot for alerting runtime events, upload it to the OTA chunk-upload service, and include the returned path in outbound webhook payloads.
|
||||||
|
|
||||||
|
**Architecture:** Keep `BatchEngine` unchanged and treat snapshot upload as runtime-side enrichment. Reuse the already captured RGB frame from the active detection loop, encode it to JPEG with `ffmpeg`, upload it through the documented token/init/chunk/complete flow, then merge the returned `object_key` into the webhook payload for alert-level batch events and the derived case events from the same cycle.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 standard library backend, existing `ffmpeg` dependency, JSONL webhook retry flow, unittest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Snapshot Upload Client
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/cold_display_guard/alarm_snapshots.py`
|
||||||
|
- Test: `tests/test_alarm_snapshots.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
Add tests that cover:
|
||||||
|
- loading upload settings from config
|
||||||
|
- encoding a current RGB frame into JPEG via injected encoder helper
|
||||||
|
- successful OTA upload flow returning `object_key`
|
||||||
|
- disabled or non-alert events skipping upload
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
Expected: FAIL because the snapshot upload module does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
Implement:
|
||||||
|
- upload settings parsing with defaults for `https://ota.zhengxinshipin.com` and secret `change-me-in-production`
|
||||||
|
- current-frame JPEG encoding
|
||||||
|
- token/init/chunk/complete upload workflow with injectable HTTP helpers for tests
|
||||||
|
- per-cycle alert snapshot metadata structure carrying `object_key`, file name, and upload status
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 2: Runtime And Webhook Integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/main.py`
|
||||||
|
- Modify: `src/cold_display_guard/webhooks.py`
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
- Test: `tests/test_webhooks.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
Add tests that cover:
|
||||||
|
- runtime uploads one snapshot when a cycle contains alert-severity events
|
||||||
|
- webhook payload includes uploaded `object_key` for alert batch events
|
||||||
|
- derived case webhook payload includes the same snapshot path for matching case-creation events
|
||||||
|
- upload failure does not block webhook delivery and instead records failure metadata in payload
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
Expected: FAIL because runtime/webhook code does not accept snapshot metadata yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
Implement:
|
||||||
|
- alert event selection based on event severity
|
||||||
|
- one-per-cycle snapshot upload using the current frame
|
||||||
|
- payload enrichment for batch-event and matching case-event webhooks
|
||||||
|
- retry queue persistence of the already enriched payload
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 3: Config, Secrets, Docs, And Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/config.py`
|
||||||
|
- Modify: `src/cold_display_guard/manage_api.py`
|
||||||
|
- Modify: `config/example.toml`
|
||||||
|
- Modify: `README_zh.md`
|
||||||
|
- Test: `tests/test_config.py`
|
||||||
|
- Test: `tests/test_manage_api.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
Extend tests so:
|
||||||
|
- config formatting writes snapshot-upload settings
|
||||||
|
- management config payload strips sensitive upload secret
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
Expected: FAIL because snapshot upload settings are not exposed/formatted yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
Implement:
|
||||||
|
- config keys for snapshot upload URL, secret, object prefix, enable flag, and timeout/chunk settings
|
||||||
|
- config payload secret stripping
|
||||||
|
- README updates for alert snapshot upload behavior and returned webhook fields
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run targeted and full verification**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
|
||||||
|
- `cd web && pnpm build`
|
||||||
|
Expected: PASS
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Webhook Case Management Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build local case management plus outbound/inbound webhook support on top of the existing runtime batch-event flow.
|
||||||
|
|
||||||
|
**Architecture:** Keep `BatchEngine` as the factual event source, then add a separate case-state module that consumes selected events and persists case snapshots. Add a webhook delivery module for both batch events and case events, expose management APIs for case listing and handling, and render the resulting case workflow in the existing management console without mixing facts and workflow state.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 via pyenv, Python standard library HTTP/JSON/TOML stack, JSONL files, unittest, Vite + vanilla JavaScript, Node test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create: `src/cold_display_guard/cases.py`
|
||||||
|
- Create: `src/cold_display_guard/webhooks.py`
|
||||||
|
- Modify: `src/cold_display_guard/config.py`
|
||||||
|
- Modify: `src/cold_display_guard/main.py`
|
||||||
|
- Modify: `src/cold_display_guard/manage_api.py`
|
||||||
|
- Modify: `web/src/main.js`
|
||||||
|
- Modify: `web/src/zone-state.js`
|
||||||
|
- Create: `tests/test_cases.py`
|
||||||
|
- Create: `tests/test_webhooks.py`
|
||||||
|
- Modify: `tests/test_manage_api.py`
|
||||||
|
- Modify: `tests/test_main.py`
|
||||||
|
- Modify: `web/test/zone-state.test.js`
|
||||||
|
|
||||||
|
### Task 1: Backend Case State Layer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/cold_display_guard/cases.py`
|
||||||
|
- Create: `tests/test_cases.py`
|
||||||
|
- Modify: `src/cold_display_guard/main.py`
|
||||||
|
- Modify: `tests/test_main.py`
|
||||||
|
|
||||||
|
- [ ] Write failing tests for case creation, case escalation, manual/callback/auto close, and restore behavior in `tests/test_cases.py`.
|
||||||
|
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
|
||||||
|
Expected: failing assertions or import errors for missing case helpers.
|
||||||
|
- [ ] Implement minimal case dataclasses, JSONL load/save helpers, event-to-case transitions, and restore logic in `src/cold_display_guard/cases.py`.
|
||||||
|
- [ ] Wire runtime event processing in `src/cold_display_guard/main.py` so emitted batch events produce persisted case snapshots.
|
||||||
|
- [ ] Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Webhook Configuration And Delivery
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/cold_display_guard/webhooks.py`
|
||||||
|
- Create: `tests/test_webhooks.py`
|
||||||
|
- Modify: `src/cold_display_guard/config.py`
|
||||||
|
- Modify: `src/cold_display_guard/main.py`
|
||||||
|
|
||||||
|
- [ ] Write failing tests for webhook config parsing, batch event payload delivery, case event payload delivery, and delivery-failure logging in `tests/test_webhooks.py`.
|
||||||
|
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
Expected: FAIL because webhook helpers/config support do not exist yet.
|
||||||
|
- [ ] Implement webhook settings parsing/saving in `src/cold_display_guard/config.py` and synchronous delivery plus audit logging in `src/cold_display_guard/webhooks.py`.
|
||||||
|
- [ ] Integrate webhook sending into `src/cold_display_guard/main.py` after local event and case persistence.
|
||||||
|
- [ ] Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Management API For Cases And Callback Handling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/manage_api.py`
|
||||||
|
- Modify: `tests/test_manage_api.py`
|
||||||
|
- Modify: `src/cold_display_guard/config.py`
|
||||||
|
|
||||||
|
- [ ] Write failing API tests for `/api/manage/cases`, `/api/manage/cases/summary`, `/api/manage/cases/{case_id}/handle`, and `/api/manage/webhooks/case-update` in `tests/test_manage_api.py`.
|
||||||
|
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
Expected: FAIL because the new endpoints and case summary behavior are missing.
|
||||||
|
- [ ] Implement case listing, case summary, manual handle, and token-protected callback handling in `src/cold_display_guard/manage_api.py`.
|
||||||
|
- [ ] Ensure config payloads expose webhook settings and case/log sink paths without leaking secrets unnecessarily.
|
||||||
|
- [ ] Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Frontend Case Management UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/main.js`
|
||||||
|
- Modify: `web/src/zone-state.js`
|
||||||
|
- Modify: `web/test/zone-state.test.js`
|
||||||
|
|
||||||
|
- [ ] Write failing frontend tests for case summary mapping, case table rendering helpers, event/case separation, and manual handle request shaping in `web/test/zone-state.test.js`.
|
||||||
|
- [ ] Run: `node --test web/test/zone-state.test.js`
|
||||||
|
Expected: FAIL because case helpers and UI state handling do not exist yet.
|
||||||
|
- [ ] Implement frontend model helpers and UI rendering for case summaries, case rows, and manual handle actions while preserving the existing runtime event table semantics.
|
||||||
|
- [ ] Run:
|
||||||
|
- `node --test web/test/zone-state.test.js`
|
||||||
|
- `cd web && pnpm build`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 5: Full Verification And Documentation Alignment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README_zh.md`
|
||||||
|
- Modify: `tasks/todo.md`
|
||||||
|
|
||||||
|
- [ ] Update documentation for new webhook config, case logs, and management endpoints if implementation changed the documented surface area.
|
||||||
|
- [ ] Run targeted verification:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
- `node --test web/test/zone-state.test.js`
|
||||||
|
Expected: PASS.
|
||||||
|
- [ ] Run full verification:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
|
||||||
|
- `cd web && pnpm build`
|
||||||
|
Expected: PASS.
|
||||||
|
- [ ] Record final verification outcomes and any environmental caveats in `tasks/todo.md`.
|
||||||
105
docs/superpowers/plans/2026-06-09-webhook-retry-queue.md
Normal file
105
docs/superpowers/plans/2026-06-09-webhook-retry-queue.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Webhook Retry Queue Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add persistent webhook retry queue handling so failed outbound webhook deliveries are retried with backoff instead of being recorded only as one-shot failures.
|
||||||
|
|
||||||
|
**Architecture:** Keep the current synchronous direct-send path as the first attempt, but persist failed outbound deliveries into a separate append-only retry-state JSONL log. Reconstruct the latest retry state from that log, retry due items from runtime and management API entry points, and expose queue visibility plus manual drain control through the existing management API.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 standard library backend, JSONL persistence, unittest, existing Vite frontend left unchanged for this phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Retry Queue Model And Delivery Semantics
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/webhooks.py`
|
||||||
|
- Test: `tests/test_webhooks.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing retry-queue tests**
|
||||||
|
Add tests for:
|
||||||
|
- non-2xx direct delivery is treated as failure rather than success
|
||||||
|
- failed direct delivery appends a pending retry snapshot
|
||||||
|
- due retry success marks the queued item delivered
|
||||||
|
- repeated retry failure increments attempts and eventually becomes `dead_letter`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
Expected: FAIL because retry queue helpers and non-2xx handling do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal retry queue support**
|
||||||
|
In `src/cold_display_guard/webhooks.py`:
|
||||||
|
- add webhook retry settings parsing
|
||||||
|
- add retry snapshot load/append helpers
|
||||||
|
- add in-memory retry store operations
|
||||||
|
- treat only HTTP `2xx` as successful delivery
|
||||||
|
- enqueue failed direct deliveries
|
||||||
|
- retry due queued deliveries with bounded exponential backoff and dead-letter cutoff
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 2: Runtime And Manage API Integration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/main.py`
|
||||||
|
- Modify: `src/cold_display_guard/manage_api.py`
|
||||||
|
- Test: `tests/test_main.py`
|
||||||
|
- Test: `tests/test_manage_api.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing integration tests**
|
||||||
|
Add tests for:
|
||||||
|
- runtime delivery enqueues failed outbound webhooks and drains due retries
|
||||||
|
- manual case handling uses the queue-aware sender
|
||||||
|
- management API can list queued retry items
|
||||||
|
- management API can manually trigger a retry drain and report results
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
Expected: FAIL because runtime/API do not know about queue paths or drain actions yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement minimal integration**
|
||||||
|
- add retry-queue path resolution to runtime and management API
|
||||||
|
- make runtime direct sends queue-aware and drain due items each cycle
|
||||||
|
- make case-handle callbacks/manual operations queue-aware
|
||||||
|
- add `GET /api/manage/webhooks/retries`
|
||||||
|
- add `POST /api/manage/webhooks/retries/drain`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 3: Config Surface, Docs, And Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/cold_display_guard/config.py`
|
||||||
|
- Modify: `config/example.toml`
|
||||||
|
- Modify: `README_zh.md`
|
||||||
|
- Test: `tests/test_config.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing config/doc tests**
|
||||||
|
Extend config tests so saved config output includes retry queue sink/settings.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
Run: `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
|
||||||
|
Expected: FAIL because retry queue config formatting does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement config and docs updates**
|
||||||
|
- add defaults for retry queue sink path and retry policy settings
|
||||||
|
- expose the non-secret retry config in manage config payload
|
||||||
|
- document retry queue behavior, new log file, and manual drain/list endpoints
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run targeted and full verification**
|
||||||
|
Run:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_config.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_manage_api.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Webhook Case Management Design
|
||||||
|
|
||||||
|
**Goal:** Add outbound webhooks plus a local case-management layer so the project can both push runtime facts to external systems and independently track pending/handled cases in the local management console.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing runtime event stream as the source of operational facts. Add a separate case-state layer that consumes selected runtime events, persists case state transitions, exposes management APIs, and emits case webhooks without mutating the underlying batch facts. Integrate manual handling and external callback handling through the same case-state model.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11+ standard library backend, JSONL persistence, Vite + vanilla JavaScript frontend, existing unittest and Node test suites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This design extends the current project in four focused areas:
|
||||||
|
|
||||||
|
1. Add outbound webhook delivery for runtime batch events.
|
||||||
|
2. Add a local case model for operator workflow.
|
||||||
|
3. Add management APIs for listing, summarizing, manually handling, and externally updating cases.
|
||||||
|
4. Add frontend views and actions for local case operations.
|
||||||
|
|
||||||
|
The runtime batch engine remains the producer of factual detection events. Case handling is a downstream interpretation layer.
|
||||||
|
|
||||||
|
## Current Constraints
|
||||||
|
|
||||||
|
- The current runtime writes facts to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`.
|
||||||
|
- The management API is a small standard-library HTTP server and should stay that way.
|
||||||
|
- The frontend already renders runtime metrics and runtime events and should continue to do so.
|
||||||
|
- The user-selected workflow requires both manual handling and external callback handling.
|
||||||
|
- The user-selected workflow requires both event webhooks and case webhooks.
|
||||||
|
- The events that should enter the local pending-case flow are `time_alarm`, `batch_pending_disposal`, and `warning_escalated`.
|
||||||
|
|
||||||
|
## Design Summary
|
||||||
|
|
||||||
|
The system is split into three cooperating layers:
|
||||||
|
|
||||||
|
1. **Batch event layer**
|
||||||
|
Produces facts such as `batch_started`, `time_alarm`, `batch_pending_disposal`, `batch_discarded`, and `warning_escalated`. These remain append-only runtime facts.
|
||||||
|
|
||||||
|
2. **Case state layer**
|
||||||
|
Consumes selected batch events and maintains a separate per-batch local case state. The case layer owns pending/handled workflow and does not rewrite prior runtime facts.
|
||||||
|
|
||||||
|
3. **Integration layer**
|
||||||
|
Delivers outbound event and case webhooks, accepts external case callbacks, and records webhook delivery attempts for audit and debugging.
|
||||||
|
|
||||||
|
## Persistence Model
|
||||||
|
|
||||||
|
- `logs/events.jsonl`
|
||||||
|
Existing runtime fact log. No schema removals.
|
||||||
|
- `logs/cases.jsonl`
|
||||||
|
New append-only case transition log. Each line records a case snapshot after a state change.
|
||||||
|
- `logs/webhook_delivery.jsonl`
|
||||||
|
New append-only webhook delivery audit log. Each line records an attempted outbound delivery result.
|
||||||
|
|
||||||
|
`events.jsonl` remains the source of factual batch history. `cases.jsonl` is the source of case workflow state. `webhook_delivery.jsonl` is operational telemetry only.
|
||||||
|
|
||||||
|
## Case Model
|
||||||
|
|
||||||
|
Each batch can own at most one local case. A case is created or updated from selected batch events and then independently handled by a local operator or external callback.
|
||||||
|
|
||||||
|
### Case fields
|
||||||
|
|
||||||
|
- `case_id`
|
||||||
|
- `batch_id`
|
||||||
|
- `camera_id`
|
||||||
|
- `zone_id`
|
||||||
|
- `zone_label`
|
||||||
|
- `case_type`
|
||||||
|
- `case_status`
|
||||||
|
- `source_event`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
- `handled_at`
|
||||||
|
- `handled_by`
|
||||||
|
- `handled_source`
|
||||||
|
- `last_event_ts`
|
||||||
|
- `payload`
|
||||||
|
|
||||||
|
### Case type values
|
||||||
|
|
||||||
|
- `time_alarm`
|
||||||
|
- `pending_disposal`
|
||||||
|
- `warning_escalated`
|
||||||
|
|
||||||
|
### Case status values
|
||||||
|
|
||||||
|
- `open`
|
||||||
|
- `handled`
|
||||||
|
|
||||||
|
### Handled source values
|
||||||
|
|
||||||
|
- `manual`
|
||||||
|
- `webhook_callback`
|
||||||
|
- `auto_closed`
|
||||||
|
|
||||||
|
## Case State Flow
|
||||||
|
|
||||||
|
1. `time_alarm`
|
||||||
|
Create a case if one does not exist for the batch. If a case already exists, keep it open and refresh timestamps.
|
||||||
|
|
||||||
|
2. `batch_pending_disposal`
|
||||||
|
Create a case if one does not exist. If one exists, update it in place and upgrade `case_type` to `pending_disposal`.
|
||||||
|
|
||||||
|
3. `warning_escalated`
|
||||||
|
Update the same case in place and upgrade `case_type` to `warning_escalated`.
|
||||||
|
|
||||||
|
4. Manual handling
|
||||||
|
Mark the case as `handled`, set `handled_source=manual`, record `handled_by`, and append the new snapshot to `cases.jsonl`.
|
||||||
|
|
||||||
|
5. External callback handling
|
||||||
|
Mark the case as `handled`, set `handled_source=webhook_callback`, optionally record `handled_by` and `source_ref`, and append the new snapshot to `cases.jsonl`.
|
||||||
|
|
||||||
|
6. `batch_discarded`
|
||||||
|
If the related case is still `open`, close it automatically with `handled_source=auto_closed`.
|
||||||
|
|
||||||
|
Handled cases must not reopen when stale older events are replayed or re-read. Only new event processing in forward time may mutate an existing case. Restore logic must preserve handled status across runtime/API restarts.
|
||||||
|
|
||||||
|
## Backend Components
|
||||||
|
|
||||||
|
- Create `src/cold_display_guard/cases.py` for case transition logic, persistence, restore, and summary helpers.
|
||||||
|
- Create `src/cold_display_guard/webhooks.py` for webhook config parsing, payload building, synchronous delivery, and delivery audit logging.
|
||||||
|
- Extend `src/cold_display_guard/config.py` for webhook configuration and case/log sink paths.
|
||||||
|
- Extend `src/cold_display_guard/main.py` to feed runtime events into case persistence and webhook delivery.
|
||||||
|
- Extend `src/cold_display_guard/manage_api.py` to expose case listing, case summary, manual handling, and token-protected callback handling.
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
All new endpoints stay under `/api/manage/*`.
|
||||||
|
|
||||||
|
- `GET /api/manage/cases`
|
||||||
|
Query: `status=open|handled` optional, `limit` optional.
|
||||||
|
- `GET /api/manage/cases/summary`
|
||||||
|
Returns case counts and latest update time.
|
||||||
|
- `POST /api/manage/cases/{case_id}/handle`
|
||||||
|
Body: `handled_by` required, `note` optional.
|
||||||
|
- `POST /api/manage/webhooks/case-update`
|
||||||
|
Body: `case_id` required, `status` required and must equal `handled`, `handled_by` optional, `source_ref` optional.
|
||||||
|
|
||||||
|
The callback endpoint must require the configured shared token in the `X-Webhook-Token` header and must reject unauthenticated updates.
|
||||||
|
|
||||||
|
## Webhook Configuration
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[webhooks]
|
||||||
|
enabled = true
|
||||||
|
event_url = "https://example.com/runtime-events"
|
||||||
|
case_url = "https://example.com/case-events"
|
||||||
|
callback_token = "shared-secret"
|
||||||
|
connect_timeout_seconds = 3
|
||||||
|
read_timeout_seconds = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Outbound Webhook Delivery
|
||||||
|
|
||||||
|
Event webhook payload core fields:
|
||||||
|
|
||||||
|
- `kind = "batch_event"`
|
||||||
|
- `event`
|
||||||
|
- `ts`
|
||||||
|
- `batch_id`
|
||||||
|
- `camera_id`
|
||||||
|
- `zone_id`
|
||||||
|
- `zone_label`
|
||||||
|
- `severity`
|
||||||
|
- `state`
|
||||||
|
|
||||||
|
Case webhook payload core fields:
|
||||||
|
|
||||||
|
- `kind = "case_event"`
|
||||||
|
- `action = "created" | "updated" | "handled"`
|
||||||
|
- `case_id`
|
||||||
|
- `case_type`
|
||||||
|
- `case_status`
|
||||||
|
- `batch_id`
|
||||||
|
- `source_event`
|
||||||
|
- `handled_source`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
Delivery rules:
|
||||||
|
|
||||||
|
- Local runtime facts and case state must be persisted before webhook failure can affect control flow.
|
||||||
|
- Webhook failure must append a line to `logs/webhook_delivery.jsonl`.
|
||||||
|
- Webhook failure must not stop local event persistence or local case persistence.
|
||||||
|
- This batch does not add a retry queue.
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
- Keep the current runtime event table for factual runtime events only.
|
||||||
|
- Add a separate case table with:
|
||||||
|
- `case_id`
|
||||||
|
- `case_type`
|
||||||
|
- `case_status`
|
||||||
|
- `zone_label`
|
||||||
|
- `batch_id`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
- `handled_source`
|
||||||
|
- Add manual-handle UI for `open` cases with `handled_by` required and `note` optional.
|
||||||
|
- Add summary cards for:
|
||||||
|
- `open_case_count`
|
||||||
|
- `handled_case_count`
|
||||||
|
- `time_alarm_case_count`
|
||||||
|
- `pending_disposal_case_count`
|
||||||
|
- `warning_escalated_case_count`
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
- Preserve existing batch engine behavior tests.
|
||||||
|
- Add case tests for create, escalate, manual handle, callback handle, auto-close, and non-reopen behavior.
|
||||||
|
- Add webhook tests for payloads, delivery success, and failure audit logging.
|
||||||
|
- Add API tests for new case and callback endpoints.
|
||||||
|
- Add frontend tests for case rendering, case summary mapping, and manual-handle request flow.
|
||||||
|
|
||||||
|
Verification commands:
|
||||||
|
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
|
||||||
|
- `node --test web/test/zone-state.test.js`
|
||||||
|
- `cd web && pnpm build`
|
||||||
81
findings.md
81
findings.md
@@ -14,3 +14,84 @@
|
|||||||
- If a batch is removed after the maximum dwell threshold, the system expects a trash-bin deposit event within a configurable window.
|
- If a batch is removed after the maximum dwell threshold, the system expects a trash-bin deposit event within a configurable window.
|
||||||
- If a removed over-threshold batch reappears in any display zone before being discarded, that is a violation.
|
- If a removed over-threshold batch reappears in any display zone before being discarded, that is a violation.
|
||||||
- If food is added while a zone is already occupied, that is a mixed-batch violation.
|
- If food is added while a zone is already occupied, that is a mixed-batch violation.
|
||||||
|
|
||||||
|
## v1.1 优化改造 Findings
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- The current backend already accepts configured `layout.zone_ids`, so the engine does not require a fixed 2x4 grid internally.
|
||||||
|
- The current frontend is the main fixed-grid constraint: `web/src/main.js` hard-codes `r1c1` through `r2c4` and draws those region controls.
|
||||||
|
- `merge_calibration()` already accepts arbitrary zone IDs and clamps polygon points, but it does not enforce the new 1-10 numeric region policy.
|
||||||
|
- The runtime vision layer consumes `[[zones]]` from config, so it can follow numeric zones once config and frontend write them.
|
||||||
|
- `BatchEngine` only emits events when a zone changes or pending disposal expires; a time alarm while the batch remains occupied requires a new periodic active-batch check.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Food regions must be numbered `1` through `N`, with `N` between 1 and 10.
|
||||||
|
- Trash ROI is a separate region under `[trash]` and must not consume a food zone number.
|
||||||
|
- The management API should preserve standard-library HTTP behavior.
|
||||||
|
- Existing JSONL consumers may still expect `event`, `zone_id`, `dwell_seconds`, and timestamps; v1.1 should add fields rather than remove core fields.
|
||||||
|
- The frontend remains Vite + vanilla JavaScript; no framework migration in this batch.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Keep `max_dwell_seconds` as the configurable time-alarm threshold to avoid introducing two competing dwell thresholds.
|
||||||
|
- Add `time_alarm` when an active batch reaches the threshold, while keeping the batch active until the zone clears.
|
||||||
|
- Once an alarmed batch clears, put it into pending trash confirmation and emit `batch_pending_disposal`.
|
||||||
|
- If no trash deposit occurs before the confirmation deadline, emit `warning_escalated` with severity `warning`; retain compatibility by using the same pending-disposal mechanism.
|
||||||
|
- Emit `zone_index` and `zone_label` on every zone event when the zone ID is numeric.
|
||||||
|
- The backend planning agent suggested `batch_dwell_alert` and `missing_disposal_warning`, but the accepted prototype and user phrasing use `time_alarm` and `warning_escalated`; implementation should follow the accepted prototype names.
|
||||||
|
- Every future subagent dispatch must begin with the standard context header requested by the user:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[项目: /Users/yoilun/Code/cold_display_guard]
|
||||||
|
[工作流批次: v1.1 优化改造]
|
||||||
|
[阶段: 阶段 x]
|
||||||
|
[角色: 对应智能体角色]
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `阶段 x` only as a workflow stage inside the same `v1.1 优化改造` batch.
|
||||||
|
|
||||||
|
## Backend Planning Notes
|
||||||
|
|
||||||
|
- `EngineSettings.zone_ids` should remain config driven; numeric zones are preferred for new configs, but old `r1c1` style IDs should continue loading.
|
||||||
|
- v1.1 can derive `zone_index` from numeric `zone_id` when possible.
|
||||||
|
- Suggested batch additions:
|
||||||
|
- `alerted_at`
|
||||||
|
- an indicator that the time alarm has already been emitted
|
||||||
|
- Suggested state flow:
|
||||||
|
- `active`: region occupied before threshold.
|
||||||
|
- `alerted`: region occupied after `time_alarm`.
|
||||||
|
- `pending_disposal`: alarmed food removed and waiting for trash deposit.
|
||||||
|
- `discarded`: pending batch matched to trash.
|
||||||
|
- `warning`: alarmed food removed and no trash deposit before deadline.
|
||||||
|
- `consumed`: non-alarmed food removed.
|
||||||
|
- `build_summary()` currently counts only `*_violation`; v1.1 should include `severity: alert|warning` in alert summaries.
|
||||||
|
- FIFO matching of pending batches to trash deposits remains acceptable but should stay covered by tests.
|
||||||
|
|
||||||
|
## Frontend Planning Notes
|
||||||
|
|
||||||
|
- Replace the fixed `zoneIds` array in `web/src/main.js` with dynamic food-zone state derived from config, constrained to 1-10 zones.
|
||||||
|
- Keep `trashRoi` separate from `foodZones`; never include it in `zone_index` numbering.
|
||||||
|
- Suggested frontend state groups:
|
||||||
|
- `foodZoneCount`
|
||||||
|
- `foodZones: [{id, label, points}]`
|
||||||
|
- `trashRoi`
|
||||||
|
- active edit target type/index
|
||||||
|
- config form values including alarm threshold
|
||||||
|
- normalized event rows for display
|
||||||
|
- Current target must be visually obvious before canvas clicks add points.
|
||||||
|
- Coordinates must remain normalized relative to the displayed frame/image.
|
||||||
|
- Reducing zone count should truncate removed zones only after a clear confirmation or obvious UI affordance.
|
||||||
|
- Re-capturing an RTSP frame must not discard unsaved point edits.
|
||||||
|
- Event table must safely render old and new events, including `time_alarm` and `warning_escalated`.
|
||||||
|
- Frontend validation should cover 1/10 zones, food/trash editing, save/reload recovery, old 8-zone config compatibility, and threshold validation.
|
||||||
|
|
||||||
|
## Homepage Demo Runtime Notes
|
||||||
|
|
||||||
|
- Docker runtime can write diagnostics while no events exist, especially when the configured RTSP stream is unreachable.
|
||||||
|
- A diagnostics-only summary is not enough to represent the accepted prototype; the home runtime view should show a clearly marked demo state until real events exist.
|
||||||
|
- Demo runtime content must never look like a real alarm: banner, metrics labels, event source tags, and event file text identify it as demo data.
|
||||||
|
- Real events take precedence over demo data. Per-zone progress uses the latest event by timestamp when both candidates have timestamps; otherwise it falls back to event order.
|
||||||
|
- Event-derived progress uses the configured dwell threshold when an event omits `max_dwell_seconds`.
|
||||||
|
- Any runtime summary value rendered through `innerHTML`, including `latest_zone_counts`, must be HTML-escaped.
|
||||||
|
|||||||
172
progress.md
172
progress.md
@@ -11,3 +11,175 @@
|
|||||||
- Initialized git repository and created the initial project commit.
|
- Initialized git repository and created the initial project commit.
|
||||||
- Added RTSP single-frame calibration tool under `tools/calibrator`.
|
- Added RTSP single-frame calibration tool under `tools/calibrator`.
|
||||||
- Added formal management API on port `19080` and Vite frontend on port `23000`.
|
- Added formal management API on port `19080` and Vite frontend on port `23000`.
|
||||||
|
|
||||||
|
## 2026-05-26 v1.1 优化改造
|
||||||
|
|
||||||
|
### Session Log
|
||||||
|
|
||||||
|
| Time | Batch Workstream | Actor | Action | Result |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 2026-05-26 | Batch setup and planning | Main Agent | Created active goal for `v1.1 优化改造` | Goal tracks dynamic zones, trash ROI editing, custom alarm threshold, warning escalation |
|
||||||
|
| 2026-05-26 | Batch setup and planning | Main Agent | Started backend and frontend planning sub-agents | Waiting for role outputs while updating plan files |
|
||||||
|
| 2026-05-26 | Batch setup and planning | Main Agent | Updated `task_plan.md` and `findings.md` with v1.1 scope | `v1.1 优化改造` planning in progress |
|
||||||
|
| 2026-05-26 | Batch setup and planning | Frontend Agent | Returned frontend planning notes | Added dynamic zones, trash ROI, event display, and validation notes to `findings.md` |
|
||||||
|
| 2026-05-26 | Batch setup and planning | Backend Agent | Returned backend event model and test risk notes | Added event flow, summary risk, and compatibility notes to `findings.md` |
|
||||||
|
| 2026-05-26 | Batch setup and planning | User | Clarified that all requested work belongs to one v1.1 development batch with different workstreams | Updated `task_plan.md` and `docs/project.md` wording |
|
||||||
|
| 2026-05-26 | Batch setup and planning | User | Asked to remove earlier split-work wording and make it part of one batch | Renamed v1.1 plan table to batch workstreams and replaced split-work wording in progress |
|
||||||
|
| 2026-05-26 | Batch setup and planning | User | Set the batch name to `v1.1 优化改造` | Updated planning documents to use this name |
|
||||||
|
| 2026-05-26 | Backend event model | Review Agent | Re-reviewed event model after severity fix | Pass; no blocking issues for Config/API workstream |
|
||||||
|
| 2026-05-26 | Batch setup and planning | User | Required a standard context header before each subagent task dispatch | Added header convention to `task_plan.md` and `findings.md`; future subagent prompts will include project, batch, stage, and role |
|
||||||
|
| 2026-05-26 | Batch setup and planning | User | Clarified that `项目` in subagent task headers should use the actual project path | Updated the required header path to `/Users/yoilun/Code/cold_display_guard` |
|
||||||
|
| 2026-05-26 | Config and management API | Main Agent | Added 1-10 numeric zone validation, `zone_count`, `label`, trash ROI separation, and alarm/warning summary counts | Target config/API tests and full Python tests passed |
|
||||||
|
| 2026-05-26 | Config and management API | Review Agent | Reviewed Config/API workstream | No blocking issues; raised two major contract issues, both fixed with regression tests |
|
||||||
|
| 2026-05-26 | Frontend management console | Main Agent | Added dynamic 1-10 numeric zone editor, independent trash ROI editing, minute-based alarm threshold, and alarm/warning event rendering | Frontend unit test and Vite build passed |
|
||||||
|
| 2026-05-26 | Frontend management console | Frontend Agent | Reviewed frontend workstream | No blocking issues; raised two major legacy-mapping/label issues, both fixed with regression tests and sent for re-review |
|
||||||
|
| 2026-05-26 | Documentation and final review | Main Agent | Updated `README_zh.md`, `docs/project.md`, and `config/example.toml` for v1.1 numeric zones and event flow | Docs now describe one `v1.1 优化改造` batch and current configuration/event model |
|
||||||
|
| 2026-05-26 | Frontend management console | Frontend Agent | Re-reviewed frontend legacy mapping and label normalization fixes | Pass; no blocking, no major, no new minor issues |
|
||||||
|
| 2026-05-26 | Documentation and final review | Review Agent | Ran final v1.1 review | No blocking; found two major issues in removal-time alarm ordering and partial calibration zone-count preservation, plus one planning-doc minor |
|
||||||
|
| 2026-05-26 | Documentation and final review | Main Agent | Fixed final review issues | Added tests and fixes for removal-frame `time_alarm`, partial numeric calibration preserving `zone_count`, and updated top-level plan wording |
|
||||||
|
| 2026-05-26 | Documentation and final review | Review Agent | Re-reviewed final fixes | Pass; no blocking, no major, no minor issues |
|
||||||
|
| 2026-05-26 | Documentation and final review | Main Agent | Completed `v1.1 优化改造` batch | Stop conditions satisfied and final verification recorded |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Main Agent | Cleared old event data and added complete demo runtime homepage | Homepage now defaults to runtime view with demo banner, metrics, dwell progress, and event table when real events are empty |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Frontend Agent | Implemented demo runtime display and tests | Added `buildRuntimeDisplayModel`, progress rows, demo/real labels, and responsive styles |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Review Agent | Reviewed homepage demo runtime display | Found progress ordering, threshold fallback, and XSS issues; all were fixed with regression tests |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Review Agent | Final re-review | Pass; no blocking or major issues |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Main Agent | Rebuilt Docker web service | `http://127.0.0.1:23000` serves nginx container with latest frontend asset |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | User | Reported that the runtime demo homepage was still not visible | Reproduced in Chrome; found frontend initialization stopped before tab switching |
|
||||||
|
| 2026-05-26 | Homepage demo runtime display | Main Agent | Fixed null config runtime display crash | `buildRuntimeDisplayModel()` now tolerates `config: null`; Chrome shows runtime page with demo data, metrics, progress, and event table |
|
||||||
|
| 2026-05-26 | Remote Docker deployment | Main Agent | Probed `xiaozheng@192.168.5.206` for Docker availability | Blocked by SSH authentication failure: `Permission denied (publickey,password)` |
|
||||||
|
| 2026-05-26 | Remote Docker deployment | Main Agent | Synced project to `xiaozheng@192.168.5.206:/home/xiaozheng/cold_display_guard` | `rsync -az --delete` completed with `.git`, local env, node modules, web dist, and logs excluded |
|
||||||
|
| 2026-05-26 | Remote Docker deployment | Main Agent | Built and started Docker services on `192.168.5.206` | API and Web are running; runtime was stopped because `[stream].rtsp_url` is empty |
|
||||||
|
| 2026-05-26 | Hide demo runtime data | Main Agent | Removed synthetic demo runtime summary/events/progress from the frontend model | Empty or diagnostics-only runtime data now renders empty states and real metrics only |
|
||||||
|
| 2026-05-26 | Hide demo runtime data | Main Agent | Synced and rebuilt Web on `192.168.5.206` | Remote Web serves `index-D3qCb2DS.js` without demo batch/camera or visible demo-data strings |
|
||||||
|
| 2026-05-27 | Runtime recognition startup | Main Agent | Checked whether recognition had started on `192.168.5.206` | Runtime was stopped, then started after fixing remote timezone from `shanghai` to `Asia/Shanghai`; diagnostics show frame capture and `baseline_ready: true` |
|
||||||
|
| 2026-05-27 | Runtime recognition investigation | Main Agent | Investigated why zones 1 and 6 appeared to stop counting after 20 minutes | Evidence shows zones 1 and 6 remain occupied; frontend freezes at the one-time `time_alarm` event dwell value because no live tick is emitted/rendered |
|
||||||
|
| 2026-05-28 | Runtime vision small-object detection | Main Agent | Investigated why zones 1/2/5 placements were missed, zone 4 started timing, and zone 2 produced repeated short events | Evidence showed small dark objects were diluted by whole-region mean/texture while zone 4 reflection drove texture false positives |
|
||||||
|
| 2026-05-28 | Runtime vision small-object detection | Main Agent | Added dark-pixel fraction occupancy and bright-reflection filtering, then deployed to `192.168.5.206` | Current remote diagnostics show zones 1/2/5 occupied and zone 4 empty |
|
||||||
|
| 2026-05-29 | Same-frame trash confirmation | Main Agent | Investigated why zone 1 was removed and trash motion was visible but still escalated | Diagnostics showed `deposit=true` in the same frame as removal, but the engine applied trash deposits before creating `pending_disposal` |
|
||||||
|
| 2026-05-29 | Same-frame trash confirmation | Main Agent | Applied remaining trash deposits again after zone transitions and deployed to `192.168.5.206` | Same-frame removal plus visible trash ROI motion now emits `batch_pending_disposal` followed by `batch_discarded` |
|
||||||
|
| 2026-05-29 | Zone 4 reflection and stale progress | Main Agent | Investigated why zone 4 kept timing and why zone 9/10 progress bars appeared | Zone 4 had high bright reflection plus a small dark edge crossing the dark threshold; zone 9/10 came from historical events outside the current 1-8 config |
|
||||||
|
| 2026-05-29 | Zone 4 reflection and stale progress | Main Agent | Added high-bright/small-dark reflection suppression and filtered progress rows to current configured zones only | Remote summary now reports zones 1-8 all empty and frontend model returns no progress rows |
|
||||||
|
| 2026-05-29 | Event table dwell display | Main Agent | Investigated why a zone 1 `batch_started` row kept counting after the batch was consumed | Frontend displayed live dwell per row and did not know the same batch had a later terminal event |
|
||||||
|
| 2026-05-29 | Event table dwell display | Main Agent | Limited event-table live dwell to the latest non-terminal event per batch and deployed the Web container | Removed `batch_started` rows now keep their recorded dwell value while the terminal row shows final dwell |
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
|
||||||
|
| Time | Command | Result | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_engine.py -v` | pass | 11 engine tests passed after v1.1 event model changes |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 20 full Python tests passed |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_config.py -v` | pass | 6 config tests passed after numeric zone validation fixes |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest tests/test_manage_api.py -v` | pass | 7 manage API tests passed after warning summary fixes |
|
||||||
|
| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 7 frontend zone-state tests passed |
|
||||||
|
| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 28 full Python tests passed after v1.1 config/API changes |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after final review fixes |
|
||||||
|
| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 7 frontend zone-state tests passed after final review fixes |
|
||||||
|
| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed after final review fixes |
|
||||||
|
| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 14 frontend zone-state tests passed after homepage demo runtime and review fixes |
|
||||||
|
| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed with latest homepage runtime asset |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after homepage runtime changes |
|
||||||
|
| 2026-05-26 | `docker compose --env-file cold-display-guard.env -f docker-compose.yml up -d --build cold-display-guard-web` | pass | Docker web image rebuilt and container restarted |
|
||||||
|
| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 15 frontend zone-state tests passed after null-config startup fix |
|
||||||
|
| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed after null-config startup fix |
|
||||||
|
| 2026-05-26 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 30 full Python tests passed after null-config startup fix |
|
||||||
|
| 2026-05-26 | `docker compose build cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Built `cold-display-guard:dev` and `cold-display-guard-web:dev` |
|
||||||
|
| 2026-05-26 | `docker compose up -d` on `192.168.5.206` | pass | API, runtime, and web containers created; runtime exited due missing RTSP |
|
||||||
|
| 2026-05-26 | `curl http://192.168.5.206:19080/api/manage/health` | pass | API returned `{"status":"ok"}` |
|
||||||
|
| 2026-05-26 | `curl -I http://192.168.5.206:23000/` | pass | Web returned `HTTP/1.1 200 OK` |
|
||||||
|
| 2026-05-26 | `node --test web/test/zone-state.test.js` | pass | 16 frontend model tests passed after hiding demo runtime data |
|
||||||
|
| 2026-05-26 | `cd web && pnpm build` | pass | Vite production build passed with `index-D3qCb2DS.js` |
|
||||||
|
| 2026-05-26 | `docker compose up -d --build cold-display-guard-web` on `192.168.5.206` | pass | Remote Web and API containers restarted; API healthy, Web returned `HTTP/1.1 200 OK` |
|
||||||
|
| 2026-05-26 | `rg "演示数据|DEMO DATA|demo_batch|demo_camera" /private/tmp/cold-display-guard-remote-web.js` | pass | No matches in the deployed remote JS asset |
|
||||||
|
| 2026-05-27 | `docker ps -a --filter name=cold-display-guard` on `192.168.5.206` | pass | Runtime is `Up`; API is healthy; Web is up |
|
||||||
|
| 2026-05-27 | `tail -5 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | Runtime is writing fresh diagnostics; baseline became ready and all 10 zones reported counts |
|
||||||
|
| 2026-05-27 | `grep '"zone_id": "1"' logs/events.jsonl | tail -20` on `192.168.5.206` | pass | Zone 1 started `2026-05-27T09:23:43+08:00` and emitted `time_alarm` at `2026-05-27T09:43:48+08:00`; no removal event followed |
|
||||||
|
| 2026-05-27 | `grep '"zone_id": "6"' logs/events.jsonl | tail -20` on `192.168.5.206` | pass | Zone 6 started `2026-05-27T09:23:49+08:00` and emitted `time_alarm` at `2026-05-27T09:43:54+08:00`; no removal event followed |
|
||||||
|
| 2026-05-27 | `tail -5 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | Latest diagnostics still report zones 1 and 6 as `occupied: true` |
|
||||||
|
| 2026-05-27 | `node --test web/test/zone-state.test.js` | pass | 18 frontend model tests passed after live dwell timer fix |
|
||||||
|
| 2026-05-27 | `PYTHONPATH=src python3 -m unittest tests/test_vision.py -v` | pass | Vision regression tests passed for consecutive occupancy confirmation and raised reflection threshold |
|
||||||
|
| 2026-05-27 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 32 full Python tests passed after runtime vision changes |
|
||||||
|
| 2026-05-27 | `pnpm build` in `web/` | pass | Vite production build passed with `index-BkBYO5x5.js` |
|
||||||
|
| 2026-05-27 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config |
|
||||||
|
| 2026-05-27 | Remote config append `[runtime]` thresholds | pass | Added `occupancy_mean_delta = 45.0`, `occupancy_confirm_frames = 2`, and `empty_confirm_frames = 2` without changing RTSP |
|
||||||
|
| 2026-05-27 | `docker compose build cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Built new API/runtime shared image and web image with live timer code |
|
||||||
|
| 2026-05-27 | `docker compose up -d --no-deps cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | API and Web restarted; runtime intentionally left running to avoid relearning baseline with items still present |
|
||||||
|
| 2026-05-27 | `curl` remote health and web index | pass | API returned healthy and Web serves `index-BkBYO5x5.js` |
|
||||||
|
| 2026-05-27 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests passed after event-table live dwell and current-zone filtering |
|
||||||
|
| 2026-05-27 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 36 full Python tests passed after runtime state restore and seeded vision baseline |
|
||||||
|
| 2026-05-27 | `docker compose up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | Runtime restarted on new image while preserving active 1/6/7 timers from event history and prior baseline |
|
||||||
|
| 2026-05-27 | `tail -n 3 logs/runtime_diagnostics.jsonl` on `192.168.5.206` | pass | New diagnostics include `raw_occupied`/streaks; 1/6/7 occupied, 3/4/5/8 empty |
|
||||||
|
| 2026-05-27 | Remote summary after 12 seconds | pass | Event count stayed at 579; no new false events after runtime restart |
|
||||||
|
| 2026-05-28 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 39 full Python tests passed after dark-fraction occupancy and reflection filtering |
|
||||||
|
| 2026-05-28 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests passed |
|
||||||
|
| 2026-05-28 | `pnpm build` in `web/` | pass | Vite production build passed with `index-DFRi3R8X.js` |
|
||||||
|
| 2026-05-28 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config |
|
||||||
|
| 2026-05-28 | Remote config runtime patch | pass | Added dark-fraction and bright-reflection runtime thresholds without printing or changing RTSP |
|
||||||
|
| 2026-05-28 | `docker compose build cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | API/runtime rebuilt and restarted on the new image |
|
||||||
|
| 2026-05-28 | Remote diagnostics/API summary | pass | Current counts show zones 1/2/5 occupied and zones 3/4/6/7/8 empty; zone 4 has reflection texture but dark fraction remains `0.0` |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine.BatchEngineTests.test_same_observation_removal_and_trash_motion_discards_alerted_batch -v` | red then pass | Reproduced and fixed same-frame trash motion being ignored for a newly pending alerted batch |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_main -v` | red then pass | Runtime restart state restore now uses dark-fraction/bright-reflection occupancy rules |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 41 full Python tests passed after same-frame trash confirmation and restore-rule fixes |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 20 frontend model tests still passed |
|
||||||
|
| 2026-05-29 | `rsync -az --delete ... --exclude config/example.toml` to `192.168.5.206` | pass | Code synced while preserving remote RTSP/calibration config |
|
||||||
|
| 2026-05-29 | `docker compose build cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | API/runtime rebuilt and restarted on the same-frame trash confirmation fix |
|
||||||
|
| 2026-05-29 | Remote status and diagnostics check | pass | Runtime/API are up; API healthy; latest diagnostics are being written after restart |
|
||||||
|
| 2026-05-29 | Targeted reflection/progress tests | red then pass | Added regressions for bright reflection with small dark edge, runtime restore/API recompute using that rule, and hiding historical zone 9/10 progress |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 42 full Python tests passed after zone 4 reflection suppression |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 21 frontend model tests passed after current-zone progress filtering |
|
||||||
|
| 2026-05-29 | `pnpm build` in `web/` | pass | Vite production build passed with `index-sJMxcaD6.js` |
|
||||||
|
| 2026-05-29 | `docker compose build cold-display-guard-api cold-display-guard-web` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api cold-display-guard-web` on `192.168.5.206` | pass | Runtime/API/Web rebuilt and restarted with reflection and progress fixes |
|
||||||
|
| 2026-05-29 | Remote API/diagnostics/frontend model verification | pass | API `latest_zone_counts` shows zones 1-8 all `0`; latest diagnostics show zone 4 `occupied: false`; model progress rows are empty |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | red then pass | Added regression so `batch_started` rows stop live ticking after the same batch has a terminal event |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 42 Python tests still passed after frontend-only dwell display fix |
|
||||||
|
| 2026-05-29 | `pnpm build` in `web/` | pass | Vite production build passed with `index-BoXFyXbk.js` |
|
||||||
|
| 2026-05-29 | `docker compose build cold-display-guard-web` and `up -d --no-deps cold-display-guard-web` on `192.168.5.206` | pass | Remote Web rebuilt and serves `index-BoXFyXbk.js` |
|
||||||
|
| 2026-05-29 | Remote frontend model verification for `batch_000473` | pass | `batch_started` displays `0` seconds and `batch_consumed` displays final `64` seconds |
|
||||||
|
| 2026-05-29 | API stable-occupancy regression tests | red then pass | `latest_zone_counts` now uses runtime's debounced `occupied` state before raw threshold fallback |
|
||||||
|
| 2026-05-29 | Trash sustained-motion regression test | red then pass | Two consecutive moderate trash motions below the strong threshold now confirm disposal |
|
||||||
|
| 2026-05-29 | Runtime restore stable-occupancy regression test | red then pass | Restart restore now preserves debounced occupancy for threshold-edge zones |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 45 full Python tests passed |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed |
|
||||||
|
| 2026-05-29 | `docker compose build cold-display-guard-runtime cold-display-guard-api` and `up -d --no-deps cold-display-guard-runtime cold-display-guard-api` on `192.168.5.206` | pass | Runtime/API rebuilt and restarted with stable count and sustained trash-motion fixes |
|
||||||
|
| 2026-05-29 | Remote API/diagnostics verification | pass | API healthy; runtime writes `motion_streak`/`strong_motion`/`sustained_motion`; API summary matches stable zone counts |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision.VisionTests.test_runtime_vision_defaults_raise_brightness_reflection_threshold -v` | red then pass | Default runtime sampling is now dense enough for small ROIs: `sample_stride_pixels = 4` |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 45 full Python tests passed after dense sampling default |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed |
|
||||||
|
| 2026-05-29 | Remote runtime/API rebuild and config patch | pass | Remote config now has `sample_stride_pixels = 4`; runtime/API restarted |
|
||||||
|
| 2026-05-29 | Remote zone 1 diagnostics verification | pass | Zone 1 stayed occupied for 16 consecutive post-deploy checks; API summary reports zone 1 as occupied |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine.BatchEngineTests.test_same_observation_trash_motion_discards_multiple_newly_pending_batches -v` | red then pass | Same-frame trash motion now discards multiple alerted batches that clear together |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision.VisionTests.test_detector_allows_quick_sequential_strong_trash_motions -v` | red then pass | Quick sequential strong trash motions are no longer suppressed by the old 8-second cooldown |
|
||||||
|
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 47 full Python tests passed after multi-zone trash confirmation fix |
|
||||||
|
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed |
|
||||||
|
| 2026-05-29 | Remote runtime/API rebuild and config patch | pass | Remote config now has `trash_motion_cooldown_seconds = 3`; diagnostics include `in_cooldown` |
|
||||||
|
|
||||||
|
### Bug Loop
|
||||||
|
|
||||||
|
| Batch Workstream | Bug | Fix Attempt | Retest Result |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Backend event model | Review agent found `severity` missing from some events such as `batch_started` and `mixed_batch_violation` | Added regression assertions and made `_event()` assign default severity by event type | Resolved; re-review passed |
|
||||||
|
| Config and management API | Review agent found `zone_count` could conflict with numeric `zone_ids`, and pending disposal was counted as an upgraded warning | Added regression tests; validate `zone_count == len(zone_ids)` and count only `warning_escalated`/legacy `_violation` as warning events | Resolved; target tests and full Python tests passed |
|
||||||
|
| Frontend management console | Frontend agent found old `rows/cols` configs without `zone_ids` lost legacy polygons, and old labels could be saved back to numeric zones | Added regression tests; derive legacy `rNcM` source IDs from `rows/cols` and normalize labels to `区域 N` | Resolved; re-review passed |
|
||||||
|
| Documentation and final review | Final review found removal observations at the threshold skipped `time_alarm` before pending disposal | Added regression tests and moved time-alarm application before zone-clear transitions | Resolved; Python tests passed |
|
||||||
|
| Documentation and final review | Final review found partial calibration saves could shrink `zone_count` to the number of completed polygons | Added frontend payload `layout`, backend merge support for target numeric zone IDs, and regression tests preserving existing polygons/count | Resolved; Python and frontend tests passed |
|
||||||
|
| Homepage demo runtime display | Review found real progress kept maximum dwell time instead of the latest event | Added latest-event regression tests and selected per-zone progress by timestamp or event order | Resolved; frontend tests passed |
|
||||||
|
| Homepage demo runtime display | Review found event rows missing threshold values fell back to 1200 instead of config threshold | Added regression test and inherited the configured threshold | Resolved; frontend tests passed |
|
||||||
|
| Homepage demo runtime display | Review found `latest_zone_counts` was interpolated into `innerHTML` without escaping | Added shared `escapeHtml()` and escaped each zone-count fragment | Resolved; final review passed |
|
||||||
|
| Homepage demo runtime display | Browser showed static calibration page because runtime display read `config.thresholds` while `config` was still `null` | Added regression test for `config: null` and normalized missing config to `{}` before deriving thresholds | Resolved; Chrome verification shows runtime demo homepage |
|
||||||
|
| Remote Docker deployment | SSH to `xiaozheng@192.168.5.206` failed with `Permission denied (publickey,password)` | Tried normal SSH and escalated SSH using the same host/user; local key was not accepted and no interactive password was available | Blocked until credentials are provided or the local public key is authorized on the remote host |
|
||||||
|
| Remote Docker deployment | Initial `docker compose up -d --build` attempted to pull `cold-display-guard:dev` before a local image existed | Stopped the hanging SSH command, confirmed no existing local image, then explicitly ran `docker compose build cold-display-guard-api cold-display-guard-web` before `up -d` | Resolved; images built and API/Web started |
|
||||||
|
| Remote Docker deployment | `cold-display-guard-runtime` restarted repeatedly | Checked runtime logs; root cause was `ValueError: stream.rtsp_url is required` because `config/example.toml` has an empty RTSP URL | Stopped runtime container until an RTSP URL is configured |
|
||||||
|
| Hide demo runtime data | Empty runtime data still generated synthetic demo summary/events/progress | Added failing frontend tests, then replaced demo fallback with empty summary, empty events, and empty progress rows | Resolved; frontend tests and build passed |
|
||||||
|
| Hide demo runtime data | Legacy `event.demo` rows or `cold_display_guard_demo` summaries could still surface if old data existed | Added failing regression test, filtered demo-marked events/summaries, and removed visible demo labels from event rendering | Resolved; remote JS asset has no visible demo-data strings |
|
||||||
|
| Runtime recognition startup | Runtime restarted after RTSP was configured but exited with `ZoneInfoNotFoundError: 'No time zone found with key shanghai'` | Updated remote config and local example config to `timezone = "Asia/Shanghai"`, then restarted runtime | Resolved; runtime remains up and writes diagnostics |
|
||||||
|
| Runtime recognition investigation | Frontend dwell display stops near the 20-minute alarm even while the item remains present | Added live dwell computation from `started_at` for non-ended batches, one-second frontend re-render, five-second runtime-data polling, and broader event fetch limit | Resolved; Web/API deployed |
|
||||||
|
| Runtime vision false positives | Reflections in empty zones crossed the old mean-luma threshold and triggered occupancy/alarm | Raised default `occupancy_mean_delta` to `55.0`, added 2-frame occupied/empty confirmation, recomputed current counts in API from diagnostics, restored runtime state from events/diagnostics, and restarted runtime | Resolved; latest diagnostics show only 1/6/7 occupied |
|
||||||
|
| Runtime vision small dark objects | Zones 1/2/5 contained compact dark objects that did not always exceed whole-region mean/texture thresholds; zone 4 bright reflection exceeded texture threshold; zone 2 flickered around the old threshold and created repeated short batches | Added dark-fraction metrics, required dark evidence for texture occupancy, ignored bright reflection without dark evidence, and added manage API recomputation with the same rule | Resolved; latest remote diagnostics keep 1/2/5 occupied, 4 empty, and zone 2 no longer produces consume/start flicker after deployment |
|
||||||
|
| Same-frame trash confirmation | Trash motion in the visible trash ROI could occur in the exact frame where an alerted zone was removed; the engine consumed trash deposits before it created the new pending-disposal batch, so the deposit was lost and the batch later escalated | Added a regression test and reapplied leftover trash deposits after zone transitions; also updated runtime restore to use the dark-fraction rules before restart | Resolved for future events; existing historical `warning_escalated` rows are not rewritten |
|
||||||
|
| Zone 4 reflection and stale zone 9/10 progress | Zone 4 reflection produced both bright pixels and a small dark edge above the dark-object threshold; old zone 9/10 events were still eligible for progress rows because missing live counts were treated as occupied | Added a reflection classifier for high-bright/small-dark patterns and required progress rows to belong to current configured food zones with explicit live occupancy when diagnostics are present | Resolved; remote currently shows no occupied zones and no progress rows |
|
||||||
|
| Event table live dwell after removal | Earlier `batch_started` rows had no `ended_at`, so the frontend kept applying live dwell even after a later event in the same batch ended it | Event rows now compute live dwell only for the latest non-terminal event in each batch | Resolved; removed batches no longer keep counting in their `batch_started` row |
|
||||||
|
| Zone 2 progress flicker | API summary recomputed latest zone counts from raw metrics, bypassing runtime's occupied/empty confirmation; threshold-edge zone 2 could flip to `0` while runtime still held stable occupied | Summary now prefers per-zone stable `occupied` from diagnostics, then falls back to raw recompute only for older diagnostics | Resolved; API counts align with runtime stable state |
|
||||||
|
| Zone 2 disposal escalated after trash drop | The zone 2 batch was removed and entered pending disposal, but trash `motion_delta` peaked around `10.1`, below the one-frame threshold `18`, so no trash deposit was counted before the deadline | Added sustained trash-motion confirmation: two consecutive moderate motions at `>= 8.0` count as a deposit, while the strong one-frame threshold remains | Resolved for future events; historical `batch_000474` warning row is not rewritten |
|
||||||
|
| Runtime restart can drop threshold-edge timers | Runtime restore used raw threshold recompute instead of stable `occupied`, so a restart during a one-frame raw dip could lose an active timer | Restore now uses stable `occupied` when present and keeps raw recompute only as a fallback | Resolved; regression test covers zone 2-style flicker |
|
||||||
|
| Zone 1 timer reset | Zone 1's ROI had too few samples with the default stride `8`; dark evidence jumped between `0.0714` and `0.0357/0`, causing two occupied frames followed by two empty frames and repeated short batches | Reduced default and remote `sample_stride_pixels` to `4` so small ROI/object evidence is less quantized | Resolved in current verification; zone 1 remains continuously occupied after deploy |
|
||||||
|
| Zone 1/4 disposal missed after zone 2 discard | Zone 2 generated a trash deposit at `14:32:13`; zone 1/4 cleared at `14:32:19`, but their strong trash motion was inside the old 8-second cooldown, and one same-frame deposit could only discard one newly pending batch | Reduced trash cooldown to 3 seconds and let one same-frame trash motion discard all newly pending alerted batches from that observation | Resolved for future events; historical zone 1/4 rows are not rewritten |
|
||||||
|
|||||||
1110
prototype/custom-zones/index.html
Normal file
1110
prototype/custom-zones/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1258
src/cold_display_guard/alarm_snapshots.py
Normal file
1258
src/cold_display_guard/alarm_snapshots.py
Normal file
File diff suppressed because it is too large
Load Diff
243
src/cold_display_guard/cases.py
Normal file
243
src/cold_display_guard/cases.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
EVENT_CASE_TYPES = {
|
||||||
|
"time_pre_warning": "pre_warning",
|
||||||
|
"time_alarm": "time_alarm",
|
||||||
|
"batch_pending_disposal": "pending_disposal",
|
||||||
|
"alarm_removal_timeout": "alarm_removal_timeout",
|
||||||
|
"warning_escalated": "warning_escalated",
|
||||||
|
}
|
||||||
|
|
||||||
|
CASE_PRIORITY = {
|
||||||
|
"pre_warning": 1,
|
||||||
|
"time_alarm": 2,
|
||||||
|
"pending_disposal": 3,
|
||||||
|
"alarm_removal_timeout": 4,
|
||||||
|
"warning_escalated": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CaseSnapshot:
|
||||||
|
case_id: str
|
||||||
|
batch_id: str
|
||||||
|
camera_id: str
|
||||||
|
zone_id: str
|
||||||
|
zone_label: str
|
||||||
|
case_type: str
|
||||||
|
case_status: str
|
||||||
|
source_event: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
handled_at: datetime | None = None
|
||||||
|
handled_by: str = ""
|
||||||
|
handled_source: str = ""
|
||||||
|
last_event_ts: datetime | None = None
|
||||||
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"case_id": self.case_id,
|
||||||
|
"batch_id": self.batch_id,
|
||||||
|
"camera_id": self.camera_id,
|
||||||
|
"zone_id": self.zone_id,
|
||||||
|
"zone_label": self.zone_label,
|
||||||
|
"case_type": self.case_type,
|
||||||
|
"case_status": self.case_status,
|
||||||
|
"source_event": self.source_event,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
"handled_by": self.handled_by,
|
||||||
|
"handled_source": self.handled_source,
|
||||||
|
"payload": self.payload,
|
||||||
|
}
|
||||||
|
if self.handled_at is not None:
|
||||||
|
payload["handled_at"] = self.handled_at.isoformat()
|
||||||
|
if self.last_event_ts is not None:
|
||||||
|
payload["last_event_ts"] = self.last_event_ts.isoformat()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> "CaseSnapshot":
|
||||||
|
return cls(
|
||||||
|
case_id=str(payload.get("case_id", "")),
|
||||||
|
batch_id=str(payload.get("batch_id", "")),
|
||||||
|
camera_id=str(payload.get("camera_id", "")),
|
||||||
|
zone_id=str(payload.get("zone_id", "")),
|
||||||
|
zone_label=str(payload.get("zone_label", "")),
|
||||||
|
case_type=str(payload.get("case_type", "")),
|
||||||
|
case_status=str(payload.get("case_status", "")),
|
||||||
|
source_event=str(payload.get("source_event", "")),
|
||||||
|
created_at=parse_datetime(payload.get("created_at")) or datetime.min,
|
||||||
|
updated_at=parse_datetime(payload.get("updated_at")) or datetime.min,
|
||||||
|
handled_at=parse_datetime(payload.get("handled_at")),
|
||||||
|
handled_by=str(payload.get("handled_by", "")),
|
||||||
|
handled_source=str(payload.get("handled_source", "")),
|
||||||
|
last_event_ts=parse_datetime(payload.get("last_event_ts")),
|
||||||
|
payload=dict(payload.get("payload", {}) or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseStore:
|
||||||
|
def __init__(self, snapshots: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
self._cases: dict[str, CaseSnapshot] = {}
|
||||||
|
for payload in snapshots or []:
|
||||||
|
snapshot = CaseSnapshot.from_dict(payload)
|
||||||
|
if not snapshot.case_id:
|
||||||
|
continue
|
||||||
|
existing = self._cases.get(snapshot.case_id)
|
||||||
|
if existing is None or snapshot.updated_at >= existing.updated_at:
|
||||||
|
self._cases[snapshot.case_id] = snapshot
|
||||||
|
|
||||||
|
def latest_cases(self) -> list[dict[str, Any]]:
|
||||||
|
snapshots = sorted(self._cases.values(), key=lambda item: item.updated_at, reverse=True)
|
||||||
|
return [snapshot.to_dict() for snapshot in snapshots]
|
||||||
|
|
||||||
|
def apply_batch_events(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
snapshots: list[dict[str, Any]] = []
|
||||||
|
for event in events:
|
||||||
|
snapshot = self._apply_batch_event(event)
|
||||||
|
if snapshot is not None:
|
||||||
|
snapshots.append(snapshot.to_dict())
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
def mark_handled(
|
||||||
|
self,
|
||||||
|
case_id: str,
|
||||||
|
*,
|
||||||
|
handled_at: datetime,
|
||||||
|
handled_by: str = "",
|
||||||
|
handled_source: str,
|
||||||
|
note: str = "",
|
||||||
|
source_ref: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
snapshot = self._cases[case_id]
|
||||||
|
snapshot.case_status = "handled"
|
||||||
|
snapshot.updated_at = handled_at
|
||||||
|
snapshot.handled_at = handled_at
|
||||||
|
snapshot.handled_by = handled_by
|
||||||
|
snapshot.handled_source = handled_source
|
||||||
|
payload = dict(snapshot.payload)
|
||||||
|
if note:
|
||||||
|
payload["note"] = note
|
||||||
|
if source_ref:
|
||||||
|
payload["source_ref"] = source_ref
|
||||||
|
snapshot.payload = payload
|
||||||
|
return snapshot.to_dict()
|
||||||
|
|
||||||
|
def _apply_batch_event(self, event: dict[str, Any]) -> CaseSnapshot | None:
|
||||||
|
event_name = str(event.get("event", ""))
|
||||||
|
when = parse_datetime(event.get("ts"))
|
||||||
|
if when is None:
|
||||||
|
return None
|
||||||
|
batch_id = str(event.get("batch_id", "")).strip()
|
||||||
|
if not batch_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
case_id = build_case_id(batch_id)
|
||||||
|
existing = self._cases.get(case_id)
|
||||||
|
if event_name in {"batch_discarded", "pre_warning_handled"}:
|
||||||
|
if existing is None or existing.case_status == "handled":
|
||||||
|
return None
|
||||||
|
handled_source = "auto_removed_before_alarm" if event_name == "pre_warning_handled" else "auto_closed"
|
||||||
|
return self._close_case(existing, when, handled_source=handled_source)
|
||||||
|
|
||||||
|
case_type = EVENT_CASE_TYPES.get(event_name)
|
||||||
|
if case_type is None:
|
||||||
|
return None
|
||||||
|
if existing is not None:
|
||||||
|
if existing.last_event_ts is not None and when <= existing.last_event_ts:
|
||||||
|
return None
|
||||||
|
if existing.case_status == "handled":
|
||||||
|
return None
|
||||||
|
existing.case_type = higher_priority_case_type(existing.case_type, case_type)
|
||||||
|
existing.case_status = "open"
|
||||||
|
existing.source_event = event_name
|
||||||
|
existing.updated_at = when
|
||||||
|
existing.last_event_ts = when
|
||||||
|
existing.payload = {"event": dict(event)}
|
||||||
|
return existing
|
||||||
|
|
||||||
|
snapshot = CaseSnapshot(
|
||||||
|
case_id=case_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
camera_id=str(event.get("camera_id", "")),
|
||||||
|
zone_id=str(event.get("zone_id", "")),
|
||||||
|
zone_label=str(event.get("zone_label", "")),
|
||||||
|
case_type=case_type,
|
||||||
|
case_status="open",
|
||||||
|
source_event=event_name,
|
||||||
|
created_at=when,
|
||||||
|
updated_at=when,
|
||||||
|
last_event_ts=when,
|
||||||
|
payload={"event": dict(event)},
|
||||||
|
)
|
||||||
|
self._cases[case_id] = snapshot
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def _close_case(self, snapshot: CaseSnapshot, handled_at: datetime, *, handled_source: str) -> CaseSnapshot:
|
||||||
|
snapshot.case_status = "handled"
|
||||||
|
snapshot.updated_at = handled_at
|
||||||
|
snapshot.handled_at = handled_at
|
||||||
|
snapshot.handled_source = handled_source
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def build_case_id(batch_id: str) -> str:
|
||||||
|
return f"case_{batch_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def higher_priority_case_type(current: str, incoming: str) -> str:
|
||||||
|
if CASE_PRIORITY.get(incoming, 0) >= CASE_PRIORITY.get(current, 0):
|
||||||
|
return incoming
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def append_case_snapshots(path: Path, payloads: list[dict[str, Any]]) -> None:
|
||||||
|
if not payloads:
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
if path.exists() and path.stat().st_size > 0 and not file_ends_with_newline(path):
|
||||||
|
handle.write("\n")
|
||||||
|
for payload in payloads:
|
||||||
|
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||||
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def load_case_snapshots(path: Path) -> list[dict[str, Any]]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
items.append(payload)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def parse_datetime(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def file_ends_with_newline(path: Path) -> bool:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(-1, 2)
|
||||||
|
return handle.read(1) == b"\n"
|
||||||
@@ -9,6 +9,7 @@ from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings
|
|||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATH = Path("config/example.toml")
|
DEFAULT_CONFIG_PATH = Path("config/example.toml")
|
||||||
|
MAX_CUSTOM_FOOD_ZONES = 10
|
||||||
|
|
||||||
|
|
||||||
def load_settings(path: str | Path) -> EngineSettings:
|
def load_settings(path: str | Path) -> EngineSettings:
|
||||||
@@ -16,13 +17,15 @@ def load_settings(path: str | Path) -> EngineSettings:
|
|||||||
thresholds: dict[str, Any] = data.get("thresholds", {})
|
thresholds: dict[str, Any] = data.get("thresholds", {})
|
||||||
layout: dict[str, Any] = data.get("layout", {})
|
layout: dict[str, Any] = data.get("layout", {})
|
||||||
|
|
||||||
zone_ids = tuple(layout.get("zone_ids") or _zone_ids_from_rows_cols(layout))
|
zone_ids = _zone_ids_from_layout(layout)
|
||||||
if not zone_ids:
|
if not zone_ids:
|
||||||
zone_ids = DEFAULT_ZONE_IDS
|
zone_ids = DEFAULT_ZONE_IDS
|
||||||
|
|
||||||
return EngineSettings(
|
return EngineSettings(
|
||||||
camera_id=str(data.get("camera_id", "cold_display_cam_01")),
|
camera_id=str(data.get("camera_id", "cold_display_cam_01")),
|
||||||
|
pre_warning_seconds=int(thresholds.get("pre_warning_seconds", 0)),
|
||||||
max_dwell_seconds=int(thresholds.get("max_dwell_seconds", 10_800)),
|
max_dwell_seconds=int(thresholds.get("max_dwell_seconds", 10_800)),
|
||||||
|
alarm_removal_seconds=int(thresholds.get("alarm_removal_seconds", 0)),
|
||||||
trash_confirmation_seconds=int(thresholds.get("trash_confirmation_seconds", 120)),
|
trash_confirmation_seconds=int(thresholds.get("trash_confirmation_seconds", 120)),
|
||||||
zone_ids=zone_ids,
|
zone_ids=zone_ids,
|
||||||
)
|
)
|
||||||
@@ -56,24 +59,53 @@ def merge_calibration(
|
|||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
zones: list[dict[str, Any]],
|
zones: list[dict[str, Any]],
|
||||||
trash_roi: list[list[float]] | None,
|
trash_roi: list[list[float]] | None,
|
||||||
|
layout_update: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
merged = deepcopy(data)
|
merged = deepcopy(data)
|
||||||
|
incoming_numeric_zone_ids = _incoming_numeric_zone_ids(layout_update)
|
||||||
valid_zones: dict[str, dict[str, Any]] = {}
|
valid_zones: dict[str, dict[str, Any]] = {}
|
||||||
for zone in zones:
|
for zone in zones:
|
||||||
zone_id = str(zone.get("id", "")).strip()
|
zone_id = str(zone.get("id", "")).strip()
|
||||||
|
if zone_id.lower() == "trash":
|
||||||
|
continue
|
||||||
polygon = _normalize_points(zone.get("polygon", []))
|
polygon = _normalize_points(zone.get("polygon", []))
|
||||||
if not zone_id or len(polygon) < 3:
|
if not zone_id or len(polygon) < 3:
|
||||||
continue
|
continue
|
||||||
valid_zones[zone_id] = {"id": zone_id, "polygon": polygon}
|
valid_zone: dict[str, Any] = {"id": zone_id, "polygon": polygon}
|
||||||
|
label = str(zone.get("label", "")).strip()
|
||||||
|
if zone_id.isdecimal():
|
||||||
|
valid_zone["label"] = f"区域 {int(zone_id)}"
|
||||||
|
elif label:
|
||||||
|
valid_zone["label"] = label
|
||||||
|
valid_zones[zone_id] = valid_zone
|
||||||
|
|
||||||
if valid_zones:
|
if valid_zones or incoming_numeric_zone_ids:
|
||||||
|
layout = merged.setdefault("layout", {})
|
||||||
|
existing_numeric_zone_ids = _existing_numeric_zone_ids(layout)
|
||||||
|
if incoming_numeric_zone_ids or existing_numeric_zone_ids or _is_numeric_zone_ids(valid_zones):
|
||||||
|
zone_order = _numeric_calibration_zone_order(
|
||||||
|
incoming_numeric_zone_ids,
|
||||||
|
existing_numeric_zone_ids,
|
||||||
|
valid_zones,
|
||||||
|
)
|
||||||
|
_validate_numeric_zone_ids(zone_order)
|
||||||
|
existing_by_id = {
|
||||||
|
str(zone.get("id", "")).strip(): zone
|
||||||
|
for zone in merged.get("zones", [])
|
||||||
|
if str(zone.get("id", "")).strip()
|
||||||
|
}
|
||||||
|
layout.pop("rows", None)
|
||||||
|
layout.pop("cols", None)
|
||||||
|
layout["zone_count"] = len(zone_order)
|
||||||
|
layout["zone_ids"] = zone_order
|
||||||
|
merged["zones"] = _ordered_normalized_zones(zone_order, valid_zones, existing_by_id)
|
||||||
|
else:
|
||||||
existing_by_id = {
|
existing_by_id = {
|
||||||
str(zone.get("id", "")).strip(): zone
|
str(zone.get("id", "")).strip(): zone
|
||||||
for zone in merged.get("zones", [])
|
for zone in merged.get("zones", [])
|
||||||
if str(zone.get("id", "")).strip()
|
if str(zone.get("id", "")).strip()
|
||||||
}
|
}
|
||||||
existing_by_id.update(valid_zones)
|
existing_by_id.update(valid_zones)
|
||||||
layout = merged.setdefault("layout", {})
|
|
||||||
zone_order = [str(item) for item in layout.get("zone_ids", []) if str(item) in existing_by_id]
|
zone_order = [str(item) for item in layout.get("zone_ids", []) if str(item) in existing_by_id]
|
||||||
for zone_id in valid_zones:
|
for zone_id in valid_zones:
|
||||||
if zone_id not in zone_order:
|
if zone_id not in zone_order:
|
||||||
@@ -105,7 +137,9 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
|
|
||||||
thresholds = data.get("thresholds", {})
|
thresholds = data.get("thresholds", {})
|
||||||
lines.append("[thresholds]")
|
lines.append("[thresholds]")
|
||||||
|
lines.append(f'pre_warning_seconds = {int(thresholds.get("pre_warning_seconds", 0))}')
|
||||||
lines.append(f'max_dwell_seconds = {int(thresholds.get("max_dwell_seconds", 10_800))}')
|
lines.append(f'max_dwell_seconds = {int(thresholds.get("max_dwell_seconds", 10_800))}')
|
||||||
|
lines.append(f'alarm_removal_seconds = {int(thresholds.get("alarm_removal_seconds", 0))}')
|
||||||
lines.append(f'trash_confirmation_seconds = {int(thresholds.get("trash_confirmation_seconds", 120))}')
|
lines.append(f'trash_confirmation_seconds = {int(thresholds.get("trash_confirmation_seconds", 120))}')
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -123,10 +157,16 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
layout = data.get("layout", {})
|
layout = data.get("layout", {})
|
||||||
zone_ids = [str(item) for item in layout.get("zone_ids", DEFAULT_ZONE_IDS)]
|
zone_ids = list(_zone_ids_from_layout(layout))
|
||||||
|
if not zone_ids:
|
||||||
|
zone_ids = list(DEFAULT_ZONE_IDS)
|
||||||
|
numeric_layout = _is_numeric_zone_ids(zone_ids)
|
||||||
|
lines.append("[layout]")
|
||||||
|
if numeric_layout:
|
||||||
|
lines.append(f"zone_count = {len(zone_ids)}")
|
||||||
|
else:
|
||||||
rows = int(layout.get("rows", 2))
|
rows = int(layout.get("rows", 2))
|
||||||
cols = int(layout.get("cols", 4))
|
cols = int(layout.get("cols", 4))
|
||||||
lines.append("[layout]")
|
|
||||||
lines.append(f"rows = {rows}")
|
lines.append(f"rows = {rows}")
|
||||||
lines.append(f"cols = {cols}")
|
lines.append(f"cols = {cols}")
|
||||||
lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
|
lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
|
||||||
@@ -139,6 +179,9 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
continue
|
continue
|
||||||
lines.append("[[zones]]")
|
lines.append("[[zones]]")
|
||||||
lines.append(f'id = "{_escape(zone_id)}"')
|
lines.append(f'id = "{_escape(zone_id)}"')
|
||||||
|
label = str(zone.get("label", "")).strip()
|
||||||
|
if label:
|
||||||
|
lines.append(f'label = "{_escape(label)}"')
|
||||||
lines.append(f"polygon = {_format_points(polygon)}")
|
lines.append(f"polygon = {_format_points(polygon)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -153,9 +196,82 @@ def format_config_document(data: dict[str, Any]) -> str:
|
|||||||
lines.append("[event_sink]")
|
lines.append("[event_sink]")
|
||||||
lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"')
|
lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"')
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
case_sink = data.get("case_sink", {})
|
||||||
|
if case_sink:
|
||||||
|
lines.append("[case_sink]")
|
||||||
|
lines.append(f'path = "{_escape(str(case_sink.get("path", "logs/cases.jsonl")))}"')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
alarm_snapshot_upload = data.get("alarm_snapshot_upload", {})
|
||||||
|
if alarm_snapshot_upload:
|
||||||
|
lines.append("[alarm_snapshot_upload]")
|
||||||
|
for key in (
|
||||||
|
"connect_timeout_seconds",
|
||||||
|
"encode_timeout_seconds",
|
||||||
|
"enabled",
|
||||||
|
"object_key_prefix",
|
||||||
|
"read_timeout_seconds",
|
||||||
|
"secret",
|
||||||
|
"service_url",
|
||||||
|
):
|
||||||
|
if key not in alarm_snapshot_upload:
|
||||||
|
continue
|
||||||
|
value = alarm_snapshot_upload[key]
|
||||||
|
if isinstance(value, bool):
|
||||||
|
lines.append(f"{key} = {str(value).lower()}")
|
||||||
|
elif isinstance(value, int | float):
|
||||||
|
lines.append(f"{key} = {value}")
|
||||||
|
else:
|
||||||
|
lines.append(f'{key} = "{_escape(str(value))}"')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
webhook_retry_sink = data.get("webhook_retry_sink", {})
|
||||||
|
if webhook_retry_sink:
|
||||||
|
lines.append("[webhook_retry_sink]")
|
||||||
|
lines.append(f'path = "{_escape(str(webhook_retry_sink.get("path", "logs/webhook_retry.jsonl")))}"')
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
webhooks = data.get("webhooks", {})
|
||||||
|
if webhooks:
|
||||||
|
lines.append("[webhooks]")
|
||||||
|
for key in (
|
||||||
|
"callback_token",
|
||||||
|
"case_url",
|
||||||
|
"connect_timeout_seconds",
|
||||||
|
"enabled",
|
||||||
|
"event_url",
|
||||||
|
"read_timeout_seconds",
|
||||||
|
"retry_backoff_seconds",
|
||||||
|
"retry_batch_limit",
|
||||||
|
"retry_max_attempts",
|
||||||
|
"retry_max_backoff_seconds",
|
||||||
|
"source_id",
|
||||||
|
):
|
||||||
|
if key not in webhooks:
|
||||||
|
continue
|
||||||
|
value = webhooks[key]
|
||||||
|
if isinstance(value, bool):
|
||||||
|
lines.append(f"{key} = {str(value).lower()}")
|
||||||
|
elif isinstance(value, int | float):
|
||||||
|
lines.append(f"{key} = {value}")
|
||||||
|
else:
|
||||||
|
lines.append(f'{key} = "{_escape(str(value))}"')
|
||||||
|
lines.append("")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_ids_from_layout(layout: dict[str, Any]) -> tuple[str, ...]:
|
||||||
|
zone_ids = _coerce_zone_ids(layout.get("zone_ids"))
|
||||||
|
if zone_ids:
|
||||||
|
_validate_numeric_zone_ids(zone_ids)
|
||||||
|
_validate_zone_count_matches_ids(layout, zone_ids)
|
||||||
|
return tuple(zone_ids)
|
||||||
|
if "zone_count" in layout:
|
||||||
|
return tuple(_numeric_zone_ids_from_count(layout.get("zone_count")))
|
||||||
|
return _zone_ids_from_rows_cols(layout)
|
||||||
|
|
||||||
|
|
||||||
def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
|
def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
|
||||||
rows = int(layout.get("rows", 0))
|
rows = int(layout.get("rows", 0))
|
||||||
cols = int(layout.get("cols", 0))
|
cols = int(layout.get("cols", 0))
|
||||||
@@ -164,6 +280,108 @@ def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
|
|||||||
return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1))
|
return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _incoming_numeric_zone_ids(layout_update: dict[str, Any] | None) -> list[str]:
|
||||||
|
if not isinstance(layout_update, dict):
|
||||||
|
return []
|
||||||
|
zone_ids = list(_zone_ids_from_layout(layout_update))
|
||||||
|
if not zone_ids:
|
||||||
|
return []
|
||||||
|
if not _is_numeric_zone_ids(zone_ids):
|
||||||
|
raise ValueError("calibration layout zone IDs must be numeric")
|
||||||
|
return zone_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_numeric_zone_ids(layout: dict[str, Any]) -> list[str]:
|
||||||
|
zone_ids = list(_zone_ids_from_layout(layout))
|
||||||
|
if not _is_numeric_zone_ids(zone_ids):
|
||||||
|
return []
|
||||||
|
return zone_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_normalized_zones(
|
||||||
|
zone_order: list[str],
|
||||||
|
valid_zones: dict[str, dict[str, Any]],
|
||||||
|
existing_by_id: dict[str, dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
zones: list[dict[str, Any]] = []
|
||||||
|
for zone_id in zone_order:
|
||||||
|
zone = _normalized_zone(valid_zones.get(zone_id) or existing_by_id.get(zone_id))
|
||||||
|
if zone is not None:
|
||||||
|
zones.append(zone)
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_calibration_zone_order(
|
||||||
|
incoming_numeric_zone_ids: list[str],
|
||||||
|
existing_numeric_zone_ids: list[str],
|
||||||
|
valid_zones: dict[str, dict[str, Any]],
|
||||||
|
) -> list[str]:
|
||||||
|
if incoming_numeric_zone_ids:
|
||||||
|
return incoming_numeric_zone_ids
|
||||||
|
valid_zone_ids = sorted(valid_zones, key=int) if _is_numeric_zone_ids(valid_zones) else []
|
||||||
|
if existing_numeric_zone_ids and valid_zone_ids:
|
||||||
|
if set(valid_zone_ids).issubset(set(existing_numeric_zone_ids)):
|
||||||
|
return existing_numeric_zone_ids
|
||||||
|
return valid_zone_ids
|
||||||
|
return existing_numeric_zone_ids or valid_zone_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_zone(zone: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if zone is None:
|
||||||
|
return None
|
||||||
|
zone_id = str(zone.get("id", "")).strip()
|
||||||
|
polygon = _normalize_points(zone.get("polygon", []))
|
||||||
|
if not zone_id or len(polygon) < 3:
|
||||||
|
return None
|
||||||
|
normalized: dict[str, Any] = {"id": zone_id, "polygon": polygon}
|
||||||
|
label = str(zone.get("label", "")).strip()
|
||||||
|
if zone_id.isdecimal():
|
||||||
|
normalized["label"] = f"区域 {int(zone_id)}"
|
||||||
|
elif label:
|
||||||
|
normalized["label"] = label
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_zone_ids(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list | tuple):
|
||||||
|
return []
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_zone_ids_from_count(value: Any) -> list[str]:
|
||||||
|
count = int(value)
|
||||||
|
if count < 1 or count > MAX_CUSTOM_FOOD_ZONES:
|
||||||
|
raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}")
|
||||||
|
return [str(index) for index in range(1, count + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_numeric_zone_ids(zone_ids: Any) -> bool:
|
||||||
|
return bool(zone_ids) and all(str(zone_id).isdecimal() for zone_id in zone_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_numeric_zone_ids(zone_ids: list[str] | tuple[str, ...]) -> None:
|
||||||
|
numeric_ids = [zone_id for zone_id in zone_ids if zone_id.isdecimal()]
|
||||||
|
if not numeric_ids:
|
||||||
|
return
|
||||||
|
if len(numeric_ids) != len(zone_ids):
|
||||||
|
raise ValueError("numeric food zone IDs must not be mixed with legacy zone IDs")
|
||||||
|
if len(zone_ids) < 1 or len(zone_ids) > MAX_CUSTOM_FOOD_ZONES:
|
||||||
|
raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}")
|
||||||
|
expected = [str(index) for index in range(1, len(zone_ids) + 1)]
|
||||||
|
if list(zone_ids) != expected:
|
||||||
|
raise ValueError("numeric food zone IDs must be contiguous from 1")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_zone_count_matches_ids(layout: dict[str, Any], zone_ids: list[str]) -> None:
|
||||||
|
if "zone_count" not in layout:
|
||||||
|
return
|
||||||
|
count = int(layout["zone_count"])
|
||||||
|
if count < 1 or count > MAX_CUSTOM_FOOD_ZONES:
|
||||||
|
raise ValueError(f"food zone count must be 1 to {MAX_CUSTOM_FOOD_ZONES}")
|
||||||
|
if count != len(zone_ids):
|
||||||
|
raise ValueError("zone_count must match zone_ids length")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_points(value: Any) -> list[list[float]]:
|
def _normalize_points(value: Any) -> list[list[float]]:
|
||||||
points: list[list[float]] = []
|
points: list[list[float]] = []
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ class BatchEngine:
|
|||||||
def process(self, observation: Observation) -> list[dict[str, Any]]:
|
def process(self, observation: Observation) -> list[dict[str, Any]]:
|
||||||
events: list[dict[str, Any]] = []
|
events: list[dict[str, Any]] = []
|
||||||
zone_counts = self._normalized_counts(observation.zone_counts)
|
zone_counts = self._normalized_counts(observation.zone_counts)
|
||||||
|
previous_zone_counts = dict(self._zone_counts)
|
||||||
|
remaining_trash_deposits = observation.trash_deposit_count
|
||||||
|
|
||||||
events.extend(self._expire_pending_disposal(observation.ts))
|
events.extend(self._expire_pending_disposal(observation.ts))
|
||||||
events.extend(self._apply_trash_deposits(observation.ts, observation.trash_deposit_count))
|
trash_events = self._apply_trash_deposits(observation.ts, remaining_trash_deposits)
|
||||||
|
remaining_trash_deposits = max(0, remaining_trash_deposits - len(trash_events))
|
||||||
|
events.extend(trash_events)
|
||||||
|
|
||||||
appeared_zones = [
|
appeared_zones = [
|
||||||
zone_id
|
zone_id
|
||||||
@@ -30,6 +34,11 @@ class BatchEngine:
|
|||||||
if appeared_zones and self.pending_disposal:
|
if appeared_zones and self.pending_disposal:
|
||||||
events.extend(self._mark_pending_as_returned(observation.ts, appeared_zones))
|
events.extend(self._mark_pending_as_returned(observation.ts, appeared_zones))
|
||||||
|
|
||||||
|
events.extend(self._apply_pre_warnings(observation.ts, previous_zone_counts))
|
||||||
|
events.extend(self._apply_time_alarms(observation.ts, previous_zone_counts))
|
||||||
|
events.extend(self._apply_alarm_removal_timeouts(observation.ts))
|
||||||
|
|
||||||
|
pending_count_before_zone_transitions = len(self.pending_disposal)
|
||||||
for zone_id, new_count in zone_counts.items():
|
for zone_id, new_count in zone_counts.items():
|
||||||
previous_count = self._zone_counts.get(zone_id, 0)
|
previous_count = self._zone_counts.get(zone_id, 0)
|
||||||
if previous_count == 0 and new_count > 0:
|
if previous_count == 0 and new_count > 0:
|
||||||
@@ -59,6 +68,11 @@ class BatchEngine:
|
|||||||
|
|
||||||
self._zone_counts[zone_id] = new_count
|
self._zone_counts[zone_id] = new_count
|
||||||
|
|
||||||
|
newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions)
|
||||||
|
trash_deposits_to_apply = remaining_trash_deposits
|
||||||
|
if remaining_trash_deposits > 0 and newly_pending_count > 1:
|
||||||
|
trash_deposits_to_apply = max(remaining_trash_deposits, newly_pending_count)
|
||||||
|
events.extend(self._apply_trash_deposits(observation.ts, trash_deposits_to_apply))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
def _normalized_counts(self, incoming: dict[str, int]) -> dict[str, int]:
|
def _normalized_counts(self, incoming: dict[str, int]) -> dict[str, int]:
|
||||||
@@ -72,6 +86,76 @@ class BatchEngine:
|
|||||||
self._next_batch_index += 1
|
self._next_batch_index += 1
|
||||||
return batch_id
|
return batch_id
|
||||||
|
|
||||||
|
def restore_from_events(self, events: list[dict[str, Any]], active_zone_counts: dict[str, int] | None = None) -> None:
|
||||||
|
active_counts = {str(zone_id): max(0, int(count)) for zone_id, count in (active_zone_counts or {}).items()}
|
||||||
|
self.active_by_zone.clear()
|
||||||
|
self.pending_disposal.clear()
|
||||||
|
self.closed_batches.clear()
|
||||||
|
self._zone_counts = {zone_id: 0 for zone_id in self.settings.zone_ids}
|
||||||
|
max_batch_index = 0
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
batch_id = str(event.get("batch_id", ""))
|
||||||
|
max_batch_index = max(max_batch_index, batch_index(batch_id))
|
||||||
|
zone_id = str(event.get("zone_id", ""))
|
||||||
|
if zone_id not in self._zone_counts:
|
||||||
|
continue
|
||||||
|
event_name = str(event.get("event", ""))
|
||||||
|
if event_name in {
|
||||||
|
"batch_started",
|
||||||
|
"batch_count_changed",
|
||||||
|
"mixed_batch_violation",
|
||||||
|
"time_pre_warning",
|
||||||
|
"time_alarm",
|
||||||
|
"alarm_removal_timeout",
|
||||||
|
}:
|
||||||
|
if active_zone_counts is not None and active_counts.get(zone_id, 0) <= 0:
|
||||||
|
self.active_by_zone.pop(zone_id, None)
|
||||||
|
self._zone_counts[zone_id] = 0
|
||||||
|
continue
|
||||||
|
batch = self._batch_from_event(event)
|
||||||
|
if batch is None:
|
||||||
|
continue
|
||||||
|
self.active_by_zone[zone_id] = batch
|
||||||
|
self._zone_counts[zone_id] = max(1, active_counts.get(zone_id, batch.last_count))
|
||||||
|
elif event_name in {"batch_consumed", "batch_pending_disposal", "batch_discarded", "warning_escalated", "overdue_return_violation"} or event_name.endswith("_violation"):
|
||||||
|
self.active_by_zone.pop(zone_id, None)
|
||||||
|
self._zone_counts[zone_id] = 0
|
||||||
|
|
||||||
|
if active_zone_counts is not None:
|
||||||
|
for zone_id in self._zone_counts:
|
||||||
|
self._zone_counts[zone_id] = active_counts.get(zone_id, 0)
|
||||||
|
self._next_batch_index = max(self._next_batch_index, max_batch_index + 1)
|
||||||
|
|
||||||
|
def _batch_from_event(self, event: dict[str, Any]) -> Batch | None:
|
||||||
|
batch_id = str(event.get("batch_id", "")).strip()
|
||||||
|
zone_id = str(event.get("zone_id", "")).strip()
|
||||||
|
started_at = parse_event_datetime(event.get("started_at"))
|
||||||
|
if not batch_id or not zone_id or started_at is None:
|
||||||
|
return None
|
||||||
|
batch = Batch(
|
||||||
|
batch_id=batch_id,
|
||||||
|
zone_id=zone_id,
|
||||||
|
started_at=started_at,
|
||||||
|
last_count=max(1, int(event.get("current_count", 1) or 1)),
|
||||||
|
state=str(event.get("state", "active") or "active"),
|
||||||
|
)
|
||||||
|
batch.pre_warned_at = parse_event_datetime(event.get("pre_warned_at"))
|
||||||
|
batch.alerted_at = parse_event_datetime(event.get("alerted_at"))
|
||||||
|
if batch.alerted_at is not None:
|
||||||
|
batch.state = "alerted"
|
||||||
|
if self.settings.alarm_removal_seconds > 0:
|
||||||
|
batch.alarm_removal_deadline = parse_event_datetime(event.get("alarm_removal_deadline"))
|
||||||
|
if batch.alarm_removal_deadline is None:
|
||||||
|
batch.alarm_removal_deadline = batch.alerted_at + self.settings.alarm_removal_window
|
||||||
|
elif batch.pre_warned_at is not None:
|
||||||
|
batch.state = "pre_warning"
|
||||||
|
batch.alarm_removal_timed_out_at = parse_event_datetime(event.get("alarm_removal_timed_out_at"))
|
||||||
|
if batch.alarm_removal_timed_out_at is not None:
|
||||||
|
batch.state = "alarm_removal_timeout"
|
||||||
|
batch.dwell_seconds = max(0, int(event.get("dwell_seconds", 0) or 0))
|
||||||
|
return batch
|
||||||
|
|
||||||
def _start_batch(self, zone_id: str, count: int, when: datetime) -> dict[str, Any]:
|
def _start_batch(self, zone_id: str, count: int, when: datetime) -> dict[str, Any]:
|
||||||
batch = Batch(
|
batch = Batch(
|
||||||
batch_id=self._next_batch_id(),
|
batch_id=self._next_batch_id(),
|
||||||
@@ -90,16 +174,97 @@ class BatchEngine:
|
|||||||
batch.dwell_seconds = batch.current_dwell_seconds(when)
|
batch.dwell_seconds = batch.current_dwell_seconds(when)
|
||||||
batch.ended_at = when
|
batch.ended_at = when
|
||||||
|
|
||||||
if batch.dwell_seconds >= self.settings.max_dwell_seconds:
|
if batch.alerted_at is not None or batch.dwell_seconds >= self.settings.max_dwell_seconds:
|
||||||
batch.state = "pending_disposal"
|
batch.state = "pending_disposal"
|
||||||
batch.pending_since = when
|
batch.pending_since = when
|
||||||
batch.disposal_deadline = when + self.settings.trash_confirmation_window
|
batch.disposal_deadline = when + self.settings.trash_confirmation_window
|
||||||
self.pending_disposal.append(batch)
|
self.pending_disposal.append(batch)
|
||||||
return self._event("batch_pending_disposal", when, batch)
|
return self._event("batch_pending_disposal", when, batch, severity="warning")
|
||||||
|
|
||||||
|
if batch.pre_warned_at is not None:
|
||||||
|
batch.state = "handled"
|
||||||
|
self.closed_batches.append(batch)
|
||||||
|
return self._event(
|
||||||
|
"pre_warning_handled",
|
||||||
|
when,
|
||||||
|
batch,
|
||||||
|
severity="info",
|
||||||
|
handled_source="auto_removed_before_alarm",
|
||||||
|
)
|
||||||
|
|
||||||
batch.state = "consumed"
|
batch.state = "consumed"
|
||||||
self.closed_batches.append(batch)
|
self.closed_batches.append(batch)
|
||||||
return self._event("batch_consumed", when, batch)
|
return self._event("batch_consumed", when, batch, severity="info")
|
||||||
|
|
||||||
|
def _apply_pre_warnings(self, when: datetime, zone_counts: dict[str, int]) -> list[dict[str, Any]]:
|
||||||
|
if self.settings.pre_warning_seconds <= 0:
|
||||||
|
return []
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for zone_id, batch in self.active_by_zone.items():
|
||||||
|
if batch.pre_warned_at is not None or batch.alerted_at is not None:
|
||||||
|
continue
|
||||||
|
dwell_seconds = batch.current_dwell_seconds(when)
|
||||||
|
if dwell_seconds < self.settings.pre_warning_seconds:
|
||||||
|
continue
|
||||||
|
batch.state = "pre_warning"
|
||||||
|
batch.pre_warned_at = when
|
||||||
|
events.append(
|
||||||
|
self._event(
|
||||||
|
"time_pre_warning",
|
||||||
|
when,
|
||||||
|
batch,
|
||||||
|
severity="warning",
|
||||||
|
current_count=zone_counts.get(zone_id, batch.last_count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _apply_time_alarms(self, when: datetime, zone_counts: dict[str, int]) -> list[dict[str, Any]]:
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for zone_id, batch in self.active_by_zone.items():
|
||||||
|
if batch.alerted_at is not None:
|
||||||
|
continue
|
||||||
|
dwell_seconds = batch.current_dwell_seconds(when)
|
||||||
|
if dwell_seconds < self.settings.max_dwell_seconds:
|
||||||
|
continue
|
||||||
|
batch.state = "alerted"
|
||||||
|
batch.alerted_at = when
|
||||||
|
if self.settings.alarm_removal_seconds > 0:
|
||||||
|
batch.alarm_removal_deadline = when + self.settings.alarm_removal_window
|
||||||
|
events.append(
|
||||||
|
self._event(
|
||||||
|
"time_alarm",
|
||||||
|
when,
|
||||||
|
batch,
|
||||||
|
severity="alarm",
|
||||||
|
current_count=zone_counts.get(zone_id, batch.last_count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _apply_alarm_removal_timeouts(self, when: datetime) -> list[dict[str, Any]]:
|
||||||
|
if self.settings.alarm_removal_seconds <= 0:
|
||||||
|
return []
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
for batch in self.active_by_zone.values():
|
||||||
|
if batch.alerted_at is None or batch.alarm_removal_deadline is None:
|
||||||
|
continue
|
||||||
|
if batch.alarm_removal_timed_out_at is not None:
|
||||||
|
continue
|
||||||
|
if when <= batch.alarm_removal_deadline:
|
||||||
|
continue
|
||||||
|
batch.state = "alarm_removal_timeout"
|
||||||
|
batch.alarm_removal_timed_out_at = when
|
||||||
|
events.append(
|
||||||
|
self._event(
|
||||||
|
"alarm_removal_timeout",
|
||||||
|
when,
|
||||||
|
batch,
|
||||||
|
severity="alarm",
|
||||||
|
reason="alarmed_batch_not_removed_after_alarm_window",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
def _mark_mixed_batch(
|
def _mark_mixed_batch(
|
||||||
self,
|
self,
|
||||||
@@ -149,7 +314,7 @@ class BatchEngine:
|
|||||||
batch = self.pending_disposal.pop(0)
|
batch = self.pending_disposal.pop(0)
|
||||||
batch.state = "discarded"
|
batch.state = "discarded"
|
||||||
self.closed_batches.append(batch)
|
self.closed_batches.append(batch)
|
||||||
events.append(self._event("batch_discarded", when, batch))
|
events.append(self._event("batch_discarded", when, batch, severity="info"))
|
||||||
deposit_count -= 1
|
deposit_count -= 1
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -158,15 +323,16 @@ class BatchEngine:
|
|||||||
still_pending: list[Batch] = []
|
still_pending: list[Batch] = []
|
||||||
for batch in self.pending_disposal:
|
for batch in self.pending_disposal:
|
||||||
if batch.disposal_deadline is not None and when > batch.disposal_deadline:
|
if batch.disposal_deadline is not None and when > batch.disposal_deadline:
|
||||||
batch.state = "violation"
|
batch.state = "warning"
|
||||||
batch.violation_reasons.add("missing_disposal")
|
batch.violation_reasons.add("missing_disposal")
|
||||||
self.closed_batches.append(batch)
|
self.closed_batches.append(batch)
|
||||||
events.append(
|
events.append(
|
||||||
self._event(
|
self._event(
|
||||||
"missing_disposal_violation",
|
"warning_escalated",
|
||||||
when,
|
when,
|
||||||
batch,
|
batch,
|
||||||
reason="trash_deposit_not_observed_before_deadline",
|
severity="warning",
|
||||||
|
reason="alarmed_batch_removed_without_trash_deposit",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -177,14 +343,30 @@ class BatchEngine:
|
|||||||
def _event(self, event_name: str, when: datetime, batch: Batch, **extra: Any) -> dict[str, Any]:
|
def _event(self, event_name: str, when: datetime, batch: Batch, **extra: Any) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"event": event_name,
|
"event": event_name,
|
||||||
|
"severity": self._event_severity(event_name),
|
||||||
"ts": when.isoformat(),
|
"ts": when.isoformat(),
|
||||||
"camera_id": self.settings.camera_id,
|
"camera_id": self.settings.camera_id,
|
||||||
"zone_id": batch.zone_id,
|
"zone_id": batch.zone_id,
|
||||||
|
"zone_label": self._zone_label(batch.zone_id),
|
||||||
"batch_id": batch.batch_id,
|
"batch_id": batch.batch_id,
|
||||||
"state": batch.state,
|
"state": batch.state,
|
||||||
"started_at": batch.started_at.isoformat(),
|
"started_at": batch.started_at.isoformat(),
|
||||||
"dwell_seconds": batch.current_dwell_seconds(when),
|
"dwell_seconds": batch.current_dwell_seconds(when),
|
||||||
|
"pre_warning_seconds": self.settings.pre_warning_seconds,
|
||||||
|
"max_dwell_seconds": self.settings.max_dwell_seconds,
|
||||||
|
"alarm_removal_seconds": self.settings.alarm_removal_seconds,
|
||||||
}
|
}
|
||||||
|
zone_index = self._zone_index(batch.zone_id)
|
||||||
|
if zone_index is not None:
|
||||||
|
payload["zone_index"] = zone_index
|
||||||
|
if batch.pre_warned_at is not None:
|
||||||
|
payload["pre_warned_at"] = batch.pre_warned_at.isoformat()
|
||||||
|
if batch.alerted_at is not None:
|
||||||
|
payload["alerted_at"] = batch.alerted_at.isoformat()
|
||||||
|
if batch.alarm_removal_deadline is not None:
|
||||||
|
payload["alarm_removal_deadline"] = batch.alarm_removal_deadline.isoformat()
|
||||||
|
if batch.alarm_removal_timed_out_at is not None:
|
||||||
|
payload["alarm_removal_timed_out_at"] = batch.alarm_removal_timed_out_at.isoformat()
|
||||||
if batch.ended_at is not None:
|
if batch.ended_at is not None:
|
||||||
payload["ended_at"] = batch.ended_at.isoformat()
|
payload["ended_at"] = batch.ended_at.isoformat()
|
||||||
if batch.disposal_deadline is not None:
|
if batch.disposal_deadline is not None:
|
||||||
@@ -193,3 +375,41 @@ class BatchEngine:
|
|||||||
payload["violation_reasons"] = sorted(batch.violation_reasons)
|
payload["violation_reasons"] = sorted(batch.violation_reasons)
|
||||||
payload.update(extra)
|
payload.update(extra)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
def _event_severity(self, event_name: str) -> str:
|
||||||
|
if event_name in {"time_alarm", "alarm_removal_timeout"}:
|
||||||
|
return "alarm"
|
||||||
|
if event_name in {"warning_escalated", "batch_pending_disposal", "time_pre_warning"}:
|
||||||
|
return "warning"
|
||||||
|
if event_name.endswith("_violation"):
|
||||||
|
return "warning"
|
||||||
|
return "info"
|
||||||
|
|
||||||
|
def _zone_index(self, zone_id: str) -> int | None:
|
||||||
|
if zone_id.isdecimal():
|
||||||
|
return int(zone_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _zone_label(self, zone_id: str) -> str:
|
||||||
|
zone_index = self._zone_index(zone_id)
|
||||||
|
if zone_index is None:
|
||||||
|
return zone_id
|
||||||
|
return f"区域 {zone_index}"
|
||||||
|
|
||||||
|
|
||||||
|
def batch_index(batch_id: str) -> int:
|
||||||
|
try:
|
||||||
|
return int(str(batch_id).rsplit("_", maxsplit=1)[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_datetime(value: Any) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from cold_display_guard.alarm_snapshots import capture_alert_snapshot
|
||||||
|
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
|
||||||
from cold_display_guard.config import load_config_document, load_settings, resolve_config_path, resolve_project_root
|
from cold_display_guard.config import load_config_document, load_settings, resolve_config_path, resolve_project_root
|
||||||
from cold_display_guard.engine import BatchEngine
|
from cold_display_guard.engine import BatchEngine
|
||||||
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
|
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
|
||||||
from cold_display_guard.models import Observation
|
from cold_display_guard.models import Observation
|
||||||
from cold_display_guard.vision import ZoneOccupancyDetector, load_regions, load_runtime_vision_settings
|
from cold_display_guard.vision import (
|
||||||
|
RegionMetrics,
|
||||||
|
ZoneOccupancyDetector,
|
||||||
|
load_regions,
|
||||||
|
load_runtime_vision_settings,
|
||||||
|
metrics_indicate_occupied,
|
||||||
|
)
|
||||||
|
from cold_display_guard.webhooks import drain_webhook_retries, send_batch_event_webhooks, send_case_webhooks
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -45,6 +54,12 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
|||||||
|
|
||||||
timezone = ZoneInfo(str(config.get("timezone", "Asia/Shanghai")))
|
timezone = ZoneInfo(str(config.get("timezone", "Asia/Shanghai")))
|
||||||
event_path = resolve_project_path(project_root, str(config.get("event_sink", {}).get("path", "logs/events.jsonl")))
|
event_path = resolve_project_path(project_root, str(config.get("event_sink", {}).get("path", "logs/events.jsonl")))
|
||||||
|
case_path = case_sink_path(project_root, config)
|
||||||
|
webhook_retry_path = webhook_retry_sink_path(project_root, config)
|
||||||
|
webhook_delivery_path = resolve_project_path(
|
||||||
|
project_root,
|
||||||
|
str(config.get("webhook_delivery_sink", {}).get("path", "logs/webhook_delivery.jsonl")),
|
||||||
|
)
|
||||||
diagnostics_path = resolve_project_path(project_root, str(runtime.get("diagnostics_path", "logs/runtime_diagnostics.jsonl")))
|
diagnostics_path = resolve_project_path(project_root, str(runtime.get("diagnostics_path", "logs/runtime_diagnostics.jsonl")))
|
||||||
sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0)))
|
sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0)))
|
||||||
frame_width = max(64, int(runtime.get("frame_width", 640)))
|
frame_width = max(64, int(runtime.get("frame_width", 640)))
|
||||||
@@ -57,14 +72,26 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
|||||||
height=frame_height,
|
height=frame_height,
|
||||||
timeout_seconds=capture_timeout_seconds,
|
timeout_seconds=capture_timeout_seconds,
|
||||||
)
|
)
|
||||||
detector = ZoneOccupancyDetector(regions, trash_region, load_runtime_vision_settings(config))
|
vision_settings = load_runtime_vision_settings(config)
|
||||||
|
detector = ZoneOccupancyDetector(regions, trash_region, vision_settings)
|
||||||
engine = BatchEngine(settings)
|
engine = BatchEngine(settings)
|
||||||
|
case_store = load_case_store(case_path)
|
||||||
|
baseline_seed, active_zone_counts = restore_runtime_state(diagnostics_path, config)
|
||||||
|
if baseline_seed:
|
||||||
|
detector.seed_baseline(baseline_seed)
|
||||||
|
if active_zone_counts:
|
||||||
|
detector.seed_occupancy(active_zone_counts)
|
||||||
|
engine.restore_from_events(load_jsonl_tail(event_path, 2000), active_zone_counts=active_zone_counts)
|
||||||
|
|
||||||
event_path.parent.mkdir(parents=True, exist_ok=True)
|
event_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
case_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
webhook_retry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
webhook_delivery_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
diagnostics_path.parent.mkdir(parents=True, exist_ok=True)
|
diagnostics_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"Cold Display Guard runtime started")
|
print(f"Cold Display Guard runtime started")
|
||||||
print(f"Config: {resolved_config}")
|
print(f"Config: {resolved_config}")
|
||||||
print(f"Events: {event_path}")
|
print(f"Events: {event_path}")
|
||||||
|
print(f"Cases: {case_path}")
|
||||||
print(f"Diagnostics: {diagnostics_path}")
|
print(f"Diagnostics: {diagnostics_path}")
|
||||||
|
|
||||||
iteration = 0
|
iteration = 0
|
||||||
@@ -77,6 +104,17 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
|||||||
observation = Observation(ts=when, zone_counts=zone_counts, trash_deposit_count=trash_deposit_count)
|
observation = Observation(ts=when, zone_counts=zone_counts, trash_deposit_count=trash_deposit_count)
|
||||||
events = engine.process(observation)
|
events = engine.process(observation)
|
||||||
append_jsonl(event_path, events)
|
append_jsonl(event_path, events)
|
||||||
|
case_snapshots = persist_case_updates(case_store, case_path, events)
|
||||||
|
snapshot_upload = capture_runtime_alarm_snapshot(frame, events, config, now=when)
|
||||||
|
deliver_runtime_webhooks(
|
||||||
|
events,
|
||||||
|
case_snapshots,
|
||||||
|
config,
|
||||||
|
webhook_delivery_path,
|
||||||
|
retry_path=webhook_retry_path,
|
||||||
|
now=when,
|
||||||
|
snapshot_upload=snapshot_upload,
|
||||||
|
)
|
||||||
append_jsonl(
|
append_jsonl(
|
||||||
diagnostics_path,
|
diagnostics_path,
|
||||||
[
|
[
|
||||||
@@ -109,6 +147,16 @@ def resolve_project_path(project_root: Path, raw_path: str) -> Path:
|
|||||||
return path.resolve()
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def case_sink_path(project_root: Path, config: dict) -> Path:
|
||||||
|
raw_path = str(config.get("case_sink", {}).get("path", "logs/cases.jsonl"))
|
||||||
|
return resolve_project_path(project_root, raw_path)
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_retry_sink_path(project_root: Path, config: dict) -> Path:
|
||||||
|
raw_path = str(config.get("webhook_retry_sink", {}).get("path", "logs/webhook_retry.jsonl"))
|
||||||
|
return resolve_project_path(project_root, raw_path)
|
||||||
|
|
||||||
|
|
||||||
def append_jsonl(path: Path, payloads: list[dict]) -> None:
|
def append_jsonl(path: Path, payloads: list[dict]) -> None:
|
||||||
if not payloads:
|
if not payloads:
|
||||||
return
|
return
|
||||||
@@ -118,5 +166,151 @@ def append_jsonl(path: Path, payloads: list[dict]) -> None:
|
|||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def load_case_store(path: Path) -> CaseStore:
|
||||||
|
return CaseStore(load_case_snapshots(path))
|
||||||
|
|
||||||
|
|
||||||
|
def persist_case_updates(case_store: CaseStore, path: Path, events: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
|
case_store = load_case_store(path)
|
||||||
|
snapshots = case_store.apply_batch_events(events)
|
||||||
|
append_case_snapshots(path, snapshots)
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def capture_runtime_alarm_snapshot(
|
||||||
|
frame,
|
||||||
|
events: list[dict[str, object]],
|
||||||
|
config: dict[str, object],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
jpeg_encoder=None,
|
||||||
|
uploader=None,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
return capture_alert_snapshot(
|
||||||
|
frame,
|
||||||
|
events,
|
||||||
|
config,
|
||||||
|
now=now,
|
||||||
|
jpeg_encoder=jpeg_encoder,
|
||||||
|
uploader=uploader,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deliver_runtime_webhooks(
|
||||||
|
events: list[dict[str, object]],
|
||||||
|
case_snapshots: list[dict[str, object]],
|
||||||
|
config: dict[str, object],
|
||||||
|
audit_path: Path,
|
||||||
|
*,
|
||||||
|
retry_path: Path | None = None,
|
||||||
|
http_post=None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
snapshot_upload: dict[str, object] | None = None,
|
||||||
|
) -> None:
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
events,
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=http_post,
|
||||||
|
now=now,
|
||||||
|
snapshot_upload=snapshot_upload,
|
||||||
|
)
|
||||||
|
send_case_webhooks(
|
||||||
|
case_snapshots,
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=http_post,
|
||||||
|
now=now,
|
||||||
|
snapshot_upload=snapshot_upload,
|
||||||
|
)
|
||||||
|
if retry_path is not None:
|
||||||
|
drain_webhook_retries(config, retry_path, audit_path, http_post=http_post, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_runtime_state(diagnostics_path: Path, config: dict) -> tuple[dict[str, RegionMetrics], dict[str, int]]:
|
||||||
|
latest = load_jsonl_tail(diagnostics_path, 1)
|
||||||
|
if not latest:
|
||||||
|
return {}, {}
|
||||||
|
item = latest[-1]
|
||||||
|
diagnostics = item.get("diagnostics")
|
||||||
|
if not isinstance(diagnostics, dict):
|
||||||
|
return {}, latest_zone_counts_from_item(item)
|
||||||
|
zones = diagnostics.get("zones")
|
||||||
|
if not isinstance(zones, dict):
|
||||||
|
return {}, latest_zone_counts_from_item(item)
|
||||||
|
|
||||||
|
settings = load_runtime_vision_settings(config)
|
||||||
|
baselines: dict[str, RegionMetrics] = {}
|
||||||
|
zone_counts: dict[str, int] = {}
|
||||||
|
for zone_id, metrics in zones.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
region_id = str(zone_id)
|
||||||
|
baseline_mean = numeric_metric(metrics.get("baseline_mean_luma"))
|
||||||
|
baseline_texture = numeric_metric(metrics.get("baseline_texture"))
|
||||||
|
baseline_dark_fraction = numeric_metric(metrics.get("baseline_dark_fraction")) or 0.0
|
||||||
|
baseline_bright_fraction = numeric_metric(metrics.get("baseline_bright_fraction")) or 0.0
|
||||||
|
if baseline_mean is not None and baseline_texture is not None:
|
||||||
|
baselines[region_id] = RegionMetrics(
|
||||||
|
mean_luma=baseline_mean,
|
||||||
|
texture=baseline_texture,
|
||||||
|
sample_count=1,
|
||||||
|
dark_fraction=baseline_dark_fraction,
|
||||||
|
bright_fraction=baseline_bright_fraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
stable_occupied = metrics.get("occupied")
|
||||||
|
if isinstance(stable_occupied, bool):
|
||||||
|
zone_counts[region_id] = 1 if stable_occupied else 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
mean_delta = numeric_metric(metrics.get("mean_delta"))
|
||||||
|
texture_delta = numeric_metric(metrics.get("texture_delta"))
|
||||||
|
if mean_delta is None or texture_delta is None:
|
||||||
|
continue
|
||||||
|
dark_fraction = numeric_metric(metrics.get("dark_fraction"))
|
||||||
|
bright_fraction = numeric_metric(metrics.get("bright_fraction")) or 0.0
|
||||||
|
occupied = metrics_indicate_occupied(
|
||||||
|
settings,
|
||||||
|
mean_delta,
|
||||||
|
texture_delta,
|
||||||
|
dark_fraction=dark_fraction,
|
||||||
|
baseline_dark_fraction=baseline_dark_fraction,
|
||||||
|
bright_fraction=bright_fraction,
|
||||||
|
)
|
||||||
|
zone_counts[region_id] = 1 if occupied else 0
|
||||||
|
return baselines, zone_counts or latest_zone_counts_from_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_zone_counts_from_item(item: dict) -> dict[str, int]:
|
||||||
|
zone_counts = item.get("zone_counts")
|
||||||
|
if not isinstance(zone_counts, dict):
|
||||||
|
return {}
|
||||||
|
return {str(zone_id): max(0, int(count)) for zone_id, count in zone_counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_metric(value: object) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_jsonl_tail(path: Path, limit: int) -> list[dict]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
items: list[dict] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines()[-limit:]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
items.append(payload)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
|
||||||
from cold_display_guard.config import (
|
from cold_display_guard.config import (
|
||||||
load_config_document,
|
load_config_document,
|
||||||
merge_calibration,
|
merge_calibration,
|
||||||
@@ -18,6 +19,8 @@ from cold_display_guard.config import (
|
|||||||
resolve_project_root,
|
resolve_project_root,
|
||||||
save_config_document,
|
save_config_document,
|
||||||
)
|
)
|
||||||
|
from cold_display_guard.vision import load_runtime_vision_settings, metrics_indicate_occupied
|
||||||
|
from cold_display_guard.webhooks import drain_webhook_retries, load_retry_snapshots, send_case_webhooks
|
||||||
|
|
||||||
|
|
||||||
PROJECT_TYPE = "cold_display_guard"
|
PROJECT_TYPE = "cold_display_guard"
|
||||||
@@ -65,6 +68,21 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
||||||
self._send_json({"items": load_events(ctx, limit), "limit": limit})
|
self._send_json({"items": load_events(ctx, limit), "limit": limit})
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/api/manage/cases":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
||||||
|
status = str(query.get("status", [""])[0]).strip().lower()
|
||||||
|
self._send_json({"items": load_cases(ctx, limit=limit, status=status), "limit": limit})
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/manage/cases/summary":
|
||||||
|
self._send_json(build_case_summary(ctx))
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/manage/webhooks/retries":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
||||||
|
status = str(query.get("status", [""])[0]).strip().lower()
|
||||||
|
self._send_json({"items": load_webhook_retries(ctx, limit=limit, status=status), "limit": limit})
|
||||||
|
return
|
||||||
if parsed.path == "/api/manage/diagnostics":
|
if parsed.path == "/api/manage/diagnostics":
|
||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES)
|
limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES)
|
||||||
@@ -87,6 +105,16 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
if parsed.path == "/api/manage/snapshot":
|
if parsed.path == "/api/manage/snapshot":
|
||||||
self._capture_snapshot()
|
self._capture_snapshot()
|
||||||
return
|
return
|
||||||
|
if parsed.path.startswith("/api/manage/cases/") and parsed.path.endswith("/handle"):
|
||||||
|
case_id = parsed.path.removeprefix("/api/manage/cases/").removesuffix("/handle").strip("/")
|
||||||
|
self._handle_case(case_id)
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/manage/webhooks/case-update":
|
||||||
|
self._handle_case_callback()
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/manage/webhooks/retries/drain":
|
||||||
|
self._drain_webhook_retries()
|
||||||
|
return
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def log_message(self, format: str, *args: object) -> None:
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
@@ -119,9 +147,17 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
if not isinstance(zones, list):
|
if not isinstance(zones, list):
|
||||||
self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST)
|
self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
|
layout = payload.get("layout")
|
||||||
|
if layout is not None and not isinstance(layout, dict):
|
||||||
|
self._send_json({"error": "layout must be an object"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
trash_roi = trash.get("roi") if isinstance(trash, dict) else None
|
trash_roi = trash.get("roi") if isinstance(trash, dict) else None
|
||||||
data = load_config_document(ctx.config_path)
|
data = load_config_document(ctx.config_path)
|
||||||
merged = merge_calibration(data, zones, trash_roi)
|
try:
|
||||||
|
merged = merge_calibration(data, zones, trash_roi, layout)
|
||||||
|
except ValueError as exc:
|
||||||
|
self._send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
save_config_document(ctx.config_path, merged)
|
save_config_document(ctx.config_path, merged)
|
||||||
self._send_json(config_payload(ctx))
|
self._send_json(config_payload(ctx))
|
||||||
|
|
||||||
@@ -145,6 +181,59 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(image)
|
self.wfile.write(image)
|
||||||
|
|
||||||
|
def _handle_case(self, case_id: str) -> None:
|
||||||
|
payload = self._read_json()
|
||||||
|
handled_by = str(payload.get("handled_by", "")).strip()
|
||||||
|
if not case_id:
|
||||||
|
self._send_json({"error": "case_id is required"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
if not handled_by:
|
||||||
|
self._send_json({"error": "handled_by is required"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
snapshot = handle_case_update(
|
||||||
|
ctx,
|
||||||
|
case_id,
|
||||||
|
handled_by=handled_by,
|
||||||
|
handled_source="manual",
|
||||||
|
note=str(payload.get("note", "")).strip(),
|
||||||
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
self._send_json({"error": "case not found"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
self._send_json(snapshot)
|
||||||
|
|
||||||
|
def _handle_case_callback(self) -> None:
|
||||||
|
payload = self._read_json()
|
||||||
|
config = load_config_document(ctx.config_path)
|
||||||
|
token = str(config.get("webhooks", {}).get("callback_token", ""))
|
||||||
|
if not token or self.headers.get("X-Webhook-Token") != token:
|
||||||
|
self._send_json({"error": "forbidden"}, HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
case_id = str(payload.get("case_id", "")).strip()
|
||||||
|
status = str(payload.get("status", "")).strip().lower()
|
||||||
|
if not case_id:
|
||||||
|
self._send_json({"error": "case_id is required"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
if status != "handled":
|
||||||
|
self._send_json({"error": "status must be handled"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
snapshot = handle_case_update(
|
||||||
|
ctx,
|
||||||
|
case_id,
|
||||||
|
handled_by=str(payload.get("handled_by", "")).strip(),
|
||||||
|
handled_source="webhook_callback",
|
||||||
|
source_ref=str(payload.get("source_ref", "")).strip(),
|
||||||
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
self._send_json({"error": "case not found"}, HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
self._send_json(snapshot)
|
||||||
|
|
||||||
|
def _drain_webhook_retries(self) -> None:
|
||||||
|
payload = self._read_json()
|
||||||
|
limit = bounded_int(payload.get("limit", 200), 1, MAX_EVENT_LINES)
|
||||||
|
self._send_json(drain_webhook_retry_queue(ctx, limit=limit))
|
||||||
|
|
||||||
def _read_json(self) -> dict[str, Any]:
|
def _read_json(self) -> dict[str, Any]:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
if length == 0:
|
if length == 0:
|
||||||
@@ -220,6 +309,12 @@ def main() -> int:
|
|||||||
def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
||||||
data = load_config_document(ctx.config_path)
|
data = load_config_document(ctx.config_path)
|
||||||
event_path = event_sink_path(ctx, data)
|
event_path = event_sink_path(ctx, data)
|
||||||
|
case_path = case_sink_path(ctx, data)
|
||||||
|
retry_path = webhook_retry_sink_path(ctx, data)
|
||||||
|
alarm_snapshot_upload = dict(data.get("alarm_snapshot_upload", {}) or {})
|
||||||
|
alarm_snapshot_upload.pop("secret", None)
|
||||||
|
webhooks = dict(data.get("webhooks", {}) or {})
|
||||||
|
webhooks.pop("callback_token", None)
|
||||||
return {
|
return {
|
||||||
"project_type": PROJECT_TYPE,
|
"project_type": PROJECT_TYPE,
|
||||||
"config_path": str(ctx.config_path),
|
"config_path": str(ctx.config_path),
|
||||||
@@ -234,28 +329,48 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
|||||||
"zones": data.get("zones", []),
|
"zones": data.get("zones", []),
|
||||||
"trash": data.get("trash", {}),
|
"trash": data.get("trash", {}),
|
||||||
"event_sink": {"path": str(event_path)},
|
"event_sink": {"path": str(event_path)},
|
||||||
|
"case_sink": {"path": str(case_path)},
|
||||||
|
"webhook_retry_sink": {"path": str(retry_path)},
|
||||||
|
"alarm_snapshot_upload": alarm_snapshot_upload,
|
||||||
|
"webhooks": webhooks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
||||||
|
config = load_config_document(ctx.config_path)
|
||||||
events = load_events(ctx, MAX_EVENT_LINES)
|
events = load_events(ctx, MAX_EVENT_LINES)
|
||||||
diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES)
|
diagnostics = load_diagnostics(ctx, MAX_EVENT_LINES)
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
last_event_time = ""
|
last_event_time = ""
|
||||||
latest_alert = ""
|
latest_alert = ""
|
||||||
|
alert_count = 0
|
||||||
|
warning_count = 0
|
||||||
|
violation_count = 0
|
||||||
for event in events:
|
for event in events:
|
||||||
event_name = str(event.get("event", "unknown"))
|
event_name = str(event.get("event", "unknown"))
|
||||||
|
severity = str(event.get("severity", "")).lower()
|
||||||
counts[event_name] = counts.get(event_name, 0) + 1
|
counts[event_name] = counts.get(event_name, 0) + 1
|
||||||
ts = str(event.get("ts", ""))
|
ts = str(event.get("ts", ""))
|
||||||
if ts:
|
if ts:
|
||||||
last_event_time = ts
|
last_event_time = ts
|
||||||
if event_name.endswith("_violation"):
|
is_alarm = severity == "alarm" or event_name == "time_alarm"
|
||||||
|
is_warning = event_name == "warning_escalated" or event_name.endswith("_violation")
|
||||||
|
if is_alarm:
|
||||||
|
alert_count += 1
|
||||||
|
latest_alert = ts
|
||||||
|
if is_warning:
|
||||||
|
warning_count += 1
|
||||||
|
latest_alert = ts
|
||||||
|
if event_name == "warning_escalated" or event_name.endswith("_violation"):
|
||||||
|
violation_count += 1
|
||||||
|
elif severity == "warning" and event.get("state") == "warning":
|
||||||
|
violation_count += 1
|
||||||
|
if event_name.endswith("_violation") and not severity:
|
||||||
latest_alert = ts
|
latest_alert = ts
|
||||||
|
|
||||||
active_alert_count = sum(counts.get(name, 0) for name in counts if name.endswith("_violation"))
|
|
||||||
headline = "No batch events yet"
|
headline = "No batch events yet"
|
||||||
if events:
|
if events:
|
||||||
headline = f"{len(events)} event(s), {active_alert_count} violation event(s)"
|
headline = f"{len(events)} event(s), {alert_count} alarm event(s), {warning_count} warning event(s)"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"result_type": PROJECT_TYPE,
|
"result_type": PROJECT_TYPE,
|
||||||
@@ -264,12 +379,14 @@ def build_summary(ctx: ManageContext) -> dict[str, Any]:
|
|||||||
"metrics": {
|
"metrics": {
|
||||||
"event_counts": counts,
|
"event_counts": counts,
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
"violation_count": active_alert_count,
|
"alert_count": alert_count,
|
||||||
|
"warning_count": warning_count,
|
||||||
|
"violation_count": violation_count,
|
||||||
"latest_alert_time": latest_alert,
|
"latest_alert_time": latest_alert,
|
||||||
"events_path": str(event_sink_path(ctx)),
|
"events_path": str(event_sink_path(ctx)),
|
||||||
"diagnostics_path": str(diagnostics_path(ctx)),
|
"diagnostics_path": str(diagnostics_path(ctx)),
|
||||||
"diagnostics_count": len(diagnostics),
|
"diagnostics_count": len(diagnostics),
|
||||||
"latest_zone_counts": latest_zone_counts(diagnostics),
|
"latest_zone_counts": latest_zone_counts(diagnostics, config),
|
||||||
"baseline_ready": latest_baseline_ready(diagnostics),
|
"baseline_ready": latest_baseline_ready(diagnostics),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -280,6 +397,53 @@ def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
|
|||||||
return load_jsonl_tail(path, limit)
|
return load_jsonl_tail(path, limit)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cases(ctx: ManageContext, limit: int, status: str = "") -> list[dict[str, Any]]:
|
||||||
|
store = CaseStore(load_case_snapshots(case_sink_path(ctx)))
|
||||||
|
cases = store.latest_cases()
|
||||||
|
if status:
|
||||||
|
cases = [item for item in cases if str(item.get("case_status", "")).lower() == status]
|
||||||
|
return cases[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def load_webhook_retries(ctx: ManageContext, limit: int, status: str = "") -> list[dict[str, Any]]:
|
||||||
|
latest: dict[str, dict[str, Any]] = {}
|
||||||
|
for item in load_retry_snapshots(webhook_retry_sink_path(ctx)):
|
||||||
|
retry_id = str(item.get("retry_id", "")).strip()
|
||||||
|
if retry_id:
|
||||||
|
latest[retry_id] = item
|
||||||
|
items = list(latest.values())
|
||||||
|
if status:
|
||||||
|
items = [item for item in items if str(item.get("status", "")).lower() == status]
|
||||||
|
items.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
|
||||||
|
return items[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def build_case_summary(ctx: ManageContext) -> dict[str, Any]:
|
||||||
|
cases = load_cases(ctx, limit=MAX_EVENT_LINES)
|
||||||
|
summary = {
|
||||||
|
"open_case_count": 0,
|
||||||
|
"handled_case_count": 0,
|
||||||
|
"time_alarm_case_count": 0,
|
||||||
|
"pending_disposal_case_count": 0,
|
||||||
|
"warning_escalated_case_count": 0,
|
||||||
|
"latest_case_update_time": "",
|
||||||
|
}
|
||||||
|
for case in cases:
|
||||||
|
status = str(case.get("case_status", ""))
|
||||||
|
case_type = str(case.get("case_type", ""))
|
||||||
|
if status == "open":
|
||||||
|
summary["open_case_count"] += 1
|
||||||
|
elif status == "handled":
|
||||||
|
summary["handled_case_count"] += 1
|
||||||
|
key = f"{case_type}_case_count"
|
||||||
|
if key in summary:
|
||||||
|
summary[key] += 1
|
||||||
|
updated_at = str(case.get("updated_at", ""))
|
||||||
|
if updated_at and updated_at > str(summary["latest_case_update_time"]):
|
||||||
|
summary["latest_case_update_time"] = updated_at
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
|
def load_diagnostics(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
|
||||||
path = diagnostics_path(ctx)
|
path = diagnostics_path(ctx)
|
||||||
return load_jsonl_tail(path, limit)
|
return load_jsonl_tail(path, limit)
|
||||||
@@ -308,6 +472,36 @@ def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> P
|
|||||||
return path.resolve()
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def case_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
||||||
|
if data is None:
|
||||||
|
data = load_config_document(ctx.config_path)
|
||||||
|
raw_path = str(data.get("case_sink", {}).get("path", "logs/cases.jsonl"))
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = ctx.project_root / path
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_retry_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
||||||
|
if data is None:
|
||||||
|
data = load_config_document(ctx.config_path)
|
||||||
|
raw_path = str(data.get("webhook_retry_sink", {}).get("path", "logs/webhook_retry.jsonl"))
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = ctx.project_root / path
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_delivery_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
||||||
|
if data is None:
|
||||||
|
data = load_config_document(ctx.config_path)
|
||||||
|
raw_path = str(data.get("webhook_delivery_sink", {}).get("path", "logs/webhook_delivery.jsonl"))
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = ctx.project_root / path
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
|
||||||
if data is None:
|
if data is None:
|
||||||
data = load_config_document(ctx.config_path)
|
data = load_config_document(ctx.config_path)
|
||||||
@@ -318,14 +512,135 @@ def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) ->
|
|||||||
return path.resolve()
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
def latest_zone_counts(diagnostics: list[dict[str, Any]]) -> dict[str, int]:
|
def handle_case_update(
|
||||||
|
ctx: ManageContext,
|
||||||
|
case_id: str,
|
||||||
|
*,
|
||||||
|
handled_by: str,
|
||||||
|
handled_source: str,
|
||||||
|
note: str = "",
|
||||||
|
source_ref: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
config = load_config_document(ctx.config_path)
|
||||||
|
path = case_sink_path(ctx, config)
|
||||||
|
retry_path = webhook_retry_sink_path(ctx, config)
|
||||||
|
delivery_path = webhook_delivery_path(ctx, config)
|
||||||
|
store = CaseStore(load_case_snapshots(path))
|
||||||
|
matching = {item["case_id"] for item in store.latest_cases()}
|
||||||
|
if case_id not in matching:
|
||||||
|
return None
|
||||||
|
handled_at = datetime.now(timezone.utc)
|
||||||
|
snapshot = store.mark_handled(
|
||||||
|
case_id,
|
||||||
|
handled_at=handled_at,
|
||||||
|
handled_by=handled_by,
|
||||||
|
handled_source=handled_source,
|
||||||
|
note=note,
|
||||||
|
source_ref=source_ref,
|
||||||
|
)
|
||||||
|
append_case_snapshots(path, [snapshot])
|
||||||
|
send_case_webhooks([snapshot], config, delivery_path, retry_path=retry_path, now=handled_at)
|
||||||
|
drain_webhook_retries(config, retry_path, delivery_path, now=handled_at)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def drain_webhook_retry_queue(ctx: ManageContext, *, limit: int) -> dict[str, Any]:
|
||||||
|
config = load_config_document(ctx.config_path)
|
||||||
|
webhooks = dict(config.get("webhooks", {}) or {})
|
||||||
|
webhooks["retry_batch_limit"] = limit
|
||||||
|
config = dict(config)
|
||||||
|
config["webhooks"] = webhooks
|
||||||
|
updates = drain_webhook_retries(
|
||||||
|
config,
|
||||||
|
webhook_retry_sink_path(ctx, config),
|
||||||
|
webhook_delivery_path(ctx, config),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": updates,
|
||||||
|
"retried_count": len(updates),
|
||||||
|
"delivered_count": sum(1 for item in updates if str(item.get("status", "")) == "delivered"),
|
||||||
|
"dead_letter_count": sum(1 for item in updates if str(item.get("status", "")) == "dead_letter"),
|
||||||
|
"pending_count": sum(1 for item in updates if str(item.get("status", "")) == "pending"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def latest_zone_counts(diagnostics: list[dict[str, Any]], config: dict[str, Any] | None = None) -> dict[str, int]:
|
||||||
for item in reversed(diagnostics):
|
for item in reversed(diagnostics):
|
||||||
|
stable_counts = stable_zone_counts_from_diagnostics(item)
|
||||||
|
if stable_counts:
|
||||||
|
return stable_counts
|
||||||
|
recomputed = recompute_zone_counts_from_diagnostics(item, config or {})
|
||||||
|
if recomputed:
|
||||||
|
return recomputed
|
||||||
zone_counts = item.get("zone_counts")
|
zone_counts = item.get("zone_counts")
|
||||||
if isinstance(zone_counts, dict):
|
if isinstance(zone_counts, dict):
|
||||||
return {str(key): int(value) for key, value in zone_counts.items()}
|
return {str(key): int(value) for key, value in zone_counts.items()}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def stable_zone_counts_from_diagnostics(item: dict[str, Any]) -> dict[str, int]:
|
||||||
|
diagnostics_payload = item.get("diagnostics")
|
||||||
|
if not isinstance(diagnostics_payload, dict):
|
||||||
|
return {}
|
||||||
|
zones = diagnostics_payload.get("zones")
|
||||||
|
if not isinstance(zones, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
zone_counts = item.get("zone_counts")
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
if isinstance(zone_counts, dict):
|
||||||
|
counts = {str(key): int(value) for key, value in zone_counts.items()}
|
||||||
|
|
||||||
|
saw_stable_state = False
|
||||||
|
for zone_id, metrics in zones.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
occupied = metrics.get("occupied")
|
||||||
|
if not isinstance(occupied, bool):
|
||||||
|
continue
|
||||||
|
counts[str(zone_id)] = 1 if occupied else 0
|
||||||
|
saw_stable_state = True
|
||||||
|
return counts if saw_stable_state else {}
|
||||||
|
|
||||||
|
|
||||||
|
def recompute_zone_counts_from_diagnostics(item: dict[str, Any], config: dict[str, Any]) -> dict[str, int]:
|
||||||
|
diagnostics_payload = item.get("diagnostics")
|
||||||
|
if not isinstance(diagnostics_payload, dict):
|
||||||
|
return {}
|
||||||
|
zones = diagnostics_payload.get("zones")
|
||||||
|
if not isinstance(zones, dict):
|
||||||
|
return {}
|
||||||
|
settings = load_runtime_vision_settings(config)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for zone_id, metrics in zones.items():
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
continue
|
||||||
|
mean_delta = numeric_metric(metrics.get("mean_delta"))
|
||||||
|
texture_delta = numeric_metric(metrics.get("texture_delta"))
|
||||||
|
if mean_delta is None or texture_delta is None:
|
||||||
|
continue
|
||||||
|
dark_fraction = numeric_metric(metrics.get("dark_fraction"))
|
||||||
|
baseline_dark_fraction = numeric_metric(metrics.get("baseline_dark_fraction")) or 0.0
|
||||||
|
bright_fraction = numeric_metric(metrics.get("bright_fraction")) or 0.0
|
||||||
|
occupied = metrics_indicate_occupied(
|
||||||
|
settings,
|
||||||
|
mean_delta,
|
||||||
|
texture_delta,
|
||||||
|
dark_fraction=dark_fraction,
|
||||||
|
baseline_dark_fraction=baseline_dark_fraction,
|
||||||
|
bright_fraction=bright_fraction,
|
||||||
|
)
|
||||||
|
counts[str(zone_id)] = 1 if occupied else 0
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def numeric_metric(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def latest_baseline_ready(diagnostics: list[dict[str, Any]]) -> bool:
|
def latest_baseline_ready(diagnostics: list[dict[str, Any]]) -> bool:
|
||||||
for item in reversed(diagnostics):
|
for item in reversed(diagnostics):
|
||||||
diagnostics_payload = item.get("diagnostics")
|
diagnostics_payload = item.get("diagnostics")
|
||||||
|
|||||||
@@ -11,14 +11,24 @@ DEFAULT_ZONE_IDS = tuple(f"r{row}c{col}" for row in range(1, 3) for col in range
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EngineSettings:
|
class EngineSettings:
|
||||||
camera_id: str = "cold_display_cam_01"
|
camera_id: str = "cold_display_cam_01"
|
||||||
|
pre_warning_seconds: int = 0
|
||||||
max_dwell_seconds: int = 10_800
|
max_dwell_seconds: int = 10_800
|
||||||
|
alarm_removal_seconds: int = 0
|
||||||
trash_confirmation_seconds: int = 120
|
trash_confirmation_seconds: int = 120
|
||||||
zone_ids: tuple[str, ...] = DEFAULT_ZONE_IDS
|
zone_ids: tuple[str, ...] = DEFAULT_ZONE_IDS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pre_warning(self) -> timedelta:
|
||||||
|
return timedelta(seconds=self.pre_warning_seconds)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_dwell(self) -> timedelta:
|
def max_dwell(self) -> timedelta:
|
||||||
return timedelta(seconds=self.max_dwell_seconds)
|
return timedelta(seconds=self.max_dwell_seconds)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alarm_removal_window(self) -> timedelta:
|
||||||
|
return timedelta(seconds=self.alarm_removal_seconds)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def trash_confirmation_window(self) -> timedelta:
|
def trash_confirmation_window(self) -> timedelta:
|
||||||
return timedelta(seconds=self.trash_confirmation_seconds)
|
return timedelta(seconds=self.trash_confirmation_seconds)
|
||||||
@@ -56,6 +66,10 @@ class Batch:
|
|||||||
started_at: datetime
|
started_at: datetime
|
||||||
last_count: int
|
last_count: int
|
||||||
state: str = "active"
|
state: str = "active"
|
||||||
|
pre_warned_at: datetime | None = None
|
||||||
|
alerted_at: datetime | None = None
|
||||||
|
alarm_removal_deadline: datetime | None = None
|
||||||
|
alarm_removal_timed_out_at: datetime | None = None
|
||||||
ended_at: datetime | None = None
|
ended_at: datetime | None = None
|
||||||
pending_since: datetime | None = None
|
pending_since: datetime | None = None
|
||||||
disposal_deadline: datetime | None = None
|
disposal_deadline: datetime | None = None
|
||||||
|
|||||||
@@ -25,11 +25,23 @@ class Region:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RuntimeVisionSettings:
|
class RuntimeVisionSettings:
|
||||||
baseline_frames: int = 3
|
baseline_frames: int = 3
|
||||||
sample_stride_pixels: int = 8
|
sample_stride_pixels: int = 4
|
||||||
occupancy_mean_delta: float = 24.0
|
occupancy_mean_delta: float = 55.0
|
||||||
occupancy_texture_delta: float = 18.0
|
occupancy_texture_delta: float = 18.0
|
||||||
|
occupancy_dark_luma_threshold: float = 80.0
|
||||||
|
occupancy_dark_fraction: float = 0.06
|
||||||
|
occupancy_absolute_dark_fraction: float = 0.0
|
||||||
|
occupancy_texture_dark_fraction: float = 0.04
|
||||||
|
occupancy_bright_luma_threshold: float = 220.0
|
||||||
|
occupancy_bright_reflection_fraction: float = 0.18
|
||||||
|
occupancy_reflection_dark_fraction: float = 0.10
|
||||||
|
occupancy_reflection_bright_dark_ratio: float = 2.0
|
||||||
|
occupancy_confirm_frames: int = 2
|
||||||
|
empty_confirm_frames: int = 2
|
||||||
trash_motion_delta: float = 18.0
|
trash_motion_delta: float = 18.0
|
||||||
trash_motion_cooldown_seconds: int = 8
|
trash_sustained_motion_delta: float = 8.0
|
||||||
|
trash_sustained_motion_frames: int = 2
|
||||||
|
trash_motion_cooldown_seconds: int = 3
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -37,6 +49,8 @@ class RegionMetrics:
|
|||||||
mean_luma: float
|
mean_luma: float
|
||||||
texture: float
|
texture: float
|
||||||
sample_count: int
|
sample_count: int
|
||||||
|
dark_fraction: float = 0.0
|
||||||
|
bright_fraction: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class ZoneOccupancyDetector:
|
class ZoneOccupancyDetector:
|
||||||
@@ -51,13 +65,26 @@ class ZoneOccupancyDetector:
|
|||||||
self.settings = settings or RuntimeVisionSettings()
|
self.settings = settings or RuntimeVisionSettings()
|
||||||
self._baseline: dict[str, RegionMetrics] = {}
|
self._baseline: dict[str, RegionMetrics] = {}
|
||||||
self._baseline_samples: dict[str, list[RegionMetrics]] = {region.region_id: [] for region in regions}
|
self._baseline_samples: dict[str, list[RegionMetrics]] = {region.region_id: [] for region in regions}
|
||||||
|
self._stable_occupancy: dict[str, bool] = {region.region_id: False for region in regions}
|
||||||
|
self._occupied_streaks: dict[str, int] = {region.region_id: 0 for region in regions}
|
||||||
|
self._empty_streaks: dict[str, int] = {region.region_id: 0 for region in regions}
|
||||||
if trash_region is not None:
|
if trash_region is not None:
|
||||||
self._baseline_samples[trash_region.region_id] = []
|
self._baseline_samples[trash_region.region_id] = []
|
||||||
self._previous_trash_metrics: RegionMetrics | None = None
|
self._previous_trash_metrics: RegionMetrics | None = None
|
||||||
self._last_trash_motion_at: datetime | None = None
|
self._last_trash_motion_at: datetime | None = None
|
||||||
|
self._trash_motion_streak = 0
|
||||||
|
|
||||||
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, Any]]:
|
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, Any]]:
|
||||||
metrics_by_region = {region.region_id: region_metrics(frame, region, self.settings.sample_stride_pixels) for region in self.regions}
|
metrics_by_region = {
|
||||||
|
region.region_id: region_metrics(
|
||||||
|
frame,
|
||||||
|
region,
|
||||||
|
self.settings.sample_stride_pixels,
|
||||||
|
self.settings.occupancy_dark_luma_threshold,
|
||||||
|
self.settings.occupancy_bright_luma_threshold,
|
||||||
|
)
|
||||||
|
for region in self.regions
|
||||||
|
}
|
||||||
self._update_baseline(metrics_by_region)
|
self._update_baseline(metrics_by_region)
|
||||||
|
|
||||||
zone_counts: dict[str, int] = {}
|
zone_counts: dict[str, int] = {}
|
||||||
@@ -69,10 +96,8 @@ class ZoneOccupancyDetector:
|
|||||||
if baseline is not None:
|
if baseline is not None:
|
||||||
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
|
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
|
||||||
texture_delta = metrics.texture - baseline.texture
|
texture_delta = metrics.texture - baseline.texture
|
||||||
occupied = (
|
raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta)
|
||||||
mean_delta >= self.settings.occupancy_mean_delta
|
occupied = self._confirmed_occupancy(region.region_id, raw_occupied)
|
||||||
or texture_delta >= self.settings.occupancy_texture_delta
|
|
||||||
)
|
|
||||||
diagnostics["zones"][region.region_id] = {
|
diagnostics["zones"][region.region_id] = {
|
||||||
"mean_luma": round(metrics.mean_luma, 3),
|
"mean_luma": round(metrics.mean_luma, 3),
|
||||||
"baseline_mean_luma": round(baseline.mean_luma, 3),
|
"baseline_mean_luma": round(baseline.mean_luma, 3),
|
||||||
@@ -80,7 +105,15 @@ class ZoneOccupancyDetector:
|
|||||||
"texture": round(metrics.texture, 3),
|
"texture": round(metrics.texture, 3),
|
||||||
"baseline_texture": round(baseline.texture, 3),
|
"baseline_texture": round(baseline.texture, 3),
|
||||||
"texture_delta": round(texture_delta, 3),
|
"texture_delta": round(texture_delta, 3),
|
||||||
|
"dark_fraction": round(metrics.dark_fraction, 4),
|
||||||
|
"baseline_dark_fraction": round(baseline.dark_fraction, 4),
|
||||||
|
"dark_fraction_delta": round(metrics.dark_fraction - baseline.dark_fraction, 4),
|
||||||
|
"bright_fraction": round(metrics.bright_fraction, 4),
|
||||||
|
"baseline_bright_fraction": round(baseline.bright_fraction, 4),
|
||||||
|
"raw_occupied": raw_occupied,
|
||||||
"occupied": occupied,
|
"occupied": occupied,
|
||||||
|
"occupied_streak": self._occupied_streaks[region.region_id],
|
||||||
|
"empty_streak": self._empty_streaks[region.region_id],
|
||||||
}
|
}
|
||||||
zone_counts[region.region_id] = 1 if occupied else 0
|
zone_counts[region.region_id] = 1 if occupied else 0
|
||||||
|
|
||||||
@@ -91,6 +124,37 @@ class ZoneOccupancyDetector:
|
|||||||
def baseline_ready(self) -> bool:
|
def baseline_ready(self) -> bool:
|
||||||
return all(region.region_id in self._baseline for region in self.regions)
|
return all(region.region_id in self._baseline for region in self.regions)
|
||||||
|
|
||||||
|
def seed_baseline(self, baselines: dict[str, RegionMetrics]) -> None:
|
||||||
|
known_region_ids = {region.region_id for region in self.regions}
|
||||||
|
for region_id, metrics in baselines.items():
|
||||||
|
if region_id not in known_region_ids:
|
||||||
|
continue
|
||||||
|
self._baseline[region_id] = metrics
|
||||||
|
self._baseline_samples[region_id] = []
|
||||||
|
|
||||||
|
def seed_occupancy(self, zone_counts: dict[str, int]) -> None:
|
||||||
|
for region in self.regions:
|
||||||
|
occupied = int(zone_counts.get(region.region_id, 0)) > 0
|
||||||
|
self._stable_occupancy[region.region_id] = occupied
|
||||||
|
self._occupied_streaks[region.region_id] = self.settings.occupancy_confirm_frames if occupied else 0
|
||||||
|
self._empty_streaks[region.region_id] = self.settings.empty_confirm_frames if not occupied else 0
|
||||||
|
|
||||||
|
def _raw_occupied(
|
||||||
|
self,
|
||||||
|
metrics: RegionMetrics,
|
||||||
|
baseline: RegionMetrics,
|
||||||
|
mean_delta: float,
|
||||||
|
texture_delta: float,
|
||||||
|
) -> bool:
|
||||||
|
return metrics_indicate_occupied(
|
||||||
|
self.settings,
|
||||||
|
mean_delta,
|
||||||
|
texture_delta,
|
||||||
|
dark_fraction=metrics.dark_fraction,
|
||||||
|
baseline_dark_fraction=baseline.dark_fraction,
|
||||||
|
bright_fraction=metrics.bright_fraction,
|
||||||
|
)
|
||||||
|
|
||||||
def _update_baseline(self, metrics_by_region: dict[str, RegionMetrics]) -> None:
|
def _update_baseline(self, metrics_by_region: dict[str, RegionMetrics]) -> None:
|
||||||
for region_id, metrics in metrics_by_region.items():
|
for region_id, metrics in metrics_by_region.items():
|
||||||
if region_id in self._baseline:
|
if region_id in self._baseline:
|
||||||
@@ -100,6 +164,19 @@ class ZoneOccupancyDetector:
|
|||||||
if len(samples) >= self.settings.baseline_frames:
|
if len(samples) >= self.settings.baseline_frames:
|
||||||
self._baseline[region_id] = average_metrics(samples)
|
self._baseline[region_id] = average_metrics(samples)
|
||||||
|
|
||||||
|
def _confirmed_occupancy(self, region_id: str, raw_occupied: bool) -> bool:
|
||||||
|
if raw_occupied:
|
||||||
|
self._occupied_streaks[region_id] = self._occupied_streaks.get(region_id, 0) + 1
|
||||||
|
self._empty_streaks[region_id] = 0
|
||||||
|
if self._occupied_streaks[region_id] >= self.settings.occupancy_confirm_frames:
|
||||||
|
self._stable_occupancy[region_id] = True
|
||||||
|
else:
|
||||||
|
self._empty_streaks[region_id] = self._empty_streaks.get(region_id, 0) + 1
|
||||||
|
self._occupied_streaks[region_id] = 0
|
||||||
|
if self._empty_streaks[region_id] >= self.settings.empty_confirm_frames:
|
||||||
|
self._stable_occupancy[region_id] = False
|
||||||
|
return self._stable_occupancy.get(region_id, False)
|
||||||
|
|
||||||
def _trash_deposit_count(self, frame: Frame, when: datetime, diagnostics: dict[str, Any]) -> int:
|
def _trash_deposit_count(self, frame: Frame, when: datetime, diagnostics: dict[str, Any]) -> int:
|
||||||
if self.trash_region is None:
|
if self.trash_region is None:
|
||||||
return 0
|
return 0
|
||||||
@@ -108,17 +185,29 @@ class ZoneOccupancyDetector:
|
|||||||
previous = self._previous_trash_metrics
|
previous = self._previous_trash_metrics
|
||||||
self._previous_trash_metrics = metrics
|
self._previous_trash_metrics = metrics
|
||||||
if previous is None:
|
if previous is None:
|
||||||
diagnostics["trash"] = {"motion_delta": 0.0, "deposit": False}
|
diagnostics["trash"] = {"motion_delta": 0.0, "motion_streak": 0, "deposit": False}
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
motion_delta = abs(metrics.mean_luma - previous.mean_luma) + abs(metrics.texture - previous.texture)
|
motion_delta = abs(metrics.mean_luma - previous.mean_luma) + abs(metrics.texture - previous.texture)
|
||||||
|
if motion_delta >= self.settings.trash_sustained_motion_delta:
|
||||||
|
self._trash_motion_streak += 1
|
||||||
|
else:
|
||||||
|
self._trash_motion_streak = 0
|
||||||
cooldown = timedelta(seconds=self.settings.trash_motion_cooldown_seconds)
|
cooldown = timedelta(seconds=self.settings.trash_motion_cooldown_seconds)
|
||||||
in_cooldown = self._last_trash_motion_at is not None and when - self._last_trash_motion_at < cooldown
|
in_cooldown = self._last_trash_motion_at is not None and when - self._last_trash_motion_at < cooldown
|
||||||
deposit = motion_delta >= self.settings.trash_motion_delta and not in_cooldown
|
strong_motion = motion_delta >= self.settings.trash_motion_delta
|
||||||
|
sustained_motion = self._trash_motion_streak >= self.settings.trash_sustained_motion_frames
|
||||||
|
deposit = (strong_motion or sustained_motion) and not in_cooldown
|
||||||
|
motion_streak = self._trash_motion_streak
|
||||||
if deposit:
|
if deposit:
|
||||||
self._last_trash_motion_at = when
|
self._last_trash_motion_at = when
|
||||||
|
self._trash_motion_streak = 0
|
||||||
diagnostics["trash"] = {
|
diagnostics["trash"] = {
|
||||||
"motion_delta": round(motion_delta, 3),
|
"motion_delta": round(motion_delta, 3),
|
||||||
|
"motion_streak": motion_streak,
|
||||||
|
"strong_motion": strong_motion,
|
||||||
|
"sustained_motion": sustained_motion,
|
||||||
|
"in_cooldown": in_cooldown,
|
||||||
"deposit": deposit,
|
"deposit": deposit,
|
||||||
}
|
}
|
||||||
return 1 if deposit else 0
|
return 1 if deposit else 0
|
||||||
@@ -143,11 +232,23 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting
|
|||||||
runtime = config.get("runtime", {})
|
runtime = config.get("runtime", {})
|
||||||
return RuntimeVisionSettings(
|
return RuntimeVisionSettings(
|
||||||
baseline_frames=max(1, int(runtime.get("baseline_frames", 3))),
|
baseline_frames=max(1, int(runtime.get("baseline_frames", 3))),
|
||||||
sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 8))),
|
sample_stride_pixels=max(1, int(runtime.get("sample_stride_pixels", 4))),
|
||||||
occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 24.0)),
|
occupancy_mean_delta=float(runtime.get("occupancy_mean_delta", 55.0)),
|
||||||
occupancy_texture_delta=float(runtime.get("occupancy_texture_delta", 18.0)),
|
occupancy_texture_delta=float(runtime.get("occupancy_texture_delta", 18.0)),
|
||||||
|
occupancy_dark_luma_threshold=float(runtime.get("occupancy_dark_luma_threshold", 80.0)),
|
||||||
|
occupancy_dark_fraction=float(runtime.get("occupancy_dark_fraction", 0.06)),
|
||||||
|
occupancy_absolute_dark_fraction=float(runtime.get("occupancy_absolute_dark_fraction", 0.0)),
|
||||||
|
occupancy_texture_dark_fraction=float(runtime.get("occupancy_texture_dark_fraction", 0.04)),
|
||||||
|
occupancy_bright_luma_threshold=float(runtime.get("occupancy_bright_luma_threshold", 220.0)),
|
||||||
|
occupancy_bright_reflection_fraction=float(runtime.get("occupancy_bright_reflection_fraction", 0.18)),
|
||||||
|
occupancy_reflection_dark_fraction=float(runtime.get("occupancy_reflection_dark_fraction", 0.10)),
|
||||||
|
occupancy_reflection_bright_dark_ratio=float(runtime.get("occupancy_reflection_bright_dark_ratio", 2.0)),
|
||||||
|
occupancy_confirm_frames=max(1, int(runtime.get("occupancy_confirm_frames", 2))),
|
||||||
|
empty_confirm_frames=max(1, int(runtime.get("empty_confirm_frames", 2))),
|
||||||
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
|
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
|
||||||
trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 8))),
|
trash_sustained_motion_delta=float(runtime.get("trash_sustained_motion_delta", 8.0)),
|
||||||
|
trash_sustained_motion_frames=max(1, int(runtime.get("trash_sustained_motion_frames", 2))),
|
||||||
|
trash_motion_cooldown_seconds=max(0, int(runtime.get("trash_motion_cooldown_seconds", 3))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +263,13 @@ def normalize_polygon(value: Any) -> tuple[tuple[float, float], ...]:
|
|||||||
return tuple(points)
|
return tuple(points)
|
||||||
|
|
||||||
|
|
||||||
def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics:
|
def region_metrics(
|
||||||
|
frame: Frame,
|
||||||
|
region: Region,
|
||||||
|
stride: int,
|
||||||
|
dark_luma_threshold: float = 80.0,
|
||||||
|
bright_luma_threshold: float = 220.0,
|
||||||
|
) -> RegionMetrics:
|
||||||
xs = [point[0] for point in region.polygon]
|
xs = [point[0] for point in region.polygon]
|
||||||
ys = [point[1] for point in region.polygon]
|
ys = [point[1] for point in region.polygon]
|
||||||
min_x = max(0, int(min(xs) * frame.width))
|
min_x = max(0, int(min(xs) * frame.width))
|
||||||
@@ -184,7 +291,15 @@ def region_metrics(frame: Frame, region: Region, stride: int) -> RegionMetrics:
|
|||||||
return RegionMetrics(mean_luma=0.0, texture=0.0, sample_count=0)
|
return RegionMetrics(mean_luma=0.0, texture=0.0, sample_count=0)
|
||||||
mean = sum(values) / len(values)
|
mean = sum(values) / len(values)
|
||||||
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
variance = sum((value - mean) ** 2 for value in values) / len(values)
|
||||||
return RegionMetrics(mean_luma=mean, texture=variance ** 0.5, sample_count=len(values))
|
dark_fraction = sum(value < dark_luma_threshold for value in values) / len(values)
|
||||||
|
bright_fraction = sum(value > bright_luma_threshold for value in values) / len(values)
|
||||||
|
return RegionMetrics(
|
||||||
|
mean_luma=mean,
|
||||||
|
texture=variance ** 0.5,
|
||||||
|
sample_count=len(values),
|
||||||
|
dark_fraction=dark_fraction,
|
||||||
|
bright_fraction=bright_fraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
||||||
@@ -192,6 +307,47 @@ def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
|||||||
mean_luma=sum(item.mean_luma for item in samples) / len(samples),
|
mean_luma=sum(item.mean_luma for item in samples) / len(samples),
|
||||||
texture=sum(item.texture for item in samples) / len(samples),
|
texture=sum(item.texture for item in samples) / len(samples),
|
||||||
sample_count=min(item.sample_count for item in samples),
|
sample_count=min(item.sample_count for item in samples),
|
||||||
|
dark_fraction=sum(item.dark_fraction for item in samples) / len(samples),
|
||||||
|
bright_fraction=sum(item.bright_fraction for item in samples) / len(samples),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_indicate_occupied(
|
||||||
|
settings: RuntimeVisionSettings,
|
||||||
|
mean_delta: float,
|
||||||
|
texture_delta: float,
|
||||||
|
dark_fraction: float | None = None,
|
||||||
|
baseline_dark_fraction: float = 0.0,
|
||||||
|
bright_fraction: float = 0.0,
|
||||||
|
) -> bool:
|
||||||
|
if dark_fraction is None:
|
||||||
|
return mean_delta >= settings.occupancy_mean_delta or texture_delta >= settings.occupancy_texture_delta
|
||||||
|
|
||||||
|
dark_delta = dark_fraction - baseline_dark_fraction
|
||||||
|
bright_reflection = is_bright_reflection(settings, dark_delta, bright_fraction)
|
||||||
|
dark_occupied = dark_delta >= settings.occupancy_dark_fraction and not bright_reflection
|
||||||
|
absolute_dark_occupied = (
|
||||||
|
settings.occupancy_absolute_dark_fraction > 0
|
||||||
|
and dark_fraction >= settings.occupancy_absolute_dark_fraction
|
||||||
|
and not bright_reflection
|
||||||
|
)
|
||||||
|
mean_occupied = mean_delta >= settings.occupancy_mean_delta and not bright_reflection
|
||||||
|
texture_occupied = (
|
||||||
|
texture_delta >= settings.occupancy_texture_delta
|
||||||
|
and dark_delta >= settings.occupancy_texture_dark_fraction
|
||||||
|
and not bright_reflection
|
||||||
|
)
|
||||||
|
return dark_occupied or absolute_dark_occupied or mean_occupied or texture_occupied
|
||||||
|
|
||||||
|
|
||||||
|
def is_bright_reflection(settings: RuntimeVisionSettings, dark_delta: float, bright_fraction: float) -> bool:
|
||||||
|
if bright_fraction < settings.occupancy_bright_reflection_fraction:
|
||||||
|
return False
|
||||||
|
if dark_delta < settings.occupancy_texture_dark_fraction:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
dark_delta < settings.occupancy_reflection_dark_fraction
|
||||||
|
and bright_fraction >= dark_delta * settings.occupancy_reflection_bright_dark_ratio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
580
src/cold_display_guard/webhooks.py
Normal file
580
src/cold_display_guard/webhooks.py
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib import request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WebhookSettings:
|
||||||
|
enabled: bool = False
|
||||||
|
event_url: str = ""
|
||||||
|
case_url: str = ""
|
||||||
|
source_id: str = ""
|
||||||
|
callback_token: str = ""
|
||||||
|
connect_timeout_seconds: float = 3.0
|
||||||
|
read_timeout_seconds: float = 5.0
|
||||||
|
retry_max_attempts: int = 5
|
||||||
|
retry_backoff_seconds: float = 30.0
|
||||||
|
retry_max_backoff_seconds: float = 1_800.0
|
||||||
|
retry_batch_limit: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
HttpPost = Callable[[str, dict[str, object], tuple[float, float]], tuple[int, str]]
|
||||||
|
|
||||||
|
|
||||||
|
class RetryStore:
|
||||||
|
def __init__(self, snapshots: list[dict[str, object]] | None = None) -> None:
|
||||||
|
self._entries: dict[str, dict[str, object]] = {}
|
||||||
|
for snapshot in snapshots or []:
|
||||||
|
retry_id = str(snapshot.get("retry_id", "")).strip()
|
||||||
|
if retry_id:
|
||||||
|
self._entries[retry_id] = normalize_retry_snapshot(snapshot)
|
||||||
|
|
||||||
|
def latest_items(self, *, limit: int = 200, status: str = "") -> list[dict[str, object]]:
|
||||||
|
items = list(self._entries.values())
|
||||||
|
if status:
|
||||||
|
items = [item for item in items if str(item.get("status", "")).lower() == status.lower()]
|
||||||
|
items.sort(key=retry_sort_key, reverse=True)
|
||||||
|
return [dict(item) for item in items[:limit]]
|
||||||
|
|
||||||
|
def due_items(self, now: datetime, *, limit: int) -> list[dict[str, object]]:
|
||||||
|
due: list[dict[str, object]] = []
|
||||||
|
for item in self._entries.values():
|
||||||
|
if str(item.get("status", "")) != "pending":
|
||||||
|
continue
|
||||||
|
next_attempt_at = parse_iso_datetime(item.get("next_attempt_at"))
|
||||||
|
if next_attempt_at is None or next_attempt_at <= now:
|
||||||
|
due.append(item)
|
||||||
|
due.sort(key=due_retry_sort_key)
|
||||||
|
return [dict(item) for item in due[:limit]]
|
||||||
|
|
||||||
|
def enqueue_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
attempted_at: datetime,
|
||||||
|
settings: WebhookSettings,
|
||||||
|
status_code: int | None,
|
||||||
|
message: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
retry_id = f"retry_{uuid.uuid4().hex}"
|
||||||
|
attempt_count = 1
|
||||||
|
pending = attempt_count < settings.retry_max_attempts
|
||||||
|
snapshot = normalize_retry_snapshot(
|
||||||
|
{
|
||||||
|
"retry_id": retry_id,
|
||||||
|
"target": target,
|
||||||
|
"url": url,
|
||||||
|
"payload": payload,
|
||||||
|
"status": "pending" if pending else "dead_letter",
|
||||||
|
"attempt_count": attempt_count,
|
||||||
|
"created_at": attempted_at.isoformat(),
|
||||||
|
"updated_at": attempted_at.isoformat(),
|
||||||
|
"next_attempt_at": schedule_retry(attempted_at, settings, attempt_count).isoformat() if pending else "",
|
||||||
|
"delivered_at": "",
|
||||||
|
"last_status_code": status_code,
|
||||||
|
"last_message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._entries[retry_id] = snapshot
|
||||||
|
return dict(snapshot)
|
||||||
|
|
||||||
|
def record_retry_result(
|
||||||
|
self,
|
||||||
|
retry_id: str,
|
||||||
|
*,
|
||||||
|
attempted_at: datetime,
|
||||||
|
settings: WebhookSettings,
|
||||||
|
status: str,
|
||||||
|
status_code: int | None,
|
||||||
|
message: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
current = dict(self._entries[retry_id])
|
||||||
|
attempt_count = int(current.get("attempt_count", 0)) + 1
|
||||||
|
current["attempt_count"] = attempt_count
|
||||||
|
current["updated_at"] = attempted_at.isoformat()
|
||||||
|
current["last_status_code"] = status_code
|
||||||
|
current["last_message"] = message
|
||||||
|
if status == "ok":
|
||||||
|
current["status"] = "delivered"
|
||||||
|
current["next_attempt_at"] = ""
|
||||||
|
current["delivered_at"] = attempted_at.isoformat()
|
||||||
|
else:
|
||||||
|
pending = attempt_count < settings.retry_max_attempts
|
||||||
|
current["status"] = "pending" if pending else "dead_letter"
|
||||||
|
current["next_attempt_at"] = schedule_retry(attempted_at, settings, attempt_count).isoformat() if pending else ""
|
||||||
|
current["delivered_at"] = ""
|
||||||
|
snapshot = normalize_retry_snapshot(current)
|
||||||
|
self._entries[retry_id] = snapshot
|
||||||
|
return dict(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def load_webhook_settings(config: dict[str, Any]) -> WebhookSettings:
|
||||||
|
payload = config.get("webhooks", {})
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
return WebhookSettings(
|
||||||
|
enabled=bool(payload.get("enabled", False)),
|
||||||
|
event_url=str(payload.get("event_url", "")),
|
||||||
|
case_url=str(payload.get("case_url", "")),
|
||||||
|
source_id=str(payload.get("source_id", "")),
|
||||||
|
callback_token=str(payload.get("callback_token", "")),
|
||||||
|
connect_timeout_seconds=float(payload.get("connect_timeout_seconds", 3.0)),
|
||||||
|
read_timeout_seconds=float(payload.get("read_timeout_seconds", 5.0)),
|
||||||
|
retry_max_attempts=max(1, int(payload.get("retry_max_attempts", 5))),
|
||||||
|
retry_backoff_seconds=max(1.0, float(payload.get("retry_backoff_seconds", 30.0))),
|
||||||
|
retry_max_backoff_seconds=max(1.0, float(payload.get("retry_max_backoff_seconds", 1_800.0))),
|
||||||
|
retry_batch_limit=max(1, int(payload.get("retry_batch_limit", 20))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch_event_payload(
|
||||||
|
event: dict[str, object],
|
||||||
|
*,
|
||||||
|
camera_ip: str = "",
|
||||||
|
snapshot_upload: dict[str, object] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
batch_id = str(event.get("batch_id", ""))
|
||||||
|
event_name = str(event.get("event", ""))
|
||||||
|
ts = str(event.get("ts", ""))
|
||||||
|
pre_warned_at = str(event.get("pre_warned_at", ""))
|
||||||
|
alerted_at = str(event.get("alerted_at", ""))
|
||||||
|
ended_at = str(event.get("ended_at", ""))
|
||||||
|
payload = {
|
||||||
|
"kind": "batch_event",
|
||||||
|
"event": event_name,
|
||||||
|
"event_code": batch_id,
|
||||||
|
"ts": ts,
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"camera_id": event.get("camera_id", ""),
|
||||||
|
"camera_ip": camera_ip,
|
||||||
|
"zone_id": event.get("zone_id", ""),
|
||||||
|
"zone_label": event.get("zone_label", ""),
|
||||||
|
"severity": event.get("severity", ""),
|
||||||
|
"state": event.get("state", ""),
|
||||||
|
"started_at": event.get("started_at", ""),
|
||||||
|
"ended_at": ended_at,
|
||||||
|
"removed_at": ended_at,
|
||||||
|
"dwell_seconds": event.get("dwell_seconds", ""),
|
||||||
|
"is_discarded": event_name == "batch_discarded",
|
||||||
|
"discarded_at": ts if event_name == "batch_discarded" else "",
|
||||||
|
"created_at": pre_warned_at or alerted_at or ts,
|
||||||
|
"pre_warned_at": pre_warned_at,
|
||||||
|
"alerted_at": alerted_at,
|
||||||
|
"alarm_at": alerted_at,
|
||||||
|
"updated_at": ts,
|
||||||
|
}
|
||||||
|
return attach_snapshot_upload(payload, batch_id=batch_id, snapshot_upload=snapshot_upload)
|
||||||
|
|
||||||
|
|
||||||
|
def build_case_event_payload(
|
||||||
|
snapshot: dict[str, object],
|
||||||
|
*,
|
||||||
|
camera_ip: str = "",
|
||||||
|
snapshot_upload: dict[str, object] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
batch_id = str(snapshot.get("batch_id", ""))
|
||||||
|
created_at = str(snapshot.get("created_at", ""))
|
||||||
|
updated_at = str(snapshot.get("updated_at", ""))
|
||||||
|
handled_at = str(snapshot.get("handled_at", ""))
|
||||||
|
handled_source = str(snapshot.get("handled_source", ""))
|
||||||
|
event = snapshot_event(snapshot)
|
||||||
|
pre_warned_at = str(event.get("pre_warned_at", ""))
|
||||||
|
alerted_at = str(event.get("alerted_at", ""))
|
||||||
|
ended_at = str(event.get("ended_at", ""))
|
||||||
|
discarded = handled_source == "auto_closed"
|
||||||
|
payload = {
|
||||||
|
"kind": "case_event",
|
||||||
|
"action": infer_case_action(snapshot),
|
||||||
|
"case_id": snapshot.get("case_id", ""),
|
||||||
|
"event_code": batch_id or snapshot.get("case_id", ""),
|
||||||
|
"case_type": snapshot.get("case_type", ""),
|
||||||
|
"case_status": snapshot.get("case_status", ""),
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"camera_id": snapshot.get("camera_id", ""),
|
||||||
|
"camera_ip": camera_ip,
|
||||||
|
"zone_id": snapshot.get("zone_id", ""),
|
||||||
|
"zone_label": snapshot.get("zone_label", ""),
|
||||||
|
"source_event": snapshot.get("source_event", ""),
|
||||||
|
"handled_source": handled_source,
|
||||||
|
"started_at": event.get("started_at", ""),
|
||||||
|
"ended_at": ended_at,
|
||||||
|
"removed_at": ended_at,
|
||||||
|
"dwell_seconds": event.get("dwell_seconds", ""),
|
||||||
|
"is_discarded": discarded,
|
||||||
|
"discarded_at": handled_at if discarded else "",
|
||||||
|
"created_at": pre_warned_at or alerted_at or created_at,
|
||||||
|
"pre_warned_at": pre_warned_at,
|
||||||
|
"alerted_at": alerted_at,
|
||||||
|
"alarm_at": alerted_at,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
|
return attach_snapshot_upload(payload, batch_id=batch_id, snapshot_upload=snapshot_upload)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_case_action(snapshot: dict[str, object]) -> str:
|
||||||
|
if str(snapshot.get("case_status", "")) == "handled":
|
||||||
|
return "handled"
|
||||||
|
created_at = str(snapshot.get("created_at", ""))
|
||||||
|
updated_at = str(snapshot.get("updated_at", ""))
|
||||||
|
return "created" if created_at and created_at == updated_at else "updated"
|
||||||
|
|
||||||
|
|
||||||
|
def send_batch_event_webhooks(
|
||||||
|
events: list[dict[str, object]],
|
||||||
|
config: dict[str, Any],
|
||||||
|
audit_path: Path,
|
||||||
|
*,
|
||||||
|
retry_path: Path | None = None,
|
||||||
|
http_post: HttpPost | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
snapshot_upload: dict[str, object] | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
settings = load_webhook_settings(config)
|
||||||
|
if not settings.enabled or not settings.event_url:
|
||||||
|
return []
|
||||||
|
camera_ip = infer_camera_ip(config)
|
||||||
|
attempted_at = now or datetime.now(timezone.utc)
|
||||||
|
deliveries: list[dict[str, object]] = []
|
||||||
|
retry_updates: list[dict[str, object]] = []
|
||||||
|
store = load_retry_store(retry_path) if retry_path is not None else None
|
||||||
|
for event in events:
|
||||||
|
payload = build_batch_event_payload(event, camera_ip=camera_ip, snapshot_upload=snapshot_upload)
|
||||||
|
if settings.source_id:
|
||||||
|
payload["source_id"] = settings.source_id
|
||||||
|
record = deliver_webhook(
|
||||||
|
settings.event_url,
|
||||||
|
payload,
|
||||||
|
audit_path,
|
||||||
|
target="batch_event",
|
||||||
|
settings=settings,
|
||||||
|
http_post=http_post,
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
delivery_mode="direct",
|
||||||
|
)
|
||||||
|
deliveries.append(record)
|
||||||
|
if store is not None and record["status"] == "error":
|
||||||
|
retry_updates.append(
|
||||||
|
store.enqueue_failure(
|
||||||
|
target="batch_event",
|
||||||
|
url=settings.event_url,
|
||||||
|
payload=payload,
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
settings=settings,
|
||||||
|
status_code=optional_int(record.get("status_code")),
|
||||||
|
message=str(record.get("message", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if retry_path is not None:
|
||||||
|
append_retry_snapshots(retry_path, retry_updates)
|
||||||
|
return deliveries
|
||||||
|
|
||||||
|
|
||||||
|
def send_case_webhooks(
|
||||||
|
snapshots: list[dict[str, object]],
|
||||||
|
config: dict[str, Any],
|
||||||
|
audit_path: Path,
|
||||||
|
*,
|
||||||
|
retry_path: Path | None = None,
|
||||||
|
http_post: HttpPost | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
snapshot_upload: dict[str, object] | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
settings = load_webhook_settings(config)
|
||||||
|
if not settings.enabled or not settings.case_url:
|
||||||
|
return []
|
||||||
|
camera_ip = infer_camera_ip(config)
|
||||||
|
attempted_at = now or datetime.now(timezone.utc)
|
||||||
|
deliveries: list[dict[str, object]] = []
|
||||||
|
retry_updates: list[dict[str, object]] = []
|
||||||
|
store = load_retry_store(retry_path) if retry_path is not None else None
|
||||||
|
for snapshot in snapshots:
|
||||||
|
payload = build_case_event_payload(snapshot, camera_ip=camera_ip, snapshot_upload=snapshot_upload)
|
||||||
|
if settings.source_id:
|
||||||
|
payload["source_id"] = settings.source_id
|
||||||
|
record = deliver_webhook(
|
||||||
|
settings.case_url,
|
||||||
|
payload,
|
||||||
|
audit_path,
|
||||||
|
target="case_event",
|
||||||
|
settings=settings,
|
||||||
|
http_post=http_post,
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
delivery_mode="direct",
|
||||||
|
)
|
||||||
|
deliveries.append(record)
|
||||||
|
if store is not None and record["status"] == "error":
|
||||||
|
retry_updates.append(
|
||||||
|
store.enqueue_failure(
|
||||||
|
target="case_event",
|
||||||
|
url=settings.case_url,
|
||||||
|
payload=payload,
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
settings=settings,
|
||||||
|
status_code=optional_int(record.get("status_code")),
|
||||||
|
message=str(record.get("message", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if retry_path is not None:
|
||||||
|
append_retry_snapshots(retry_path, retry_updates)
|
||||||
|
return deliveries
|
||||||
|
|
||||||
|
|
||||||
|
def drain_webhook_retries(
|
||||||
|
config: dict[str, Any],
|
||||||
|
retry_path: Path,
|
||||||
|
audit_path: Path,
|
||||||
|
*,
|
||||||
|
http_post: HttpPost | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
settings = load_webhook_settings(config)
|
||||||
|
if not settings.enabled or not retry_path.exists():
|
||||||
|
return []
|
||||||
|
attempted_at = now or datetime.now(timezone.utc)
|
||||||
|
store = load_retry_store(retry_path)
|
||||||
|
updates: list[dict[str, object]] = []
|
||||||
|
for item in store.due_items(attempted_at, limit=settings.retry_batch_limit):
|
||||||
|
payload = dict(item.get("payload", {}) or {})
|
||||||
|
record = deliver_webhook(
|
||||||
|
str(item.get("url", "")),
|
||||||
|
payload,
|
||||||
|
audit_path,
|
||||||
|
target=str(item.get("target", "")),
|
||||||
|
settings=settings,
|
||||||
|
http_post=http_post,
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
retry_id=str(item.get("retry_id", "")),
|
||||||
|
delivery_mode="retry",
|
||||||
|
)
|
||||||
|
updates.append(
|
||||||
|
store.record_retry_result(
|
||||||
|
str(item.get("retry_id", "")),
|
||||||
|
attempted_at=attempted_at,
|
||||||
|
settings=settings,
|
||||||
|
status=str(record.get("status", "error")),
|
||||||
|
status_code=optional_int(record.get("status_code")),
|
||||||
|
message=str(record.get("message", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append_retry_snapshots(retry_path, updates)
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def load_retry_snapshots(path: Path) -> list[dict[str, object]]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
snapshots: list[dict[str, object]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
snapshots.append(normalize_retry_snapshot(payload))
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def append_retry_snapshots(path: Path, snapshots: list[dict[str, object]]) -> None:
|
||||||
|
if not snapshots:
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
if path.exists() and path.stat().st_size > 0 and not file_ends_with_newline(path):
|
||||||
|
handle.write("\n")
|
||||||
|
for snapshot in snapshots:
|
||||||
|
handle.write(json.dumps(snapshot, ensure_ascii=False, sort_keys=True))
|
||||||
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def load_retry_store(path: Path | None) -> RetryStore:
|
||||||
|
if path is None:
|
||||||
|
return RetryStore()
|
||||||
|
return RetryStore(load_retry_snapshots(path))
|
||||||
|
|
||||||
|
|
||||||
|
def deliver_webhook(
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
audit_path: Path,
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
settings: WebhookSettings,
|
||||||
|
http_post: HttpPost | None = None,
|
||||||
|
attempted_at: datetime | None = None,
|
||||||
|
retry_id: str = "",
|
||||||
|
delivery_mode: str = "direct",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
post = http_post or post_json
|
||||||
|
timeout = (settings.connect_timeout_seconds, settings.read_timeout_seconds)
|
||||||
|
recorded_at = attempted_at or datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
status_code, response_text = post(url, payload, timeout)
|
||||||
|
if 200 <= status_code < 300:
|
||||||
|
record = {
|
||||||
|
"ts": recorded_at.isoformat(),
|
||||||
|
"target": target,
|
||||||
|
"url": url,
|
||||||
|
"status": "ok",
|
||||||
|
"status_code": status_code,
|
||||||
|
"message": response_text,
|
||||||
|
"retry_id": retry_id,
|
||||||
|
"delivery_mode": delivery_mode,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
record = {
|
||||||
|
"ts": recorded_at.isoformat(),
|
||||||
|
"target": target,
|
||||||
|
"url": url,
|
||||||
|
"status": "error",
|
||||||
|
"status_code": status_code,
|
||||||
|
"message": response_text,
|
||||||
|
"retry_id": retry_id,
|
||||||
|
"delivery_mode": delivery_mode,
|
||||||
|
}
|
||||||
|
except OSError as exc:
|
||||||
|
record = {
|
||||||
|
"ts": recorded_at.isoformat(),
|
||||||
|
"target": target,
|
||||||
|
"url": url,
|
||||||
|
"status": "error",
|
||||||
|
"message": str(exc),
|
||||||
|
"retry_id": retry_id,
|
||||||
|
"delivery_mode": delivery_mode,
|
||||||
|
}
|
||||||
|
append_delivery_record(audit_path, record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
data = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||||
|
req = request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with request.urlopen(req, timeout=sum(timeout)) as response:
|
||||||
|
return response.getcode(), response.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def append_delivery_record(path: Path, payload: dict[str, object]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||||
|
handle.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_retry_snapshot(snapshot: dict[str, object]) -> dict[str, object]:
|
||||||
|
payload = snapshot.get("payload", {})
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
return {
|
||||||
|
"retry_id": str(snapshot.get("retry_id", "")).strip(),
|
||||||
|
"target": str(snapshot.get("target", "")).strip(),
|
||||||
|
"url": str(snapshot.get("url", "")).strip(),
|
||||||
|
"status": str(snapshot.get("status", "pending")).strip() or "pending",
|
||||||
|
"attempt_count": max(0, int(snapshot.get("attempt_count", 0))),
|
||||||
|
"payload": payload,
|
||||||
|
"created_at": str(snapshot.get("created_at", "")).strip(),
|
||||||
|
"updated_at": str(snapshot.get("updated_at", "")).strip(),
|
||||||
|
"next_attempt_at": str(snapshot.get("next_attempt_at", "")).strip(),
|
||||||
|
"delivered_at": str(snapshot.get("delivered_at", "")).strip(),
|
||||||
|
"last_status_code": optional_int(snapshot.get("last_status_code")),
|
||||||
|
"last_message": str(snapshot.get("last_message", "")).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_datetime(value: object) -> datetime | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_retry(attempted_at: datetime, settings: WebhookSettings, attempt_count: int) -> datetime:
|
||||||
|
exponent = max(0, attempt_count - 1)
|
||||||
|
seconds = min(settings.retry_max_backoff_seconds, settings.retry_backoff_seconds * (2**exponent))
|
||||||
|
return attempted_at + timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def retry_sort_key(snapshot: dict[str, object]) -> tuple[str, str]:
|
||||||
|
return str(snapshot.get("updated_at", "")), str(snapshot.get("retry_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def due_retry_sort_key(snapshot: dict[str, object]) -> tuple[str, str]:
|
||||||
|
return str(snapshot.get("next_attempt_at", "")), str(snapshot.get("retry_id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def optional_int(value: object) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value) if value is not None and value != "" else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def infer_camera_ip(config: dict[str, Any]) -> str:
|
||||||
|
stream = config.get("stream", {})
|
||||||
|
if not isinstance(stream, dict):
|
||||||
|
return ""
|
||||||
|
rtsp_url = str(stream.get("rtsp_url", "")).strip()
|
||||||
|
if not rtsp_url:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return urlsplit(rtsp_url).hostname or ""
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_event(snapshot: dict[str, object]) -> dict[str, object]:
|
||||||
|
payload = snapshot.get("payload", {})
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
event = payload.get("event", {})
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return {}
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def attach_snapshot_upload(
|
||||||
|
payload: dict[str, object],
|
||||||
|
*,
|
||||||
|
batch_id: str,
|
||||||
|
snapshot_upload: dict[str, object] | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not snapshot_upload:
|
||||||
|
return payload
|
||||||
|
batch_ids = {str(item).strip() for item in snapshot_upload.get("batch_ids", []) if str(item).strip()}
|
||||||
|
if batch_ids and batch_id not in batch_ids:
|
||||||
|
return payload
|
||||||
|
status = str(snapshot_upload.get("status", "")).strip()
|
||||||
|
if status:
|
||||||
|
payload["snapshot_upload_status"] = status
|
||||||
|
object_key = str(snapshot_upload.get("object_key", "")).strip()
|
||||||
|
if object_key:
|
||||||
|
payload["snapshot_object_key"] = object_key
|
||||||
|
file_name = str(snapshot_upload.get("file_name", "")).strip()
|
||||||
|
if file_name:
|
||||||
|
payload["snapshot_file_name"] = file_name
|
||||||
|
captured_at = str(snapshot_upload.get("captured_at", "")).strip()
|
||||||
|
if captured_at:
|
||||||
|
payload["snapshot_captured_at"] = captured_at
|
||||||
|
error_message = str(snapshot_upload.get("error", "")).strip()
|
||||||
|
if error_message:
|
||||||
|
payload["snapshot_upload_error"] = error_message
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def file_ends_with_newline(path: Path) -> bool:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(-1, 2)
|
||||||
|
return handle.read(1) == b"\n"
|
||||||
55
task_plan.md
55
task_plan.md
@@ -2,20 +2,20 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Create an independent git project under `~/Code` for monitoring food batches in a refrigerated display cabinet. The system tracks each configured display zone, starts a batch timer when food appears, ends it when the zone clears, and raises compliance alerts for over-3-hour removal without trash disposal or for over-3-hour food being put back.
|
Create and evolve an independent git project under `~/Code` for monitoring food batches in a refrigerated display cabinet. The system tracks each configured food zone, starts a batch timer when food appears, raises a configurable time alarm, and escalates alarmed food to a warning if it is removed without a matching trash-bin deposit.
|
||||||
|
|
||||||
## Confirmed Decisions
|
## Confirmed Decisions
|
||||||
|
|
||||||
- The trash bin is visible in the same camera frame.
|
- The trash bin is visible in the same camera frame.
|
||||||
- The display cabinet starts as a 4-column by 2-row layout, but zones must be configurable.
|
- Food zones are configurable; v1.1 supports 1 to 10 numeric zones.
|
||||||
- A zone may contain multiple food items.
|
- A zone may contain multiple food items.
|
||||||
- Items in the same zone are treated as one batch.
|
- Items in the same zone are treated as one batch.
|
||||||
- Mixed batches are not allowed; a zone must clear before a new batch can start.
|
- Mixed batches are not allowed; a zone must clear before a new batch can start.
|
||||||
- The first implementation is a standalone project, not a modification of `store_dwell_alert`.
|
- The first implementation is a standalone project, not a modification of `store_dwell_alert`.
|
||||||
|
|
||||||
## Phases
|
## Original Milestones
|
||||||
|
|
||||||
| Phase | Status | Notes |
|
| Milestone | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Create project skeleton | complete | Built under `~/Code/cold_display_guard`. |
|
| Create project skeleton | complete | Built under `~/Code/cold_display_guard`. |
|
||||||
| Write design and implementation plan | complete | Saved in `docs/plans/`. |
|
| Write design and implementation plan | complete | Saved in `docs/plans/`. |
|
||||||
@@ -29,3 +29,50 @@ Create an independent git project under `~/Code` for monitoring food batches in
|
|||||||
| Error | Attempt | Resolution |
|
| Error | Attempt | Resolution |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Ended batches reported `0` dwell seconds | First `unittest` run | Calculate dwell seconds before assigning `ended_at`. |
|
| Ended batches reported `0` dwell seconds | First `unittest` run | Calculate dwell seconds before assigning `ended_at`. |
|
||||||
|
|
||||||
|
## v1.1 优化改造
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
正式支持 1 到 10 个自定义食品区域、阿拉伯数字区域标注、可编辑垃圾桶 ROI、自定义时间报警阈值,以及“到达报警阈值先报警,报警后移出但未丢垃圾桶则升级为警告”的事件链路。
|
||||||
|
|
||||||
|
本节所有需求属于同一个 `v1.1 优化改造` 批次;下方只是该批次内的工作项,不代表拆成多个独立批次或多个版本。
|
||||||
|
|
||||||
|
### Stop Conditions
|
||||||
|
|
||||||
|
- [x] v1.1 所有工作项完成。
|
||||||
|
- [x] 必要 Python 测试通过。
|
||||||
|
- [x] 前端构建通过。
|
||||||
|
- [x] `docs/project.md` 更新项目目标、架构、配置、运行方式和关键决策。
|
||||||
|
- [x] 没有 blocking bug 或未处理的高风险问题。
|
||||||
|
- [x] 如果同一问题连续 3 次修复失败,暂停并报告原因、已尝试方案和建议下一步。
|
||||||
|
|
||||||
|
### Workstreams Inside This Batch
|
||||||
|
|
||||||
|
| Workstream | Status | Goal | Acceptance Criteria |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Batch setup and planning | complete | 建立 `v1.1 优化改造` 文件化计划和项目文档 | `task_plan.md`、`findings.md`、`progress.md`、`docs/project.md` 包含 v1.1 范围、工作项、验收标准和风险 |
|
||||||
|
| Backend event model | complete | 状态机支持数字区域、时间报警、报警升级警告 | TDD 覆盖 `time_alarm`、`warning_escalated`、数字区域元数据;目标测试和全量 Python 测试通过;代码审查通过 |
|
||||||
|
| Config and management API | complete | 配置/API 支持 1-10 区域、报警阈值、垃圾桶 ROI 保存 | 配置 round trip、校验、summary/events 字段测试通过;代码审查反馈已修复 |
|
||||||
|
| Frontend management console | complete | 管理页支持动态区域标定、垃圾桶 ROI 标点、报警阈值配置和新事件显示 | `web/src/main.js`、`web/src/styles.css` 实现交互;`pnpm build` 通过;前端复审通过 |
|
||||||
|
| Homepage demo runtime display | complete | 首页在无真实事件时也展示完整原型样例,并清空旧事件数据 | 首页默认进入运行页;演示态包含运行摘要、计时进度、事件表和清晰演示标识;真实事件优先;前端测试和构建通过;Docker web 已重建 |
|
||||||
|
| Documentation and final review | complete | 更新 README/project docs,执行最终代码审查和验证 | README 与命令/字段一致;代码审查无 blocking;验证证据记录到 `progress.md` |
|
||||||
|
|
||||||
|
### v1.1 Decisions
|
||||||
|
|
||||||
|
- 食品区域使用数字字符串 ID:`"1"` 到 `"10"`;事件中同时输出 `zone_index` 和 `zone_label`。
|
||||||
|
- 垃圾桶 ROI 保持在 `[trash] roi`,不占用食品区域编号。
|
||||||
|
- `max_dwell_seconds` 继续作为主要时间报警阈值;默认可保持 10800 秒,用户可以改成 1200 秒等。
|
||||||
|
- 到达阈值时先发 `time_alarm`,批次继续处于活跃区域。
|
||||||
|
- 已报警批次从区域移出后进入垃圾桶确认窗口;若窗口内没有垃圾桶动作,发 `warning_escalated`。
|
||||||
|
- 首页运行页在事件为空或运行数据不完整时显示标记为演示的数据,避免空白页面;真实事件数据存在时优先展示真实数据。
|
||||||
|
- 后续每次派发智能体任务,都必须在任务正文开头加入标准上下文头:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[项目: /Users/yoilun/Code/cold_display_guard]
|
||||||
|
[工作流批次: v1.1 优化改造]
|
||||||
|
[阶段: 阶段 x]
|
||||||
|
[角色: 对应智能体角色]
|
||||||
|
```
|
||||||
|
|
||||||
|
其中 `阶段 x` 表示同一 `v1.1 优化改造` 批次内的工作阶段,不代表拆分成独立批次。
|
||||||
|
|||||||
19
tasks/lessons.md
Normal file
19
tasks/lessons.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Lessons
|
||||||
|
|
||||||
|
- 2026-06-10: 远端接收端路由不能只凭已有相似服务或历史路径推断,必须先对用户指定的精确路径做真实 HTTP 探测,再决定配置值。
|
||||||
|
Prevention:
|
||||||
|
1. 对每个用户指定的 Webhook 路径,先在目标主机上用与真实请求接近的 `POST` 探测并记录状态码。
|
||||||
|
2. 如果存在多个相似路径,只能在验证过用户指定路径不可用后,才考虑回退到其它路径。
|
||||||
|
3. 切换远端配置前,先确认发送端容器对目标主机名或 IP 实际可达,避免写入不可解析的地址。
|
||||||
|
|
||||||
|
- 2026-06-15: 告警截图叠加中文区域名时,不能依赖默认西文字体或手写点阵字形;这会在现场截图里表现为乱码或不可读文字。
|
||||||
|
Prevention:
|
||||||
|
1. 涉及中文截图叠字时,镜像必须安装可验证的 CJK 字体包,并在容器内用 `fc-match :lang=zh-cn` 确认命中 CJK 字体。
|
||||||
|
2. 部署后必须在目标容器内跑一次中文标签叠图烟测,确认真实渲染路径可用,而不只检查像素变化。
|
||||||
|
3. 字体渲染不可用时,回退文本必须转换成可读 ASCII 标识,例如 `区域 1` -> `R1`、`垃圾区` -> `TRASH`,避免继续绘制乱码中文。
|
||||||
|
|
||||||
|
- 2026-06-15: 现场识别抖动排查时,不能先假设某个区域为空;用户指出区域 1、2、6 实际都有物后,原先单纯调高相对暗区阈值会压掉真实占用。
|
||||||
|
Prevention:
|
||||||
|
1. 调整视觉阈值前,必须向现场实际状态对齐,明确每个被分析区域当前应该是有物还是空。
|
||||||
|
2. 如果物品已存在于启动基线中,不能只依赖相对基线变化;需要绝对特征或重新采空基线来识别。
|
||||||
|
3. 对“正常取用”误报,应优先检查有物状态是否短暂掉空,并用判空确认帧数或滞后来处理抖动,而不是只提高占用阈值。
|
||||||
421
tasks/todo.md
Normal file
421
tasks/todo.md
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# Task Todo
|
||||||
|
|
||||||
|
## Current Task: Runtime/API Case State Reopen Fix
|
||||||
|
|
||||||
|
**Goal:** When the management API marks a display-cabinet case as handled, the runtime process must not later append a newer `open` snapshot for the same case from stale in-memory state.
|
||||||
|
|
||||||
|
- [x] Add a failing regression test for API-written `handled` state being preserved when runtime persists later events.
|
||||||
|
- [x] Fix runtime case persistence to reconcile with the latest JSONL snapshots before applying new events.
|
||||||
|
- [x] Run targeted case/runtime tests.
|
||||||
|
- [x] Record remote chain verification and deployment status.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
- On `xiaozheng@10.8.0.23`, `case_batch_000911` was marked `handled` at `2026-06-15T07:27:12Z`, then runtime appended a newer `open` snapshot for the same case at `2026-06-15T15:38:03+08:00`.
|
||||||
|
- The API and runtime are separate processes sharing `logs/cases.jsonl`; runtime keeps a long-lived `CaseStore` loaded at startup and did not see the API-written handled snapshot.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- RED:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests.test_main.RuntimeRestoreTests.test_persist_case_updates_preserves_api_handled_snapshot -v`
|
||||||
|
- Result before fix: failed because runtime appended a later `open` snapshot.
|
||||||
|
- Local targeted verification:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests.test_main.RuntimeRestoreTests.test_persist_case_updates_preserves_api_handled_snapshot -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_cases.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- Result: all passed.
|
||||||
|
- Remote deployment:
|
||||||
|
- Synced only `src/cold_display_guard/main.py` to `xiaozheng@10.8.0.23:/home/xiaozheng/cold_display_guard/src/cold_display_guard/main.py`.
|
||||||
|
- Ran `docker compose --env-file deploy/cold-display-guard.env -f deploy/docker-compose.yml up -d --build cold-display-guard-runtime`.
|
||||||
|
- Compose recreated `cold-display-guard-api` and `cold-display-guard-runtime`; health check returned `status=ok`.
|
||||||
|
- Remote behavior check:
|
||||||
|
- Ran the same API-handled/runtime-later-event scenario inside `cold-display-guard-runtime` using a temp JSONL file.
|
||||||
|
- Result: `{"handled_source": "manual", "latest_status": "handled", "new_snapshots": 0}`.
|
||||||
|
|
||||||
|
- [x] Review the current project instructions and check for task-relevant lessons.
|
||||||
|
- [x] Inspect the OTA upload API document and current runtime/webhook capture path.
|
||||||
|
- [x] Create an isolated worktree for alarm snapshot upload implementation.
|
||||||
|
- [x] Write the detailed implementation plan to `docs/superpowers/plans/2026-06-09-alarm-snapshot-upload.md`.
|
||||||
|
- [x] Execute alarm snapshot upload client TDD cycle.
|
||||||
|
- [x] Execute runtime and webhook payload integration TDD cycle.
|
||||||
|
- [x] Update config surface, docs, and verification notes.
|
||||||
|
- [x] Run targeted verification and final full verification.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `tasks/lessons.md` is absent in this repository/worktree, so there were no prior session lessons to review.
|
||||||
|
- Upload API reference: `/Users/glo/code/go/wenma/ai_manager/zd-ai-manager/chunk-upload-oss-service/UPLOAD_API.md`
|
||||||
|
- User-provided upload target: `https://ota.zhengxinshipin.com`
|
||||||
|
- User-provided token secret: `change-me-in-production`
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
- Plan saved to `docs/superpowers/plans/2026-06-09-alarm-snapshot-upload.md`.
|
||||||
|
- Chosen implementation keeps snapshot upload entirely outside `BatchEngine` and enriches webhook payloads from the runtime side using the already captured frame.
|
||||||
|
- Implemented `src/cold_display_guard/alarm_snapshots.py` for JPEG encoding plus OTA chunk-upload orchestration, runtime integration in `src/cold_display_guard/main.py`, webhook payload enrichment in `src/cold_display_guard/webhooks.py`, config exposure/secret stripping in `src/cold_display_guard/config.py` and `src/cold_display_guard/manage_api.py`, and config/doc updates in `config/example.toml` and `README_zh.md`.
|
||||||
|
- Targeted verification passed:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_main.py -v`
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest tests/test_webhooks.py tests/test_config.py tests/test_manage_api.py -v`
|
||||||
|
- Final verification passed:
|
||||||
|
- `eval "$(/opt/homebrew/bin/pyenv init -)" && PYTHONPATH=src python -m unittest discover -s tests -v`
|
||||||
|
- `cd web && pnpm install --frozen-lockfile && pnpm build`
|
||||||
|
|
||||||
|
## Current Task: Webhook Payload Field Gap Check
|
||||||
|
|
||||||
|
- [x] Pull the actual payload currently received by `video-recognition` and compare it against the required event list fields.
|
||||||
|
- [x] Patch webhook payload builders to include the missing non-store fields required by the downstream table.
|
||||||
|
- [x] Add or update focused webhook tests for the enriched payload shape.
|
||||||
|
- [x] Run targeted verification and record the result here.
|
||||||
|
|
||||||
|
### Current Findings
|
||||||
|
|
||||||
|
- Current received payload only includes `batch_id`, `camera_id`, `event`, `kind`, `severity`, `source_id`, `state`, `ts`, `zone_id`, and `zone_label`.
|
||||||
|
- Missing or not explicitly populated for the downstream event table: event code, camera IP, batch start time, removal time, dwell duration, discard flag, discard time, create time, alarm time, and update time.
|
||||||
|
|
||||||
|
### Field Gap Verification
|
||||||
|
|
||||||
|
- Actual receiver payload before the fix, from `video-recognition` result JSONL on `10.8.0.11`, confirmed only the base fields above and did not include the downstream table time/discard/IP fields.
|
||||||
|
- Updated `src/cold_display_guard/webhooks.py` so both `batch_event` and `case_event` now include:
|
||||||
|
- `event_code`
|
||||||
|
- `camera_ip`
|
||||||
|
- `started_at`
|
||||||
|
- `ended_at`
|
||||||
|
- `removed_at`
|
||||||
|
- `dwell_seconds`
|
||||||
|
- `is_discarded`
|
||||||
|
- `discarded_at`
|
||||||
|
- `created_at`
|
||||||
|
- `alerted_at`
|
||||||
|
- `alarm_at`
|
||||||
|
- `updated_at`
|
||||||
|
- `case_event` also now carries the missing contextual fields `camera_id`, `zone_id`, and `zone_label`.
|
||||||
|
- Verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_webhooks.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_main.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`
|
||||||
|
- Deployed updated code to `xiaozheng@10.8.0.11` without overwriting the remote `config/example.toml`, rebuilt `cold-display-guard:dev`, and restarted only `cold-display-guard-api` plus `cold-display-guard-runtime`.
|
||||||
|
- Natural post-deploy traffic did not arrive during the 2-minute observation window, so final runtime verification used the deployed container to build representative batch/case webhook payloads with the live remote config and confirmed `camera_ip = 192.168.3.4` plus all new downstream fields were present.
|
||||||
|
|
||||||
|
## Current Task: Deploy To 192.168.5.103
|
||||||
|
|
||||||
|
- [x] Inspect the existing deployment layout and active containers on `xiaozheng@192.168.5.103`.
|
||||||
|
- [x] Verify the exact webhook route on that host before writing config.
|
||||||
|
- [x] Sync the current project code to the remote deployment directory without overwriting the live RTSP and calibration config.
|
||||||
|
- [x] Configure the remote webhook settings for the local `video-recognition` receiver.
|
||||||
|
- [x] Rebuild and restart the remote API/runtime containers, then verify health and outbound webhook configuration.
|
||||||
|
|
||||||
|
### Deployment Findings
|
||||||
|
|
||||||
|
- Existing deployment path on `192.168.5.103` is `/home/xiaozheng/cold_display_guard`, not `~/apps/cold-display-guard/app`.
|
||||||
|
- The host already runs `cold-display-guard-api`, `cold-display-guard-runtime`, and `cold-display-guard-web` on ports `19080` and `23000`.
|
||||||
|
- The same host also runs `video-recognition`, and a direct probe to `http://127.0.0.1:8080/api/webhook/cold-display-guard` returned `200 OK`, so this is the verified webhook target for this environment.
|
||||||
|
|
||||||
|
### Deployment Verification
|
||||||
|
|
||||||
|
- From inside the running `cold-display-guard-api` container on `192.168.5.103`:
|
||||||
|
- `http://host.docker.internal:8080/api/webhook/cold-display-guard` failed DNS resolution.
|
||||||
|
- `http://172.17.0.1:8080/api/webhook/cold-display-guard` returned `200 OK`.
|
||||||
|
- `http://192.168.5.103:8080/api/webhook/cold-display-guard` returned `200 OK`.
|
||||||
|
- The configured webhook target was set to `http://192.168.5.103:8080/api/webhook/cold-display-guard` for both `event_url` and `case_url`.
|
||||||
|
- Remote config was enriched to include:
|
||||||
|
- `case_sink`
|
||||||
|
- `alarm_snapshot_upload`
|
||||||
|
- `webhook_retry_sink`
|
||||||
|
- `webhook_delivery_sink`
|
||||||
|
- `webhooks`
|
||||||
|
- Code sync used `rsync` with `config/example.toml` excluded so the live RTSP URL and calibration polygons were preserved.
|
||||||
|
- Remote rebuild/restart completed for `cold-display-guard-api` and `cold-display-guard-runtime`.
|
||||||
|
- Verified after restart:
|
||||||
|
- `GET http://127.0.0.1:19080/api/manage/health` returned `status=ok`
|
||||||
|
- `GET http://127.0.0.1:19080/api/manage/config` showed `webhooks.enabled=true`
|
||||||
|
- `event_url` and `case_url` both active on `http://192.168.5.103:8080/api/webhook/cold-display-guard`
|
||||||
|
- `alarm_snapshot_upload.enabled=true`
|
||||||
|
|
||||||
|
## Current Task: Alarm Snapshot Calibration Overlay
|
||||||
|
|
||||||
|
**Goal:** Webhook-linked uploaded alarm snapshots should visually include the calibrated cold display zones and trash confirmation ROI from the current config.
|
||||||
|
|
||||||
|
**Design:** Keep the existing runtime flow intact: capture current RTSP frame, process events, then upload an alarm snapshot only for warning/alarm events. Before JPEG encoding, build overlay regions from `[[zones]]` plus `[trash].roi`, clamp normalized polygon coordinates to the image bounds, draw a semi-transparent fill and visible outline directly onto a copied `Frame.rgb`, and pass that annotated frame to the existing encoder/uploader. Do not change `BatchEngine`, Webhook payload shape, OTA upload protocol, or management snapshot capture.
|
||||||
|
|
||||||
|
- [x] Review task-relevant lessons and current dirty worktree.
|
||||||
|
- [x] Inspect `alarm_snapshots.py`, `main.py`, config polygon shape, and existing tests.
|
||||||
|
- [x] Write a failing unit test proving alert snapshot upload encodes an annotated frame when zones/trash ROI are configured.
|
||||||
|
- [x] Write focused unit tests for polygon overlay behavior using a tiny RGB frame.
|
||||||
|
- [x] Run targeted tests and confirm the new tests fail for the expected missing overlay behavior.
|
||||||
|
- [x] Implement the smallest standard-library overlay helper in `src/cold_display_guard/alarm_snapshots.py`.
|
||||||
|
- [x] Wire `capture_alert_snapshot` to apply configured overlays before JPEG encoding.
|
||||||
|
- [x] Run targeted snapshot/runtime tests.
|
||||||
|
- [x] Run the full Python test suite.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
- Added `apply_calibration_overlay` in `src/cold_display_guard/alarm_snapshots.py` to draw configured food-zone polygons in yellow and the trash ROI in red onto a copied frame before JPEG encoding and OTA upload.
|
||||||
|
- The overlay clamps normalized coordinates to image bounds, draws semi-transparent fills plus outlines, and leaves the original `Frame.rgb` unchanged for downstream runtime processing.
|
||||||
|
- `capture_alert_snapshot` now encodes the annotated frame when warning/alarm events trigger snapshot upload; non-alert events and disabled upload behavior are unchanged.
|
||||||
|
- Targeted verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_main.py -v`
|
||||||
|
- Full verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`
|
||||||
|
|
||||||
|
## Current Task: Deploy Overlay Update To 10.8.0.23
|
||||||
|
|
||||||
|
**Goal:** Deploy the alarm snapshot calibration overlay change to `xiaozheng@10.8.0.23` without overwriting live RTSP/calibration config or unrelated local changes.
|
||||||
|
|
||||||
|
**Plan:** Inspect the remote deployment layout first, confirm which containers are active, sync only the runtime source file required for the overlay change, rebuild/restart the API/runtime services that use the Python image, and verify both service health and the deployed source code.
|
||||||
|
|
||||||
|
- [x] Inspect remote deployment directory, Docker/Compose files, and active containers on `xiaozheng@10.8.0.23`.
|
||||||
|
- [x] Confirm the remote config file remains present and is not overwritten.
|
||||||
|
- [x] Sync `src/cold_display_guard/alarm_snapshots.py` to the remote deployment path.
|
||||||
|
- [x] Rebuild and restart only the affected `cold-display-guard-api` and `cold-display-guard-runtime` services when Compose is available.
|
||||||
|
- [x] Verify management API health after restart.
|
||||||
|
- [x] Verify the deployed remote source contains `apply_calibration_overlay`.
|
||||||
|
|
||||||
|
### Deployment Review
|
||||||
|
|
||||||
|
- Remote deployment path confirmed as `/home/xiaozheng/cold_display_guard`.
|
||||||
|
- Active services before deployment: `cold-display-guard-api`, `cold-display-guard-runtime`, and `cold-display-guard-web`.
|
||||||
|
- Remote live `config/example.toml` was checked before and after deployment and was not overwritten.
|
||||||
|
- Synced only `src/cold_display_guard/alarm_snapshots.py` to avoid deploying unrelated local `web/nginx.conf` changes.
|
||||||
|
- Created a timestamped backup of the previous remote `alarm_snapshots.py` beside the source file before syncing.
|
||||||
|
- Rebuilt `cold-display-guard:dev` with `docker compose --env-file deploy/cold-display-guard.env -f deploy/docker-compose.yml build cold-display-guard-api`.
|
||||||
|
- Restarted only `cold-display-guard-api` and `cold-display-guard-runtime` with Compose; `cold-display-guard-web` remained untouched.
|
||||||
|
- Verification passed:
|
||||||
|
- `curl http://127.0.0.1:19080/api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- `docker exec cold-display-guard-api python3 -c ...` confirmed `apply_calibration_overlay` exists in the running image with signature `(frame, config) -> Frame`.
|
||||||
|
- API and runtime logs show normal startup after restart.
|
||||||
|
|
||||||
|
## Current Task: Update Timing Parameters On 10.8.0.23
|
||||||
|
|
||||||
|
**Goal:** Adjust the live timing settings on `xiaozheng@10.8.0.23` per operator request.
|
||||||
|
|
||||||
|
**Applied mapping:** The current application has no separate pre-warning threshold. It supports `max_dwell_seconds` for the time alarm/overdue threshold and `trash_confirmation_seconds` for the disposal confirmation window before warning escalation. Applied `max_dwell_seconds = 120` and `trash_confirmation_seconds = 30`.
|
||||||
|
|
||||||
|
- [x] Back up `/home/xiaozheng/cold_display_guard/config/example.toml`.
|
||||||
|
- [x] Update `[thresholds].max_dwell_seconds` from `300` to `120`.
|
||||||
|
- [x] Update `[thresholds].trash_confirmation_seconds` from `120` to `30`.
|
||||||
|
- [x] Restart `cold-display-guard-api` and `cold-display-guard-runtime`.
|
||||||
|
- [x] Verify `/api/manage/health`.
|
||||||
|
- [x] Verify `/api/manage/config` returns `{"max_dwell_seconds": 120, "trash_confirmation_seconds": 30}`.
|
||||||
|
|
||||||
|
### Timing Update Review
|
||||||
|
|
||||||
|
- Remote config was edited in place after creating a timestamped backup.
|
||||||
|
- `cold-display-guard-api` and `cold-display-guard-runtime` were explicitly restarted with Docker Compose.
|
||||||
|
- `cold-display-guard-web` was not restarted.
|
||||||
|
- Verification passed:
|
||||||
|
- `GET http://127.0.0.1:19080/api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- `GET http://127.0.0.1:19080/api/manage/config` returned `max_dwell_seconds = 120` and `trash_confirmation_seconds = 30`.
|
||||||
|
- Container status showed `cold-display-guard-api` healthy and `cold-display-guard-runtime` running after restart.
|
||||||
|
- Note: requested `预警时长 = 1min` is not independently configurable in the current codebase; supporting distinct pre-warning at 60 seconds and overdue at 120 seconds would require a code change.
|
||||||
|
|
||||||
|
## Current Task: Pre-Warning Alarm Flow And Full Webhook/MQTT Chain
|
||||||
|
|
||||||
|
**Goal:** Implement the requested camera-side timing flow, deploy it to `xiaozheng@10.8.0.23`, and verify the Webhook -> `video_recognition_local` -> MQTT -> `store_data_platform` chain.
|
||||||
|
|
||||||
|
**Design:** Keep all timing decisions inside `cold_display_guard.BatchEngine`. Add separate thresholds for pre-warning, alarm, and alarm-removal timeout; emit explicit lifecycle events so downstream services do not infer camera-side timers. Keep `video_recognition_local` as a transparent Webhook/MQTT bridge, and update `store_data_platform` only where event names map to notifications, case types, and CRM penalty submission.
|
||||||
|
|
||||||
|
- [x] Review task-relevant instructions, lessons, and dirty worktree.
|
||||||
|
- [x] Inspect the current cold-display engine, case store, webhook payload, and tests.
|
||||||
|
- [x] Inspect `video_recognition_local` cold-display Webhook receiver and MQTT publisher.
|
||||||
|
- [x] Inspect `store_data_platform` cold-display MQTT consumer, notification mapping, and CRM submission trigger.
|
||||||
|
- [x] Inspect `xiaozheng@10.8.0.23` active containers and deployment paths.
|
||||||
|
- [x] Add failing cold-display engine/case/config/webhook tests for `time_pre_warning`, `pre_warning_handled`, `time_alarm`, and `alarm_removal_timeout`.
|
||||||
|
- [x] Implement the camera-side state machine and config fields.
|
||||||
|
- [x] Add/adjust `video_recognition_local` passthrough tests for the new event names.
|
||||||
|
- [x] Add/adjust `store_data_platform` tests and mappings for new event semantics.
|
||||||
|
- [x] Run local targeted and full relevant verification.
|
||||||
|
- [x] Deploy changed services to `xiaozheng@10.8.0.23` without overwriting live RTSP/calibration secrets.
|
||||||
|
- [x] Update the remote timing config to `pre_warning_seconds=60`, `max_dwell_seconds=120`, `alarm_removal_seconds=30`, `trash_confirmation_seconds=30`.
|
||||||
|
- [x] Verify remote Webhook target reachability from the cold-display container to local `video-recognition`.
|
||||||
|
- [x] Observe cold-display, video-recognition, MQTT, and platform logs; record the result.
|
||||||
|
|
||||||
|
### Current Findings
|
||||||
|
|
||||||
|
- `cold_display_guard` currently has only `max_dwell_seconds` and `trash_confirmation_seconds`; it cannot independently represent 1-minute pre-warning, 2-minute alarm, and 30-second alarm-removal timeout.
|
||||||
|
- `video_recognition_local` receives `/api/webhook/cold-display-guard` payloads as generic JSON and forwards them to MQTT; new event names should remain transparent, but tests should lock this behavior.
|
||||||
|
- `store_data_platform` currently treats `time_alarm` and `batch_pending_disposal` as warning notifications, and only `warning_escalated` triggers CRM penalty submission. This must change so `time_pre_warning` is the warning, `time_alarm` is the alert reminder, and `alarm_removal_timeout` triggers CRM submission.
|
||||||
|
- On `10.8.0.23`, active containers include `cold-display-guard-*`, `video-recognition`, and `mosquitto`; `video-recognition` runs with host networking, while `cold-display-guard-api` runs on its Compose network.
|
||||||
|
|
||||||
|
### Local Verification
|
||||||
|
|
||||||
|
- Cold-display full Python suite passed: `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`98` tests).
|
||||||
|
- `video_recognition_local` cold-display focused tests passed: `go test ./internal/server ./internal/mqtt ./cmd -run 'TestColdDisplayGuard|Test.*ColdDisplayGuard' -count=1`.
|
||||||
|
- `store_data_platform` display-cabinet service focused tests passed: `go test ./store_data/service -run 'Test.*StoreDisplayCabinet|TestResolveStoreDisplayCabinet.*|TestShouldSubmitStoreDisplayCabinetPenalty|TestBuildStoreDisplayCabinet.*' -count=1`.
|
||||||
|
|
||||||
|
### Deployment Review
|
||||||
|
|
||||||
|
- Synced only these cold-display source files to `xiaozheng@10.8.0.23:/home/xiaozheng/cold_display_guard/src/cold_display_guard/`: `models.py`, `config.py`, `engine.py`, `cases.py`, `webhooks.py`.
|
||||||
|
- Backed up the remote source files and live `config/example.toml` before deployment.
|
||||||
|
- Updated the live remote thresholds to `pre_warning_seconds=60`, `max_dwell_seconds=120`, `alarm_removal_seconds=30`, and `trash_confirmation_seconds=30`.
|
||||||
|
- Updated the live remote Webhook target from the unreachable old host to `http://10.8.0.23:8080/api/webhook/cold-display-guard`.
|
||||||
|
- Rebuilt `cold-display-guard:dev` and restarted only `cold-display-guard-api` and `cold-display-guard-runtime`.
|
||||||
|
- Remote verification passed:
|
||||||
|
- `GET /api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- `GET /api/manage/config` returned the four expected threshold values and the new Webhook target.
|
||||||
|
- Container-side synthetic engine run emitted `batch_started`, `time_pre_warning`, `time_alarm`, `alarm_removal_timeout`, then `batch_pending_disposal` plus `batch_discarded`.
|
||||||
|
- Natural runtime log emitted `alarm_removal_timeout` for `batch_000881` at `2026-06-15T11:52:20+08:00`.
|
||||||
|
- Webhook delivery for that event returned HTTP `200` from `video-recognition`.
|
||||||
|
- `video_recognition_local` result JSONL recorded both `alarm_removal_timeout` batch and case events.
|
||||||
|
- MQTT probe confirmed `video-recognition` published to `video/cold-display-guard/result/cold-display-guard` with `device_identifier=cold-display-guard`.
|
||||||
|
- `store_data_platform` is not deployed on `10.8.0.23` under that repository name or as an identifiable container; platform handling changes were completed and verified in the local repository.
|
||||||
|
- The cold-display retry queue has no pending entries; old `192.168.5.103` failures are already dead-letter history.
|
||||||
|
|
||||||
|
## Current Task: Alarm Snapshot Labels And Zone Colors
|
||||||
|
|
||||||
|
**Goal:** Uploaded alarm screenshots should show each calibrated region name directly on the image, and different cold-display zones should use different overlay colors.
|
||||||
|
|
||||||
|
**Design:** Extend the existing standard-library overlay path. Keep drawing configured polygons before JPEG upload, but carry a display label for each region, choose a stable color from a fixed palette by zone order, and draw a small high-contrast text label inside the polygon. Keep trash ROI red and labeled separately.
|
||||||
|
|
||||||
|
- [x] Inspect the current calibration overlay helper and tests.
|
||||||
|
- [x] Add failing tests for per-zone colors and visible region labels.
|
||||||
|
- [x] Implement labels and stable zone color palette.
|
||||||
|
- [x] Run snapshot tests and full Python tests.
|
||||||
|
- [x] Deploy the overlay update to `xiaozheng@10.8.0.23`.
|
||||||
|
- [x] Verify remote API/runtime health and deployed overlay helper.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
- `apply_calibration_overlay` now assigns each cold-display zone a stable color from a fixed palette and keeps the trash ROI red.
|
||||||
|
- Each overlay region now carries a label and draws a small high-contrast label box directly on the frame before JPEG encoding/upload.
|
||||||
|
- The built-in label renderer covers common现场 labels such as `区域 1` through digits and `垃圾区`, plus basic ASCII for custom numeric/English labels.
|
||||||
|
- Verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`99` tests)
|
||||||
|
- Deployed `src/cold_display_guard/alarm_snapshots.py` to `xiaozheng@10.8.0.23` after backing up the previous remote file.
|
||||||
|
- Rebuilt `cold-display-guard:dev` and restarted `cold-display-guard-api` plus `cold-display-guard-runtime`.
|
||||||
|
- Remote verification passed:
|
||||||
|
- `GET /api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- Container-side overlay smoke test confirmed two zones render different RGB values and label text pixels are present.
|
||||||
|
|
||||||
|
## Current Task: Alarm Snapshot Chinese Label Rendering Fix
|
||||||
|
|
||||||
|
**Goal:** Fix unreadable/garbled Chinese region names on uploaded alarm screenshots while keeping per-zone colors and fallback labeling robust.
|
||||||
|
|
||||||
|
**Design:** Use a real CJK font renderer for Chinese labels in the alarm snapshot overlay path. Install Noto CJK fonts in the runtime image, render labels through ffmpeg `drawtext` when the font is available, and fall back to readable ASCII labels if the font renderer is unavailable.
|
||||||
|
|
||||||
|
- [x] Reproduce and identify the likely root cause: remote container only matched DejaVu for `zh-cn`, so Chinese labels had no real CJK font path.
|
||||||
|
- [x] Add regression tests for Docker CJK font installation and readable ASCII fallback labels.
|
||||||
|
- [x] Update `Dockerfile` to install `fonts-noto-cjk`.
|
||||||
|
- [x] Update `alarm_snapshots.py` to prefer CJK font rendering and use `R1`/`TRASH` fallback text when needed.
|
||||||
|
- [x] Run focused and full local Python verification.
|
||||||
|
- [x] Deploy `Dockerfile` and `alarm_snapshots.py` to `xiaozheng@10.8.0.23` without overwriting live config.
|
||||||
|
- [x] Rebuild/restart `cold-display-guard-api` and `cold-display-guard-runtime`.
|
||||||
|
- [x] Verify remote API/runtime health, CJK font availability, overlay smoke behavior, and runtime logs.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
- Root cause was the screenshot overlay path not having a real Chinese font renderer in the deployed image; the container matched DejaVu before this fix.
|
||||||
|
- The rebuilt remote container now reports `NotoSansCJK-Regular.ttc: "Noto Sans CJK SC" "Regular"` for `fc-match :lang=zh-cn`.
|
||||||
|
- Remote overlay smoke test confirmed `find_cjk_font_file()` returns `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`, Chinese labels change the frame, bright label pixels are present, and different regions retain distinct colors.
|
||||||
|
- Local verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`101` tests)
|
||||||
|
- Remote verification passed:
|
||||||
|
- `GET /api/manage/health` returned `status=ok`, `runtime_status=running`, and version `dev`.
|
||||||
|
- `cold-display-guard-api` is healthy and `cold-display-guard-runtime` is running after restart.
|
||||||
|
- Runtime logs show normal startup after the restart.
|
||||||
|
|
||||||
|
## Current Task: Investigate False Normal Consumption Events On 10.8.0.23
|
||||||
|
|
||||||
|
**Goal:** Determine why the live system records a normal consumption event about every two minutes with a dwell time near 13 seconds even when no one touched the cold display cabinet.
|
||||||
|
|
||||||
|
**Debug plan:** Inspect remote runtime/event/case/diagnostic logs first, correlate `batch_started` and `batch_consumed` pairs by zone and dwell time, then trace the vision metrics for those timestamps to identify whether the source is occupancy flicker, runtime restart state restoration, config thresholds, or downstream display interpretation.
|
||||||
|
|
||||||
|
- [ ] Inspect recent remote events and confirm the exact event names, zones, dwell seconds, and cadence.
|
||||||
|
- [ ] Inspect runtime diagnostics around those timestamps for occupancy and vision metric flicker.
|
||||||
|
- [ ] Inspect live config and runtime logs for sampling/stabilization settings and restarts.
|
||||||
|
- [x] Form and test a root-cause hypothesis before changing code or live thresholds.
|
||||||
|
- [x] Record findings, fix if needed, and verify with logs/tests.
|
||||||
|
|
||||||
|
### Findings And Fix
|
||||||
|
|
||||||
|
- The repeated records were real `batch_started` -> `batch_consumed` events from the camera-side engine, not a downstream display issue.
|
||||||
|
- Before the fix, recent events showed repeated zone 1 batches ending after 13-33 seconds, matching the two-frame confirmation cadence at the current sampling rate.
|
||||||
|
- Root cause had two parts:
|
||||||
|
- Zone 1 was genuinely occupied, but its vision signal hovered around the old relative dark threshold, so short raw-occupancy dips were interpreted as item removal.
|
||||||
|
- Zone 2 was occupied before or during baseline learning, so its relative difference from baseline stayed near zero and it was not detected as occupied.
|
||||||
|
- Added `occupancy_absolute_dark_fraction` in `src/cold_display_guard/vision.py`, defaulting to `0.0` so existing configs are unchanged unless they opt in.
|
||||||
|
- Updated the live config on `xiaozheng@10.8.0.23`:
|
||||||
|
- `occupancy_dark_fraction = 0.12`
|
||||||
|
- `occupancy_absolute_dark_fraction = 0.085`
|
||||||
|
- `empty_confirm_frames = 6`
|
||||||
|
- Rebuilt and restarted `cold-display-guard-api` and `cold-display-guard-runtime`.
|
||||||
|
- Verification:
|
||||||
|
- Local full Python suite passed: `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`102` tests).
|
||||||
|
- Remote health returned `status=ok` and `runtime_status=running`.
|
||||||
|
- Remote container config shows the new thresholds.
|
||||||
|
- After deployment, latest diagnostics stabilized at `zone_counts = {"1": 1, "2": 1, "6": 1}`.
|
||||||
|
- During a two-minute observation window after `13:25`, no new `batch_consumed` events were emitted; only expected pre-warning/alarm lifecycle events appeared for the occupied zones.
|
||||||
|
|
||||||
|
## Current Task: Reduce Alarm Snapshot Label Visual Obstruction
|
||||||
|
|
||||||
|
**Goal:** Region labels on uploaded alarm screenshots should be smaller and more transparent so operators can inspect the food/display image underneath.
|
||||||
|
|
||||||
|
**Design:** Keep the existing label content, placement, CJK font rendering, and per-zone colors. Only reduce the visual weight of the label layer by lowering font size, black label-box opacity, border width, and fallback label-box opacity.
|
||||||
|
|
||||||
|
- [x] Inspect current alarm snapshot label rendering style.
|
||||||
|
- [x] Add a regression test for smaller ffmpeg drawtext label style.
|
||||||
|
- [x] Reduce drawtext font size and label-box opacity.
|
||||||
|
- [x] Keep fallback label renderer visually consistent with the ffmpeg path.
|
||||||
|
- [x] Run full local verification.
|
||||||
|
- [x] Deploy the updated snapshot overlay style to `xiaozheng@10.8.0.23`.
|
||||||
|
- [x] Verify remote runtime health and deployed label style.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Targeted snapshot test passed: `PYTHONPATH=src python3 -m unittest tests/test_alarm_snapshots.py -v`.
|
||||||
|
- Full local verification passed: `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`103` tests).
|
||||||
|
- Remote verification passed:
|
||||||
|
- `GET /api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- Running container uses `fontsize=13`, `boxcolor=black@0.34`, and `boxborderw=2` for region labels.
|
||||||
|
- `cold-display-guard-runtime` logs show normal startup after restart.
|
||||||
|
|
||||||
|
## Current Task: Limit Alert Snapshot Overlay To Event Zones
|
||||||
|
|
||||||
|
**Goal:** Uploaded warning/alarm screenshots should only draw the cold-display region polygons and names for the zones that actually triggered the warning/alarm event. Other configured zones and the trash ROI should not be drawn on those uploaded screenshots.
|
||||||
|
|
||||||
|
**Plan:** Keep the full calibration overlay helper available for tests and general use, but pass alert event zone IDs from `capture_alert_snapshot` into the overlay loader and disable trash ROI drawing for alert uploads.
|
||||||
|
|
||||||
|
- [x] Add a regression test proving alert snapshot upload only annotates the triggering event zone.
|
||||||
|
- [x] Filter snapshot overlay regions by event `zone_id` during alert upload.
|
||||||
|
- [x] Preserve full overlay behavior when `apply_calibration_overlay` is called without filters.
|
||||||
|
- [x] Run full local Python verification.
|
||||||
|
- [x] Deploy `alarm_snapshots.py` to `xiaozheng@10.8.0.23`.
|
||||||
|
- [x] Verify remote API/runtime health and deployed filtered-overlay behavior.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
- Local verification passed:
|
||||||
|
- `PYTHONPATH=src python3 -m unittest tests/test_alarm_snapshots.py -v`
|
||||||
|
- `PYTHONPATH=src python3 -m unittest discover -s tests -v` (`104` tests)
|
||||||
|
- Deployed only `src/cold_display_guard/alarm_snapshots.py` to `xiaozheng@10.8.0.23` after backing up the previous remote file; live config was not overwritten.
|
||||||
|
- Rebuilt `cold-display-guard:dev` and restarted `cold-display-guard-api` plus `cold-display-guard-runtime`.
|
||||||
|
- Remote verification passed:
|
||||||
|
- `GET /api/manage/health` returned `status=ok` and `runtime_status=running`.
|
||||||
|
- Container-side smoke test for a zone-1 alert returned `zone1_changed=True`, `zone2_unchanged=True`, and `trash_unchanged=True`.
|
||||||
|
- API/runtime logs show normal startup after restart.
|
||||||
|
|
||||||
|
## Current Task: Check Webhook Duplicate Delivery
|
||||||
|
|
||||||
|
**Goal:** Verify whether `cold_display_guard` is sending duplicate Webhook requests to `video-recognition` on `xiaozheng@10.8.0.23`.
|
||||||
|
|
||||||
|
**Investigation:** Compare the sending code path, remote webhook delivery audit, retry queue state, cold-display event/case logs, `video-recognition` HTTP logs, and the receiver-side JSONL payloads.
|
||||||
|
|
||||||
|
- [x] Inspect sender code path for direct event/case delivery and retry drain behavior.
|
||||||
|
- [x] Confirm remote Webhook config uses the same URL for `event_url` and `case_url`.
|
||||||
|
- [x] Check sender delivery audit for duplicate receiver `task_id` values.
|
||||||
|
- [x] Check retry queue for pending successful redelivery risk.
|
||||||
|
- [x] Check receiver-side cold-display JSONL for duplicate payloads and duplicate business keys.
|
||||||
|
- [x] Trace the only coarse duplicate-looking case around `batch_000898`.
|
||||||
|
|
||||||
|
### Review
|
||||||
|
|
||||||
|
- Current remote config sends both `batch_event` and `case_event` to `http://10.8.0.23:8080/api/webhook/cold-display-guard`, so one business transition can produce two HTTP POSTs to the same endpoint with different `kind` values.
|
||||||
|
- Sender audit `logs/webhook_delivery.jsonl` contains `3056` records total; recent valid delivery has `321` direct `ok` records and `0` retry `ok` records.
|
||||||
|
- Receiver-returned `task_id` values are unique: `321` unique task IDs and `0` duplicate task IDs.
|
||||||
|
- Retry queue has `547` latest retry items, all `dead_letter`; there are no pending retries.
|
||||||
|
- Receiver-side `video-recognition` cold-display files for `2026-06-15` contain `181` business payloads; exact payload duplicates are `0`, and fine-grained business key duplicates are `0`.
|
||||||
|
- Sender `events.jsonl` contains `3325` events; duplicate `(batch_id, event, ts, zone_id)` keys are `0`.
|
||||||
|
- The only coarse duplicate-looking receiver entry was `batch_000898` at `13:20:26`: the same frame emitted `time_pre_warning` and `pre_warning_handled`, which produced separate `case_event` actions `created` and `handled`. This is not the same Webhook request repeated.
|
||||||
315
tests/test_alarm_snapshots.py
Normal file
315
tests/test_alarm_snapshots.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cold_display_guard import alarm_snapshots
|
||||||
|
from cold_display_guard.alarm_snapshots import (
|
||||||
|
capture_alert_snapshot,
|
||||||
|
fallback_label_text,
|
||||||
|
load_alarm_snapshot_settings,
|
||||||
|
upload_snapshot_bytes,
|
||||||
|
)
|
||||||
|
from cold_display_guard.vision import Frame
|
||||||
|
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class AlarmSnapshotTests(unittest.TestCase):
|
||||||
|
def test_load_alarm_snapshot_settings_from_config(self) -> None:
|
||||||
|
settings = load_alarm_snapshot_settings(
|
||||||
|
{
|
||||||
|
"alarm_snapshot_upload": {
|
||||||
|
"enabled": True,
|
||||||
|
"service_url": "https://ota.zhengxinshipin.com",
|
||||||
|
"secret": "change-me-in-production",
|
||||||
|
"object_key_prefix": "alarms/cold-display",
|
||||||
|
"connect_timeout_seconds": 4,
|
||||||
|
"read_timeout_seconds": 9,
|
||||||
|
"encode_timeout_seconds": 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(settings.enabled)
|
||||||
|
self.assertEqual(settings.service_url, "https://ota.zhengxinshipin.com")
|
||||||
|
self.assertEqual(settings.secret, "change-me-in-production")
|
||||||
|
self.assertEqual(settings.object_key_prefix, "alarms/cold-display")
|
||||||
|
self.assertEqual(settings.connect_timeout_seconds, 4)
|
||||||
|
self.assertEqual(settings.read_timeout_seconds, 9)
|
||||||
|
self.assertEqual(settings.encode_timeout_seconds, 7)
|
||||||
|
|
||||||
|
def test_upload_snapshot_bytes_uses_documented_chunk_upload_flow(self) -> None:
|
||||||
|
json_calls: list[tuple[str, dict[str, object]]] = []
|
||||||
|
chunk_calls: list[tuple[str, dict[str, str], bytes, dict[str, str]]] = []
|
||||||
|
|
||||||
|
def fake_post_json(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, dict[str, object]]:
|
||||||
|
json_calls.append((url, payload))
|
||||||
|
if url.endswith("/token/generate"):
|
||||||
|
return 200, {"token": "token-1", "expires_at": 1770003600}
|
||||||
|
if url.endswith("/upload/init"):
|
||||||
|
return 200, {"upload_id": "upload-1"}
|
||||||
|
if url.endswith("/upload/complete"):
|
||||||
|
return 200, {"upload_id": "upload-1", "object_key": "uploads/alarms/a.jpg", "file_size": 3, "file_md5": "900150983cd24fb0d6963f7d28e17f72"}
|
||||||
|
raise AssertionError(url)
|
||||||
|
|
||||||
|
def fake_post_multipart(
|
||||||
|
url: str,
|
||||||
|
fields: dict[str, str],
|
||||||
|
file_field: str,
|
||||||
|
file_name: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
timeout: tuple[float, float],
|
||||||
|
) -> tuple[int, dict[str, object]]:
|
||||||
|
chunk_calls.append((url, fields, file_bytes, {"file_field": file_field, "file_name": file_name}))
|
||||||
|
return 200, {"upload_id": "upload-1", "index": 0, "size": len(file_bytes), "received_chunks": 1, "total_chunks": 1}
|
||||||
|
|
||||||
|
result = upload_snapshot_bytes(
|
||||||
|
b"abc",
|
||||||
|
file_name="alarm.jpg",
|
||||||
|
object_key_hint="alarms/a.jpg",
|
||||||
|
settings=load_alarm_snapshot_settings({}),
|
||||||
|
post_json_request=fake_post_json,
|
||||||
|
post_multipart_request=fake_post_multipart,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "uploaded")
|
||||||
|
self.assertEqual(result["object_key"], "uploads/alarms/a.jpg")
|
||||||
|
self.assertEqual(json_calls[0][0], "https://ota.zhengxinshipin.com/token/generate")
|
||||||
|
self.assertEqual(json_calls[1][0], "https://ota.zhengxinshipin.com/upload/init")
|
||||||
|
self.assertEqual(json_calls[2][0], "https://ota.zhengxinshipin.com/upload/complete")
|
||||||
|
self.assertIn("token=token-1", chunk_calls[0][0])
|
||||||
|
self.assertIn("upload_id=upload-1", chunk_calls[0][0])
|
||||||
|
self.assertEqual(chunk_calls[0][1]["chunk_md5"], "900150983cd24fb0d6963f7d28e17f72")
|
||||||
|
self.assertEqual(chunk_calls[0][3]["file_field"], "chunk")
|
||||||
|
|
||||||
|
def test_capture_alert_snapshot_skips_non_alert_events(self) -> None:
|
||||||
|
result = capture_alert_snapshot(
|
||||||
|
Frame(width=1, height=1, rgb=b"\x00\x00\x00"),
|
||||||
|
[{"event": "batch_started", "severity": "info", "batch_id": "batch_1"}],
|
||||||
|
{},
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_capture_alert_snapshot_uploads_current_frame_for_alert_events(self) -> None:
|
||||||
|
encode_calls: list[Frame] = []
|
||||||
|
upload_calls: list[tuple[bytes, str, str]] = []
|
||||||
|
|
||||||
|
def fake_encode(frame: Frame, timeout_seconds: float) -> bytes:
|
||||||
|
encode_calls.append(frame)
|
||||||
|
return b"jpeg-bytes"
|
||||||
|
|
||||||
|
def fake_upload(
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
file_name: str,
|
||||||
|
object_key_hint: str,
|
||||||
|
settings,
|
||||||
|
post_json_request=None,
|
||||||
|
post_multipart_request=None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
upload_calls.append((image_bytes, file_name, object_key_hint))
|
||||||
|
return {"status": "uploaded", "object_key": "uploads/alarms/test.jpg", "file_name": file_name}
|
||||||
|
|
||||||
|
result = capture_alert_snapshot(
|
||||||
|
Frame(width=1, height=1, rgb=b"\x01\x02\x03"),
|
||||||
|
[{"event": "time_alarm", "severity": "alarm", "batch_id": "batch_1", "camera_id": "cam_1", "zone_id": "1", "ts": "2026-06-09T09:00:00+00:00"}],
|
||||||
|
{"alarm_snapshot_upload": {"enabled": True, "object_key_prefix": "alarms/cold-display"}},
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
jpeg_encoder=fake_encode,
|
||||||
|
uploader=fake_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(encode_calls), 1)
|
||||||
|
self.assertEqual(upload_calls[0][0], b"jpeg-bytes")
|
||||||
|
self.assertEqual(result["status"], "uploaded")
|
||||||
|
self.assertEqual(result["object_key"], "uploads/alarms/test.jpg")
|
||||||
|
self.assertEqual(result["batch_ids"], ["batch_1"])
|
||||||
|
|
||||||
|
def test_calibration_overlay_draws_zones_and_trash_roi_without_mutating_source(self) -> None:
|
||||||
|
apply_overlay = getattr(alarm_snapshots, "apply_calibration_overlay", None)
|
||||||
|
self.assertTrue(callable(apply_overlay), "apply_calibration_overlay should be available")
|
||||||
|
frame = Frame(width=5, height=5, rgb=b"\x00\x00\x00" * 25)
|
||||||
|
|
||||||
|
annotated = apply_overlay(
|
||||||
|
frame,
|
||||||
|
{
|
||||||
|
"zones": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"label": "区域 1",
|
||||||
|
"polygon": [[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8]],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trash": {"roi": [[0.0, 0.0], [0.4, 0.0], [0.0, 0.4]]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(frame.rgb, b"\x00\x00\x00" * 25)
|
||||||
|
self.assertNotEqual(annotated.rgb, frame.rgb)
|
||||||
|
self.assertNotEqual(annotated.pixel(1, 1), (0, 0, 0))
|
||||||
|
self.assertNotEqual(annotated.pixel(2, 2), (0, 0, 0))
|
||||||
|
self.assertNotEqual(annotated.pixel(0, 0), (0, 0, 0))
|
||||||
|
|
||||||
|
def test_calibration_overlay_uses_distinct_zone_colors_and_draws_labels(self) -> None:
|
||||||
|
frame = Frame(width=40, height=20, rgb=b"\x00\x00\x00" * 800)
|
||||||
|
|
||||||
|
annotated = alarm_snapshots.apply_calibration_overlay(
|
||||||
|
frame,
|
||||||
|
{
|
||||||
|
"zones": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"label": "区域 1",
|
||||||
|
"polygon": [[0.05, 0.10], [0.40, 0.10], [0.40, 0.90], [0.05, 0.90]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"label": "区域 2",
|
||||||
|
"polygon": [[0.55, 0.10], [0.90, 0.10], [0.90, 0.90], [0.55, 0.90]],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(annotated.pixel(10, 15), annotated.pixel(30, 15))
|
||||||
|
label_pixels = [annotated.pixel(x, y) for y in range(2, 10) for x in range(2, 18)]
|
||||||
|
self.assertTrue(any(max(pixel) >= 220 for pixel in label_pixels), "expected bright label text pixels")
|
||||||
|
|
||||||
|
def test_chinese_label_fallback_uses_readable_ascii_when_font_renderer_is_unavailable(self) -> None:
|
||||||
|
self.assertEqual(fallback_label_text("区域 1"), "R1")
|
||||||
|
self.assertEqual(fallback_label_text("区域 12"), "R12")
|
||||||
|
self.assertEqual(fallback_label_text("垃圾区"), "TRASH")
|
||||||
|
|
||||||
|
def test_docker_image_installs_cjk_fonts_for_alarm_snapshot_labels(self) -> None:
|
||||||
|
dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("fonts-noto-cjk", dockerfile)
|
||||||
|
|
||||||
|
def test_drawtext_label_style_stays_small_and_translucent(self) -> None:
|
||||||
|
filter_text = alarm_snapshots.build_drawtext_filter(
|
||||||
|
[
|
||||||
|
alarm_snapshots.OverlayLabel(
|
||||||
|
text="区域 1",
|
||||||
|
fallback_text="R1",
|
||||||
|
x=10,
|
||||||
|
y=20,
|
||||||
|
accent_rgb=(255, 196, 0),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
Path("/tmp/NotoSansCJK-Regular.ttc"),
|
||||||
|
height=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("fontsize=13", filter_text)
|
||||||
|
self.assertIn("boxcolor=black@0.34", filter_text)
|
||||||
|
self.assertIn("boxborderw=2", filter_text)
|
||||||
|
|
||||||
|
def test_capture_alert_snapshot_encodes_frame_with_configured_calibration_overlay(self) -> None:
|
||||||
|
encoded_frames: list[Frame] = []
|
||||||
|
|
||||||
|
def fake_encode(frame: Frame, timeout_seconds: float) -> bytes:
|
||||||
|
encoded_frames.append(frame)
|
||||||
|
return b"jpeg-bytes"
|
||||||
|
|
||||||
|
def fake_upload(
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
file_name: str,
|
||||||
|
object_key_hint: str,
|
||||||
|
settings,
|
||||||
|
post_json_request=None,
|
||||||
|
post_multipart_request=None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {"status": "uploaded", "object_key": "uploads/alarms/overlay.jpg", "file_name": file_name}
|
||||||
|
|
||||||
|
source_frame = Frame(width=5, height=5, rgb=b"\x00\x00\x00" * 25)
|
||||||
|
result = capture_alert_snapshot(
|
||||||
|
source_frame,
|
||||||
|
[{"event": "time_alarm", "severity": "alarm", "batch_id": "batch_1", "camera_id": "cam_1", "zone_id": "1", "ts": "2026-06-09T09:00:00+00:00"}],
|
||||||
|
{
|
||||||
|
"alarm_snapshot_upload": {"enabled": True},
|
||||||
|
"zones": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"label": "区域 1",
|
||||||
|
"polygon": [[0.2, 0.2], [0.8, 0.2], [0.8, 0.8], [0.2, 0.8]],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"trash": {"roi": [[0.0, 0.0], [0.4, 0.0], [0.0, 0.4]]},
|
||||||
|
},
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
jpeg_encoder=fake_encode,
|
||||||
|
uploader=fake_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "uploaded")
|
||||||
|
self.assertEqual(source_frame.rgb, b"\x00\x00\x00" * 25)
|
||||||
|
self.assertEqual(len(encoded_frames), 1)
|
||||||
|
self.assertNotEqual(encoded_frames[0].rgb, source_frame.rgb)
|
||||||
|
self.assertNotEqual(encoded_frames[0].pixel(1, 1), (0, 0, 0))
|
||||||
|
|
||||||
|
def test_capture_alert_snapshot_only_draws_alert_event_zones(self) -> None:
|
||||||
|
encoded_frames: list[Frame] = []
|
||||||
|
|
||||||
|
def fake_encode(frame: Frame, timeout_seconds: float) -> bytes:
|
||||||
|
encoded_frames.append(frame)
|
||||||
|
return b"jpeg-bytes"
|
||||||
|
|
||||||
|
def fake_upload(
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
file_name: str,
|
||||||
|
object_key_hint: str,
|
||||||
|
settings,
|
||||||
|
post_json_request=None,
|
||||||
|
post_multipart_request=None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {"status": "uploaded", "object_key": "uploads/alarms/zone-only.jpg", "file_name": file_name}
|
||||||
|
|
||||||
|
source_frame = Frame(width=30, height=20, rgb=b"\x00\x00\x00" * 600)
|
||||||
|
result = capture_alert_snapshot(
|
||||||
|
source_frame,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"severity": "alarm",
|
||||||
|
"batch_id": "batch_1",
|
||||||
|
"camera_id": "cam_1",
|
||||||
|
"zone_id": "1",
|
||||||
|
"ts": "2026-06-09T09:00:00+00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"alarm_snapshot_upload": {"enabled": True},
|
||||||
|
"zones": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"label": "区域 1",
|
||||||
|
"polygon": [[0.00, 0.00], [0.45, 0.00], [0.45, 1.00], [0.00, 1.00]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"label": "区域 2",
|
||||||
|
"polygon": [[0.55, 0.00], [1.00, 0.00], [1.00, 1.00], [0.55, 1.00]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"trash": {"roi": [[0.45, 0.50], [0.55, 0.50], [0.55, 1.00], [0.45, 1.00]]},
|
||||||
|
},
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
jpeg_encoder=fake_encode,
|
||||||
|
uploader=fake_upload,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "uploaded")
|
||||||
|
self.assertEqual(len(encoded_frames), 1)
|
||||||
|
self.assertNotEqual(encoded_frames[0].pixel(5, 10), (0, 0, 0))
|
||||||
|
self.assertEqual(encoded_frames[0].pixel(25, 10), (0, 0, 0))
|
||||||
|
self.assertEqual(encoded_frames[0].pixel(15, 15), (0, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
207
tests/test_cases.py
Normal file
207
tests/test_cases.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
def event(
|
||||||
|
event_name: str,
|
||||||
|
when: datetime,
|
||||||
|
*,
|
||||||
|
batch_id: str = "batch_000001",
|
||||||
|
zone_id: str = "1",
|
||||||
|
zone_label: str = "区域 1",
|
||||||
|
camera_id: str = "cam_01",
|
||||||
|
severity: str = "info",
|
||||||
|
state: str = "active",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"event": event_name,
|
||||||
|
"ts": when.isoformat(),
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"camera_id": camera_id,
|
||||||
|
"zone_id": zone_id,
|
||||||
|
"zone_label": zone_label,
|
||||||
|
"severity": severity,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CaseStoreTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.t0 = datetime(2026, 6, 9, 9, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
def test_time_alarm_creates_open_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "time_alarm")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "time_alarm")
|
||||||
|
|
||||||
|
def test_time_pre_warning_creates_open_pre_warning_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("time_pre_warning", self.t0, severity="warning", state="pre_warning")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "pre_warning")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "time_pre_warning")
|
||||||
|
|
||||||
|
def test_pre_warning_handled_auto_closes_open_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("pre_warning_handled", self.t0.replace(minute=1), severity="info", state="handled")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "handled")
|
||||||
|
self.assertEqual(snapshots[0]["handled_source"], "auto_removed_before_alarm")
|
||||||
|
|
||||||
|
def test_time_alarm_upgrades_pre_warning_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events([event("time_alarm", self.t0.replace(minute=2), severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "time_alarm")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "time_alarm")
|
||||||
|
|
||||||
|
def test_alarm_removal_timeout_upgrades_same_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_pre_warning", self.t0, severity="warning", state="pre_warning")])
|
||||||
|
store.apply_batch_events([event("time_alarm", self.t0.replace(minute=2), severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("alarm_removal_timeout", self.t0.replace(minute=3), severity="alarm", state="alarm_removal_timeout")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "alarm_removal_timeout")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "alarm_removal_timeout")
|
||||||
|
|
||||||
|
def test_pending_disposal_upgrades_existing_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "pending_disposal")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "batch_pending_disposal")
|
||||||
|
|
||||||
|
def test_warning_escalated_upgrades_same_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||||
|
store.apply_batch_events(
|
||||||
|
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("warning_escalated", self.t0.replace(minute=2), severity="warning", state="warning")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_type"], "warning_escalated")
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "open")
|
||||||
|
self.assertEqual(snapshots[0]["source_event"], "warning_escalated")
|
||||||
|
|
||||||
|
def test_batch_discarded_auto_closes_open_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("batch_discarded", self.t0.replace(minute=3), severity="info", state="discarded")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(snapshots[0]["case_status"], "handled")
|
||||||
|
self.assertEqual(snapshots[0]["handled_source"], "auto_closed")
|
||||||
|
|
||||||
|
def test_manual_handle_closes_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
|
||||||
|
|
||||||
|
snapshot = store.mark_handled(
|
||||||
|
str(created["case_id"]),
|
||||||
|
handled_at=self.t0.replace(minute=4),
|
||||||
|
handled_by="alice",
|
||||||
|
handled_source="manual",
|
||||||
|
note="checked",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["case_status"], "handled")
|
||||||
|
self.assertEqual(snapshot["handled_source"], "manual")
|
||||||
|
self.assertEqual(snapshot["handled_by"], "alice")
|
||||||
|
self.assertEqual(snapshot["payload"]["note"], "checked")
|
||||||
|
|
||||||
|
def test_callback_handle_closes_case(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
|
||||||
|
|
||||||
|
snapshot = store.mark_handled(
|
||||||
|
str(created["case_id"]),
|
||||||
|
handled_at=self.t0.replace(minute=5),
|
||||||
|
handled_by="crm-bot",
|
||||||
|
handled_source="webhook_callback",
|
||||||
|
source_ref="crm-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(snapshot["case_status"], "handled")
|
||||||
|
self.assertEqual(snapshot["handled_source"], "webhook_callback")
|
||||||
|
self.assertEqual(snapshot["payload"]["source_ref"], "crm-123")
|
||||||
|
|
||||||
|
def test_handled_case_does_not_reopen_on_stale_event(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
created = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])[0]
|
||||||
|
store.mark_handled(
|
||||||
|
str(created["case_id"]),
|
||||||
|
handled_at=self.t0.replace(minute=5),
|
||||||
|
handled_by="alice",
|
||||||
|
handled_source="manual",
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots = store.apply_batch_events(
|
||||||
|
[event("batch_pending_disposal", self.t0.replace(minute=1), severity="warning", state="pending_disposal")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(snapshots, [])
|
||||||
|
case = store.latest_cases()[0]
|
||||||
|
self.assertEqual(case["case_status"], "handled")
|
||||||
|
self.assertEqual(case["handled_source"], "manual")
|
||||||
|
|
||||||
|
def test_case_snapshots_round_trip_through_jsonl(self) -> None:
|
||||||
|
store = CaseStore()
|
||||||
|
snapshots = store.apply_batch_events([event("time_alarm", self.t0, severity="alarm", state="alerted")])
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "cases.jsonl"
|
||||||
|
append_case_snapshots(path, snapshots)
|
||||||
|
loaded = load_case_snapshots(path)
|
||||||
|
|
||||||
|
self.assertEqual(len(loaded), 1)
|
||||||
|
self.assertEqual(loaded[0]["case_type"], "time_alarm")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -51,8 +51,9 @@ class CliTests(unittest.TestCase):
|
|||||||
events = [json.loads(line) for line in output.getvalue().splitlines()]
|
events = [json.loads(line) for line in output.getvalue().splitlines()]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[event["event"] for event in events],
|
[event["event"] for event in events],
|
||||||
["batch_started", "batch_pending_disposal", "batch_discarded"],
|
["batch_started", "time_alarm", "batch_pending_disposal", "batch_discarded"],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(events[1]["severity"], "alarm")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cold_display_guard.config import load_settings
|
from cold_display_guard.config import load_settings, save_config_document
|
||||||
|
|
||||||
|
|
||||||
class ConfigTests(unittest.TestCase):
|
class ConfigTests(unittest.TestCase):
|
||||||
@@ -16,7 +16,9 @@ class ConfigTests(unittest.TestCase):
|
|||||||
camera_id = "cam_a"
|
camera_id = "cam_a"
|
||||||
|
|
||||||
[thresholds]
|
[thresholds]
|
||||||
|
pre_warning_seconds = 20
|
||||||
max_dwell_seconds = 30
|
max_dwell_seconds = 30
|
||||||
|
alarm_removal_seconds = 2
|
||||||
trash_confirmation_seconds = 4
|
trash_confirmation_seconds = 4
|
||||||
|
|
||||||
[layout]
|
[layout]
|
||||||
@@ -29,10 +31,152 @@ cols = 2
|
|||||||
settings = load_settings(path)
|
settings = load_settings(path)
|
||||||
|
|
||||||
self.assertEqual(settings.camera_id, "cam_a")
|
self.assertEqual(settings.camera_id, "cam_a")
|
||||||
|
self.assertEqual(settings.pre_warning_seconds, 20)
|
||||||
self.assertEqual(settings.max_dwell_seconds, 30)
|
self.assertEqual(settings.max_dwell_seconds, 30)
|
||||||
|
self.assertEqual(settings.alarm_removal_seconds, 2)
|
||||||
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
||||||
self.assertEqual(settings.zone_ids, ("r1c1", "r1c2"))
|
self.assertEqual(settings.zone_ids, ("r1c1", "r1c2"))
|
||||||
|
|
||||||
|
def test_loads_numeric_zone_ids_for_custom_zone_count(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
camera_id = "cam_numeric"
|
||||||
|
|
||||||
|
[thresholds]
|
||||||
|
max_dwell_seconds = 1200
|
||||||
|
trash_confirmation_seconds = 120
|
||||||
|
|
||||||
|
[layout]
|
||||||
|
zone_count = 3
|
||||||
|
zone_ids = ["1", "2", "3"]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = load_settings(path)
|
||||||
|
|
||||||
|
self.assertEqual(settings.camera_id, "cam_numeric")
|
||||||
|
self.assertEqual(settings.max_dwell_seconds, 1200)
|
||||||
|
self.assertEqual(settings.zone_ids, ("1", "2", "3"))
|
||||||
|
|
||||||
|
def test_rejects_more_than_ten_numeric_food_zones(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
[layout]
|
||||||
|
zone_ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "1 to 10"):
|
||||||
|
load_settings(path)
|
||||||
|
|
||||||
|
def test_loads_numeric_zone_ids_from_zone_count_without_explicit_ids(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
[layout]
|
||||||
|
zone_count = 4
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = load_settings(path)
|
||||||
|
|
||||||
|
self.assertEqual(settings.zone_ids, ("1", "2", "3", "4"))
|
||||||
|
|
||||||
|
def test_rejects_numeric_zone_count_that_conflicts_with_zone_ids(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
path.write_text(
|
||||||
|
"""
|
||||||
|
[layout]
|
||||||
|
zone_count = 5
|
||||||
|
zone_ids = ["1", "2", "3"]
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "zone_count"):
|
||||||
|
load_settings(path)
|
||||||
|
|
||||||
|
def test_save_config_document_round_trips_zone_count_and_numeric_labels(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
save_config_document(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"layout": {"zone_count": 2, "zone_ids": ["1", "2"]},
|
||||||
|
"zones": [
|
||||||
|
{"id": "1", "label": "区域 1", "polygon": [[0, 0], [1, 0], [1, 1]]},
|
||||||
|
{"id": "2", "label": "区域 2", "polygon": [[0, 0], [0.5, 0], [0.5, 1]]},
|
||||||
|
],
|
||||||
|
"trash": {"roi": [[0, 0], [1, 0], [1, 1]]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("zone_count = 2", text)
|
||||||
|
self.assertIn("pre_warning_seconds = 0", text)
|
||||||
|
self.assertIn("alarm_removal_seconds = 0", text)
|
||||||
|
self.assertIn('label = "区域 1"', text)
|
||||||
|
self.assertIn("[trash]", text)
|
||||||
|
self.assertNotIn('"trash"', text.split("[layout]", maxsplit=1)[1].split("[[zones]]", maxsplit=1)[0])
|
||||||
|
|
||||||
|
def test_save_config_document_writes_webhooks_and_case_sink(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "config.toml"
|
||||||
|
save_config_document(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"alarm_snapshot_upload": {
|
||||||
|
"enabled": True,
|
||||||
|
"service_url": "https://ota.zhengxinshipin.com",
|
||||||
|
"secret": "change-me-in-production",
|
||||||
|
"object_key_prefix": "cold-display/alarms",
|
||||||
|
},
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
"source_id": "cold-display-guard",
|
||||||
|
"callback_token": "secret",
|
||||||
|
"connect_timeout_seconds": 3,
|
||||||
|
"read_timeout_seconds": 5,
|
||||||
|
"retry_max_attempts": 4,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
"retry_max_backoff_seconds": 300,
|
||||||
|
"retry_batch_limit": 12,
|
||||||
|
},
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"webhook_retry_sink": {"path": "logs/webhook_retry.jsonl"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("[alarm_snapshot_upload]", text)
|
||||||
|
self.assertIn('service_url = "https://ota.zhengxinshipin.com"', text)
|
||||||
|
self.assertIn('secret = "change-me-in-production"', text)
|
||||||
|
self.assertIn('object_key_prefix = "cold-display/alarms"', text)
|
||||||
|
self.assertIn("[webhooks]", text)
|
||||||
|
self.assertIn('event_url = "https://example.com/events"', text)
|
||||||
|
self.assertIn('case_url = "https://example.com/cases"', text)
|
||||||
|
self.assertIn('source_id = "cold-display-guard"', text)
|
||||||
|
self.assertIn('callback_token = "secret"', text)
|
||||||
|
self.assertIn("retry_max_attempts = 4", text)
|
||||||
|
self.assertIn("retry_backoff_seconds = 30", text)
|
||||||
|
self.assertIn("retry_max_backoff_seconds = 300", text)
|
||||||
|
self.assertIn("retry_batch_limit = 12", text)
|
||||||
|
self.assertIn("[case_sink]", text)
|
||||||
|
self.assertIn('path = "logs/cases.jsonl"', text)
|
||||||
|
self.assertIn("[webhook_retry_sink]", text)
|
||||||
|
self.assertIn('path = "logs/webhook_retry.jsonl"', text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
self.assertEqual([event["event"] for event in events], ["batch_started"])
|
self.assertEqual([event["event"] for event in events], ["batch_started"])
|
||||||
self.assertEqual(events[0]["zone_id"], "r1c1")
|
self.assertEqual(events[0]["zone_id"], "r1c1")
|
||||||
self.assertEqual(events[0]["current_count"], 3)
|
self.assertEqual(events[0]["current_count"], 3)
|
||||||
|
self.assertEqual(events[0]["severity"], "info")
|
||||||
|
|
||||||
def test_consumes_batch_when_removed_before_threshold(self) -> None:
|
def test_consumes_batch_when_removed_before_threshold(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
@@ -48,9 +49,29 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=10), {"r1c1": 0}))
|
events = self.engine.process(obs(self.t0 + timedelta(seconds=10), {"r1c1": 0}))
|
||||||
|
|
||||||
self.assertEqual([event["event"] for event in events], ["batch_pending_disposal"])
|
self.assertEqual([event["event"] for event in events], ["time_alarm", "batch_pending_disposal"])
|
||||||
self.assertEqual(events[0]["dwell_seconds"], 10)
|
self.assertEqual(events[0]["severity"], "alarm")
|
||||||
self.assertIn("disposal_deadline", events[0])
|
self.assertEqual(events[1]["dwell_seconds"], 10)
|
||||||
|
self.assertIn("disposal_deadline", events[1])
|
||||||
|
|
||||||
|
def test_removal_observation_at_threshold_emits_alarm_before_pending_disposal(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 0}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["time_alarm", "batch_pending_disposal"])
|
||||||
|
self.assertEqual(events[0]["severity"], "alarm")
|
||||||
|
self.assertEqual(events[0]["current_count"], 1)
|
||||||
|
self.assertEqual(events[0]["zone_index"], 1)
|
||||||
|
self.assertEqual(events[1]["severity"], "warning")
|
||||||
|
self.assertEqual(events[1]["state"], "pending_disposal")
|
||||||
|
|
||||||
def test_trash_deposit_confirms_pending_disposal(self) -> None:
|
def test_trash_deposit_confirms_pending_disposal(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
@@ -59,12 +80,13 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
||||||
|
|
||||||
def test_missing_trash_deposit_raises_violation_after_deadline(self) -> None:
|
def test_missing_trash_deposit_escalates_warning_after_deadline(self) -> None:
|
||||||
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
self.engine.process(obs(self.t0, {"r1c1": 2}))
|
||||||
self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0}))
|
self.engine.process(obs(self.t0 + timedelta(seconds=11), {"r1c1": 0}))
|
||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=17), {"r1c1": 0}))
|
events = self.engine.process(obs(self.t0 + timedelta(seconds=17), {"r1c1": 0}))
|
||||||
|
|
||||||
self.assertEqual([event["event"] for event in events], ["missing_disposal_violation"])
|
self.assertEqual([event["event"] for event in events], ["warning_escalated"])
|
||||||
|
self.assertEqual(events[0]["severity"], "warning")
|
||||||
self.assertEqual(events[0]["violation_reasons"], ["missing_disposal"])
|
self.assertEqual(events[0]["violation_reasons"], ["missing_disposal"])
|
||||||
|
|
||||||
def test_adding_food_before_zone_clears_raises_mixed_batch_violation(self) -> None:
|
def test_adding_food_before_zone_clears_raises_mixed_batch_violation(self) -> None:
|
||||||
@@ -72,6 +94,7 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
events = self.engine.process(obs(self.t0 + timedelta(seconds=1), {"r1c1": 3}))
|
events = self.engine.process(obs(self.t0 + timedelta(seconds=1), {"r1c1": 3}))
|
||||||
|
|
||||||
self.assertEqual([event["event"] for event in events], ["mixed_batch_violation"])
|
self.assertEqual([event["event"] for event in events], ["mixed_batch_violation"])
|
||||||
|
self.assertEqual(events[0]["severity"], "warning")
|
||||||
self.assertEqual(events[0]["reason"], "food_added_before_zone_cleared")
|
self.assertEqual(events[0]["reason"], "food_added_before_zone_cleared")
|
||||||
|
|
||||||
def test_count_decrease_keeps_same_batch_active(self) -> None:
|
def test_count_decrease_keeps_same_batch_active(self) -> None:
|
||||||
@@ -93,6 +116,299 @@ class BatchEngineTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(events[0]["appeared_zones"], ["r1c2"])
|
self.assertEqual(events[0]["appeared_zones"], ["r1c2"])
|
||||||
|
|
||||||
|
def test_time_alarm_emits_once_while_batch_remains_in_zone(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
|
||||||
|
alarm_events = engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1}))
|
||||||
|
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 1}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in alarm_events], ["time_alarm"])
|
||||||
|
self.assertEqual(repeated_events, [])
|
||||||
|
self.assertEqual(alarm_events[0]["severity"], "alarm")
|
||||||
|
self.assertEqual(alarm_events[0]["zone_id"], "1")
|
||||||
|
self.assertEqual(alarm_events[0]["zone_index"], 1)
|
||||||
|
self.assertEqual(alarm_events[0]["zone_label"], "区域 1")
|
||||||
|
self.assertEqual(alarm_events[0]["dwell_seconds"], 1200)
|
||||||
|
self.assertEqual(alarm_events[0]["max_dwell_seconds"], 1200)
|
||||||
|
self.assertEqual(alarm_events[0]["current_count"], 1)
|
||||||
|
self.assertIn("alerted_at", alarm_events[0])
|
||||||
|
|
||||||
|
def test_pre_warning_emits_once_before_alarm_threshold(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
pre_warning_seconds=60,
|
||||||
|
max_dwell_seconds=120,
|
||||||
|
alarm_removal_seconds=30,
|
||||||
|
trash_confirmation_seconds=30,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
|
||||||
|
pre_warning_events = engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||||
|
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=90), {"1": 1}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in pre_warning_events], ["time_pre_warning"])
|
||||||
|
self.assertEqual(pre_warning_events[0]["severity"], "warning")
|
||||||
|
self.assertEqual(pre_warning_events[0]["state"], "pre_warning")
|
||||||
|
self.assertEqual(pre_warning_events[0]["pre_warning_seconds"], 60)
|
||||||
|
self.assertEqual(pre_warning_events[0]["pre_warned_at"], (self.t0 + timedelta(seconds=60)).isoformat())
|
||||||
|
self.assertEqual(pre_warning_events[0]["current_count"], 1)
|
||||||
|
self.assertEqual(repeated_events, [])
|
||||||
|
|
||||||
|
def test_pre_warning_removed_before_alarm_is_auto_handled(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
pre_warning_seconds=60,
|
||||||
|
max_dwell_seconds=120,
|
||||||
|
alarm_removal_seconds=30,
|
||||||
|
trash_confirmation_seconds=30,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=90), {"1": 0}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["pre_warning_handled"])
|
||||||
|
self.assertEqual(events[0]["severity"], "info")
|
||||||
|
self.assertEqual(events[0]["state"], "handled")
|
||||||
|
self.assertEqual(events[0]["handled_source"], "auto_removed_before_alarm")
|
||||||
|
self.assertEqual(events[0]["ended_at"], (self.t0 + timedelta(seconds=90)).isoformat())
|
||||||
|
|
||||||
|
def test_alarm_removal_timeout_emits_once_before_late_removal(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
pre_warning_seconds=60,
|
||||||
|
max_dwell_seconds=120,
|
||||||
|
alarm_removal_seconds=30,
|
||||||
|
trash_confirmation_seconds=30,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||||
|
alarm_events = engine.process(obs(self.t0 + timedelta(seconds=120), {"1": 1}))
|
||||||
|
|
||||||
|
timeout_events = engine.process(obs(self.t0 + timedelta(seconds=151), {"1": 1}))
|
||||||
|
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=160), {"1": 1}))
|
||||||
|
removal_events = engine.process(obs(self.t0 + timedelta(seconds=170), {"1": 0}, trash=True))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in alarm_events], ["time_alarm"])
|
||||||
|
self.assertEqual(alarm_events[0]["alarm_removal_deadline"], (self.t0 + timedelta(seconds=150)).isoformat())
|
||||||
|
self.assertEqual([event["event"] for event in timeout_events], ["alarm_removal_timeout"])
|
||||||
|
self.assertEqual(timeout_events[0]["severity"], "alarm")
|
||||||
|
self.assertEqual(timeout_events[0]["state"], "alarm_removal_timeout")
|
||||||
|
self.assertEqual(timeout_events[0]["reason"], "alarmed_batch_not_removed_after_alarm_window")
|
||||||
|
self.assertEqual(repeated_events, [])
|
||||||
|
self.assertEqual([event["event"] for event in removal_events], ["batch_pending_disposal", "batch_discarded"])
|
||||||
|
|
||||||
|
def test_alarmed_batch_removed_within_alarm_window_does_not_emit_removal_timeout(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
pre_warning_seconds=60,
|
||||||
|
max_dwell_seconds=120,
|
||||||
|
alarm_removal_seconds=30,
|
||||||
|
trash_confirmation_seconds=30,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=60), {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=120), {"1": 1}))
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=150), {"1": 0}, trash=True))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["batch_pending_disposal", "batch_discarded"])
|
||||||
|
self.assertTrue(all(event["event"] != "alarm_removal_timeout" for event in events))
|
||||||
|
|
||||||
|
def test_alarmed_batch_removed_without_trash_deposit_escalates_warning(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1}))
|
||||||
|
pending_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}))
|
||||||
|
warning_events = engine.process(obs(self.t0 + timedelta(seconds=1421), {"1": 0}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in pending_events], ["batch_pending_disposal"])
|
||||||
|
self.assertEqual(pending_events[0]["severity"], "warning")
|
||||||
|
self.assertEqual(pending_events[0]["state"], "pending_disposal")
|
||||||
|
self.assertEqual(pending_events[0]["zone_index"], 1)
|
||||||
|
self.assertEqual(pending_events[0]["ended_at"], (self.t0 + timedelta(seconds=1300)).isoformat())
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in warning_events], ["warning_escalated"])
|
||||||
|
self.assertEqual(warning_events[0]["severity"], "warning")
|
||||||
|
self.assertEqual(warning_events[0]["state"], "warning")
|
||||||
|
self.assertEqual(warning_events[0]["reason"], "alarmed_batch_removed_without_trash_deposit")
|
||||||
|
self.assertEqual(warning_events[0]["zone_label"], "区域 1")
|
||||||
|
|
||||||
|
def test_alarmed_batch_removed_with_trash_deposit_is_discarded(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}))
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=1310), {"1": 0}, trash=True))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
||||||
|
self.assertEqual(events[0]["severity"], "info")
|
||||||
|
self.assertEqual(events[0]["state"], "discarded")
|
||||||
|
|
||||||
|
def test_same_observation_removal_and_trash_motion_discards_alerted_batch(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1}))
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}, trash=True))
|
||||||
|
later_events = engine.process(obs(self.t0 + timedelta(seconds=1421), {"1": 0}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in events], ["batch_pending_disposal", "batch_discarded"])
|
||||||
|
self.assertEqual(events[1]["state"], "discarded")
|
||||||
|
self.assertEqual(later_events, [])
|
||||||
|
|
||||||
|
def test_same_observation_trash_motion_discards_multiple_newly_pending_batches(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=300,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1", "4"),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.process(obs(self.t0, {"1": 1, "4": 1}))
|
||||||
|
engine.process(obs(self.t0 + timedelta(seconds=300), {"1": 1, "4": 1}))
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=360), {"1": 0, "4": 0}, trash=True))
|
||||||
|
later_events = engine.process(obs(self.t0 + timedelta(seconds=481), {"1": 0, "4": 0}))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[event["event"] for event in events],
|
||||||
|
["batch_pending_disposal", "batch_pending_disposal", "batch_discarded", "batch_discarded"],
|
||||||
|
)
|
||||||
|
self.assertEqual([event["zone_id"] for event in events if event["event"] == "batch_discarded"], ["1", "4"])
|
||||||
|
self.assertEqual(later_events, [])
|
||||||
|
|
||||||
|
def test_restore_keeps_active_alarm_batch_after_runtime_restart(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.restore_from_events(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "batch_started",
|
||||||
|
"zone_id": "1",
|
||||||
|
"batch_id": "batch_000124",
|
||||||
|
"started_at": self.t0.isoformat(),
|
||||||
|
"current_count": 1,
|
||||||
|
"state": "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"zone_id": "1",
|
||||||
|
"batch_id": "batch_000124",
|
||||||
|
"started_at": self.t0.isoformat(),
|
||||||
|
"alerted_at": (self.t0 + timedelta(seconds=1200)).isoformat(),
|
||||||
|
"current_count": 1,
|
||||||
|
"state": "alerted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
active_zone_counts={"1": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated_events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 1}))
|
||||||
|
removal_events = engine.process(obs(self.t0 + timedelta(seconds=1400), {"1": 0}))
|
||||||
|
|
||||||
|
self.assertEqual(repeated_events, [])
|
||||||
|
self.assertEqual([event["event"] for event in removal_events], ["batch_pending_disposal"])
|
||||||
|
self.assertEqual(removal_events[0]["batch_id"], "batch_000124")
|
||||||
|
self.assertEqual(removal_events[0]["dwell_seconds"], 1400)
|
||||||
|
|
||||||
|
def test_restore_keeps_alarm_removal_timeout_deadline_after_runtime_restart(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
pre_warning_seconds=60,
|
||||||
|
max_dwell_seconds=120,
|
||||||
|
alarm_removal_seconds=30,
|
||||||
|
trash_confirmation_seconds=30,
|
||||||
|
zone_ids=("1",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.restore_from_events(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"zone_id": "1",
|
||||||
|
"batch_id": "batch_000124",
|
||||||
|
"started_at": self.t0.isoformat(),
|
||||||
|
"pre_warned_at": (self.t0 + timedelta(seconds=60)).isoformat(),
|
||||||
|
"alerted_at": (self.t0 + timedelta(seconds=120)).isoformat(),
|
||||||
|
"current_count": 1,
|
||||||
|
"state": "alerted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
active_zone_counts={"1": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout_events = engine.process(obs(self.t0 + timedelta(seconds=151), {"1": 1}))
|
||||||
|
|
||||||
|
self.assertEqual([event["event"] for event in timeout_events], ["alarm_removal_timeout"])
|
||||||
|
self.assertEqual(timeout_events[0]["batch_id"], "batch_000124")
|
||||||
|
|
||||||
|
def test_restore_skips_active_false_positive_when_latest_zone_count_is_empty(self) -> None:
|
||||||
|
settings = EngineSettings(
|
||||||
|
camera_id="test_cam",
|
||||||
|
max_dwell_seconds=1200,
|
||||||
|
trash_confirmation_seconds=120,
|
||||||
|
zone_ids=("3",),
|
||||||
|
)
|
||||||
|
engine = BatchEngine(settings)
|
||||||
|
engine.restore_from_events(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"zone_id": "3",
|
||||||
|
"batch_id": "batch_000213",
|
||||||
|
"started_at": self.t0.isoformat(),
|
||||||
|
"alerted_at": (self.t0 + timedelta(seconds=1200)).isoformat(),
|
||||||
|
"current_count": 1,
|
||||||
|
"state": "alerted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
active_zone_counts={"3": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
events = engine.process(obs(self.t0 + timedelta(seconds=1300), {"3": 0}))
|
||||||
|
|
||||||
|
self.assertEqual(events, [])
|
||||||
|
self.assertEqual(engine.active_by_zone, {})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
400
tests/test_main.py
Normal file
400
tests/test_main.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cold_display_guard.cases import CaseStore, append_case_snapshots, load_case_snapshots
|
||||||
|
from cold_display_guard.main import (
|
||||||
|
case_sink_path,
|
||||||
|
capture_runtime_alarm_snapshot,
|
||||||
|
deliver_runtime_webhooks,
|
||||||
|
persist_case_updates,
|
||||||
|
restore_runtime_state,
|
||||||
|
webhook_retry_sink_path,
|
||||||
|
)
|
||||||
|
from cold_display_guard.vision import Frame
|
||||||
|
from cold_display_guard.webhooks import load_retry_snapshots
|
||||||
|
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRestoreTests(unittest.TestCase):
|
||||||
|
def test_case_sink_path_uses_default_logs_location(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
|
||||||
|
path = case_sink_path(root, {})
|
||||||
|
|
||||||
|
self.assertEqual(path, (root / "logs" / "cases.jsonl").resolve())
|
||||||
|
|
||||||
|
def test_webhook_retry_sink_path_uses_default_logs_location(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
|
||||||
|
path = webhook_retry_sink_path(root, {})
|
||||||
|
|
||||||
|
self.assertEqual(path, (root / "logs" / "webhook_retry.jsonl").resolve())
|
||||||
|
|
||||||
|
def test_persist_case_updates_writes_case_snapshots(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "cases.jsonl"
|
||||||
|
store = CaseStore()
|
||||||
|
|
||||||
|
snapshots = persist_case_updates(
|
||||||
|
store,
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
written = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(len(snapshots), 1)
|
||||||
|
self.assertEqual(written[0]["case_type"], "time_alarm")
|
||||||
|
self.assertEqual(written[0]["case_status"], "open")
|
||||||
|
|
||||||
|
def test_persist_case_updates_preserves_api_handled_snapshot(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "cases.jsonl"
|
||||||
|
runtime_store = CaseStore()
|
||||||
|
created = persist_case_updates(
|
||||||
|
runtime_store,
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)[0]
|
||||||
|
api_store = CaseStore(load_case_snapshots(path))
|
||||||
|
append_case_snapshots(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
api_store.mark_handled(
|
||||||
|
str(created["case_id"]),
|
||||||
|
handled_at=datetime(2026, 6, 9, 9, 5, tzinfo=UTC),
|
||||||
|
handled_by="alice",
|
||||||
|
handled_source="manual",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots = persist_case_updates(
|
||||||
|
runtime_store,
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "batch_pending_disposal",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 6, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "warning",
|
||||||
|
"state": "pending_disposal",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
latest = CaseStore(load_case_snapshots(path)).latest_cases()[0]
|
||||||
|
|
||||||
|
self.assertEqual(snapshots, [])
|
||||||
|
self.assertEqual(latest["case_status"], "handled")
|
||||||
|
self.assertEqual(latest["handled_source"], "manual")
|
||||||
|
|
||||||
|
def test_deliver_runtime_webhooks_sends_event_and_case_payloads(self) -> None:
|
||||||
|
deliveries: list[tuple[str, dict[str, object]]] = []
|
||||||
|
|
||||||
|
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
deliveries.append((url, payload))
|
||||||
|
return 200, "ok"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
deliver_runtime_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"handled_source": "",
|
||||||
|
"created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"updated_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audit_path,
|
||||||
|
http_post=fake_post,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(deliveries), 2)
|
||||||
|
self.assertEqual(deliveries[0][1]["kind"], "batch_event")
|
||||||
|
self.assertEqual(deliveries[1][1]["kind"], "case_event")
|
||||||
|
|
||||||
|
def test_capture_runtime_alarm_snapshot_uses_current_frame_for_alert_events(self) -> None:
|
||||||
|
frame = Frame(width=1, height=1, rgb=b"\x01\x02\x03")
|
||||||
|
result = capture_runtime_alarm_snapshot(
|
||||||
|
frame,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"severity": "alarm",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{"alarm_snapshot_upload": {"enabled": True}},
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
jpeg_encoder=lambda frame, timeout_seconds: b"jpeg",
|
||||||
|
uploader=lambda image_bytes, **kwargs: {"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "file_name": "a.jpg"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "uploaded")
|
||||||
|
self.assertEqual(result["object_key"], "uploads/alarms/a.jpg")
|
||||||
|
|
||||||
|
def test_deliver_runtime_webhooks_enqueues_failure_and_drains_due_retry(self) -> None:
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def flaky_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
attempts["count"] += 1
|
||||||
|
if attempts["count"] == 1:
|
||||||
|
return 503, "down"
|
||||||
|
return 200, "ok"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
retry_path = Path(tmpdir) / "webhook_retry.jsonl"
|
||||||
|
config = {
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"retry_max_attempts": 3,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deliver_runtime_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=flaky_post,
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
deliver_runtime_webhooks(
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=flaky_post,
|
||||||
|
now=datetime(2026, 6, 9, 9, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
retries = load_retry_snapshots(retry_path)
|
||||||
|
|
||||||
|
self.assertEqual(attempts["count"], 2)
|
||||||
|
self.assertEqual(retries[-1]["status"], "delivered")
|
||||||
|
self.assertEqual(retries[-1]["attempt_count"], 2)
|
||||||
|
|
||||||
|
def test_deliver_runtime_webhooks_includes_snapshot_path_in_alert_payloads(self) -> None:
|
||||||
|
deliveries: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
deliveries.append(payload)
|
||||||
|
return 200, "ok"
|
||||||
|
|
||||||
|
deliver_runtime_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"handled_source": "",
|
||||||
|
"created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"updated_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Path(tempfile.mkdtemp()) / "webhook_delivery.jsonl",
|
||||||
|
http_post=fake_post,
|
||||||
|
snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(deliveries[0]["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||||
|
self.assertEqual(deliveries[1]["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||||
|
|
||||||
|
def test_restore_runtime_state_uses_stable_occupancy_when_raw_metrics_flicker(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-29T10:05:26+08:00",
|
||||||
|
"zone_counts": {"2": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"2": {
|
||||||
|
"baseline_mean_luma": 165.0,
|
||||||
|
"baseline_texture": 16.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 17.077,
|
||||||
|
"texture_delta": 8.819,
|
||||||
|
"dark_fraction": 0.0357,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
"raw_occupied": False,
|
||||||
|
"occupied": True,
|
||||||
|
"empty_streak": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, zone_counts = restore_runtime_state(
|
||||||
|
diagnostics_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(zone_counts, {"2": 1})
|
||||||
|
|
||||||
|
def test_restore_runtime_state_uses_dark_fraction_rules(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
diagnostics_path = Path(tmpdir) / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-29T10:00:00+08:00",
|
||||||
|
"zone_counts": {"1": 1, "4": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"1": {
|
||||||
|
"baseline_mean_luma": 165.0,
|
||||||
|
"baseline_texture": 16.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 40.0,
|
||||||
|
"texture_delta": 18.0,
|
||||||
|
"dark_fraction": 0.10,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"baseline_mean_luma": 177.0,
|
||||||
|
"baseline_texture": 9.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"baseline_bright_fraction": 0.0,
|
||||||
|
"mean_delta": 16.0,
|
||||||
|
"texture_delta": 40.0,
|
||||||
|
"dark_fraction": 0.0769,
|
||||||
|
"bright_fraction": 0.3077,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
baselines, zone_counts = restore_runtime_state(
|
||||||
|
diagnostics_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(zone_counts, {"1": 1, "4": 0})
|
||||||
|
self.assertEqual(baselines["1"].dark_fraction, 0.0)
|
||||||
|
self.assertEqual(baselines["4"].bright_fraction, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,15 +1,48 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from cold_display_guard.config import load_config_document, merge_calibration, save_config_document
|
from cold_display_guard.config import load_config_document, merge_calibration, save_config_document
|
||||||
from cold_display_guard.manage_api import ManageContext, build_summary
|
from cold_display_guard.manage_api import ManageContext, build_summary, config_payload, create_handler
|
||||||
|
|
||||||
|
|
||||||
class ManageApiTests(unittest.TestCase):
|
class ManageApiTests(unittest.TestCase):
|
||||||
|
def _serve_once(self, ctx: ManageContext) -> tuple[ThreadingHTTPServer, threading.Thread]:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), create_handler(ctx))
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server, thread
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
server: ThreadingHTTPServer,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: dict | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5)
|
||||||
|
payload = None if body is None else json.dumps(body)
|
||||||
|
final_headers = {"Content-Type": "application/json"}
|
||||||
|
final_headers.update(headers or {})
|
||||||
|
conn.request(method, path, body=payload, headers=final_headers)
|
||||||
|
response = conn.getresponse()
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
conn.close()
|
||||||
|
return response.status, json.loads(raw or "{}")
|
||||||
|
|
||||||
|
def _stop_server(self, server: ThreadingHTTPServer, thread: threading.Thread) -> None:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
def test_merge_calibration_updates_zones_and_trash(self) -> None:
|
def test_merge_calibration_updates_zones_and_trash(self) -> None:
|
||||||
data = {
|
data = {
|
||||||
"camera_id": "cam",
|
"camera_id": "cam",
|
||||||
@@ -28,6 +61,62 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(merged["zones"][1]["id"], "r1c2")
|
self.assertEqual(merged["zones"][1]["id"], "r1c2")
|
||||||
self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8])
|
self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8])
|
||||||
|
|
||||||
|
def test_merge_calibration_replaces_numeric_food_zones_and_keeps_trash_separate(self) -> None:
|
||||||
|
data = {
|
||||||
|
"layout": {"zone_count": 2, "zone_ids": ["1", "2"]},
|
||||||
|
"zones": [
|
||||||
|
{"id": "1", "polygon": [[0, 0], [0.3, 0], [0.3, 0.3]]},
|
||||||
|
{"id": "2", "polygon": [[0.3, 0], [0.6, 0], [0.6, 0.3]]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_calibration(
|
||||||
|
data,
|
||||||
|
[
|
||||||
|
{"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.2, 0], [0.2, 0.2]]},
|
||||||
|
{"id": "2", "label": "区域 2", "polygon": [[0.2, 0], [0.4, 0], [0.4, 0.2]]},
|
||||||
|
{"id": "3", "label": "区域 3", "polygon": [[0.4, 0], [0.6, 0], [0.6, 0.2]]},
|
||||||
|
],
|
||||||
|
[[0.8, 0.8], [1, 0.8], [1, 1], [0.8, 1]],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(merged["layout"]["zone_count"], 3)
|
||||||
|
self.assertEqual(merged["layout"]["zone_ids"], ["1", "2", "3"])
|
||||||
|
self.assertEqual([zone["label"] for zone in merged["zones"]], ["区域 1", "区域 2", "区域 3"])
|
||||||
|
self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8])
|
||||||
|
self.assertNotIn("trash", merged["layout"]["zone_ids"])
|
||||||
|
|
||||||
|
def test_merge_calibration_preserves_numeric_zone_count_when_some_zones_are_unmarked(self) -> None:
|
||||||
|
data = {
|
||||||
|
"layout": {"zone_count": 3, "zone_ids": ["1", "2", "3"]},
|
||||||
|
"zones": [
|
||||||
|
{"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.2, 0], [0.2, 0.2]]},
|
||||||
|
{"id": "2", "label": "区域 2", "polygon": [[0.2, 0], [0.4, 0], [0.4, 0.2]]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_calibration(
|
||||||
|
data,
|
||||||
|
[{"id": "1", "label": "区域 1", "polygon": [[0, 0], [0.3, 0], [0.3, 0.3]]}],
|
||||||
|
[[0.8, 0.8], [1, 0.8], [1, 1]],
|
||||||
|
{"zone_count": 3, "zone_ids": ["1", "2", "3"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(merged["layout"]["zone_count"], 3)
|
||||||
|
self.assertEqual(merged["layout"]["zone_ids"], ["1", "2", "3"])
|
||||||
|
self.assertEqual([zone["id"] for zone in merged["zones"]], ["1", "2"])
|
||||||
|
self.assertEqual(merged["zones"][0]["polygon"], [[0.0, 0.0], [0.3, 0.0], [0.3, 0.3]])
|
||||||
|
self.assertEqual(merged["zones"][1]["polygon"], [[0.2, 0.0], [0.4, 0.0], [0.4, 0.2]])
|
||||||
|
|
||||||
|
def test_merge_calibration_rejects_more_than_ten_numeric_food_zones(self) -> None:
|
||||||
|
zones = [
|
||||||
|
{"id": str(index), "polygon": [[0, 0], [0.1, 0], [0.1, 0.1]]}
|
||||||
|
for index in range(1, 12)
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "1 to 10"):
|
||||||
|
merge_calibration({"layout": {}}, zones, None)
|
||||||
|
|
||||||
def test_save_config_document_round_trips_manage_fields(self) -> None:
|
def test_save_config_document_round_trips_manage_fields(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = Path(tmpdir) / "config.toml"
|
path = Path(tmpdir) / "config.toml"
|
||||||
@@ -65,8 +154,9 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
events_path.write_text(
|
events_path.write_text(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
json.dumps({"event": "batch_started", "ts": "2026-04-27T10:00:00+08:00"}),
|
json.dumps({"event": "batch_started", "severity": "info", "ts": "2026-04-27T10:00:00+08:00"}),
|
||||||
json.dumps({"event": "missing_disposal_violation", "ts": "2026-04-27T13:02:00+08:00"}),
|
json.dumps({"event": "time_alarm", "severity": "alarm", "ts": "2026-04-27T12:00:00+08:00"}),
|
||||||
|
json.dumps({"event": "warning_escalated", "severity": "warning", "ts": "2026-04-27T13:02:00+08:00"}),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -74,8 +164,43 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
|
|
||||||
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
self.assertEqual(summary["metrics"]["event_count"], 2)
|
self.assertEqual(summary["metrics"]["event_count"], 3)
|
||||||
|
self.assertEqual(summary["metrics"]["alert_count"], 1)
|
||||||
|
self.assertEqual(summary["metrics"]["warning_count"], 1)
|
||||||
self.assertEqual(summary["metrics"]["violation_count"], 1)
|
self.assertEqual(summary["metrics"]["violation_count"], 1)
|
||||||
|
self.assertEqual(summary["metrics"]["latest_alert_time"], "2026-04-27T13:02:00+08:00")
|
||||||
|
|
||||||
|
def test_summary_counts_escalated_and_legacy_warnings_without_pending_disposal(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"event_sink": {"path": "logs/events.jsonl"},
|
||||||
|
"layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
events_path = root / "logs" / "events.jsonl"
|
||||||
|
events_path.parent.mkdir()
|
||||||
|
events_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
json.dumps({"event": "batch_pending_disposal", "severity": "warning", "ts": "2026-04-27T12:01:00+08:00"}),
|
||||||
|
json.dumps({"event": "mixed_batch_violation", "ts": "2026-04-27T12:02:00+08:00"}),
|
||||||
|
json.dumps({"event": "warning_escalated", "severity": "warning", "ts": "2026-04-27T12:03:00+08:00"}),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
|
self.assertEqual(summary["metrics"]["event_count"], 3)
|
||||||
|
self.assertEqual(summary["metrics"]["alert_count"], 0)
|
||||||
|
self.assertEqual(summary["metrics"]["warning_count"], 2)
|
||||||
|
self.assertEqual(summary["metrics"]["violation_count"], 2)
|
||||||
|
self.assertEqual(summary["metrics"]["latest_alert_time"], "2026-04-27T12:03:00+08:00")
|
||||||
|
|
||||||
def test_summary_reads_runtime_diagnostics(self) -> None:
|
def test_summary_reads_runtime_diagnostics(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -108,6 +233,542 @@ class ManageApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1})
|
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"r1c1": 1})
|
||||||
self.assertTrue(summary["metrics"]["baseline_ready"])
|
self.assertTrue(summary["metrics"]["baseline_ready"])
|
||||||
|
|
||||||
|
def test_summary_uses_stable_runtime_occupancy_when_raw_metrics_flicker(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"diagnostics_path": "logs/runtime_diagnostics.jsonl",
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
},
|
||||||
|
"event_sink": {"path": "logs/events.jsonl"},
|
||||||
|
"layout": {"zone_count": 2, "zone_ids": ["1", "2"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.parent.mkdir()
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-29T10:05:26+08:00",
|
||||||
|
"zone_counts": {"1": 0, "2": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"1": {
|
||||||
|
"mean_delta": 0.0,
|
||||||
|
"texture_delta": 0.0,
|
||||||
|
"dark_fraction": 0.0,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
"occupied": False,
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"mean_delta": 17.077,
|
||||||
|
"texture_delta": 8.819,
|
||||||
|
"dark_fraction": 0.0357,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
"raw_occupied": False,
|
||||||
|
"occupied": True,
|
||||||
|
"empty_streak": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
|
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 0, "2": 1})
|
||||||
|
|
||||||
|
def test_summary_recomputes_latest_zone_counts_from_runtime_thresholds_when_stable_state_is_absent(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"diagnostics_path": "logs/runtime_diagnostics.jsonl",
|
||||||
|
"occupancy_mean_delta": 45.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
},
|
||||||
|
"event_sink": {"path": "logs/events.jsonl"},
|
||||||
|
"layout": {"zone_count": 3, "zone_ids": ["1", "2", "3"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.parent.mkdir()
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-27T11:02:23+08:00",
|
||||||
|
"zone_counts": {"1": 1, "3": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"1": {"mean_delta": 70.0, "texture_delta": 27.0},
|
||||||
|
"3": {"mean_delta": 36.0, "texture_delta": -9.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
|
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "3": 0})
|
||||||
|
|
||||||
|
def test_summary_recomputes_latest_zone_counts_with_dark_fraction_rule(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"diagnostics_path": "logs/runtime_diagnostics.jsonl",
|
||||||
|
"occupancy_mean_delta": 55.0,
|
||||||
|
"occupancy_texture_delta": 18.0,
|
||||||
|
"occupancy_dark_fraction": 0.06,
|
||||||
|
"occupancy_texture_dark_fraction": 0.04,
|
||||||
|
"occupancy_bright_reflection_fraction": 0.18,
|
||||||
|
},
|
||||||
|
"event_sink": {"path": "logs/events.jsonl"},
|
||||||
|
"layout": {"zone_count": 2, "zone_ids": ["1", "2"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
diagnostics_path = root / "logs" / "runtime_diagnostics.jsonl"
|
||||||
|
diagnostics_path.parent.mkdir()
|
||||||
|
diagnostics_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ts": "2026-05-28T09:41:13+08:00",
|
||||||
|
"zone_counts": {"1": 1, "2": 1},
|
||||||
|
"diagnostics": {
|
||||||
|
"baseline_ready": True,
|
||||||
|
"zones": {
|
||||||
|
"1": {
|
||||||
|
"mean_delta": 45.0,
|
||||||
|
"texture_delta": 20.0,
|
||||||
|
"dark_fraction": 0.20,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"bright_fraction": 0.0,
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"mean_delta": 16.0,
|
||||||
|
"texture_delta": 40.0,
|
||||||
|
"dark_fraction": 0.0769,
|
||||||
|
"baseline_dark_fraction": 0.0,
|
||||||
|
"bright_fraction": 0.3077,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = build_summary(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
|
self.assertEqual(summary["metrics"]["latest_zone_counts"], {"1": 1, "2": 0})
|
||||||
|
|
||||||
|
def test_config_payload_exposes_case_sink_and_webhooks_without_callback_token(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"webhook_retry_sink": {"path": "logs/webhook_retry.jsonl"},
|
||||||
|
"alarm_snapshot_upload": {
|
||||||
|
"enabled": True,
|
||||||
|
"service_url": "https://ota.zhengxinshipin.com",
|
||||||
|
"secret": "change-me-in-production",
|
||||||
|
},
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
"callback_token": "secret",
|
||||||
|
"retry_max_attempts": 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = config_payload(ManageContext(config_path=config_path, project_root=root))
|
||||||
|
|
||||||
|
self.assertEqual(payload["case_sink"]["path"], str((root / "logs" / "cases.jsonl").resolve()))
|
||||||
|
self.assertEqual(payload["webhook_retry_sink"]["path"], str((root / "logs" / "webhook_retry.jsonl").resolve()))
|
||||||
|
self.assertTrue(payload["alarm_snapshot_upload"]["enabled"])
|
||||||
|
self.assertEqual(payload["alarm_snapshot_upload"]["service_url"], "https://ota.zhengxinshipin.com")
|
||||||
|
self.assertNotIn("secret", payload["alarm_snapshot_upload"])
|
||||||
|
self.assertTrue(payload["webhooks"]["enabled"])
|
||||||
|
self.assertEqual(payload["webhooks"]["retry_max_attempts"], 4)
|
||||||
|
self.assertNotIn("callback_token", payload["webhooks"])
|
||||||
|
|
||||||
|
def test_cases_endpoint_returns_latest_snapshots(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases_path = root / "logs" / "cases.jsonl"
|
||||||
|
cases_path.parent.mkdir()
|
||||||
|
cases_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
status, payload = self._request(server, "GET", "/api/manage/cases?status=open")
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(len(payload["items"]), 1)
|
||||||
|
self.assertEqual(payload["items"][0]["case_id"], "case_batch_000001")
|
||||||
|
|
||||||
|
def test_case_summary_endpoint_counts_open_and_handled(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases_path = root / "logs" / "cases.jsonl"
|
||||||
|
cases_path.parent.mkdir()
|
||||||
|
cases_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000002",
|
||||||
|
"batch_id": "batch_000002",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "warning_escalated",
|
||||||
|
"case_status": "handled",
|
||||||
|
"source_event": "warning_escalated",
|
||||||
|
"created_at": "2026-06-09T09:01:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:05:00+08:00",
|
||||||
|
"handled_source": "manual",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
status, payload = self._request(server, "GET", "/api/manage/cases/summary")
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["open_case_count"], 1)
|
||||||
|
self.assertEqual(payload["handled_case_count"], 1)
|
||||||
|
self.assertEqual(payload["warning_escalated_case_count"], 1)
|
||||||
|
|
||||||
|
def test_manual_handle_endpoint_appends_handled_snapshot(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases_path = root / "logs" / "cases.jsonl"
|
||||||
|
cases_path.parent.mkdir()
|
||||||
|
cases_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
status, payload = self._request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/api/manage/cases/case_batch_000001/handle",
|
||||||
|
body={"handled_by": "alice", "note": "checked"},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["case_status"], "handled")
|
||||||
|
self.assertEqual(lines[-1]["handled_source"], "manual")
|
||||||
|
self.assertEqual(lines[-1]["payload"]["note"], "checked")
|
||||||
|
|
||||||
|
def test_manual_handle_endpoint_enqueues_failed_case_webhook_for_retry(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
"retry_max_attempts": 3,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases_path = root / "logs" / "cases.jsonl"
|
||||||
|
retry_path = root / "logs" / "webhook_retry.jsonl"
|
||||||
|
cases_path.parent.mkdir()
|
||||||
|
cases_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
with mock.patch("cold_display_guard.webhooks.post_json", side_effect=OSError("network down")):
|
||||||
|
status, payload = self._request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/api/manage/cases/case_batch_000001/handle",
|
||||||
|
body={"handled_by": "alice"},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
retries = [json.loads(line) for line in retry_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["case_status"], "handled")
|
||||||
|
self.assertEqual(retries[-1]["status"], "pending")
|
||||||
|
self.assertEqual(retries[-1]["target"], "case_event")
|
||||||
|
self.assertEqual(retries[-1]["attempt_count"], 1)
|
||||||
|
|
||||||
|
def test_retry_queue_endpoint_returns_pending_items(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(config_path, {"layout": {"zone_ids": ["1"]}})
|
||||||
|
retry_path = root / "logs" / "webhook_retry.jsonl"
|
||||||
|
retry_path.parent.mkdir()
|
||||||
|
retry_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"retry_id": "retry_000001",
|
||||||
|
"target": "case_event",
|
||||||
|
"url": "https://example.com/cases",
|
||||||
|
"status": "pending",
|
||||||
|
"attempt_count": 1,
|
||||||
|
"payload": {"kind": "case_event"},
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"next_attempt_at": "2026-06-09T09:01:00+08:00",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
status, payload = self._request(server, "GET", "/api/manage/webhooks/retries?status=pending")
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["items"][0]["retry_id"], "retry_000001")
|
||||||
|
self.assertEqual(payload["items"][0]["status"], "pending")
|
||||||
|
|
||||||
|
def test_retry_drain_endpoint_retries_pending_item(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"webhooks": {"enabled": True, "retry_max_attempts": 3, "retry_backoff_seconds": 30},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
retry_path = root / "logs" / "webhook_retry.jsonl"
|
||||||
|
retry_path.parent.mkdir()
|
||||||
|
retry_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"retry_id": "retry_000001",
|
||||||
|
"target": "case_event",
|
||||||
|
"url": "https://example.com/cases",
|
||||||
|
"status": "pending",
|
||||||
|
"attempt_count": 1,
|
||||||
|
"payload": {"kind": "case_event", "case_id": "case_batch_000001"},
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"next_attempt_at": "2026-06-09T09:01:00+08:00",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
with mock.patch("cold_display_guard.webhooks.post_json", return_value=(200, "ok")):
|
||||||
|
status, payload = self._request(server, "POST", "/api/manage/webhooks/retries/drain", body={})
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
lines = [json.loads(line) for line in retry_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["retried_count"], 1)
|
||||||
|
self.assertEqual(payload["delivered_count"], 1)
|
||||||
|
self.assertEqual(lines[-1]["status"], "delivered")
|
||||||
|
self.assertEqual(lines[-1]["attempt_count"], 2)
|
||||||
|
|
||||||
|
def test_callback_endpoint_requires_token_and_handles_case(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
root = Path(tmpdir)
|
||||||
|
config_path = root / "config" / "local.toml"
|
||||||
|
save_config_document(
|
||||||
|
config_path,
|
||||||
|
{
|
||||||
|
"case_sink": {"path": "logs/cases.jsonl"},
|
||||||
|
"webhooks": {"callback_token": "secret"},
|
||||||
|
"layout": {"zone_ids": ["1"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cases_path = root / "logs" / "cases.jsonl"
|
||||||
|
cases_path.parent.mkdir()
|
||||||
|
cases_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "open",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"created_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"updated_at": "2026-06-09T09:00:00+08:00",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = ManageContext(config_path=config_path, project_root=root)
|
||||||
|
server, thread = self._serve_once(ctx)
|
||||||
|
try:
|
||||||
|
unauthorized_status, _ = self._request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/api/manage/webhooks/case-update",
|
||||||
|
body={"case_id": "case_batch_000001", "status": "handled"},
|
||||||
|
)
|
||||||
|
status, payload = self._request(
|
||||||
|
server,
|
||||||
|
"POST",
|
||||||
|
"/api/manage/webhooks/case-update",
|
||||||
|
body={"case_id": "case_batch_000001", "status": "handled", "handled_by": "crm-bot"},
|
||||||
|
headers={"X-Webhook-Token": "secret"},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._stop_server(server, thread)
|
||||||
|
|
||||||
|
lines = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(unauthorized_status, 403)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(payload["handled_source"], "webhook_callback")
|
||||||
|
self.assertEqual(lines[-1]["handled_source"], "webhook_callback")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from cold_display_guard.vision import (
|
from cold_display_guard.vision import (
|
||||||
Frame,
|
Frame,
|
||||||
Region,
|
Region,
|
||||||
|
RegionMetrics,
|
||||||
RuntimeVisionSettings,
|
RuntimeVisionSettings,
|
||||||
ZoneOccupancyDetector,
|
ZoneOccupancyDetector,
|
||||||
|
load_runtime_vision_settings,
|
||||||
|
metrics_indicate_occupied,
|
||||||
point_in_polygon,
|
point_in_polygon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +29,16 @@ def patched_frame(width: int, height: int, base: int, patch: tuple[int, int, int
|
|||||||
return Frame(width=width, height=height, rgb=bytes(pixels))
|
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||||
|
|
||||||
|
|
||||||
|
def multi_patched_frame(width: int, height: int, base: int, patches: list[tuple[int, int, int, int, int]]) -> Frame:
|
||||||
|
pixels = bytearray(bytes([base, base, base]) * width * height)
|
||||||
|
for x1, y1, x2, y2, value in patches:
|
||||||
|
for y in range(y1, y2):
|
||||||
|
for x in range(x1, x2):
|
||||||
|
offset = (y * width + x) * 3
|
||||||
|
pixels[offset : offset + 3] = bytes([value, value, value])
|
||||||
|
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||||
|
|
||||||
|
|
||||||
class VisionTests(unittest.TestCase):
|
class VisionTests(unittest.TestCase):
|
||||||
def test_point_in_polygon(self) -> None:
|
def test_point_in_polygon(self) -> None:
|
||||||
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||||
@@ -42,6 +55,8 @@ class VisionTests(unittest.TestCase):
|
|||||||
sample_stride_pixels=4,
|
sample_stride_pixels=4,
|
||||||
occupancy_mean_delta=10,
|
occupancy_mean_delta=10,
|
||||||
occupancy_texture_delta=10,
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
@@ -67,6 +82,216 @@ class VisionTests(unittest.TestCase):
|
|||||||
self.assertEqual(first_deposit, 0)
|
self.assertEqual(first_deposit, 0)
|
||||||
self.assertEqual(second_deposit, 1)
|
self.assertEqual(second_deposit, 1)
|
||||||
|
|
||||||
|
def test_detector_reports_sustained_trash_motion_below_single_frame_threshold(self) -> None:
|
||||||
|
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[],
|
||||||
|
trash_region=trash,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
trash_motion_delta=18,
|
||||||
|
trash_sustained_motion_delta=8,
|
||||||
|
trash_sustained_motion_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
_, second_deposit, second_diagnostics = detector.observe(solid_frame(32, 32, 39), now)
|
||||||
|
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 48), now)
|
||||||
|
|
||||||
|
self.assertEqual(first_deposit, 0)
|
||||||
|
self.assertEqual(second_deposit, 0)
|
||||||
|
self.assertEqual(second_diagnostics["trash"]["motion_streak"], 1)
|
||||||
|
self.assertEqual(third_deposit, 1)
|
||||||
|
self.assertEqual(third_diagnostics["trash"]["motion_streak"], 2)
|
||||||
|
|
||||||
|
def test_detector_allows_quick_sequential_strong_trash_motions(self) -> None:
|
||||||
|
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[],
|
||||||
|
trash_region=trash,
|
||||||
|
settings=RuntimeVisionSettings(sample_stride_pixels=4, trash_motion_delta=18),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
_, second_deposit, _ = detector.observe(solid_frame(32, 32, 90), now + timedelta(seconds=1))
|
||||||
|
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 30), now + timedelta(seconds=7))
|
||||||
|
|
||||||
|
self.assertEqual(first_deposit, 0)
|
||||||
|
self.assertEqual(second_deposit, 1)
|
||||||
|
self.assertEqual(third_deposit, 1)
|
||||||
|
self.assertFalse(third_diagnostics["trash"]["in_cooldown"])
|
||||||
|
|
||||||
|
def test_detector_requires_consecutive_occupied_frames(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=10,
|
||||||
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_confirm_frames=2,
|
||||||
|
empty_confirm_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
first_counts, _, first_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||||
|
second_counts, _, second_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||||
|
first_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
second_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||||
|
|
||||||
|
self.assertEqual(first_counts, {"1": 0})
|
||||||
|
self.assertTrue(first_diagnostics["zones"]["1"]["raw_occupied"])
|
||||||
|
self.assertEqual(first_diagnostics["zones"]["1"]["occupied_streak"], 1)
|
||||||
|
self.assertEqual(second_counts, {"1": 1})
|
||||||
|
self.assertTrue(second_diagnostics["zones"]["1"]["occupied"])
|
||||||
|
self.assertEqual(first_empty_counts, {"1": 1})
|
||||||
|
self.assertEqual(second_empty_counts, {"1": 0})
|
||||||
|
|
||||||
|
def test_runtime_vision_defaults_raise_brightness_reflection_threshold(self) -> None:
|
||||||
|
settings = load_runtime_vision_settings({})
|
||||||
|
|
||||||
|
self.assertEqual(settings.sample_stride_pixels, 4)
|
||||||
|
self.assertEqual(settings.occupancy_mean_delta, 55.0)
|
||||||
|
self.assertEqual(settings.occupancy_absolute_dark_fraction, 0.0)
|
||||||
|
self.assertEqual(settings.occupancy_confirm_frames, 2)
|
||||||
|
self.assertEqual(settings.empty_confirm_frames, 2)
|
||||||
|
self.assertEqual(settings.trash_motion_delta, 18.0)
|
||||||
|
self.assertEqual(settings.trash_sustained_motion_delta, 8.0)
|
||||||
|
self.assertEqual(settings.trash_sustained_motion_frames, 2)
|
||||||
|
self.assertEqual(settings.trash_motion_cooldown_seconds, 3)
|
||||||
|
|
||||||
|
def test_absolute_dark_fraction_can_detect_food_already_present_in_baseline(self) -> None:
|
||||||
|
settings = RuntimeVisionSettings(
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=18,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_absolute_dark_fraction=0.085,
|
||||||
|
)
|
||||||
|
|
||||||
|
occupied = metrics_indicate_occupied(
|
||||||
|
settings,
|
||||||
|
mean_delta=5.0,
|
||||||
|
texture_delta=0.5,
|
||||||
|
dark_fraction=0.09,
|
||||||
|
baseline_dark_fraction=0.10,
|
||||||
|
bright_fraction=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(occupied)
|
||||||
|
|
||||||
|
def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=10,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=18,
|
||||||
|
occupancy_confirm_frames=2,
|
||||||
|
empty_confirm_frames=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
detector.seed_baseline({"1": RegionMetrics(mean_luma=30.0, texture=0.0, sample_count=1)})
|
||||||
|
detector.seed_occupancy({"1": 1})
|
||||||
|
|
||||||
|
counts, _, diagnostics = detector.observe(solid_frame(32, 32, 90), datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
self.assertTrue(diagnostics["baseline_ready"])
|
||||||
|
self.assertEqual(counts, {"1": 1})
|
||||||
|
self.assertTrue(diagnostics["zones"]["1"]["occupied"])
|
||||||
|
|
||||||
|
def test_detector_reports_compact_dark_object_as_occupied(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=100,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 180), now)
|
||||||
|
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 180, (0, 0, 8, 32, 20)), now)
|
||||||
|
|
||||||
|
self.assertEqual(counts, {"1": 1})
|
||||||
|
self.assertGreaterEqual(diagnostics["zones"]["1"]["dark_fraction"], 0.06)
|
||||||
|
|
||||||
|
def test_detector_ignores_bright_reflection_without_dark_object(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=10,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_texture_dark_fraction=0.04,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(32, 32, 160), now)
|
||||||
|
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 160, (0, 0, 8, 32, 255)), now)
|
||||||
|
|
||||||
|
self.assertEqual(counts, {"1": 0})
|
||||||
|
self.assertGreaterEqual(diagnostics["zones"]["1"]["texture_delta"], 10)
|
||||||
|
self.assertLess(diagnostics["zones"]["1"]["dark_fraction"], 0.04)
|
||||||
|
|
||||||
|
def test_detector_ignores_bright_reflection_with_small_dark_edge(self) -> None:
|
||||||
|
detector = ZoneOccupancyDetector(
|
||||||
|
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||||
|
trash_region=None,
|
||||||
|
settings=RuntimeVisionSettings(
|
||||||
|
baseline_frames=1,
|
||||||
|
sample_stride_pixels=4,
|
||||||
|
occupancy_mean_delta=55,
|
||||||
|
occupancy_texture_delta=18,
|
||||||
|
occupancy_dark_luma_threshold=80,
|
||||||
|
occupancy_dark_fraction=0.06,
|
||||||
|
occupancy_texture_dark_fraction=0.04,
|
||||||
|
occupancy_confirm_frames=1,
|
||||||
|
empty_confirm_frames=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
detector.observe(solid_frame(40, 40, 180), now)
|
||||||
|
counts, _, diagnostics = detector.observe(
|
||||||
|
multi_patched_frame(
|
||||||
|
40,
|
||||||
|
40,
|
||||||
|
180,
|
||||||
|
[
|
||||||
|
(0, 0, 12, 40, 255),
|
||||||
|
(12, 0, 16, 32, 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
zone = diagnostics["zones"]["1"]
|
||||||
|
self.assertEqual(counts, {"1": 0})
|
||||||
|
self.assertGreaterEqual(zone["dark_fraction"], 0.06)
|
||||||
|
self.assertGreaterEqual(zone["bright_fraction"], 0.18)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
449
tests/test_webhooks.py
Normal file
449
tests/test_webhooks.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cold_display_guard.webhooks import (
|
||||||
|
build_batch_event_payload,
|
||||||
|
build_case_event_payload,
|
||||||
|
drain_webhook_retries,
|
||||||
|
load_webhook_settings,
|
||||||
|
load_retry_snapshots,
|
||||||
|
send_batch_event_webhooks,
|
||||||
|
send_case_webhooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
UTC = timezone.utc
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookTests(unittest.TestCase):
|
||||||
|
def test_load_webhook_settings_from_config(self) -> None:
|
||||||
|
settings = load_webhook_settings(
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
"source_id": "cold-display-guard",
|
||||||
|
"callback_token": "secret",
|
||||||
|
"connect_timeout_seconds": 4,
|
||||||
|
"read_timeout_seconds": 6,
|
||||||
|
"retry_max_attempts": 4,
|
||||||
|
"retry_backoff_seconds": 15,
|
||||||
|
"retry_max_backoff_seconds": 90,
|
||||||
|
"retry_batch_limit": 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(settings.enabled)
|
||||||
|
self.assertEqual(settings.event_url, "https://example.com/events")
|
||||||
|
self.assertEqual(settings.case_url, "https://example.com/cases")
|
||||||
|
self.assertEqual(settings.source_id, "cold-display-guard")
|
||||||
|
self.assertEqual(settings.callback_token, "secret")
|
||||||
|
self.assertEqual(settings.connect_timeout_seconds, 4)
|
||||||
|
self.assertEqual(settings.read_timeout_seconds, 6)
|
||||||
|
self.assertEqual(settings.retry_max_attempts, 4)
|
||||||
|
self.assertEqual(settings.retry_backoff_seconds, 15)
|
||||||
|
self.assertEqual(settings.retry_max_backoff_seconds, 90)
|
||||||
|
self.assertEqual(settings.retry_batch_limit, 8)
|
||||||
|
|
||||||
|
def test_build_batch_event_payload_wraps_runtime_event(self) -> None:
|
||||||
|
payload = build_batch_event_payload(
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
"started_at": datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat(),
|
||||||
|
"dwell_seconds": 1200,
|
||||||
|
"alerted_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
},
|
||||||
|
camera_ip="192.168.3.4",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["kind"], "batch_event")
|
||||||
|
self.assertEqual(payload["event"], "time_alarm")
|
||||||
|
self.assertEqual(payload["event_code"], "batch_000001")
|
||||||
|
self.assertEqual(payload["camera_ip"], "192.168.3.4")
|
||||||
|
self.assertEqual(payload["zone_label"], "区域 1")
|
||||||
|
self.assertEqual(payload["started_at"], datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat())
|
||||||
|
self.assertEqual(payload["dwell_seconds"], 1200)
|
||||||
|
self.assertFalse(payload["is_discarded"])
|
||||||
|
self.assertEqual(payload["alerted_at"], datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat())
|
||||||
|
|
||||||
|
def test_build_batch_event_payload_preserves_pre_warning_and_alarm_times(self) -> None:
|
||||||
|
pre_warned_at = datetime(2026, 6, 9, 8, 59, tzinfo=UTC).isoformat()
|
||||||
|
alarm_at = datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat()
|
||||||
|
|
||||||
|
pre_warning_payload = build_batch_event_payload(
|
||||||
|
{
|
||||||
|
"event": "time_pre_warning",
|
||||||
|
"ts": pre_warned_at,
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "warning",
|
||||||
|
"state": "pre_warning",
|
||||||
|
"started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(),
|
||||||
|
"pre_warned_at": pre_warned_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
alarm_payload = build_batch_event_payload(
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": alarm_at,
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
"started_at": datetime(2026, 6, 9, 8, 58, tzinfo=UTC).isoformat(),
|
||||||
|
"pre_warned_at": pre_warned_at,
|
||||||
|
"alerted_at": alarm_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(pre_warning_payload["pre_warned_at"], pre_warned_at)
|
||||||
|
self.assertEqual(pre_warning_payload["created_at"], pre_warned_at)
|
||||||
|
self.assertEqual(pre_warning_payload["alarm_at"], "")
|
||||||
|
self.assertEqual(alarm_payload["pre_warned_at"], pre_warned_at)
|
||||||
|
self.assertEqual(alarm_payload["alarm_at"], alarm_at)
|
||||||
|
|
||||||
|
def test_build_batch_event_payload_includes_uploaded_snapshot_path(self) -> None:
|
||||||
|
payload = build_batch_event_payload(
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
},
|
||||||
|
snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["snapshot_upload_status"], "uploaded")
|
||||||
|
self.assertEqual(payload["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||||
|
|
||||||
|
def test_build_case_event_payload_wraps_case_snapshot(self) -> None:
|
||||||
|
payload = build_case_event_payload(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"case_type": "warning_escalated",
|
||||||
|
"case_status": "handled",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"source_event": "warning_escalated",
|
||||||
|
"created_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"handled_at": datetime(2026, 6, 9, 9, 6, tzinfo=UTC).isoformat(),
|
||||||
|
"handled_source": "auto_closed",
|
||||||
|
"updated_at": datetime(2026, 6, 9, 9, 5, tzinfo=UTC).isoformat(),
|
||||||
|
"payload": {
|
||||||
|
"event": {
|
||||||
|
"started_at": datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat(),
|
||||||
|
"ended_at": datetime(2026, 6, 9, 9, 4, tzinfo=UTC).isoformat(),
|
||||||
|
"dwell_seconds": 1440,
|
||||||
|
"alerted_at": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
camera_ip="192.168.3.4",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["kind"], "case_event")
|
||||||
|
self.assertEqual(payload["action"], "handled")
|
||||||
|
self.assertEqual(payload["case_id"], "case_batch_000001")
|
||||||
|
self.assertEqual(payload["event_code"], "batch_000001")
|
||||||
|
self.assertEqual(payload["camera_id"], "cam_01")
|
||||||
|
self.assertEqual(payload["camera_ip"], "192.168.3.4")
|
||||||
|
self.assertEqual(payload["zone_label"], "区域 1")
|
||||||
|
self.assertEqual(payload["started_at"], datetime(2026, 6, 9, 8, 40, tzinfo=UTC).isoformat())
|
||||||
|
self.assertEqual(payload["ended_at"], datetime(2026, 6, 9, 9, 4, tzinfo=UTC).isoformat())
|
||||||
|
self.assertEqual(payload["dwell_seconds"], 1440)
|
||||||
|
self.assertTrue(payload["is_discarded"])
|
||||||
|
self.assertEqual(payload["discarded_at"], datetime(2026, 6, 9, 9, 6, tzinfo=UTC).isoformat())
|
||||||
|
self.assertEqual(payload["alerted_at"], datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat())
|
||||||
|
|
||||||
|
def test_build_case_event_payload_includes_uploaded_snapshot_path(self) -> None:
|
||||||
|
payload = build_case_event_payload(
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"case_type": "warning_escalated",
|
||||||
|
"case_status": "open",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"source_event": "warning_escalated",
|
||||||
|
"handled_source": "",
|
||||||
|
"updated_at": datetime(2026, 6, 9, 9, 5, tzinfo=UTC).isoformat(),
|
||||||
|
},
|
||||||
|
snapshot_upload={"status": "uploaded", "object_key": "uploads/alarms/a.jpg", "batch_ids": ["batch_000001"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["snapshot_upload_status"], "uploaded")
|
||||||
|
self.assertEqual(payload["snapshot_object_key"], "uploads/alarms/a.jpg")
|
||||||
|
|
||||||
|
def test_send_batch_event_webhooks_delivers_payload(self) -> None:
|
||||||
|
deliveries: list[tuple[str, dict[str, object], tuple[float, float]]] = []
|
||||||
|
|
||||||
|
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
deliveries.append((url, payload, timeout))
|
||||||
|
return 202, "ok"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"source_id": "cold-display-guard",
|
||||||
|
"connect_timeout_seconds": 4,
|
||||||
|
"read_timeout_seconds": 6,
|
||||||
|
},
|
||||||
|
"stream": {"rtsp_url": "rtsp://admin:secret@192.168.3.4:554/h264/ch1/main/av_stream"},
|
||||||
|
},
|
||||||
|
audit_path,
|
||||||
|
http_post=fake_post,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(deliveries[0][0], "https://example.com/events")
|
||||||
|
self.assertEqual(deliveries[0][1]["kind"], "batch_event")
|
||||||
|
self.assertEqual(deliveries[0][1]["camera_ip"], "192.168.3.4")
|
||||||
|
self.assertEqual(deliveries[0][1]["source_id"], "cold-display-guard")
|
||||||
|
self.assertEqual(deliveries[0][2], (4.0, 6.0))
|
||||||
|
|
||||||
|
def test_send_case_webhooks_delivers_payload(self) -> None:
|
||||||
|
deliveries: list[tuple[str, dict[str, object]]] = []
|
||||||
|
|
||||||
|
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
deliveries.append((url, payload))
|
||||||
|
return 200, "ok"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
send_case_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"case_id": "case_batch_000001",
|
||||||
|
"case_type": "time_alarm",
|
||||||
|
"case_status": "handled",
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"source_event": "time_alarm",
|
||||||
|
"handled_source": "manual",
|
||||||
|
"updated_at": datetime(2026, 6, 9, 9, 10, tzinfo=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"case_url": "https://example.com/cases",
|
||||||
|
"source_id": "cold-display-guard",
|
||||||
|
},
|
||||||
|
"stream": {"rtsp_url": "rtsp://admin:secret@192.168.3.4:554/h264/ch1/main/av_stream"},
|
||||||
|
},
|
||||||
|
audit_path,
|
||||||
|
http_post=fake_post,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(deliveries[0][0], "https://example.com/cases")
|
||||||
|
self.assertEqual(deliveries[0][1]["kind"], "case_event")
|
||||||
|
self.assertEqual(deliveries[0][1]["action"], "handled")
|
||||||
|
self.assertEqual(deliveries[0][1]["camera_ip"], "192.168.3.4")
|
||||||
|
self.assertEqual(deliveries[0][1]["source_id"], "cold-display-guard")
|
||||||
|
|
||||||
|
def test_failed_delivery_is_logged_without_raising(self) -> None:
|
||||||
|
def fake_post(url: str, payload: dict[str, object], timeout: tuple[float, float]) -> tuple[int, str]:
|
||||||
|
raise OSError("network down")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audit_path,
|
||||||
|
http_post=fake_post,
|
||||||
|
)
|
||||||
|
logged = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(logged[0]["status"], "error")
|
||||||
|
self.assertEqual(logged[0]["target"], "batch_event")
|
||||||
|
self.assertIn("network down", logged[0]["message"])
|
||||||
|
|
||||||
|
def test_non_2xx_delivery_is_enqueued_for_retry(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
retry_path = Path(tmpdir) / "webhook_retry.jsonl"
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"retry_max_attempts": 3,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=lambda url, payload, timeout: (503, "service unavailable"),
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
retries = load_retry_snapshots(retry_path)
|
||||||
|
logged = [json.loads(line) for line in audit_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
self.assertEqual(logged[0]["status"], "error")
|
||||||
|
self.assertEqual(logged[0]["status_code"], 503)
|
||||||
|
self.assertEqual(retries[-1]["status"], "pending")
|
||||||
|
self.assertEqual(retries[-1]["attempt_count"], 1)
|
||||||
|
self.assertEqual(retries[-1]["target"], "batch_event")
|
||||||
|
self.assertEqual(retries[-1]["url"], "https://example.com/events")
|
||||||
|
|
||||||
|
def test_due_retry_is_marked_delivered_after_success(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
retry_path = Path(tmpdir) / "webhook_retry.jsonl"
|
||||||
|
config = {
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"retry_max_attempts": 3,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=lambda url, payload, timeout: (503, "service unavailable"),
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
drained = drain_webhook_retries(
|
||||||
|
config,
|
||||||
|
retry_path,
|
||||||
|
audit_path,
|
||||||
|
http_post=lambda url, payload, timeout: (200, "ok"),
|
||||||
|
now=datetime(2026, 6, 9, 9, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
retries = load_retry_snapshots(retry_path)
|
||||||
|
|
||||||
|
self.assertEqual(len(drained), 1)
|
||||||
|
self.assertEqual(retries[-1]["status"], "delivered")
|
||||||
|
self.assertEqual(retries[-1]["attempt_count"], 2)
|
||||||
|
self.assertEqual(retries[-1]["last_status_code"], 200)
|
||||||
|
|
||||||
|
def test_retry_reaches_dead_letter_after_attempt_limit(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
audit_path = Path(tmpdir) / "webhook_delivery.jsonl"
|
||||||
|
retry_path = Path(tmpdir) / "webhook_retry.jsonl"
|
||||||
|
config = {
|
||||||
|
"webhooks": {
|
||||||
|
"enabled": True,
|
||||||
|
"event_url": "https://example.com/events",
|
||||||
|
"retry_max_attempts": 2,
|
||||||
|
"retry_backoff_seconds": 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_batch_event_webhooks(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"event": "time_alarm",
|
||||||
|
"ts": datetime(2026, 6, 9, 9, 0, tzinfo=UTC).isoformat(),
|
||||||
|
"batch_id": "batch_000001",
|
||||||
|
"camera_id": "cam_01",
|
||||||
|
"zone_id": "1",
|
||||||
|
"zone_label": "区域 1",
|
||||||
|
"severity": "alarm",
|
||||||
|
"state": "alerted",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
audit_path,
|
||||||
|
retry_path=retry_path,
|
||||||
|
http_post=lambda url, payload, timeout: (503, "service unavailable"),
|
||||||
|
now=datetime(2026, 6, 9, 9, 0, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
drained = drain_webhook_retries(
|
||||||
|
config,
|
||||||
|
retry_path,
|
||||||
|
audit_path,
|
||||||
|
http_post=lambda url, payload, timeout: (503, "still down"),
|
||||||
|
now=datetime(2026, 6, 9, 9, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
retries = load_retry_snapshots(retry_path)
|
||||||
|
|
||||||
|
self.assertEqual(len(drained), 1)
|
||||||
|
self.assertEqual(retries[-1]["status"], "dead_letter")
|
||||||
|
self.assertEqual(retries[-1]["attempt_count"], 2)
|
||||||
|
self.assertEqual(retries[-1]["last_status_code"], 503)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
29
web/Dockerfile
Normal file
29
web/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/node:20-alpine AS builder
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
|
WORKDIR /source
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.30.3
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.29.4-alpine
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||||
|
apk add --no-cache tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /source/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
23
web/nginx.conf
Normal file
23
web/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=10s;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
set $api_upstream http://cold-display-guard-api:19080;
|
||||||
|
proxy_pass $api_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
763
web/src/main.js
763
web/src/main.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
560
web/src/zone-state.js
Normal file
560
web/src/zone-state.js
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
export const TRASH_REGION_ID = "trash";
|
||||||
|
export const MIN_FOOD_ZONE_COUNT = 1;
|
||||||
|
export const MAX_FOOD_ZONE_COUNT = 10;
|
||||||
|
export const DEFAULT_FOOD_ZONE_COUNT = 8;
|
||||||
|
const DEFAULT_RUNTIME_THRESHOLD_SECONDS = 1200;
|
||||||
|
|
||||||
|
const zonePalette = [
|
||||||
|
"#d92d20",
|
||||||
|
"#b54708",
|
||||||
|
"#4e5ba6",
|
||||||
|
"#008a5a",
|
||||||
|
"#0077a3",
|
||||||
|
"#155eef",
|
||||||
|
"#7f56d9",
|
||||||
|
"#c11574",
|
||||||
|
"#4f7f1f",
|
||||||
|
"#8c5a00",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function deriveFoodZones(config = {}) {
|
||||||
|
const layout = config.layout || {};
|
||||||
|
const sourceZones = config.zones || [];
|
||||||
|
const configuredIds = normalizeZoneIds(layout.zone_ids);
|
||||||
|
const numericIds = configuredIds.filter(isNumericId);
|
||||||
|
const sourceZonesById = new Map(sourceZones.map((zone) => [String(zone.id || ""), zone]));
|
||||||
|
const count = deriveZoneCount(layout, configuredIds, sourceZones);
|
||||||
|
const legacyIds = deriveLegacySourceIds(layout, configuredIds, sourceZones, count);
|
||||||
|
|
||||||
|
return numericZoneIds(count).map((id, index) => {
|
||||||
|
const legacySourceId = legacyIds[index];
|
||||||
|
const numericSourceId = numericIds.includes(id) ? id : "";
|
||||||
|
const source = sourceZonesById.get(numericSourceId) || sourceZonesById.get(legacySourceId) || {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: `区域 ${id}`,
|
||||||
|
sourceId: String(source.id || numericSourceId || legacySourceId || id),
|
||||||
|
polygon: normalizePolygon(source.polygon),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveZoneCount(layout = {}, configuredIds = normalizeZoneIds(layout.zone_ids), zones = []) {
|
||||||
|
if (configuredIds.length) {
|
||||||
|
return clampZoneCount(configuredIds.length);
|
||||||
|
}
|
||||||
|
if (layout.zone_count !== undefined) {
|
||||||
|
return clampZoneCount(layout.zone_count);
|
||||||
|
}
|
||||||
|
const rows = Number(layout.rows);
|
||||||
|
const cols = Number(layout.cols);
|
||||||
|
if (Number.isFinite(rows) && Number.isFinite(cols) && rows > 0 && cols > 0) {
|
||||||
|
return clampZoneCount(rows * cols);
|
||||||
|
}
|
||||||
|
if (Array.isArray(zones) && zones.length) {
|
||||||
|
return clampZoneCount(zones.length);
|
||||||
|
}
|
||||||
|
return DEFAULT_FOOD_ZONE_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampZoneCount(value, fallback = DEFAULT_FOOD_ZONE_COUNT) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
const count = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
|
||||||
|
return Math.min(MAX_FOOD_ZONE_COUNT, Math.max(MIN_FOOD_ZONE_COUNT, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numericZoneIds(count) {
|
||||||
|
return Array.from({length: clampZoneCount(count)}, (_, index) => String(index + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyPolygonMap(foodZones) {
|
||||||
|
return Object.fromEntries([...foodZones.map((zone) => [zone.id, []]), [TRASH_REGION_ID, []]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPolygonMap(foodZones, existing = {}, trashRoi = []) {
|
||||||
|
const polygons = createEmptyPolygonMap(foodZones);
|
||||||
|
for (const zone of foodZones) {
|
||||||
|
const existingPolygon = normalizePolygon(existing[zone.id]);
|
||||||
|
polygons[zone.id] = existingPolygon.length ? existingPolygon : normalizePolygon(zone.polygon);
|
||||||
|
}
|
||||||
|
const existingTrash = normalizePolygon(existing[TRASH_REGION_ID]);
|
||||||
|
polygons[TRASH_REGION_ID] = existingTrash.length ? existingTrash : normalizePolygon(trashRoi);
|
||||||
|
return polygons;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalibrationPayload(foodZones, polygons) {
|
||||||
|
const zones = foodZones
|
||||||
|
.map((zone) => ({
|
||||||
|
id: zone.id,
|
||||||
|
label: getRegionLabel(zone.id),
|
||||||
|
polygon: serializePolygon(polygons[zone.id]),
|
||||||
|
}))
|
||||||
|
.filter((zone) => zone.polygon.length >= 3);
|
||||||
|
const trashPolygon = serializePolygon(polygons[TRASH_REGION_ID]);
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
zone_count: foodZones.length,
|
||||||
|
zone_ids: foodZones.map((zone) => zone.id),
|
||||||
|
},
|
||||||
|
zones,
|
||||||
|
trash: trashPolygon.length >= 3 ? {roi: trashPolygon} : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyEvent(event = {}) {
|
||||||
|
const eventName = String(event.event || "");
|
||||||
|
const severity = String(event.severity || defaultSeverity(eventName)).toLowerCase();
|
||||||
|
const zoneIndex = deriveEventZoneIndex(event);
|
||||||
|
const zoneLabel = String(event.zone_label || (zoneIndex ? `区域 ${zoneIndex}` : event.zone_id || ""));
|
||||||
|
const isAlert = severity === "alarm" || eventName === "time_alarm";
|
||||||
|
const isWarning = severity === "warning" || eventName === "warning_escalated" || eventName.endsWith("_violation");
|
||||||
|
const isViolation = eventName === "warning_escalated" || eventName.endsWith("_violation") || event.state === "warning";
|
||||||
|
return {
|
||||||
|
severity,
|
||||||
|
tone: isWarning ? "warning" : isAlert ? "alarm" : "info",
|
||||||
|
zoneIndex,
|
||||||
|
zoneLabel,
|
||||||
|
isAlert,
|
||||||
|
isWarning,
|
||||||
|
isViolation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuntimeDisplayModel({
|
||||||
|
summary = null,
|
||||||
|
events = [],
|
||||||
|
config = {},
|
||||||
|
foodZones = deriveFoodZones(config),
|
||||||
|
demoReason = "",
|
||||||
|
now = new Date(),
|
||||||
|
} = {}) {
|
||||||
|
const safeConfig = config || {};
|
||||||
|
const realEvents = (Array.isArray(events) ? events : []).filter((event) => !isDemoRuntimeEvent(event));
|
||||||
|
const hasEvents = realEvents.length > 0;
|
||||||
|
const hasSummary = hasRuntimeSummary(summary) && !isDemoRuntimeSummary(summary);
|
||||||
|
const thresholdSeconds = runtimeThresholdSeconds(safeConfig, realEvents);
|
||||||
|
const displaySummary = hasSummary ? summary : createEmptyRuntimeSummary(thresholdSeconds);
|
||||||
|
const displayEvents = buildDisplayEvents(realEvents, now);
|
||||||
|
const latestZoneCounts = displaySummary?.metrics?.latest_zone_counts || {};
|
||||||
|
const configuredZoneIndexes = new Set(foodZones.map((zone) => Number(zone.id)).filter((id) => Number.isFinite(id)));
|
||||||
|
const progressRows = hasEvents
|
||||||
|
? buildProgressRowsFromEvents(realEvents, thresholdSeconds, now)
|
||||||
|
.filter((row) => configuredZoneIndexes.size === 0 || configuredZoneIndexes.has(row.zoneIndex))
|
||||||
|
.filter((row) => zoneCurrentlyOccupied(latestZoneCounts, row.zoneIndex))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDemo: false,
|
||||||
|
summaryIsDemo: false,
|
||||||
|
eventsAreDemo: false,
|
||||||
|
progressIsDemo: false,
|
||||||
|
hasSummary,
|
||||||
|
hasEvents,
|
||||||
|
demoReason,
|
||||||
|
summary: displaySummary,
|
||||||
|
events: realEvents,
|
||||||
|
displayEvents,
|
||||||
|
progressRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCaseDisplayModel({summary = null, cases = []} = {}) {
|
||||||
|
const metrics = {
|
||||||
|
openCaseCount: Number(summary?.open_case_count || 0),
|
||||||
|
handledCaseCount: Number(summary?.handled_case_count || 0),
|
||||||
|
timeAlarmCaseCount: Number(summary?.time_alarm_case_count || 0),
|
||||||
|
pendingDisposalCaseCount: Number(summary?.pending_disposal_case_count || 0),
|
||||||
|
warningEscalatedCaseCount: Number(summary?.warning_escalated_case_count || 0),
|
||||||
|
};
|
||||||
|
const rows = (Array.isArray(cases) ? cases : [])
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
caseId: String(item.case_id || ""),
|
||||||
|
typeLabel: caseTypeLabel(item.case_type),
|
||||||
|
statusLabel: String(item.case_status || "") === "handled" ? "已处理" : "待处理",
|
||||||
|
tone: String(item.case_status || "") === "handled" ? "good" : "warning",
|
||||||
|
handledSourceLabel: caseHandledSourceLabel(item.handled_source),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => timestampMillis(right.updated_at) - timestampMillis(left.updated_at));
|
||||||
|
return {metrics, rows};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildManualHandlePayload(handledBy, note = "") {
|
||||||
|
const payload = {
|
||||||
|
handled_by: String(handledBy || "").trim(),
|
||||||
|
};
|
||||||
|
const trimmedNote = String(note || "").trim();
|
||||||
|
if (trimmedNote) {
|
||||||
|
payload.note = trimmedNote;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegionColor(id) {
|
||||||
|
if (id === TRASH_REGION_ID) {
|
||||||
|
return "#111827";
|
||||||
|
}
|
||||||
|
const index = Number(id) - 1;
|
||||||
|
return zonePalette[index] || "#667085";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegionLabel(id) {
|
||||||
|
if (id === TRASH_REGION_ID) {
|
||||||
|
return "垃圾桶";
|
||||||
|
}
|
||||||
|
if (isNumericId(id)) {
|
||||||
|
return `区域 ${id}`;
|
||||||
|
}
|
||||||
|
const match = String(id).match(/^r(\d)c(\d)$/);
|
||||||
|
return match ? `${match[1]}排${match[2]}列` : String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsToAlarmMinutes(seconds) {
|
||||||
|
const parsed = Number(seconds);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.round(parsed / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alarmMinutesToSeconds(minutes) {
|
||||||
|
const parsed = Number(minutes);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
return Math.max(60, Math.round(parsed * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (char) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
})[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRuntimeSummary(summary) {
|
||||||
|
const metrics = summary?.metrics;
|
||||||
|
return Boolean(metrics && typeof metrics === "object");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDemoRuntimeSummary(summary) {
|
||||||
|
return containsDemoMarker(summary?.result_type) || containsDemoMarker(summary?.headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDemoRuntimeEvent(event) {
|
||||||
|
return event?.demo === true || containsDemoMarker(event?.camera_id) || containsDemoMarker(event?.batch_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsDemoMarker(value) {
|
||||||
|
const text = String(value || "").toLowerCase();
|
||||||
|
return text.includes("demo") || text.includes("演示");
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseTypeLabel(caseType) {
|
||||||
|
if (caseType === "warning_escalated") {
|
||||||
|
return "升级警告";
|
||||||
|
}
|
||||||
|
if (caseType === "pending_disposal") {
|
||||||
|
return "待丢弃确认";
|
||||||
|
}
|
||||||
|
if (caseType === "time_alarm") {
|
||||||
|
return "超时报警";
|
||||||
|
}
|
||||||
|
return String(caseType || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseHandledSourceLabel(source) {
|
||||||
|
if (source === "manual") {
|
||||||
|
return "人工处理";
|
||||||
|
}
|
||||||
|
if (source === "webhook_callback") {
|
||||||
|
return "回调处理";
|
||||||
|
}
|
||||||
|
if (source === "auto_closed") {
|
||||||
|
return "自动关闭";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyRuntimeSummary(thresholdSeconds) {
|
||||||
|
return {
|
||||||
|
result_type: "cold_display_guard",
|
||||||
|
headline: "暂无事件数据",
|
||||||
|
last_result_time: "",
|
||||||
|
metrics: {
|
||||||
|
event_counts: {},
|
||||||
|
event_count: 0,
|
||||||
|
alert_count: 0,
|
||||||
|
warning_count: 0,
|
||||||
|
violation_count: 0,
|
||||||
|
latest_alert_time: "",
|
||||||
|
events_path: "-",
|
||||||
|
diagnostics_path: "-",
|
||||||
|
diagnostics_count: 0,
|
||||||
|
latest_zone_counts: {},
|
||||||
|
baseline_ready: false,
|
||||||
|
max_dwell_seconds: thresholdSeconds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDisplayEvents(events, now) {
|
||||||
|
const liveEventOrdersByBatch = latestLiveEventOrdersByBatch(events);
|
||||||
|
return events.map((event, order) => ({
|
||||||
|
...event,
|
||||||
|
displayDwellSeconds: displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now) {
|
||||||
|
const fallbackSeconds = normalizeSeconds(event.dwell_seconds);
|
||||||
|
const batchId = String(event.batch_id || "");
|
||||||
|
if (!batchId || liveEventOrdersByBatch.get(batchId) !== order) {
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
|
return liveDwellSeconds(event, fallbackSeconds, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestLiveEventOrdersByBatch(events) {
|
||||||
|
const latestByBatch = new Map();
|
||||||
|
events.forEach((event, order) => {
|
||||||
|
const batchId = String(event.batch_id || "");
|
||||||
|
if (!batchId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidate = {
|
||||||
|
event,
|
||||||
|
eventTime: eventTimestamp(event),
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
const existing = latestByBatch.get(batchId);
|
||||||
|
if (!existing || isNewerEventCandidate(candidate, existing)) {
|
||||||
|
latestByBatch.set(batchId, candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveOrders = new Map();
|
||||||
|
latestByBatch.forEach((candidate, batchId) => {
|
||||||
|
if (isLiveBatchEvent(candidate.event)) {
|
||||||
|
liveOrders.set(batchId, candidate.order);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return liveOrders;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneCurrentlyOccupied(latestZoneCounts, zoneIndex) {
|
||||||
|
if (!latestZoneCounts || typeof latestZoneCounts !== "object") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Object.keys(latestZoneCounts).length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const count = latestZoneCounts[String(zoneIndex)];
|
||||||
|
if (count === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Number(count) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProgressRowsFromEvents(events, thresholdSeconds, now) {
|
||||||
|
const candidatesByZone = new Map();
|
||||||
|
events.forEach((event, order) => {
|
||||||
|
const meta = classifyEvent(event);
|
||||||
|
if (!meta.zoneIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dwellSeconds = liveDwellSeconds(event, normalizeSeconds(event.dwell_seconds), now);
|
||||||
|
const threshold = normalizeSeconds(event.max_dwell_seconds) || thresholdSeconds;
|
||||||
|
const existing = candidatesByZone.get(meta.zoneIndex);
|
||||||
|
const row = {
|
||||||
|
zoneIndex: meta.zoneIndex,
|
||||||
|
zoneLabel: meta.zoneLabel || `区域 ${meta.zoneIndex}`,
|
||||||
|
dwellSeconds,
|
||||||
|
thresholdSeconds: threshold,
|
||||||
|
progressPct: progressPct(dwellSeconds, threshold),
|
||||||
|
status: progressStatus(event, dwellSeconds, threshold),
|
||||||
|
source: "real",
|
||||||
|
};
|
||||||
|
const candidate = {
|
||||||
|
row,
|
||||||
|
eventTime: eventTimestamp(event),
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
if (!existing || isNewerEventCandidate(candidate, existing)) {
|
||||||
|
candidatesByZone.set(meta.zoneIndex, candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...candidatesByZone.values()].map((candidate) => candidate.row).sort((a, b) => a.zoneIndex - b.zoneIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeThresholdSeconds(config = {}, events = []) {
|
||||||
|
const fromConfig = normalizeSeconds(config.thresholds?.max_dwell_seconds);
|
||||||
|
if (fromConfig > 0) {
|
||||||
|
return fromConfig;
|
||||||
|
}
|
||||||
|
const fromEvent = events.map((event) => normalizeSeconds(event.max_dwell_seconds)).find((seconds) => seconds > 0);
|
||||||
|
return fromEvent || DEFAULT_RUNTIME_THRESHOLD_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressStatus(event, dwellSeconds, thresholdSeconds) {
|
||||||
|
const meta = classifyEvent(event);
|
||||||
|
if (meta.isWarning || meta.isViolation) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
if (meta.isAlert || dwellSeconds >= thresholdSeconds) {
|
||||||
|
return "alarm";
|
||||||
|
}
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressPct(dwellSeconds, thresholdSeconds) {
|
||||||
|
if (!thresholdSeconds) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(100, Math.round((dwellSeconds / thresholdSeconds) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSeconds(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimestamp(event) {
|
||||||
|
const parsed = timestampMillis(event.ts);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveDwellSeconds(event, fallbackSeconds, now) {
|
||||||
|
if (!isLiveBatchEvent(event)) {
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
|
const startedAt = timestampMillis(event.started_at);
|
||||||
|
const nowAt = timestampMillis(now);
|
||||||
|
if (!Number.isFinite(startedAt) || !Number.isFinite(nowAt) || nowAt < startedAt) {
|
||||||
|
return fallbackSeconds;
|
||||||
|
}
|
||||||
|
return Math.max(fallbackSeconds, Math.round((nowAt - startedAt) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLiveBatchEvent(event = {}) {
|
||||||
|
const terminalEvents = new Set([
|
||||||
|
"batch_consumed",
|
||||||
|
"batch_pending_disposal",
|
||||||
|
"batch_discarded",
|
||||||
|
"warning_escalated",
|
||||||
|
"overdue_return_violation",
|
||||||
|
]);
|
||||||
|
const terminalStates = new Set(["consumed", "pending_disposal", "discarded", "warning"]);
|
||||||
|
const eventName = String(event.event || "");
|
||||||
|
const state = String(event.state || "").toLowerCase();
|
||||||
|
return Boolean(event.started_at)
|
||||||
|
&& !event.ended_at
|
||||||
|
&& !terminalEvents.has(eventName)
|
||||||
|
&& !terminalStates.has(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampMillis(value) {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.getTime();
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(String(value || ""));
|
||||||
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewerEventCandidate(next, existing) {
|
||||||
|
if (next.eventTime !== null && existing.eventTime !== null && next.eventTime !== existing.eventTime) {
|
||||||
|
return next.eventTime > existing.eventTime;
|
||||||
|
}
|
||||||
|
return next.order > existing.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePolygon(value) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.filter((point) => Array.isArray(point) || (point && typeof point === "object"))
|
||||||
|
.map((point) => {
|
||||||
|
const x = Array.isArray(point) ? point[0] : point.x;
|
||||||
|
const y = Array.isArray(point) ? point[1] : point.y;
|
||||||
|
return {x: round(clamp(Number(x))), y: round(clamp(Number(y)))};
|
||||||
|
})
|
||||||
|
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializePolygon(points) {
|
||||||
|
return normalizePolygon(points).map((point) => [point.x, point.y]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZoneIds(value) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.map((id) => String(id).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLegacySourceIds(layout, configuredIds, zones, count) {
|
||||||
|
const configuredLegacyIds = configuredIds.filter((id) => !isNumericId(id));
|
||||||
|
if (configuredLegacyIds.length) {
|
||||||
|
return configuredLegacyIds;
|
||||||
|
}
|
||||||
|
if (!configuredIds.length) {
|
||||||
|
const rowColIds = rowColumnZoneIds(layout).slice(0, count);
|
||||||
|
if (rowColIds.length) {
|
||||||
|
return rowColIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zones.map((zone) => String(zone.id || "")).filter((id) => id && !isNumericId(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowColumnZoneIds(layout) {
|
||||||
|
const rows = Number(layout.rows);
|
||||||
|
const cols = Number(layout.cols);
|
||||||
|
if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const ids = [];
|
||||||
|
for (let row = 1; row <= Math.trunc(rows); row += 1) {
|
||||||
|
for (let col = 1; col <= Math.trunc(cols); col += 1) {
|
||||||
|
ids.push(`r${row}c${col}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericId(id) {
|
||||||
|
return /^\d+$/.test(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveEventZoneIndex(event) {
|
||||||
|
const explicit = Number(event.zone_index);
|
||||||
|
if (Number.isInteger(explicit) && explicit > 0) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
const zoneId = String(event.zone_id || "");
|
||||||
|
if (isNumericId(zoneId)) {
|
||||||
|
return Number(zoneId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSeverity(eventName) {
|
||||||
|
if (eventName === "time_alarm") {
|
||||||
|
return "alarm";
|
||||||
|
}
|
||||||
|
if (eventName === "warning_escalated" || eventName.endsWith("_violation")) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value) {
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value) {
|
||||||
|
return Math.round(value * 1000000) / 1000000;
|
||||||
|
}
|
||||||
687
web/test/zone-state.test.js
Normal file
687
web/test/zone-state.test.js
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TRASH_REGION_ID,
|
||||||
|
alarmMinutesToSeconds,
|
||||||
|
buildCalibrationPayload,
|
||||||
|
buildCaseDisplayModel,
|
||||||
|
buildManualHandlePayload,
|
||||||
|
buildPolygonMap,
|
||||||
|
buildRuntimeDisplayModel,
|
||||||
|
classifyEvent,
|
||||||
|
deriveFoodZones,
|
||||||
|
escapeHtml,
|
||||||
|
secondsToAlarmMinutes,
|
||||||
|
} from "../src/zone-state.js";
|
||||||
|
|
||||||
|
test("deriveFoodZones creates numeric zones from legacy grid config", () => {
|
||||||
|
const zones = deriveFoodZones({
|
||||||
|
layout: {zone_ids: ["r1c1", "r1c2"]},
|
||||||
|
zones: [
|
||||||
|
{id: "r1c1", label: "1排1列", polygon: [[0, 0], [0.4, 0], [0.4, 0.4]]},
|
||||||
|
{id: "r1c2", polygon: [[0.4, 0], [0.8, 0], [0.8, 0.4]]},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
|
||||||
|
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
|
||||||
|
assert.deepEqual(zones[1].polygon, [
|
||||||
|
{x: 0.4, y: 0},
|
||||||
|
{x: 0.8, y: 0},
|
||||||
|
{x: 0.8, y: 0.4},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deriveFoodZones maps legacy rows and columns without explicit zone ids", () => {
|
||||||
|
const zones = deriveFoodZones({
|
||||||
|
layout: {rows: 1, cols: 2},
|
||||||
|
zones: [
|
||||||
|
{id: "r1c1", polygon: [[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]},
|
||||||
|
{id: "r1c2", polygon: [[0.4, 0.1], [0.6, 0.1], [0.6, 0.3]]},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(zones.map((zone) => zone.id), ["1", "2"]);
|
||||||
|
assert.deepEqual(zones.map((zone) => zone.label), ["区域 1", "区域 2"]);
|
||||||
|
assert.deepEqual(zones[0].polygon, [
|
||||||
|
{x: 0.1, y: 0.1},
|
||||||
|
{x: 0.3, y: 0.1},
|
||||||
|
{x: 0.3, y: 0.3},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(zones[1].polygon, [
|
||||||
|
{x: 0.4, y: 0.1},
|
||||||
|
{x: 0.6, y: 0.1},
|
||||||
|
{x: 0.6, y: 0.3},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deriveFoodZones honors numeric zone count and clamps to ten", () => {
|
||||||
|
const zones = deriveFoodZones({
|
||||||
|
layout: {
|
||||||
|
zone_count: 11,
|
||||||
|
zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(zones.length, 10);
|
||||||
|
assert.equal(zones.at(-1).id, "10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildCalibrationPayload keeps trash roi separate from food zones", () => {
|
||||||
|
const payload = buildCalibrationPayload(
|
||||||
|
[
|
||||||
|
{id: "1", label: "1排1列"},
|
||||||
|
{id: "2", label: "区域 2"},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
1: [{x: 0, y: 0}, {x: 0.2, y: 0}, {x: 0.2, y: 0.2}],
|
||||||
|
2: [{x: 0.2, y: 0}, {x: 0.4, y: 0}],
|
||||||
|
[TRASH_REGION_ID]: [{x: 0.8, y: 0.8}, {x: 1, y: 0.8}, {x: 1, y: 1}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(payload.zones, [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
label: "区域 1",
|
||||||
|
polygon: [[0, 0], [0.2, 0], [0.2, 0.2]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(payload.layout, {zone_count: 2, zone_ids: ["1", "2"]});
|
||||||
|
assert.deepEqual(payload.trash.roi, [[0.8, 0.8], [1, 0.8], [1, 1]]);
|
||||||
|
assert.equal(payload.zones.some((zone) => zone.id === TRASH_REGION_ID), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPolygonMap keeps saved config polygons when draft entries are empty", () => {
|
||||||
|
const foodZones = deriveFoodZones({
|
||||||
|
layout: {zone_count: 1, zone_ids: ["1"]},
|
||||||
|
zones: [{id: "1", polygon: [[0, 0], [0.5, 0], [0.5, 0.5]]}],
|
||||||
|
trash: {roi: [[0.8, 0.8], [1, 0.8], [1, 1]]},
|
||||||
|
});
|
||||||
|
|
||||||
|
const polygons = buildPolygonMap(foodZones, {1: [], [TRASH_REGION_ID]: []}, [[0.8, 0.8], [1, 0.8], [1, 1]]);
|
||||||
|
|
||||||
|
assert.deepEqual(polygons["1"], [
|
||||||
|
{x: 0, y: 0},
|
||||||
|
{x: 0.5, y: 0},
|
||||||
|
{x: 0.5, y: 0.5},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(polygons[TRASH_REGION_ID], [
|
||||||
|
{x: 0.8, y: 0.8},
|
||||||
|
{x: 1, y: 0.8},
|
||||||
|
{x: 1, y: 1},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("classifyEvent exposes alarm and warning event display data", () => {
|
||||||
|
assert.deepEqual(classifyEvent({event: "time_alarm", zone_id: "2"}), {
|
||||||
|
severity: "alarm",
|
||||||
|
tone: "alarm",
|
||||||
|
zoneIndex: 2,
|
||||||
|
zoneLabel: "区域 2",
|
||||||
|
isAlert: true,
|
||||||
|
isWarning: false,
|
||||||
|
isViolation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(classifyEvent({event: "warning_escalated", severity: "warning", zone_index: 3}), {
|
||||||
|
severity: "warning",
|
||||||
|
tone: "warning",
|
||||||
|
zoneIndex: 3,
|
||||||
|
zoneLabel: "区域 3",
|
||||||
|
isAlert: false,
|
||||||
|
isWarning: true,
|
||||||
|
isViolation: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("alarm minute helpers round trip to backend seconds", () => {
|
||||||
|
assert.equal(secondsToAlarmMinutes(1200), 20);
|
||||||
|
assert.equal(secondsToAlarmMinutes(10800), 180);
|
||||||
|
assert.equal(alarmMinutesToSeconds(20), 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapeHtml neutralizes dynamic HTML before innerHTML rendering", () => {
|
||||||
|
assert.equal(
|
||||||
|
escapeHtml('<img src=x onerror=alert(1)> & "zone"'),
|
||||||
|
"<img src=x onerror=alert(1)> & "zone"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel does not synthesize demo runtime data", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: null,
|
||||||
|
events: [],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
|
||||||
|
demoReason: "接口不可用",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.isDemo, false);
|
||||||
|
assert.equal(model.summaryIsDemo, false);
|
||||||
|
assert.equal(model.eventsAreDemo, false);
|
||||||
|
assert.equal(model.progressIsDemo, false);
|
||||||
|
assert.equal(model.demoReason, "接口不可用");
|
||||||
|
assert.equal(model.summary.metrics.event_count, 0);
|
||||||
|
assert.deepEqual(model.events, []);
|
||||||
|
assert.deepEqual(model.progressRows, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel tolerates null config before backend config loads", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: null,
|
||||||
|
events: [],
|
||||||
|
config: null,
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.isDemo, false);
|
||||||
|
assert.deepEqual(model.events, []);
|
||||||
|
assert.deepEqual(model.progressRows, []);
|
||||||
|
assert.equal(model.summary.metrics.max_dwell_seconds, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel keeps diagnostics-only runtime data without demo fallback", () => {
|
||||||
|
const summary = {
|
||||||
|
metrics: {
|
||||||
|
event_count: 0,
|
||||||
|
alert_count: 0,
|
||||||
|
warning_count: 0,
|
||||||
|
violation_count: 0,
|
||||||
|
diagnostics_count: 8,
|
||||||
|
latest_zone_counts: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary,
|
||||||
|
events: [],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.summaryIsDemo, false);
|
||||||
|
assert.equal(model.eventsAreDemo, false);
|
||||||
|
assert.equal(model.progressIsDemo, false);
|
||||||
|
assert.equal(model.summary, summary);
|
||||||
|
assert.deepEqual(model.events, []);
|
||||||
|
assert.deepEqual(model.progressRows, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel filters legacy demo events and summaries", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {
|
||||||
|
result_type: "cold_display_guard_demo",
|
||||||
|
metrics: {
|
||||||
|
event_count: 4,
|
||||||
|
alert_count: 1,
|
||||||
|
warning_count: 2,
|
||||||
|
violation_count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
demo: true,
|
||||||
|
event: "time_alarm",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_started",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
dwell_seconds: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 3}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.hasSummary, false);
|
||||||
|
assert.equal(model.summary.metrics.event_count, 0);
|
||||||
|
assert.deepEqual(model.events.map((event) => event.zone_id), ["2"]);
|
||||||
|
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel keeps real summary and events ahead of demo data", () => {
|
||||||
|
const realSummary = {
|
||||||
|
metrics: {
|
||||||
|
event_count: 1,
|
||||||
|
alert_count: 1,
|
||||||
|
warning_count: 0,
|
||||||
|
violation_count: 0,
|
||||||
|
diagnostics_count: 2,
|
||||||
|
baseline_ready: true,
|
||||||
|
latest_alert_time: "2026-05-26T14:40:00+08:00",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const realEvents = [{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-26T14:40:00+08:00",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "batch_real",
|
||||||
|
dwell_seconds: 1300,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
}];
|
||||||
|
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: realSummary,
|
||||||
|
events: realEvents,
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.isDemo, false);
|
||||||
|
assert.equal(model.summaryIsDemo, false);
|
||||||
|
assert.equal(model.eventsAreDemo, false);
|
||||||
|
assert.equal(model.progressIsDemo, false);
|
||||||
|
assert.equal(model.summary, realSummary);
|
||||||
|
assert.deepEqual(model.events, realEvents);
|
||||||
|
assert.deepEqual(model.progressRows, [{
|
||||||
|
zoneIndex: 2,
|
||||||
|
zoneLabel: "区域 2",
|
||||||
|
dwellSeconds: 1300,
|
||||||
|
thresholdSeconds: 1200,
|
||||||
|
progressPct: 100,
|
||||||
|
status: "alarm",
|
||||||
|
source: "real",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel uses latest real event for zone progress", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {
|
||||||
|
metrics: {
|
||||||
|
event_count: 2,
|
||||||
|
alert_count: 1,
|
||||||
|
warning_count: 0,
|
||||||
|
violation_count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-26T14:40:00+08:00",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "old_batch",
|
||||||
|
dwell_seconds: 1300,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_started",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-26T14:50:00+08:00",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "new_batch",
|
||||||
|
current_count: 2,
|
||||||
|
dwell_seconds: 0,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.progressRows, [{
|
||||||
|
zoneIndex: 2,
|
||||||
|
zoneLabel: "区域 2",
|
||||||
|
dwellSeconds: 0,
|
||||||
|
thresholdSeconds: 1200,
|
||||||
|
progressPct: 0,
|
||||||
|
status: "normal",
|
||||||
|
source: "real",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel keeps active dwell timer moving from started_at", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 1, alert_count: 1}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-27T09:43:48+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_active",
|
||||||
|
state: "alerted",
|
||||||
|
started_at: "2026-05-27T09:23:43+08:00",
|
||||||
|
alerted_at: "2026-05-27T09:43:48+08:00",
|
||||||
|
dwell_seconds: 1205,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
|
||||||
|
now: "2026-05-27T09:50:00+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.progressRows[0].dwellSeconds, 1577);
|
||||||
|
assert.equal(model.progressRows[0].progressPct, 100);
|
||||||
|
assert.equal(model.progressRows[0].status, "alarm");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel exposes live dwell seconds for event table rows", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 1, alert_count: 1}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-27T09:43:48+08:00",
|
||||||
|
zone_id: "6",
|
||||||
|
zone_index: 6,
|
||||||
|
zone_label: "区域 6",
|
||||||
|
batch_id: "batch_active",
|
||||||
|
state: "alerted",
|
||||||
|
started_at: "2026-05-27T09:23:49+08:00",
|
||||||
|
alerted_at: "2026-05-27T09:43:54+08:00",
|
||||||
|
dwell_seconds: 1204,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
|
||||||
|
now: "2026-05-27T11:03:49+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.events[0].dwell_seconds, 1204);
|
||||||
|
assert.equal(model.displayEvents[0].displayDwellSeconds, 6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel does not keep batch_started row ticking after removal", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 2, latest_zone_counts: {"1": 0}}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "batch_started",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-29T09:59:49+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_done",
|
||||||
|
state: "active",
|
||||||
|
started_at: "2026-05-29T09:59:49+08:00",
|
||||||
|
dwell_seconds: 0,
|
||||||
|
max_dwell_seconds: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_consumed",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-29T10:00:53+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_done",
|
||||||
|
state: "consumed",
|
||||||
|
started_at: "2026-05-29T09:59:49+08:00",
|
||||||
|
ended_at: "2026-05-29T10:00:53+08:00",
|
||||||
|
dwell_seconds: 64,
|
||||||
|
max_dwell_seconds: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 300}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 8}}),
|
||||||
|
now: "2026-05-29T10:05:00+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.displayEvents[0].displayDwellSeconds, 0);
|
||||||
|
assert.equal(model.displayEvents[1].displayDwellSeconds, 64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel hides live progress for zones currently empty in diagnostics", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 2, alert_count: 2, latest_zone_counts: {"1": 1, "3": 0}}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-27T09:43:48+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_real",
|
||||||
|
state: "alerted",
|
||||||
|
started_at: "2026-05-27T09:23:43+08:00",
|
||||||
|
dwell_seconds: 1204,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-27T10:13:55+08:00",
|
||||||
|
zone_id: "3",
|
||||||
|
zone_index: 3,
|
||||||
|
zone_label: "区域 3",
|
||||||
|
batch_id: "batch_reflection",
|
||||||
|
state: "alerted",
|
||||||
|
started_at: "2026-05-27T09:53:51+08:00",
|
||||||
|
dwell_seconds: 1204,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
|
||||||
|
now: "2026-05-27T11:03:51+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel hides historical zones outside current configuration", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 3, latest_zone_counts: {"4": 1}}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-29T09:50:00+08:00",
|
||||||
|
zone_id: "4",
|
||||||
|
zone_index: 4,
|
||||||
|
zone_label: "区域 4",
|
||||||
|
batch_id: "batch_current",
|
||||||
|
started_at: "2026-05-29T09:45:00+08:00",
|
||||||
|
dwell_seconds: 300,
|
||||||
|
max_dwell_seconds: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_consumed",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-28T08:31:53+08:00",
|
||||||
|
zone_id: "9",
|
||||||
|
zone_index: 9,
|
||||||
|
zone_label: "区域 9",
|
||||||
|
batch_id: "batch_old_9",
|
||||||
|
started_at: "2026-05-28T08:13:48+08:00",
|
||||||
|
ended_at: "2026-05-28T08:31:53+08:00",
|
||||||
|
dwell_seconds: 1085,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_consumed",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-28T08:31:53+08:00",
|
||||||
|
zone_id: "10",
|
||||||
|
zone_index: 10,
|
||||||
|
zone_label: "区域 10",
|
||||||
|
batch_id: "batch_old_10",
|
||||||
|
started_at: "2026-05-28T08:13:48+08:00",
|
||||||
|
ended_at: "2026-05-28T08:31:53+08:00",
|
||||||
|
dwell_seconds: 1085,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 8, zone_ids: ["1", "2", "3", "4", "5", "6", "7", "8"]}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.progressRows.map((row) => row.zoneIndex), [4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel does not advance ended batch dwell timer", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 1}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "batch_consumed",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-27T09:25:00+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_done",
|
||||||
|
state: "consumed",
|
||||||
|
started_at: "2026-05-27T09:23:43+08:00",
|
||||||
|
ended_at: "2026-05-27T09:25:00+08:00",
|
||||||
|
dwell_seconds: 77,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 2}}),
|
||||||
|
now: "2026-05-27T09:50:00+08:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(model.progressRows[0].dwellSeconds, 77);
|
||||||
|
assert.equal(model.progressRows[0].progressPct, 6);
|
||||||
|
assert.equal(model.progressRows[0].status, "normal");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel falls back to event order when latest event has no timestamp", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 2, alert_count: 1}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "time_alarm",
|
||||||
|
severity: "alarm",
|
||||||
|
ts: "2026-05-26T14:40:00+08:00",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "old_batch",
|
||||||
|
dwell_seconds: 1300,
|
||||||
|
max_dwell_seconds: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event: "batch_started",
|
||||||
|
severity: "info",
|
||||||
|
zone_id: "2",
|
||||||
|
zone_index: 2,
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "new_batch",
|
||||||
|
current_count: 2,
|
||||||
|
dwell_seconds: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 1200}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.progressRows, [{
|
||||||
|
zoneIndex: 2,
|
||||||
|
zoneLabel: "区域 2",
|
||||||
|
dwellSeconds: 0,
|
||||||
|
thresholdSeconds: 1200,
|
||||||
|
progressPct: 0,
|
||||||
|
status: "normal",
|
||||||
|
source: "real",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildRuntimeDisplayModel uses config threshold when event omits threshold", () => {
|
||||||
|
const model = buildRuntimeDisplayModel({
|
||||||
|
summary: {metrics: {event_count: 1}},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
event: "batch_count_changed",
|
||||||
|
severity: "info",
|
||||||
|
ts: "2026-05-26T14:40:00+08:00",
|
||||||
|
zone_id: "1",
|
||||||
|
zone_index: 1,
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_1",
|
||||||
|
dwell_seconds: 700,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {thresholds: {max_dwell_seconds: 600}},
|
||||||
|
foodZones: deriveFoodZones({layout: {zone_count: 4}}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.progressRows, [{
|
||||||
|
zoneIndex: 1,
|
||||||
|
zoneLabel: "区域 1",
|
||||||
|
dwellSeconds: 700,
|
||||||
|
thresholdSeconds: 600,
|
||||||
|
progressPct: 100,
|
||||||
|
status: "alarm",
|
||||||
|
source: "real",
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildCaseDisplayModel normalizes case rows and summary metrics", () => {
|
||||||
|
const model = buildCaseDisplayModel({
|
||||||
|
summary: {
|
||||||
|
open_case_count: 1,
|
||||||
|
handled_case_count: 2,
|
||||||
|
time_alarm_case_count: 1,
|
||||||
|
pending_disposal_case_count: 1,
|
||||||
|
warning_escalated_case_count: 1,
|
||||||
|
},
|
||||||
|
cases: [
|
||||||
|
{
|
||||||
|
case_id: "case_batch_000001",
|
||||||
|
case_type: "warning_escalated",
|
||||||
|
case_status: "open",
|
||||||
|
zone_label: "区域 1",
|
||||||
|
batch_id: "batch_000001",
|
||||||
|
updated_at: "2026-06-09T09:10:00+08:00",
|
||||||
|
handled_source: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case_id: "case_batch_000002",
|
||||||
|
case_type: "time_alarm",
|
||||||
|
case_status: "handled",
|
||||||
|
zone_label: "区域 2",
|
||||||
|
batch_id: "batch_000002",
|
||||||
|
updated_at: "2026-06-09T09:12:00+08:00",
|
||||||
|
handled_source: "manual",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(model.metrics, {
|
||||||
|
openCaseCount: 1,
|
||||||
|
handledCaseCount: 2,
|
||||||
|
timeAlarmCaseCount: 1,
|
||||||
|
pendingDisposalCaseCount: 1,
|
||||||
|
warningEscalatedCaseCount: 1,
|
||||||
|
});
|
||||||
|
assert.equal(model.rows[0].caseId, "case_batch_000002");
|
||||||
|
assert.equal(model.rows[0].statusLabel, "已处理");
|
||||||
|
assert.equal(model.rows[0].tone, "good");
|
||||||
|
assert.equal(model.rows[1].typeLabel, "升级警告");
|
||||||
|
assert.equal(model.rows[1].statusLabel, "待处理");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildManualHandlePayload trims handled_by and keeps optional note", () => {
|
||||||
|
assert.deepEqual(buildManualHandlePayload(" alice ", " checked "), {
|
||||||
|
handled_by: "alice",
|
||||||
|
note: "checked",
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildManualHandlePayload("bob", ""), {
|
||||||
|
handled_by: "bob",
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user