"""
EMScope WebSocket API Client — v5
===================================

Client library for the EMScope EMI measurement instrument.
Communicates with the EMScope WebSocket server on port 8010 (SW ≥ 4.01).

Protocol overview
-----------------
- All **control** messages are JSON over WebSocket (both directions).
- **Amplitude data** is sent as raw **binary WebSocket frames**:

  ``[flags : uint8][padding : 3 bytes][float32 values …]``

  - ``flags & 0x01`` → overload (ADC saturation)
  - ``flags & 0x02`` → repetition (quasi-peak repeat trigger, 200 Hz / 9 kHz only)
  - The float32 array contains one amplitude value per frequency bin.

- The **frequency array** (``freq``) is transmitted as JSON
  ``{"freq": [...], "rbw": "9"}`` **only when it changes** (RBW or display-range
  update).  Between two such changes, consecutive binary frames share the same
  cached frequency axis.  :meth:`parse_messages` updates ``self.freq``
  automatically and always reconstructs ``self.values`` as
  ``[[freq_hz, amp], ...]`` so callers never need to manage the cache.

- **Impedance mode** is the exception: data arrives as JSON
  ``{"values": [[freq, amp], ...], "overload": bool, "repetition": false}``.
  :meth:`has_sweep_data` detects both formats transparently.

Quick start
-----------
::

    import asyncio
    from api_v5 import EmscopeConnection

    async def main():
        async with EmscopeConnection("ws://10.10.14.45:8010") as emscope:
            await emscope.set_rbw("9")
            await emscope.set_detector_type("pk")

            for _ in range(10):
                msg = await emscope.parse_messages()
                if emscope.has_sweep_data(msg):
                    print("Peak:", max(v[1] for v in emscope.values))

    asyncio.run(main())

Requirements
------------
- Python 3.8+
- ``websockets`` library (``pip install websockets``)
"""

import asyncio
import json
import struct
from typing import Any, Dict, List, Optional, Union

import websockets


