capva 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
capva/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """
2
+ capva - Unified EPICS Process Variable client (CA + PVA).
3
+
4
+ Supports both Channel Access (CA) and PV Access (PVA) with a single, consistent API.
5
+ Automatically selects the optimal backend: p4p for PVA, pyepics for CA.
6
+ """
7
+
8
+ from .constants import DEFAULT_IO_TIMEOUT
9
+ from .pv import PV
10
+ from .pv_data import PVData
11
+ from .pool import PVPool
12
+ from .monitor_handle import MonitorHandle
13
+ from .tools import MonitorSession, pvget, pvinfo, pvmonitor, pvput
14
+ from .exceptions import (
15
+ EPICSProtocolError,
16
+ EPICSConnectionError,
17
+ EPICSGetError,
18
+ EPICSPutError,
19
+ EPICSTimeoutError,
20
+ )
21
+
22
+ __all__ = [
23
+ "PV",
24
+ "PVData",
25
+ "PVPool",
26
+ "MonitorHandle",
27
+ "pvget",
28
+ "pvput",
29
+ "pvinfo",
30
+ "MonitorSession",
31
+ "pvmonitor",
32
+ "EPICSProtocolError",
33
+ "EPICSConnectionError",
34
+ "EPICSGetError",
35
+ "EPICSPutError",
36
+ "EPICSTimeoutError",
37
+ ]
capva/array_b64.py ADDED
@@ -0,0 +1,40 @@
1
+ """Numeric array base64 encoding for PV value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any, List, Optional, Union
7
+
8
+ import numpy as np
9
+
10
+
11
+ def _numeric_array_to_base64(array: Union[List, np.ndarray], dtype: str) -> str:
12
+ arr = np.asarray(array, dtype=dtype)
13
+ if arr.dtype.byteorder not in ("<", "="):
14
+ arr = arr.astype("<" + arr.dtype.str[1:])
15
+ return base64.b64encode(arr.tobytes()).decode("ascii")
16
+
17
+
18
+ def encode_array(arr: Any) -> tuple[Optional[str], Optional[str]]:
19
+ """Returns (b64arr, b64dtype) for numeric arrays."""
20
+ if arr is None:
21
+ return None, None
22
+
23
+ arr = np.asarray(arr)
24
+ if arr.size == 0:
25
+ return None, None
26
+
27
+ if np.issubdtype(arr.dtype, np.floating):
28
+ return _numeric_array_to_base64(arr, "float64"), "float64"
29
+
30
+ if np.issubdtype(arr.dtype, np.integer):
31
+ min_val, max_val = arr.min(), arr.max()
32
+ if -128 <= min_val <= max_val <= 127:
33
+ dtype = "int8"
34
+ elif -32768 <= min_val <= max_val <= 32767:
35
+ dtype = "int16"
36
+ else:
37
+ dtype = "int32"
38
+ return _numeric_array_to_base64(arr, dtype), dtype
39
+
40
+ return None, None
capva/constants.py ADDED
@@ -0,0 +1,3 @@
1
+ """Shared constants for capva."""
2
+
3
+ DEFAULT_IO_TIMEOUT: float = 5.0
capva/exceptions.py ADDED
@@ -0,0 +1,26 @@
1
+ """Custom exceptions for capva."""
2
+
3
+
4
+ class EPICSProtocolError(Exception):
5
+ """Raised when protocol detection or selection fails."""
6
+ pass
7
+
8
+
9
+ class EPICSConnectionError(Exception):
10
+ """Raised when connection to PV fails."""
11
+ pass
12
+
13
+
14
+ class EPICSGetError(Exception):
15
+ """Raised when a get operation fails after the PV is reachable."""
16
+ pass
17
+
18
+
19
+ class EPICSPutError(Exception):
20
+ """Raised when a put operation fails after the PV is reachable."""
21
+ pass
22
+
23
+
24
+ class EPICSTimeoutError(Exception):
25
+ """Raised when PV operation times out."""
26
+ pass
@@ -0,0 +1,13 @@
1
+ """Opaque handle for PV monitor subscriptions.
2
+
3
+ MonitorHandle stores two values returned from provider ``monitor()`` calls:
4
+
5
+ - owner: the CAPV or PVAPV instance that created this subscription
6
+ - handle: CA callback index (int) or p4p Subscription, depending on protocol
7
+ """
8
+
9
+
10
+ class MonitorHandle:
11
+ def __init__(self, owner, handle):
12
+ self._owner = owner
13
+ self._handle = handle
capva/pool.py ADDED
@@ -0,0 +1,66 @@
1
+ """Reference-counted pool of PV instances keyed by protocol and name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import threading
7
+ from typing import Dict, List, Tuple
8
+
9
+ from .protocol import pvname_key
10
+ from .pv import PV
11
+
12
+
13
+ def _pool_key_from_pv(pv: PV) -> str:
14
+ return f"{pv.protocol}:{pv.pvname}"
15
+
16
+
17
+ @dataclass
18
+ class _PoolEntry:
19
+ pv: PV
20
+ refs: int = 0
21
+
22
+
23
+ class PVPool:
24
+ _lock = threading.Lock()
25
+ _entries: Dict[str, _PoolEntry] = {}
26
+
27
+ @classmethod
28
+ def getPV(cls, pvname: str) -> PV:
29
+ if not pvname or not pvname.strip():
30
+ raise ValueError("pvname must be a non-empty string")
31
+
32
+ key = pvname_key(pvname)
33
+ with cls._lock:
34
+ entry = cls._entries.get(key)
35
+ if entry is None:
36
+ entry = _PoolEntry(pv=PV(pvname))
37
+ cls._entries[key] = entry
38
+ entry.refs += 1
39
+ return entry.pv
40
+
41
+ @classmethod
42
+ def releasePV(cls, pv: PV) -> None:
43
+ key = _pool_key_from_pv(pv)
44
+ with cls._lock:
45
+ entry = cls._entries.get(key)
46
+ if entry is None:
47
+ return
48
+ entry.refs -= 1
49
+ if entry.refs <= 0:
50
+ entry.pv.close()
51
+ del cls._entries[key]
52
+
53
+ @classmethod
54
+ def getReferenceCount(cls, pvname: str) -> int:
55
+ key = pvname_key(pvname)
56
+ with cls._lock:
57
+ entry = cls._entries.get(key)
58
+ return entry.refs if entry else 0
59
+
60
+ @classmethod
61
+ def getPVReferences(cls) -> List[Tuple[str, PV, int]]:
62
+ with cls._lock:
63
+ return [
64
+ (key, entry.pv, entry.refs)
65
+ for key, entry in cls._entries.items()
66
+ ]
capva/protocol.py ADDED
@@ -0,0 +1,20 @@
1
+ """Protocol detection and constants for EPICS PV client."""
2
+
3
+ from typing import Tuple
4
+
5
+ CA = "ca"
6
+ PVA = "pva"
7
+ DEFAULT_PROTOCOL = CA
8
+
9
+
10
+ def parse_protocol(pv_name: str) -> Tuple[str, str]:
11
+ if pv_name.startswith("pva://"):
12
+ return PVA, pv_name[6:]
13
+ elif pv_name.startswith("ca://"):
14
+ return CA, pv_name[5:]
15
+ return DEFAULT_PROTOCOL, pv_name
16
+
17
+
18
+ def pvname_key(pvname: str) -> str:
19
+ protocol, clean_name = parse_protocol(pvname)
20
+ return f"{protocol}:{clean_name}"
@@ -0,0 +1,6 @@
1
+ """PV implementations for CA and PVA protocols."""
2
+
3
+ from .ca_pv import CAPV
4
+ from .pva_pv import PVAPV
5
+
6
+ __all__ = ["CAPV", "PVAPV"]
@@ -0,0 +1,158 @@
1
+ """EPICS Channel Access (CA) PV implementation using pyepics"""
2
+
3
+ from typing import Any, Callable
4
+
5
+ import epics
6
+ from epics.ca import ChannelAccessGetFailure, ChannelAccessException
7
+ from ..constants import DEFAULT_IO_TIMEOUT
8
+ from ..pv_data import PVData
9
+ from ..pv_parser import parse_ca, parse_ca_update, ca_metadata
10
+ from ..monitor_handle import MonitorHandle
11
+ from ..exceptions import EPICSTimeoutError, EPICSConnectionError, EPICSGetError, EPICSPutError
12
+
13
+
14
+ class CAPV:
15
+ def __init__(self, pvname: str):
16
+ self.pvname = pvname
17
+ self._pv = epics.PV(pvname)
18
+ self._monitor_cbs: dict[int, Callable[[PVData], None]] = {}
19
+
20
+ def get(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> PVData:
21
+ if not self._pv.wait_for_connection(timeout=timeout):
22
+ raise EPICSConnectionError(f"PV {self.pvname} not connected")
23
+
24
+ try:
25
+ ca_dict = self._pv.get_with_metadata(
26
+ with_ctrlvars=True,
27
+ use_monitor=False,
28
+ timeout=timeout,
29
+ )
30
+ except ChannelAccessGetFailure as e:
31
+ raise EPICSGetError(
32
+ f"Get failed for {self.pvname}: {e}"
33
+ )
34
+ if ca_dict is None:
35
+ raise EPICSTimeoutError(
36
+ f"Get operation timed out after {timeout}s for {self.pvname}"
37
+ )
38
+ return parse_ca(ca_dict, self.pvname)
39
+
40
+ def put(self, value, *, timeout: float = DEFAULT_IO_TIMEOUT, wait: bool = False) -> None:
41
+ if not self._pv.wait_for_connection(timeout=timeout):
42
+ raise EPICSConnectionError(f"PV {self.pvname} not connected")
43
+
44
+ try:
45
+ result = self._pv.put(value, wait=wait, timeout=timeout)
46
+ except ChannelAccessException as e:
47
+ raise EPICSPutError(f"Put failed for {self.pvname}: {e}")
48
+
49
+ if result is None:
50
+ raise EPICSConnectionError(f"PV {self.pvname} not connected")
51
+ elif result == 1:
52
+ pass
53
+ elif result == -1:
54
+ if wait:
55
+ raise EPICSTimeoutError(
56
+ f"Put timed out after {timeout}s for {self.pvname}"
57
+ )
58
+ raise EPICSPutError(
59
+ f"Put failed for {self.pvname}: unexpected result {result}"
60
+ )
61
+ else:
62
+ raise EPICSPutError(
63
+ f"Put failed for {self.pvname}: unexpected result {result}"
64
+ )
65
+
66
+ def info(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> dict[str, Any]:
67
+ if not self._pv.wait_for_connection(timeout=timeout):
68
+ raise EPICSConnectionError(f"PV {self.pvname} not connected")
69
+
70
+ try:
71
+ ca_dict = self._pv.get_with_metadata(
72
+ with_ctrlvars=True,
73
+ use_monitor=False,
74
+ timeout=timeout,
75
+ )
76
+ except ChannelAccessGetFailure as e:
77
+ raise EPICSGetError(
78
+ f"Get failed for {self.pvname}: {e}"
79
+ )
80
+ if ca_dict is None:
81
+ raise EPICSTimeoutError(
82
+ f"Get operation timed out after {timeout}s for {self.pvname}"
83
+ )
84
+
85
+ base_type = self._pv.type
86
+ type_str = f"{base_type}[]" if self._pv.nelm > 1 else base_type
87
+ info: dict[str, Any] = {
88
+ "pvName": self.pvname,
89
+ "protocol": "ca",
90
+ "state": "CONNECTED",
91
+ "host": self._pv.host or "",
92
+ "type": type_str,
93
+ }
94
+ info.update(ca_metadata(ca_dict))
95
+ return info
96
+
97
+ def _on_connection_change(self, *, conn=None, **kw):
98
+ if conn:
99
+ return
100
+ disconnected = PVData.create_disconnected(self.pvname)
101
+ for cb in self._monitor_cbs.values():
102
+ cb(disconnected)
103
+
104
+ def monitor(
105
+ self,
106
+ callback: Callable[[PVData], None],
107
+ ) -> MonitorHandle:
108
+ if not self._monitor_cbs:
109
+ self._pv.connection_callbacks.append(self._on_connection_change)
110
+
111
+ def wrapped_callback(**ca_kwargs):
112
+ callback(parse_ca_update(ca_kwargs, self.pvname))
113
+
114
+ # run_now=False: CA monitor delivers an initial update on subscribe;
115
+ # True would duplicate it via run_callback.
116
+ index = self._pv.add_callback(
117
+ wrapped_callback,
118
+ with_ctrlvars=True,
119
+ run_now=False,
120
+ )
121
+ self._monitor_cbs[index] = callback
122
+
123
+ if not self._pv.connected:
124
+ callback(PVData.create_disconnected(self.pvname))
125
+
126
+ return MonitorHandle(self, index)
127
+
128
+ def clear_monitor(self, handle: MonitorHandle) -> None:
129
+ if handle._owner is not self:
130
+ raise ValueError(
131
+ f"MonitorHandle does not belong to PV {self.pvname!r}"
132
+ )
133
+ self._monitor_cbs.pop(handle._handle, None)
134
+ self._pv.remove_callback(handle._handle)
135
+ if not self._monitor_cbs:
136
+ try:
137
+ self._pv.connection_callbacks.remove(self._on_connection_change)
138
+ except ValueError:
139
+ pass
140
+
141
+ def disconnect(self) -> None:
142
+ self._monitor_cbs.clear()
143
+ try:
144
+ self._pv.connection_callbacks.remove(self._on_connection_change)
145
+ except ValueError:
146
+ pass
147
+ # Clears monitor callbacks and the shared CA subscription;
148
+ # explicit remove_callback is unnecessary.
149
+ self._pv.disconnect()
150
+
151
+ def close(self) -> None:
152
+ chid = self._pv.chid
153
+ self.disconnect()
154
+ if chid is not None:
155
+ try:
156
+ epics.ca.clear_channel(chid)
157
+ except Exception:
158
+ pass
@@ -0,0 +1,127 @@
1
+ """EPICS PVAccess (PVA) PV implementation using p4p"""
2
+
3
+ from typing import Any, Callable
4
+
5
+ from p4p.client.thread import Context, Disconnected, TimeoutError
6
+ from ..constants import DEFAULT_IO_TIMEOUT
7
+ from ..pv_data import PVData
8
+ from ..pv_parser import parse_pva, parse_pva_update, pva_metadata
9
+ from ..monitor_handle import MonitorHandle
10
+ from ..exceptions import EPICSTimeoutError, EPICSConnectionError, EPICSGetError, EPICSPutError
11
+
12
+ _pva_context = Context("pva", nt=False)
13
+
14
+
15
+ class PVAPV:
16
+ def __init__(self, pvname: str):
17
+ self.pvname = pvname
18
+ self._subscriptions: list = []
19
+
20
+ def get(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> PVData:
21
+ pva_value = _pva_context.get(
22
+ self.pvname, timeout=timeout, throw=False
23
+ )
24
+ if isinstance(pva_value, Exception):
25
+ if isinstance(pva_value, TimeoutError):
26
+ raise EPICSTimeoutError(
27
+ f"Get operation timed out after {timeout}s for {self.pvname}"
28
+ )
29
+ if isinstance(pva_value, Disconnected):
30
+ raise EPICSConnectionError(
31
+ f"PV {self.pvname} not connected: {pva_value}"
32
+ )
33
+ raise EPICSGetError(
34
+ f"Failed to get {self.pvname}: "
35
+ f"{type(pva_value).__name__}: {pva_value}"
36
+ )
37
+ return parse_pva(pva_value, self.pvname)
38
+
39
+ def put(self, value, *, timeout: float = DEFAULT_IO_TIMEOUT, wait: bool = False) -> None:
40
+ result = _pva_context.put(
41
+ self.pvname,
42
+ value,
43
+ timeout=timeout,
44
+ wait=wait,
45
+ throw=False,
46
+ )
47
+ if result is not None:
48
+ if isinstance(result, TimeoutError):
49
+ raise EPICSTimeoutError(
50
+ f"Put operation timed out after {timeout}s for {self.pvname}"
51
+ )
52
+ if isinstance(result, Disconnected):
53
+ raise EPICSConnectionError(
54
+ f"PV {self.pvname} not connected: {result}"
55
+ )
56
+ raise EPICSPutError(
57
+ f"Failed to put {self.pvname}: "
58
+ f"{type(result).__name__}: {result}"
59
+ )
60
+
61
+ def info(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> dict[str, Any]:
62
+ pva_value = _pva_context.get(
63
+ self.pvname, timeout=timeout, throw=False
64
+ )
65
+ if isinstance(pva_value, Exception):
66
+ if isinstance(pva_value, TimeoutError):
67
+ raise EPICSTimeoutError(
68
+ f"Get operation timed out after {timeout}s for {self.pvname}"
69
+ )
70
+ if isinstance(pva_value, Disconnected):
71
+ raise EPICSConnectionError(
72
+ f"PV {self.pvname} not connected: {pva_value}"
73
+ )
74
+ raise EPICSGetError(
75
+ f"Failed to get {self.pvname}: "
76
+ f"{type(pva_value).__name__}: {pva_value}"
77
+ )
78
+
79
+ info: dict[str, Any] = {
80
+ "pvName": self.pvname,
81
+ "protocol": "pva",
82
+ "state": "CONNECTED",
83
+ "host": "",
84
+ "type": pva_value.getID(),
85
+ }
86
+ info.update(pva_metadata(pva_value))
87
+ return info
88
+
89
+ def monitor(
90
+ self,
91
+ callback: Callable[[PVData], None],
92
+ ) -> MonitorHandle:
93
+ def wrapped_callback(pva_update):
94
+ if isinstance(pva_update, Disconnected):
95
+ callback(PVData.create_disconnected(self.pvname))
96
+ return
97
+ if isinstance(pva_update, Exception):
98
+ return
99
+ callback(parse_pva_update(pva_update, self.pvname))
100
+
101
+ subscription = _pva_context.monitor(
102
+ self.pvname, wrapped_callback, notify_disconnect=True
103
+ )
104
+ self._subscriptions.append(subscription)
105
+ return MonitorHandle(self, subscription)
106
+
107
+ def clear_monitor(self, handle: MonitorHandle) -> None:
108
+ if handle._owner is not self:
109
+ raise ValueError(
110
+ f"MonitorHandle does not belong to PV {self.pvname!r}"
111
+ )
112
+ subscription = handle._handle
113
+ subscription.close()
114
+ try:
115
+ self._subscriptions.remove(subscription)
116
+ except ValueError:
117
+ pass
118
+
119
+ def disconnect(self) -> None:
120
+ for subscription in list(self._subscriptions):
121
+ subscription.close()
122
+ self._subscriptions.clear()
123
+ # This call is optional in p4p; unused channels are removed from the context cache after ~20 seconds.
124
+ # _pva_context.disconnect(self.pvname)
125
+
126
+ def close(self) -> None:
127
+ self.disconnect()
capva/pv.py ADDED
@@ -0,0 +1,68 @@
1
+ """Unified PV client interface for EPICS CA and PVA"""
2
+
3
+ from typing import Any, Callable
4
+
5
+ from .constants import DEFAULT_IO_TIMEOUT
6
+ from .pv_data import PVData
7
+ from .monitor_handle import MonitorHandle
8
+ from .protocol import parse_protocol, CA, PVA
9
+ from .providers.ca_pv import CAPV
10
+ from .providers.pva_pv import PVAPV
11
+ from .exceptions import (
12
+ EPICSProtocolError,
13
+ EPICSConnectionError,
14
+ EPICSGetError,
15
+ EPICSPutError,
16
+ EPICSTimeoutError,
17
+ )
18
+
19
+
20
+ class PV:
21
+ def __init__(self, pvname: str):
22
+ protocol, clean_name = parse_protocol(pvname)
23
+ self.protocol = protocol
24
+ self.pvname = clean_name
25
+
26
+ if protocol == CA:
27
+ self._pv = CAPV(clean_name)
28
+ elif protocol == PVA:
29
+ self._pv = PVAPV(clean_name)
30
+ else:
31
+ raise EPICSProtocolError(f"Unsupported protocol: {protocol}")
32
+
33
+ def get(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> PVData:
34
+ return self._pv.get(timeout=timeout)
35
+
36
+ def get_or_none(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> PVData | None:
37
+ try:
38
+ return self.get(timeout=timeout)
39
+ except (EPICSTimeoutError, EPICSConnectionError):
40
+ return None
41
+
42
+ def put(self, value, *, timeout: float = DEFAULT_IO_TIMEOUT, wait: bool = False) -> None:
43
+ self._pv.put(value, timeout=timeout, wait=wait)
44
+
45
+ def put_or_false(self, value, *, timeout: float = DEFAULT_IO_TIMEOUT, wait: bool = False) -> bool:
46
+ try:
47
+ self.put(value, timeout=timeout, wait=wait)
48
+ return True
49
+ except (EPICSTimeoutError, EPICSConnectionError, EPICSPutError):
50
+ return False
51
+
52
+ def info(self, *, timeout: float = DEFAULT_IO_TIMEOUT) -> dict[str, Any]:
53
+ return self._pv.info(timeout=timeout)
54
+
55
+ def monitor(
56
+ self,
57
+ callback: Callable[[PVData], None],
58
+ ) -> MonitorHandle:
59
+ return self._pv.monitor(callback)
60
+
61
+ def clear_monitor(self, handle: MonitorHandle) -> None:
62
+ self._pv.clear_monitor(handle)
63
+
64
+ def disconnect(self) -> None:
65
+ self._pv.disconnect()
66
+
67
+ def close(self) -> None:
68
+ self._pv.close()
capva/pv_data.py ADDED
@@ -0,0 +1,152 @@
1
+ """Data models for EPICS PV client"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Any, List, Literal, Optional, Union
8
+
9
+ import numpy as np
10
+
11
+ from .array_b64 import encode_array
12
+
13
+
14
+ # NormativeType data definitions
15
+ # See: https://docs.epics-controls.org/en/latest/pv-access/Normative-Types-Specification.html
16
+ @dataclass
17
+ class Alarm:
18
+ severity: int = 0
19
+ status: int = 0
20
+ message: str = "NO_ALARM"
21
+
22
+
23
+ @dataclass
24
+ class TimeStamp:
25
+ secondsPastEpoch: int = 0
26
+ nanoseconds: int = 0
27
+ userTag: int = 0
28
+
29
+
30
+ @dataclass
31
+ class Display:
32
+ limitLow: Optional[float] = None
33
+ limitHigh: Optional[float] = None
34
+ description: Optional[str] = None
35
+ units: Optional[str] = None
36
+ precision: Optional[int] = None
37
+ form: Optional[int] = None
38
+ choices: Optional[List[str]] = None
39
+
40
+
41
+ @dataclass
42
+ class Control:
43
+ limitLow: Optional[float] = None
44
+ limitHigh: Optional[float] = None
45
+ minStep: Optional[float] = None
46
+
47
+
48
+ @dataclass
49
+ class ValueAlarm:
50
+ active: Optional[bool] = None
51
+ lowAlarmLimit: Optional[float] = None
52
+ lowWarningLimit: Optional[float] = None
53
+ highWarningLimit: Optional[float] = None
54
+ highAlarmLimit: Optional[float] = None
55
+ lowAlarmSeverity: Optional[int] = None
56
+ lowWarningSeverity: Optional[int] = None
57
+ highWarningSeverity: Optional[int] = None
58
+ highAlarmSeverity: Optional[int] = None
59
+ hysteresis: Optional[float] = None
60
+
61
+
62
+ @dataclass
63
+ class PVData:
64
+ pvName: Optional[str] = None
65
+ value: Optional[Union[float, int, str, List[float], List[int], List[str]]] = None
66
+ enumChoices: Optional[List[str]] = None
67
+ alarm: Optional[Alarm] = None
68
+ timeStamp: Optional[TimeStamp] = None
69
+ display: Optional[Display] = None
70
+ control: Optional[Control] = None
71
+ valueAlarm: Optional[ValueAlarm] = None
72
+
73
+ @classmethod
74
+ def create_disconnected(cls, pvname: str) -> "PVData":
75
+ # Alarm shape aligned with org.epics.vtype.Alarm.disconnected():
76
+ # INVALID(3), CLIENT(7), "Disconnected"
77
+ return cls(
78
+ pvName=pvname,
79
+ alarm=Alarm(severity=3, status=7, message="Disconnected"),
80
+ )
81
+
82
+ def is_disconnected(self) -> bool:
83
+ alarm = self.alarm
84
+ return (
85
+ alarm is not None
86
+ and alarm.severity == 3
87
+ and alarm.status == 7
88
+ and alarm.message == "Disconnected"
89
+ )
90
+
91
+ def to_dict(
92
+ self,
93
+ *,
94
+ mode: Literal["full", "update", "metadata"] = "full",
95
+ base64_encode: bool = False,
96
+ ) -> dict[str, Any]:
97
+ if mode == "full":
98
+ d = {k: v for k, v in asdict(self).items() if v is not None}
99
+ if base64_encode and isinstance(self.value, (list, np.ndarray)):
100
+ b64, dtype = encode_array(self.value)
101
+ if b64 is not None:
102
+ d.pop("value", None)
103
+ d["b64arr"] = b64
104
+ d["b64dtype"] = dtype
105
+ return d
106
+
107
+ if mode == "update":
108
+ d: dict[str, Any] = {}
109
+ if self.pvName is not None:
110
+ d["pvName"] = self.pvName
111
+ if base64_encode and isinstance(self.value, (list, np.ndarray)):
112
+ b64, dtype = encode_array(self.value)
113
+ if b64 is not None:
114
+ d["b64arr"] = b64
115
+ d["b64dtype"] = dtype
116
+ elif self.value is not None:
117
+ d["value"] = self.value
118
+ elif self.value is not None:
119
+ d["value"] = self.value
120
+ if self.enumChoices is not None:
121
+ d["enumChoices"] = self.enumChoices
122
+ if self.alarm is not None:
123
+ d["alarm"] = asdict(self.alarm)
124
+ if self.timeStamp is not None:
125
+ d["timeStamp"] = asdict(self.timeStamp)
126
+ return d
127
+
128
+ if mode == "metadata":
129
+ d = {}
130
+ if self.display is not None:
131
+ d["display"] = asdict(self.display)
132
+ if self.control is not None:
133
+ d["control"] = asdict(self.control)
134
+ if self.valueAlarm is not None:
135
+ d["valueAlarm"] = asdict(self.valueAlarm)
136
+ return d
137
+
138
+ raise ValueError(
139
+ f"mode must be 'full', 'update', or 'metadata', got {mode!r}"
140
+ )
141
+
142
+ def to_json(
143
+ self,
144
+ *,
145
+ mode: Literal["full", "update", "metadata"] = "full",
146
+ base64_encode: bool = False,
147
+ **kwargs: Any,
148
+ ) -> str:
149
+ return json.dumps(
150
+ self.to_dict(mode=mode, base64_encode=base64_encode),
151
+ **kwargs,
152
+ )
capva/pv_parser.py ADDED
@@ -0,0 +1,255 @@
1
+ """Parsing utilities for EPICS PV client"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import Any, List, Optional, Union
7
+
8
+ import math
9
+
10
+ import numpy as np
11
+ from epics import dbr
12
+ from p4p.wrapper import Value as p4pValue
13
+
14
+ from .pv_data import PVData, Alarm, TimeStamp, Display, Control, ValueAlarm
15
+
16
+
17
+ def get_none_if_nan(obj, k: str):
18
+ v = obj.get(k)
19
+ return None if isinstance(v, float) and math.isnan(v) else v
20
+
21
+
22
+ def ca_alarm_message(status) -> str:
23
+ try:
24
+ return dbr.AlarmStatus(int(status)).name
25
+ except (ValueError, TypeError):
26
+ return "UNKNOWN"
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # CA helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _ca_normalize(v):
35
+ """Converts numpy types and arrays to JSON-serializable Python types."""
36
+ if isinstance(v, np.generic):
37
+ return v.item()
38
+ if isinstance(v, np.ndarray):
39
+ return v.tolist()
40
+ return v
41
+
42
+
43
+ def _ca_parse_value(pv_obj: dict) -> tuple[Any, Optional[List[str]]]:
44
+ value_field = pv_obj.get("value")
45
+ enumChoices = pv_obj.get("enum_strs")
46
+ value = _ca_normalize(value_field)
47
+ return value, enumChoices
48
+
49
+
50
+ def _ca_parse_alarm_ts(pv_obj: dict) -> tuple[Alarm, TimeStamp]:
51
+ status = _ca_normalize(pv_obj.get("status", 0))
52
+ alarm = Alarm(
53
+ severity = _ca_normalize(pv_obj.get("severity", 0)),
54
+ status = status,
55
+ message = ca_alarm_message(status),
56
+ )
57
+ ts = _ca_normalize(pv_obj.get("timestamp", 0.0)) or 0.0
58
+ sec = int(ts)
59
+ nsec = int((ts - sec) * 1e9)
60
+ timestamp = TimeStamp(
61
+ secondsPastEpoch = sec,
62
+ nanoseconds = nsec,
63
+ )
64
+ return alarm, timestamp
65
+
66
+
67
+ def _ca_parse_metadata_fields(
68
+ pv_obj: dict,
69
+ ) -> tuple[Display, Control, ValueAlarm]:
70
+ display = Display(
71
+ limitLow = _ca_normalize(get_none_if_nan(pv_obj, "lower_disp_limit")),
72
+ limitHigh = _ca_normalize(get_none_if_nan(pv_obj, "upper_disp_limit")),
73
+ units = pv_obj.get("units"),
74
+ precision = _ca_normalize(get_none_if_nan(pv_obj, "precision")),
75
+ )
76
+ control = Control(
77
+ limitLow = _ca_normalize(get_none_if_nan(pv_obj, "lower_ctrl_limit")),
78
+ limitHigh = _ca_normalize(get_none_if_nan(pv_obj, "upper_ctrl_limit")),
79
+ )
80
+ value_alarm = ValueAlarm(
81
+ lowAlarmLimit = _ca_normalize(get_none_if_nan(pv_obj, "lower_alarm_limit")),
82
+ highAlarmLimit = _ca_normalize(get_none_if_nan(pv_obj, "upper_alarm_limit")),
83
+ lowWarningLimit = _ca_normalize(get_none_if_nan(pv_obj, "lower_warning_limit")),
84
+ highWarningLimit = _ca_normalize(get_none_if_nan(pv_obj, "upper_warning_limit")),
85
+
86
+ # pyepics CA metadata has no hysteresis/HYST
87
+ hysteresis = None,
88
+ )
89
+ return display, control, value_alarm
90
+
91
+
92
+ def ca_metadata(pv_obj: dict) -> dict[str, Any]:
93
+ display, control, value_alarm = _ca_parse_metadata_fields(pv_obj)
94
+ return {
95
+ "display": asdict(display),
96
+ "control": asdict(control),
97
+ "valueAlarm": asdict(value_alarm),
98
+ }
99
+
100
+
101
+ def _ca_to_pvdata(
102
+ pv_obj: dict,
103
+ pv_name: str,
104
+ *,
105
+ with_metadata: bool,
106
+ ) -> PVData:
107
+ value, enumChoices = _ca_parse_value(pv_obj)
108
+ alarm, timestamp = _ca_parse_alarm_ts(pv_obj)
109
+ display = control = value_alarm = None
110
+ if with_metadata:
111
+ display, control, value_alarm = _ca_parse_metadata_fields(pv_obj)
112
+ return PVData(
113
+ pvName = pv_name,
114
+ value = value,
115
+ enumChoices = enumChoices,
116
+ alarm = alarm,
117
+ timeStamp = timestamp,
118
+ display = display,
119
+ control = control,
120
+ valueAlarm = value_alarm,
121
+ )
122
+
123
+
124
+ def parse_ca(pv_obj: dict, pv_name: str) -> PVData:
125
+ """Full CA snapshot: value, alarm, timeStamp, and metadata."""
126
+ return _ca_to_pvdata(pv_obj, pv_name, with_metadata=True)
127
+
128
+
129
+ def parse_ca_update(pv_obj: dict, pv_name: str) -> PVData:
130
+ """Fast CA monitor path: value, alarm, timeStamp only (no metadata)."""
131
+ return _ca_to_pvdata(pv_obj, pv_name, with_metadata=False)
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # PVA helpers
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def _pva_parse_value(pv_obj) -> tuple[Any, Optional[List[str]]]:
140
+ enumChoices: Optional[List[str]] = None
141
+ value: Any = None
142
+
143
+ value_field = pv_obj.get("value")
144
+
145
+ if isinstance(value_field, (int, float, str)):
146
+ value = value_field
147
+ elif (
148
+ isinstance(value_field, p4pValue)
149
+ and value_field.has("index")
150
+ and value_field.has("choices")
151
+ ):
152
+ value = value_field.get("index")
153
+ enumChoices = value_field.get("choices")
154
+ elif isinstance(value_field, (list, np.ndarray)):
155
+ value = (
156
+ value_field.tolist()
157
+ if isinstance(value_field, np.ndarray)
158
+ else list(value_field)
159
+ )
160
+
161
+ return value, enumChoices
162
+
163
+
164
+ def _pva_parse_alarm_ts(pv_obj) -> tuple[Alarm, TimeStamp]:
165
+ a = pv_obj.get("alarm") or {}
166
+ alarm = Alarm(
167
+ severity = a.get("severity", 0),
168
+ status = a.get("status", 0),
169
+ message = a.get("message", "NO_ALARM"),
170
+ )
171
+ ts = pv_obj.get("timeStamp") or {}
172
+ timestamp = TimeStamp(
173
+ secondsPastEpoch = ts.get("secondsPastEpoch", 0),
174
+ nanoseconds = ts.get("nanoseconds", 0),
175
+ userTag = ts.get("userTag", 0),
176
+ )
177
+ return alarm, timestamp
178
+
179
+
180
+ def _pva_parse_metadata_fields(
181
+ pv_obj,
182
+ ) -> tuple[Display, Control, ValueAlarm]:
183
+ d = pv_obj.get("display") or {}
184
+ form = d.get("form") or {}
185
+ display = Display(
186
+ limitLow = get_none_if_nan(d, "limitLow"),
187
+ limitHigh = get_none_if_nan(d, "limitHigh"),
188
+ description = d.get("description"),
189
+ units = d.get("units"),
190
+ precision = d.get("precision"),
191
+ form = form.get("index"),
192
+ choices = form.get("choices"),
193
+ )
194
+ c = pv_obj.get("control") or {}
195
+ control = Control(
196
+ limitLow = get_none_if_nan(c, "limitLow"),
197
+ limitHigh = get_none_if_nan(c, "limitHigh"),
198
+ minStep = c.get("minStep"),
199
+ )
200
+ va = pv_obj.get("valueAlarm") or {}
201
+ value_alarm = ValueAlarm(
202
+ active = va.get("active"),
203
+ lowAlarmLimit = get_none_if_nan(va, "lowAlarmLimit"),
204
+ lowWarningLimit = get_none_if_nan(va, "lowWarningLimit"),
205
+ highWarningLimit = get_none_if_nan(va, "highWarningLimit"),
206
+ highAlarmLimit = get_none_if_nan(va, "highAlarmLimit"),
207
+ lowAlarmSeverity = va.get("lowAlarmSeverity"),
208
+ lowWarningSeverity = va.get("lowWarningSeverity"),
209
+ highWarningSeverity = va.get("highWarningSeverity"),
210
+ highAlarmSeverity = va.get("highAlarmSeverity"),
211
+ hysteresis = get_none_if_nan(va, "hysteresis"),
212
+ )
213
+ return display, control, value_alarm
214
+
215
+
216
+ def pva_metadata(pv_obj) -> dict[str, Any]:
217
+ display, control, value_alarm = _pva_parse_metadata_fields(pv_obj)
218
+ return {
219
+ "display": asdict(display),
220
+ "control": asdict(control),
221
+ "valueAlarm": asdict(value_alarm),
222
+ }
223
+
224
+
225
+ def _pva_to_pvdata(
226
+ pv_obj,
227
+ pv_name: Optional[str],
228
+ *,
229
+ with_metadata: bool,
230
+ ) -> PVData:
231
+ value, enumChoices = _pva_parse_value(pv_obj)
232
+ alarm, timestamp = _pva_parse_alarm_ts(pv_obj)
233
+ display = control = value_alarm = None
234
+ if with_metadata:
235
+ display, control, value_alarm = _pva_parse_metadata_fields(pv_obj)
236
+ return PVData(
237
+ pvName = pv_name,
238
+ value = value,
239
+ enumChoices = enumChoices,
240
+ alarm = alarm,
241
+ timeStamp = timestamp,
242
+ display = display,
243
+ control = control,
244
+ valueAlarm = value_alarm,
245
+ )
246
+
247
+
248
+ def parse_pva(pv_obj, pv_name: Optional[str] = None) -> PVData:
249
+ """Full PVA snapshot: value, alarm, timeStamp, and metadata."""
250
+ return _pva_to_pvdata(pv_obj, pv_name, with_metadata=True)
251
+
252
+
253
+ def parse_pva_update(pv_obj, pv_name: Optional[str] = None) -> PVData:
254
+ """Fast PVA monitor path: value, alarm, timeStamp only (no metadata)."""
255
+ return _pva_to_pvdata(pv_obj, pv_name, with_metadata=False)
capva/tools.py ADDED
@@ -0,0 +1,71 @@
1
+ """Procedural API aligned with EPICS pvget, pvput, pvinfo, and pvmonitor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from .constants import DEFAULT_IO_TIMEOUT
8
+ from .monitor_handle import MonitorHandle
9
+ from .pv import PV
10
+ from .pv_data import PVData
11
+
12
+
13
+ class MonitorSession:
14
+ def __init__(self, pv: PV, handle: MonitorHandle) -> None:
15
+ self._pv = pv
16
+ self._handle = handle
17
+
18
+ def close(self) -> None:
19
+ pv = self._pv
20
+ if pv is None:
21
+ return
22
+ self._pv = None
23
+ pv.clear_monitor(self._handle)
24
+ pv.close()
25
+
26
+
27
+ def pvget(
28
+ pvname: str,
29
+ *,
30
+ timeout: float = DEFAULT_IO_TIMEOUT,
31
+ ) -> PVData:
32
+ pv = PV(pvname)
33
+ try:
34
+ return pv.get(timeout=timeout)
35
+ finally:
36
+ pv.close()
37
+
38
+
39
+ def pvput(
40
+ pvname: str,
41
+ value,
42
+ *,
43
+ timeout: float = DEFAULT_IO_TIMEOUT,
44
+ wait: bool = False,
45
+ ) -> None:
46
+ pv = PV(pvname)
47
+ try:
48
+ pv.put(value, timeout=timeout, wait=wait)
49
+ finally:
50
+ pv.close()
51
+
52
+
53
+ def pvinfo(
54
+ pvname: str,
55
+ *,
56
+ timeout: float = DEFAULT_IO_TIMEOUT,
57
+ ) -> dict[str, Any]:
58
+ pv = PV(pvname)
59
+ try:
60
+ return pv.info(timeout=timeout)
61
+ finally:
62
+ pv.close()
63
+
64
+
65
+ def pvmonitor(
66
+ pvname: str,
67
+ callback: Callable[[PVData], None],
68
+ ) -> MonitorSession:
69
+ pv = PV(pvname)
70
+ handle = pv.monitor(callback)
71
+ return MonitorSession(pv, handle)
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: capva
3
+ Version: 0.1.0
4
+ Summary: EPICS PV client supporting both Channel Access and PV Access
5
+ Author: Lin Wang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/wanglin86769/capva
8
+ Keywords: epics
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: pyepics>=3.5.10
22
+ Requires-Dist: p4p>=4.2.2
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # capva
28
+
29
+ Unified Python client for EPICS PVs over **Channel Access (CA)** and **PV Access (PVA)**.
30
+
31
+ capva wraps [pyepics](https://github.com/pyepics/pyepics) and [p4p](https://github.com/epics-base/p4p) behind one API. Reads and monitors return a structured **`PVData`** model; JSON/Web payloads are built with **`PVData.to_dict()`** (optional base64 array encoding).
32
+
33
+ **Developed from the [weiss](https://github.com/weiss-controls/weiss) project** — capva builds on that codebase and refactors the PV client layer into a standalone Python library with a unified CA/PVA API and structured `PVData`.
34
+
35
+ ```mermaid
36
+ flowchart TB
37
+ subgraph capva["capva — standalone PV client library"]
38
+ api["PV, PVPool, tools"]
39
+ prov["CA_PV, PVA_PV"]
40
+ data["pv_parser, PVData"]
41
+ end
42
+
43
+ subgraph drivers["protocol drivers"]
44
+ direction LR
45
+ pyepics["pyepics (CA)"]
46
+ p4p["p4p (PVA)"]
47
+ end
48
+
49
+ epics["EPICS IOC"]
50
+
51
+ prov --> pyepics
52
+ prov --> p4p
53
+ pyepics --> epics
54
+ p4p --> epics
55
+ ```
56
+
57
+ ## Features
58
+
59
+ - **Single API for CA and PVA** — One client for Channel Access and PV Access; capva picks the backend from the PV name so application code does not split into separate CA/PVA paths.
60
+ - **Unified `PVData` model** — Every read and monitor callback returns the same structured snapshot (value, alarm, timeStamp, display, control, …), regardless of protocol.
61
+ - **Public interfaces: `PV`, `PVPool`, and procedural tools** — Use the object API for multi-step work on one connection, `PVPool` when several callers share a PV, or one-shot `pvget` / `pvput` / `pvinfo` / `pvmonitor` when a script only needs a single operation.
62
+ - **Reference-counted `PVPool`** — `getPV` / `releasePV` reuse one connection per PV name; the channel closes when the last reference is released.
63
+ - **Protocol prefixes** — `ca://…` for Channel Access, `pva://…` for PV Access, or no prefix to default to CA.
64
+
65
+ ## Requirements
66
+
67
+ - Python ≥ 3.10
68
+
69
+ ## Installation
70
+
71
+ From [PyPI](https://pypi.org/project/capva/):
72
+
73
+ ```bash
74
+ pip install capva
75
+ ```
76
+
77
+ From a checkout (development):
78
+
79
+ ```bash
80
+ pip install -e .
81
+ ```
82
+
83
+ ## Quick start
84
+
85
+ Examples use `pva://calcExample`; switch to `ca://…` or a bare name (CA default) as needed.
86
+
87
+ ### Procedural API
88
+
89
+ ```python
90
+ import time
91
+
92
+ from capva import PVData, pvget, pvmonitor
93
+
94
+ PV_NAME = "pva://calcExample"
95
+
96
+ data = pvget(PV_NAME)
97
+ print(data.value)
98
+
99
+ def on_update(data: PVData) -> None:
100
+ if data.is_disconnected():
101
+ print(f"{data.pvName} disconnected")
102
+ else:
103
+ print(data.value)
104
+
105
+ session = pvmonitor(PV_NAME, on_update)
106
+ try:
107
+ time.sleep(30)
108
+ finally:
109
+ session.close()
110
+ ```
111
+
112
+ ### Object API
113
+
114
+ ```python
115
+ import time
116
+
117
+ from capva import PV, PVData
118
+
119
+ PV_NAME = "pva://calcExample"
120
+
121
+ pv = PV(PV_NAME)
122
+ handle = None
123
+ try:
124
+ data = pv.get()
125
+ print(data.value)
126
+
127
+ def on_update(data: PVData) -> None:
128
+ if data.is_disconnected():
129
+ print(f"{data.pvName} disconnected")
130
+ else:
131
+ print(data.value)
132
+
133
+ handle = pv.monitor(on_update)
134
+ time.sleep(30)
135
+ finally:
136
+ if handle is not None:
137
+ pv.clear_monitor(handle)
138
+ pv.close()
139
+ ```
140
+
141
+ ### PVPool
142
+
143
+ ```python
144
+ from capva import PVPool
145
+
146
+ PV_NAME = "pva://calcExample"
147
+
148
+ pv1 = PVPool.getPV(PV_NAME)
149
+ pv2 = PVPool.getPV(PV_NAME)
150
+ try:
151
+ print(pv1 is pv2) # same pooled instance
152
+ print(pv1.get().value)
153
+ finally:
154
+ PVPool.releasePV(pv1)
155
+ PVPool.releasePV(pv2)
156
+ ```
157
+
158
+ ### `PVData.to_dict` modes
159
+
160
+ | `mode` | Use case |
161
+ |--------|----------|
162
+ | `"full"` | Complete snapshot (value, alarm, timeStamp, display, control, …) |
163
+ | `"update"` | Monitor/Web push (no metadata fields) |
164
+ | `"metadata"` | display / control / valueAlarm only |
165
+
166
+ Set `base64_encode=True` on `"full"` or `"update"` to emit `b64arr` / `b64dtype` instead of a numeric array `value`.
167
+
168
+ ## Project layout
169
+
170
+ ```
171
+ src/capva/
172
+ pv.py, pv_data.py # Public PV + PVData model
173
+ pv_parser.py # CA/PVA → PVData
174
+ tools.py # pvget, pvput, pvinfo, pvmonitor
175
+ pool.py # PVPool
176
+ providers/ # ca_pv, pva_pv
177
+ examples/
178
+ tool_*.py # Procedural tools (one-shot)
179
+ pv_*.py # PV class API
180
+ pool_*.py # PVPool (shared connections)
181
+ tests/ # Unit tests (mocked; no IOC required)
182
+ ```
183
+
184
+ ## Examples
185
+
186
+ Edit `PV_NAME` at the top of each script, then run against a real IOC:
187
+
188
+ ```bash
189
+ # Procedural tools
190
+ python examples/tool_get.py
191
+ python examples/tool_info.py
192
+ python examples/tool_put.py
193
+ python examples/tool_monitor.py
194
+
195
+ # PV class
196
+ python examples/pv_get.py
197
+ python examples/pv_info.py
198
+ python examples/pv_put.py
199
+ python examples/pv_monitor.py
200
+
201
+ # PVPool
202
+ python examples/pool_get.py
203
+ python examples/pool_info.py
204
+ python examples/pool_put.py
205
+ python examples/pool_monitor.py
206
+
207
+ # Web JSON payload (wfExample waveform PV + Node.js)
208
+ python examples/encode_array.py
209
+ node examples/decode_array.js
210
+ ```
211
+
212
+ ## License
213
+
214
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,19 @@
1
+ capva/__init__.py,sha256=HEpccjuAfUUYqPJpOV0095gWYV6LQ8gWxjlGrhudSew,898
2
+ capva/array_b64.py,sha256=ewz68YjK0ge-cHe6lRCFRNfVoiVftvBqHu6rNjHAeGs,1218
3
+ capva/constants.py,sha256=I2k46Nww6cZYsk4kcTLF7FOF8T2WVvjOn-RmhMIhQvM,70
4
+ capva/exceptions.py,sha256=TYby8R7DeMysrIVn0FoiOF5NYF857ZifkPthJz6A0Oo,587
5
+ capva/monitor_handle.py,sha256=i-cQvel6vWyu9JjvyLsjY_-nG7lsjeeObSXje5vuKB4,406
6
+ capva/pool.py,sha256=EiyGq7oW_OEbTmvmMB9pkXEaPzDU4CarzdgzQJYN1bw,1783
7
+ capva/protocol.py,sha256=Iewl-RVUGmzZve24Vrb6UgAA5ZQ-XpAUSNUOg73oWWw,507
8
+ capva/pv.py,sha256=gGePw6pcZZOHa0-RSEx0Dxe_MJsS1PfcwynH86fy5Mk,2194
9
+ capva/pv_data.py,sha256=NAGfYcsOFRcboYTQIM-z0Ll75hx_4cvzOaSQi5h5N_A,4772
10
+ capva/pv_parser.py,sha256=6DqDQ5Ie1LU7IRyo3rkpXnLCh9hSjgs3oszBziQUeFo,8329
11
+ capva/tools.py,sha256=UtmjHxdcBxppII9BbyjD2svSCoeaoPL0rZZvaEGsQxU,1479
12
+ capva/providers/__init__.py,sha256=vkDWwiq63aNykp737HTGotp9kCP5a0ftk9fI739lf7s,137
13
+ capva/providers/ca_pv.py,sha256=_fmq3XtE0BFeT-R5Ra-MW0pd0u2KmBSpnts0yJgqwzk,5704
14
+ capva/providers/pva_pv.py,sha256=pOsgdWvk6W9Z05Tz1GzzDPRiCs9tpiEQaBAcGDU6fsk,4752
15
+ capva-0.1.0.dist-info/licenses/LICENSE,sha256=z_o60cadUCm8yPNB17ZF_Mrub-qqv4re8OyD2lkkC14,1086
16
+ capva-0.1.0.dist-info/METADATA,sha256=tNsE814Y9BqVrhHDU9I20ux9pvPcn0D53-TvwSur_MU,5999
17
+ capva-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ capva-0.1.0.dist-info/top_level.txt,sha256=dfcUKLML0iSTuvTLOYLEs6jTGbi8qdHkrW9u4FC2R1E,6
19
+ capva-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lin Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ capva