summaryrefslogtreecommitdiff
path: root/api_layers/protocols/scpi.py
blob: 730f8e06f8cdca6d990827971c29836e1eb5abc6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
"""
api_layers/protocols/scpi.py

SCPI (Standard Commands for Programmable Instruments) protocol layer.

Each channel maps to a query string.  The layer sends each query in
sequence and parses the numeric response.

Terminator: LF (\\n) by default; instruments also accept CR+LF.
Response parsing strips unit suffixes — "+3.14159 V" → 3.14159.

Example channel setup:
  SCPIChannel("V1", "Voltage",  query="MEAS:VOLT?",         unit="V")
  SCPIChannel("I1", "Current",  query="MEAS:CURR?",         unit="A")
  SCPIChannel("T1", "Temp",     query="SENS:TEMP:DATA?",    unit="°C")
"""

import math
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional

from api_layers.protocols.base_protocol import BaseProtocol


@dataclass
class SCPIChannel:
    channel_id: str
    name:       str
    query:      str          # e.g. "MEAS:VOLT?" or "MEAS:VOLT? (@1)"
    unit:       str  = ""
    scale:      float = 1.0
    write_cmd:  str  = ""    # e.g. "VOLT {value}" — set to send writes


class SCPILayer(BaseProtocol):

    def __init__(
        self,
        port:          str,
        baud:          int              = 9600,
        channels:      List[SCPIChannel] = None,
        poll_interval: float            = 0.2,
        simulate:      bool             = True,
    ):
        super().__init__(port, baud, poll_interval, simulate)
        self.channels = channels or []

    # ── Protocol ──────────────────────────────────────────────────────────

    def _poll(self) -> Dict[str, float]:
        result: Dict[str, float] = {}
        for ch in self.channels:
            if not ch.query.strip():
                continue
            try:
                self._ser.write(f"{ch.query.strip()}\n".encode())
                resp = self._ser.readline().decode(errors="replace").strip()
                val  = _parse_numeric(resp)
                if val is not None:
                    result[ch.channel_id] = val * ch.scale
            except Exception:
                pass
        return result

    def _simulate(self, t: float) -> Dict[str, float]:
        return {
            ch.channel_id: math.sin(t * (0.3 + i * 0.2)) * 5.0 + i * 2.0
            for i, ch in enumerate(self.channels)
        }

    def write(self, channel_id: str, value) -> bool:
        ch = next((c for c in self.channels if c.channel_id == channel_id), None)
        if ch is None or not ch.write_cmd:
            return False
        try:
            cmd = ch.write_cmd.format(value=value)
            self._ser.write(f"{cmd}\n".encode())
            return True
        except Exception:
            return False

    def query_idn(self) -> str:
        """Send *IDN? and return instrument identification string."""
        if not self._ser:
            return "(not connected)"
        try:
            self._ser.write(b"*IDN?\n")
            return self._ser.readline().decode(errors="replace").strip()
        except Exception as e:
            return f"(error: {e})"


# ── Helpers ───────────────────────────────────────────────────────────────────

_NUM_RE = re.compile(r"[+-]?\d+\.?\d*(?:[eE][+-]?\d+)?")


def _parse_numeric(resp: str) -> Optional[float]:
    """Extract first float from SCPI response, ignoring unit suffixes."""
    m = _NUM_RE.search(resp)
    return float(m.group()) if m else None