Расширить общие API графиков и GAS обмена

This commit is contained in:
2026-09-05 02:37:11 +03:00
parent 78d3f6690b
commit b1f7b965f4
22 changed files with 294 additions and 39 deletions

View File

@@ -37,7 +37,7 @@ class PlotMath:
self.library = library
library.set_plot_abi_version.restype = ctypes.c_uint32
library.set_plot_abi_version.argtypes = []
if library.set_plot_abi_version() != 1:
if library.set_plot_abi_version() != 2:
raise RuntimeError("Unsupported plot ABI")
library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double),
ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t]
@@ -67,6 +67,10 @@ class PlotMath:
except ValueError:
return None
def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
"""Validate absolute axis limits in the shared core."""
return Bounds(*self.call(8, left, right, bottom, top))
@dataclass(frozen=True)
class Viewport:
@@ -92,6 +96,9 @@ class Bounds:
bottom: float
top: float
def validated(self, core: PlotMath) -> "Bounds":
return core.limits(self.left, self.right, self.bottom, self.top)
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
return core.call(2, value, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0]

View File

@@ -37,6 +37,12 @@ class Spectrum:
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
@dataclass(frozen=True)
class SpectrumPeak:
frequency_hz: float
amplitude: float
class NativeSpectrum:
def __init__(self, library: ctypes.CDLL):
self.lib = library
@@ -46,6 +52,11 @@ class NativeSpectrum:
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
pointer, ctypes.c_size_t, pointer]
self._analyze.restype = ctypes.c_int
self._peak = library.set_spectrum_dominant_peak
self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
pointer, ctypes.c_size_t]
self._peak.restype = ctypes.c_int
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
@@ -69,3 +80,15 @@ class NativeSpectrum:
raise ValueError(message.get(status, "FFT failed"))
n = int(meta[0])
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
absolute_floor: float = 1e-6) -> SpectrumPeak | None:
amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
output = (ctypes.c_double * 2)()
status = self._peak(amplitudes, len(spectrum.amplitudes),
spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
scratch, len(scratch), output, 2)
if status < 0:
raise ValueError("Invalid spectrum peak input")
return SpectrumPeak(output[0], output[1]) if status else None

View File

@@ -99,12 +99,6 @@ class TrendSignal:
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
raise ValueError("Word offset must be 0..6")
def word_value(self, word: int) -> float:
if not 0 <= word <= 65535:
raise ValueError("Not a 16-bit word")
return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word)
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
ids = set()
for profile, signals in settings.items():
@@ -178,6 +172,20 @@ class NativeTrends:
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8,
ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
self.decode.restype = ctypes.c_int32
self._word = library.set_trend_word_value
self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
self._word.restype = ctypes.c_int32
self._watch_request = library.set_trend_watch_request
self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
self._watch_request.restype = ctypes.c_size_t
self._watch_ack = library.set_trend_watch_ack
self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
self._watch_ack.restype = ctypes.c_int
self._watch_decode = library.set_trend_watch_decode
self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
self._watch_decode.restype = ctypes.c_int
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
if signal.source not in ("CAN_GAS", "CAN_RAW"):
@@ -191,3 +199,33 @@ class NativeTrends:
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
payload, len(data))
return None if value == -2147483648 else float(value)
def word_value(self, signal: TrendSignal, word: int) -> float:
if not 0 <= word <= 65535:
raise ValueError("Not a 16-bit word")
return float(self._word(word, signal.valueType == "INT16"))
def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
type(address) is not int or not 0 <= address <= 65535 for address in addresses):
raise ValueError("Invalid GAS watch request")
source = (ctypes.c_uint16 * len(addresses))(*addresses)
output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
size = self._watch_request(period_ms, source, len(addresses), output, len(output))
if not size:
raise ValueError("Invalid GAS watch request")
return bytes(output[:size])
def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
if not self._watch_ack(data, len(payload), period_ms, count):
raise ValueError("Device did not accept the complete GAS subscription")
def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
timestamp = ctypes.c_uint32()
words = (ctypes.c_uint16 * MAX_SIGNALS)()
count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
if count < 0 or expected_count is not None and count != expected_count:
raise ValueError("Invalid GAS watch data")
return timestamp.value, list(words[:count])