feat: add cold display alarm flow and labeled snapshots

This commit is contained in:
2026-06-15 12:59:25 +08:00
parent 46889c0621
commit 1059850378
15 changed files with 1164 additions and 15 deletions

View File

@@ -2,10 +2,12 @@ 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,
)
@@ -153,6 +155,40 @@ class AlarmSnapshotTests(unittest.TestCase):
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_capture_alert_snapshot_encodes_frame_with_configured_calibration_overlay(self) -> None:
encoded_frames: list[Frame] = []

View File

@@ -48,6 +48,55 @@ class CaseStoreTests(unittest.TestCase):
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")])

View File

@@ -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,6 +122,8 @@ 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])

View File

@@ -140,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",
@@ -260,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",

View File

@@ -80,6 +80,46 @@ class WebhookTests(unittest.TestCase):
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(
{