Fix repeat ownership and close out capture evidence
Start each repeat after its own manifest entry, refuse packets from a restarted or stopped camera, keep a simulated repeat from reading as recorded evidence, and decide completeness from the writer report. Add the operator summary (four files plus an atomic pointer) and read-only video review of a hold window. Verified with the full Autocal suite (567 passed), the shared recorder/playback files (57 passed), the lint gate and the documentation gate; the two pre-existing panel_check layout failures also fail on a clean HEAD worktree.
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
"""Synthetic recording evidence for the acquisition, summary and review tests.
|
||||
|
||||
Two rules govern this module:
|
||||
|
||||
* a fixture writes the **shared recorder's real headers**, so a reader is tested
|
||||
against the files it will meet rather than against a convenient subset;
|
||||
* a video fixture is a **real decodable clip**. A file of fake MP4 bytes proves
|
||||
nothing about seeking, decoding or frame identity.
|
||||
|
||||
Nothing here touches the app's preference file, its ``outputs/`` directory or a
|
||||
real camera. Fixtures are built inside the test's ``tmp_path``; the run's
|
||||
``--basetemp`` points at the task scratch directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.record import FORCES_HEADER, FRAMES_HEADER
|
||||
|
||||
#: One working day in nanoseconds: holds inside a fixture are this far apart.
|
||||
HOLD_STRIDE_NS = 10_000_000_000
|
||||
#: A fixed, arbitrary session-relative origin for synthetic host timestamps.
|
||||
BASE_NS = 1_700_000_000_000_000_000
|
||||
|
||||
FRAMES_FIELDS = list(FRAMES_HEADER)
|
||||
FORCES_FIELDS = list(FORCES_HEADER)
|
||||
|
||||
|
||||
def write_csv(path: Path, fields: list[str], rows: list[dict]) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return path
|
||||
|
||||
|
||||
def read_csv(path: Path) -> list[dict]:
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def frame_row(
|
||||
frame_id: int,
|
||||
t_host_mono_ns: int,
|
||||
*,
|
||||
segment: int = 1,
|
||||
segment_frame: int | None = None,
|
||||
in_video: int = 1,
|
||||
dropped: int = 0,
|
||||
time_basis: str = "host_camera",
|
||||
t_rel_s: float = 0.0,
|
||||
) -> dict:
|
||||
return {
|
||||
"frame_id": frame_id,
|
||||
"source_frame_id": frame_id,
|
||||
"t_rel_s": repr(float(t_rel_s)),
|
||||
"t_host_mono_ns": int(t_host_mono_ns),
|
||||
"t_host_utc_ns": int(t_host_mono_ns),
|
||||
"acquired_at": "2026-10-17T00:00:00.000+00:00",
|
||||
"time_basis": time_basis,
|
||||
"media_time_ms": "",
|
||||
"segment": segment if in_video else 0,
|
||||
"segment_frame": (segment_frame if segment_frame is not None else frame_id)
|
||||
if in_video
|
||||
else 0,
|
||||
"in_video": in_video,
|
||||
"dropped": dropped,
|
||||
"capture_dropped": 0,
|
||||
}
|
||||
|
||||
|
||||
def force_row(
|
||||
frame_id: int,
|
||||
*,
|
||||
fn: float | None,
|
||||
fx: float | None = 0.0,
|
||||
fy: float | None = 0.0,
|
||||
valid: int = 1,
|
||||
session_id: int = 7,
|
||||
t_rel_s: float = 0.0,
|
||||
) -> dict:
|
||||
def cell(value):
|
||||
return "" if value is None else repr(float(value))
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"frame_id": frame_id,
|
||||
"t_rel_s": repr(float(t_rel_s)),
|
||||
"FN_raw": cell(fn),
|
||||
"FX_raw": cell(fx),
|
||||
"FY_raw": cell(fy),
|
||||
"FN_N": "",
|
||||
"FX_N": "",
|
||||
"FY_N": "",
|
||||
"valid": valid,
|
||||
"calibration_id": "",
|
||||
"FN_output_raw": "",
|
||||
"FX_output_raw": "",
|
||||
"FY_output_raw": "",
|
||||
"FN_output_N": "",
|
||||
"FX_output_N": "",
|
||||
"FY_output_N": "",
|
||||
"Fmag_raw": "",
|
||||
}
|
||||
|
||||
|
||||
def write_video(path: Path, *, frames: int = 6, fps: float = 10.0) -> Path:
|
||||
"""A real, short, decodable clip whose frame number is visible in the picture.
|
||||
|
||||
Frame ``i`` carries a white 8x8 marker at column ``4 + (i % 6) * 8``. A test
|
||||
can therefore ask the decoder which frame it got instead of trusting a seek.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
size = (64, 48) # width, height
|
||||
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), size)
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError("the MP4 writer is unavailable in this environment")
|
||||
try:
|
||||
for index in range(frames):
|
||||
image = np.zeros((size[1], size[0], 3), dtype=np.uint8)
|
||||
column = 4 + (index % 6) * 8
|
||||
image[4:12, column : column + 8] = 255
|
||||
writer.write(image)
|
||||
finally:
|
||||
writer.release()
|
||||
return path
|
||||
|
||||
|
||||
def decoded_frame_number(path: Path, *, segment_frame: int | None = None) -> int:
|
||||
"""Decode one frame of a :func:`write_video` clip and read its marker column."""
|
||||
import cv2
|
||||
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
try:
|
||||
if segment_frame is not None:
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
|
||||
ok, image = capture.read()
|
||||
if not ok:
|
||||
raise RuntimeError(f"could not decode {path.name}")
|
||||
column = int(image[4:12].sum(axis=(0, 2)).argmax())
|
||||
return (column - 4) // 8
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def write_capture(
|
||||
directory: Path,
|
||||
frames: list[dict],
|
||||
forces: list[dict],
|
||||
*,
|
||||
fields: list[str] | None = None,
|
||||
video_segments: int = 1,
|
||||
video_frames: int = 6,
|
||||
) -> Path:
|
||||
"""Write ``frames.csv``, ``forces.csv`` and one real MP4 per video segment."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
write_csv(directory / "frames.csv", fields or FRAMES_FIELDS, frames)
|
||||
write_csv(directory / "forces.csv", FORCES_FIELDS, forces)
|
||||
for segment in range(1, video_segments + 1):
|
||||
write_video(directory / f"raw_{segment:04d}.mp4", frames=video_frames)
|
||||
write_json_file(
|
||||
directory / "session.json",
|
||||
{
|
||||
"format_version": "1.4",
|
||||
"session_id": forces[0]["session_id"] if forces else 7,
|
||||
"files": {"frames": "frames.csv", "forces": "forces.csv", "video": "raw_%04d.mp4"},
|
||||
},
|
||||
)
|
||||
return directory
|
||||
|
||||
|
||||
def write_json_file(path: Path, payload: dict) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hold:
|
||||
"""One declared reference window as the campaign writes it."""
|
||||
|
||||
step_index: int
|
||||
branch: str
|
||||
target_load_n: float
|
||||
reference_n: float
|
||||
start_ns: int
|
||||
end_ns: int
|
||||
complete: int = 1
|
||||
|
||||
@property
|
||||
def stability_verified(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def reference_row(hold: Hold, *, site_id: str, repeat_index: int, repeat_count: int) -> dict:
|
||||
row = {name: "" for name in (
|
||||
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
|
||||
"t_end_host_mono_ns", "F_ref_normal_N", "site_id", "x", "y", "z",
|
||||
"coordinate_frame", "repeat_index", "repeat_count", "hold_complete",
|
||||
"reference_source", "fx_N", "fy_N", "fz_N", "force_to_tool_rotation",
|
||||
"nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg",
|
||||
"pose_pitch_deg", "pose_z_mm", "normal_aligned_to",
|
||||
)}
|
||||
row.update({
|
||||
"step_index": hold.step_index,
|
||||
"branch": hold.branch,
|
||||
"target_load_n": hold.target_load_n,
|
||||
"t_start_host_mono_ns": hold.start_ns,
|
||||
"t_end_host_mono_ns": hold.end_ns,
|
||||
"F_ref_normal_N": hold.reference_n,
|
||||
"site_id": site_id,
|
||||
"x": 0.0, "y": 0.0, "z": 0.0,
|
||||
"coordinate_frame": "sensor",
|
||||
"repeat_index": repeat_index,
|
||||
"repeat_count": repeat_count,
|
||||
"hold_complete": hold.complete,
|
||||
"reference_source": "weight",
|
||||
})
|
||||
return row
|
||||
|
||||
|
||||
REFERENCE_FIELDS = list(reference_row(
|
||||
Hold(0, "baseline", 0.0, 0.0, 0, 1), site_id="", repeat_index=1, repeat_count=1,
|
||||
).keys())
|
||||
|
||||
|
||||
def holds_for_levels(
|
||||
levels: list[float], *, first_start_ns: int = BASE_NS, hold_ns: int = 4_000_000_000,
|
||||
) -> list[Hold]:
|
||||
"""baseline, one load per level, then unload back to zero."""
|
||||
steps: list[tuple[str, float]] = [("baseline", 0.0)]
|
||||
steps += [("load", float(level)) for level in levels]
|
||||
steps += [("unload", value) for value in [*reversed(levels[:-1]), 0.0]]
|
||||
holds = []
|
||||
for index, (branch, target) in enumerate(steps):
|
||||
start = first_start_ns + index * HOLD_STRIDE_NS
|
||||
holds.append(Hold(index, branch, target, target, start, start + hold_ns))
|
||||
return holds
|
||||
|
||||
|
||||
def hold_samples(
|
||||
hold: Hold,
|
||||
*,
|
||||
count: int = 3,
|
||||
first_frame_id: int,
|
||||
raw_base: float,
|
||||
raw_per_n: float,
|
||||
raw_per_axis: dict[str, float] | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Evenly spaced samples inside a hold, raw linear in the declared force."""
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
span = (hold.end_ns - hold.start_ns) // (count + 1)
|
||||
fn = raw_base + raw_per_n * hold.reference_n
|
||||
for offset in range(count):
|
||||
frame_id = first_frame_id + offset
|
||||
stamp = hold.start_ns + span * (offset + 1)
|
||||
frames.append(frame_row(frame_id, stamp, t_rel_s=(stamp - BASE_NS) / 1e9))
|
||||
values = {"fn": fn}
|
||||
if raw_per_axis:
|
||||
values.update({
|
||||
"fx": raw_per_axis["FX"] * hold.reference_n,
|
||||
"fy": raw_per_axis["FY"] * hold.reference_n,
|
||||
})
|
||||
forces.append(force_row(frame_id, **values))
|
||||
return frames, forces
|
||||
|
||||
|
||||
def _write_multipoint_trial(
|
||||
path: Path,
|
||||
*,
|
||||
session_id: str,
|
||||
site_id: str,
|
||||
repeat_index: int,
|
||||
repeat_count: int,
|
||||
holds: list[Hold],
|
||||
with_capture: bool,
|
||||
video: bool,
|
||||
raw_base: float,
|
||||
raw_per_n: float,
|
||||
samples_per_hold: int,
|
||||
frame_start: int,
|
||||
) -> list[dict]:
|
||||
write_csv(
|
||||
path / "reference.csv",
|
||||
REFERENCE_FIELDS,
|
||||
[reference_row(hold, site_id=site_id, repeat_index=repeat_index,
|
||||
repeat_count=repeat_count) for hold in holds],
|
||||
)
|
||||
write_csv(
|
||||
path / "events.csv",
|
||||
["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"],
|
||||
[],
|
||||
)
|
||||
write_json_file(path / "session.json", {
|
||||
"trial_id": f"{site_id}-repeat-{repeat_index:03d}",
|
||||
"site_id": site_id,
|
||||
"status": "complete",
|
||||
"uv_px": {"u": 128, "v": 64},
|
||||
"images": {"captured": with_capture,
|
||||
"reason": "" if with_capture else "camera not attached yet"},
|
||||
})
|
||||
if not with_capture:
|
||||
return []
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
for hold in holds:
|
||||
hold_frames, hold_forces = hold_samples(
|
||||
hold,
|
||||
count=samples_per_hold,
|
||||
first_frame_id=frame_start + len(frames),
|
||||
raw_base=raw_base,
|
||||
raw_per_n=raw_per_n,
|
||||
)
|
||||
frames.extend(hold_frames)
|
||||
forces.extend(hold_forces)
|
||||
write_capture(path / "capture", frames, forces, fields=None if video else FRAMES_FIELDS,
|
||||
video_segments=1)
|
||||
return []
|
||||
|
||||
|
||||
def multipoint_session(
|
||||
root: Path,
|
||||
*,
|
||||
sites: tuple[str, ...] = ("P1",),
|
||||
repeats: int = 1,
|
||||
levels: tuple[float, ...] = (5.0,),
|
||||
with_capture: bool = True,
|
||||
video: bool = True,
|
||||
raw_base: float = 100.0,
|
||||
raw_per_n: float = 40.0,
|
||||
samples_per_hold: int = 3,
|
||||
simulate: bool = False,
|
||||
trial_status: str = "complete",
|
||||
extra_holds: list[Hold] | None = None,
|
||||
) -> Path:
|
||||
"""A complete multi-point session directory through the review contract."""
|
||||
session_id = f"finger-SN1_{root.name}_test0001"
|
||||
trials: list[dict] = []
|
||||
completion: list[dict] = []
|
||||
site_records = []
|
||||
for site_id in sites:
|
||||
site_records.append({
|
||||
"site_id": site_id,
|
||||
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
"position_frame": "sensor",
|
||||
"uv_px": {"u": 128, "v": 64},
|
||||
"levels_n": list(levels),
|
||||
"hold_s": 4.0,
|
||||
"repeats": repeats,
|
||||
})
|
||||
complete = 0
|
||||
for repeat in range(1, repeats + 1):
|
||||
site_dir = root / "sites" / site_id / f"repeat-{repeat:03d}"
|
||||
holds = holds_for_levels(list(levels))
|
||||
if extra_holds:
|
||||
holds = [*holds, *extra_holds]
|
||||
directory = f"sites/{site_id}/repeat-{repeat:03d}"
|
||||
_write_multipoint_trial(
|
||||
site_dir,
|
||||
session_id=session_id,
|
||||
site_id=site_id,
|
||||
repeat_index=repeat,
|
||||
repeat_count=repeats,
|
||||
holds=holds,
|
||||
with_capture=with_capture,
|
||||
video=video,
|
||||
raw_base=raw_base,
|
||||
raw_per_n=raw_per_n,
|
||||
samples_per_hold=samples_per_hold,
|
||||
frame_start=1,
|
||||
)
|
||||
trials.append({
|
||||
"trial_id": f"{site_id}-repeat-{repeat:03d}",
|
||||
"site_id": site_id,
|
||||
"repeat_index": repeat,
|
||||
"directory": directory,
|
||||
"status": trial_status,
|
||||
"reason": "" if trial_status == "complete" else "stopped by the operator",
|
||||
})
|
||||
if trial_status == "complete":
|
||||
complete += 1
|
||||
completion.append({"site_id": site_id, "repeats_planned": repeats,
|
||||
"repeats_complete": complete,
|
||||
"complete": complete >= repeats})
|
||||
write_json_file(root / "session.json", {
|
||||
"format_version": "autocal-multipoint-1.0",
|
||||
"session_id": session_id,
|
||||
"sensor_model": "finger",
|
||||
"sensor_sn": "SN1",
|
||||
"coordinate_frame": "sensor",
|
||||
"max_force_n": 20.0,
|
||||
"min_force_n": 0.0,
|
||||
"simulate": simulate,
|
||||
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||
"sites": site_records,
|
||||
"trials": trials,
|
||||
"completion": completion,
|
||||
"started_at": "2026-10-17T00:00:00+00:00",
|
||||
"sync": {"association": "A frame belongs to a hold when its t_host_mono_ns is in "
|
||||
"[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."},
|
||||
"platform_motion": False,
|
||||
"automatic_force_control": False,
|
||||
})
|
||||
return root
|
||||
|
||||
|
||||
def single_session(
|
||||
root: Path,
|
||||
*,
|
||||
sites: tuple[str, ...] = ("site-001",),
|
||||
trials_per_site: int = 1,
|
||||
levels: tuple[float, ...] = (5.0,),
|
||||
with_capture: bool = True,
|
||||
raw_base: float = 100.0,
|
||||
raw_per_n: float = 40.0,
|
||||
samples_per_hold: int = 3,
|
||||
trial_status: str = "complete",
|
||||
reference_alignment_possible: bool = True,
|
||||
) -> Path:
|
||||
"""A complete single-trial calibration session (``autocal-session-1.0``)."""
|
||||
session_id = f"finger-SN1_{root.name}_test0001"
|
||||
uv_definition = {
|
||||
"unit": "px", "u_positive": "right", "v_positive": "up",
|
||||
"origin_raster_xy_px": [0, 0], "raster_size_hw": [64, 64],
|
||||
"mapping_sha256": "test-mapping",
|
||||
}
|
||||
site_records = [
|
||||
{"site_id": site_id, "uv_xy_px": [10 + index, 20 + index],
|
||||
"position_sensor_mm": [1.0, 2.0, 3.0]}
|
||||
for index, site_id in enumerate(sites)
|
||||
]
|
||||
trials: list[dict] = []
|
||||
for site_index, site_id in enumerate(sites):
|
||||
for repeat in range(1, trials_per_site + 1):
|
||||
trial_id = f"trial-{len(trials) + 1:03d}"
|
||||
directory = f"trials/{trial_id}"
|
||||
path = root / directory
|
||||
holds = holds_for_levels(list(levels))
|
||||
write_csv(path / "reference.csv", REFERENCE_FIELDS, [
|
||||
reference_row(hold, site_id=site_id, repeat_index=repeat, repeat_count=1)
|
||||
for hold in holds
|
||||
])
|
||||
frames: list[dict] = []
|
||||
forces: list[dict] = []
|
||||
if with_capture:
|
||||
for hold in holds:
|
||||
hold_frames, hold_forces = hold_samples(
|
||||
hold, count=samples_per_hold,
|
||||
first_frame_id=1 + len(frames),
|
||||
raw_base=raw_base, raw_per_n=raw_per_n,
|
||||
)
|
||||
frames.extend(hold_frames)
|
||||
forces.extend(hold_forces)
|
||||
write_csv(path / "frames.csv", FRAMES_FIELDS, frames)
|
||||
write_csv(path / "forces.csv", FORCES_FIELDS, forces)
|
||||
write_json_file(path / "session.json", {
|
||||
"trial_id": trial_id,
|
||||
"site_id": site_id,
|
||||
"session_id": 7,
|
||||
"calibration_session_id": session_id,
|
||||
"trial_status": trial_status,
|
||||
"trial_reason": "" if trial_status == "complete" else "application closed",
|
||||
"reference_alignment_possible": reference_alignment_possible,
|
||||
"uv_definition": uv_definition,
|
||||
"steps": [{"step_index": hold.step_index, "branch": hold.branch,
|
||||
"target_load_n": hold.target_load_n} for hold in holds],
|
||||
})
|
||||
trials.append({
|
||||
"trial_id": trial_id, "site_id": site_id, "repeat_index": repeat,
|
||||
"directory": directory, "status": trial_status,
|
||||
"reason": "" if trial_status == "complete" else "application closed",
|
||||
})
|
||||
write_json_file(root / "session.json", {
|
||||
"format_version": "autocal-session-1.0",
|
||||
"session_id": session_id,
|
||||
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||
"sites": site_records,
|
||||
"trials": trials,
|
||||
"uv_definition": uv_definition,
|
||||
"sensor_model": "finger",
|
||||
"sensor_sn": "SN1",
|
||||
"started_at": "2026-10-17T00:00:00+00:00",
|
||||
})
|
||||
return root
|
||||
|
||||
|
||||
class FakeWorker:
|
||||
"""A fingerprint worker stand-in whose session can be restarted mid-recording."""
|
||||
|
||||
def __init__(self, *, session_id: int = 1, frame_id: int = 1, time_basis: str = "host_camera",
|
||||
clock=None):
|
||||
self.session_id = int(session_id)
|
||||
self.is_running = True
|
||||
self.sinks: list = []
|
||||
self.time_basis = time_basis
|
||||
self._clock = clock
|
||||
self._frame_id = int(frame_id)
|
||||
self._started = self._now_ns()
|
||||
self.packet = self._packet()
|
||||
|
||||
def _now_ns(self) -> int:
|
||||
return time.perf_counter_ns() if self._clock is None else int(self._clock())
|
||||
|
||||
def _packet(self) -> SdkPacket:
|
||||
import numpy as np
|
||||
|
||||
now = self._now_ns()
|
||||
return SdkPacket(
|
||||
raw_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
processed_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
timestamp=time.time(),
|
||||
fps=30,
|
||||
process_ms=1,
|
||||
session_id=self.session_id,
|
||||
frame_id=self._frame_id,
|
||||
mono_t=(now - self._started) / 1e9,
|
||||
capture_mono_ns=now,
|
||||
capture_utc_ns=time.time_ns(),
|
||||
source_frame_id=self._frame_id,
|
||||
time_basis=self.time_basis,
|
||||
normal_force_raw=1.0,
|
||||
)
|
||||
|
||||
def advance(self) -> SdkPacket:
|
||||
self._frame_id += 1
|
||||
self.packet = self._packet()
|
||||
return self.packet
|
||||
|
||||
def latest(self):
|
||||
return self.packet
|
||||
|
||||
def add_sink(self, sink) -> None:
|
||||
if sink not in self.sinks:
|
||||
self.sinks.append(sink)
|
||||
|
||||
def remove_sink(self, sink) -> None:
|
||||
if sink in self.sinks:
|
||||
self.sinks.remove(sink)
|
||||
|
||||
def emit(self) -> None:
|
||||
for sink in list(self.sinks):
|
||||
sink(self.packet)
|
||||
|
||||
def restart(self) -> None:
|
||||
"""A new acquisition session with fresh frame numbers, sinks still attached."""
|
||||
self.session_id += 1
|
||||
self._frame_id = 1
|
||||
self._started = self._now_ns()
|
||||
self.packet = self._packet()
|
||||
|
||||
def stop(self, *, wait: bool = True) -> None:
|
||||
self.is_running = False
|
||||
Reference in New Issue
Block a user