"""DSLogic ONLINE through an isolated DSView/libsigrok4DSL host.""" from array import array from dataclasses import dataclass, asdict import json import math import os from pathlib import Path import re import subprocess import struct import tempfile import time from .files import DigitalCapture, DigitalChannel, ImportCancelled from .dslogic_trigger import validate_trigger DRIVER = 'dsview' @dataclass(frozen=True) class DSLogicDevice: device_id: str name: str channels: tuple @dataclass(frozen=True) class DSLogicSettings: device_id: str executable: str = 'setgui-dslogic' channels: tuple = (0, 1) sample_rate: int = 1000000 duration: float = 1.0 threshold: float = None buffer_mb: int = 256 acquisition_mode: str = 'stream' trigger: dict = None def validate(self): if not re.fullmatch(r'dsview:\d+\.\d+', self.device_id): raise ValueError('Выберите устройство DSLogic; повторите поиск устройств.') if not self.executable.strip(): raise ValueError('Не найден модуль DSView. Пересоберите или переустановите SETGUI.') if (not self.channels or len(set(self.channels)) != len(self.channels) or any(type(c) is not int or not 0 <= c < 32 for c in self.channels)): raise ValueError('Выберите цифровые каналы без повторений.') if type(self.sample_rate) is not int or not 1 <= self.sample_rate <= 400000000: raise ValueError('Частота должна быть от 1 до 400000000 S/s.') if not math.isfinite(self.duration) or not 0 <= self.duration <= 3600: raise ValueError('Длительность должна быть от 0 до 3600 с.') if self.threshold is not None and (not math.isfinite(self.threshold) or not 0 <= self.threshold <= 5): raise ValueError('Порог должен быть от 0 до 5 V.') if not 16 <= self.buffer_mb <= 4096: raise ValueError('Лимит записи должен быть от 16 до 4096 MB.') if self.acquisition_mode not in ('buffer', 'stream', 'internal'): raise ValueError('Неизвестный режим прибора.') if self.duration == 0 and self.acquisition_mode != 'stream': raise ValueError('Режим «До остановки» доступен только для потокового захвата.') validate_trigger(self.trigger, self.channels, self.acquisition_mode) def _popen(arguments, **kwargs): try: # A frozen GUI must not pass its extraction directory to the separate # PyInstaller host (especially PyInstaller 5's _MEIPASS2 protocol). environment = {k: v for k, v in os.environ.items() if k != '_MEIPASS2' and not k.startswith('_PYI_')} kwargs.setdefault('env', environment) return subprocess.Popen(arguments, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), **kwargs) except OSError as exc: raise RuntimeError('Не удалось запустить модуль DSView. Нужен setgui-dslogic.exe из сборки SETGUI. %s' % exc) from exc def _terminate(process): if process.poll() is None: process.terminate() try: process.wait(timeout=3) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=3) def _request(executable, request, directory, stop, cancel, status): directory = Path(directory) (directory / 'request.json').write_text(json.dumps(request), encoding='utf-8') with tempfile.TemporaryFile() as log: process = _popen([executable, str(directory)], stdin=subprocess.DEVNULL, stdout=log, stderr=log) try: startup_deadline = time.monotonic() + 30 capture_deadline = None stop_deadline = None ready = False waiting_trigger = False while process.poll() is None: now = time.monotonic() if cancel(): raise ImportCancelled() if request['action'] == 'capture' and not ready and (directory / 'ready').exists(): ready = True duration = request['settings']['duration'] trigger = request['settings'].get('trigger') or {'kind': 'none'} triggered = trigger.get('kind', 'none') != 'none' waiting_trigger = triggered timeout = trigger.get('timeout', 30) capture_deadline = (now + duration + timeout + 35 if timeout else None) if triggered else (now + duration + 35 if duration else None) status('Ожидание триггера DSLogic…' if triggered else 'Захват DSLogic…') if waiting_trigger and (directory / 'triggered').exists(): waiting_trigger = False capture_deadline = now + duration + 35 if duration else None if stop_deadline is None: status('Триггер сработал. Получение записи DSLogic…') if stop() and stop_deadline is None: (directory / 'stop').touch() stop_deadline = now + 10 status('Остановка DSView и получение записи…') if ((not ready and now > startup_deadline) or (capture_deadline is not None and now > capture_deadline) or (stop_deadline is not None and now > stop_deadline)): raise RuntimeError('Модуль DSView не отвечает. Проверьте USB и закройте DSView.') time.sleep(.02) if cancel(): raise ImportCancelled() result_file = directory / 'result.json' if not result_file.exists(): log.seek(0) detail = log.read(4096).decode('utf-8', 'replace') raise RuntimeError('Модуль DSView завершился с кодом %s. %s' % (process.returncode, detail)) result = json.loads(result_file.read_text(encoding='utf-8')) if result.get('error'): raise RuntimeError(result['error']) if process.returncode: raise RuntimeError('Модуль DSView завершился с кодом %s.' % process.returncode) return result finally: _terminate(process) def list_devices(executable='setgui-dslogic', cancel=lambda: False): with tempfile.TemporaryDirectory(prefix='setgui-dsview-') as directory: result = _request(executable, {'action': 'scan'}, directory, lambda: False, cancel, lambda _: None) return [DSLogicDevice(d['device_id'], d['name'], tuple(d['channels'])) for d in result['devices']] def read_cross(stream, metadata, settings, cancel=lambda: False): """Convert channel-interleaved 64-bit words to edges without expanding samples.""" bits = metadata['channels'] samples, rate = metadata['samples'], metadata['sample_rate'] if (metadata.get('format') != 'cross64-le' or bits != sorted(settings.channels) or type(samples) is not int or samples <= 0 or not math.isfinite(rate) or rate <= 0 or (settings.acquisition_mode != 'internal' and rate != settings.sample_rate)): raise ValueError('Некорректные параметры записи DSView.') channels = [DigitalChannel('D%d' % bit, 0, array('d')) for bit in bits] group = struct.Struct('<' + 'Q' * len(bits)) offset = edges = 0 previous = [0] * len(bits) while offset < samples: if cancel(): raise ImportCancelled() groups = min(4096, (samples - offset + 63) // 64) raw = stream.read(groups * group.size) if len(raw) != groups * group.size: raise ValueError('Обрезанная запись DSView.') for words in group.iter_unpack(raw): length = min(64, samples - offset) mask = (1 << length) - 1 for index, word in enumerate(words): channel = channels[index] if offset == 0: channel.initial = word & 1 previous[index] = channel.initial changed = (word ^ ((word << 1) | previous[index])) & mask while changed: low_bit = changed & -changed channel.edges.append((offset + low_bit.bit_length() - 1) / rate) changed ^= low_bit edges += 1 if edges * 8 > settings.buffer_mb * 1024 * 1024: raise ValueError('Превышен лимит памяти фронтов. Уменьшите длительность или число каналов.') previous[index] = (word >> (length - 1)) & 1 offset += length source = metadata.get('name', 'DSLogic') + ' · %g MS/s' % (rate / 1e6) if metadata.get('limited'): source += ' · достигнут лимит записи' capture = DigitalCapture(source, 'DSLogic ONLINE · DSView', channels, 0, samples / rate, rate) trigger_sample = metadata.get('trigger_sample') if trigger_sample is not None and 0 <= trigger_sample < samples: capture.trigger_time = trigger_sample / rate return capture def acquire(settings, stop, cancel, status=lambda text: None): settings.validate() if cancel(): raise ImportCancelled() status('Подключение к DSLogic через DSView…') with tempfile.TemporaryDirectory(prefix='setgui-dsview-') as directory: result = _request(settings.executable, {'action': 'capture', 'settings': asdict(settings)}, directory, stop, cancel, status) status('Построение цифровых каналов…') with (Path(directory) / 'capture.bin').open('rb') as stream: capture = read_cross(stream, result, settings, cancel) if cancel(): raise ImportCancelled() return capture