class EmscopeConnection:
    """
    Async WebSocket client for the EMScope measurement server.

    Instance attributes are updated in place each time :meth:`parse_messages`
    is called.  The most important ones are:

    - ``values``      – last received spectrum: list of ``[freq_hz, amplitude]`` pairs.
                        Populated for both EMI mode (binary frames) and impedance
                        mode (JSON values).
    - ``freq``        – cached frequency axis in Hz.  Updated automatically each
                        time the server sends a new freq array (RBW / display-range
                        change).  Do not rely on this being stable across sweeps.
    - ``overload``    – ``True`` if the ADC saturated during the last sweep.
    - ``repetition``  – ``True`` if a quasi-peak repeat was triggered (200 Hz / 9 kHz).
    - ``SN``          – device serial number (populated on connect).
    - ``temperatures``– ``[pcb_temp_C, fpga_temp_C]`` after :meth:`request_temps`.
    """

    # Fields the client is allowed to set on the server.
    # These are the *wire* key names sent in JSON messages.
    _WRITABLE_FIELDS = {
        "measure_channel",
        "detector_type",
        "trace_type",
        "average",
        "rbw",
        "measure_mode",
        "reference_level",
        "input_attenuator",
        "amp_units",
        "sweep_time",
        "external_loss",
        "session_UUID",
        "visible",
        "display_range",
        "osc_us_div",
        "osc_volts_div",
        "osc_sweep",
        "trigger",
        "osc_trigger_offset",
        "osc_trigger_level",
        "osc_vertical_offset",
        "osc_horizontal_offset",
    }

    # All fields the server may send — used by parse_messages() to update
    # the corresponding instance attribute via setattr().
    _ALL_FIELDS = _WRITABLE_FIELDS | {
        # Spectrum / time-domain data
        "values",
        "freq",
        "time",
        # Device info (sent on connect)
        "SN",
        "SFP_SN",
        "MAC",
        "sw_version",
        "measurement_uncertainty",
        "num_points",
        # Live status
        "input_attenuator",
        "overload",
        "repetition",
        "threephase",
        # Reports
        "report",
        "frase",
        "report_image",
        # Database catalogs
        "standards",
        "sessions",
        "external_losses",
        "external_losses_threephase",
        "transfer_impedances",
        "transfer_impedances_threephase",
        "clickmeter_devices",
        # Licenses
        "licenses",
        "invalid_licenses",
        # System / status messages
        "temperatures",
        "errors",
        "session_saved",
        "test_status",
        "time_limit",
        "rejected_command",
        "show_message",
        "error",
        "message",
        "timestamp",
        "type",
        # ClickMeter data
        "clickmeter_channel_values",
        "overload_region",
        "flowcharts",
        "active_trace",
        "test_duration",
    }

    def __init__(self, url: str, session_uuid: str = "emscope-api"):
        """
        Create a new connection instance (does not connect yet).

        Args:
            url:          WebSocket URL of the EMScope server,
                          e.g. ``"ws://10.10.14.45:8010"``.
            session_uuid: Unique string that locks the device to this session.
                          Passed automatically by :meth:`connect`. Defaults to
                          ``"emscope-api"``. Change it if multiple independent
                          clients may connect to the same instrument.
        """
        self.url = url
        self.session_uuid = session_uuid
        self.websocket = None
        self._reset_values()

    # ── Lifecycle ─────────────────────────────────────────────────────────

    async def connect(self) -> None:
        """
        Open the WebSocket connection and activate the client session.

        The server requires a ``session_UUID`` message before it will send
        any data or accept configuration.  This method sends one automatically
        using the ``session_uuid`` value passed to :meth:`__init__`.

        After ``session_UUID`` is accepted, the server replies with device info
        (``SN``, ``MAC``, ``SFP_SN``, ``sw_version``).  This method waits for
        that reply so that ``self.SN``, ``self.MAC``, and ``self.sw_version``
        are populated before returning.

        Raises:
            websockets.exceptions.WebSocketException: on connection failure.
            websockets.exceptions.ConnectionClosedError: if the server closes
                the connection with code 4003, meaning another session already
                holds the device lock with a different UUID.
        """
        self.websocket = await websockets.connect(self.url)
        await self.set_session_uuid(self.session_uuid)
        await self.parse_messages()  # receives {"SN": ..., "MAC": ..., "SFP_SN": ...}

    async def disconnect(self) -> None:
        """Close the WebSocket connection gracefully."""
        if self.websocket is not None:
            await self.websocket.close()
            self.websocket = None

    async def __aenter__(self) -> "EmscopeConnection":
        await self.connect()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
        await self.disconnect()
        return False

    # ── Message parsing ───────────────────────────────────────────────────

    async def parse_messages(self) -> Optional[Dict[str, Any]]:
        """
        Receive and parse one WebSocket message from the server.

        Updates the corresponding instance attributes automatically.
        Responds to server ``ping`` messages with ``pong`` transparently.

        **Binary amplitude frames** are handled internally: ``self.values`` is
        reconstructed from the cached ``self.freq`` array, and ``self.overload``
        / ``self.repetition`` are decoded from the flags byte.  For these
        frames the method returns ``{"amplitudes": True}`` so callers can use
        :meth:`has_sweep_data` without needing to know the wire format.

        Returns:
            The parsed message as a dict, or ``None`` if the connection was
            closed.  For binary amplitude frames, returns
            ``{"amplitudes": True}``.

        Example::

            while True:
                msg = await emscope.parse_messages()
                if msg is None:
                    break
                if emscope.has_sweep_data(msg):
                    process(emscope.values)
        """
        try:
            raw = await self.websocket.recv()
        except websockets.ConnectionClosed:
            return None

        # ── Binary amplitude frame ─────────────────────────────────────────
        # Format: [flags:uint8][padding:3B][float32 amplitude_0 … amplitude_N]
        # flags bit0 = overload, bit1 = repetition
        if isinstance(raw, (bytes, bytearray)):
            if len(raw) >= 4:
                flags = raw[0]
                self.overload = bool(flags & 0x01)
                self.repetition = bool(flags & 0x02)
                n_floats = (len(raw) - 4) // 4
                if n_floats > 0 and len(self.freq) == n_floats:
                    amps = struct.unpack_from("<{}f".format(n_floats), raw, 4)
                    self.values = [[f, a] for f, a in zip(self.freq, amps)]
                else:
                    self.values = []
            return {"amplitudes": True}

        # ── JSON message ───────────────────────────────────────────────────
        data = json.loads(raw)

        for key, value in data.items():
            if key == "ping":
                await self.websocket.send(json.dumps({"pong": True}))
                continue

            # The server sends "input_attenuator" to report the resolved
            # auto-attenuator value; store it separately so we don't
            # overwrite the user-set value (which may be "auto").
            if key == "input_attenuator":
                self.input_attenuator_auto_value = value
                continue

            # freq is sent once per RBW / display-range change.
            # Cache it; binary frames that follow share this axis.
            if key == "freq":
                self.freq = value
                continue

            if key in self._ALL_FIELDS:
                setattr(self, key, value)
            else:
                print("WARNING: Received unknown key from server: {!r}".format(key))

        return data

    # ── Sweep-data helper ─────────────────────────────────────────────────

    def has_sweep_data(self, msg: Optional[Dict[str, Any]]) -> bool:
        """
        Return ``True`` if *msg* carries a new spectrum sweep.

        Works for both measurement modes:

        - **EMI mode** (freq + binary frame): returns ``True`` when
          ``parse_messages`` processed a binary amplitude frame and populated
          ``self.values`` (the message dict contains ``"amplitudes"``).
        - **Impedance mode**: returns ``True`` when the JSON message contains
          ``"values"`` (the combined freq+amplitude array).

        Args:
            msg: The dict returned by :meth:`parse_messages`.

        Returns:
            ``True`` if *msg* signals a completed sweep.

        Example::

            msg = await emscope.parse_messages()
            if emscope.has_sweep_data(msg):
                process(emscope.values)
        """
        return msg is not None and ("amplitudes" in msg or "values" in msg)

    # ── Generic configuration setter ──────────────────────────────────────

    async def set_value(self, field: str, value: Any) -> None:
        """
        Set a writable configuration field on the server.

        This is the generic setter. Prefer the specific helpers (e.g.
        :meth:`set_rbw`, :meth:`set_detector_type`) when available, as they
        include parameter validation and documentation.

        Args:
            field: One of the writable field names (see ``_WRITABLE_FIELDS``).
            value: The new value. Must match the type expected by the server.

        Raises:
            ValueError: if ``field`` is not a valid writable field.

        Valid writable fields and their accepted values:

        ========================== =============================================
        Field                      Valid values
        ========================== =============================================
        ``measure_channel``        ``"lg"``, ``"ng"``, ``"cm"``, ``"dm"``,
                                   ``"l1"``, ``"l2"``, ``"l3"``, ``"n"`` (RX4)
        ``detector_type``          ``"pk"``, ``"qp"``, ``"av"``
        ``trace_type``             ``"clearwrite"``, ``"maxhold"``,
                                   ``"minhold"``, ``"freeze"``, ``"average"``
        ``average``                Integer, typically 10–20
        ``measure_mode``           ``"circuit"`` (default) or ``"modal"``
        ``reference_level``        Integer (units depend on ``amp_units``)
        ``input_attenuator``       ``0``–``78`` dB, or ``"auto"``
        ``amp_units``              ``"dbuv"``, ``"dbmv"``, ``"dbm"``,
                                   ``"volts"``, ``"watts"``
        ``sweep_time``             Float, 1–15 (seconds)
        ``external_loss``          Name of an existing external loss table,
                                   or ``""``
        ``session_UUID``           Arbitrary string; locks the device
        ``visible``                Boolean
        ``display_range``          ``[from_hz, to_hz]``
        ========================== =============================================
        """
        if field not in self._WRITABLE_FIELDS:
            raise ValueError(
                "Cannot set field {!r}. "
                "Writable fields: {}".format(field, sorted(self._WRITABLE_FIELDS))
            )
        setattr(self, field, value)
        await self.websocket.send(json.dumps({field: value}))

    # ── RBW / band ────────────────────────────────────────────────────────

    # Default frequency range for each RBW value, in Hz.
    # Used by set_rbw() when no custom display_range is provided.
    _RBW_DEFAULT_DISPLAY_RANGE = {
        "200":   (9_000,          150_000),
        "9":     (150_000,     30_000_000),
        "120":   (30_000_000, 110_000_000),
        "1":     (10_000,         150_000),
        "10":    (150_000,     30_000_000),
        "200_9": (9_000,       30_000_000),
        "1_10":  (10_000,      30_000_000),
    }

    async def set_rbw(
        self,
        rbw: str,
        threephase: bool = False,
        display_range: Optional[tuple] = None,
    ) -> None:
        """
        Set the resolution bandwidth (RBW) and frequency band, and wait for
        the server to confirm the change before returning.

        Changing RBW may trigger a firmware reload on the instrument (up to
        ~5 seconds for bands like ``"120"`` that require a different bitstream).
        The server echoes ``{"rbw": "<value>"}`` once the firmware is ready.
        This method blocks until that echo is received, so it is safe to send
        further configuration immediately after it returns.

        The display range is set automatically to the natural band of the
        selected RBW (see ``_RBW_DEFAULT_DISPLAY_RANGE``). Pass a custom
        ``display_range`` tuple to override it.

        .. note::
            After this method returns, ``self.freq`` may not yet be populated
            (the server sends the freq array with the first measurement frame).
            Use :meth:`parse_messages` in a loop to wait for it.

        Args:
            rbw: Resolution bandwidth selector:

                =========== ========= =================== ==============
                Value        RBW       Frequency range     Standard
                =========== ========= =================== ==============
                ``"200"``    200 Hz    9 kHz – 150 kHz     CISPR 16-1-1
                ``"9"``      9 kHz     150 kHz – 30 MHz    CISPR 16-1-1
                ``"120"``    120 kHz   30 MHz – 110 MHz    CISPR 16-1-1
                ``"1"``      1 kHz     10 kHz – 150 kHz    MIL-STD-461
                ``"10"``     10 kHz    150 kHz – 30 MHz    MIL-STD-461
                ``"200_9"``  dual      9 kHz – 30 MHz      CISPR dual-band
                ``"1_10"``   dual      10 kHz – 30 MHz     MIL dual-band
                =========== ========= =================== ==============

            threephase:    ``True`` for three-phase (RX4) models.
            display_range: Optional ``(from_hz, to_hz)`` tuple to override the
                           default frequency window for the selected band.
                           Ignored for RBW ``"120"`` (server always returns the
                           full 30–110 MHz band for that RBW).
        """
        _FREQ_MODES    = {"200", "9", "1", "10", "200_9", "1_10"}
        _SPECIAL_MODES = {"120", "osc", "imp", "clickmeter"}

        effective_range = display_range or self._RBW_DEFAULT_DISPLAY_RANGE.get(rbw)

        # Reset display_range on the server before switching into a special
        # mode so the new firmware starts with a clean frequency window.
        if self.rbw in _FREQ_MODES and rbw in _SPECIAL_MODES:
            await self.websocket.send(json.dumps({"display_range": [0, 10_000_000_000]}))

        self.rbw = rbw
        self.threephase = threephase
        await self.websocket.send(json.dumps({"rbw": rbw, "threephase": threephase}))

        # Wait for the server RBW echo (firmware ready).
        # Other messages may arrive first — keep reading until confirmed.
        while True:
            msg = await self.parse_messages()
            if msg is None or "rbw" in msg:
                break

        # Send display_range after RBW is confirmed (freq_modes only).
        if effective_range is not None:
            self.display_range = list(effective_range)
            if rbw in _FREQ_MODES:
                await self.websocket.send(json.dumps({"display_range": list(effective_range)}))

    # ── Convenience setters ───────────────────────────────────────────────

    async def set_detector_type(self, detector: str) -> None:
        """
        Set the detector type.

        Args:
            detector: ``"pk"`` (Peak), ``"qp"`` (Quasi-Peak), or ``"av"`` (Average).
        """
        await self.set_value("detector_type", detector)

    async def set_measure_channel(self, channel: str) -> None:
        """
        Set the input channel.

        Args:
            channel: For RX2 models: ``"lg"``, ``"ng"``, ``"cm"``, ``"dm"``.
                     For RX4 models: ``"l1"``, ``"l2"``, ``"l3"``, ``"n"``.
        """
        await self.set_value("measure_channel", channel)

    async def set_trace_type(self, trace: str) -> None:
        """
        Set the trace display mode.

        Args:
            trace: ``"clearwrite"``, ``"maxhold"``, ``"minhold"``,
                   ``"freeze"``, or ``"average"``.
        """
        await self.set_value("trace_type", trace)

    async def set_amp_units(self, units: str) -> None:
        """
        Set the amplitude units for displayed values.

        Args:
            units: ``"dbuv"``, ``"dbmv"``, ``"dbm"``, ``"volts"``, or ``"watts"``.
        """
        await self.set_value("amp_units", units)

    async def set_reference_level(self, level: Union[int, str]) -> None:
        """
        Set the reference level (top of the display scale).

        The value must be consistent with the current ``amp_units``.
        Typical values: 100 dBuV, 70 dBmV, 20 dBm.

        Args:
            level: Reference level as integer (or numeric string).
        """
        await self.set_value("reference_level", level)

    async def set_input_attenuator(self, value: Union[int, str]) -> None:
        """
        Set the input attenuator.

        Args:
            value: Attenuation in dB (0–78), or ``"auto"`` for automatic selection.
        """
        await self.set_value("input_attenuator", value)

    async def set_sweep_time(self, seconds: Union[float, str]) -> None:
        """
        Set the sweep time per band.

        Args:
            seconds: Sweep duration, 1–15 seconds (float or numeric string).
        """
        await self.set_value("sweep_time", seconds)

    async def set_average(self, n: int) -> None:
        """
        Set the number of averages for ``trace_type="average"``.

        Args:
            n: Number of averages (typically 10–20).
        """
        await self.set_value("average", n)

    async def set_mode(self, mode: str) -> None:
        """
        Set the measurement mode (circuit or modal).

        The wire key for this command is ``measure_mode`` (not ``mode``).
        This method handles the mapping automatically.

        Args:
            mode: ``"circuit"`` (default) or ``"modal"``.
        """
        self.mode = mode  # keep self.mode as a readable attribute
        await self.set_value("measure_mode", mode)

    async def set_external_loss(self, name: str) -> None:
        """
        Apply an external loss (cable / LISN attenuation) table.

        Args:
            name: Name of an existing external loss table, or ``""`` to disable.
        """
        await self.set_value("external_loss", name)

    async def set_display_range(self, from_hz: int, to_hz: int) -> None:
        """
        Restrict the displayed frequency range within the current band.

        Both values must fall within the frequency range of the active RBW.

        .. note::
            ``display_range`` is **not supported** for RBW ``"120"``.  The
            server ignores this command in that mode and always returns the
            full 30–110 MHz band.

        Args:
            from_hz: Lower frequency boundary in Hz.
            to_hz:   Upper frequency boundary in Hz.
        """
        if self.rbw == "120":
            print("WARNING: display_range is not supported for RBW '120' and will be ignored by the server.")
            return
        await self.set_value("display_range", [from_hz, to_hz])

    async def set_visible(self, visible: bool) -> None:
        """
        Show or hide the trace on the instrument front panel.

        Args:
            visible: ``True`` to show, ``False`` to hide.
        """
        await self.set_value("visible", visible)

    async def set_session_uuid(self, uuid: str) -> None:
        """
        Lock the device to this session.

        If another client already holds a lock with a different UUID, the
        server will close the WebSocket with close code **4003**.

        Args:
            uuid: Arbitrary unique string identifying this session.
        """
        await self.set_value("session_UUID", uuid)

    # ── Data request commands ─────────────────────────────────────────────

    async def request_standards(self) -> None:
        """
        Request the list of limit standards from the server.

        After calling this, call :meth:`parse_messages` until ``self.standards``
        is populated. Each entry is a dict ``{name: {rbw, data}}``.
        """
        await self.websocket.send(json.dumps({"get_standards": True}))

    async def request_sessions(self) -> None:
        """
        Request the list of saved preset sessions from the server.

        After calling this, call :meth:`parse_messages` until ``self.sessions``
        is populated. Each entry is a dict ``{name: json_string}``.
        """
        await self.websocket.send(json.dumps({"get_sessions": True}))

    async def request_external_losses(self) -> None:
        """
        Request the list of external loss attenuator tables from the server.

        After calling this, call :meth:`parse_messages` until
        ``self.external_losses`` is populated.
        Each entry is a dict ``{name: [[freq, line_loss, neutral_loss], ...]}``.
        """
        await self.websocket.send(json.dumps({"get_external_losses": True}))

    async def request_transfer_impedances(self) -> None:
        """
        Request the list of transfer impedance tables from the server.

        After calling this, call :meth:`parse_messages` until
        ``self.transfer_impedances`` is populated.
        """
        await self.websocket.send(json.dumps({"get_transfer_impedances": True}))

    async def request_licenses(self) -> None:
        """
        Request the list of activated licenses from the server.

        After calling this, call :meth:`parse_messages` until
        ``self.licenses`` is populated. Example: ``["emi", "osc"]``.
        ``self.invalid_licenses`` is populated in the same reply.
        """
        await self.websocket.send(json.dumps({"get_licenses": True}))

    async def request_temps(self) -> None:
        """
        Request the current PCB and FPGA temperatures from the server.

        After calling this, call :meth:`parse_messages` until
        ``self.temperatures`` is populated as ``[pcb_temp_C, fpga_temp_C]``.
        """
        await self.websocket.send(json.dumps({"get_temps": True}))

    async def request_report(
        self, standard: str, subranges: int = 10, margin: int = 10
    ) -> None:
        """
        Request an EMC compliance report for a given limit standard.

        After calling this, call :meth:`parse_messages` until ``self.report``
        is populated.  ``self.frase`` and ``self.report_image`` are set in the
        same reply.

        Each entry in ``self.report`` is a list::

            [trace_id, marker, freq_MHz,
             pk_dBuV, pk_limit, pk_margin,
             qp_dBuV, qp_limit, qp_margin,
             av_dBuV, av_limit, av_margin,
             channel, "PASS"/"FAIL"]

        ``self.frase`` is ``True`` if any emission is within ``margin`` dB of
        a limit.  ``self.report_image`` is ``True`` if a plot was generated.

        Args:
            standard:  Name of the limit standard to evaluate against.
            subranges: Number of sub-ranges for peak selection (default 10).
            margin:    Pass/fail margin in dB (default 10).
        """
        await self.websocket.send(json.dumps({
            "standard": standard,
            "subranges": subranges,
            "margin": margin,
        }))

    # ── Standards CRUD ────────────────────────────────────────────────────

    async def create_standard(self, name: str, rbw: str, values: List[List[str]]) -> None:
        """
        Create a new limit standard.

        Args:
            name:   Name for the new standard.
            rbw:    RBW this standard applies to (e.g. ``"9"`` or ``"120"``).
            values: List of frequency rows.  Each row is a list of 6 strings::

                        [from_MHz, to_MHz,
                         qp_from_dBuV, qp_to_dBuV,
                         av_from_dBuV, av_to_dBuV]
        """
        await self.websocket.send(json.dumps({
            "name": name,
            "modify": False,
            "standard_rbw": rbw,
            "values": values,
        }))

    async def edit_standard(
        self,
        original_name: str,
        new_name: str,
        rbw: str,
        values: List[List[str]],
    ) -> None:
        """
        Edit an existing limit standard.

        Args:
            original_name: Current name of the standard to edit.
            new_name:      New name (can be the same as original to keep it).
            rbw:           New RBW value.
            values:        New frequency rows (same format as :meth:`create_standard`).
        """
        await self.websocket.send(json.dumps({
            "original_name": original_name,
            "name": new_name,
            "modify": True,
            "standard_rbw": rbw,
            "values": values,
        }))

    async def delete_standard(self, name: str) -> None:
        """
        Delete a limit standard by name.

        Args:
            name: Name of the standard to delete.
        """
        await self.websocket.send(json.dumps({"delete_standard": name}))

    async def reset_standards(self) -> None:
        """Reset all limit standards to factory defaults."""
        await self.websocket.send(json.dumps({"reset_standards": True}))

    # ── Sessions CRUD ─────────────────────────────────────────────────────

    async def create_session(self, name: str, values: str) -> None:
        """
        Save a new preset session.

        Args:
            name:   Name for the new session.
            values: Session configuration as a JSON string.
        """
        await self.websocket.send(json.dumps({
            "session_name": name,
            "modify": False,
            "session_values": values,
        }))

    async def edit_session(
        self, original_name: str, new_name: str, values: str
    ) -> None:
        """
        Edit an existing preset session.

        Args:
            original_name: Current name of the session.
            new_name:      New name (can be the same to keep it).
            values:        Updated session configuration as a JSON string.
        """
        await self.websocket.send(json.dumps({
            "original_session": original_name,
            "session_name": new_name,
            "modify": True,
            "values": values,
        }))

    async def delete_session(self, name: str) -> None:
        """
        Delete a preset session by name.

        Args:
            name: Name of the session to delete.
        """
        await self.websocket.send(json.dumps({"delete_session": name}))

    async def reset_sessions(self) -> None:
        """Delete all saved sessions."""
        await self.websocket.send(json.dumps({"reset_sessions": True}))

    # ── External loss CRUD ────────────────────────────────────────────────

    async def create_external_loss(self, name: str, values: List[List[str]]) -> None:
        """
        Create a new external loss (cable / LISN) attenuation table.

        Args:
            name:   Name for the new attenuator table.
            values: List of frequency rows.  Each row is a list of 3 strings::

                        [frequency_MHz, line_loss_dB, neutral_loss_dB]

        Example::

            await emscope.create_external_loss("LISN 50uH", [
                ["0.009",  "1.2", "1.1"],
                ["30.000", "2.5", "2.4"],
            ])
        """
        await self.websocket.send(json.dumps({
            "external_loss_name": name,
            "modify": False,
            "values": values,
        }))

    async def edit_external_loss(
        self, original_name: str, new_name: str, values: List[List[str]]
    ) -> None:
        """
        Edit an existing external loss table.

        Args:
            original_name: Current name of the table.
            new_name:      New name (can be the same to keep it).
            values:        Updated rows (same format as :meth:`create_external_loss`).
        """
        await self.websocket.send(json.dumps({
            "original_external_loss_name": original_name,
            "external_loss_name": new_name,
            "modify": True,
            "values": values,
        }))

    async def delete_external_loss(self, name: str) -> None:
        """
        Delete an external loss table by name.

        Args:
            name: Name of the table to delete.
        """
        await self.websocket.send(json.dumps({"delete_external_loss": name}))

    # ── Private helpers ───────────────────────────────────────────────────

    def _reset_values(self) -> None:
        """Initialize all instance attributes to their defaults."""
        # Device info (populated on connect, read-only)
        self.SN: str = ""
        self.SFP_SN: str = ""
        self.MAC: str = ""
        self.sw_version: str = ""
        self.measurement_uncertainty: str = ""
        self.num_points: int = 0

        # Measurement configuration (writable)
        self.measure_channel: str = "lg"
        self.detector_type: str = "pk"
        self.trace_type: str = "clearwrite"
        self.average: int = 10
        self.rbw: str = "9"
        self.measure_mode: str = "circuit"
        self.mode: str = "circuit"           # readable alias for measure_mode
        self.reference_level: int = 100
        self.input_attenuator: Union[int, str] = "auto"
        self.input_attenuator_auto_value: int = 0
        self.amp_units: str = "dbuv"
        self.sweep_time: float = 1.0
        self.external_loss: str = ""
        self.session_UUID: str = ""
        self.visible: bool = True
        self.display_range: Optional[List[int]] = None
        self.threephase: bool = False

        # Oscilloscope settings
        self.osc_us_div: Optional[float] = None
        self.osc_volts_div: Optional[float] = None
        self.osc_sweep: Optional[str] = None
        self.trigger: Optional[str] = None
        self.osc_trigger_offset: Optional[float] = None
        self.osc_trigger_level: Optional[float] = None
        self.osc_vertical_offset: Optional[float] = None
        self.osc_horizontal_offset: Optional[float] = None

        # Measurement data (updated each sweep)
        self.freq: List[float] = []          # cached freq axis from server
        self.time: List[float] = []          # oscilloscope time axis
        self.values: List[List[float]] = []  # [[freq_hz, amp], ...]
        self.overload: bool = False
        self.repetition: bool = False

        # Requested data (populated after the corresponding request_* call)
        self.standards: List[dict] = []
        self.sessions: List[dict] = []
        self.external_losses: List[dict] = []
        self.external_losses_threephase: List[dict] = []
        self.transfer_impedances: List[dict] = []
        self.transfer_impedances_threephase: List[dict] = []
        self.clickmeter_devices: List[dict] = []
        self.licenses: List[str] = []
        self.invalid_licenses: List[str] = []
        self.report: List[list] = []
        self.frase: bool = False
        self.report_image: bool = False
        self.temperatures: List[float] = []

        # System / status fields
        self.errors: dict = {}
        self.session_saved: str = ""
        self.test_status: str = ""
        self.time_limit: int = 0
        self.rejected_command: str = ""
        self.show_message: list = []
        self.error: str = ""
        self.message: str = ""
        self.timestamp: float = 0.0
        self.type: str = ""

        # ClickMeter data
        self.clickmeter_channel_values: list = []
        self.overload_region: list = []
        self.flowcharts: list = []
        self.active_trace: str = ""
        self.test_duration: float = 0.0

    # ── Backwards-compatibility aliases ───────────────────────────────────

    async def setValue(self, field: str, value: Any) -> None:
        """Alias for :meth:`set_value`. Kept for backwards compatibility."""
        await self.set_value(field, value)

    async def setRBW(self, rbw: str, threephase: bool = False) -> None:
        """Alias for :meth:`set_rbw`. Kept for backwards compatibility."""
        await self.set_rbw(rbw, threephase)

    async def parseMessages(self) -> Optional[Dict[str, Any]]:
        """Alias for :meth:`parse_messages`. Kept for backwards compatibility."""
        return await self.parse_messages()

    async def updateStandardsList(self) -> None:
        """Alias for :meth:`request_standards`. Kept for backwards compatibility."""
        await self.request_standards()
