206 lines
10 KiB
Python
206 lines
10 KiB
Python
"""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
|