subject: store the whole session and drive calibration from the acquisition stream
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# 记录通道与数据可靠性
|
||||
|
||||
本文记录 Studio 在"完整记录、数据可靠优先"这轮改动中的架构决定,以及针对架构评审
|
||||
第 8 节所列缺陷的修法与验证方式。
|
||||
|
||||
核心结论是:**标定采样、全程历史、无效样本隔离之所以会出问题,根因是同一处架构缺口
|
||||
—— 采集数据只有一个出口,就是 16 ms 的 UI 定时器。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 改动前的问题链
|
||||
|
||||
```
|
||||
worker 线程 ──> self._latest(单槽,后写覆盖)
|
||||
│
|
||||
UI 定时器 _on_run_tick(16 ms)──┬──> 曲线历史(只保留窗口内样本)
|
||||
├──> 标定采样(且被 if self._show_cal 守卫)
|
||||
└──> 箭头/深度渲染
|
||||
```
|
||||
|
||||
由此派生出评审指出的多个缺陷,它们不是彼此独立的问题:
|
||||
|
||||
| 现象 | 机制 |
|
||||
|---|---|
|
||||
| 标定依赖面板可见 | `app_window.py` 中 `push_raw` 被 `if self._show_cal:` 守卫,而它是唯一检查录制截止时间的地方 |
|
||||
| 标定录制中无法停止 | `push_raw` 永不执行 → `_finish_recording` 永不调用 → `_recording` 恒为真 → 而 `_recording` 又让录制按钮禁用。**两次点击都无法脱困** |
|
||||
| 历史被窗口裁掉 | `MetricHistory._prune()` 主动丢弃 `t < t_end - window_sec` 的样本 |
|
||||
| 面板未刷新时丢数据 | 单槽 `_latest` 后写覆盖,UI 降帧即丢样本 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 现在的结构
|
||||
|
||||
```
|
||||
worker 线程 ──> self._latest(显示,可有损)
|
||||
│
|
||||
└──> _emit(packet) ──> 记录 sink(记录,不可丢)
|
||||
│
|
||||
app_window._on_packet_recorded(运行在采集线程,不碰 Qt)
|
||||
├──> SessionStore 全程存储
|
||||
└──> CalibrationRecorder.feed() 标定采样缓冲
|
||||
|
||||
UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
|
||||
└──> CalibrationRecorder.tick() 到点后结算(Qt 回调留在 UI 线程)
|
||||
```
|
||||
|
||||
分工原则:
|
||||
|
||||
- **`feed()` 在采集线程调用**,只往线程安全的缓冲里追加,绝不触碰 Qt 控件。因此
|
||||
隐藏标定面板、曲线重绘变慢、窗口后台化都不会中断记录。
|
||||
- **`tick()` 在 UI 线程调用**,负责判定截止时间、计算统计量并触发回调 —— Qt 信号与
|
||||
控件更新必须留在 UI 线程。
|
||||
- **sink 是普通 callable 列表**,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。
|
||||
|
||||
### 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `shared/.../acquisition/sinks.py` | `RecordSinkMixin`:sink 注册与扇出,两个 worker 共用 |
|
||||
| `shared/.../force/session.py` | `SessionStore`:全程存储,内存有界,双单位 + 有效性 + 帧号 |
|
||||
| `shared/.../force/calibration_recorder.py` | 标定采样状态机,与 Qt 无关 |
|
||||
|
||||
---
|
||||
|
||||
## 3. SessionStore 的三条保证
|
||||
|
||||
1. **不是"把窗口调大"**:按定长 numpy 块追加,O(1) 写入;达到 `max_samples` 时释放最旧
|
||||
的整块并累加 `dropped`。`dropped` 由界面显式提示(`status.samples_dropped`),
|
||||
不允许静默丢数据。
|
||||
2. **同时保存 raw 与标定后数值**:每个样本存 `(fn,fx,fy)_raw` 与 `(fn,fx,fy)_N` 两套。
|
||||
切换显示单位时曲线从 store 重建,而不是把新单位的值追加到旧缓冲上。
|
||||
3. **无效样本标记而不是归零**:读取失败时存 NaN 且 `valid=False`;`iter_valid()` 只产出
|
||||
有效样本,因此标定统计不可能把一个失败读数当作 0 平均进去。
|
||||
|
||||
---
|
||||
|
||||
## 4. 针对评审第 8 节的修法
|
||||
|
||||
| # | 问题 | 修法 |
|
||||
|---|---|---|
|
||||
| 1 | 标定中无法点击停止 | `_refresh_enabled` 改为 `can_record = self._running`,录制中按钮保持可用并显示「停止」 |
|
||||
| 2 | 切换 raw/N 后历史混用单位 | 新增 `ForcePlotsPanel.set_units(unit, store)`:清空缓冲并从 `SessionStore` 按目标单位重建;时间原点保留,X 轴不会跳回 0 |
|
||||
| 3 | 异常被当成正常数据 | `read_forces_from_sensor` 不再吞异常:返回 `valid=False` + `invalid_reason` + NaN;`SdkPacket` 新增 `valid` / `invalid_reason` / `raw_available`;无效样本不进曲线、不进标定 |
|
||||
| 4 | 标定依赖面板可见 | 采样改由 worker sink 驱动;`_show_cal` 只影响可见性,不再作为采样入口 |
|
||||
| 5 | 历史 FN 被截断 | 去掉写入前的 `max(0.0, fn)` 夹值与显示前的 `np.maximum`;负值原样保留,越界由坐标轴范围裁剪(只影响绘制,不影响数据) |
|
||||
| 6 | 导出覆盖并自动应用标定 | 导出只写用户标定目录(`local_bundle_dir=None`),不再改写 `config/sensors/<type>/`;应用前弹窗显示目标设备类型与配置,且只下发给匹配的 worker |
|
||||
| 7 | 深度显示误导 | `depth_map_to_bgr` 支持 `value_range` 固定色标与 `invalid_mask`;色标在会话内锁定到首个有效深度帧并在面板头部显示数值范围;无深度时显示明确提示,**不再回退成普通形变图** |
|
||||
| 8 | 历史只保留窗口内样本 | 见 §3;`SessionStore` 与显示用的 `MetricHistory` 分离 |
|
||||
|
||||
另外修正了评审未列出的一处:RAW 通道不可用时,原代码把标定后的 N 值复制进 raw 字段
|
||||
(`fn_raw = fn`)。现改为 `raw_available=False` 且 raw 保持 NaN,避免"原始值"实际上是
|
||||
标定值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```powershell
|
||||
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
|
||||
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
|
||||
cd D:\orisys_ws\orisys-app\apps\studio
|
||||
& $py tools/ui_check.py # 含 "record channel" 段落
|
||||
```
|
||||
|
||||
- `shared/tests/test_record_channel.py`:11 项,覆盖内存上界、双单位、无效隔离、
|
||||
跨线程 feed、截止时间结算、取消语义。
|
||||
- `shared/tests/test_worker_cancellation.py`:4 项,覆盖非阻塞 stop、线程所有权、
|
||||
会话交接。
|
||||
- `tools/ui_check.py`:新增 `record channel` 段落,断言记录通道在**完全没有 UI 定时器
|
||||
触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用取消入口、
|
||||
深度缺失不回退、色标锁帧。
|
||||
- `apps/studio/tests/test_ui_smoke.py`:上述不变量各有对应 pytest 用例,复用
|
||||
`ui_check._StuckWorker` 与 `ui_check._fake_packet`,保持单一来源。
|
||||
|
||||
### 已知环境限制
|
||||
|
||||
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
|
||||
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
|
||||
覆盖同样的不变量且可完整运行。
|
||||
|
||||
---
|
||||
|
||||
## 6. 尚未完成(第二阶段及以后)
|
||||
|
||||
- MP4 分段录制、`frames.csv` 帧索引、`session.json` 元数据(评审第一阶段剩余部分)。
|
||||
- CSV 导出(`session_id/frame_id/t_rel_s/FN_raw/…/valid/calibration_id`)——数据模型
|
||||
`SessionSample` 已按这些字段设计,`SessionStore.iter_valid()` 已可直接供导出使用。
|
||||
- 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
|
||||
- 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前
|
||||
`CalibrationPoint` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
|
||||
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。
|
||||
@@ -29,6 +29,8 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import select_display_forces
|
||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||
@@ -115,6 +117,19 @@ class StudioMainWindow(QMainWindow):
|
||||
self._last_plot_packet_key: tuple[int, float] | None = None
|
||||
self._last_rendered_view_mode: str | None = None
|
||||
self._run_source_label = ""
|
||||
# ---- record channel -------------------------------------------------
|
||||
# Fed from the acquisition thread via the worker's sink list, so it does
|
||||
# not depend on the UI refresh rate or on which panels are visible.
|
||||
self.session_store = SessionStore()
|
||||
self.cal_recorder = CalibrationRecorder(
|
||||
on_finished=self._on_calibration_point,
|
||||
on_cancelled=self._on_calibration_cancelled,
|
||||
)
|
||||
self._record_error_count = 0
|
||||
self._dropped_reported = 0
|
||||
self._active_calibration_id = ""
|
||||
self._depth_range_value: tuple[float, float] | None = None
|
||||
self._last_depth_frame = None
|
||||
|
||||
icon = QIcon(str(logo_path()))
|
||||
if not icon.isNull():
|
||||
@@ -169,6 +184,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._apply_panel_visibility()
|
||||
self._restore_splitter_sizes()
|
||||
self._update_run_enabled()
|
||||
self.cal_panel.bind_recorder(self.cal_recorder)
|
||||
self.cal_panel.set_meta_context(
|
||||
sensor_type=self.sensor_picker.sensor_type(),
|
||||
config_ref=self._resolve_config_ref(),
|
||||
@@ -204,6 +220,40 @@ class StudioMainWindow(QMainWindow):
|
||||
on_error=self._on_worker_error,
|
||||
on_status=self._on_worker_status,
|
||||
)
|
||||
# Both workers publish into the same record channel.
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
worker.add_sink(self._on_packet_recorded)
|
||||
|
||||
# ------------------------------------------------------- record channel
|
||||
def _on_packet_recorded(self, packet) -> None:
|
||||
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
|
||||
|
||||
The plots are allowed to drop frames; this path is not. It is also the
|
||||
reason calibration sampling no longer depends on the calibration panel
|
||||
being visible.
|
||||
"""
|
||||
sample = SessionSample(
|
||||
frame_id=int(getattr(packet, "frame_id", 0)),
|
||||
t_rel_s=float(packet.timestamp),
|
||||
fn_raw=float(packet.normal_force_raw),
|
||||
fx_raw=float(packet.shear_x_raw),
|
||||
fy_raw=float(packet.shear_y_raw),
|
||||
fn_n=float(packet.normal_force),
|
||||
fx_n=float(packet.shear_x),
|
||||
fy_n=float(packet.shear_y),
|
||||
valid=bool(getattr(packet, "valid", True)),
|
||||
calibration_id=self._calibration_id(),
|
||||
error=str(getattr(packet, "invalid_reason", "") or ""),
|
||||
)
|
||||
self.session_store.append(sample)
|
||||
self.cal_recorder.feed(sample)
|
||||
if not sample.valid:
|
||||
self._record_error_count += 1
|
||||
|
||||
def _calibration_id(self) -> str:
|
||||
"""Cached per session — this runs for every acquired sample."""
|
||||
return self._active_calibration_id
|
||||
|
||||
|
||||
def _wire_toolbar(self) -> None:
|
||||
self.toolbar.search_clicked.connect(self._on_search_cameras)
|
||||
@@ -511,6 +561,12 @@ class StudioMainWindow(QMainWindow):
|
||||
def _on_units_changed(self, units: str) -> None:
|
||||
self.prefs["units"] = units
|
||||
self.force_plots.set_fixed_newton(str(units) == "newton")
|
||||
# Rebuild the visible curves from the whole-session store, which keeps
|
||||
# both triplets, instead of appending new-unit values onto old ones.
|
||||
self.force_plots.set_units(
|
||||
"N" if str(units) == "newton" else "raw",
|
||||
store=self.session_store,
|
||||
)
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_image_view_mode_changed(self, mode: str) -> None:
|
||||
@@ -567,11 +623,45 @@ class StudioMainWindow(QMainWindow):
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_calibration_exported(self, path: str) -> None:
|
||||
"""Apply newly written force_calibration.json to the running Sensor."""
|
||||
"""Apply a freshly exported calibration — only on explicit confirmation.
|
||||
|
||||
The export no longer overwrites the device bundle, and it no longer
|
||||
silently pushes the coefficients into both workers. The user is shown
|
||||
the exact target device first, and only the matching worker is updated.
|
||||
"""
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
config_ref = self._resolve_config_ref() or ""
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
t("cal.apply_title"),
|
||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
self.statusBar().showMessage(t("cal.apply_skipped"))
|
||||
return
|
||||
worker = self._worker_for(sensor_type)
|
||||
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||
self.statusBar().showMessage(t("cal.apply_no_target"))
|
||||
return
|
||||
worker.set_pending_calibration(path)
|
||||
self.statusBar().showMessage(
|
||||
t("cal.apply_done", sensor_type=sensor_type)
|
||||
)
|
||||
|
||||
def _worker_for(self, sensor_type: str):
|
||||
self._ensure_workers()
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
if worker is not None and hasattr(worker, "set_pending_calibration"):
|
||||
worker.set_pending_calibration(path)
|
||||
if sensor_type == "finger":
|
||||
return self._finger_worker
|
||||
return self._box_worker
|
||||
|
||||
def _on_calibration_point(self, point) -> None:
|
||||
"""Called on the UI thread when a calibration point finishes."""
|
||||
self.cal_panel.on_record_finished(point)
|
||||
|
||||
def _on_calibration_cancelled(self) -> None:
|
||||
self.cal_panel.on_record_cancelled()
|
||||
|
||||
def _on_finger_3d_toggled(self, visible: bool) -> None:
|
||||
if self.sensor_picker.sensor_type() != "finger":
|
||||
@@ -806,10 +896,22 @@ class StudioMainWindow(QMainWindow):
|
||||
self._last_session_error = ""
|
||||
self._run_source_label = str(source)
|
||||
self._active_sensor_type = sensor_type
|
||||
# Fresh record channel for the new session.
|
||||
self._active_calibration_id = str(config_ref)
|
||||
self._record_error_count = 0
|
||||
self._dropped_reported = 0
|
||||
self._depth_range_value = None
|
||||
self._last_depth_frame = None
|
||||
self.session_store.reset(calibration_id=str(config_ref))
|
||||
self.cal_recorder.reset_session()
|
||||
self._ensure_workers()
|
||||
self._active_worker = (
|
||||
self._finger_worker if sensor_type == "finger" else self._box_worker
|
||||
)
|
||||
# Register the record channel on the worker we are about to start, so
|
||||
# it is wired even if the worker was installed by something other than
|
||||
# ``_ensure_workers`` (``add_sink`` is idempotent).
|
||||
self._active_worker.add_sink(self._on_packet_recorded)
|
||||
try:
|
||||
self._active_worker.start(source, config_ref=config_ref)
|
||||
except Exception as exc:
|
||||
@@ -948,32 +1050,43 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
if is_new:
|
||||
self._last_plot_packet_key = packet_key
|
||||
display = select_display_forces(
|
||||
units_pref=self.toolbar.units(),
|
||||
force_calibrated=packet.force_calibrated,
|
||||
fn=packet.normal_force,
|
||||
fx=packet.shear_x,
|
||||
fy=packet.shear_y,
|
||||
fn_raw=packet.normal_force_raw,
|
||||
fx_raw=packet.shear_x_raw,
|
||||
fy_raw=packet.shear_y_raw,
|
||||
)
|
||||
if display.using_raw_fallback:
|
||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
||||
self.force_plots.push(
|
||||
packet.timestamp,
|
||||
display.fn,
|
||||
display.fx,
|
||||
display.fy,
|
||||
unit=display.unit,
|
||||
)
|
||||
if self._show_cal:
|
||||
self.cal_panel.push_raw(
|
||||
packet.normal_force_raw,
|
||||
packet.shear_x_raw,
|
||||
packet.shear_y_raw,
|
||||
packet.timestamp,
|
||||
# Invalid readouts must not be drawn as if they were data, and they
|
||||
# must not be written into the plot buffer: NaN would poison the
|
||||
# auto-range and a zero would be indistinguishable from a real one.
|
||||
if packet.valid:
|
||||
display = select_display_forces(
|
||||
units_pref=self.toolbar.units(),
|
||||
force_calibrated=packet.force_calibrated,
|
||||
fn=packet.normal_force,
|
||||
fx=packet.shear_x,
|
||||
fy=packet.shear_y,
|
||||
fn_raw=packet.normal_force_raw,
|
||||
fx_raw=packet.shear_x_raw,
|
||||
fy_raw=packet.shear_y_raw,
|
||||
)
|
||||
if display.using_raw_fallback:
|
||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
||||
self.force_plots.push(
|
||||
packet.timestamp,
|
||||
display.fn,
|
||||
display.fx,
|
||||
display.fy,
|
||||
unit=display.unit,
|
||||
)
|
||||
else:
|
||||
self._report_invalid_sample(packet.invalid_reason)
|
||||
# The calibration panel is a *view*: sampling is driven by the
|
||||
# record channel, so hiding the panel can no longer stall it.
|
||||
self.cal_panel.set_live(
|
||||
packet.normal_force_raw,
|
||||
packet.shear_x_raw,
|
||||
packet.shear_y_raw,
|
||||
)
|
||||
|
||||
# Finalise calibration sampling on the UI thread once its deadline passes.
|
||||
self.cal_recorder.tick()
|
||||
self.cal_panel.poll_recorder()
|
||||
self._report_dropped_samples()
|
||||
|
||||
mode = self.arrow_panel.view_mode()
|
||||
if is_new or mode != self._last_rendered_view_mode:
|
||||
@@ -981,6 +1094,52 @@ class StudioMainWindow(QMainWindow):
|
||||
if self._show_fps and is_new:
|
||||
self.arrow_panel.set_fps(packet.fps)
|
||||
|
||||
def _report_invalid_sample(self, reason: str) -> None:
|
||||
"""Surface failed readouts instead of letting them pass as data."""
|
||||
if self._session_phase == "running":
|
||||
self._set_phase("error")
|
||||
self.statusBar().showMessage(
|
||||
t("status.sample_invalid", count=self._record_error_count, reason=reason)
|
||||
)
|
||||
|
||||
def _report_dropped_samples(self) -> None:
|
||||
"""Never let the memory bound discard data silently."""
|
||||
dropped = self.session_store.dropped
|
||||
if dropped > self._dropped_reported:
|
||||
self._dropped_reported = dropped
|
||||
self.statusBar().showMessage(t("status.samples_dropped", count=dropped))
|
||||
|
||||
# ------------------------------------------------------------ depth scale
|
||||
def _depth_range(self) -> tuple[float, float] | None:
|
||||
"""The colour scale, latched to the first frame that carries depth.
|
||||
|
||||
Re-fitting the range on every frame (the old behaviour) made colours
|
||||
drift, so two frames could look identical while holding different
|
||||
depths. Latching keeps frames comparable for the whole session; the
|
||||
numeric range is shown in the panel header.
|
||||
"""
|
||||
if self._depth_range_value is not None:
|
||||
return self._depth_range_value
|
||||
depth = self._last_depth_frame
|
||||
if depth is None:
|
||||
return None
|
||||
arr = np.asarray(depth, dtype=np.float64)
|
||||
finite = np.isfinite(arr)
|
||||
if arr.size == 0 or not np.any(finite):
|
||||
return None
|
||||
lo = float(np.nanmin(arr[finite]))
|
||||
hi = float(np.nanmax(arr[finite]))
|
||||
if hi <= lo:
|
||||
hi = lo + 1.0
|
||||
self._depth_range_value = (lo, hi)
|
||||
return self._depth_range_value
|
||||
|
||||
def _depth_scale_text(self) -> str:
|
||||
span = self._depth_range()
|
||||
if span is None:
|
||||
return ""
|
||||
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
|
||||
|
||||
def _render_run_image(self, packet) -> None:
|
||||
if packet is None:
|
||||
return
|
||||
@@ -996,8 +1155,20 @@ class StudioMainWindow(QMainWindow):
|
||||
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
|
||||
colormap = flow_colormap(self._theme)
|
||||
if mode == "depth":
|
||||
depth_bgr = depth_map_to_bgr(packet.depth)
|
||||
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
|
||||
self._last_depth_frame = packet.depth
|
||||
depth_bgr = depth_map_to_bgr(
|
||||
packet.depth,
|
||||
value_range=self._depth_range(),
|
||||
invalid_mask=packet.valid_mask,
|
||||
)
|
||||
if depth_bgr is None:
|
||||
# Never substitute the processed frame here: that made a missing
|
||||
# depth map look like a depth reading. Say so instead.
|
||||
self.arrow_panel.clear_preview(t("panels.depth_unavailable"))
|
||||
self.arrow_panel.set_depth_scale(None)
|
||||
return
|
||||
self.arrow_panel.set_depth_scale(self._depth_scale_text())
|
||||
frame = depth_bgr
|
||||
elif mode == "arrows_stitched":
|
||||
frame = draw_arrow_display(
|
||||
packet.processed_frame,
|
||||
@@ -1056,6 +1227,12 @@ class StudioMainWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self._stop_preview(announce=False)
|
||||
# Detach the record channel first: a worker that is still winding down
|
||||
# must not keep a reference to this window (and through it to widgets
|
||||
# that Qt is about to destroy).
|
||||
for worker in (self._box_worker, self._finger_worker):
|
||||
if worker is not None and hasattr(worker, "remove_sink"):
|
||||
worker.remove_sink(self._on_packet_recorded)
|
||||
# Blocking: on the way out we must not leave a device handle behind.
|
||||
self._stop_run(reason="user_stop", wait=True)
|
||||
self._persist_prefs()
|
||||
|
||||
Reference in New Issue
Block a user