From be1bcd16ec8f631f3186abdeac93b343df7c636a Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Wed, 10 Jun 2026 10:50:51 +0200 Subject: [PATCH 1/4] feat(detections): add recorded_at column with backfill migration Add a recorded_at timestamp to the detections table, carrying the on-device capture time (UTC), distinct from created_at which stays as the trusted DB insertion clock. New rows default recorded_at to utcnow(). The Alembic migration adds the column as nullable, backfills it from created_at for existing rows, then tightens it to NOT NULL. Refs #510 --- src/app/models.py | 5 +++ src/app/schemas/detections.py | 4 +++ ...9f1a2b3d5_add_recorded_at_to_detections.py | 31 +++++++++++++++++++ src/tests/conftest.py | 4 +++ 4 files changed, 44 insertions(+) create mode 100644 src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py diff --git a/src/app/models.py b/src/app/models.py index 3dea578d9..5288b1223 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -91,6 +91,11 @@ class Detection(SQLModel, table=True): bbox: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH_SINGLE, nullable=False) others_bboxes: Union[str, None] = Field(default=None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS, nullable=True) created_at: datetime = Field(default_factory=utcnow, nullable=False) + recorded_at: datetime = Field( + default_factory=utcnow, + nullable=False, + description="UTC timestamp of when the image was captured on-device. Defaults to created_at when unknown.", + ) class Sequence(SQLModel, table=True): diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index f85f7c71b..2b35a8c01 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -4,6 +4,7 @@ # See LICENSE or go to for full license details. import re +from datetime import datetime from typing import Optional, Union from pydantic import BaseModel, Field @@ -37,6 +38,9 @@ class DetectionCreate(BaseModel): json_schema_extra={"examples": ["[(0.1, 0.1, 0.9, 0.9, 0.5)]"]}, ) others_bboxes: Optional[str] = Field(None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS) + recorded_at: Optional[datetime] = Field( + None, description="UTC timestamp of when the image was captured on-device. Defaults to server now if omitted." + ) class DetectionUrl(BaseModel): diff --git a/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py b/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py new file mode 100644 index 000000000..744384a32 --- /dev/null +++ b/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py @@ -0,0 +1,31 @@ +"""add recorded_at column to detections and backfill from created_at + +Revision ID: c4e9f1a2b3d5 +Revises: b3d8a9c1e2f4 +Create Date: 2026-05-27 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c4e9f1a2b3d5" +down_revision: Union[str, None] = "b3d8a9c1e2f4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add as nullable first so existing rows don't violate the NOT NULL constraint. + op.add_column("detections", sa.Column("recorded_at", sa.DateTime(), nullable=True)) + # Backfill: legacy detections have no capture time, so fall back to the DB insertion time. + op.execute("UPDATE detections SET recorded_at = created_at WHERE recorded_at IS NULL") + # Tighten to NOT NULL to match the SQLModel definition. + op.alter_column("detections", "recorded_at", nullable=False) + + +def downgrade() -> None: + op.drop_column("detections", "recorded_at") diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 7955ce16f..37bee98dd 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -154,6 +154,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), }, { "id": 2, @@ -164,6 +165,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format), }, { "id": 3, @@ -174,6 +176,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), }, { "id": 4, @@ -184,6 +187,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), }, ] From 1fc9ffe8a773c24d768ed9338aca5e83b7a619a6 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Wed, 10 Jun 2026 10:50:57 +0200 Subject: [PATCH 2/4] feat(detections): drive sequence linking off recorded_at Sequence bucketing now keys on each detection's recorded_at instead of created_at, so detections cached on the engine and uploaded in a burst group by when they were captured rather than when they reached the API. The POST /detections payload accepts an optional recorded_at (defaults to server now when the engine omits it), and the candidate-sequence and overlap time-window queries anchor on the new detection's capture time instead of utcnow(). Sequence started_at / last_seen_at and the GET /sequences/{id}/detections ordering follow recorded_at too. Refs #510 --- src/app/api/api_v1/endpoints/detections.py | 26 ++-- src/app/api/api_v1/endpoints/sequences.py | 4 +- src/tests/endpoints/test_detections.py | 148 ++++++++++++++++++++- 3 files changed, 166 insertions(+), 12 deletions(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index a8364b960..c2d12d6a3 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -104,7 +104,7 @@ async def _get_last_bbox_for_sequence( ) -> Optional[Tuple[float, float, float, float, float]]: dets = await detections.fetch_all( filters=("sequence_id", sequence_id), - order_by="created_at", + order_by="recorded_at", order_desc=True, limit=1, ) @@ -358,6 +358,9 @@ async def create_detection( max_length=settings.MAX_BBOX_STR_LENGTH, ), pose_id: int = Form(..., gt=0, description="pose id of the detection"), + recorded_at: Optional[datetime] = Form( + None, description="UTC timestamp of when the image was captured by the engine; defaults to server now if omitted" + ), file: UploadFile = File(..., alias="file"), detections: DetectionCRUD = Depends(get_detection_crud), webhooks: WebhookCRUD = Depends(get_webhook_crud), @@ -393,6 +396,10 @@ async def create_detection( created: List[Detection] = [] camera = cast(Camera, await cameras.get(token_payload.sub, strict=True)) + # The engine may report when the image was actually captured; fall back to now when it doesn't. + # All bboxes from a single upload share the same capture time. + effective_recorded_at = recorded_at or utcnow() + for idx, bbox_str in enumerate(bbox_strings): single_bboxes = _bbox_list_to_str([bbox_str]) other_bbox_strings = bbox_strings[:idx] + bbox_strings[idx + 1 :] @@ -404,6 +411,7 @@ async def create_detection( bucket_key=bucket_key, bbox=single_bboxes, others_bboxes=others_bboxes, + recorded_at=effective_recorded_at, ) ) @@ -415,7 +423,7 @@ async def create_detection( inequality_pair=( "last_seen_at", ">", - utcnow() - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), + effective_recorded_at - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), ), order_by="last_seen_at", order_desc=True, @@ -430,7 +438,7 @@ async def create_detection( break if matched_sequence is not None: - await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.created_at)) + await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.recorded_at)) det = await detections.update(det.id, DetectionSequence(sequence_id=matched_sequence.id)) # Only the primary bbox tracks the sequence; siblings in others_bboxes are unrelated detections. det_max_conf = max_conf_from_bboxes(det.bbox) @@ -445,11 +453,11 @@ async def create_detection( dets_ = await detections.fetch_all( filters=det_filters, inequality_pair=( - "created_at", + "recorded_at", ">", - utcnow() - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS), + effective_recorded_at - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS), ), - order_by="created_at", + order_by="recorded_at", order_desc=False, ) overlapping_dets: List[Detection] = [] @@ -462,7 +470,7 @@ async def create_detection( overlapping_dets.append(cand) if len(overlapping_dets) >= settings.SEQUENCE_MIN_INTERVAL_DETS: - first_det = min(overlapping_dets, key=lambda item: item.created_at) + first_det = min(overlapping_dets, key=lambda item: item.recorded_at) cone_azimuth, cone_angle = resolve_cone(pose.azimuth, first_det.bbox, camera.angle_of_view) seq_max_conf = max_conf_from_bboxes(*[d.bbox for d in overlapping_dets]) sequence_ = await sequences.create( @@ -472,8 +480,8 @@ async def create_detection( camera_azimuth=pose.azimuth, sequence_azimuth=cone_azimuth, cone_angle=cone_angle, - started_at=first_det.created_at, - last_seen_at=det.created_at, + started_at=first_det.recorded_at, + last_seen_at=det.recorded_at, max_conf=seq_max_conf, ) ) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 5aa8ffa83..0409c6434 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -114,7 +114,7 @@ async def get_sequence( async def fetch_sequence_detections( sequence_id: int = Path(..., gt=0), limit: int = Query(10, description="Maximum number of detections to fetch", ge=1, le=100), - desc: bool = Query(True, description="Whether to order the detections by created_at in descending order"), + desc: bool = Query(True, description="Whether to order the detections by recorded_at in descending order"), cameras: CameraCRUD = Depends(get_camera_crud), detections: DetectionCRUD = Depends(get_detection_crud), sequences: SequenceCRUD = Depends(get_sequence_crud), @@ -135,7 +135,7 @@ async def fetch_sequence_detections( ) for elt in await detections.fetch_all( filters=("sequence_id", sequence_id), - order_by="created_at", + order_by="recorded_at", order_desc=desc, limit=limit, ) diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 374ce6e2c..2f6cd4615 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -2,7 +2,7 @@ import io from ast import literal_eval from collections import Counter -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any, Dict, List, Union import pytest # type: ignore @@ -877,6 +877,7 @@ async def test_create_detection_sequence_flow_direct(detection_session: AsyncSes background_tasks=BackgroundTasks(), bboxes="[(0.2,0.2,0.3,0.3,0.9)]", pose_id=pose_id, + recorded_at=None, file=upload, detections=detections, webhooks=webhooks, @@ -914,6 +915,7 @@ async def fake_fetch_all(*args, **kwargs): background_tasks=BackgroundTasks(), bboxes="[(0.25,0.25,0.35,0.35,0.9)]", pose_id=pose_id, + recorded_at=None, file=upload_again, detections=detections, webhooks=webhooks, @@ -1358,3 +1360,147 @@ async def test_attach_sequence_does_not_bridge_to_distant_alert(detection_sessio mappings_res = await detection_session.exec(select(AlertSequence).where(AlertSequence.alert_id == smoke_a_alert_id)) seqs_in_a = {m.sequence_id for m in mappings_res.all()} assert seq_cam2.id not in seqs_in_a + + +@pytest.mark.asyncio +async def test_create_detection_uses_payload_recorded_at( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + recorded_at = datetime(2024, 1, 15, 10, 30, 0, 123456) + payload = { + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]", + "recorded_at": recorded_at.isoformat(), + } + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + assert det.recorded_at == recorded_at + + +@pytest.mark.asyncio +async def test_create_detection_defaults_recorded_at_to_now( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + payload = {"pose_id": pytest.pose_table[0]["id"], "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]"} + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + # When the engine omits recorded_at it falls back to the server clock, lining up with created_at. + assert abs((det.recorded_at - det.created_at).total_seconds()) < 5 + + +@pytest.mark.asyncio +async def test_sequence_linking_uses_recorded_at( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +): + monkeypatch.setattr(settings, "SEQUENCE_MIN_INTERVAL_DETS", 2) + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + + # Two detections uploaded back-to-back but captured ~2h ago, 30s apart (within SEQUENCE_MIN_INTERVAL_SECONDS). + t1 = utcnow() - timedelta(hours=2) + t2 = t1 + timedelta(seconds=30) + + resp1 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.3,0.3,0.9)]", + "recorded_at": t1.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp1.status_code == 201, resp1.text + assert resp1.json()["sequence_id"] is None + + resp2 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.15,0.15,0.35,0.35,0.9)]", + "recorded_at": t2.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp2.status_code == 201, resp2.text + seq_id = resp2.json()["sequence_id"] + assert isinstance(seq_id, int) + + seq = await detection_session.get(Sequence, seq_id) + assert seq is not None + # Sequence bounds come from recorded_at (capture time), not from created_at (~now). + assert abs((seq.started_at - t1).total_seconds()) < 1 + assert abs((seq.last_seen_at - t2).total_seconds()) < 1 + assert (utcnow() - seq.started_at).total_seconds() > 3600 + + +@pytest.mark.asyncio +async def test_distant_recorded_at_does_not_group_into_sequence( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +): + monkeypatch.setattr(settings, "SEQUENCE_MIN_INTERVAL_DETS", 2) + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + + async def count_sequences() -> int: + res = await detection_session.exec(select(Sequence)) + return len(res.all()) + + base_seq = await count_sequences() + + # Same upload burst, but captured 1h apart — beyond SEQUENCE_MIN_INTERVAL_SECONDS, so they must NOT group. + t1 = utcnow() - timedelta(hours=3) + t2 = t1 + timedelta(hours=1) + + resp1 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.3,0.3,0.9)]", + "recorded_at": t1.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp1.status_code == 201, resp1.text + + resp2 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.15,0.15,0.35,0.35,0.9)]", + "recorded_at": t2.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp2.status_code == 201, resp2.text + assert resp2.json()["sequence_id"] is None + assert await count_sequences() == base_seq From d56a55964eb1a4b0ced25dd89a3608553cf85d0b Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Wed, 10 Jun 2026 11:20:28 +0200 Subject: [PATCH 3/4] feat(detections): normalize recorded_at to UTC Timezone-aware capture timestamps (for example a France-local +02:00 value) are converted to UTC before being stored and before the sequence time-window comparisons, matching the naive-UTC convention used by the rest of the schema. Naive timestamps are assumed to already be UTC. Adds a to_utc_naive helper next to utcnow(). Refs #510 --- src/app/api/api_v1/endpoints/detections.py | 12 ++++++--- src/app/core/time.py | 12 +++++++++ src/tests/endpoints/test_detections.py | 29 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index c2d12d6a3..c2d8ff868 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -40,7 +40,7 @@ get_webhook_crud, ) from app.core.config import settings -from app.core.time import utcnow +from app.core.time import to_utc_naive, utcnow from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, OrganizationCRUD, PoseCRUD, SequenceCRUD, WebhookCRUD from app.models import Alert, AlertSequence, Camera, Detection, Organization, Pose, Role, Sequence, UserRole from app.schemas.alerts import AlertCreate, AlertUpdate @@ -359,7 +359,11 @@ async def create_detection( ), pose_id: int = Form(..., gt=0, description="pose id of the detection"), recorded_at: Optional[datetime] = Form( - None, description="UTC timestamp of when the image was captured by the engine; defaults to server now if omitted" + None, + description=( + "Timestamp of when the image was captured by the engine. Timezone-aware values are " + "converted to UTC; naive values are assumed UTC. Defaults to server now if omitted." + ), ), file: UploadFile = File(..., alias="file"), detections: DetectionCRUD = Depends(get_detection_crud), @@ -397,8 +401,10 @@ async def create_detection( camera = cast(Camera, await cameras.get(token_payload.sub, strict=True)) # The engine may report when the image was actually captured; fall back to now when it doesn't. + # Aware timestamps are normalized to UTC (naive timestamps are assumed to already be UTC) so + # the value matches the DB columns and the time-window comparisons below. # All bboxes from a single upload share the same capture time. - effective_recorded_at = recorded_at or utcnow() + effective_recorded_at = to_utc_naive(recorded_at) if recorded_at is not None else utcnow() for idx, bbox_str in enumerate(bbox_strings): single_bboxes = _bbox_list_to_str([bbox_str]) diff --git a/src/app/core/time.py b/src/app/core/time.py index 82e9c60bb..01b4203ab 100644 --- a/src/app/core/time.py +++ b/src/app/core/time.py @@ -9,3 +9,15 @@ def utcnow() -> datetime: """UTC wall clock, returned as a naive datetime to match existing DB columns.""" return datetime.now(timezone.utc).replace(tzinfo=None) + + +def to_utc_naive(value: datetime) -> datetime: + """Normalize a datetime to naive UTC, matching how DB columns store time. + + A timezone-aware value (e.g. ``2026-05-27T10:00:00+02:00`` from a French engine) is + converted to UTC before the tzinfo is dropped, so it lands at ``08:00:00``. A naive value + is assumed to already be UTC and returned unchanged. + """ + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 2f6cd4615..2a28568d3 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -2,7 +2,7 @@ import io from ast import literal_eval from collections import Counter -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Union import pytest # type: ignore @@ -1387,6 +1387,33 @@ async def test_create_detection_uses_payload_recorded_at( assert det.recorded_at == recorded_at +@pytest.mark.asyncio +async def test_create_detection_converts_aware_recorded_at_to_utc( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + # A France-local (UTC+2) capture time must be stored as the equivalent naive-UTC instant. + aware = datetime(2024, 7, 1, 10, 30, 0, 123456, tzinfo=timezone(timedelta(hours=2))) + payload = { + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]", + "recorded_at": aware.isoformat(), + } + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + assert det.recorded_at == datetime(2024, 7, 1, 8, 30, 0, 123456) + assert det.recorded_at.tzinfo is None + + @pytest.mark.asyncio async def test_create_detection_defaults_recorded_at_to_now( async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes From 25edadcafe97d517d2ce89ddb713251eb528b4e0 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Wed, 10 Jun 2026 11:41:22 +0200 Subject: [PATCH 4/4] fix(e2e): assert sequence started_at against recorded_at The sequence started_at now derives from the detection recorded_at rather than created_at, which come from separate utcnow() calls. Compare against recorded_at so the end-to-end check matches the new behavior. Refs #510 --- scripts/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index 3abf2abc5..a9eaef1fe 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -148,7 +148,7 @@ def main(args): # Check that a sequence has been created sequence = api_request("get", f"{args.endpoint}/sequences/1", agent_auth) assert sequence["camera_id"] == cam_id - assert sequence["started_at"] == response.json()["created_at"] + assert sequence["started_at"] == response.json()["recorded_at"] assert sequence["last_seen_at"] > sequence["started_at"] assert sequence["camera_azimuth"] == pose_azimuth # Fetch the latest sequence