Compare commits
14 Commits
lightweigh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3895c24c | |||
| 7b9ec2e148 | |||
| fa2c90e250 | |||
| 1059850378 | |||
| 46889c0621 | |||
| 547fb6290f | |||
| 45e2cf70f7 | |||
| e919ffd561 | |||
| 04729a0fd1 | |||
| 523f928303 | |||
| 8f516fdc01 | |||
| 81f170924c | |||
| 9d791be174 | |||
| 490b3089d2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@ __pycache__/
|
||||
.DS_Store
|
||||
*.textClipping
|
||||
.pytest_cache/
|
||||
.superpowers/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -97,3 +97,33 @@
|
||||
- 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.
|
||||
@@ -11,6 +11,7 @@ RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g; s
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
fonts-noto-cjk \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
121
README_zh.md
121
README_zh.md
@@ -20,9 +20,7 @@
|
||||
|
||||
## 当前实现范围
|
||||
|
||||
当前版本已经接入可运行的轻量视觉流程:区域占用、垃圾桶动作和 v1.2 的轻量 motion trajectory 都使用启发式图像差分实现,不使用 YOLO。后续训练好的 YOLO 食品检测模型会通过统一的 `disposal_evidence` / backend 合约接入,不改变批次计时状态机的业务输入形态。
|
||||
|
||||
视觉或 backend 模块需要输出标准观察数据:
|
||||
当前版本先实现纯业务状态机,不依赖摄像头模型。后续视觉模块只需要输出标准观察数据:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -31,23 +29,7 @@
|
||||
"1": 1,
|
||||
"2": 0
|
||||
},
|
||||
"trash_deposit": false,
|
||||
"disposal_evidence": [
|
||||
{
|
||||
"source_zone_id": "1",
|
||||
"target": "trash",
|
||||
"confidence": 0.9,
|
||||
"method": "motion",
|
||||
"track_points": [
|
||||
{"x": 0.22, "y": 0.30},
|
||||
{"x": 0.48, "y": 0.58},
|
||||
{"x": 0.76, "y": 0.78}
|
||||
],
|
||||
"item_class": null,
|
||||
"detector_score": null,
|
||||
"observed_at": "2026-04-27T10:00:03+08:00"
|
||||
}
|
||||
]
|
||||
"trash_deposit": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,6 +97,7 @@ http://127.0.0.1:23000
|
||||
- 标定数字食品区域和垃圾桶 ROI
|
||||
- 直接保存标定结果到项目配置文件
|
||||
- 查看事件汇总、区域序号、停留时间、报警和警告事件
|
||||
- 查看本地处置单状态,并手工标记为已处理
|
||||
|
||||
项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
|
||||
|
||||
@@ -135,6 +118,15 @@ http://127.0.0.1:19080
|
||||
- `PUT /api/manage/calibration`
|
||||
- `GET /api/manage/summary`
|
||||
- `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` 用于手动触发一次到期重试补偿。
|
||||
|
||||
## 运行识别计时进程
|
||||
|
||||
@@ -150,17 +142,17 @@ scripts/run_runtime.sh
|
||||
2. 用 `ffmpeg` 周期抓取小尺寸 RGB 帧。
|
||||
3. 按标定区域做占用变化检测。
|
||||
4. 判断垃圾桶区域是否有明显投放动作。
|
||||
5. 对刚清空的来源区域运行轻量 motion trajectory,生成可选的 `disposal_evidence`。
|
||||
6. 调用批次计时状态机,优先使用匹配 `source_zone_id` 的 `disposal_evidence` 确认丢弃,再回退到通用垃圾桶动作。
|
||||
7. 写入 `logs/events.jsonl`,管理页会读取这个文件。
|
||||
5. 调用批次计时状态机。
|
||||
6. 将 `time_alarm`、`batch_pending_disposal`、`warning_escalated` 映射到本地处置单状态。
|
||||
7. 写入 `logs/events.jsonl`、`logs/cases.jsonl`、`logs/runtime_diagnostics.jsonl`。
|
||||
8. 按配置向外部系统推送事件 webhook 和处置单 webhook。
|
||||
|
||||
当前视觉版本是可运行的启发式版本:
|
||||
|
||||
- 每个格口输出 `0/1` 占用状态,不识别单份数量。
|
||||
- 启动后的前几帧用于建立空柜基线,默认 `3` 帧。
|
||||
- 如果启动时格口里已经有食品,系统会把它当作基线,后续要等画面变化后才会产生计时事件。
|
||||
- v1.2 轨迹识别是轻量 motion trajectory,不加载 YOLO,不要求模型文件。
|
||||
- 训练好的 YOLO 模型后续应作为新的 backend 接入,并继续输出统一的 `disposal_evidence`。
|
||||
- 真实生产精度后续应接食品检测模型。
|
||||
|
||||
可选运行参数可以放在配置文件的 `[runtime]` 中:
|
||||
|
||||
@@ -183,39 +175,70 @@ occupancy_reflection_dark_fraction = 0.10
|
||||
occupancy_reflection_bright_dark_ratio = 2.0
|
||||
occupancy_confirm_frames = 2
|
||||
empty_confirm_frames = 2
|
||||
lighting_shift_guard_enabled = true
|
||||
lighting_shift_min_regions = 3
|
||||
lighting_shift_region_fraction = 0.6
|
||||
lighting_shift_mean_delta = 45.0
|
||||
trash_motion_delta = 18.0
|
||||
trash_sustained_motion_delta = 8.0
|
||||
trash_sustained_motion_frames = 2
|
||||
trash_motion_cooldown_seconds = 3
|
||||
trajectory_enabled = true
|
||||
trajectory_window_seconds = 8
|
||||
trajectory_sample_interval_seconds = 1.0
|
||||
trajectory_min_points = 3
|
||||
trajectory_segmented_enabled = true
|
||||
trajectory_segmented_min_points = 2
|
||||
trajectory_min_confidence = 0.72
|
||||
trajectory_motion_delta = 20.0
|
||||
trajectory_min_blob_area = 12
|
||||
trajectory_max_blob_area_fraction = 0.35
|
||||
trajectory_trash_entry_margin = 0.04
|
||||
trajectory_backend = "motion"
|
||||
yolo_enabled = false
|
||||
yolo_model_path = ""
|
||||
yolo_min_confidence = 0.65
|
||||
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
|
||||
```
|
||||
|
||||
`trajectory_backend = "motion"` 表示当前使用轻量轨迹 backend。`yolo_enabled`、`yolo_model_path` 和 `yolo_min_confidence` 是为后续训练模型预留的配置项;当前版本即使保留这些字段,也不会启用 YOLO 推理。
|
||||
运行时会额外记录:
|
||||
|
||||
运行诊断写入 `logs/runtime_diagnostics.jsonl`。每行包含顶层 `disposal_evidence`,以及 `diagnostics.trajectory`:
|
||||
- `logs/cases.jsonl`:本地处置单状态变更
|
||||
- `logs/webhook_retry.jsonl`:Webhook 重试队列状态快照
|
||||
- `logs/webhook_delivery.jsonl`:Webhook 投递结果审计
|
||||
|
||||
- 顶层 `disposal_evidence`:本帧实际输出给状态机的来源区域到垃圾桶证据。
|
||||
- `diagnostics.lighting_shift`:多数区域同时同方向亮度漂移时启用,防止灯光/曝光变化被当成食品进出。
|
||||
- `diagnostics.trajectory`:轻量轨迹 backend 的候选、来源 motion 预缓存、过期、拒绝、分段轨迹和已发出证据等调试信息。
|
||||
当某一轮识别结果里出现 `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`:上传失败原因,仅失败时返回
|
||||
|
||||
## 本地测试
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ timezone = "Asia/Shanghai"
|
||||
rtsp_url = ""
|
||||
|
||||
[thresholds]
|
||||
pre_warning_seconds = 900
|
||||
max_dwell_seconds = 1200
|
||||
alarm_removal_seconds = 1800
|
||||
trash_confirmation_seconds = 120
|
||||
|
||||
[layout]
|
||||
@@ -36,10 +38,10 @@ polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.
|
||||
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
|
||||
|
||||
[runtime]
|
||||
sample_interval_seconds = 5.0
|
||||
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
|
||||
@@ -48,29 +50,41 @@ occupancy_reflection_dark_fraction = 0.10
|
||||
occupancy_reflection_bright_dark_ratio = 2.0
|
||||
occupancy_confirm_frames = 2
|
||||
empty_confirm_frames = 2
|
||||
lighting_shift_guard_enabled = true
|
||||
lighting_shift_min_regions = 3
|
||||
lighting_shift_region_fraction = 0.6
|
||||
lighting_shift_mean_delta = 45.0
|
||||
trash_motion_delta = 18.0
|
||||
trash_sustained_motion_delta = 8.0
|
||||
trash_sustained_motion_frames = 2
|
||||
trash_motion_cooldown_seconds = 3
|
||||
trajectory_enabled = true
|
||||
trajectory_window_seconds = 8
|
||||
trajectory_sample_interval_seconds = 1.0
|
||||
trajectory_min_points = 3
|
||||
trajectory_segmented_enabled = true
|
||||
trajectory_segmented_min_points = 2
|
||||
trajectory_min_confidence = 0.72
|
||||
trajectory_motion_delta = 20.0
|
||||
trajectory_min_blob_area = 12
|
||||
trajectory_max_blob_area_fraction = 0.35
|
||||
trajectory_trash_entry_margin = 0.04
|
||||
trajectory_backend = "motion"
|
||||
yolo_enabled = false
|
||||
yolo_model_path = ""
|
||||
yolo_min_confidence = 0.65
|
||||
|
||||
[event_sink]
|
||||
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
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
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.
|
||||
|
||||
The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal confirmation. The first implementation uses lightweight motion tracking and keeps YOLO disabled, while preserving an evidence contract that a later trained YOLO product detector can enrich.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Backend package: `src/cold_display_guard/`
|
||||
@@ -17,7 +15,6 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal
|
||||
- `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.
|
||||
- v1.2 adds trajectory evidence between vision and engine: `TrajectoryTracker` emits source-zone-to-trash evidence; `BatchEngine` consumes the backend-neutral `disposal_evidence` contract.
|
||||
- Frontend package: `web/`
|
||||
- Vite + vanilla JavaScript management console.
|
||||
- Default web port `23000`.
|
||||
@@ -26,7 +23,6 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal
|
||||
- Runtime data:
|
||||
- Events JSONL default path `logs/events.jsonl`.
|
||||
- Diagnostics JSONL default path `logs/runtime_diagnostics.jsonl`.
|
||||
- v1.2 diagnostics include root-level `disposal_evidence` plus `diagnostics.trajectory`.
|
||||
- Deployment:
|
||||
- Root Python Docker image for API/runtime.
|
||||
- `web/Dockerfile` for static web console.
|
||||
@@ -49,42 +45,6 @@ The `v1.2 轨迹识别` batch adds source-zone trajectory evidence for disposal
|
||||
- Trash ROI:
|
||||
- Stored under `[trash] roi`.
|
||||
- Does not use a food zone number.
|
||||
- v1.2 trajectory settings:
|
||||
- `lighting_shift_guard_enabled`: freezes occupancy changes when many regions shift brightness in the same direction.
|
||||
- `lighting_shift_min_regions`, `lighting_shift_region_fraction`, `lighting_shift_mean_delta`: tune the global lighting/exposure guard.
|
||||
- `trajectory_enabled`: enables source-zone trajectory evidence.
|
||||
- `trajectory_window_seconds`: seconds after a zone clears where movement can confirm disposal.
|
||||
- `trajectory_sample_interval_seconds`: faster runtime delay while a candidate is active.
|
||||
- `trajectory_min_points`: minimum sampled motion points required before evidence can emit.
|
||||
- `trajectory_segmented_enabled`, `trajectory_segmented_min_points`: allow a source point plus trash-entry point to confirm disposal when the middle of the path is occluded.
|
||||
- `trajectory_min_confidence`: minimum confidence before evidence can close pending disposal.
|
||||
- `trajectory_motion_delta`: frame-difference threshold for trajectory motion points.
|
||||
- `trajectory_min_blob_area`: minimum connected motion area to keep as a point.
|
||||
- `trajectory_max_blob_area_fraction`: rejects overly broad frame motion as ambiguous.
|
||||
- `trajectory_trash_entry_margin`: margin for treating a track point as entering the trash ROI.
|
||||
- `trajectory_backend`: first valid value is `"motion"`.
|
||||
- `yolo_enabled`, `yolo_model_path`, `yolo_min_confidence`: reserved for a future trained model backend. Current v1.2 keeps YOLO disabled.
|
||||
|
||||
## v1.2 Runtime Flow
|
||||
|
||||
1. `RTSPFrameSource` captures a resized RGB frame.
|
||||
2. `ZoneOccupancyDetector` updates per-zone binary occupancy and generic trash-motion count from calibrated ROIs.
|
||||
3. `TrajectoryTracker` caches recent source-region motion while a zone is occupied, watches zones that just cleared, follows lightweight motion points toward the trash ROI, and emits source-specific `DisposalEvidence` when confidence passes the configured threshold. If the middle of the path is occluded, a segmented source-to-trash track can still emit evidence.
|
||||
4. `BatchEngine` processes `Observation(zone_counts, trash_deposit_count, disposal_evidence)`.
|
||||
5. For pending disposal, matching `disposal_evidence.source_zone_id` confirms `batch_discarded` before generic FIFO `trash_deposit_count` fallback is used.
|
||||
6. Runtime writes events to `logs/events.jsonl` and diagnostics to `logs/runtime_diagnostics.jsonl`.
|
||||
|
||||
The current tracker is a motion backend only. A later trained YOLO detector should plug in as another backend that enriches or replaces the evidence producer while preserving the same `disposal_evidence` contract consumed by the engine.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
- Runtime diagnostics JSONL records one item per runtime iteration.
|
||||
- Root `disposal_evidence` is the exact evidence list passed into the engine for that iteration.
|
||||
- `diagnostics.zones` contains occupancy metrics used to derive `zone_counts`.
|
||||
- `diagnostics.lighting_shift` reports whether global brightness drift suppressed occupancy transitions.
|
||||
- `diagnostics.trash` contains generic trash-motion metrics and cooldown state.
|
||||
- `diagnostics.trajectory` contains v1.2 candidate counts, emitted evidence count, motion point count, source-seeded and segmented-track flags, and per-candidate emitted/rejected/expired records.
|
||||
- Capture failures still keep the v1.2 schema with root `disposal_evidence: []` and `diagnostics.trajectory.reason = "frame_capture_failed"`.
|
||||
|
||||
## Event Model
|
||||
|
||||
@@ -99,8 +59,6 @@ The current tracker is a motion backend only. A later trained YOLO detector shou
|
||||
|
||||
Events should include `zone_id`, `zone_index`, `zone_label`, `started_at`, `dwell_seconds`, and relevant alarm/removal/deadline timestamps when available.
|
||||
|
||||
In v1.2, `batch_discarded` can be triggered by zone-scoped `disposal_evidence` before falling back to generic `trash_deposit_count`. Evidence must match the pending batch's `source_zone_id`.
|
||||
|
||||
## Runbook
|
||||
|
||||
- Python tests:
|
||||
@@ -117,18 +75,10 @@ In v1.2, `batch_discarded` can be triggered by zone-scoped `disposal_evidence` b
|
||||
- `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`
|
||||
- v1.2 operating notes:
|
||||
- Keep `trajectory_backend = "motion"` and `yolo_enabled = false` unless a trained YOLO backend has been explicitly deployed.
|
||||
- Confirm `logs/runtime_diagnostics.jsonl` contains top-level `disposal_evidence` and `diagnostics.trajectory` before judging trajectory behavior from events alone.
|
||||
- When `TrajectoryTracker` has active candidates, runtime sampling uses `trajectory_sample_interval_seconds`; this can temporarily be faster than the normal `sample_interval_seconds`.
|
||||
- On remote deployments, preserve the remote `config/example.toml` calibration and stream settings when syncing code.
|
||||
|
||||
## Known Risks
|
||||
|
||||
- The current vision detector is heuristic and reports binary occupancy, not item counts.
|
||||
- The lighting-shift guard suppresses multi-zone brightness/exposure jumps; if operators intentionally fill most zones at once under a large lighting change, diagnostics should be reviewed before treating that interval as clean data.
|
||||
- v1.2 motion tracking improves disposal matching but can still miss movement if the hand/object path is occluded, too broad, too small, or sampled too sparsely.
|
||||
- YOLO config fields are present for compatibility, but no trained YOLO model is part of the current runtime.
|
||||
- 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.
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# v1.2 Trajectory Recognition 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 source-zone trajectory evidence so alarmed items moved to the trash are discarded by their actual source zone, while keeping YOLO as a future optional backend.
|
||||
|
||||
**Architecture:** Extend `Observation` with backend-neutral `disposal_evidence`, make `BatchEngine` consume matching evidence before generic trash fallback, then add a no-dependency motion trajectory tracker in the vision layer. Runtime writes diagnostics and uses faster sampling only while trajectory candidates are active.
|
||||
|
||||
**Tech Stack:** Python 3.11+ standard library, existing `Frame` RGB bytes, `unittest`, Vite/Node tests only if frontend files change.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data Contract And Engine Evidence Handling
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cold_display_guard/models.py`
|
||||
- Modify: `src/cold_display_guard/engine.py`
|
||||
- Test: `tests/test_engine.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add tests for:
|
||||
- `Observation.from_dict()` normalizes `disposal_evidence`.
|
||||
- Matching evidence discards the pending batch for the same source zone.
|
||||
- Evidence for zone 4 does not discard pending zone 1.
|
||||
- Same-observation removal plus evidence closes the newly pending batch.
|
||||
- Low-confidence evidence is ignored.
|
||||
|
||||
- [ ] **Step 2: Run RED tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_engine -v`
|
||||
|
||||
Expected: FAIL because `Observation` has no `disposal_evidence` and engine ignores evidence.
|
||||
|
||||
- [ ] **Step 3: Implement minimal contract and engine logic**
|
||||
|
||||
Add a `DisposalEvidence` dataclass and `Observation.disposal_evidence`. In `BatchEngine.process()`, apply evidence to matching `pending_disposal` before generic trash deposits and again after zone transitions for same-frame removals.
|
||||
|
||||
- [ ] **Step 4: Run GREEN tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_engine -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit phase**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/cold_display_guard/models.py src/cold_display_guard/engine.py tests/test_engine.py
|
||||
git commit -m "feat: add disposal evidence engine handling"
|
||||
```
|
||||
|
||||
### Task 2: Lightweight Motion Trajectory Backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cold_display_guard/vision.py`
|
||||
- Test: `tests/test_vision.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add synthetic RGB-frame tests for:
|
||||
- Motion from source zone to trash ROI emits evidence.
|
||||
- Motion that starts away from source zone is rejected.
|
||||
- Motion that never reaches trash ROI is rejected.
|
||||
- One-frame reflection flash is rejected.
|
||||
- Multiple active candidates do not cross-close each other.
|
||||
|
||||
- [ ] **Step 2: Run RED tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision -v`
|
||||
|
||||
Expected: FAIL because no trajectory tracker exists.
|
||||
|
||||
- [ ] **Step 3: Implement minimal motion tracker**
|
||||
|
||||
Add trajectory settings, candidate state, motion blob extraction from frame deltas, confidence scoring, and diagnostics. Keep the implementation standard-library only.
|
||||
|
||||
- [ ] **Step 4: Run GREEN tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit phase**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/cold_display_guard/vision.py tests/test_vision.py
|
||||
git commit -m "feat: add lightweight trajectory tracking"
|
||||
```
|
||||
|
||||
### Task 3: Runtime Configuration And Diagnostics Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/cold_display_guard/main.py`
|
||||
- Modify: `src/cold_display_guard/vision.py`
|
||||
- Modify: `config/example.toml`
|
||||
- Test: `tests/test_vision.py`
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add tests that verify runtime defaults include trajectory settings with YOLO disabled and diagnostics rows include emitted evidence when present.
|
||||
|
||||
- [ ] **Step 2: Run RED tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision tests.test_main -v`
|
||||
|
||||
Expected: FAIL because runtime does not pass evidence into `Observation` or expose trajectory sampling state.
|
||||
|
||||
- [ ] **Step 3: Implement runtime integration**
|
||||
|
||||
Return `disposal_evidence` from vision observation, write it to diagnostics, pass it to `Observation`, and use `trajectory_sample_interval_seconds` while candidates are active.
|
||||
|
||||
- [ ] **Step 4: Run GREEN tests**
|
||||
|
||||
Run: `PYTHONPATH=src python3 -m unittest tests.test_vision tests.test_main -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit phase**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add src/cold_display_guard/main.py src/cold_display_guard/vision.py config/example.toml tests/test_vision.py tests/test_main.py
|
||||
git commit -m "feat: integrate trajectory runtime diagnostics"
|
||||
```
|
||||
|
||||
### Task 4: Documentation, Full Verification, And Deployment Prep
|
||||
|
||||
**Files:**
|
||||
- Modify: `README_zh.md`
|
||||
- Modify: `docs/project.md`
|
||||
- Modify: `task_plan.md`
|
||||
- Modify: `findings.md`
|
||||
- Modify: `progress.md`
|
||||
- Modify: `memories.md`
|
||||
|
||||
- [ ] **Step 1: Update docs**
|
||||
|
||||
Document v1.2 trajectory settings, evidence semantics, tests, and remote deployment notes.
|
||||
|
||||
- [ ] **Step 2: Run full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
||||
node --test web/test/zone-state.test.js
|
||||
cd web && pnpm build
|
||||
```
|
||||
|
||||
Expected: PASS for all commands. If frontend files did not change, frontend commands still provide regression coverage for the management console.
|
||||
|
||||
- [ ] **Step 3: Commit phase**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add README_zh.md docs/project.md task_plan.md findings.md progress.md memories.md docs/superpowers/plans/2026-05-29-v1.2-trajectory-recognition.md
|
||||
git commit -m "docs: document v1.2 trajectory recognition"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Prepare remote deploy**
|
||||
|
||||
Use rsync excluding `config/example.toml`, rebuild runtime/API, and verify Docker services. Record the exact commands and results in `progress.md`.
|
||||
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
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
# Lightweight Trajectory Tracking With YOLO-Ready Evidence
|
||||
|
||||
Date: 2026-05-29
|
||||
Branch: `lightweight-trajectory-tracking`
|
||||
|
||||
## Summary
|
||||
|
||||
The runtime currently confirms disposal by matching a zone becoming empty with generic trash-bin motion. That produces false matches when several zones change close together, when the trash ROI moves for an unrelated reason, or when reflection changes look like motion.
|
||||
|
||||
This design adds a trajectory evidence layer. Version 1 uses lightweight motion tracking to infer "source zone -> trash ROI" during a short window after a zone becomes empty. Version 2 can add a trained YOLO backend later without changing the event engine contract.
|
||||
|
||||
The first implementation must not require YOLO, PyTorch, ONNX Runtime, or OpenVINO. It must keep the current ROI occupancy timer and add a stronger disposal confirmation path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Confirm disposal by source zone, not by FIFO matching alone.
|
||||
- Reduce cases where zone 1 or zone 4 removal is incorrectly matched to another zone.
|
||||
- Suppress reflection-only and trash-bin-only movement from confirming disposal.
|
||||
- Keep CPU load low by activating trajectory analysis only after a zone becomes empty.
|
||||
- Preserve a stable data contract that a future trained YOLO model can enrich.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not convert the whole project to YOLO in the first trajectory version.
|
||||
- Do not train or bundle a model in this branch.
|
||||
- Do not replace ROI occupancy timing; it remains the authority for zone occupied/empty state.
|
||||
- Do not require visual access inside the trash bin. Confirmation is based on motion entering the trash mouth ROI.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
`main.py` captures one RTSP frame per sample interval with `ffmpeg`, passes it to `ZoneOccupancyDetector.observe()`, creates an `Observation`, and sends it to `BatchEngine.process()`.
|
||||
|
||||
`vision.py` currently outputs:
|
||||
|
||||
- `zone_counts`: stable occupied/empty state per configured zone.
|
||||
- `trash_deposit_count`: count of generic trash ROI motion events.
|
||||
- `diagnostics`: metrics for zones and trash motion.
|
||||
|
||||
`engine.py` currently consumes:
|
||||
|
||||
- `Observation.zone_counts`
|
||||
- `Observation.trash_deposit_count`
|
||||
|
||||
When a timed-out batch is removed, it becomes pending disposal. A later trash motion can close pending batches, using FIFO order when source-zone evidence is missing.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
Add a trajectory evidence path between vision and engine:
|
||||
|
||||
1. Zone occupancy still runs first.
|
||||
2. When a zone transitions from occupied to empty, vision opens a short tracking window for that zone.
|
||||
3. While any tracking window is active, the runtime temporarily shortens the capture delay so movement is sampled densely enough for a path.
|
||||
4. During the window, a lightweight motion backend tracks moving blobs across the source zone, the path/corridor, and the trash mouth ROI.
|
||||
5. If the path is coherent, vision emits a zone-scoped disposal evidence item.
|
||||
6. The engine applies zone-scoped disposal evidence before using generic trash motion fallback.
|
||||
|
||||
The engine should depend on a neutral evidence format, not on YOLO or any specific tracking backend.
|
||||
|
||||
## Data Contract
|
||||
|
||||
Add `disposal_evidence` to `Observation`.
|
||||
|
||||
Example V1 evidence:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_zone_id": "1",
|
||||
"target": "trash",
|
||||
"confidence": 0.86,
|
||||
"method": "motion",
|
||||
"started_at": "2026-05-29T14:03:20+08:00",
|
||||
"ended_at": "2026-05-29T14:03:25+08:00",
|
||||
"track_points": [[152, 210], [181, 219], [226, 235], [275, 252]],
|
||||
"item_class": null,
|
||||
"detector_score": null
|
||||
}
|
||||
```
|
||||
|
||||
Example later YOLO-enriched evidence:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_zone_id": "1",
|
||||
"target": "trash",
|
||||
"confidence": 0.94,
|
||||
"method": "motion+yolo",
|
||||
"started_at": "2026-05-29T14:03:20+08:00",
|
||||
"ended_at": "2026-05-29T14:03:25+08:00",
|
||||
"track_points": [[152, 210], [181, 219], [226, 235], [275, 252]],
|
||||
"item_class": "trained_product_a",
|
||||
"detector_score": 0.91
|
||||
}
|
||||
```
|
||||
|
||||
`trash_deposit_count` remains for compatibility and fallback, but zone-scoped `disposal_evidence` takes priority.
|
||||
|
||||
## Components
|
||||
|
||||
### `TrajectoryTracker`
|
||||
|
||||
Owns active tracking windows. It receives current frame, timestamp, zone counts, region polygons, and trash ROI.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Detect occupied-to-empty transitions.
|
||||
- Start a per-zone candidate window.
|
||||
- Keep recent motion observations for each active candidate.
|
||||
- Decide whether a candidate has enough evidence to emit disposal evidence.
|
||||
- Expire weak candidates without closing a batch.
|
||||
- Report whether any candidate is active so `main.py` can use the faster trajectory sample interval.
|
||||
|
||||
### `MotionTrajectoryBackend`
|
||||
|
||||
The default V1 backend. It uses frame-to-frame differences and connected motion regions.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Compute motion mask from the current and previous frame.
|
||||
- Filter out tiny, static, and reflection-like changes.
|
||||
- Extract moving blob centroids and bounding boxes.
|
||||
- Associate centroids over time into a short track.
|
||||
- Return backend-neutral track observations.
|
||||
|
||||
The backend must work without external model dependencies.
|
||||
|
||||
### `YoloDetectionBackend`
|
||||
|
||||
An optional future backend. It is not implemented in V1 but the interface is reserved.
|
||||
|
||||
Responsibilities when enabled later:
|
||||
|
||||
- Run only during active tracking windows or on configured path crops.
|
||||
- Detect trained product classes and optionally hands/person keypoints.
|
||||
- Attach `item_class`, `detector_score`, and bounding boxes to the same evidence contract.
|
||||
- Never bypass trajectory validation. YOLO detections enrich confidence; they do not directly close events.
|
||||
|
||||
### `EvidenceFusion`
|
||||
|
||||
Combines backend output into final evidence.
|
||||
|
||||
V1 uses motion-only signals:
|
||||
|
||||
- Origin score: first meaningful motion is near or inside the source zone.
|
||||
- Direction score: track generally moves from source zone toward trash ROI.
|
||||
- Target score: final track points intersect or approach the trash mouth ROI.
|
||||
- Stability score: track persists across enough frames and is not a one-frame flash.
|
||||
|
||||
V2 can add YOLO class and detector confidence into the same confidence calculation.
|
||||
|
||||
### `BatchEngine`
|
||||
|
||||
The engine should process evidence in this order:
|
||||
|
||||
1. Expire old pending disposal records.
|
||||
2. Apply zone-scoped `disposal_evidence` to matching pending batches first.
|
||||
3. Process zone transitions.
|
||||
4. Apply any evidence created in the same observation to newly pending batches.
|
||||
5. Use remaining generic `trash_deposit_count` as fallback for older behavior.
|
||||
|
||||
Zone-scoped evidence should only discard the pending batch from `source_zone_id`. It must not close a different zone when the source zone has no pending disposal.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
1. A zone is occupied long enough to create an active batch.
|
||||
2. The batch reaches the dwell alarm threshold and emits `time_alarm`.
|
||||
3. The item is removed from the zone.
|
||||
4. Occupancy confirms the zone is empty, and the tracker opens or continues a short candidate window for that zone.
|
||||
5. The engine emits `batch_pending_disposal` for that zone.
|
||||
6. While the candidate is active, the runtime samples faster than the normal dwell timer interval.
|
||||
7. Motion backend observes a track from source zone toward trash ROI.
|
||||
8. If the track enters the trash mouth ROI with enough confidence, `disposal_evidence` is emitted.
|
||||
9. The engine emits `batch_discarded` for that same zone. If evidence is emitted in the same observation that created pending disposal, the engine applies it after processing the zone-empty transition.
|
||||
10. If no evidence arrives before the pending deadline, the current warning escalation behavior remains.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add runtime settings with conservative defaults:
|
||||
|
||||
```toml
|
||||
[runtime]
|
||||
trajectory_enabled = true
|
||||
trajectory_window_seconds = 8
|
||||
trajectory_sample_interval_seconds = 1.0
|
||||
trajectory_min_points = 3
|
||||
trajectory_min_confidence = 0.72
|
||||
trajectory_motion_delta = 20.0
|
||||
trajectory_min_blob_area = 12
|
||||
trajectory_max_blob_area_fraction = 0.35
|
||||
trajectory_trash_entry_margin = 0.04
|
||||
trajectory_backend = "motion"
|
||||
yolo_enabled = false
|
||||
yolo_model_path = ""
|
||||
yolo_min_confidence = 0.65
|
||||
```
|
||||
|
||||
`yolo_enabled = false` is the only valid first implementation mode. The config keys are included so deployment files and UI can evolve without changing the observation contract.
|
||||
|
||||
`trajectory_sample_interval_seconds` applies only while at least one trajectory candidate is active. Normal monitoring keeps using the existing `sample_interval_seconds`.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Append trajectory diagnostics to each runtime diagnostics row:
|
||||
|
||||
```json
|
||||
{
|
||||
"trajectory": {
|
||||
"active_candidates": ["1"],
|
||||
"emitted_evidence": [
|
||||
{
|
||||
"source_zone_id": "1",
|
||||
"confidence": 0.86,
|
||||
"method": "motion"
|
||||
}
|
||||
],
|
||||
"expired_candidates": [],
|
||||
"rejected_candidates": [
|
||||
{
|
||||
"source_zone_id": "4",
|
||||
"reason": "target_not_reached"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Diagnostics should explain why a candidate was accepted, expired, or rejected. This is required for tuning the live camera.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If tracking cannot run because there is no previous frame, no evidence is emitted.
|
||||
- If trash ROI is not configured, trajectory evidence is disabled and current generic behavior remains.
|
||||
- If faster sampling cannot keep up with RTSP capture time, runtime should continue at the achievable rate and record capture timing in diagnostics.
|
||||
- If multiple zones become empty at once, keep independent candidates. A track can confirm only one source zone unless future YOLO tracking explicitly supports multiple objects.
|
||||
- If evidence confidence is below threshold, do not close pending disposal.
|
||||
- If YOLO is enabled later but the model fails to load, runtime should fall back to motion-only tracking and record a diagnostic error.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `Observation.from_dict()` accepts and normalizes `disposal_evidence`.
|
||||
- Engine discards a pending batch from the matching source zone when evidence arrives.
|
||||
- Engine does not discard zone 1 when evidence says source zone 4.
|
||||
- Same-observation zone removal plus disposal evidence closes the newly pending batch.
|
||||
- Generic `trash_deposit_count` still works as fallback.
|
||||
- Low-confidence evidence is ignored.
|
||||
|
||||
Vision tests:
|
||||
|
||||
- Motion track from zone polygon to trash ROI emits evidence.
|
||||
- Motion that starts away from the source zone is rejected.
|
||||
- Motion that never reaches trash ROI is rejected.
|
||||
- One-frame reflection flash is rejected.
|
||||
- Multiple active candidates do not cross-close each other.
|
||||
|
||||
Runtime tests:
|
||||
|
||||
- Diagnostics include trajectory status.
|
||||
- Config defaults load with trajectory enabled and YOLO disabled.
|
||||
- Existing tests for zone occupancy, trash motion, restore state, API summary, and web zone rendering keep passing.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Implement the data contract and engine evidence handling behind config.
|
||||
2. Add motion trajectory backend and diagnostics.
|
||||
3. Keep generic trash motion fallback enabled during rollout.
|
||||
4. Deploy to the remote runtime and observe diagnostics for zones 1, 2, 4, 5, 6, and trash ROI.
|
||||
5. Tune thresholds from live diagnostics.
|
||||
6. Later, add YOLO backend as a separate implementation that feeds the same evidence contract.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Removing an alarmed item from zone 1 and moving it visibly to the trash mouth closes zone 1, not another zone.
|
||||
- Removing alarmed items from multiple zones close together does not rely on FIFO when trajectory evidence identifies the source zone.
|
||||
- Motion inside trash ROI alone does not confirm disposal if no source-zone trajectory exists.
|
||||
- Reflection-only changes do not emit disposal evidence.
|
||||
- The runtime works without YOLO dependencies installed.
|
||||
- The future YOLO path can be added by implementing the reserved backend without changing `BatchEngine` event semantics.
|
||||
@@ -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`
|
||||
31
findings.md
31
findings.md
@@ -52,37 +52,6 @@
|
||||
|
||||
Use `阶段 x` only as a workflow stage inside the same `v1.1 优化改造` batch.
|
||||
|
||||
## v1.2 轨迹识别 Findings
|
||||
|
||||
## Architecture
|
||||
|
||||
- `main.py` 当前每轮从 RTSP 截一帧,调用 `ZoneOccupancyDetector.observe()` 得到 `zone_counts`、`trash_deposit_count`、`diagnostics`,再构造 `Observation` 给 `BatchEngine.process()`。
|
||||
- `vision.py` 当前只有区域占用和垃圾桶动作计数,没有从“来源区域到垃圾桶”的轨迹证据。
|
||||
- `engine.py` 当前先处理 pending 过期和旧 trash deposit,再处理区域转移,最后对同帧剩余 trash deposit 做兜底;这为同帧 evidence 处理提供了插入点。
|
||||
- `models.py` 的 `Observation` 是最适合扩展统一 evidence contract 的位置。
|
||||
- v1.2 设计规格已保存并提交:`docs/superpowers/specs/2026-05-29-lightweight-trajectory-yolo-ready-design.md`,commit `ac6d368`。
|
||||
|
||||
## Constraints
|
||||
|
||||
- 第一版必须在无 YOLO 依赖下运行。
|
||||
- 轨迹检测只应在区域变空后的短窗口活跃,避免持续 CPU 压力。
|
||||
- 垃圾桶内部不可见;确认点是进入垃圾桶口 ROI,不是看到桶内物品。
|
||||
- 不能让一个来源区域的 evidence 关闭另一个区域的 pending batch。
|
||||
- 远端部署必须继续排除 `config/example.toml`,以保留 RTSP 和现场标定。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 新字段命名为 `disposal_evidence`,元素使用 `source_zone_id`、`target`、`confidence`、`method`、`track_points`、可选 `item_class`、`detector_score`。
|
||||
- `trash_deposit_count` 保留兼容和兜底,但 engine 先使用 zone-scoped evidence。
|
||||
- 轨迹诊断必须记录 active、emitted、rejected、expired,方便现场调参。
|
||||
- 后续 YOLO 模型只作为 evidence 增强输入,不能修改 `BatchEngine` 的业务语义。
|
||||
|
||||
## Final Review Findings
|
||||
|
||||
- Evidence and generic trash fallback must share the same count budget: one matched `disposal_evidence` consumes one observed trash deposit signal when `trash_deposit_count` is also present, but extra trash deposits must still fall back to pending batches.
|
||||
- A trajectory candidate must not append source-zone-external motion before source motion has been seen. If outside motion appears first and is later followed by source noise, the candidate is rejected with `motion_started_outside_source` instead of producing evidence.
|
||||
- Runtime diagnostics on the deployed host should be checked for schema only, not by printing config, because the remote config may contain RTSP credentials and calibration.
|
||||
|
||||
## 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.
|
||||
|
||||
42
memories.md
42
memories.md
@@ -1,42 +0,0 @@
|
||||
# Memories
|
||||
|
||||
## User and Workflow
|
||||
|
||||
- User wants Chinese responses.
|
||||
- Project path for subagent headers is `/Users/yoilun/Code/cold_display_guard`.
|
||||
- Current workflow batch is `v1.2 轨迹识别`.
|
||||
- User requested following `/Users/yoilun/Code/goal-subagents-workflow-prompt.md`.
|
||||
- Every subagent task must begin with:
|
||||
|
||||
```text
|
||||
[项目: /Users/yoilun/Code/cold_display_guard]
|
||||
[工作流批次: v1.2 轨迹识别]
|
||||
[阶段: 阶段 x]
|
||||
[角色: 对应智能体角色]
|
||||
```
|
||||
|
||||
## Technical Direction
|
||||
|
||||
- First implement lightweight motion trajectory detection without YOLO dependencies.
|
||||
- Preserve a stable `disposal_evidence` contract for a future trained YOLO product detector.
|
||||
- Keep ROI occupancy timing as the source of zone occupied/empty state.
|
||||
- Use trajectory evidence before generic trash-motion FIFO fallback.
|
||||
- Current runtime writes top-level `disposal_evidence` and nested `diagnostics.trajectory` into runtime diagnostics JSONL.
|
||||
|
||||
## v1.2 Completed Facts
|
||||
|
||||
- Stage 1 established the backend contract: `Observation.disposal_evidence` normalizes backend-neutral disposal evidence, and the engine can discard a pending batch only when evidence targets `trash`, meets confidence, and matches the pending `source_zone_id`.
|
||||
- Stage 2 added the lightweight motion trajectory runtime path: ROI occupancy still drives occupied/empty state, `TrajectoryTracker` emits source-zone-to-trash evidence, and generic trash-motion count remains as a fallback.
|
||||
- Stage 3 added diagnostics and tests for runtime evidence propagation, trajectory sampling interval behavior, capture-failure schema, and trajectory/yolo runtime config parsing.
|
||||
- Final review fixes: matched evidence now only subtracts the trash fallback budget by the number of batches it actually closed, and trajectory candidates reject outside-before-source motion with `motion_started_outside_source`.
|
||||
- 2026-06-01 false events across zones 1-7 were caused by a global brightness/exposure drop around 04:55 and recovery around 08:16; the fix is a lighting-shift guard that freezes occupancy transitions when many regions shift brightness in the same direction.
|
||||
- 2026-06-01 trajectory update allows segmented source-to-trash tracks: after a source-zone motion point is seen, the middle of the path may be occluded, and a later trash-entry point can still emit `disposal_evidence` when direction/confidence pass.
|
||||
- 2026-06-01 follow-up fixed empty-confirmation lag: `TrajectoryTracker` now caches recent source-region motion while a zone is still occupied and seeds the disposal candidate when the stable zone count finally clears.
|
||||
- Current v1.2 does not use YOLO. `yolo_enabled`, `yolo_model_path`, and `yolo_min_confidence` are reserved for a future trained model backend that should keep emitting the same `disposal_evidence` shape.
|
||||
|
||||
## Remote Deployment Notes
|
||||
|
||||
- Remote deployment target is `xiaozheng@192.168.5.206:/home/xiaozheng/cold_display_guard`.
|
||||
- Preserve the remote `config/example.toml`; it may contain camera, calibration, threshold, and deployment-specific runtime settings that must not be overwritten blindly.
|
||||
- When syncing code remotely, verify that runtime diagnostics still show top-level `disposal_evidence` and `diagnostics.trajectory` before evaluating v1.2 trajectory behavior from `logs/events.jsonl`.
|
||||
- The latest v1.2 deployment was verified with `cold-display-guard-runtime` and `cold-display-guard-api` up, API health `status=ok`, and diagnostics schema showing `has_disposal_evidence=True` plus `has_trajectory=True`.
|
||||
182
progress.md
182
progress.md
@@ -183,185 +183,3 @@
|
||||
| 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 |
|
||||
|
||||
## 2026-05-29 v1.2 轨迹识别
|
||||
|
||||
### Session Log
|
||||
|
||||
| Time | Phase | Actor | Action | Result |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2026-05-29 | Setup | Main Agent | Created Goal for `v1.2 轨迹识别` using `/Users/yoilun/Code/cold_display_guard` as the real project path | Active goal tracks lightweight trajectory detection plus YOLO-ready evidence contract |
|
||||
| 2026-05-29 | Setup | Main Agent | Read `/Users/yoilun/Code/goal-subagents-workflow-prompt.md` | Workflow requires task files, stage-based coding/testing agents, bug loop limit, and standard subagent context header |
|
||||
| 2026-05-29 | Setup | Main Agent | Read existing `task_plan.md`, `findings.md`, `progress.md`, and `docs/project.md` | v1.1 state is complete; v1.2 plan can start on branch `lightweight-trajectory-tracking` |
|
||||
| 2026-05-29 | Phase 1 | Main Agent | Marked Phase 1 as `in_progress` | Preparing coding agent for data contract and engine evidence handling |
|
||||
| 2026-05-29 | Phase 1 | Coding Agent | Implemented initial `disposal_evidence` contract and engine handling | Target engine tests and full Python tests passed in coding agent run, but testing agent found review issues |
|
||||
| 2026-05-29 | Phase 1 | Testing Agent | Reviewed phase 1 implementation | Verdict fail: evidence/count double consumption, missing target validation, null classifier fields coercion |
|
||||
| 2026-05-29 | Phase 1 | Coding Agent | Fixed testing-agent findings | Added target validation, nullable optional fields, and evidence/count double-consume guard |
|
||||
| 2026-05-29 | Phase 1 | Testing Agent | Re-tested phase 1 fixes | Verdict pass; no bugs found |
|
||||
| 2026-05-29 | Phase 1 | Main Agent | Ran local verification | `tests.test_engine` passed with 24 tests; full Python suite passed with 55 tests |
|
||||
| 2026-05-29 | Phase 2 | Main Agent | Marked Phase 2 as `in_progress` | Preparing fresh coding/testing agents for lightweight motion trajectory detection |
|
||||
| 2026-05-29 | Phase 2 | Coding Agent | Implemented initial lightweight `TrajectoryTracker` | Target vision tests passed locally, but testing agent found multi-candidate and source-margin risks |
|
||||
| 2026-05-29 | Phase 2 | Testing Agent | Reviewed initial trajectory tracker | Verdict fail: single blob can confirm multiple candidates, source margin false positive, diagnostics lack per-candidate reasons |
|
||||
| 2026-05-29 | Phase 2 | Coding Agent | Fixed trajectory tracker findings | Added blob consumption, strict source polygon origin, and per-candidate diagnostics |
|
||||
| 2026-05-29 | Phase 2 | Testing Agent | Re-tested phase 2 fixes | Verdict pass; no bugs found |
|
||||
| 2026-05-29 | Phase 2 | Main Agent | Ran local verification | `tests.test_vision` passed with 20 tests; full Python suite passed with 64 tests; dependency scan had no model/heavy vision matches |
|
||||
| 2026-05-29 | Phase 3 | Main Agent | Marked Phase 3 as `in_progress` | Preparing fresh coding/testing agents for runtime integration |
|
||||
| 2026-05-29 | Phase 3 | Coding Agent | Implemented initial runtime integration | Target main/vision tests and full Python tests passed in coding agent run |
|
||||
| 2026-05-29 | Phase 3 | Testing Agent | Reviewed runtime integration | Verdict pass with non-blocking concerns: capture-error diagnostics schema, `create=True` patch robustness, broad helper type |
|
||||
| 2026-05-29 | Phase 3 | Coding Agent | Fixed runtime integration review concerns | Error diagnostics keep trajectory schema; tests no longer use `create=True`; evidence payload helper type narrowed |
|
||||
| 2026-05-29 | Phase 3 | Testing Agent | Re-tested runtime integration concerns | Verdict pass; no new issues |
|
||||
| 2026-05-29 | Phase 3 | Main Agent | Ran local verification | `tests.test_main tests.test_vision` passed with 26 tests; full Python suite passed with 68 tests; dependency scan had no model/heavy vision matches |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Marked Phase 4 as `in_progress` | Preparing documentation, final verification, remote deployment, and final review |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Synced v1.2 code to `xiaozheng@192.168.5.206` | `rsync` completed while excluding remote `config/example.toml` |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Tried remote config patch with inline heredoc | Failed with shell quoting `SyntaxError`; switching to scp temporary patch script |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Patched remote runtime config via uploaded Python script | Added missing trajectory/yolo runtime keys without printing RTSP config |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Rebuilt and restarted remote runtime/API containers | `cold-display-guard-runtime` and `cold-display-guard-api` recreated and started |
|
||||
| 2026-05-29 | Phase 4 | Final Code Review Agent | Reviewed full v1.2 implementation | Verdict fail: extra trash fallback was suppressed, tracker could seed from outside-source motion, and docs named a non-existent backend |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Fixed final review findings | Added regression tests, adjusted trash fallback budgeting, rejected outside-before-source trajectories, and renamed docs to `TrajectoryTracker` |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Re-ran local full verification | Python 70 tests passed; frontend 22 tests passed; Vite build passed; no heavy model dependency matches |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Re-synced and redeployed fix to `xiaozheng@192.168.5.206` | Runtime/API rebuilt and restarted from the fixed code |
|
||||
| 2026-05-29 | Phase 4 | Main Agent | Verified remote runtime after redeploy | Containers are up, API health returns `status=ok`, diagnostics contain `disposal_evidence` and `diagnostics.trajectory` |
|
||||
|
||||
### Test Results
|
||||
|
||||
| Time | Command | Result | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_engine -v` | pass | 24 engine tests passed after phase 1 evidence fixes |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 55 full Python tests passed after phase 1 |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_vision -v` | pass | 20 vision tests passed after phase 2 trajectory tracker |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 64 full Python tests passed after phase 2 |
|
||||
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest tests.test_main tests.test_vision -v` | pass | 26 runtime/vision tests passed after phase 3 |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 68 full Python tests passed after phase 3 |
|
||||
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
|
||||
| 2026-05-29 | `PYTHONPATH=src python3 -m unittest discover -s tests -v` | pass | 70 full Python tests passed after final review fixes |
|
||||
| 2026-05-29 | `node --test web/test/zone-state.test.js` | pass | 22 frontend model tests passed |
|
||||
| 2026-05-29 | `cd web && pnpm build` | pass | Vite production build passed |
|
||||
| 2026-05-29 | `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml` | pass | No matches; command exited 1 because no heavy vision/model dependency was found |
|
||||
| 2026-05-29 | `docker compose ... build cold-display-guard-runtime cold-display-guard-api && docker compose ... up -d --no-deps ...` on remote | pass | Runtime/API rebuilt and restarted after final fixes |
|
||||
| 2026-05-29 | `curl --max-time 5 http://192.168.5.206:19080/api/manage/health` | pass | `status=ok`, `runtime_status=running` |
|
||||
| 2026-05-29 | Remote diagnostics schema check script | pass | `has_disposal_evidence=True`, `has_trajectory=True` |
|
||||
|
||||
### Bug Loop
|
||||
|
||||
| Phase | Bug | Fix Attempt | Retest Result |
|
||||
| --- | --- | --- | --- |
|
||||
| Phase 1 | `disposal_evidence` and `trash_deposit_count` can double-consume the same disposal signal | Added regression test and suppress generic trash fallback when confirming source-specific evidence exists in the observation | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 1 | High-confidence evidence with non-trash target can close pending disposal | Added target whitelist for `trash` / `trash_bin` plus regression test | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 1 | `item_class: null` and `detector_score: null` lose null semantics | Changed optional evidence fields to preserve `None` plus regression test | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 2 | Single motion blob can confirm multiple active candidates | Added frame-local blob IDs and consume each sampled blob once per frame | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 2 | Source-zone margin can treat near-outside movement as source-origin movement | Source-origin check now requires strict source polygon containment | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 2 | Trajectory diagnostics only expose aggregate counts | Added `emitted`, `rejected`, and `expired` diagnostic lists with source, reason, point count, confidence, and direction score | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 3 | Capture-error diagnostics rows lack `disposal_evidence` and `diagnostics.trajectory` schema | Added regression test and wrote empty evidence plus trajectory error diagnostics on capture failure | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 3 | Runtime tests patch `TrajectoryTracker` with `create=True`, which can mask missing imports | Removed `create=True` and asserted fake tracker observe calls | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 3 | `disposal_evidence_payloads()` accepts `list[object]` but blindly calls `asdict()` | Narrowed helper signature to `list[DisposalEvidence]` | Resolved; testing agent and local full Python suite passed |
|
||||
| Phase 4 | Remote config patch inline heredoc lost the empty-string value for `yolo_model_path` | Switched to an scp-uploaded Python patch script instead of repeating inline quoting | Resolved; remote config patch reported 13 runtime keys patched |
|
||||
| Phase 4 | Source-specific evidence disabled all generic fallback trash deposits | Subtract only the count of evidence-discard events from `remaining_trash_deposits`; add regression test for evidence plus `trash_deposit_count=2` | Resolved; targeted regression and full Python suite passed |
|
||||
| Phase 4 | Tracker could keep an outside-source blob as the first point before source motion | Track outside-before-source contamination, reject later source noise with `motion_started_outside_source`, and never append those outside points | Resolved; targeted regression and full Python suite passed |
|
||||
| Phase 4 | `docs/project.md` referenced non-existent `MotionTrajectoryBackend` | Changed architecture docs to name the implemented `TrajectoryTracker` | Resolved; `rg` no longer finds the stale name in project docs/README/src/tests |
|
||||
|
||||
## 2026-05-29 Phase Completed: Phase 4 - Documentation, Verification, And Deployment
|
||||
|
||||
Status: complete
|
||||
|
||||
Files Changed:
|
||||
- `.gitignore`
|
||||
- `README_zh.md`
|
||||
- `docs/project.md`
|
||||
- `findings.md`
|
||||
- `memories.md`
|
||||
- `progress.md`
|
||||
- `task_plan.md`
|
||||
- `src/cold_display_guard/engine.py`
|
||||
- `src/cold_display_guard/vision.py`
|
||||
- `tests/test_engine.py`
|
||||
- `tests/test_vision.py`
|
||||
|
||||
Tests:
|
||||
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass, 70 tests
|
||||
- `node --test web/test/zone-state.test.js`: pass, 22 tests
|
||||
- `cd web && pnpm build`: pass
|
||||
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
|
||||
- Remote Docker rebuild/restart: pass
|
||||
- Remote API health: pass
|
||||
- Remote diagnostics schema check: pass
|
||||
|
||||
Notes:
|
||||
- Final review findings were fixed before redeploy.
|
||||
- Remote sync continued to exclude `config/example.toml` to preserve camera/calibration settings.
|
||||
- Remote diagnostics check intentionally printed only schema booleans and safe trajectory keys.
|
||||
|
||||
Risks:
|
||||
- v1.2 is still heuristic motion tracking. Live precision should be tuned from diagnostics, and future YOLO integration should continue using the same `disposal_evidence` contract.
|
||||
|
||||
## 2026-05-29 Phase Completed: Phase 3 - Runtime Integration
|
||||
|
||||
Status: complete
|
||||
|
||||
Files Changed:
|
||||
- `src/cold_display_guard/main.py`
|
||||
- `config/example.toml`
|
||||
- `tests/test_main.py`
|
||||
- `tests/test_vision.py`
|
||||
- `task_plan.md`
|
||||
- `progress.md`
|
||||
|
||||
Tests:
|
||||
- `PYTHONPATH=src python3 -m unittest tests.test_main tests.test_vision -v`: pass
|
||||
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
|
||||
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
|
||||
|
||||
Notes:
|
||||
- Runtime now passes trajectory `disposal_evidence` into `Observation`.
|
||||
- Diagnostics rows include top-level serialized `disposal_evidence` and nested `diagnostics.trajectory`.
|
||||
- Capture failure diagnostics keep the same trajectory/evidence schema.
|
||||
- Runtime sleeps at `trajectory_sample_interval_seconds` while trajectory candidates are active.
|
||||
|
||||
Risks:
|
||||
- No live-camera validation has run yet in this phase; deployment and remote runtime observation remain phase 4 work.
|
||||
|
||||
## 2026-05-29 Phase Completed: Phase 2 - Lightweight Motion Trajectory Backend
|
||||
|
||||
Status: complete
|
||||
|
||||
Files Changed:
|
||||
- `src/cold_display_guard/vision.py`
|
||||
- `tests/test_vision.py`
|
||||
- `task_plan.md`
|
||||
- `progress.md`
|
||||
|
||||
Tests:
|
||||
- `PYTHONPATH=src python3 -m unittest tests.test_vision -v`: pass
|
||||
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
|
||||
- `rg -n "ultralytics|torch|onnxruntime|openvino|opencv|cv2|numpy" src tests pyproject.toml`: pass, no matches
|
||||
|
||||
Notes:
|
||||
- `TrajectoryTracker` now emits `DisposalEvidence` with `target=trash` and `method=motion`.
|
||||
- Tracker uses frame-delta motion blobs, strict source-origin validation, target ROI validation, direction scoring, and per-candidate diagnostics.
|
||||
- Multiple candidates cannot reuse the same frame-local blob for confirmation.
|
||||
|
||||
Risks:
|
||||
- Tracker is implemented but not yet wired into `main.py`; phase 3 will integrate runtime observation, diagnostics, and faster active-candidate sampling.
|
||||
|
||||
## 2026-05-29 Phase Completed: Phase 1 - Data Contract And Engine Evidence Handling
|
||||
|
||||
Status: complete
|
||||
|
||||
Files Changed:
|
||||
- `src/cold_display_guard/models.py`
|
||||
- `src/cold_display_guard/engine.py`
|
||||
- `tests/test_engine.py`
|
||||
- `task_plan.md`
|
||||
- `progress.md`
|
||||
|
||||
Tests:
|
||||
- `PYTHONPATH=src python3 -m unittest tests.test_engine -v`: pass
|
||||
- `PYTHONPATH=src python3 -m unittest discover -s tests -v`: pass
|
||||
|
||||
Notes:
|
||||
- `Observation` now supports `disposal_evidence`.
|
||||
- `BatchEngine` applies source-zone evidence before generic trash fallback and again after same-frame zone transitions.
|
||||
- Evidence must meet confidence threshold and target the trash.
|
||||
|
||||
Risks:
|
||||
- Threshold is currently a phase-1 constant; later runtime config integration will make it configurable.
|
||||
|
||||
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"
|
||||
@@ -23,7 +23,9 @@ def load_settings(path: str | Path) -> EngineSettings:
|
||||
|
||||
return EngineSettings(
|
||||
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)),
|
||||
alarm_removal_seconds=int(thresholds.get("alarm_removal_seconds", 0)),
|
||||
trash_confirmation_seconds=int(thresholds.get("trash_confirmation_seconds", 120)),
|
||||
zone_ids=zone_ids,
|
||||
)
|
||||
@@ -135,7 +137,9 @@ def format_config_document(data: dict[str, Any]) -> str:
|
||||
|
||||
thresholds = data.get("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'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("")
|
||||
|
||||
@@ -192,6 +196,68 @@ def format_config_document(data: dict[str, Any]) -> str:
|
||||
lines.append("[event_sink]")
|
||||
lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"')
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from cold_display_guard.models import Batch, DisposalEvidence, EngineSettings, Observation
|
||||
|
||||
|
||||
DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD = 0.72
|
||||
TRASH_DISPOSAL_TARGETS = {"trash", "trash_bin"}
|
||||
from cold_display_guard.models import Batch, EngineSettings, Observation
|
||||
|
||||
|
||||
class BatchEngine:
|
||||
@@ -24,16 +20,8 @@ class BatchEngine:
|
||||
zone_counts = self._normalized_counts(observation.zone_counts)
|
||||
previous_zone_counts = dict(self._zone_counts)
|
||||
remaining_trash_deposits = observation.trash_deposit_count
|
||||
used_disposal_evidence: set[int] = set()
|
||||
|
||||
events.extend(self._expire_pending_disposal(observation.ts))
|
||||
evidence_events = self._apply_disposal_evidence(
|
||||
observation.ts,
|
||||
observation.disposal_evidence,
|
||||
used_disposal_evidence,
|
||||
)
|
||||
remaining_trash_deposits = max(0, remaining_trash_deposits - len(evidence_events))
|
||||
events.extend(evidence_events)
|
||||
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)
|
||||
@@ -46,7 +34,9 @@ class BatchEngine:
|
||||
if appeared_zones and self.pending_disposal:
|
||||
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():
|
||||
@@ -79,13 +69,6 @@ class BatchEngine:
|
||||
self._zone_counts[zone_id] = new_count
|
||||
|
||||
newly_pending_count = max(0, len(self.pending_disposal) - pending_count_before_zone_transitions)
|
||||
evidence_events = self._apply_disposal_evidence(
|
||||
observation.ts,
|
||||
observation.disposal_evidence,
|
||||
used_disposal_evidence,
|
||||
)
|
||||
remaining_trash_deposits = max(0, remaining_trash_deposits - len(evidence_events))
|
||||
events.extend(evidence_events)
|
||||
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)
|
||||
@@ -118,7 +101,14 @@ class BatchEngine:
|
||||
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_alarm"}:
|
||||
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
|
||||
@@ -150,9 +140,19 @@ class BatchEngine:
|
||||
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
|
||||
|
||||
@@ -181,10 +181,44 @@ class BatchEngine:
|
||||
self.pending_disposal.append(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"
|
||||
self.closed_batches.append(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():
|
||||
@@ -195,6 +229,8 @@ class BatchEngine:
|
||||
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",
|
||||
@@ -206,6 +242,30 @@ class BatchEngine:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
zone_id: str,
|
||||
@@ -258,39 +318,6 @@ class BatchEngine:
|
||||
deposit_count -= 1
|
||||
return events
|
||||
|
||||
def _apply_disposal_evidence(
|
||||
self,
|
||||
when: datetime,
|
||||
disposal_evidence: list[DisposalEvidence],
|
||||
used_evidence_indices: set[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
for index, evidence in enumerate(disposal_evidence):
|
||||
if index in used_evidence_indices:
|
||||
continue
|
||||
if not self._is_confirming_disposal_evidence(evidence):
|
||||
continue
|
||||
pending_index = self._pending_index_for_source_zone(evidence.source_zone_id)
|
||||
if pending_index is None:
|
||||
continue
|
||||
batch = self.pending_disposal.pop(pending_index)
|
||||
batch.state = "discarded"
|
||||
self.closed_batches.append(batch)
|
||||
used_evidence_indices.add(index)
|
||||
events.append(self._event("batch_discarded", when, batch, severity="info"))
|
||||
return events
|
||||
|
||||
def _is_confirming_disposal_evidence(self, evidence: DisposalEvidence) -> bool:
|
||||
if evidence.confidence < DISPOSAL_EVIDENCE_CONFIDENCE_THRESHOLD:
|
||||
return False
|
||||
return evidence.target.lower() in TRASH_DISPOSAL_TARGETS
|
||||
|
||||
def _pending_index_for_source_zone(self, source_zone_id: str) -> int | None:
|
||||
for index, batch in enumerate(self.pending_disposal):
|
||||
if batch.zone_id == source_zone_id:
|
||||
return index
|
||||
return None
|
||||
|
||||
def _expire_pending_disposal(self, when: datetime) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
still_pending: list[Batch] = []
|
||||
@@ -325,13 +352,21 @@ class BatchEngine:
|
||||
"state": batch.state,
|
||||
"started_at": batch.started_at.isoformat(),
|
||||
"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:
|
||||
payload["ended_at"] = batch.ended_at.isoformat()
|
||||
if batch.disposal_deadline is not None:
|
||||
@@ -342,9 +377,9 @@ class BatchEngine:
|
||||
return payload
|
||||
|
||||
def _event_severity(self, event_name: str) -> str:
|
||||
if event_name == "time_alarm":
|
||||
if event_name in {"time_alarm", "alarm_removal_timeout"}:
|
||||
return "alarm"
|
||||
if event_name in {"warning_escalated", "batch_pending_disposal"}:
|
||||
if event_name in {"warning_escalated", "batch_pending_disposal", "time_pre_warning"}:
|
||||
return "warning"
|
||||
if event_name.endswith("_violation"):
|
||||
return "warning"
|
||||
|
||||
@@ -3,23 +3,24 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
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.engine import BatchEngine
|
||||
from cold_display_guard.frame_source import FrameCaptureError, RTSPFrameSource
|
||||
from cold_display_guard.models import DisposalEvidence, Observation
|
||||
from cold_display_guard.models import Observation
|
||||
from cold_display_guard.vision import (
|
||||
RegionMetrics,
|
||||
TrajectoryTracker,
|
||||
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:
|
||||
@@ -53,6 +54,12 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
|
||||
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")))
|
||||
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")))
|
||||
sample_interval_seconds = max(0.1, float(runtime.get("sample_interval_seconds", 5.0)))
|
||||
frame_width = max(64, int(runtime.get("frame_width", 640)))
|
||||
@@ -67,8 +74,8 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
)
|
||||
vision_settings = load_runtime_vision_settings(config)
|
||||
detector = ZoneOccupancyDetector(regions, trash_region, vision_settings)
|
||||
trajectory_tracker = TrajectoryTracker(regions, trash_region, vision_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)
|
||||
@@ -77,10 +84,14 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
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)
|
||||
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)
|
||||
print(f"Cold Display Guard runtime started")
|
||||
print(f"Config: {resolved_config}")
|
||||
print(f"Events: {event_path}")
|
||||
print(f"Cases: {case_path}")
|
||||
print(f"Diagnostics: {diagnostics_path}")
|
||||
|
||||
iteration = 0
|
||||
@@ -90,15 +101,20 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
try:
|
||||
frame = source.capture()
|
||||
zone_counts, trash_deposit_count, diagnostics = detector.observe(frame, when)
|
||||
disposal_evidence, trajectory_diagnostics = trajectory_tracker.observe(frame, when, zone_counts)
|
||||
observation = Observation(
|
||||
ts=when,
|
||||
zone_counts=zone_counts,
|
||||
trash_deposit_count=trash_deposit_count,
|
||||
disposal_evidence=disposal_evidence,
|
||||
)
|
||||
observation = Observation(ts=when, zone_counts=zone_counts, trash_deposit_count=trash_deposit_count)
|
||||
events = engine.process(observation)
|
||||
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(
|
||||
diagnostics_path,
|
||||
[
|
||||
@@ -106,8 +122,7 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
"ts": when.isoformat(),
|
||||
"zone_counts": zone_counts,
|
||||
"trash_deposit_count": trash_deposit_count,
|
||||
"disposal_evidence": disposal_evidence_payloads(disposal_evidence),
|
||||
"diagnostics": {**diagnostics, "trajectory": trajectory_diagnostics},
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -116,32 +131,13 @@ def run(config_path: str | Path, once: bool = False, max_iterations: int = 0) ->
|
||||
except FrameCaptureError as exc:
|
||||
append_jsonl(
|
||||
diagnostics_path,
|
||||
[
|
||||
{
|
||||
"ts": when.isoformat(),
|
||||
"error": "frame_capture_failed",
|
||||
"message": str(exc),
|
||||
"disposal_evidence": [],
|
||||
"diagnostics": {
|
||||
"trajectory": {
|
||||
"disabled": True,
|
||||
"reason": "frame_capture_failed",
|
||||
"emitted_evidence": 0,
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
[{"ts": when.isoformat(), "error": "frame_capture_failed", "message": str(exc)}],
|
||||
)
|
||||
print(f"{when.isoformat()} frame capture failed: {exc}")
|
||||
|
||||
if once or (max_iterations > 0 and iteration >= max_iterations):
|
||||
break
|
||||
sleep_seconds = (
|
||||
vision_settings.trajectory_sample_interval_seconds
|
||||
if trajectory_tracker.has_active_candidates
|
||||
else sample_interval_seconds
|
||||
)
|
||||
time.sleep(sleep_seconds)
|
||||
time.sleep(sample_interval_seconds)
|
||||
|
||||
|
||||
def resolve_project_path(project_root: Path, raw_path: str) -> Path:
|
||||
@@ -151,6 +147,16 @@ def resolve_project_path(project_root: Path, raw_path: str) -> Path:
|
||||
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:
|
||||
if not payloads:
|
||||
return
|
||||
@@ -160,8 +166,67 @@ def append_jsonl(path: Path, payloads: list[dict]) -> None:
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def disposal_evidence_payloads(disposal_evidence: list[DisposalEvidence]) -> list[dict]:
|
||||
return [asdict(item) for item in disposal_evidence]
|
||||
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]]:
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
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 (
|
||||
load_config_document,
|
||||
merge_calibration,
|
||||
@@ -19,6 +20,7 @@ from cold_display_guard.config import (
|
||||
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"
|
||||
@@ -66,6 +68,21 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
||||
limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
|
||||
self._send_json({"items": load_events(ctx, limit), "limit": limit})
|
||||
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":
|
||||
query = parse_qs(parsed.query)
|
||||
limit = bounded_int(query.get("limit", ["50"])[0], 1, MAX_EVENT_LINES)
|
||||
@@ -88,6 +105,16 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
||||
if parsed.path == "/api/manage/snapshot":
|
||||
self._capture_snapshot()
|
||||
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)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
@@ -154,6 +181,59 @@ def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
|
||||
self.end_headers()
|
||||
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]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length == 0:
|
||||
@@ -229,6 +309,12 @@ def main() -> int:
|
||||
def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
||||
data = load_config_document(ctx.config_path)
|
||||
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 {
|
||||
"project_type": PROJECT_TYPE,
|
||||
"config_path": str(ctx.config_path),
|
||||
@@ -243,6 +329,10 @@ def config_payload(ctx: ManageContext) -> dict[str, Any]:
|
||||
"zones": data.get("zones", []),
|
||||
"trash": data.get("trash", {}),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +397,53 @@ def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
|
||||
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]]:
|
||||
path = diagnostics_path(ctx)
|
||||
return load_jsonl_tail(path, limit)
|
||||
@@ -335,6 +472,36 @@ def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> P
|
||||
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:
|
||||
if data is None:
|
||||
data = load_config_document(ctx.config_path)
|
||||
@@ -345,6 +512,58 @@ def diagnostics_path(ctx: ManageContext, data: dict[str, Any] | None = None) ->
|
||||
return path.resolve()
|
||||
|
||||
|
||||
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):
|
||||
stable_counts = stable_zone_counts_from_diagnostics(item)
|
||||
|
||||
@@ -11,52 +11,34 @@ DEFAULT_ZONE_IDS = tuple(f"r{row}c{col}" for row in range(1, 3) for col in range
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EngineSettings:
|
||||
camera_id: str = "cold_display_cam_01"
|
||||
pre_warning_seconds: int = 0
|
||||
max_dwell_seconds: int = 10_800
|
||||
alarm_removal_seconds: int = 0
|
||||
trash_confirmation_seconds: int = 120
|
||||
zone_ids: tuple[str, ...] = DEFAULT_ZONE_IDS
|
||||
|
||||
@property
|
||||
def pre_warning(self) -> timedelta:
|
||||
return timedelta(seconds=self.pre_warning_seconds)
|
||||
|
||||
@property
|
||||
def max_dwell(self) -> timedelta:
|
||||
return timedelta(seconds=self.max_dwell_seconds)
|
||||
|
||||
@property
|
||||
def alarm_removal_window(self) -> timedelta:
|
||||
return timedelta(seconds=self.alarm_removal_seconds)
|
||||
|
||||
@property
|
||||
def trash_confirmation_window(self) -> timedelta:
|
||||
return timedelta(seconds=self.trash_confirmation_seconds)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DisposalEvidence:
|
||||
source_zone_id: str
|
||||
target: str
|
||||
confidence: float
|
||||
method: str
|
||||
track_points: list[Any]
|
||||
item_class: str | None
|
||||
detector_score: float | None
|
||||
observed_at: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "DisposalEvidence":
|
||||
return cls(
|
||||
source_zone_id=str(payload.get("source_zone_id", "")).strip(),
|
||||
target=str(payload.get("target", "")).strip(),
|
||||
confidence=_float_or_zero(payload.get("confidence", 0.0)),
|
||||
method=str(payload.get("method", "")).strip(),
|
||||
track_points=_normalize_track_points(payload.get("track_points", [])),
|
||||
item_class=_optional_string(payload.get("item_class")),
|
||||
detector_score=_optional_float(payload.get("detector_score")),
|
||||
observed_at=_optional_string(
|
||||
payload.get("observed_at", payload.get("detected_at", payload.get("ts")))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Observation:
|
||||
ts: datetime
|
||||
zone_counts: dict[str, int]
|
||||
trash_deposit_count: int = 0
|
||||
disposal_evidence: list[DisposalEvidence] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "Observation":
|
||||
@@ -74,7 +56,6 @@ class Observation:
|
||||
ts=ts,
|
||||
zone_counts={key: max(0, int(value)) for key, value in payload["zone_counts"].items()},
|
||||
trash_deposit_count=max(0, trash_deposit_count),
|
||||
disposal_evidence=_normalize_disposal_evidence(payload.get("disposal_evidence", [])),
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +66,10 @@ class Batch:
|
||||
started_at: datetime
|
||||
last_count: int
|
||||
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
|
||||
pending_since: datetime | None = None
|
||||
disposal_deadline: datetime | None = None
|
||||
@@ -96,47 +80,3 @@ class Batch:
|
||||
if self.ended_at is not None:
|
||||
return self.dwell_seconds
|
||||
return max(0, int((when - self.started_at).total_seconds()))
|
||||
|
||||
|
||||
def _normalize_disposal_evidence(raw_evidence: Any) -> list[DisposalEvidence]:
|
||||
if raw_evidence is None:
|
||||
return []
|
||||
if isinstance(raw_evidence, dict):
|
||||
raw_items = [raw_evidence]
|
||||
else:
|
||||
raw_items = raw_evidence
|
||||
return [
|
||||
DisposalEvidence.from_dict(item)
|
||||
for item in raw_items
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
|
||||
def _normalize_track_points(raw_track_points: Any) -> list[Any]:
|
||||
if isinstance(raw_track_points, list):
|
||||
return list(raw_track_points)
|
||||
if isinstance(raw_track_points, tuple):
|
||||
return list(raw_track_points)
|
||||
return []
|
||||
|
||||
|
||||
def _float_or_zero(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_string(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return str(value).strip()
|
||||
|
||||
@@ -2,11 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from cold_display_guard.models import DisposalEvidence
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Frame:
|
||||
@@ -33,6 +30,7 @@ class RuntimeVisionSettings:
|
||||
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
|
||||
@@ -40,29 +38,10 @@ class RuntimeVisionSettings:
|
||||
occupancy_reflection_bright_dark_ratio: float = 2.0
|
||||
occupancy_confirm_frames: int = 2
|
||||
empty_confirm_frames: int = 2
|
||||
lighting_shift_guard_enabled: bool = True
|
||||
lighting_shift_min_regions: int = 3
|
||||
lighting_shift_region_fraction: float = 0.6
|
||||
lighting_shift_mean_delta: float = 45.0
|
||||
trash_motion_delta: float = 18.0
|
||||
trash_sustained_motion_delta: float = 8.0
|
||||
trash_sustained_motion_frames: int = 2
|
||||
trash_motion_cooldown_seconds: int = 3
|
||||
trajectory_enabled: bool = True
|
||||
trajectory_window_seconds: int = 8
|
||||
trajectory_sample_interval_seconds: float = 1.0
|
||||
trajectory_min_points: int = 3
|
||||
trajectory_segmented_enabled: bool = True
|
||||
trajectory_segmented_min_points: int = 2
|
||||
trajectory_min_confidence: float = 0.72
|
||||
trajectory_motion_delta: float = 20.0
|
||||
trajectory_min_blob_area: int = 12
|
||||
trajectory_max_blob_area_fraction: float = 0.35
|
||||
trajectory_trash_entry_margin: float = 0.04
|
||||
trajectory_backend: str = "motion"
|
||||
yolo_enabled: bool = False
|
||||
yolo_model_path: str = ""
|
||||
yolo_min_confidence: float = 0.65
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -74,31 +53,6 @@ class RegionMetrics:
|
||||
bright_fraction: float = 0.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _MotionPoint:
|
||||
blob_id: int
|
||||
x: float
|
||||
y: float
|
||||
area: int
|
||||
when: datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TrajectoryCandidate:
|
||||
source_region: Region
|
||||
opened_at: datetime
|
||||
last_sample_at: datetime | None = None
|
||||
points: list[_MotionPoint] | None = None
|
||||
source_motion_seen: bool = False
|
||||
pre_source_motion_seen: bool = False
|
||||
source_seeded: bool = False
|
||||
forced_rejection_reason: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.points is None:
|
||||
self.points = []
|
||||
|
||||
|
||||
class ZoneOccupancyDetector:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -134,12 +88,7 @@ class ZoneOccupancyDetector:
|
||||
self._update_baseline(metrics_by_region)
|
||||
|
||||
zone_counts: dict[str, int] = {}
|
||||
lighting_shift = self._lighting_shift(metrics_by_region)
|
||||
diagnostics: dict[str, Any] = {
|
||||
"zones": {},
|
||||
"baseline_ready": self.baseline_ready,
|
||||
"lighting_shift": lighting_shift,
|
||||
}
|
||||
diagnostics: dict[str, Any] = {"zones": {}, "baseline_ready": self.baseline_ready}
|
||||
for region in self.regions:
|
||||
metrics = metrics_by_region[region.region_id]
|
||||
baseline = self._baseline.get(region.region_id)
|
||||
@@ -147,12 +96,8 @@ class ZoneOccupancyDetector:
|
||||
if baseline is not None:
|
||||
mean_delta = abs(metrics.mean_luma - baseline.mean_luma)
|
||||
texture_delta = metrics.texture - baseline.texture
|
||||
if lighting_shift["active"]:
|
||||
raw_occupied = False
|
||||
occupied = self._stable_occupancy.get(region.region_id, False)
|
||||
else:
|
||||
raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta)
|
||||
occupied = self._confirmed_occupancy(region.region_id, raw_occupied)
|
||||
raw_occupied = self._raw_occupied(metrics, baseline, mean_delta, texture_delta)
|
||||
occupied = self._confirmed_occupancy(region.region_id, raw_occupied)
|
||||
diagnostics["zones"][region.region_id] = {
|
||||
"mean_luma": round(metrics.mean_luma, 3),
|
||||
"baseline_mean_luma": round(baseline.mean_luma, 3),
|
||||
@@ -169,7 +114,6 @@ class ZoneOccupancyDetector:
|
||||
"occupied": occupied,
|
||||
"occupied_streak": self._occupied_streaks[region.region_id],
|
||||
"empty_streak": self._empty_streaks[region.region_id],
|
||||
"lighting_shift_suppressed": lighting_shift["active"],
|
||||
}
|
||||
zone_counts[region.region_id] = 1 if occupied else 0
|
||||
|
||||
@@ -220,60 +164,6 @@ class ZoneOccupancyDetector:
|
||||
if len(samples) >= self.settings.baseline_frames:
|
||||
self._baseline[region_id] = average_metrics(samples)
|
||||
|
||||
def _lighting_shift(self, metrics_by_region: dict[str, RegionMetrics]) -> dict[str, Any]:
|
||||
if not self.settings.lighting_shift_guard_enabled:
|
||||
return self._lighting_shift_diagnostics(False, None, 0, 0, 0)
|
||||
|
||||
eligible_region_count = 0
|
||||
darker_count = 0
|
||||
brighter_count = 0
|
||||
for region in self.regions:
|
||||
metrics = metrics_by_region.get(region.region_id)
|
||||
baseline = self._baseline.get(region.region_id)
|
||||
if metrics is None or baseline is None:
|
||||
continue
|
||||
eligible_region_count += 1
|
||||
signed_delta = metrics.mean_luma - baseline.mean_luma
|
||||
if abs(signed_delta) < self.settings.lighting_shift_mean_delta:
|
||||
continue
|
||||
if signed_delta < 0:
|
||||
darker_count += 1
|
||||
else:
|
||||
brighter_count += 1
|
||||
|
||||
required_regions = max(
|
||||
self.settings.lighting_shift_min_regions,
|
||||
ceil(eligible_region_count * self.settings.lighting_shift_region_fraction),
|
||||
)
|
||||
active_direction: str | None = None
|
||||
shifted_count = max(darker_count, brighter_count)
|
||||
if eligible_region_count >= self.settings.lighting_shift_min_regions and shifted_count >= required_regions:
|
||||
active_direction = "darker" if darker_count >= brighter_count else "brighter"
|
||||
return self._lighting_shift_diagnostics(
|
||||
active_direction is not None,
|
||||
active_direction,
|
||||
shifted_count,
|
||||
eligible_region_count,
|
||||
required_regions,
|
||||
)
|
||||
|
||||
def _lighting_shift_diagnostics(
|
||||
self,
|
||||
active: bool,
|
||||
direction: str | None,
|
||||
shifted_regions: int,
|
||||
eligible_regions: int,
|
||||
required_regions: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"active": active,
|
||||
"direction": direction,
|
||||
"shifted_regions": shifted_regions,
|
||||
"eligible_regions": eligible_regions,
|
||||
"required_regions": required_regions,
|
||||
"mean_delta_threshold": self.settings.lighting_shift_mean_delta,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -323,418 +213,6 @@ class ZoneOccupancyDetector:
|
||||
return 1 if deposit else 0
|
||||
|
||||
|
||||
class TrajectoryTracker:
|
||||
def __init__(
|
||||
self,
|
||||
regions: list[Region],
|
||||
trash_region: Region | None,
|
||||
settings: RuntimeVisionSettings | None = None,
|
||||
) -> None:
|
||||
self.regions = regions
|
||||
self.trash_region = trash_region
|
||||
self.settings = settings or RuntimeVisionSettings()
|
||||
self._previous_frame: Frame | None = None
|
||||
self._previous_zone_counts: dict[str, int] = {}
|
||||
self._candidates: list[_TrajectoryCandidate] = []
|
||||
self._recent_source_motion: dict[str, _MotionPoint] = {}
|
||||
self._emitted_track_signatures: set[tuple[tuple[float, float], ...]] = set()
|
||||
|
||||
@property
|
||||
def has_active_candidates(self) -> bool:
|
||||
return bool(self._candidates)
|
||||
|
||||
def observe(
|
||||
self,
|
||||
frame: Frame,
|
||||
when: datetime,
|
||||
zone_counts: dict[str, int],
|
||||
) -> tuple[list[DisposalEvidence], dict[str, Any]]:
|
||||
diagnostics: dict[str, Any] = {
|
||||
"active_candidates": len(self._candidates),
|
||||
"emitted_evidence": 0,
|
||||
"expired_candidates": 0,
|
||||
"rejected_candidates": 0,
|
||||
"emitted": [],
|
||||
"rejected": [],
|
||||
"expired": [],
|
||||
"disabled": False,
|
||||
"reason": None,
|
||||
}
|
||||
if not self.settings.trajectory_enabled:
|
||||
diagnostics["disabled"] = True
|
||||
diagnostics["reason"] = "trajectory_disabled"
|
||||
self._remember(frame, zone_counts)
|
||||
return [], diagnostics
|
||||
if self.trash_region is None:
|
||||
diagnostics["disabled"] = True
|
||||
diagnostics["reason"] = "missing_trash_region"
|
||||
self._remember(frame, zone_counts)
|
||||
return [], diagnostics
|
||||
if self.settings.trajectory_backend != "motion":
|
||||
diagnostics["disabled"] = True
|
||||
diagnostics["reason"] = "unsupported_trajectory_backend"
|
||||
self._remember(frame, zone_counts)
|
||||
return [], diagnostics
|
||||
|
||||
blobs = self._motion_points(frame, when) if self._previous_frame is not None else []
|
||||
self._remember_recent_source_motion(blobs, when, zone_counts)
|
||||
self._open_candidates(when, zone_counts)
|
||||
|
||||
emitted: list[DisposalEvidence] = []
|
||||
remaining: list[_TrajectoryCandidate] = []
|
||||
consumed_blob_ids: set[int] = set()
|
||||
emitted_track_signatures = set(self._emitted_track_signatures)
|
||||
for candidate in self._candidates:
|
||||
rejected_reason = self._sample_candidate(candidate, blobs, when, consumed_blob_ids)
|
||||
if rejected_reason is not None:
|
||||
diagnostics["rejected_candidates"] += 1
|
||||
diagnostics["rejected"].append(self._candidate_event(candidate, rejected_reason))
|
||||
continue
|
||||
if when - candidate.opened_at > timedelta(seconds=self.settings.trajectory_window_seconds):
|
||||
diagnostics["expired_candidates"] += 1
|
||||
diagnostics["expired"].append(self._candidate_event(candidate, "expired"))
|
||||
if not self._candidate_ready(candidate):
|
||||
diagnostics["rejected_candidates"] += 1
|
||||
diagnostics["rejected"].append(self._candidate_event(candidate, self._rejection_reason(candidate)))
|
||||
else:
|
||||
signature = self._track_signature(candidate)
|
||||
if signature in emitted_track_signatures:
|
||||
diagnostics["rejected_candidates"] += 1
|
||||
diagnostics["rejected"].append(self._candidate_event(candidate, "ambiguous_motion_track"))
|
||||
else:
|
||||
emitted.append(self._evidence(candidate, when))
|
||||
emitted_track_signatures.add(signature)
|
||||
self._emitted_track_signatures.add(signature)
|
||||
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
||||
continue
|
||||
if self._candidate_reached_trash(candidate):
|
||||
if self._candidate_ready(candidate):
|
||||
signature = self._track_signature(candidate)
|
||||
if signature in emitted_track_signatures:
|
||||
diagnostics["rejected_candidates"] += 1
|
||||
diagnostics["rejected"].append(self._candidate_event(candidate, "ambiguous_motion_track"))
|
||||
else:
|
||||
emitted.append(self._evidence(candidate, when))
|
||||
emitted_track_signatures.add(signature)
|
||||
self._emitted_track_signatures.add(signature)
|
||||
diagnostics["emitted"].append(self._candidate_event(candidate, "emitted"))
|
||||
else:
|
||||
diagnostics["rejected_candidates"] += 1
|
||||
diagnostics["rejected"].append(self._candidate_event(candidate, self._rejection_reason(candidate)))
|
||||
continue
|
||||
remaining.append(candidate)
|
||||
|
||||
self._candidates = remaining
|
||||
if not self._candidates:
|
||||
self._emitted_track_signatures.clear()
|
||||
diagnostics["emitted_evidence"] = len(emitted)
|
||||
diagnostics["active_candidates"] = len(self._candidates)
|
||||
diagnostics["motion_points"] = len(blobs)
|
||||
self._remember(frame, zone_counts)
|
||||
return emitted, diagnostics
|
||||
|
||||
def _remember(self, frame: Frame, zone_counts: dict[str, int]) -> None:
|
||||
self._previous_frame = frame
|
||||
self._previous_zone_counts = {region_id: max(0, int(count)) for region_id, count in zone_counts.items()}
|
||||
|
||||
def _open_candidates(self, when: datetime, zone_counts: dict[str, int]) -> None:
|
||||
active_region_ids = {candidate.source_region.region_id for candidate in self._candidates}
|
||||
used_seed_signatures: set[tuple[float, float, str]] = set()
|
||||
for region in self.regions:
|
||||
previous = self._previous_zone_counts.get(region.region_id, 0)
|
||||
current = max(0, int(zone_counts.get(region.region_id, 0)))
|
||||
if previous > 0 and current == 0 and region.region_id not in active_region_ids:
|
||||
candidate = _TrajectoryCandidate(source_region=region, opened_at=when)
|
||||
recent = self._recent_source_motion.get(region.region_id)
|
||||
if recent is not None and when - recent.when <= timedelta(seconds=self.settings.trajectory_window_seconds):
|
||||
signature = (round(recent.x, 4), round(recent.y, 4), recent.when.isoformat())
|
||||
if signature in used_seed_signatures:
|
||||
candidate.forced_rejection_reason = "ambiguous_motion_track"
|
||||
else:
|
||||
used_seed_signatures.add(signature)
|
||||
candidate.points.append(recent)
|
||||
candidate.source_motion_seen = True
|
||||
candidate.source_seeded = True
|
||||
candidate.last_sample_at = recent.when
|
||||
self._candidates.append(candidate)
|
||||
self._recent_source_motion.pop(region.region_id, None)
|
||||
|
||||
def _remember_recent_source_motion(self, blobs: list[_MotionPoint], when: datetime, zone_counts: dict[str, int]) -> None:
|
||||
if not blobs:
|
||||
return
|
||||
for region in self.regions:
|
||||
previous = self._previous_zone_counts.get(region.region_id, 0)
|
||||
current = max(0, int(zone_counts.get(region.region_id, 0)))
|
||||
if previous <= 0 and current <= 0:
|
||||
continue
|
||||
source_blobs = [blob for blob in blobs if region_contains(region, blob.x, blob.y)]
|
||||
if not source_blobs:
|
||||
continue
|
||||
self._recent_source_motion[region.region_id] = _nearest_point(region_center(region), source_blobs)
|
||||
|
||||
def _motion_points(self, frame: Frame, when: datetime) -> list[_MotionPoint]:
|
||||
previous = self._previous_frame
|
||||
if previous is None or previous.width != frame.width or previous.height != frame.height:
|
||||
return []
|
||||
|
||||
width = frame.width
|
||||
height = frame.height
|
||||
changed = bytearray(width * height)
|
||||
threshold = self.settings.trajectory_motion_delta
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
offset = (y * width + x) * 3
|
||||
current_luma = _luma(frame.rgb[offset], frame.rgb[offset + 1], frame.rgb[offset + 2])
|
||||
previous_luma = _luma(previous.rgb[offset], previous.rgb[offset + 1], previous.rgb[offset + 2])
|
||||
if abs(current_luma - previous_luma) >= threshold:
|
||||
changed[y * width + x] = 1
|
||||
|
||||
min_area = max(1, int(self.settings.trajectory_min_blob_area))
|
||||
max_area = max(min_area, int(width * height * self.settings.trajectory_max_blob_area_fraction))
|
||||
points: list[_MotionPoint] = []
|
||||
next_blob_id = 0
|
||||
for start in range(width * height):
|
||||
if not changed[start]:
|
||||
continue
|
||||
stack = [start]
|
||||
changed[start] = 0
|
||||
area = 0
|
||||
sum_x = 0
|
||||
sum_y = 0
|
||||
while stack:
|
||||
index = stack.pop()
|
||||
x = index % width
|
||||
y = index // width
|
||||
area += 1
|
||||
sum_x += x
|
||||
sum_y += y
|
||||
if x > 0:
|
||||
neighbor = index - 1
|
||||
if changed[neighbor]:
|
||||
changed[neighbor] = 0
|
||||
stack.append(neighbor)
|
||||
if x + 1 < width:
|
||||
neighbor = index + 1
|
||||
if changed[neighbor]:
|
||||
changed[neighbor] = 0
|
||||
stack.append(neighbor)
|
||||
if y > 0:
|
||||
neighbor = index - width
|
||||
if changed[neighbor]:
|
||||
changed[neighbor] = 0
|
||||
stack.append(neighbor)
|
||||
if y + 1 < height:
|
||||
neighbor = index + width
|
||||
if changed[neighbor]:
|
||||
changed[neighbor] = 0
|
||||
stack.append(neighbor)
|
||||
if min_area <= area <= max_area:
|
||||
points.append(
|
||||
_MotionPoint(
|
||||
blob_id=next_blob_id,
|
||||
x=(sum_x / area + 0.5) / width,
|
||||
y=(sum_y / area + 0.5) / height,
|
||||
area=area,
|
||||
when=when,
|
||||
)
|
||||
)
|
||||
next_blob_id += 1
|
||||
return points
|
||||
|
||||
def _sample_candidate(
|
||||
self,
|
||||
candidate: _TrajectoryCandidate,
|
||||
blobs: list[_MotionPoint],
|
||||
when: datetime,
|
||||
consumed_blob_ids: set[int],
|
||||
) -> str | None:
|
||||
if candidate.forced_rejection_reason is not None:
|
||||
return candidate.forced_rejection_reason
|
||||
if not blobs:
|
||||
return None
|
||||
if (
|
||||
candidate.last_sample_at is not None
|
||||
and (when - candidate.last_sample_at).total_seconds() < self.settings.trajectory_sample_interval_seconds
|
||||
):
|
||||
return None
|
||||
|
||||
if not candidate.source_motion_seen:
|
||||
source_blobs = [
|
||||
blob for blob in blobs if region_contains(candidate.source_region, blob.x, blob.y)
|
||||
]
|
||||
if source_blobs:
|
||||
available_source_blobs = [blob for blob in source_blobs if blob.blob_id not in consumed_blob_ids]
|
||||
if not available_source_blobs:
|
||||
return "ambiguous_motion_track"
|
||||
if candidate.pre_source_motion_seen:
|
||||
return "motion_started_outside_source"
|
||||
candidate.source_motion_seen = True
|
||||
point = _nearest_point(region_center(candidate.source_region), available_source_blobs)
|
||||
else:
|
||||
available_blobs = [blob for blob in blobs if blob.blob_id not in consumed_blob_ids]
|
||||
if not available_blobs:
|
||||
return None
|
||||
candidate.pre_source_motion_seen = True
|
||||
if any(
|
||||
region_contains(self.trash_region, blob.x, blob.y, margin=self.settings.trajectory_trash_entry_margin)
|
||||
for blob in available_blobs
|
||||
if self.trash_region is not None
|
||||
):
|
||||
return "motion_started_outside_source"
|
||||
return None
|
||||
else:
|
||||
previous = candidate.points[-1] if candidate.points else None
|
||||
available_blobs = [blob for blob in blobs if blob.blob_id not in consumed_blob_ids]
|
||||
if not available_blobs:
|
||||
return None
|
||||
point = self._next_progress_point(candidate, available_blobs, previous)
|
||||
|
||||
if candidate.points and _distance((candidate.points[-1].x, candidate.points[-1].y), (point.x, point.y)) < 0.015:
|
||||
return None
|
||||
candidate.points.append(point)
|
||||
consumed_blob_ids.add(point.blob_id)
|
||||
candidate.last_sample_at = when
|
||||
return None
|
||||
|
||||
def _next_progress_point(
|
||||
self,
|
||||
candidate: _TrajectoryCandidate,
|
||||
blobs: list[_MotionPoint],
|
||||
previous: _MotionPoint | None,
|
||||
) -> _MotionPoint:
|
||||
if self.trash_region is None:
|
||||
return blobs[0]
|
||||
source = region_center(candidate.source_region)
|
||||
target = region_center(self.trash_region)
|
||||
expected = (target[0] - source[0], target[1] - source[1])
|
||||
expected_length = (expected[0] ** 2 + expected[1] ** 2) ** 0.5
|
||||
if expected_length <= 1e-9:
|
||||
origin = (previous.x, previous.y) if previous is not None else source
|
||||
return _nearest_point(origin, blobs)
|
||||
unit = (expected[0] / expected_length, expected[1] / expected_length)
|
||||
origin = (previous.x, previous.y) if previous is not None else source
|
||||
|
||||
def score(point: _MotionPoint) -> float:
|
||||
dx = point.x - origin[0]
|
||||
dy = point.y - origin[1]
|
||||
projection = dx * unit[0] + dy * unit[1]
|
||||
perpendicular = abs(dx * unit[1] - dy * unit[0])
|
||||
return projection - 0.25 * perpendicular
|
||||
|
||||
return max(blobs, key=score)
|
||||
|
||||
def _candidate_reached_trash(self, candidate: _TrajectoryCandidate) -> bool:
|
||||
points = candidate.points or []
|
||||
return any(
|
||||
region_contains(self.trash_region, point.x, point.y, margin=self.settings.trajectory_trash_entry_margin)
|
||||
for point in points
|
||||
if self.trash_region is not None
|
||||
)
|
||||
|
||||
def _candidate_ready(self, candidate: _TrajectoryCandidate) -> bool:
|
||||
confidence = self._confidence(candidate)
|
||||
return (
|
||||
candidate.source_motion_seen
|
||||
and self._candidate_reached_trash(candidate)
|
||||
and self._has_enough_track_points(candidate)
|
||||
and self._direction_score(candidate) >= 0.35
|
||||
and confidence >= self.settings.trajectory_min_confidence
|
||||
)
|
||||
|
||||
def _rejection_reason(self, candidate: _TrajectoryCandidate) -> str:
|
||||
if not candidate.source_motion_seen:
|
||||
return "missing_source_motion"
|
||||
if not self._candidate_reached_trash(candidate):
|
||||
return "did_not_reach_trash"
|
||||
if not self._has_enough_track_points(candidate):
|
||||
return "insufficient_points"
|
||||
if self._direction_score(candidate) < 0.35:
|
||||
return "bad_direction"
|
||||
if self._confidence(candidate) < self.settings.trajectory_min_confidence:
|
||||
return "low_confidence"
|
||||
return "rejected"
|
||||
|
||||
def _has_enough_track_points(self, candidate: _TrajectoryCandidate) -> bool:
|
||||
point_count = len(candidate.points or [])
|
||||
if point_count >= self.settings.trajectory_min_points:
|
||||
return True
|
||||
return (
|
||||
self.settings.trajectory_segmented_enabled
|
||||
and point_count >= self.settings.trajectory_segmented_min_points
|
||||
and candidate.source_motion_seen
|
||||
and self._candidate_reached_trash(candidate)
|
||||
)
|
||||
|
||||
def _candidate_event(self, candidate: _TrajectoryCandidate, reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"source_zone_id": candidate.source_region.region_id,
|
||||
"reason": reason,
|
||||
"point_count": len(candidate.points or []),
|
||||
"confidence": round(self._confidence(candidate), 3),
|
||||
"direction_score": round(self._direction_score(candidate), 3),
|
||||
"segmented": self._is_segmented_track(candidate),
|
||||
"source_seeded": candidate.source_seeded,
|
||||
}
|
||||
|
||||
def _is_segmented_track(self, candidate: _TrajectoryCandidate) -> bool:
|
||||
point_count = len(candidate.points or [])
|
||||
return (
|
||||
self.settings.trajectory_segmented_enabled
|
||||
and self.settings.trajectory_segmented_min_points <= point_count < self.settings.trajectory_min_points
|
||||
and candidate.source_motion_seen
|
||||
and self._candidate_reached_trash(candidate)
|
||||
)
|
||||
|
||||
def _track_signature(self, candidate: _TrajectoryCandidate) -> tuple[tuple[float, float], ...]:
|
||||
return tuple((round(point.x, 4), round(point.y, 4)) for point in candidate.points or [])
|
||||
|
||||
def _confidence(self, candidate: _TrajectoryCandidate) -> float:
|
||||
point_count = len(candidate.points or [])
|
||||
point_score = min(1.0, point_count / max(1, self.settings.trajectory_min_points))
|
||||
source_score = 1.0 if candidate.source_motion_seen else 0.0
|
||||
trash_score = 1.0 if self._candidate_reached_trash(candidate) else 0.0
|
||||
direction_score = max(0.0, self._direction_score(candidate))
|
||||
return min(1.0, 0.20 * source_score + 0.35 * trash_score + 0.25 * direction_score + 0.20 * point_score)
|
||||
|
||||
def _direction_score(self, candidate: _TrajectoryCandidate) -> float:
|
||||
points = candidate.points or []
|
||||
if len(points) < 2 or self.trash_region is None:
|
||||
return 0.0
|
||||
start = points[0]
|
||||
end = points[-1]
|
||||
motion = (end.x - start.x, end.y - start.y)
|
||||
motion_length = (motion[0] ** 2 + motion[1] ** 2) ** 0.5
|
||||
if motion_length <= 1e-9:
|
||||
return 0.0
|
||||
source = region_center(candidate.source_region)
|
||||
target = region_center(self.trash_region)
|
||||
expected = (target[0] - source[0], target[1] - source[1])
|
||||
expected_length = (expected[0] ** 2 + expected[1] ** 2) ** 0.5
|
||||
if expected_length <= 1e-9:
|
||||
return 0.0
|
||||
return (motion[0] * expected[0] + motion[1] * expected[1]) / (motion_length * expected_length)
|
||||
|
||||
def _evidence(self, candidate: _TrajectoryCandidate, when: datetime) -> DisposalEvidence:
|
||||
return DisposalEvidence(
|
||||
source_zone_id=candidate.source_region.region_id,
|
||||
target="trash",
|
||||
confidence=round(self._confidence(candidate), 3),
|
||||
method="motion",
|
||||
track_points=[
|
||||
{
|
||||
"x": round(point.x, 4),
|
||||
"y": round(point.y, 4),
|
||||
"area": point.area,
|
||||
"observed_at": point.when.isoformat(),
|
||||
}
|
||||
for point in candidate.points or []
|
||||
],
|
||||
item_class=None,
|
||||
detector_score=None,
|
||||
observed_at=when.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def load_regions(config: dict[str, Any]) -> tuple[list[Region], Region | None]:
|
||||
regions: list[Region] = []
|
||||
for zone in config.get("zones", []):
|
||||
@@ -759,6 +237,7 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting
|
||||
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)),
|
||||
@@ -766,32 +245,10 @@ def load_runtime_vision_settings(config: dict[str, Any]) -> RuntimeVisionSetting
|
||||
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))),
|
||||
lighting_shift_guard_enabled=bool(runtime.get("lighting_shift_guard_enabled", True)),
|
||||
lighting_shift_min_regions=max(1, int(runtime.get("lighting_shift_min_regions", 3))),
|
||||
lighting_shift_region_fraction=max(0.0, min(1.0, float(runtime.get("lighting_shift_region_fraction", 0.6)))),
|
||||
lighting_shift_mean_delta=float(runtime.get("lighting_shift_mean_delta", 45.0)),
|
||||
trash_motion_delta=float(runtime.get("trash_motion_delta", 18.0)),
|
||||
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))),
|
||||
trajectory_enabled=bool(runtime.get("trajectory_enabled", True)),
|
||||
trajectory_window_seconds=max(1, int(runtime.get("trajectory_window_seconds", 8))),
|
||||
trajectory_sample_interval_seconds=max(0.0, float(runtime.get("trajectory_sample_interval_seconds", 1.0))),
|
||||
trajectory_min_points=max(1, int(runtime.get("trajectory_min_points", 3))),
|
||||
trajectory_segmented_enabled=bool(runtime.get("trajectory_segmented_enabled", True)),
|
||||
trajectory_segmented_min_points=max(2, int(runtime.get("trajectory_segmented_min_points", 2))),
|
||||
trajectory_min_confidence=float(runtime.get("trajectory_min_confidence", 0.72)),
|
||||
trajectory_motion_delta=float(runtime.get("trajectory_motion_delta", 20.0)),
|
||||
trajectory_min_blob_area=max(1, int(runtime.get("trajectory_min_blob_area", 12))),
|
||||
trajectory_max_blob_area_fraction=max(
|
||||
0.0,
|
||||
min(1.0, float(runtime.get("trajectory_max_blob_area_fraction", 0.35))),
|
||||
),
|
||||
trajectory_trash_entry_margin=max(0.0, float(runtime.get("trajectory_trash_entry_margin", 0.04))),
|
||||
trajectory_backend=str(runtime.get("trajectory_backend", "motion")),
|
||||
yolo_enabled=bool(runtime.get("yolo_enabled", False)),
|
||||
yolo_model_path=str(runtime.get("yolo_model_path", "")),
|
||||
yolo_min_confidence=float(runtime.get("yolo_min_confidence", 0.65)),
|
||||
)
|
||||
|
||||
|
||||
@@ -855,37 +312,6 @@ def average_metrics(samples: list[RegionMetrics]) -> RegionMetrics:
|
||||
)
|
||||
|
||||
|
||||
def region_center(region: Region) -> tuple[float, float]:
|
||||
return (
|
||||
sum(point[0] for point in region.polygon) / len(region.polygon),
|
||||
sum(point[1] for point in region.polygon) / len(region.polygon),
|
||||
)
|
||||
|
||||
|
||||
def region_contains(region: Region, x: float, y: float, margin: float = 0.0) -> bool:
|
||||
if margin <= 0:
|
||||
return point_in_polygon(x, y, region.polygon)
|
||||
xs = [point[0] for point in region.polygon]
|
||||
ys = [point[1] for point in region.polygon]
|
||||
if x < min(xs) - margin or x > max(xs) + margin or y < min(ys) - margin or y > max(ys) + margin:
|
||||
return False
|
||||
return point_in_polygon(x, y, region.polygon) or (
|
||||
min(xs) - margin <= x <= max(xs) + margin and min(ys) - margin <= y <= max(ys) + margin
|
||||
)
|
||||
|
||||
|
||||
def _nearest_point(origin: tuple[float, float], points: list[_MotionPoint]) -> _MotionPoint:
|
||||
return min(points, key=lambda point: _distance(origin, (point.x, point.y)))
|
||||
|
||||
|
||||
def _distance(first: tuple[float, float], second: tuple[float, float]) -> float:
|
||||
return ((first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2) ** 0.5
|
||||
|
||||
|
||||
def _luma(r: int, g: int, b: int) -> float:
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b
|
||||
|
||||
|
||||
def metrics_indicate_occupied(
|
||||
settings: RuntimeVisionSettings,
|
||||
mean_delta: float,
|
||||
@@ -900,13 +326,18 @@ def metrics_indicate_occupied(
|
||||
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 mean_occupied or texture_occupied
|
||||
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:
|
||||
|
||||
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"
|
||||
39
task_plan.md
39
task_plan.md
@@ -76,42 +76,3 @@ Create and evolve an independent git project under `~/Code` for monitoring food
|
||||
```
|
||||
|
||||
其中 `阶段 x` 表示同一 `v1.1 优化改造` 批次内的工作阶段,不代表拆分成独立批次。
|
||||
|
||||
## v1.2 轨迹识别
|
||||
|
||||
### Goal
|
||||
|
||||
在 `/Users/yoilun/Code/cold_display_guard` 中完成轨迹识别改造:保留现有 ROI 占用计时和垃圾桶动作兜底,新增轻量轨迹移动检测,输出可被未来 YOLO 物品识别模型复用的统一 `disposal_evidence`,让报警后移出的物品按来源区域确认是否进入垃圾桶。
|
||||
|
||||
### Stop Conditions
|
||||
|
||||
- [x] v1.2 所有阶段完成。
|
||||
- [x] 必要 Python 测试通过。
|
||||
- [x] 前端测试或构建在受影响时通过。
|
||||
- [x] `docs/project.md` 记录 v1.2 架构、配置、运行方式和关键决策。
|
||||
- [x] 没有 blocking bug 或未处理的高风险问题。
|
||||
- [x] 如果同一问题连续 3 次修复仍失败,暂停并报告原因、已尝试方案和建议下一步。
|
||||
|
||||
### Phases
|
||||
|
||||
| Phase | Status | Goal | Acceptance Criteria |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | complete | 建立 `disposal_evidence` 数据契约并让状态机优先按来源区域丢弃 | `Observation` 支持 evidence;engine 能按 `source_zone_id` 精确关闭 pending batch;同帧移除+evidence 有回归测试;旧 `trash_deposit_count` 仍可兜底 |
|
||||
| 2 | complete | 实现无 YOLO 依赖的轻量轨迹检测 | synthetic frame 测试覆盖源区域到垃圾桶、非源区域运动、未到垃圾桶、单帧反光、多候选互不串扰;不引入模型依赖 |
|
||||
| 3 | complete | 集成 runtime 配置、诊断和候选窗口加速采样 | `main.py` 写入 `disposal_evidence` 与 trajectory diagnostics;配置默认 `trajectory_enabled=true`、`yolo_enabled=false`;候选活跃时使用更短采样间隔 |
|
||||
| 4 | complete | 文档、全量验证和部署准备 | README/project/progress 更新;Python 全量测试通过;前端测试/构建按影响范围验证;远端部署命令和风险记录清楚 |
|
||||
|
||||
### v1.2 Decisions
|
||||
|
||||
- 第一版使用 `MotionTrajectoryBackend`,不安装 YOLO、PyTorch、ONNX Runtime 或 OpenVINO。
|
||||
- YOLO 作为后续 `YoloDetectionBackend` 接入统一 evidence contract,不能绕过轨迹校验直接关闭业务事件。
|
||||
- 状态机只消费 `disposal_evidence`,不依赖具体视觉后端。
|
||||
- 轨迹 evidence 优先级高于 FIFO 垃圾桶动作兜底。
|
||||
- 子 agent 派发必须使用标准上下文头:
|
||||
|
||||
```text
|
||||
[项目: /Users/yoilun/Code/cold_display_guard]
|
||||
[工作流批次: v1.2 轨迹识别]
|
||||
[阶段: 阶段 x]
|
||||
[角色: 对应智能体角色]
|
||||
```
|
||||
|
||||
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()
|
||||
@@ -16,7 +16,9 @@ class ConfigTests(unittest.TestCase):
|
||||
camera_id = "cam_a"
|
||||
|
||||
[thresholds]
|
||||
pre_warning_seconds = 20
|
||||
max_dwell_seconds = 30
|
||||
alarm_removal_seconds = 2
|
||||
trash_confirmation_seconds = 4
|
||||
|
||||
[layout]
|
||||
@@ -29,7 +31,9 @@ cols = 2
|
||||
settings = load_settings(path)
|
||||
|
||||
self.assertEqual(settings.camera_id, "cam_a")
|
||||
self.assertEqual(settings.pre_warning_seconds, 20)
|
||||
self.assertEqual(settings.max_dwell_seconds, 30)
|
||||
self.assertEqual(settings.alarm_removal_seconds, 2)
|
||||
self.assertEqual(settings.trash_confirmation_seconds, 4)
|
||||
self.assertEqual(settings.zone_ids, ("r1c1", "r1c2"))
|
||||
|
||||
@@ -118,10 +122,61 @@ zone_ids = ["1", "2", "3"]
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,39 +9,16 @@ from cold_display_guard import BatchEngine, EngineSettings, Observation
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def obs(
|
||||
ts: datetime,
|
||||
counts: dict[str, int],
|
||||
trash: bool | int = False,
|
||||
disposal_evidence: list[dict[str, object]] | None = None,
|
||||
) -> Observation:
|
||||
def obs(ts: datetime, counts: dict[str, int], trash: bool | int = False) -> Observation:
|
||||
return Observation.from_dict(
|
||||
{
|
||||
"ts": ts.isoformat(),
|
||||
"zone_counts": counts,
|
||||
"trash_deposit": trash,
|
||||
"disposal_evidence": disposal_evidence or [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def disposal_evidence(
|
||||
source_zone_id: str,
|
||||
confidence: float = 0.93,
|
||||
target: str = "trash_bin",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"source_zone_id": source_zone_id,
|
||||
"target": target,
|
||||
"confidence": confidence,
|
||||
"method": "trajectory",
|
||||
"track_points": [{"x": 101, "y": 202, "ts": "2026-04-27T10:20:01+00:00"}],
|
||||
"item_class": "prepared_food",
|
||||
"detector_score": 0.88,
|
||||
"observed_at": "2026-04-27T10:20:02+00:00",
|
||||
}
|
||||
|
||||
|
||||
class BatchEngineTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.settings = EngineSettings(
|
||||
@@ -103,227 +80,6 @@ class BatchEngineTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
||||
|
||||
def test_observation_from_dict_normalizes_disposal_evidence(self) -> None:
|
||||
observation = Observation.from_dict(
|
||||
{
|
||||
"ts": self.t0.isoformat(),
|
||||
"zone_counts": {"1": "2"},
|
||||
"disposal_evidence": [
|
||||
{
|
||||
"source_zone_id": 1,
|
||||
"target": "trash_bin",
|
||||
"confidence": "0.83",
|
||||
"method": "trajectory",
|
||||
"track_points": [{"x": 1, "y": 2}],
|
||||
"item_class": "prepared_food",
|
||||
"detector_score": "0.91",
|
||||
"observed_at": "2026-04-27T10:00:01+00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
evidence = observation.disposal_evidence[0]
|
||||
self.assertEqual(evidence.source_zone_id, "1")
|
||||
self.assertEqual(evidence.target, "trash_bin")
|
||||
self.assertAlmostEqual(evidence.confidence, 0.83)
|
||||
self.assertEqual(evidence.method, "trajectory")
|
||||
self.assertEqual(evidence.track_points, [{"x": 1, "y": 2}])
|
||||
self.assertEqual(evidence.item_class, "prepared_food")
|
||||
self.assertAlmostEqual(evidence.detector_score, 0.91)
|
||||
self.assertEqual(evidence.observed_at, "2026-04-27T10:00:01+00:00")
|
||||
|
||||
def test_observation_from_dict_preserves_null_optional_disposal_fields(self) -> None:
|
||||
observation = Observation.from_dict(
|
||||
{
|
||||
"ts": self.t0.isoformat(),
|
||||
"zone_counts": {"1": 1},
|
||||
"disposal_evidence": [
|
||||
{
|
||||
"source_zone_id": "1",
|
||||
"target": "trash_bin",
|
||||
"confidence": 0.83,
|
||||
"method": "trajectory",
|
||||
"track_points": [],
|
||||
"item_class": None,
|
||||
"detector_score": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
evidence = observation.disposal_evidence[0]
|
||||
self.assertIsNone(evidence.item_class)
|
||||
self.assertIsNone(evidence.detector_score)
|
||||
|
||||
def test_matching_disposal_evidence_discards_pending_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}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0}))
|
||||
|
||||
events = engine.process(
|
||||
obs(
|
||||
self.t0 + timedelta(seconds=1310),
|
||||
{"1": 0},
|
||||
disposal_evidence=[disposal_evidence("1")],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual([event["event"] for event in events], ["batch_discarded"])
|
||||
self.assertEqual(events[0]["zone_id"], "1")
|
||||
self.assertEqual(engine.pending_disposal, [])
|
||||
|
||||
def test_disposal_evidence_for_another_zone_does_not_discard_wrong_pending_batch(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
max_dwell_seconds=1200,
|
||||
trash_confirmation_seconds=120,
|
||||
zone_ids=("1", "4"),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1, "4": 0}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "4": 0}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0}))
|
||||
|
||||
events = engine.process(
|
||||
obs(
|
||||
self.t0 + timedelta(seconds=1310),
|
||||
{"1": 0, "4": 0},
|
||||
disposal_evidence=[disposal_evidence("4")],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
|
||||
|
||||
def test_non_trash_disposal_evidence_target_is_ignored(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},
|
||||
disposal_evidence=[disposal_evidence("1", target="customer_hand")],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
|
||||
|
||||
def test_disposal_evidence_and_trash_count_do_not_double_consume_same_signal(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
max_dwell_seconds=1200,
|
||||
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=1200), {"1": 1, "4": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "4": 0}))
|
||||
|
||||
events = engine.process(
|
||||
obs(
|
||||
self.t0 + timedelta(seconds=1310),
|
||||
{"1": 0, "4": 0},
|
||||
trash=True,
|
||||
disposal_evidence=[disposal_evidence("4")],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual([(event["event"], event["zone_id"]) for event in events], [("batch_discarded", "4")])
|
||||
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
|
||||
|
||||
def test_extra_trash_deposits_still_fallback_after_matching_disposal_evidence(self) -> None:
|
||||
settings = EngineSettings(
|
||||
camera_id="test_cam",
|
||||
max_dwell_seconds=1200,
|
||||
trash_confirmation_seconds=120,
|
||||
zone_ids=("1", "2"),
|
||||
)
|
||||
engine = BatchEngine(settings)
|
||||
engine.process(obs(self.t0, {"1": 1, "2": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1200), {"1": 1, "2": 1}))
|
||||
engine.process(obs(self.t0 + timedelta(seconds=1300), {"1": 0, "2": 0}))
|
||||
|
||||
events = engine.process(
|
||||
obs(
|
||||
self.t0 + timedelta(seconds=1310),
|
||||
{"1": 0, "2": 0},
|
||||
trash=2,
|
||||
disposal_evidence=[disposal_evidence("1")],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[(event["event"], event["zone_id"]) for event in events],
|
||||
[("batch_discarded", "1"), ("batch_discarded", "2")],
|
||||
)
|
||||
self.assertEqual(engine.pending_disposal, [])
|
||||
|
||||
def test_same_observation_removal_and_disposal_evidence_discards_newly_pending_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},
|
||||
disposal_evidence=[disposal_evidence("1")],
|
||||
)
|
||||
)
|
||||
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]["zone_id"], "1")
|
||||
self.assertEqual(later_events, [])
|
||||
|
||||
def test_low_confidence_disposal_evidence_is_ignored(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},
|
||||
disposal_evidence=[disposal_evidence("1", confidence=0.71)],
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(events, [])
|
||||
self.assertEqual([batch.zone_id for batch in engine.pending_disposal], ["1"])
|
||||
|
||||
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 + timedelta(seconds=11), {"r1c1": 0}))
|
||||
@@ -384,6 +140,96 @@ class BatchEngineTests(unittest.TestCase):
|
||||
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",
|
||||
@@ -504,6 +350,37 @@ class BatchEngineTests(unittest.TestCase):
|
||||
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",
|
||||
|
||||
@@ -3,17 +3,299 @@ from __future__ import annotations
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cold_display_guard.frame_source import FrameCaptureError
|
||||
from cold_display_guard.main import run, restore_runtime_state
|
||||
from cold_display_guard.models import DisposalEvidence
|
||||
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"
|
||||
@@ -114,187 +396,5 @@ class RuntimeRestoreTests(unittest.TestCase):
|
||||
self.assertEqual(baselines["4"].bright_fraction, 0.0)
|
||||
|
||||
|
||||
class RuntimeLoopTests(unittest.TestCase):
|
||||
def test_run_writes_disposal_evidence_and_trajectory_diagnostics(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path, diagnostics_path = write_runtime_config(tmpdir)
|
||||
captured_observations = []
|
||||
tracker_calls = []
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def capture(self) -> Frame:
|
||||
return Frame(width=2, height=2, rgb=bytes([0, 0, 0]) * 4)
|
||||
|
||||
class FakeDetector:
|
||||
def __init__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, object]]:
|
||||
return {"1": 0}, 0, {"zones": {"1": {"occupied": False}}}
|
||||
|
||||
class FakeTracker:
|
||||
def __init__(self, *args: object) -> None:
|
||||
self.has_active_candidates = False
|
||||
|
||||
def observe(
|
||||
self,
|
||||
frame: Frame,
|
||||
when: datetime,
|
||||
zone_counts: dict[str, int],
|
||||
) -> tuple[list[DisposalEvidence], dict[str, object]]:
|
||||
tracker_calls.append(zone_counts)
|
||||
return [
|
||||
DisposalEvidence(
|
||||
source_zone_id="1",
|
||||
target="trash",
|
||||
confidence=0.9,
|
||||
method="motion",
|
||||
track_points=[{"x": 0.1, "y": 0.2}],
|
||||
item_class=None,
|
||||
detector_score=None,
|
||||
observed_at=when.isoformat(),
|
||||
)
|
||||
], {"active_candidates": 0, "emitted_evidence": 1}
|
||||
|
||||
class FakeEngine:
|
||||
def __init__(self, settings: object) -> None:
|
||||
pass
|
||||
|
||||
def process(self, observation: object) -> list[dict[str, object]]:
|
||||
captured_observations.append(observation)
|
||||
return []
|
||||
|
||||
with patch("cold_display_guard.main.RTSPFrameSource", FakeSource), patch(
|
||||
"cold_display_guard.main.ZoneOccupancyDetector", FakeDetector
|
||||
), patch("cold_display_guard.main.TrajectoryTracker", FakeTracker), patch(
|
||||
"cold_display_guard.main.BatchEngine", FakeEngine
|
||||
):
|
||||
run(config_path, max_iterations=1)
|
||||
|
||||
diagnostics = [json.loads(line) for line in diagnostics_path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
self.assertEqual(len(captured_observations), 1)
|
||||
self.assertEqual(tracker_calls, [{"1": 0}])
|
||||
self.assertEqual(captured_observations[0].disposal_evidence[0].source_zone_id, "1")
|
||||
self.assertEqual(diagnostics[0]["disposal_evidence"][0]["source_zone_id"], "1")
|
||||
self.assertEqual(diagnostics[0]["disposal_evidence"][0]["target"], "trash")
|
||||
self.assertEqual(diagnostics[0]["diagnostics"]["trajectory"]["emitted_evidence"], 1)
|
||||
|
||||
def test_run_uses_trajectory_sample_interval_when_candidates_are_active(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path, _ = write_runtime_config(tmpdir, sample_interval=5.0, trajectory_interval=1.0)
|
||||
sleeps = []
|
||||
tracker_calls = []
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def capture(self) -> Frame:
|
||||
return Frame(width=2, height=2, rgb=bytes([0, 0, 0]) * 4)
|
||||
|
||||
class FakeDetector:
|
||||
def __init__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def observe(self, frame: Frame, when: datetime) -> tuple[dict[str, int], int, dict[str, object]]:
|
||||
return {"1": 0}, 0, {}
|
||||
|
||||
class FakeTracker:
|
||||
def __init__(self, *args: object) -> None:
|
||||
self.has_active_candidates = False
|
||||
|
||||
def observe(
|
||||
self,
|
||||
frame: Frame,
|
||||
when: datetime,
|
||||
zone_counts: dict[str, int],
|
||||
) -> tuple[list[DisposalEvidence], dict[str, object]]:
|
||||
tracker_calls.append(zone_counts)
|
||||
self.has_active_candidates = True
|
||||
return [], {"active_candidates": 1}
|
||||
|
||||
with patch("cold_display_guard.main.RTSPFrameSource", FakeSource), patch(
|
||||
"cold_display_guard.main.ZoneOccupancyDetector", FakeDetector
|
||||
), patch("cold_display_guard.main.TrajectoryTracker", FakeTracker), patch(
|
||||
"cold_display_guard.main.time.sleep", sleeps.append
|
||||
):
|
||||
run(config_path, max_iterations=2)
|
||||
|
||||
self.assertEqual(tracker_calls, [{"1": 0}, {"1": 0}])
|
||||
self.assertEqual(sleeps, [1.0])
|
||||
|
||||
def test_capture_failure_diagnostics_keep_trajectory_schema(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path, diagnostics_path = write_runtime_config(tmpdir)
|
||||
|
||||
class FailingSource:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
def capture(self) -> Frame:
|
||||
raise FrameCaptureError("camera offline")
|
||||
|
||||
with patch("cold_display_guard.main.RTSPFrameSource", FailingSource):
|
||||
run(config_path, max_iterations=1)
|
||||
|
||||
diagnostics = [json.loads(line) for line in diagnostics_path.read_text(encoding="utf-8").splitlines()]
|
||||
|
||||
self.assertEqual(diagnostics[0]["error"], "frame_capture_failed")
|
||||
self.assertEqual(diagnostics[0]["disposal_evidence"], [])
|
||||
self.assertEqual(diagnostics[0]["diagnostics"]["trajectory"]["reason"], "frame_capture_failed")
|
||||
|
||||
|
||||
def write_runtime_config(
|
||||
tmpdir: str,
|
||||
sample_interval: float = 5.0,
|
||||
trajectory_interval: float = 1.0,
|
||||
) -> tuple[Path, Path]:
|
||||
root = Path(tmpdir)
|
||||
event_path = root / "events.jsonl"
|
||||
diagnostics_path = root / "runtime_diagnostics.jsonl"
|
||||
config_path = root / "config.toml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'camera_id = "test-camera"',
|
||||
'timezone = "UTC"',
|
||||
"",
|
||||
"[stream]",
|
||||
'rtsp_url = "rtsp://example.invalid/stream"',
|
||||
"",
|
||||
"[thresholds]",
|
||||
"max_dwell_seconds = 1200",
|
||||
"trash_confirmation_seconds = 120",
|
||||
"",
|
||||
"[layout]",
|
||||
"zone_count = 1",
|
||||
'zone_ids = ["1"]',
|
||||
"",
|
||||
"[[zones]]",
|
||||
'id = "1"',
|
||||
"polygon = [[0.0, 0.0], [0.5, 0.0], [0.5, 0.5], [0.0, 0.5]]",
|
||||
"",
|
||||
"[trash]",
|
||||
"roi = [[0.6, 0.6], [1.0, 0.6], [1.0, 1.0], [0.6, 1.0]]",
|
||||
"",
|
||||
"[runtime]",
|
||||
f"sample_interval_seconds = {sample_interval}",
|
||||
f"trajectory_sample_interval_seconds = {trajectory_interval}",
|
||||
f'diagnostics_path = "{diagnostics_path}"',
|
||||
"",
|
||||
"[event_sink]",
|
||||
f'path = "{event_path}"',
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return config_path, diagnostics_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
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.manage_api import ManageContext, build_summary
|
||||
from cold_display_guard.manage_api import ManageContext, build_summary, config_payload, create_handler
|
||||
|
||||
|
||||
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:
|
||||
data = {
|
||||
"camera_id": "cam",
|
||||
@@ -350,6 +383,392 @@ class ManageApiTests(unittest.TestCase):
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -8,9 +8,9 @@ from cold_display_guard.vision import (
|
||||
Region,
|
||||
RegionMetrics,
|
||||
RuntimeVisionSettings,
|
||||
TrajectoryTracker,
|
||||
ZoneOccupancyDetector,
|
||||
load_runtime_vision_settings,
|
||||
metrics_indicate_occupied,
|
||||
point_in_polygon,
|
||||
)
|
||||
|
||||
@@ -39,11 +39,6 @@ def multi_patched_frame(width: int, height: int, base: int, patches: list[tuple[
|
||||
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||
|
||||
|
||||
def frame_with_motion_patch(width: int, height: int, top_left: tuple[int, int]) -> Frame:
|
||||
x, y = top_left
|
||||
return patched_frame(width, height, 40, (x, y, x + 8, y + 8, 180))
|
||||
|
||||
|
||||
class VisionTests(unittest.TestCase):
|
||||
def test_point_in_polygon(self) -> None:
|
||||
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||
@@ -158,81 +153,37 @@ class VisionTests(unittest.TestCase):
|
||||
self.assertEqual(first_empty_counts, {"1": 1})
|
||||
self.assertEqual(second_empty_counts, {"1": 0})
|
||||
|
||||
def test_detector_ignores_global_lighting_dimming_across_many_zones(self) -> None:
|
||||
regions = [
|
||||
Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0)))
|
||||
for index in range(7)
|
||||
]
|
||||
detector = ZoneOccupancyDetector(
|
||||
regions,
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=2,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=18,
|
||||
occupancy_confirm_frames=2,
|
||||
empty_confirm_frames=2,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 6, 1, 4, 55, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(70, 20, 180), now)
|
||||
first_counts, _, first_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=5))
|
||||
second_counts, _, second_diagnostics = detector.observe(solid_frame(70, 20, 100), now + timedelta(seconds=10))
|
||||
|
||||
self.assertEqual(first_counts, {str(index): 0 for index in range(1, 8)})
|
||||
self.assertEqual(second_counts, {str(index): 0 for index in range(1, 8)})
|
||||
self.assertTrue(first_diagnostics["lighting_shift"]["active"])
|
||||
self.assertTrue(second_diagnostics["lighting_shift"]["active"])
|
||||
self.assertTrue(all(not zone["raw_occupied"] for zone in second_diagnostics["zones"].values()))
|
||||
|
||||
def test_detector_allows_single_zone_object_while_lighting_guard_is_available(self) -> None:
|
||||
regions = [
|
||||
Region(str(index + 1), ((index / 7, 0.0), ((index + 1) / 7, 0.0), ((index + 1) / 7, 1.0), (index / 7, 1.0)))
|
||||
for index in range(7)
|
||||
]
|
||||
detector = ZoneOccupancyDetector(
|
||||
regions,
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=2,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=100,
|
||||
occupancy_dark_luma_threshold=80,
|
||||
occupancy_dark_fraction=0.06,
|
||||
occupancy_confirm_frames=2,
|
||||
empty_confirm_frames=2,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 6, 1, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(70, 20, 180), now)
|
||||
object_frame = patched_frame(70, 20, 180, (0, 0, 10, 20, 20))
|
||||
detector.observe(object_frame, now + timedelta(seconds=5))
|
||||
counts, _, diagnostics = detector.observe(object_frame, now + timedelta(seconds=10))
|
||||
|
||||
self.assertEqual(counts["1"], 1)
|
||||
self.assertEqual(sum(counts.values()), 1)
|
||||
self.assertFalse(diagnostics["lighting_shift"]["active"])
|
||||
self.assertTrue(diagnostics["zones"]["1"]["raw_occupied"])
|
||||
|
||||
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)
|
||||
self.assertTrue(settings.lighting_shift_guard_enabled)
|
||||
self.assertEqual(settings.lighting_shift_min_regions, 3)
|
||||
self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.6)
|
||||
self.assertEqual(settings.lighting_shift_mean_delta, 45.0)
|
||||
|
||||
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(
|
||||
@@ -341,426 +292,6 @@ class VisionTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(zone["dark_fraction"], 0.06)
|
||||
self.assertGreaterEqual(zone["bright_fraction"], 0.18)
|
||||
|
||||
def test_motion_track_from_source_zone_to_trash_roi_emits_evidence(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
trajectory_min_confidence=0.72,
|
||||
trajectory_motion_delta=20.0,
|
||||
trajectory_min_blob_area=12,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
all_evidence: list[object] = []
|
||||
emitted_evidence_count = 0
|
||||
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36), (66, 36)]):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
emitted_evidence_count += diagnostics["emitted_evidence"]
|
||||
|
||||
self.assertTrue(all_evidence)
|
||||
emitted = all_evidence[0]
|
||||
self.assertEqual(emitted.source_zone_id, "source")
|
||||
self.assertEqual(emitted.target, "trash")
|
||||
self.assertEqual(emitted.method, "motion")
|
||||
self.assertGreaterEqual(emitted.confidence, 0.72)
|
||||
self.assertGreaterEqual(len(emitted.track_points), 3)
|
||||
self.assertGreaterEqual(emitted_evidence_count, 1)
|
||||
|
||||
def test_segmented_source_to_trash_track_survives_temporary_occlusion(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
trajectory_min_confidence=0.72,
|
||||
trajectory_motion_delta=20.0,
|
||||
trajectory_min_blob_area=12,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 6, 1, 10, 55, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
source_motion_frame = frame_with_motion_patch(80, 80, (16, 36))
|
||||
first_evidence, _ = tracker.observe(source_motion_frame, now + timedelta(seconds=1), {"source": 0})
|
||||
occluded_evidence, occluded_diagnostics = tracker.observe(source_motion_frame, now + timedelta(seconds=2), {"source": 0})
|
||||
trash_evidence, trash_diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, (66, 36)),
|
||||
now + timedelta(seconds=3),
|
||||
{"source": 0},
|
||||
)
|
||||
|
||||
self.assertEqual(first_evidence, [])
|
||||
self.assertEqual(occluded_evidence, [])
|
||||
self.assertEqual(occluded_diagnostics["active_candidates"], 1)
|
||||
self.assertEqual(len(trash_evidence), 1)
|
||||
emitted = trash_evidence[0]
|
||||
self.assertEqual(emitted.source_zone_id, "source")
|
||||
self.assertEqual(emitted.target, "trash")
|
||||
self.assertEqual(emitted.method, "motion")
|
||||
self.assertEqual(len(emitted.track_points), 2)
|
||||
self.assertGreaterEqual(emitted.confidence, 0.72)
|
||||
self.assertEqual(trash_diagnostics["emitted"][0]["reason"], "emitted")
|
||||
self.assertTrue(trash_diagnostics["emitted"][0]["segmented"])
|
||||
|
||||
def test_recent_source_motion_seeds_track_when_empty_confirmation_lags(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
trajectory_min_confidence=0.72,
|
||||
trajectory_motion_delta=20.0,
|
||||
trajectory_min_blob_area=12,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 6, 1, 11, 25, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (16, 36)), now + timedelta(seconds=1), {"source": 1})
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, (66, 36)),
|
||||
now + timedelta(seconds=2),
|
||||
{"source": 0},
|
||||
)
|
||||
|
||||
self.assertEqual(len(evidence), 1)
|
||||
self.assertEqual(evidence[0].source_zone_id, "source")
|
||||
self.assertEqual(len(evidence[0].track_points), 2)
|
||||
self.assertTrue(diagnostics["emitted"][0]["segmented"])
|
||||
self.assertTrue(diagnostics["emitted"][0]["source_seeded"])
|
||||
|
||||
def test_motion_that_starts_away_from_source_zone_is_rejected(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (50, 10)), now, {"source": 1})
|
||||
all_evidence: list[object] = []
|
||||
rejected_candidates = 0
|
||||
for index, point in enumerate([(52, 14), (56, 20), (60, 28), (66, 36)]):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
rejected_candidates += diagnostics["rejected_candidates"]
|
||||
|
||||
self.assertEqual(all_evidence, [])
|
||||
self.assertGreaterEqual(rejected_candidates, 1)
|
||||
|
||||
def test_motion_that_never_reaches_trash_roi_is_rejected(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_window_seconds=3,
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
all_evidence: list[object] = []
|
||||
diagnostics = {}
|
||||
for index, point in enumerate([(16, 36), (26, 36), (36, 36), (42, 36), (46, 36)]):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
|
||||
self.assertEqual(all_evidence, [])
|
||||
self.assertGreaterEqual(diagnostics["expired_candidates"], 1)
|
||||
self.assertGreaterEqual(diagnostics["rejected_candidates"], 1)
|
||||
|
||||
def test_one_frame_reflection_flash_is_rejected(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(solid_frame(80, 80, 40), now, {"source": 1})
|
||||
flash_frame = patched_frame(80, 80, 40, (56, 28, 72, 52, 255))
|
||||
evidence, diagnostics = tracker.observe(flash_frame, now + timedelta(seconds=1), {"source": 0})
|
||||
later_evidence, later_diagnostics = tracker.observe(solid_frame(80, 80, 40), now + timedelta(seconds=2), {"source": 0})
|
||||
|
||||
self.assertEqual(evidence, [])
|
||||
self.assertEqual(later_evidence, [])
|
||||
self.assertEqual(diagnostics["emitted_evidence"], 0)
|
||||
self.assertEqual(later_diagnostics["emitted_evidence"], 0)
|
||||
self.assertEqual(later_diagnostics["rejected_candidates"], 0)
|
||||
|
||||
def test_multiple_active_candidates_do_not_cross_close_each_other(self) -> None:
|
||||
left = Region("left", ((0.05, 0.15), (0.25, 0.15), (0.25, 0.35), (0.05, 0.35)))
|
||||
right = Region("right", ((0.05, 0.65), (0.25, 0.65), (0.25, 0.85), (0.05, 0.85)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[left, right],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(
|
||||
multi_patched_frame(80, 80, 40, [(8, 18, 16, 26, 180), (8, 54, 16, 62, 180)]),
|
||||
now,
|
||||
{"left": 1, "right": 1},
|
||||
)
|
||||
all_evidence = []
|
||||
frames = [
|
||||
[(16, 20, 24, 28, 180), (18, 54, 26, 62, 180)],
|
||||
[(28, 24, 36, 32, 180), (30, 52, 38, 60, 180)],
|
||||
[(44, 30, 52, 38, 180), (44, 50, 52, 58, 180)],
|
||||
[(60, 36, 68, 44, 180), (60, 50, 68, 58, 180)],
|
||||
]
|
||||
for index, patches in enumerate(frames):
|
||||
evidence, _ = tracker.observe(
|
||||
multi_patched_frame(80, 80, 40, patches),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"left": 0, "right": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
|
||||
self.assertEqual({item.source_zone_id for item in all_evidence}, {"left", "right"})
|
||||
self.assertEqual(len(all_evidence), 2)
|
||||
|
||||
def test_multiple_active_candidates_do_not_emit_same_motion_track(self) -> None:
|
||||
first = Region("first", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
second = Region("second", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[first, second],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"first": 1, "second": 1})
|
||||
all_evidence = []
|
||||
rejected = []
|
||||
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36), (66, 36)]):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"first": 0, "second": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
rejected.extend(diagnostics["rejected"])
|
||||
|
||||
tracks = [tuple((point["x"], point["y"]) for point in item.track_points) for item in all_evidence]
|
||||
self.assertLessEqual(len(all_evidence), 1)
|
||||
self.assertEqual(len(tracks), len(set(tracks)))
|
||||
self.assertTrue(any(item["reason"] == "ambiguous_motion_track" for item in rejected))
|
||||
|
||||
def test_motion_inside_source_margin_but_outside_source_polygon_is_rejected(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (20, 36)), now, {"source": 1})
|
||||
all_evidence = []
|
||||
rejected = []
|
||||
for index, point in enumerate([(24, 36), (36, 36), (50, 36), (66, 36), (70, 36)]):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
rejected.extend(diagnostics["rejected"])
|
||||
|
||||
self.assertEqual(all_evidence, [])
|
||||
self.assertTrue(any(item["reason"] == "motion_started_outside_source" for item in rejected))
|
||||
|
||||
def test_motion_before_source_motion_cannot_seed_later_trash_evidence(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(trajectory_sample_interval_seconds=0.0, trajectory_min_points=3),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(solid_frame(80, 80, 40), now, {"source": 1})
|
||||
all_evidence = []
|
||||
rejected = []
|
||||
frames = [
|
||||
frame_with_motion_patch(80, 80, (34, 36)),
|
||||
frame_with_motion_patch(80, 80, (16, 36)),
|
||||
frame_with_motion_patch(80, 80, (34, 36)),
|
||||
frame_with_motion_patch(80, 80, (50, 36)),
|
||||
frame_with_motion_patch(80, 80, (66, 36)),
|
||||
]
|
||||
for index, frame in enumerate(frames):
|
||||
evidence, diagnostics = tracker.observe(
|
||||
frame,
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
all_evidence.extend(evidence)
|
||||
rejected.extend(diagnostics["rejected"])
|
||||
|
||||
self.assertEqual(all_evidence, [])
|
||||
self.assertTrue(any(item["reason"] == "motion_started_outside_source" for item in rejected))
|
||||
|
||||
def test_trajectory_diagnostics_include_per_candidate_events(self) -> None:
|
||||
source = Region("source", ((0.05, 0.35), (0.25, 0.35), (0.25, 0.65), (0.05, 0.65)))
|
||||
trash = Region("trash", ((0.72, 0.35), (0.95, 0.35), (0.95, 0.65), (0.72, 0.65)))
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_window_seconds=3,
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
emitted_diagnostics = {}
|
||||
for index, point in enumerate([(16, 36), (28, 36), (42, 36), (58, 36)]):
|
||||
_, emitted_diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
emitted_event = emitted_diagnostics["emitted"][0]
|
||||
self.assert_candidate_event(emitted_event, "source", "emitted")
|
||||
|
||||
tracker = TrajectoryTracker(
|
||||
[source],
|
||||
trash,
|
||||
RuntimeVisionSettings(
|
||||
trajectory_window_seconds=2,
|
||||
trajectory_sample_interval_seconds=0.0,
|
||||
trajectory_min_points=3,
|
||||
),
|
||||
)
|
||||
tracker.observe(frame_with_motion_patch(80, 80, (8, 36)), now, {"source": 1})
|
||||
rejected_diagnostics = {}
|
||||
for index, point in enumerate([(16, 36), (26, 36), (36, 36), (42, 36)]):
|
||||
_, rejected_diagnostics = tracker.observe(
|
||||
frame_with_motion_patch(80, 80, point),
|
||||
now + timedelta(seconds=index + 1),
|
||||
{"source": 0},
|
||||
)
|
||||
expired_event = rejected_diagnostics["expired"][0]
|
||||
rejected_event = rejected_diagnostics["rejected"][0]
|
||||
self.assert_candidate_event(expired_event, "source", "expired")
|
||||
self.assert_candidate_event(rejected_event, "source", "did_not_reach_trash")
|
||||
|
||||
def assert_candidate_event(self, event: dict[str, object], source_zone_id: str, reason: str) -> None:
|
||||
self.assertEqual(event["source_zone_id"], source_zone_id)
|
||||
self.assertEqual(event["reason"], reason)
|
||||
self.assertIn("point_count", event)
|
||||
self.assertIn("confidence", event)
|
||||
self.assertIn("direction_score", event)
|
||||
|
||||
def test_runtime_vision_defaults_include_trajectory_and_yolo_fields(self) -> None:
|
||||
settings = load_runtime_vision_settings({})
|
||||
|
||||
self.assertTrue(settings.trajectory_enabled)
|
||||
self.assertEqual(settings.trajectory_window_seconds, 8)
|
||||
self.assertEqual(settings.trajectory_sample_interval_seconds, 1.0)
|
||||
self.assertEqual(settings.trajectory_min_points, 3)
|
||||
self.assertTrue(settings.trajectory_segmented_enabled)
|
||||
self.assertEqual(settings.trajectory_segmented_min_points, 2)
|
||||
self.assertEqual(settings.trajectory_min_confidence, 0.72)
|
||||
self.assertEqual(settings.trajectory_motion_delta, 20.0)
|
||||
self.assertEqual(settings.trajectory_min_blob_area, 12)
|
||||
self.assertEqual(settings.trajectory_max_blob_area_fraction, 0.35)
|
||||
self.assertEqual(settings.trajectory_trash_entry_margin, 0.04)
|
||||
self.assertEqual(settings.trajectory_backend, "motion")
|
||||
self.assertFalse(settings.yolo_enabled)
|
||||
self.assertEqual(settings.yolo_model_path, "")
|
||||
self.assertEqual(settings.yolo_min_confidence, 0.65)
|
||||
|
||||
def test_runtime_vision_settings_read_trajectory_and_yolo_fields_from_config(self) -> None:
|
||||
settings = load_runtime_vision_settings(
|
||||
{
|
||||
"runtime": {
|
||||
"trajectory_enabled": False,
|
||||
"trajectory_window_seconds": 11,
|
||||
"trajectory_sample_interval_seconds": 0.5,
|
||||
"trajectory_min_points": 4,
|
||||
"trajectory_segmented_enabled": False,
|
||||
"trajectory_segmented_min_points": 3,
|
||||
"trajectory_min_confidence": 0.8,
|
||||
"trajectory_motion_delta": 25.0,
|
||||
"trajectory_min_blob_area": 20,
|
||||
"trajectory_max_blob_area_fraction": 0.25,
|
||||
"trajectory_trash_entry_margin": 0.02,
|
||||
"trajectory_backend": "motion",
|
||||
"yolo_enabled": True,
|
||||
"yolo_model_path": "models/yolo.onnx",
|
||||
"yolo_min_confidence": 0.7,
|
||||
"lighting_shift_guard_enabled": False,
|
||||
"lighting_shift_min_regions": 4,
|
||||
"lighting_shift_region_fraction": 0.75,
|
||||
"lighting_shift_mean_delta": 60.0,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertFalse(settings.trajectory_enabled)
|
||||
self.assertEqual(settings.trajectory_window_seconds, 11)
|
||||
self.assertEqual(settings.trajectory_sample_interval_seconds, 0.5)
|
||||
self.assertEqual(settings.trajectory_min_points, 4)
|
||||
self.assertFalse(settings.trajectory_segmented_enabled)
|
||||
self.assertEqual(settings.trajectory_segmented_min_points, 3)
|
||||
self.assertEqual(settings.trajectory_min_confidence, 0.8)
|
||||
self.assertEqual(settings.trajectory_motion_delta, 25.0)
|
||||
self.assertEqual(settings.trajectory_min_blob_area, 20)
|
||||
self.assertEqual(settings.trajectory_max_blob_area_fraction, 0.25)
|
||||
self.assertEqual(settings.trajectory_trash_entry_margin, 0.02)
|
||||
self.assertEqual(settings.trajectory_backend, "motion")
|
||||
self.assertTrue(settings.yolo_enabled)
|
||||
self.assertEqual(settings.yolo_model_path, "models/yolo.onnx")
|
||||
self.assertEqual(settings.yolo_min_confidence, 0.7)
|
||||
self.assertFalse(settings.lighting_shift_guard_enabled)
|
||||
self.assertEqual(settings.lighting_shift_min_regions, 4)
|
||||
self.assertAlmostEqual(settings.lighting_shift_region_fraction, 0.75)
|
||||
self.assertEqual(settings.lighting_shift_mean_delta, 60.0)
|
||||
|
||||
|
||||
if __name__ == "__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()
|
||||
@@ -2,11 +2,14 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
resolver 127.0.0.11 ipv6=off valid=10s;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://cold-display-guard-api:19080;
|
||||
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;
|
||||
|
||||
109
web/src/main.js
109
web/src/main.js
@@ -3,6 +3,8 @@ import {
|
||||
TRASH_REGION_ID,
|
||||
alarmMinutesToSeconds,
|
||||
buildCalibrationPayload,
|
||||
buildCaseDisplayModel,
|
||||
buildManualHandlePayload,
|
||||
buildPolygonMap,
|
||||
buildRuntimeDisplayModel,
|
||||
clampZoneCount,
|
||||
@@ -31,6 +33,8 @@ const state = {
|
||||
config: null,
|
||||
summary: null,
|
||||
events: [],
|
||||
cases: [],
|
||||
caseSummary: null,
|
||||
activeTab: "events",
|
||||
activeRegion: "1",
|
||||
foodZones: defaultFoodZones,
|
||||
@@ -147,6 +151,11 @@ app.innerHTML = `
|
||||
<div class="panel-title">最近事件</div>
|
||||
<div id="eventsTable" class="events-table"></div>
|
||||
</section>
|
||||
<section class="panel case-panel">
|
||||
<div class="panel-meta">CASE WORKFLOW</div>
|
||||
<div class="panel-title">处置单</div>
|
||||
<div id="casesTable" class="events-table"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="settingsView" class="view hidden">
|
||||
@@ -216,6 +225,7 @@ const els = {
|
||||
runtimeProgress: document.querySelector("#runtimeProgress"),
|
||||
metrics: document.querySelector("#metrics"),
|
||||
eventsTable: document.querySelector("#eventsTable"),
|
||||
casesTable: document.querySelector("#casesTable"),
|
||||
statusPill: document.querySelector("#statusPill"),
|
||||
activeRegionBadge: document.querySelector("#activeRegionBadge"),
|
||||
};
|
||||
@@ -245,6 +255,7 @@ function wireEvents() {
|
||||
document.querySelector("#clearRegion").addEventListener("click", clearRegion);
|
||||
document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
|
||||
els.canvas.addEventListener("click", addPoint);
|
||||
els.casesTable.addEventListener("click", handleCaseTableClick);
|
||||
window.addEventListener("resize", drawCanvas);
|
||||
els.foodZoneCount.addEventListener("input", () => updateFoodZoneCount(els.foodZoneCount.value));
|
||||
[els.rtspUrl, els.settingsRtspUrl, els.cameraId, els.timezone, els.maxDwell, els.trashWindow].forEach((input) => {
|
||||
@@ -309,9 +320,11 @@ async function refreshRuntimeDataSilently() {
|
||||
}
|
||||
|
||||
async function loadRuntimeData() {
|
||||
const [summaryResult, eventsResult] = await Promise.allSettled([
|
||||
const [summaryResult, eventsResult, casesResult, caseSummaryResult] = await Promise.allSettled([
|
||||
apiJson("/api/manage/summary"),
|
||||
apiJson("/api/manage/events?limit=1000"),
|
||||
apiJson("/api/manage/cases?limit=1000"),
|
||||
apiJson("/api/manage/cases/summary"),
|
||||
]);
|
||||
const errors = [];
|
||||
if (summaryResult.status === "fulfilled") {
|
||||
@@ -326,6 +339,18 @@ async function loadRuntimeData() {
|
||||
state.events = [];
|
||||
errors.push(`events ${errorMessage(eventsResult.reason)}`);
|
||||
}
|
||||
if (casesResult.status === "fulfilled") {
|
||||
state.cases = casesResult.value.items || [];
|
||||
} else {
|
||||
state.cases = [];
|
||||
errors.push(`cases ${errorMessage(casesResult.reason)}`);
|
||||
}
|
||||
if (caseSummaryResult.status === "fulfilled") {
|
||||
state.caseSummary = caseSummaryResult.value;
|
||||
} else {
|
||||
state.caseSummary = null;
|
||||
errors.push(`case summary ${errorMessage(caseSummaryResult.reason)}`);
|
||||
}
|
||||
state.runtimeDemoReason = errors.length ? errors.join(";") : "";
|
||||
}
|
||||
|
||||
@@ -515,12 +540,21 @@ function buildRuntimeModel() {
|
||||
});
|
||||
}
|
||||
|
||||
function buildCaseModel() {
|
||||
return buildCaseDisplayModel({
|
||||
summary: state.caseSummary,
|
||||
cases: state.cases,
|
||||
});
|
||||
}
|
||||
|
||||
function renderRuntimeSections() {
|
||||
const runtimeModel = buildRuntimeModel();
|
||||
const caseModel = buildCaseModel();
|
||||
renderRuntimeOverview(runtimeModel);
|
||||
renderMetrics(runtimeModel);
|
||||
renderMetrics(runtimeModel, caseModel);
|
||||
renderRuntimeProgress(runtimeModel);
|
||||
renderEvents(runtimeModel);
|
||||
renderCases(caseModel);
|
||||
}
|
||||
|
||||
function renderRegionList() {
|
||||
@@ -739,12 +773,13 @@ function renderRuntimeOverview(model) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMetrics(model) {
|
||||
function renderMetrics(model, caseModel) {
|
||||
const metrics = model.summary?.metrics || {};
|
||||
const alertCount = metrics.alert_count ?? 0;
|
||||
const warningCount = metrics.warning_count ?? 0;
|
||||
const violationCount = metrics.violation_count ?? 0;
|
||||
const baselineReady = Boolean(metrics.baseline_ready);
|
||||
const caseMetrics = caseModel?.metrics || {};
|
||||
const metricLabel = (label) => label;
|
||||
const cards = [
|
||||
{label: metricLabel("事件总数"), value: metrics.event_count ?? 0, tone: "neutral"},
|
||||
@@ -754,6 +789,11 @@ function renderMetrics(model) {
|
||||
{label: metricLabel("诊断帧数"), value: metrics.diagnostics_count ?? 0, tone: "neutral"},
|
||||
{label: metricLabel("基线状态"), value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"},
|
||||
{label: metricLabel("最新报警"), value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"},
|
||||
{label: metricLabel("待处理处置单"), value: caseMetrics.openCaseCount ?? 0, tone: (caseMetrics.openCaseCount ?? 0) > 0 ? "warning" : "good"},
|
||||
{label: metricLabel("已处理处置单"), value: caseMetrics.handledCaseCount ?? 0, tone: "good"},
|
||||
{label: metricLabel("超时报警单"), value: caseMetrics.timeAlarmCaseCount ?? 0, tone: "neutral"},
|
||||
{label: metricLabel("待丢弃确认单"), value: caseMetrics.pendingDisposalCaseCount ?? 0, tone: "neutral"},
|
||||
{label: metricLabel("升级警告单"), value: caseMetrics.warningEscalatedCaseCount ?? 0, tone: (caseMetrics.warningEscalatedCaseCount ?? 0) > 0 ? "danger" : "good"},
|
||||
{label: metricLabel("事件文件"), value: metrics.events_path || "-", tone: "path"},
|
||||
];
|
||||
const zoneCounts = metrics.latest_zone_counts || {};
|
||||
@@ -834,6 +874,36 @@ function renderEvents(model) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCases(model) {
|
||||
if (!model.rows.length) {
|
||||
els.casesTable.innerHTML = `<div class="empty">还没有处置单数据</div>`;
|
||||
return;
|
||||
}
|
||||
els.casesTable.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>处置单</th><th>类型</th><th>状态</th><th>区域</th><th>批次</th><th>更新时间</th><th>处理来源</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
${model.rows
|
||||
.map((row) => `
|
||||
<tr class="event-row ${row.tone}">
|
||||
<td>${escapeHtml(row.caseId)}</td>
|
||||
<td>${escapeHtml(row.typeLabel)}</td>
|
||||
<td><span class="event-severity ${row.tone}">${escapeHtml(row.statusLabel)}</span></td>
|
||||
<td>${escapeHtml(row.zone_label || "")}</td>
|
||||
<td>${escapeHtml(row.batch_id || "")}</td>
|
||||
<td>${escapeHtml(row.updated_at || "")}</td>
|
||||
<td>${escapeHtml(row.handledSourceLabel || "-")}</td>
|
||||
<td>${row.case_status === "open"
|
||||
? `<button type="button" class="secondary-action" data-handle-case="${escapeHtml(row.caseId)}">标记已处理</button>`
|
||||
: `<span class="event-source real">已完成</span>`}</td>
|
||||
</tr>
|
||||
`)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const value = Number(seconds);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
@@ -898,6 +968,39 @@ async function apiJson(path, options = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
function handleCaseTableClick(event) {
|
||||
const button = event.target.closest("[data-handle-case]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
handleCase(button.dataset.handleCase);
|
||||
}
|
||||
|
||||
async function handleCase(caseId) {
|
||||
const handledBy = window.prompt("请输入处理人");
|
||||
if (handledBy === null) {
|
||||
return;
|
||||
}
|
||||
const trimmedHandledBy = handledBy.trim();
|
||||
if (!trimmedHandledBy) {
|
||||
setStatus("处理人不能为空");
|
||||
return;
|
||||
}
|
||||
const note = window.prompt("请输入处理备注(可选)") || "";
|
||||
try {
|
||||
setStatus("正在更新处置单状态...");
|
||||
await apiJson(`/api/manage/cases/${encodeURIComponent(caseId)}/handle`, {
|
||||
method: "POST",
|
||||
body: buildManualHandlePayload(trimmedHandledBy, note),
|
||||
});
|
||||
await loadRuntimeData();
|
||||
renderRuntimeSections();
|
||||
setStatus("处置单已标记为已处理");
|
||||
} catch (error) {
|
||||
setStatus(`更新处置单失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
state.status = message;
|
||||
els.statusText.textContent = message;
|
||||
|
||||
@@ -158,6 +158,38 @@ export function buildRuntimeDisplayModel({
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -221,6 +253,32 @@ function containsDemoMarker(value) {
|
||||
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",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
TRASH_REGION_ID,
|
||||
alarmMinutesToSeconds,
|
||||
buildCalibrationPayload,
|
||||
buildCaseDisplayModel,
|
||||
buildManualHandlePayload,
|
||||
buildPolygonMap,
|
||||
buildRuntimeDisplayModel,
|
||||
classifyEvent,
|
||||
@@ -628,3 +630,58 @@ test("buildRuntimeDisplayModel uses config threshold when event omits threshold"
|
||||
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