kamil_adc support added
This commit is contained in:
@@ -10,10 +10,10 @@ import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -67,6 +67,7 @@ class KamilAdcTtyReader:
|
||||
tty_path: str
|
||||
_fd: int | None = field(init=False, default=None, repr=False)
|
||||
_buffer: bytearray = field(init=False, default_factory=bytearray, repr=False)
|
||||
_packet_start_pending: bool = field(init=False, default=False, repr=False)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the configured TTY path for binary reads."""
|
||||
@@ -83,36 +84,67 @@ class KamilAdcTtyReader:
|
||||
finally:
|
||||
self._fd = None
|
||||
self._buffer.clear()
|
||||
self._packet_start_pending = False
|
||||
|
||||
def read_sweep(
|
||||
self,
|
||||
*,
|
||||
points: int,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
expected_points: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Read one packet-start marker followed by exactly `points` IQ frames."""
|
||||
if points <= 0:
|
||||
raise ValueError("Kamil ADC sweep points must be > 0")
|
||||
if points > KAMIL_ADC_MAX_STEP:
|
||||
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
|
||||
"""Read one full packet, optionally discarding packets with an unexpected point count."""
|
||||
if self._fd is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
if expected_points is not None:
|
||||
if expected_points <= 0:
|
||||
raise ValueError("Kamil ADC expected points must be > 0")
|
||||
if expected_points > KAMIL_ADC_MAX_STEP:
|
||||
raise ValueError(f"Kamil ADC expected points must be <= {KAMIL_ADC_MAX_STEP}")
|
||||
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
self._read_until_packet_start(deadline, process)
|
||||
while True:
|
||||
values = self._read_one_sweep(deadline, process)
|
||||
if expected_points is None or int(values.size) == int(expected_points):
|
||||
return values
|
||||
logger.warning(
|
||||
"Discarding Kamil ADC sweep with %d points; expected %d",
|
||||
int(values.size),
|
||||
int(expected_points),
|
||||
)
|
||||
|
||||
values = np.empty(points, dtype=np.complex64)
|
||||
for index in range(points):
|
||||
frame = self._read_frame(deadline, process, received_points=index, expected_points=points)
|
||||
values[index] = KamilAdcFrameParser.parse_point(frame, index + 1)
|
||||
return values
|
||||
def _read_one_sweep(
|
||||
self,
|
||||
deadline: float,
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
) -> np.ndarray:
|
||||
"""Read one packet from start marker to the next start marker."""
|
||||
if self._packet_start_pending:
|
||||
self._packet_start_pending = False
|
||||
else:
|
||||
self._read_until_packet_start(deadline, process)
|
||||
|
||||
values: list[complex] = []
|
||||
expected_step = 1
|
||||
while True:
|
||||
frame = self._read_frame(deadline, process, received_points=len(values))
|
||||
if KamilAdcFrameParser.is_packet_start(frame):
|
||||
if not values:
|
||||
continue
|
||||
self._packet_start_pending = True
|
||||
return np.asarray(values, dtype=np.complex64)
|
||||
|
||||
if expected_step > KAMIL_ADC_MAX_STEP:
|
||||
raise RuntimeError(f"Kamil ADC sweep exceeded {KAMIL_ADC_MAX_STEP} points without packet end")
|
||||
values.append(KamilAdcFrameParser.parse_point(frame, expected_step))
|
||||
expected_step += 1
|
||||
|
||||
def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None:
|
||||
"""Discard bytes already buffered before starting a new logical sweep."""
|
||||
"""Discard stale bytes while keeping the newest packet-start boundary."""
|
||||
if self._fd is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
self._buffer.clear()
|
||||
self._packet_start_pending = False
|
||||
fd = self._require_fd()
|
||||
while True:
|
||||
self._raise_if_process_exited(process)
|
||||
@@ -132,6 +164,17 @@ class KamilAdcTtyReader:
|
||||
raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining")
|
||||
self._buffer.extend(chunk)
|
||||
self._keep_latest_packet_start_tail()
|
||||
|
||||
def _keep_latest_packet_start_tail(self) -> None:
|
||||
"""Keep only bytes from the latest complete packet-start marker onward."""
|
||||
start_index = self._buffer.rfind(_START_FRAME)
|
||||
if start_index >= 0:
|
||||
del self._buffer[:start_index]
|
||||
return
|
||||
if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
|
||||
del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
|
||||
|
||||
def _read_until_packet_start(
|
||||
self,
|
||||
@@ -153,7 +196,7 @@ class KamilAdcTtyReader:
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
*,
|
||||
received_points: int,
|
||||
expected_points: int,
|
||||
expected_points: int | None = None,
|
||||
) -> bytes:
|
||||
while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
|
||||
self._read_available(deadline, process, received_points, expected_points)
|
||||
@@ -172,6 +215,10 @@ class KamilAdcTtyReader:
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
if received_points is None or expected_points is None:
|
||||
if received_points is not None:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep end: received {received_points} points"
|
||||
)
|
||||
raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker")
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
|
||||
@@ -214,15 +261,14 @@ class KamilAdcTtyReader:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcService:
|
||||
"""Launch `kamil_adc`, configure laser board, and acquire TTY sweeps."""
|
||||
"""Launch `kamil_adc` and acquire TTY sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
|
||||
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
|
||||
_laser_controller: Any | None = field(init=False, default=None, repr=False)
|
||||
_laser_variation_active: bool = field(init=False, default=False, repr=False)
|
||||
_expected_points: int | None = field(init=False, default=None, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_config()
|
||||
@@ -235,13 +281,12 @@ class KamilAdcService:
|
||||
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
|
||||
|
||||
def open(self) -> None:
|
||||
"""Apply laser configuration, launch the collector, and open its TTY stream."""
|
||||
"""Launch the collector and open its TTY stream."""
|
||||
if self._reader is not None:
|
||||
return
|
||||
|
||||
previous_tty_identity = _tty_identity(self.config.radar.kamil_adc.tty_path)
|
||||
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
|
||||
try:
|
||||
self._apply_laser_control()
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity)
|
||||
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
|
||||
@@ -252,25 +297,20 @@ class KamilAdcService:
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close TTY, stop the external collector, and disconnect laser control."""
|
||||
"""Close TTY and stop the external collector."""
|
||||
if self._reader is not None:
|
||||
with suppress(Exception):
|
||||
self._reader.close()
|
||||
self._reader = None
|
||||
|
||||
self._stop_process()
|
||||
self._close_laser_control()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Store sweep settings and construct the synthetic frequency axis."""
|
||||
"""Store sweep settings used to construct the synthetic frequency axis."""
|
||||
self._validate_sweep(sweep)
|
||||
self._settings = sweep
|
||||
self._frequency_hz = np.linspace(
|
||||
float(sweep.start_hz),
|
||||
float(sweep.stop_hz),
|
||||
int(sweep.points),
|
||||
dtype=np.float32,
|
||||
)
|
||||
self._frequency_hz = None
|
||||
self._expected_points = None
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Kamil ADC has no runtime-readable sweep limit API."""
|
||||
@@ -278,7 +318,7 @@ class KamilAdcService:
|
||||
|
||||
def acquire(self) -> SweepResult:
|
||||
"""Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros."""
|
||||
if self._settings is None or self._frequency_hz is None:
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
@@ -287,13 +327,21 @@ class KamilAdcService:
|
||||
code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"Kamil ADC process is not running (code={code})")
|
||||
|
||||
points = int(self._settings.points)
|
||||
self._reader.discard_pending(process)
|
||||
s21 = self._reader.read_sweep(
|
||||
points=points,
|
||||
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
|
||||
process=process,
|
||||
expected_points=self._expected_points,
|
||||
)
|
||||
points = int(s21.size)
|
||||
if points <= 0:
|
||||
raise RuntimeError("Kamil ADC sweep contained no points")
|
||||
if self._expected_points is None:
|
||||
self._expected_points = points
|
||||
self._frequency_hz = self._build_frequency_axis(points)
|
||||
logger.info("Kamil ADC sweep point count locked to %d", points)
|
||||
if self._frequency_hz is None:
|
||||
self._frequency_hz = self._build_frequency_axis(points)
|
||||
return SweepResult(
|
||||
x=self._frequency_hz.copy(),
|
||||
traces={
|
||||
@@ -353,79 +401,6 @@ class KamilAdcService:
|
||||
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
|
||||
)
|
||||
|
||||
def _apply_laser_control(self) -> None:
|
||||
laser = self.config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
return
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
pi_coeff1_p=laser.pi_coeff1_p,
|
||||
pi_coeff1_i=laser.pi_coeff1_i,
|
||||
pi_coeff2_p=laser.pi_coeff2_p,
|
||||
pi_coeff2_i=laser.pi_coeff2_i,
|
||||
)
|
||||
try:
|
||||
controller.connect()
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
)
|
||||
elif mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: "
|
||||
f"{variation.variation_type}"
|
||||
) from exc
|
||||
controller.start_variation(
|
||||
variation_type=variation_type,
|
||||
params={
|
||||
"static_temp1": variation.static_temp1,
|
||||
"static_temp2": variation.static_temp2,
|
||||
"static_current1": variation.static_current1,
|
||||
"static_current2": variation.static_current2,
|
||||
"min_value": variation.min_value,
|
||||
"max_value": variation.max_value,
|
||||
"step": variation.step,
|
||||
"time_step": variation.time_step,
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
self._laser_variation_active = True
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
except Exception:
|
||||
with suppress(Exception):
|
||||
controller.disconnect()
|
||||
raise
|
||||
|
||||
self._laser_controller = controller
|
||||
|
||||
def _close_laser_control(self) -> None:
|
||||
controller = self._laser_controller
|
||||
self._laser_controller = None
|
||||
if controller is None:
|
||||
self._laser_variation_active = False
|
||||
return
|
||||
|
||||
try:
|
||||
if self._laser_variation_active:
|
||||
controller.stop_task()
|
||||
finally:
|
||||
self._laser_variation_active = False
|
||||
controller.disconnect()
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
if not self.config.is_kamil_adc:
|
||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
||||
@@ -457,31 +432,33 @@ class KamilAdcService:
|
||||
if not os.access(executable_path, os.X_OK):
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
|
||||
|
||||
laser = self.config.radar.laser_control
|
||||
if laser.enabled:
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
|
||||
@staticmethod
|
||||
def _validate_sweep(sweep: RadarSweepModel) -> None:
|
||||
points = int(sweep.points)
|
||||
if points <= 0:
|
||||
raise ValueError("Kamil ADC sweep points must be > 0")
|
||||
if points > KAMIL_ADC_MAX_STEP:
|
||||
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
|
||||
if float(sweep.stop_hz) < float(sweep.start_hz):
|
||||
raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")
|
||||
|
||||
def _build_frequency_axis(self, points: int) -> np.ndarray:
|
||||
if self._settings is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
return np.linspace(
|
||||
float(self._settings.start_hz),
|
||||
float(self._settings.stop_hz),
|
||||
int(points),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
|
||||
def _tty_identity(path: str) -> tuple[object, ...] | None:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
return ("link", os.readlink(path))
|
||||
stat_result = os.lstat(path)
|
||||
return (
|
||||
"link",
|
||||
os.readlink(path),
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
stat_result = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -491,3 +468,95 @@ def _tty_identity(path: str) -> tuple[object, ...] | None:
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
|
||||
"""Remove stale generated TTY links before starting the external collector."""
|
||||
try:
|
||||
stat_result = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
|
||||
os.unlink(path)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
|
||||
"""Apply Kamil ADC laser settings exactly through the legacy device_main command sequence."""
|
||||
laser = config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
return False
|
||||
|
||||
_validate_laser_control_config(config)
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
pi_coeff1_p=laser.pi_coeff1_p,
|
||||
pi_coeff1_i=laser.pi_coeff1_i,
|
||||
pi_coeff2_p=laser.pi_coeff2_p,
|
||||
pi_coeff2_i=laser.pi_coeff2_i,
|
||||
)
|
||||
try:
|
||||
controller.connect()
|
||||
controller.reset()
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
return True
|
||||
if mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
|
||||
) from exc
|
||||
|
||||
controller.set_manual_mode(
|
||||
temp1=variation.static_temp1,
|
||||
temp2=variation.static_temp2,
|
||||
current1=variation.static_current1,
|
||||
current2=variation.static_current2,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID,
|
||||
)
|
||||
controller.start_variation(
|
||||
variation_type=variation_type,
|
||||
params={
|
||||
"static_temp1": variation.static_temp1,
|
||||
"static_temp2": variation.static_temp2,
|
||||
"static_current1": variation.static_current1,
|
||||
"static_current2": variation.static_current2,
|
||||
"min_value": variation.min_value,
|
||||
"max_value": variation.max_value,
|
||||
"step": variation.step,
|
||||
"time_step": variation.time_step,
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
return True
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
finally:
|
||||
controller.disconnect()
|
||||
|
||||
|
||||
def _validate_laser_control_config(config: RunConfigModel) -> None:
|
||||
laser = config.radar.laser_control
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
|
||||
@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
# Default PI regulator coefficients (match firmware defaults)
|
||||
DEFAULT_PI_P = 2560 # 10 * 256
|
||||
DEFAULT_PI_I = 128 # 0.5 * 256
|
||||
DEVICE_MAIN_MESSAGE_ID = 0x00FF
|
||||
|
||||
|
||||
class LaserController:
|
||||
@@ -121,6 +122,7 @@ class LaserController:
|
||||
temp2: float,
|
||||
current1: float,
|
||||
current2: float,
|
||||
message_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set manual control parameters for both lasers.
|
||||
@@ -134,6 +136,9 @@ class LaserController:
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
current2: Drive current for laser 2, mA.
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
message_id: Optional fixed DECODE_ENABLE message id. When
|
||||
omitted, the id is incremented for backward
|
||||
compatibility with the refactored API.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any parameter is out of range.
|
||||
@@ -142,7 +147,10 @@ class LaserController:
|
||||
validated = ParameterValidator.validate_manual_mode_params(
|
||||
temp1, temp2, current1, current2
|
||||
)
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
if message_id is None:
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
else:
|
||||
self._message_id = int(message_id) & 0xFFFF
|
||||
|
||||
cmd = Protocol.encode_decode_enable(
|
||||
temp1=validated['temp1'],
|
||||
@@ -244,7 +252,7 @@ class LaserController:
|
||||
validated['max_value'],
|
||||
validated['step'])
|
||||
|
||||
def stop_task(self) -> None:
|
||||
def stop_task(self, restore_message_id: Optional[int] = None) -> None:
|
||||
"""Stop the current task and restore manual mode.
|
||||
|
||||
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
|
||||
@@ -257,20 +265,13 @@ class LaserController:
|
||||
self._send_and_read_state(cmd_reset)
|
||||
logger.info("Task stopped (DEFAULT_ENABLE sent)")
|
||||
|
||||
# Restore manual mode so the board is ready for TRANS_ENABLE requests
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
cmd_restore = Protocol.encode_decode_enable(
|
||||
self.set_manual_mode(
|
||||
temp1=self._last_temp1,
|
||||
temp2=self._last_temp2,
|
||||
current1=self._last_current1,
|
||||
current2=self._last_current2,
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
message_id=self._message_id,
|
||||
message_id=restore_message_id,
|
||||
)
|
||||
self._send_and_read_state(cmd_restore)
|
||||
logger.info("Manual mode restored after task stop")
|
||||
|
||||
def get_measurements(self) -> Optional[Measurements]:
|
||||
@@ -380,4 +381,4 @@ class LaserController:
|
||||
except Exception:
|
||||
pass
|
||||
self.disconnect()
|
||||
return False
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user