Add DSLogic capture, signal conversion and DSView decoders

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent cc22c803d1
commit 513e79b127
44 changed files with 5982 additions and 36 deletions

View File

@@ -4,10 +4,12 @@ from __future__ import annotations
from bisect import bisect_left, bisect_right
from dataclasses import dataclass
import heapq
from itertools import groupby
import math
from .files import ImportCancelled
from .decoders.gate_timing import TimingChecker
from .decoders.transistor_pair import PairTimingChecker
from .decoders.set_uart import StreamParser
from .decoders.pm35_uart import PM35Parser
from .decoders.set_can import legacy, Reassembler
@@ -84,7 +86,7 @@ def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=Fals
continue
finish = time + bits * bit
if finish > end + bit * 1e-6:
yield time, end, None, 'Неполный UART-байт в конце диапазона'
yield time, end, None, 'Неполный UART-байт в конце диапазона'
break
read = lambda pos: channel.level_at(time + pos * bit) ^ inverted
if read(0.5):
@@ -94,10 +96,10 @@ def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=Fals
value = sum(v << i for i, v in enumerate(values))
error = None
if parity != 'none' and read(9.5) != ((sum(values) + (parity == 'odd')) & 1):
error = 'Ошибка чётности UART'
error = 'Ошибка чётности UART'
stop_start = 9 + (parity != 'none')
if any(read(stop_start + i + 0.5) != 1 for i in range(stops)):
error = 'Ошибка стопового бита UART / BREAK'
error = 'Ошибка стопового бита UART / BREAK'
yield time, finish, value, error
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
@@ -142,7 +144,7 @@ class _CanBits:
break
sample = boundary + self.sample_point * self.bit
if sample >= self.end:
raise EOFError('Неполный CAN-кадр в конце диапазона')
raise EOFError('Неполный CAN-кадр в конце диапазона')
self.last_sample = sample
self.position += 1
return self.channel.level_at(sample) ^ self.inverted
@@ -151,7 +153,7 @@ class _CanBits:
if self.run == 5:
value = self.raw()
if value == self.previous:
raise ValueError('CAN: ошибка bit stuffing / error frame')
raise ValueError('CAN: ошибка bit stuffing / error frame')
self.previous, self.run = value, 1
value = self.raw()
self.run = self.run + 1 if value == self.previous else 1
@@ -181,34 +183,34 @@ def can_frames(channel, start, end, bitrate, inverted=False, sample_point=.7, ca
frame, error = None, None
try:
if reader.take(1):
raise ValueError('CAN: неверный SOF')
raise ValueError('CAN: неверный SOF')
ident = reader.take(11)
rtr, extended = reader.take(1), reader.take(1)
if extended:
if rtr != 1:
raise ValueError('CAN: неверный SRR')
raise ValueError('CAN: неверный SRR')
ident = (ident << 18) | reader.take(18)
rtr = reader.take(1)
if reader.take(2):
raise ValueError('CAN FD / reserved bits не поддерживаются')
raise ValueError('CAN FD / reserved bits не поддерживаются')
elif reader.take(1):
raise ValueError('CAN FD / reserved bit не поддерживается')
raise ValueError('CAN FD / reserved bit не поддерживается')
dlc = reader.take(4)
if dlc > 8:
raise ValueError('CAN: поддерживается classic DLC 0…8')
raise ValueError('CAN: поддерживается classic DLC 0…8')
data = bytes(reader.take(8) for _ in range(0 if rtr else dlc))
expected = can_crc(reader.bits)
received = reader.take(15)
if reader.run == 5:
if reader.raw() == reader.previous:
raise ValueError('CAN: неверный последний stuff bit')
raise ValueError('CAN: неверный последний stuff bit')
if reader.raw() != 1:
raise ValueError('CAN: неверный CRC delimiter')
raise ValueError('CAN: неверный CRC delimiter')
ack = reader.raw() == 0
if reader.raw() != 1 or any(reader.raw() != 1 for _ in range(7)):
raise ValueError('CAN: неверный ACK delimiter / EOF')
raise ValueError('CAN: неверный ACK delimiter / EOF')
if received != expected:
raise ValueError('CAN CRC15: получено %04X, ожидается %04X' % (received, expected))
raise ValueError('CAN CRC15: получено %04X, ожидается %04X' % (received, expected))
frame = dict(ident=ident, extended=bool(extended), remote=bool(rtr),
data=data, dlc=dlc, ack=ack)
except (ValueError, EOFError) as exc:
@@ -222,14 +224,14 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
start = capture.start if start is None else max(capture.start, start)
end = capture.end if end is None else min(capture.end, end)
if end <= start:
raise ValueError('Выберите непустой интервал анализа.')
raise ValueError('Выберите непустой интервал анализа.')
mode = options['mode']
channel = capture.channels[options.get('channel', 0)]
events = []
last_progress = -1
limit = options.get('max_events', 50000)
if limit <= 0:
raise ValueError('Лимит результатов должен быть положительным.')
raise ValueError('Лимит результатов должен быть положительным.')
def emit(a, b, kind, text, details=''):
if len(events) >= limit:
@@ -260,8 +262,10 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
if mode in ('1SP0635', '1SD536F2'):
other = options.get('status_channel', 1)
if other == options.get('channel', 0):
raise ValueError('Vin и Vstat должны быть разными каналами.')
raise ValueError('Vin1 и Vstat должны быть разными каналами.')
status = capture.channels[other]
if options.get('pair_analysis'):
raise ValueError('Use the Transistor pair analyzer for Vin1/Vin2 timing.')
checker = TimingChecker(1e9, mode, options.get('tolerance_ns', 100),
not options.get('vin_low', False), not options.get('status_low', False))
# The pure checker works in integer ticks. Offset before rounding
@@ -272,7 +276,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
for item in items:
text = item['text']
if item['kind'] == 'fault':
text += ' · аварийная обратная связь; превышение тока не подтверждено'
text += ' · аварийная обратная связь; превышение тока не подтверждено'
emit(start + item['start'] / 1e9, start + item['end'] / 1e9, item['kind'], text)
def transitions(ch, which):
@@ -281,21 +285,48 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
yield ch.edges[i], which, ch.initial ^ ((i + 1) & 1)
if bool(status.level_at(start)) == checker.vstat_active_high:
emit(start, start, 'orphan', 'Vstat активен в начале диапазона: начало импульса не записано')
for time, which, level in heapq.merge(transitions(channel, 0), transitions(status, 1)):
emit(start, start, 'orphan', 'Vstat активен в начале диапазона: начало импульса не записано')
streams = [transitions(channel, 0), transitions(status, 1)]
for time, which, level in heapq.merge(*streams):
report(time)
timing(checker.expire(tick(time)))
timing(checker.on_control_edge(tick(time), level) if which == 0 else checker.on_status_edge(tick(time), level))
timing(checker.on_control_edge(tick(time), level) if which == 0
else checker.on_status_edge(tick(time), level))
timing(checker.expire(tick(end)))
if checker.status_start is not None:
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
for pending in checker.pending:
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin: диапазон закончился до тайм-аута ACK')
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin1: диапазон закончился до тайм-аута ACK')
elif mode == 'Transistor pair':
second = options.get('vin2_channel', 1)
if second == options.get('channel', 0):
raise ValueError('Vin1 and Vin2 must be different channels.')
vin2 = capture.channels[second]
numeric = {name: options.get(name, 0) for name in (
'vin1_mintime_ns', 'vin2_mintime_ns', 'vin1_minoff_ns', 'vin2_minoff_ns',
'deadtime_12_ns', 'deadtime_21_ns')}
checker = PairTimingChecker(1e9, not options.get('vin_low', False),
not options.get('vin2_low', False), **numeric)
levels = [channel.level_at(start), vin2.level_at(start)]
checker.update(0, *levels)
def edges(ch, which):
for index in range(bisect_right(ch.edges, start), bisect_right(ch.edges, end)):
yield ch.edges[index], which, ch.initial ^ ((index + 1) & 1)
def emit_pair(items):
for item in items:
emit(start + item['start'] / 1e9, start + item['end'] / 1e9,
item['kind'], item['text'])
for time, group in groupby(heapq.merge(edges(channel, 0), edges(vin2, 1)), key=lambda e: e[0]):
report(time)
for _, which, level in group:
levels[which] = level
emit_pair(checker.update(round((time - start) * 1e9), *levels))
emit_pair(checker.finish(round((end - start) * 1e9)))
elif mode in ('UART', 'SET UART', 'PM35 UART'):
baud = options.get('baudrate', 115200)
parity, stops = options.get('parity', 'none'), options.get('stops', 1)
if not math.isfinite(baud) or baud <= 0 or parity not in ('none', 'even', 'odd') or stops not in (1, 2):
raise ValueError('Некорректные настройки UART.')
raise ValueError('Некорректные настройки UART.')
parser = (StreamParser(options.get('protocol', 'auto')) if mode == 'SET UART' else
PM35Parser(options.get('role', 'response')) if mode == 'PM35 UART' else None)
gap = (3.5 * (9 + (parity != 'none') + stops) / baud if mode == 'PM35 UART'
@@ -320,7 +351,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
bitrate = options.get('baudrate', 1000000)
sample_point = options.get('sample_point', 70) / 100
if not math.isfinite(bitrate) or bitrate <= 0 or not .1 <= sample_point <= .95:
raise ValueError('Некорректный битрейт / точка выборки CAN.')
raise ValueError('Некорректный битрейт / точка выборки CAN.')
reassembler = Reassembler()
for a, b, frame, error in can_frames(channel, start, end, bitrate, options.get('inverted', False), sample_point, cancel):
report(a)
@@ -328,7 +359,7 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
emit(a, b, 'error', error)
# Never bridge an invalid/missing physical frame.
for pending in reassembler.pending.values():
emit(pending['start'], b, 'incomplete', 'SET CAN: сборка прервана ошибкой шины')
emit(pending['start'], b, 'incomplete', 'SET CAN: сборка прервана ошибкой шины')
reassembler.pending.clear()
continue
ident, data = frame['ident'], frame['data']
@@ -347,9 +378,9 @@ def analyze_capture(capture, options, start=None, end=None, progress=lambda n: N
except ValueError as exc:
emit(a, b, 'error', str(exc))
for pending in reassembler.pending.values():
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
else:
raise ValueError('Неизвестный анализатор: ' + mode)
raise ValueError('Неизвестный анализатор: ' + mode)
except OverflowError:
truncated = True
_cancel(cancel)

View File

@@ -0,0 +1,255 @@
"""Digital CSV/JSON/SAL/DSL conversion using the shared capture readers.
SAL v3 encoding follows DSLogic_Logic_2/dslogic_script/tests/generate_test_sal.py.
Only digital signals are exported; analyzer settings and analog data are not copied.
"""
import csv
import heapq
import itertools
import json
import math
from pathlib import Path
import struct
import tempfile
import uuid
import zipfile
from .files import read_capture, ImportCancelled
from .csv_import import convert_csv, _ones, BLOCK_SAMPLES, MAX_PACKED_BYTES
from .sal_metadata import metadata
FORMATS = {'csv': 'CSV — цифровые переходы', 'json': 'JSON — цифровые переходы', 'dsl': 'DSL — DSView',
'sal': 'SAL — Logic 2 (v3)'}
def _check(cancel):
if cancel():
raise ImportCancelled()
def _csv(capture, path, progress, cancel):
with path.open('w', encoding='utf-8', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(['Time [s]', *[c.name for c in capture.channels]])
events = heapq.merge([capture.start, capture.end],
*(c.edges for c in capture.channels))
for index, (time, _) in enumerate(itertools.groupby(events)):
if index % 4096 == 0:
_check(cancel)
progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30)))
writer.writerow([repr(time), *[c.level_at(time) for c in capture.channels]])
def _json(capture, path, progress, cancel):
names = ['Time [s]', *[c.name for c in capture.channels]]
if len(set(names)) != len(names):
raise ValueError('Для JSON имена каналов должны быть уникальны и отличаться от Time [s].')
with path.open('w', encoding='utf-8', newline='') as stream:
stream.write('[\n')
events = heapq.merge([capture.start, capture.end], *(c.edges for c in capture.channels))
for index, (time, _) in enumerate(itertools.groupby(events)):
if index % 4096 == 0:
_check(cancel)
progress(int(99 * (time-capture.start) / max(capture.end-capture.start, 1e-30)))
if index:
stream.write(',\n')
row = dict(zip(names, [time, *[c.level_at(time) for c in capture.channels]]))
stream.write(' ' + json.dumps(row, ensure_ascii=False, allow_nan=False))
stream.write('\n]\n')
# Времена переводятся в целые отсчёты одинаково для SAL и DSL.
# Округление допускается лишь в пределах погрешности, без ресемплинга.
def _grid(capture, rate, cancel):
rate = rate or capture.sample_rate
if not math.isfinite(rate) or rate <= 0:
raise ValueError('Для экспорта в SAL/DSL укажите частоту дискретизации в Гц.')
def sample(time):
value = (time - capture.start) * rate
rounded = round(value)
if not math.isclose(value, rounded, rel_tol=0, abs_tol=1e-5):
raise ValueError('Временные метки не попадают на сетку выбранной частоты. Увеличьте частоту.')
return rounded
count = sample(capture.end)
# CSV final row is a sample, binary capture end is exclusive.
if capture.format.endswith('CSV') or capture.format == 'JSON':
count += 1
if not 0 < count < 2**53:
raise ValueError('Недопустимая длительность записи.')
edges = []
for channel in capture.channels:
result = []
previous = 0
for i, time in enumerate(channel.edges):
if i % 8192 == 0:
_check(cancel)
value = sample(time)
if not previous < value < count:
raise ValueError('Частота не позволяет сохранить все фронты.')
result.append(value)
previous = value
edges.append(result)
return rate, count, edges
def _run(length):
value = length - 1
if value < 64:
return bytes([value])
shift = 7
while value >> shift >= 64:
shift += 7
out = bytearray([64 | (value >> shift)])
for bit in range(shift - 7, -1, -7):
out.append(((value >> bit) & 127) | (128 if bit else 0))
return out
# SAL хранит длины серий уровней. Метаданные описывают цифровые каналы;
# настройки анализаторов исходной записи сюда не переносятся.
def _sal(capture, path, rate, count, edges, progress, cancel):
if len(capture.channels) > 16:
raise ValueError('Экспорт SAL поддерживает до 16 цифровых каналов.')
meta = metadata()
data = meta['data']
data['name'] = path.stem
data['captureStartTime'] = dict(unixTimeMilliseconds=0, fractionalMilliseconds=0)
data['captureNotes'] = 'Converted digital signals; source time origin: %s s' % capture.start
data['renderViewState'] = dict(type='PanAndZoom', leftEdgeTimeSec=0,
timeScaleSeconds=count/rate/10)
data['captureSettings']['timerModeSettings']['stopAfterSeconds'] = count/rate
data['legacySettings']['sampleRate'] = {'digital': rate}
data['legacySettings']['enabledChannels'] = [dict(type='Digital', index=i)
for i in range(len(edges))]
data['rowsSettings'] = [dict(id=str(uuid.uuid4()), height=100, isMarkedHidden=False,
type='channel', name=c.name,
channel=dict(category='legacy', type='Digital', deviceChannel=i))
for i, c in enumerate(capture.channels)]
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('meta.json', json.dumps(meta, ensure_ascii=False))
for i, (channel, transitions) in enumerate(zip(capture.channels, edges)):
encoded = bytearray()
previous = 0
for j, boundary in enumerate(itertools.chain(transitions, [count])):
if j % 8192 == 0:
_check(cancel)
encoded.extend(_run(boundary - previous))
previous = boundary
header = b'<SALEAE>' + struct.pack('<II', 3, 100)
header += struct.pack('<BdQdBBQ', 1, rate, 0, 0, 0, 0, 1)
header += struct.pack('<QQBBQ', 0, count, channel.initial, 0, len(encoded))
archive.writestr('digital-%d.bin' % i, header + encoded)
progress((i+1)*99//len(edges))
archive.writestr('trigger-store.bin', b'<SALEAE>' + struct.pack('<IIIIQ', 3, 103, 1, 0, 0))
# DSL упаковывает каждый канал по блокам. Дополнение до 64 отсчётов
# удерживает последний уровень, не создавая ложный фронт.
def _dsl(capture, path, rate, count, edges, progress, cancel):
names = [c.name for c in capture.channels]
if any(any(ch in name for ch in '\r\n=[]') for name in names):
raise ValueError('Для DSL имена каналов не должны содержать =, [, ] и переносы строк.')
padded = (count+63)//64*64
if padded//8*len(names) > MAX_PACKED_BYTES:
raise ValueError('Запись DSL превышает ограничение 2 ГиБ распакованных данных.')
blocks = (padded+BLOCK_SAMPLES-1)//BLOCK_SAMPLES
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
'device mode = 0\ncapturefile = data\n'
f'total samples = {padded}\ntotal probes = {len(names)}\ntotal blocks = {blocks}\n'
f'samplerate = {rate:.12f} Hz\ntrigger time = 0\ntrigger pos = 0\n'
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
**{'Sample count': str(padded), 'Sample rate': str(rate), 'Max Height': '1X'},
decoder=[], channel=[dict(colour='default', enabled=True, index=i, name=name,
strigger=0, type=10000, view_index=i)
for i, name in enumerate(names)])
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive:
archive.writestr('header', header)
archive.writestr('session', json.dumps(session, ensure_ascii=False))
archive.writestr('decoders', '[]')
for i, (channel, transitions) in enumerate(zip(capture.channels, edges)):
boundaries = iter(itertools.chain(transitions, [padded]))
boundary = next(boundaries)
level = channel.initial
for block in range(blocks):
_check(cancel)
start = block*BLOCK_SAMPLES
end = min(padded, start+BLOCK_SAMPLES)
buffer = bytearray((end-start)//8)
position = start
while position < end:
stop = min(end, boundary)
if level:
_ones(buffer, position-start, stop-start)
position = stop
if position == boundary:
level ^= 1
boundary = next(boundaries, padded)
_check(cancel)
archive.writestr(f'L-{i}/{block}', buffer)
progress((i*blocks+block+1)*99//(len(names)*blocks))
return padded-count
def convert_capture(source, destination, progress=lambda p: None, cancel=lambda: False,
*, sample_rate=0, csv_options=None):
"""Export to a new file; cancel/failure never leaves a partial destination."""
source, destination = Path(source), Path(destination)
suffix = destination.suffix.lower().lstrip('.')
if suffix not in FORMATS or source.suffix.lower().lstrip('.') not in FORMATS:
raise ValueError('Поддерживаются цифровые CSV, JSON, SAL и DSL.')
if source.resolve() == destination.resolve() or destination.exists():
raise ValueError('Выберите новый выходной файл: существующие файлы не перезаписываются.')
_check(cancel)
# Нормализация расширенного входа получает первые 25% прогресса.
# В рекурсивный вызов csv_options не передаётся: повторного прохода нет.
if source.suffix.lower() == '.csv' and csv_options is not None:
from .csv_options import normalize_csv
with tempfile.TemporaryDirectory(prefix='.csv-options-', dir=destination.parent) as directory:
normalized = Path(directory)/'normalized.csv'
normalize_csv(source, normalized, csv_options, sample_rate,
lambda p: progress(p*25//100), cancel)
return convert_capture(normalized, destination, lambda p: progress(25+p*75//100),
cancel, sample_rate=sample_rate)
# Автоматическая сетка использует точный потоковый CSV-импортёр.
# Явно заданную частоту проверяет _grid, её нельзя молча заменить.
if source.suffix.lower() == '.csv' and suffix == 'dsl' and not sample_rate:
result = convert_csv(source, destination, progress, cancel)
return 'Готово: %s\nDSL: %s Гц; дополнение %s отсчётов; начало %s с.' % (
destination, result['samplerate'], result['padding'], result['origin_seconds'])
capture = read_capture(source, lambda p: progress(p*35//100), cancel)
padding = 0
# Сначала собираем временный архив рядом с назначением. Его владелец —
# этот контекст: ошибка и отмена тоже удаляют промежуточный результат.
with tempfile.TemporaryDirectory(prefix='.convert-', dir=destination.parent) as directory:
temporary = Path(directory)/destination.name
report = lambda p: progress(35+p*60//100)
if suffix == 'csv':
_csv(capture, temporary, report, cancel)
elif suffix == 'json':
_json(capture, temporary, report, cancel)
else:
rate, count, edges = _grid(capture, sample_rate, cancel)
if suffix == 'sal':
_sal(capture, temporary, rate, count, edges, report, cancel)
else:
padding = _dsl(capture, temporary, rate, count, edges, report, cancel)
_check(cancel)
# Режим xb защищает от гонки с другим процессом после проверки пути.
# При ошибке удаляем только файл, который успели создать сами.
created = False
try:
with destination.open('xb') as output:
created = True
with temporary.open('rb') as stream:
while chunk := stream.read(1024*1024):
_check(cancel)
output.write(chunk)
_check(cancel)
except BaseException:
if created:
destination.unlink(missing_ok=True)
raise
progress(100)
return 'Готово: %s\nКаналов: %d. Дополнение DSL: %d отсчётов.' % (
destination, len(capture.channels), padding)

View File

@@ -0,0 +1,211 @@
"""Stream digital CSV into DSView v3, preserving intervals between transitions."""
from contextlib import contextmanager
import csv
from decimal import Decimal, InvalidOperation
import json
from math import gcd
import os
from pathlib import Path
import re
import tempfile
import zipfile
from .files import ImportCancelled
TICKS = 10**12 # exact decimal picoseconds, independent of float rounding
BLOCK_SAMPLES = 8 * 1024 * 1024
MAX_PACKED_BYTES = 2 * 1024**3
TIME_HEADER = re.compile(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', re.I)
def _check(cancel):
if cancel():
raise ImportCancelled()
@contextmanager
def _reader(path, progress, cancel):
size = max(1, path.stat().st_size)
with path.open(encoding='utf-8-sig', newline='') as stream:
consumed = 0
def lines():
nonlocal consumed
for index, line in enumerate(stream):
consumed += len(line)
if index % 4096 == 0:
_check(cancel)
progress(min(99, consumed * 100 // size))
if line.strip() and not line.lstrip().startswith(('#', ';')):
yield line
source = lines()
header = next(source, '')
if not header:
raise ValueError('CSV не содержит данных.')
try:
delimiter = csv.Sniffer().sniff(header, delimiters=',;\t').delimiter
except csv.Error as error:
raise ValueError('Не удалось определить разделитель CSV: используйте запятую, ; или табуляцию.') from error
names = [value.strip() for value in next(csv.reader([header], delimiter=delimiter))]
match = TIME_HEADER.fullmatch(names[0])
if not match or not 2 <= len(names) <= 65:
raise ValueError('Ожидается CSV: Time [s] (или ms/us/ns), затем цифровые каналы 0/1.')
channels = names[1:]
if len(set(names)) != len(names) or any(not n or any(c in n for c in '\r\n=[]') for n in channels):
raise ValueError('Имена каналов должны быть уникальными и не содержать =, [, ] или переносы строк.')
unit = (match[1] or 's').lower()
scale = {'s': TICKS, 'ms': 10**9, 'us': 10**6, 'µs': 10**6, 'ns': 1000}[unit]
def rows():
previous = None
for index, row in enumerate(csv.reader(source, delimiter=delimiter), 2):
try:
value = Decimal(row[0].strip().replace(',', '.')) * scale
if not value.is_finite() or value != value.to_integral_value():
raise ValueError('время должно быть конечным, с точностью не выше 1 пс')
timestamp = int(value)
if previous is not None and timestamp <= previous:
raise ValueError('время должно строго возрастать')
levels = tuple(item.strip() for item in row[1:])
if len(levels) != len(channels) or any(item not in ('0', '1') for item in levels):
raise ValueError('ожидаются цифровые уровни 0 или 1 для каждого канала')
previous = timestamp
yield timestamp, levels
except (ValueError, InvalidOperation, IndexError) as error:
raise ValueError('CSV, строка данных %d: %s' % (index, error)) from error
yield channels, rows()
def _ones(block, start, end):
"""Set a half-open run without iterating over every sample."""
if end <= start:
return
first, bit = divmod(start, 8)
last, stop = divmod(end, 8)
if first == last:
block[first] |= ((1 << (end - start)) - 1) << bit
return
if bit:
block[first] |= (255 << bit) & 255
first += 1
block[first:last] = b'\xff' * (last - first)
if stop:
block[last] |= (1 << stop) - 1
def convert_csv(source, destination, progress=lambda value: None, cancel=lambda: False,
*, max_packed_bytes=MAX_PACKED_BYTES, block_samples=BLOCK_SAMPLES):
"""Two bounded-memory passes; input is untouched, output created exclusively.
The first row establishes initial levels, not an edge. Its timestamp may
precede the clock grid by a fraction of one period. All subsequent times
and transition intervals are represented exactly; the origin rounds down
at most one period. The final level is held to the 64-sample DSL boundary.
"""
source, destination = Path(source), Path(destination)
if source.resolve() == destination.resolve() or destination.exists():
raise ValueError('Для импорта нужен новый выходной файл.')
if block_samples < 64 or block_samples % 64:
raise ValueError('Размер блока должен быть кратен 64 отсчётам.')
stamp = (source.stat().st_size, source.stat().st_mtime_ns)
first = previous = anchor = None
step = count = 0
with _reader(source, lambda p: progress(p * 40 // 100), cancel) as (channels, rows):
for time, _ in rows:
if count == 0:
first = time
elif count == 1:
anchor = time
else:
step = gcd(step, time - previous)
previous = time
count += 1
if count < 2:
raise ValueError('В CSV нужны минимум две строки с разным временем.')
if count == 2:
step = anchor - first
period = gcd(step, TICKS)
rate = TICKS // period
origin = anchor - ((anchor - first + period - 1) // period) * period
samples = (previous - origin) // period + 1
padded = (samples + 63) // 64 * 64
if padded // 8 * len(channels) > max_packed_bytes:
raise ValueError('Запись слишком велика для DSView при сохранении точности времени. Выберите меньший интервал CSV.')
blocks_count = (padded + block_samples - 1) // block_samples
result = dict(channels=channels, samplerate=rate, samples=samples, padding=padded - samples,
origin_seconds=str(Decimal(origin) / TICKS), source=str(source))
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
'device mode = 0\ncapturefile = data\n'
f'total samples = {padded}\ntotal probes = {len(channels)}\ntotal blocks = {blocks_count}\n'
f'samplerate = {rate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(channels)))
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
**{'Sample count': str(padded), 'Sample rate': str(rate), 'Max Height': '1X'},
decoder=[], channel=[dict(colour='default', enabled=True, index=i, name=name,
strigger=0, type=10000, view_index=i)
for i, name in enumerate(channels)])
fd, temporary = tempfile.mkstemp(prefix='.csv-', suffix='.dsl', dir=destination.parent)
os.close(fd)
try:
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED, compresslevel=1) as archive:
archive.writestr('header', header)
archive.writestr('session', json.dumps(session, ensure_ascii=False))
archive.writestr('decoders', '[]')
archive.writestr('csv_import.json', json.dumps(result, ensure_ascii=False))
if stamp != (source.stat().st_size, source.stat().st_mtime_ns):
raise ValueError('CSV изменился во время чтения.')
with _reader(source, lambda p: None, cancel) as (names, rows):
if names != channels:
raise ValueError('CSV изменился во время чтения.')
current = next(rows)
if current[0] != first:
raise ValueError('CSV изменился во время чтения.')
following = next(rows, None)
block_start = 0
row_count = 1
for block in range(blocks_count):
_check(cancel)
block_end = min(padded, block_start + block_samples)
buffers = [bytearray((block_end - block_start) // 8) for _ in channels]
position = block_start
while position < block_end:
boundary = (following[0] - origin) // period if following else padded
if boundary < position or (following and (following[0] - anchor) % period):
raise ValueError('CSV изменился во время чтения.')
end = min(boundary, block_end)
for buffer, level in zip(buffers, current[1]):
if level == '1':
_ones(buffer, position - block_start, end - block_start)
position = end
if following and position == boundary:
current, following = following, next(rows, None)
row_count += 1
for index, buffer in enumerate(buffers):
_check(cancel)
archive.writestr(f'L-{index}/{block}', buffer)
block_start = block_end
progress(40 + (block + 1) * 59 // blocks_count)
if following is not None or row_count != count:
raise ValueError('CSV изменился во время чтения.')
_check(cancel)
if stamp != (source.stat().st_size, source.stat().st_mtime_ns):
raise ValueError('CSV изменился во время чтения.')
# Never overwrite an existing user file, including one created mid-import.
created = False
try:
with destination.open('xb') as output:
created = True
with open(temporary, 'rb') as data:
while chunk := data.read(1024 * 1024):
_check(cancel)
output.write(chunk)
except BaseException:
if created:
destination.unlink(missing_ok=True)
raise
progress(100)
return result
finally:
Path(temporary).unlink(missing_ok=True)

View File

@@ -0,0 +1,132 @@
"""Streaming normalization of configurable digital CSV input."""
import csv
from decimal import Decimal, InvalidOperation
import itertools
import re
from .files import ImportCancelled
def normalize_csv(source, destination, options, sample_rate, progress, cancel):
"""Write canonical CSV without expanding transition-only input into samples."""
# Нормализация отделена от упаковки SAL/DSL: каждый выходной формат
# получает один и тот же CSV с временем в секундах и выбранными каналами.
pattern = re.compile(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', re.I)
# Decimal сохраняет десятичную сетку входа при смене единиц времени.
# str() не переносит в Decimal двоичную погрешность float из Qt.
rate = Decimal(str(sample_rate))
if not rate.is_finite() or rate < 0:
raise ValueError('Частота должна быть конечной и неотрицательной.')
mode = options.get('mode', 'auto')
duration = options.get('duration')
if duration is not None and (mode != 'events' or not rate):
raise ValueError('Длительность требует режима переходов и заданной частоты.')
# Оба потока закрываются и при отмене. Временным файлом владеет вызывающий
# convert_capture: он удалит его вместе с временным каталогом.
with source.open(encoding='utf-8-sig', newline='') as stream, destination.open('w', encoding='utf-8', newline='') as output:
size = max(1, source.stat().st_size)
consumed = 0
# Читаем последовательно, не разворачивая событийную запись в отсчёты.
# Проверка отмены ограничивает задержку реакции на кнопку в интерфейсе.
def lines():
nonlocal consumed
for index, line in enumerate(stream):
consumed += len(line)
if index % 4096 == 0:
if cancel():
raise ImportCancelled()
progress(min(99, consumed * 100 // size))
if line.strip() and not line.lstrip().startswith(('#', ';')):
yield line
source_lines = lines()
first = next(source_lines, '')
if not first:
raise ValueError('CSV не содержит данных.')
# Явный разделитель имеет приоритет. Авто оценивает первую строку;
# для неоднозначного CSV оператор может выбрать разделитель вручную.
delimiter = options.get('delimiter') or max((',', ';', '\t'), key=first.count)
first_row = next(csv.reader([first], delimiter=delimiter))
no_header = options.get('no_header', False)
# При отсутствии заголовка первая строка остаётся данными. D0/D1 —
# имена исходных колонок, поэтому время тоже может называться D0.
names = ['D%d' % i for i in range(len(first_row))] if no_header else [x.strip() for x in first_row]
if len(set(names)) != len(names) or any(not n for n in names):
raise ValueError('Имена колонок должны быть непустыми и уникальными.')
time_column = options.get('time_column', 'auto')
# Автоматически выбираем только узнаваемое имя времени. При нескольких
# кандидатах нельзя молча взять первый: это изменило бы шкалу записи.
if time_column == 'auto':
candidates = [n for n in names if pattern.fullmatch(n)]
if len(candidates) > 1:
raise ValueError('Найдено несколько колонок времени; выберите одну.')
time_column = candidates[0] if candidates else None
elif time_column == 'none':
time_column = None
if time_column is not None and time_column not in names:
raise ValueError('Колонка времени не найдена: ' + time_column)
if time_column is None and (not rate or mode == 'events'):
raise ValueError('Без колонки времени задайте частоту; режим переходов требует времени.')
# Порядок списка задаёт выходные номера каналов. Служебные колонки
# не обязаны быть цифровыми, если пользователь исключил их из списка.
channels = options.get('channels') or [n for n in names if n != time_column]
if not 1 <= len(channels) <= 64 or len(set(channels)) != len(channels):
raise ValueError('Выберите от 1 до 64 различных цифровых каналов.')
if any(n not in names or n == time_column for n in channels):
raise ValueError('Канал отсутствует или совпадает с колонкой времени.')
# Индексы вычисляются один раз, а не поиском имён для каждой строки.
indices = [names.index(n) for n in channels]
time_index = names.index(time_column) if time_column is not None else None
unit = options.get('time_unit', 'auto')
# Явная единица позволяет читать нестандартную колонку t. Без суффикса
# auto означает секунды, как и обычный импортёр цифровых записей.
if unit == 'auto':
match = re.search(r'[\[(](s|ms|us|µs|ns)[\])]$', time_column or '', re.I)
unit = match[1].lower() if match else 's'
scale = Decimal({'s': '1', 'ms': '.001', 'us': '.000001', 'µs': '.000001', 'ns': '.000000001'}[unit])
rows = csv.reader(source_lines, delimiter=delimiter)
# Возвращаем уже прочитанную первую строку в ленивый итератор.
if no_header:
rows = itertools.chain([first_row], rows)
writer = csv.writer(output)
writer.writerow(['Time [s]'] + channels)
previous = origin = None
for index, row in enumerate(rows):
try:
if len(row) != len(names):
raise ValueError('число колонок отличается от заголовка')
# Без времени строки — последовательные отсчёты с нуля.
# При наличии времени сохраняем исходное начало, даже отрицательное.
timestamp = Decimal(row[time_index].strip().replace(',', '.')) * scale if time_index is not None else Decimal(index) / rate
if not timestamp.is_finite() or (previous is not None and timestamp <= previous):
raise ValueError('время должно быть конечным и строго возрастать')
if origin is None:
origin = timestamp
# Проверка равномерности относится только к отсчётам с заданной
# частотой; событийная запись вправе иметь длинные паузы.
if mode == 'samples' and rate and abs((timestamp-origin)*rate-index) > Decimal('.00001'):
raise ValueError('временные метки не соответствуют частоте отсчётов')
levels = [row[i].strip() for i in indices]
if any(v not in ('0', '1') for v in levels):
raise ValueError('выбранные каналы должны содержать 0 или 1')
writer.writerow([str(timestamp)] + levels)
previous = timestamp
except (ValueError, InvalidOperation) as error:
raise ValueError('CSV, строка данных %d: %s' % (index+1, error)) from error
if previous is None:
raise ValueError('CSV не содержит данных.')
# Длительность задаёт исключительную границу: N отсчётов занимают
# N/rate секунд, но последний находится в (N-1)/rate. Дописываем
# удержание последнего уровня, не создавая нового фронта.
if duration is not None:
count = Decimal(str(duration)) * rate
if not count.is_finite() or count <= 0 or abs(count-count.to_integral_value()) > Decimal('.00001'):
raise ValueError('Длительность должна быть положительной и попадать на сетку частоты.')
last_sample = origin + (count.to_integral_value()-1)/rate
if previous > last_sample:
raise ValueError('Длительность заканчивается до последнего отсчёта записи.')
if previous < last_sample:
writer.writerow([str(last_sample)] + levels)
if cancel():
raise ImportCancelled()

View File

@@ -7,6 +7,7 @@ therefore checked as PASS/FAIL.
"""
from collections import deque
import math
PROFILES = {
@@ -43,7 +44,7 @@ class TimingChecker(object):
def __init__(self, samplerate, profile='1SP0635', delay_tolerance_ns=100.0,
vin_active_high=True, vstat_active_high=True,
custom=None):
custom=None, orphan_min_width_ns=0.0, cycle_results=False):
if not samplerate:
raise ValueError('samplerate is required')
if profile == 'custom':
@@ -55,8 +56,13 @@ class TimingChecker(object):
self.profile = profile
self.samplerate = float(samplerate)
self.delay_tolerance_ns = float(delay_tolerance_ns)
self.orphan_min_width_ns = float(orphan_min_width_ns)
if not math.isfinite(self.orphan_min_width_ns) or self.orphan_min_width_ns < 0:
raise ValueError('ORPHAN minimum pulse width must be finite and non-negative')
self.vin_active_high = bool(vin_active_high)
self.vstat_active_high = bool(vstat_active_high)
self.cycle_results = cycle_results
self.cycle = None
self.pending = deque()
self.status_start = None
self.status_control = None
@@ -85,12 +91,55 @@ class TimingChecker(object):
'level': int(level),
'edge': 'ON' if state_on else 'OFF',
}
events = []
if self.cycle_results:
if state_on:
if self.cycle is not None and not self.cycle['emitted']:
events += self._finish_cycle(self.cycle, sample, incomplete=True)
self.cycle = dict(start=int(sample), severity=0, reasons=[], resolved=set(), emitted=False)
item['cycle'] = self.cycle
self.pending.append(item)
return [{
return events + [{
'kind': 'control', 'start': int(sample), 'end': int(sample),
'text': 'Vin %s' % item['edge'], 'short': item['edge'],
'text': 'Vin1 %s' % item['edge'], 'short': item['edge'],
}]
def _finish_cycle(self, cycle, sample, incomplete=False):
if cycle['emitted']:
return []
cycle['emitted'] = True
if incomplete:
cycle['severity'] = max(1, cycle['severity'])
cycle['reasons'].append('incomplete cycle')
verdict = ('OK', 'WARNING', 'FAULT')[cycle['severity']]
detail = ', '.join(dict.fromkeys(cycle['reasons']))
return [dict(kind='cycle_' + verdict.lower(), start=cycle['start'], end=int(sample),
text=verdict + ': Vin1 ON / ACK / OFF / ACK' + (' - ' + detail if detail else ''),
short=verdict)]
def _cycle_note(self, cycle, kind):
if cycle is None or cycle['emitted']:
return
severity = 2 if kind in ('missing', 'width_fail', 'fault') else 1 if kind in ('delay_warn', 'orphan') else 0
cycle['severity'] = max(cycle['severity'], severity)
if severity:
cycle['reasons'].append(kind)
def _resolve_cycle(self, item, sample, kind):
cycle = item.get('cycle') if item else None
if cycle is None or cycle['emitted']:
return []
self._cycle_note(cycle, kind)
cycle['resolved'].add(item['edge'])
if cycle['resolved'] == {'ON', 'OFF'}:
return self._finish_cycle(cycle, sample)
return []
def finish_cycles(self, sample):
if self.cycle is not None and not self.cycle['emitted']:
return self._finish_cycle(self.cycle, sample, incomplete=True)
return []
def expire(self, sample):
events = []
timeout_samples = self.ns_to_samples(self.ack_timeout_ns)
@@ -99,10 +148,11 @@ class TimingChecker(object):
end = item['sample'] + timeout_samples
events.append({
'kind': 'missing', 'start': item['sample'], 'end': end,
'text': 'FAIL: no Vstat ACK after Vin %s (timeout %s)' %
'text': 'FAIL: no Vstat ACK after Vin1 %s (timeout %s)' %
(item['edge'], format_ns(self.ack_timeout_ns)),
'short': 'NO ACK',
})
events.extend(self._resolve_cycle(item, end, 'missing'))
return events
def on_status_edge(self, sample, level):
@@ -121,6 +171,7 @@ class TimingChecker(object):
typ_ns = self.spec['ack_delay_typ_ns']
delta_ns = delay_ns - typ_ns
in_window = abs(delta_ns) <= self.delay_tolerance_ns
self._cycle_note(self.status_control.get('cycle'), 'delay_ok' if in_window else 'delay_warn')
events.append({
'kind': 'delay_ok' if in_window else 'delay_warn',
'start': self.status_control['sample'], 'end': sample,
@@ -134,6 +185,7 @@ class TimingChecker(object):
return events
if self.status_start is None:
self._cycle_note(self.cycle, 'orphan')
events.append({
'kind': 'orphan', 'start': sample, 'end': sample,
'text': 'Unexpected inactive Vstat edge', 'short': 'Vstat?',
@@ -164,10 +216,14 @@ class TimingChecker(object):
'short': 'FAULT %s' % format_ns(width_ns),
})
elif control is None:
# This is an annotation filter, not a signal debounce: leave ACK
# matching and fault detection intact. Equality passes the filter.
if width_ns < self.orphan_min_width_ns:
return events
events.append({
'kind': 'orphan', 'start': start, 'end': sample,
'width_ns': width_ns,
'text': 'Unexpected Vstat pulse %s (no Vin edge)' % format_ns(width_ns),
'text': 'Unexpected Vstat pulse %s (no Vin1 edge)' % format_ns(width_ns),
'short': 'ORPHAN %s' % format_ns(width_ns),
})
else:
@@ -178,4 +234,87 @@ class TimingChecker(object):
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
'short': 'BAD ACK %s' % format_ns(width_ns),
})
kind = events[-1]['kind']
if control is None:
self._cycle_note(self.cycle, kind)
events.extend(self._resolve_cycle(control, sample, kind))
return events
class InputTimingChecker(object):
"""Measure complete active pulses and OFF->ON handovers of two inputs.
Feed all input levels at a sample together, including the initial sample.
Unknown pulse starts at the capture boundary are never measured.
"""
def __init__(self, samplerate, vin1_active_high=True, vin2_active_high=True,
vin1_mintime_ns=0, vin2_mintime_ns=0):
self.samplerate = float(samplerate)
self.minimum = (float(vin1_mintime_ns), float(vin2_mintime_ns))
if not math.isfinite(self.samplerate) or self.samplerate <= 0:
raise ValueError('samplerate must be finite and positive')
if any(not math.isfinite(v) or v < 0 for v in self.minimum):
raise ValueError('Vin mintime must be finite and non-negative')
self.polarity = (bool(vin1_active_high), bool(vin2_active_high))
self.levels = None
self.starts = [None, None]
self.off = [None, None]
self.overlap_start = None
def _event(self, kind, start, end, label, **values):
duration = (end - start) * 1e9 / self.samplerate
text = '%s: %s' % (label, format_ns(duration))
result = dict(kind=kind, start=start, end=end, text=text, short=text,
duration_ns=duration)
result.update(values)
return result
def update(self, sample, vin1, vin2=None):
sample = int(sample)
levels = [bool(vin1) == self.polarity[0],
None if vin2 is None else bool(vin2) == self.polarity[1]]
if self.levels is None:
self.levels = levels
if all(levels):
self.overlap_start = sample
return []
events = []
previous = self.levels
# Record OFF edges first so simultaneous handovers measure zero.
for i in range(2):
if previous[i] is True and levels[i] is False:
self.off[i] = sample
if self.starts[i] is not None:
duration = (sample - self.starts[i]) * 1e9 / self.samplerate
failed = duration < self.minimum[i]
events.append(self._event(
'mintime_fail' if failed else 'mintime_ok', self.starts[i], sample,
'%s: Vin%d active (mintime %s)' %
('FAIL' if failed else 'PASS', i + 1, format_ns(self.minimum[i])),
channel=i + 1))
self.starts[i] = None
for i in range(2):
if previous[i] is False and levels[i] is True:
self.starts[i] = sample
other = 1 - i
if levels[other] is False and self.off[other] is not None:
events.append(self._event('deadtime', self.off[other], sample,
'Deadtime Vin%d -> Vin%d' % (other + 1, i + 1),
from_channel=other+1, to_channel=i+1))
# A turn-on consumes the preceding turn-off of either input.
self.off = [None, None]
if all(levels) and not all(previous):
self.overlap_start = sample
elif all(previous) and not all(levels):
events.append(self._event('overlap', self.overlap_start, sample,
'FAIL: Vin1/Vin2 overlap'))
self.overlap_start = None
self.levels = levels
return events
def finish(self, sample):
if self.overlap_start is not None:
return [self._event('overlap', self.overlap_start, int(sample),
'FAIL: Vin1/Vin2 overlap (continues at capture end)')]
return []

View File

@@ -0,0 +1,56 @@
"""Independent complementary transistor input timing; no ACK/Vstat dependency."""
import math
from .gate_timing import InputTimingChecker, format_ns
class PairTimingChecker(InputTimingChecker):
def __init__(self, samplerate, vin1_active_high=True, vin2_active_high=True,
vin1_mintime_ns=0, vin2_mintime_ns=0,
deadtime_12_ns=0, deadtime_21_ns=0,
vin1_minoff_ns=0, vin2_minoff_ns=0):
super().__init__(samplerate, vin1_active_high, vin2_active_high,
vin1_mintime_ns, vin2_mintime_ns)
self.deadtime_min = (float(deadtime_12_ns), float(deadtime_21_ns))
self.minoff = (float(vin1_minoff_ns), float(vin2_minoff_ns))
if any(not math.isfinite(v) or v < 0 for v in self.deadtime_min + self.minoff):
raise ValueError('Timing limits must be finite and non-negative')
self.last_on = [None, None]
self.last_off = [None, None]
def update(self, sample, vin1, vin2):
sample = int(sample)
previous = self.levels[:] if self.levels is not None else None
events = super().update(sample, vin1, vin2)
for event in events:
if event['kind'] == 'deadtime':
source = event['from_channel'] - 1
limit = self.deadtime_min[source]
if event['duration_ns'] < limit:
event['kind'] = 'deadtime_fail'
event['text'] = 'FAULT: ' + event['text'] + ' < minimum ' + format_ns(limit)
event['short'] = 'FAULT: deadtime'
if previous is None:
return events
for i in range(2):
if previous[i] and not self.levels[i]:
self.last_off[i] = sample
elif not previous[i] and self.levels[i]:
off = self.last_off[i]
on = self.last_on[i]
if off is not None:
duration = (sample - off) * 1e9 / self.samplerate
failed = duration < self.minoff[i]
events.append(self._event('off_fail' if failed else 'off_time', off, sample,
'%s: Vin%d OFF (minimum %s)' %
('FAULT' if failed else 'OK', i + 1, format_ns(self.minoff[i])), channel=i+1))
if on is not None and off is not None and on < off < sample:
period = sample - on
frequency = self.samplerate / period
duty = 100.0 * (off - on) / period
text = 'Vin%d: period %s, %.3f Hz, duty %.2f%%' % (
i + 1, format_ns(period * 1e9 / self.samplerate), frequency, duty)
events.append(dict(kind='period', start=on, end=sample, text=text,
short='%.3f Hz / %.2f%%' % (frequency, duty),
channel=i+1, frequency_hz=frequency, duty_percent=duty))
self.last_on[i] = sample
return events

View File

@@ -0,0 +1,205 @@
"""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

View File

@@ -0,0 +1,106 @@
"""DSView trigger validation and API programming, independent of Qt/cffi."""
import math
def pattern(value):
return ''.join(str(value).upper().split())
def validate_trigger(trigger, channels, acquisition):
trigger = trigger or {'kind': 'none'}
kind = trigger.get('kind', 'none')
if kind not in ('none', 'simple', 'stages', 'serial'):
raise ValueError('Неизвестный тип триггера.')
if kind == 'none':
return
if acquisition == 'internal':
raise ValueError('Внутренний тест работает без триггера.')
if kind in ('stages', 'serial') and acquisition != 'buffer':
raise ValueError('Расширенный и последовательный триггеры требуют буферного режима.')
position = trigger.get('position', 50)
timeout = trigger.get('timeout', 30)
if type(position) is not int or not 0 <= position <= 90:
raise ValueError('Позиция триггера: 0…90 %.')
if not math.isfinite(timeout) or not 0 <= timeout <= 3600:
raise ValueError('Ожидание триггера: 0…3600 с; 0 — без тайм-аута.')
def check(value, data=False):
value = pattern(value)
if len(value) != 16 or any(c not in ('01X' if data else '01XRFC') for c in value):
raise ValueError('Условие должно содержать 16 символов: ' + ('0, 1, X.' if data else '0, 1, X, R, F, C.'))
if not data and any(c != 'X' and 15-i not in channels for i, c in enumerate(value)):
raise ValueError('В триггере указан выключенный канал.')
return value
if kind == 'simple':
value = check(trigger.get('pattern', 'X'*16))
if set(value) == {'X'}:
raise ValueError('Выберите хотя бы одно условие триггера.')
elif kind == 'stages':
stages = trigger.get('stages', [])
if not 1 <= len(stages) <= 16:
raise ValueError('Число ступеней: 1…16.')
for stage in stages:
a, b = check(stage['a']), check(stage['b'])
if a == b == 'X'*16:
raise ValueError('Задайте условие на каждой ступени.')
if stage.get('logic', 'and') not in ('and', 'or'):
raise ValueError('Логика ступени: И либо ИЛИ.')
if type(stage.get('count', 1)) is not int or not 1 <= stage.get('count', 1) <= 2147483647:
raise ValueError('Счётчик ступени: 1…2147483647.')
else:
check(trigger['start']); check(trigger['stop'])
clock = check(trigger['clock'])
check(trigger.get('compare', 'X'*16))
if not any(c in 'RFC' for c in clock):
raise ValueError('В условии такта нужен фронт R, F или C.')
if type(trigger['data_channel']) is not int or not 0 <= trigger['data_channel'] <= 15:
raise ValueError('Канал последовательных данных: D0…D15.')
if trigger['data_channel'] not in channels:
raise ValueError('Канал последовательных данных выключен.')
if type(trigger['bits']) is not int or not 1 <= trigger['bits'] <= 16:
raise ValueError('Число последовательных битов: 1…16.')
check(trigger['value'], data=True)
def program_trigger(lib, trigger, acquisition, check):
"""Follow DSView TriggerDock::commit_trigger, including stage count - 1."""
trigger = trigger or {'kind': 'none'}
kind = trigger.get('kind', 'none')
def call(name, *args):
check(getattr(lib, name)(*args), name)
call('ds_trigger_reset')
call('ds_trigger_set_en', int(kind != 'none'))
if kind == 'none':
return
call('ds_trigger_set_pos', 1 if acquisition == 'stream' else trigger.get('position', 50))
call('ds_trigger_set_mode', {'simple': 0, 'stages': 1, 'serial': 2}[kind])
if kind == 'simple':
for ch, value in enumerate(reversed(pattern(trigger['pattern']))):
call('ds_trigger_probe_set', ch, ord(value), ord('X'))
return
def values(index, a, b):
call('ds_trigger_stage_set_value', index, 16,
' '.join(pattern(a)).encode('ascii'), ' '.join(pattern(b)).encode('ascii'))
if kind == 'stages':
stages = trigger['stages']
call('ds_trigger_set_stage', len(stages)-1)
for index, stage in enumerate(stages):
values(index, stage['a'], stage['b'])
logic = int(stage.get('logic', 'and') == 'and') | (int(stage.get('contiguous', False)) << 1)
call('ds_trigger_stage_set_logic', index, 16, logic)
call('ds_trigger_stage_set_inv', index, 16, int(stage.get('inv_a', False)), int(stage.get('inv_b', False)))
call('ds_trigger_stage_set_count', index, 16, stage.get('count', 1), 0)
else:
call('ds_trigger_set_stage', 3)
values(0, trigger['start'], trigger['stop'])
values(1, trigger['clock'], trigger.get('compare', 'X'*16))
select = list('X'*16)
select[15-trigger['data_channel']] = '0'
values(2, ''.join(select), 'X'*16)
values(3, trigger['value'], 'X'*16)
for index in range(4):
call('ds_trigger_stage_set_logic', index, 16, 1)
call('ds_trigger_stage_set_inv', index, 16, 0, 0)
call('ds_trigger_stage_set_count', 1, 16, 1, 0)
call('ds_trigger_stage_set_count', 3, 16, trigger['bits']-1, 0)

View File

@@ -0,0 +1,242 @@
"""Isolated DSView driver host. JSON request/result files, raw CROSS_DATA spool.
Native callbacks and their references live until process exit. Never import
this module's native dependencies into the Qt process.
"""
import json
import os
from pathlib import Path
import re
import sys
import threading
import time
def run(request, directory):
import pydsview
from pydsview import Config, DeviceType, DeviceMode
from pydsview._binding import ffi, lib
from pydsview._constants import PacketType, Event
from pydsview.errors import check_sr
# The upstream library is a process-global singleton with async callbacks.
# This process owns exactly one operation and explicitly closes the device.
context = pydsview.DSContext()
devices = []
selected = None
for info in context.list_devices():
if 'DSLogic' not in info.name:
continue
device = context.activate_device(info.handle)
if device.device_type != DeviceType.USB or device.mode != DeviceMode.LOGIC:
continue
connection = device.get_config(Config.CONN)
ident = 'dsview:' + str(connection)
item = dict(device_id=ident, name=device.name,
channels=[c.index for c in device.channels])
devices.append(item)
if request['action'] == 'capture' and ident == request['settings']['device_id']:
selected = device
break
if request['action'] == 'scan':
lib.ds_close_all_device()
return {'devices': devices}
if selected is None:
raise RuntimeError('Выбранный DSLogic отключён. Повторите поиск устройств.')
options = request['settings']
from logic_analyzer.dslogic import DSLogicSettings
from logic_analyzer.dslogic_trigger import program_trigger
DSLogicSettings(**options).validate()
acquisition = options.get('acquisition_mode', 'stream')
selected.set_config(Config.OPERATION_MODE, {'buffer': 0, 'stream': 1, 'internal': 2}[acquisition])
if acquisition != 'stream':
selected.set_config(Config.BUFFER_OPTIONS, 1) # Upload on manual stop, as in DSView.
trigger = options.get('trigger') or {'kind': 'none'}
trigger_enabled = trigger.get('kind', 'none') != 'none'
# The driver accepts arbitrary samplerate values without validating them.
# Select a supported channel mode and verify against its advertised rates.
ffi.cdef('''
struct setgui_list_item { int id; const char *name; };
void *g_variant_lookup_value(void *, const char *, const void *);
const void *g_variant_get_fixed_array(void *, size_t *, size_t);
void g_variant_unref(void *);
struct setgui_trigger_pos { uint32_t check_id, real_pos, ram_saddr, remain_cnt_l, remain_cnt_h, status; };
''')
glib = ffi.dlopen(str(Path(pydsview.__file__).parent / '_libs/libglib-2.0-0.dll'))
modes_variant = ffi.new('GVariant *[1]')
check_sr(lib.ds_get_actived_device_config_list(ffi.NULL, Config.CHANNEL_MODE, modes_variant), 'Режимы каналов DSView')
try:
modes = ffi.cast('struct setgui_list_item *', lib.pyds_gvariant_get_uint64(modes_variant[0]))
selected_mode = None
for index in range(64):
if modes[index].id == -1:
break
title = ffi.string(modes[index].name).decode()
match = re.fullmatch(r'Use (\d+) Channels \(Max (\d+)(MHz|GHz)\)', title)
buffered = re.fullmatch(r'Use Channels 0~(\d+) \(Max (\d+)(MHz|GHz)\)', title)
capacity = int(match[1]) if match else int(buffered[1])+1 if buffered else 0
detail = match or buffered
maximum = int(detail[2]) * (1000000000 if detail[3] == 'GHz' else 1000000) if detail else 0
fits = (len(options['channels']) <= capacity if match else max(options['channels']) < capacity)
if fits and (acquisition == 'internal' or maximum >= options['sample_rate']):
selected_mode = modes[index].id
break
if selected_mode is None:
raise ValueError('Частота или число каналов не поддерживаются в выбранном режиме DSView.')
selected.set_config(Config.CHANNEL_MODE, selected_mode)
finally:
lib.pyds_gvariant_unref(modes_variant[0])
rates_variant = ffi.new('GVariant *[1]')
check_sr(lib.ds_get_actived_device_config_list(ffi.NULL, Config.SAMPLERATE, rates_variant), 'Частоты DSView')
rate_array = glib.g_variant_lookup_value(rates_variant[0], b'samplerates', ffi.NULL)
try:
if rate_array == ffi.NULL:
raise ValueError('DSView не вернул доступные частоты.')
count_rates = ffi.new('size_t *')
values = ffi.cast('uint64_t *', glib.g_variant_get_fixed_array(rate_array, count_rates, 8))
rates = [int(values[i]) for i in range(count_rates[0])]
if acquisition != 'internal' and options['sample_rate'] not in rates:
raise ValueError('Допустимые частоты, S/s: ' + ', '.join(map(str, rates)))
finally:
if rate_array != ffi.NULL:
glib.g_variant_unref(rate_array)
lib.pyds_gvariant_unref(rates_variant[0])
available = {c.index for c in selected.channels}
channels = sorted(options['channels'])
if not set(channels).issubset(available):
raise ValueError('Выбранные каналы недоступны в этом режиме.')
for channel in selected.channels:
selected.enable_channel(channel.index, channel.index in channels)
if acquisition != 'internal':
selected.samplerate = options['sample_rate']
rate = selected.samplerate
if acquisition != 'internal' and rate != options['sample_rate']:
raise ValueError('Устройство изменило частоту. Выберите поддерживаемую частоту.')
if options['threshold'] is not None:
selected.set_config(Config.VTH, float(options['threshold']))
# Round storage to complete groups: 64 samples per enabled channel.
cap_groups = options['buffer_mb'] * 1024 * 1024 // (8 * len(channels))
requested = max(1, round(options['duration'] * rate)) if options['duration'] else cap_groups * 64
limit = min(requested, cap_groups * 64)
if acquisition != 'stream':
limit = min(limit, int(selected.get_config(Config.HW_DEPTH)))
if limit < 1024:
raise ValueError('Для буферного захвата нужно не менее 1024 выборок. Увеличьте длительность.')
# Buffer hardware transfers whole 1024-sample blocks. Round the request
# up, then trim transport padding back to the requested samples on import.
selected.sample_count = ((limit + 1023) // 1024 * 1024) if acquisition != 'stream' else limit
program_trigger(lib, trigger, acquisition, check_sr)
done = threading.Event()
overflow = threading.Event()
errors = []
count = [0]
trigger_sample = [None]
trigger_seen = threading.Event()
ceiling = ((limit + 63) // 64) * 8 * len(channels)
stream = open(directory / 'capture.bin', 'wb')
@ffi.callback('void(const void*, const struct sr_datafeed_packet*)')
def data_callback(_device, packet):
try:
if packet.status:
errors.append('Ошибка пакета DSView: %d' % packet.status)
overflow.set()
if packet.type == PacketType.TRIGGER:
info = ffi.cast('const struct setgui_trigger_pos *', packet.payload)
if info.status & 1:
trigger_sample[0] = int(info.real_pos)
trigger_seen.set()
(directory / 'triggered').touch()
elif packet.type == PacketType.LOGIC:
logic = ffi.cast('const struct sr_datafeed_logic *', packet.payload)
if logic.format != 0 or logic.data_error:
raise ValueError('Неподдерживаемый формат или ошибка данных DSView.')
size = min(int(logic.length), ceiling - count[0])
if size > 0:
stream.write(ffi.buffer(ffi.cast('const char *', logic.data), size))
count[0] += size
if count[0] >= ceiling:
overflow.set()
elif packet.type == PacketType.OVERFLOW:
errors.append('Переполнение USB-потока DSLogic. Уменьшите частоту.')
overflow.set()
except Exception as exc:
errors.append(str(exc))
overflow.set()
@ffi.callback('void(int)')
def event_callback(event):
if event in (Event.COLLECT_TASK_END_BY_DETACHED, Event.COLLECT_TASK_END_BY_ERROR):
errors.append('DSLogic отключён или захват завершился с ошибкой (%d).' % event)
if event in (Event.COLLECT_TASK_END, Event.COLLECT_TASK_END_BY_DETACHED,
Event.COLLECT_TASK_END_BY_ERROR):
done.set()
lib.ds_set_datafeed_callback(data_callback)
lib.ds_set_event_callback(event_callback)
# Keep callbacks alive even if an exception unwinds this frame.
global _callbacks
_callbacks = (data_callback, event_callback)
started = False
try:
check_sr(lib.ds_start_collect(), 'Не удалось начать захват DSView')
started = True
(directory / 'ready').touch()
waiting = trigger_enabled
timeout = trigger.get('timeout', 30)
deadline = (time.monotonic() + timeout if timeout else None) if waiting else (time.monotonic() + limit / rate + 30 if options['duration'] else None)
stopped = False
while not done.wait(.02):
if waiting and trigger_seen.is_set():
waiting = False
deadline = time.monotonic() + limit / rate + 30 if options['duration'] else None
if (directory / 'stop').exists() or overflow.is_set() or (deadline and time.monotonic() > deadline):
stopped = True
check_sr(lib.ds_stop_collect(), 'Не удалось остановить DSView')
if not done.wait(5):
raise RuntimeError('DSView не подтвердил остановку захвата.')
if deadline and time.monotonic() > deadline:
raise RuntimeError('Истекло время ожидания триггера DSLogic.' if waiting else 'Истекло время ожидания данных DSLogic.')
break
if errors:
raise RuntimeError(errors[0])
stream.flush()
group_bytes = 8 * len(channels)
if count[0] % group_bytes:
raise ValueError('Неполная группа выборок DSLogic.')
samples = min(limit, count[0] // group_bytes * 64)
if not samples:
raise ValueError('DSLogic не вернул выборки.')
return dict(channels=channels, sample_rate=rate, samples=samples,
format='cross64-le', stopped=stopped, name=selected.name + ' · ' + acquisition,
trigger_sample=trigger_sample[0] if trigger_enabled else None,
limited=limit < requested or not options['duration'] and samples >= limit)
finally:
if started and lib.ds_is_collecting():
lib.ds_stop_collect()
done.wait(5)
# Keep stream open for late callbacks until OS process cleanup.
lib.ds_close_all_device()
def main():
directory = Path(sys.argv[1]).resolve()
code = 0
try:
request = json.loads((directory / 'request.json').read_text(encoding='utf-8'))
result = run(request, directory)
except BaseException as exc:
result = {'error': str(exc)}
code = 1
(directory / 'result.json').write_text(json.dumps(result, ensure_ascii=False), encoding='utf-8')
# DSView owns native threads. Interpreter finalization can invalidate cffi
# callbacks before they exit. All device I/O was closed before this point.
os._exit(code)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,87 @@
"""Process boundary for the patched DSView GUI (host protocol 1, Windows).
The host owns only its child process/window. Qt 5, Python and libusb remain
inside DSView; they must never be loaded into the hosting application's Qt.
"""
from contextlib import contextmanager
import ctypes
import os
from pathlib import Path
import sys
def resolve_runtime(bundled, executable=None, frozen=None):
"""A dsview folder beside the GUI EXE overrides its bundled runtime."""
frozen = getattr(sys, 'frozen', False) if frozen is None else frozen
if frozen:
external = Path(executable or sys.executable).resolve().parent / 'dsview'
if external.exists():
# An incomplete external install should be reported, not silently
# replaced with an older bundled component.
return external
return Path(bundled)
def child_environment(runtime, stylesheet, capture_directory, environment=None):
"""Remove Python/Qt/PyInstaller overrides before starting the private runtime."""
env = dict(os.environ if environment is None else environment)
for key in list(env):
if key.upper().startswith(('PYTHON', 'QT_', 'QML', 'PYSIDE', '_PYI', '_MEIPASS')):
env.pop(key)
windows = Path(env.get('SystemRoot', r'C:\Windows'))
env['PATH'] = os.pathsep.join(map(str, (Path(runtime), windows / 'System32', windows)))
env['DSVIEW_HOST_STYLE'] = str(stylesheet)
env['DSVIEW_CAPTURE_DIR'] = str(capture_directory)
env['PYTHONDONTWRITEBYTECODE'] = '1'
return env
def ready_window(line):
"""Only accept the versioned child's explicit ready message."""
fields = line.strip().split()
if len(fields) == 2 and fields[0] == 'DSVIEW_READY':
try:
value = int(fields[1])
return value if value > 0 else None
except ValueError:
pass
return None
def window_api():
from ctypes import wintypes
api = ctypes.WinDLL('user32', use_last_error=True)
api.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
api.GetWindowThreadProcessId.restype = wintypes.DWORD
api.GetParent.argtypes = [wintypes.HWND]
api.GetParent.restype = wintypes.HWND
return api
def owns_window(window, pid, parent=None):
if sys.platform != 'win32' or not window or not pid:
return False
from ctypes import wintypes
api = window_api()
owner = wintypes.DWORD()
api.GetWindowThreadProcessId(window, ctypes.byref(owner))
return owner.value == pid and (parent is None or api.GetParent(window) == parent)
@contextmanager
def independent_dll_directory():
"""Do not inherit PyInstaller's Qt DLL search directory into DSView."""
if sys.platform != 'win32' or not getattr(sys, 'frozen', False):
yield
return
api = ctypes.WinDLL('kernel32', use_last_error=True)
api.GetDllDirectoryW.argtypes = [ctypes.c_uint32, ctypes.c_wchar_p]
api.SetDllDirectoryW.argtypes = [ctypes.c_wchar_p]
buffer = ctypes.create_unicode_buffer(32768)
api.GetDllDirectoryW(len(buffer), buffer)
if not api.SetDllDirectoryW(None):
raise ctypes.WinError(ctypes.get_last_error())
try:
yield
finally:
api.SetDllDirectoryW(buffer.value or None)

View File

@@ -42,6 +42,7 @@ class DigitalCapture:
start: float
end: float
sample_rate: float = 0
trigger_time: float = None
def _check(cancel):
@@ -54,12 +55,14 @@ def read_capture(path, progress=lambda value: None, cancel=lambda: False):
try:
if path.suffix.lower() == '.csv':
result = _csv(path, progress, cancel)
elif path.suffix.lower() == '.json':
result = _json(path, progress, cancel)
elif path.suffix.lower() in ('.sal', '.dsl'):
with zipfile.ZipFile(path) as archive:
result = (_sal if path.suffix.lower() == '.sal' else _dsl)(
path, archive, progress, cancel)
else:
raise ValueError('Выберите цифровую запись CSV, SAL или DSL.')
raise ValueError('Выберите цифровую запись CSV, JSON, SAL или DSL.')
except (KeyError, struct.error, zipfile.BadZipFile, EOFError,
configparser.Error, UnicodeError) as exc:
raise ValueError('Повреждённый или неподдерживаемый файл: %s' % exc) from exc
@@ -70,6 +73,58 @@ def read_capture(path, progress=lambda value: None, cancel=lambda: False):
return result
def _json(path, progress, cancel):
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError('Повторяющееся поле JSON: %s.' % key)
result[key] = value
return result
_check(cancel)
with path.open(encoding='utf-8-sig') as stream:
rows = json.load(stream, object_pairs_hook=unique_object)
_check(cancel)
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
raise ValueError('Ожидается JSON: непустой массив объектов с Time [s] и каналами 0/1.')
names = list(rows[0])
if ('Time [s]' not in names or not 2 <= len(names) <= 65
or any(not name.strip() for name in names)):
raise ValueError('JSON должен содержать Time [s] и от 1 до 64 цифровых каналов.')
channels = [DigitalChannel(name, 0, array('d')) for name in names if name != 'Time [s]']
fields = set(names)
start = end = None
previous = []
for index, row in enumerate(rows):
if index % 4096 == 0:
_check(cancel)
progress(index * 99 // len(rows))
try:
if not isinstance(row, dict) or set(row) != fields:
raise ValueError('набор полей должен быть одинаковым во всех строках')
time = row['Time [s]']
if (type(time) not in (int, float) or not math.isfinite(time)
or (end is not None and time <= end)):
raise ValueError('время должно быть числом, строго возрастать и быть конечным')
values = [row[c.name] for c in channels]
if any(type(v) not in (int, float, bool) or v not in (0, 1) for v in values):
raise ValueError('цифровые уровни должны быть 0 или 1')
if start is None:
start = time
for channel, value in zip(channels, values):
channel.initial = int(value)
else:
for channel, old, value in zip(channels, previous, values):
if old != value:
channel.edges.append(time)
previous = values
end = time
except (ValueError, OverflowError) as exc:
raise ValueError('JSON, строка %d: %s' % (index + 1, exc)) from exc
return DigitalCapture(str(path), 'JSON', channels, start, end)
def _csv(path, progress, cancel):
size = max(1, path.stat().st_size)
consumed = 0

View File

@@ -0,0 +1,187 @@
"""Empty Logic 2 metadata based on the DSLogic_Logic_2 SAL fixture (schema 22)."""
from copy import deepcopy
_TEMPLATE = {'version': 22,
'data': {'renderViewState': {'type': 'PanAndZoom', 'leftEdgeTimeSec': 0, 'timeScaleSeconds': 0.001},
'captureStartTime': {'unixTimeMilliseconds': 0, 'fractionalMilliseconds': 0},
'timingMarkers': {'markers': {}, 'pairs': {}},
'measurements': [],
'highLevelAnalyzers': [],
'analyzers': [],
'rowsSettings': [],
'captureSettings': {'bufferSizeMb': 3072,
'timerModeSettings': {'stopAfterSeconds': 5e-05},
'commonCaptureSettings': {'trimAfterCapture': False, 'trimTimeSeconds': 0},
'triggerSettings': {'eventChannel': {'category': 'legacy',
'type': 'Digital',
'deviceChannel': 0},
'triggerSourceGeneration': 0,
'scopeEventType': 'Rising',
'scopeThreshold': 1,
'scopeHysteresisPercentage': 0.02,
'digitalEventType': 'Rising',
'digitalLinkedChannels': [],
'digitalLegacyPostTriggerBufferSeconds': 1,
'mode': 'Auto',
'holdOffSeconds': 0.001,
'pulseDuration': {'min': 0.001, 'max': 0.01},
'realTriggerTimeoutViewRatio': 4,
'minRealTriggerTimeoutSeconds': 1,
'autoTriggerTimeoutViewRatio': 2},
'captureMode': 'Timer',
'captureTriggerType': 'Signal'},
'legacyDevice': {'deviceId': '1000001',
'name': 'Logic Pro 16',
'deviceType': 'LogicPro16',
'isSimulation': True,
'capabilities': {'channelCapabilities': [{'type': 'Digital',
'index': 0,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 0,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 1,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 1,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 2,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 2,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 3,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 3,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 4,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 4,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 5,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 5,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 6,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 6,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 7,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 7,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 8,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 8,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 9,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 9,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 10,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 10,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 11,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 11,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 12,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 12,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 13,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 13,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 14,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 14,
'capability': 'Toggleable'},
{'type': 'Digital',
'index': 15,
'capability': 'Toggleable'},
{'type': 'Analog',
'index': 15,
'capability': 'Toggleable'}],
'sampleRateOptions': [{'digital': 500000000},
{'digital': 250000000},
{'digital': 125000000},
{'digital': 100000000},
{'digital': 50000000},
{'digital': 25000000},
{'digital': 20000000},
{'digital': 12500000},
{'digital': 10000000},
{'digital': 6250000},
{'digital': 5000000},
{'digital': 4000000},
{'digital': 2500000},
{'digital': 2000000},
{'digital': 1000000}],
'digitalThresholdOptions': [{'description': '1.2 Volts'},
{'description': '1.8 Volts'},
{'description': '3.3+ Volts'}],
'isPhysicalDevice': False}},
'legacySettings': {'enabledChannels': [{'type': 'Digital', 'index': 0},
{'type': 'Digital', 'index': 1},
{'type': 'Digital', 'index': 2},
{'type': 'Digital', 'index': 3}],
'sampleRate': {'digital': 100000000},
'digitalThreshold': {'description': '1.2 Volts'},
'glitchFilter': {'enabled': False, 'channels': []}},
'digitalTriggerTime': -1,
'name': 'Converted capture',
'dataTable': {'columns': {}},
'analyzerTrigger': {'settings': {'searchQuery': '', 'holdoffSeconds': 0.2}},
'timeManager': {'t0': {'type': 'startOfCapture'}},
'captureNotes': ''},
'binData': [{'dataId': {'value': 'LogicPro16/Digital/0'},
'category': 'legacy',
'type': 'Digital',
'deviceChannel': 0,
'file': './digital-0.bin'},
{'dataId': {'value': 'LogicPro16/Digital/1'},
'category': 'legacy',
'type': 'Digital',
'deviceChannel': 1,
'file': './digital-1.bin'},
{'dataId': {'value': 'LogicPro16/Digital/2'},
'category': 'legacy',
'type': 'Digital',
'deviceChannel': 2,
'file': './digital-2.bin'},
{'dataId': {'value': 'LogicPro16/Digital/3'},
'category': 'legacy',
'type': 'Digital',
'deviceChannel': 3,
'file': './digital-3.bin'}]}
def metadata():
return deepcopy(_TEMPLATE)

View File

@@ -0,0 +1,59 @@
import unittest
from dataclasses import replace
from unittest.mock import Mock
from logic_analyzer.dslogic import DSLogicSettings
from logic_analyzer.dslogic_trigger import program_trigger, validate_trigger
class TriggerTests(unittest.TestCase):
def test_invalid_modes_and_disabled_conditions(self):
base = DSLogicSettings('dsview:2.6')
for changes in (dict(acquisition_mode='bad'), dict(acquisition_mode='buffer', duration=0),
dict(acquisition_mode='internal', trigger=dict(kind='simple', pattern='X'*15+'R')),
dict(trigger=dict(kind='stages', stages=[])),
dict(trigger=dict(kind='simple', pattern='R'+'X'*15)),
dict(trigger=dict(kind='simple', pattern='X'*16))):
with self.subTest(changes=changes), self.assertRaises(ValueError):
replace(base, **changes).validate()
def test_simple_maps_physical_channels_and_stream_position(self):
lib = Mock()
program_trigger(lib, dict(kind='simple', pattern='F'+'X'*14+'R', position=80), 'stream', lambda *_: None)
lib.ds_trigger_set_pos.assert_called_once_with(1)
lib.ds_trigger_probe_set.assert_any_call(0, ord('R'), ord('X'))
lib.ds_trigger_probe_set.assert_any_call(15, ord('F'), ord('X'))
def test_stage_count_logic_and_masks_match_driver_abi(self):
trigger = dict(kind='stages', position=75, stages=[
dict(a='X'*15+'0', b='X'*14+'1X', logic='or', count=9, inv_a=True, contiguous=True),
dict(a='X'*15+'R', b='X'*16)])
validate_trigger(trigger, (0,1), 'buffer')
lib = Mock()
program_trigger(lib, trigger, 'buffer', lambda *_: None)
lib.ds_trigger_set_stage.assert_called_once_with(1)
lib.ds_trigger_set_pos.assert_called_once_with(75)
lib.ds_trigger_stage_set_logic.assert_any_call(0, 16, 2)
lib.ds_trigger_stage_set_inv.assert_any_call(0, 16, 1, 0)
lib.ds_trigger_stage_set_count.assert_any_call(0, 16, 9, 0)
lib.ds_trigger_stage_set_value.assert_any_call(1, 16, b'X '*15+b'R', b'X '*15+b'X')
def test_serial_mapping_and_data_channel_validation(self):
trigger = dict(kind='serial', start='X'*16, stop='X'*16, clock='X'*14+'RX',
data_channel=0, bits=8, value='X'*8+'10101010')
validate_trigger(trigger, (0,1), 'buffer')
lib = Mock()
program_trigger(lib, trigger, 'buffer', lambda *_: None)
lib.ds_trigger_set_mode.assert_called_once_with(2)
lib.ds_trigger_set_stage.assert_called_once_with(3)
lib.ds_trigger_stage_set_value.assert_any_call(2, 16, b'X '*15+b'0', b'X '*15+b'X')
lib.ds_trigger_stage_set_count.assert_any_call(3, 16, 7, 0)
with self.assertRaises(ValueError):
validate_trigger(dict(trigger, data_channel=16), (0,1,16), 'buffer')
def test_driver_errors_propagate(self):
lib = Mock()
def fail(*args):
raise RuntimeError('driver failure')
with self.assertRaisesRegex(RuntimeError, 'driver failure'):
program_trigger(lib, None, 'stream', fail)

View File

@@ -71,7 +71,7 @@ class LogicAnalysisTests(unittest.TestCase):
self.assertTrue(result.events)
self.assertTrue(all(e.kind == 'error' for e in result.events))
result = analyze_capture(uart_capture(b'\xa5', bad_stop=True), dict(mode='UART'))
self.assertTrue(any('стопового' in e.text for e in result.events))
self.assertTrue(any('стопового' in e.text for e in result.events))
def test_set_ping_bridge_crc_and_resynchronization(self):
damaged = bytearray(PING)
@@ -146,7 +146,7 @@ class LogicAnalysisTests(unittest.TestCase):
def test_can_incomplete_end(self):
bits = [1] * 10 + can_packet(0x123, b'\x55', extended=False)[:-8]
events = analyze_capture(capture_bits(bits, 1000000), dict(mode='CAN')).events
self.assertTrue(any('Неполный' in e.text for e in events))
self.assertTrue(any('Неполный' in e.text for e in events))
self.assertFalse(any(e.kind == 'can' for e in events))
def test_gate_profiles_and_truncated_window(self):
@@ -162,6 +162,24 @@ class LogicAnalysisTests(unittest.TestCase):
with self.assertRaises(ValueError):
analyze_capture(cap, dict(mode='1SP0635', status_channel=0))
def test_gate_pair_is_opt_in(self):
vin1 = DigitalChannel('Vin1', 0, array('d', [1e-6, 2e-6, 4e-6]))
status = DigitalChannel('Vstat', 0, array('d'))
vin2 = DigitalChannel('Vin2', 0, array('d', [2.2e-6, 3.2e-6]))
cap = DigitalCapture('test', 'test', [vin1, status, vin2], 0, 6e-6)
options = dict(mode='1SP0635', vin2_channel=2, vin2_mintime_ns=1500)
self.assertFalse(any(e.kind == 'deadtime' for e in analyze_capture(cap, options).events))
options['pair_analysis'] = True
with self.assertRaises(ValueError):
analyze_capture(cap, options)
options['mode'] = 'Transistor pair'
events = analyze_capture(cap, options).events
self.assertEqual(sum(e.kind == 'deadtime' for e in events), 2)
self.assertEqual(sum(e.kind == 'mintime_fail' for e in events), 1)
options['vin2_channel'] = 0
with self.assertRaises(ValueError):
analyze_capture(cap, options)
def test_measurements_cycle_and_clipped_pulse(self):
ch = DigitalChannel('D0', 0, array('d', [1e-6, 2e-6, 5e-6, 6e-6]))
measured = pulse_measurements(ch, 1.5e-6, 0, 8e-6)

View File

@@ -0,0 +1,104 @@
from dataclasses import replace
from io import BytesIO
import json
import os
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
from logic_analyzer import dslogic
from logic_analyzer.files import ImportCancelled
class DSLogicCoreTests(unittest.TestCase):
def setUp(self):
self.options = dslogic.DSLogicSettings('dsview:2.6', channels=(0, 9), sample_rate=1000000)
self.metadata = dict(channels=[0,9], samples=70, sample_rate=1000000, format='cross64-le')
def test_cross_words_preserve_physical_channels_boundary_edges_and_tail(self):
raw = struct.pack('<QQQQ', 1 << 63, (1 << 64)-1, 1, 0)
capture = dslogic.read_cross(BytesIO(raw), self.metadata, self.options)
self.assertEqual(capture.end, 70 / 1000000)
self.assertEqual([c.name for c in capture.channels], ['D0', 'D9'])
self.assertEqual([c.initial for c in capture.channels], [0, 1])
self.assertEqual([list(c.edges) for c in capture.channels], [[63e-6, 65e-6], [64e-6]])
def test_padding_is_not_imported_as_samples(self):
meta = dict(self.metadata, samples=3)
capture = dslogic.read_cross(BytesIO(struct.pack('<QQ', 1<<63, 0)), meta, self.options)
self.assertEqual(capture.end, 3e-6)
self.assertEqual(len(capture.channels[0].edges), 0)
def test_empty_truncated_wrong_mapping_and_cancel(self):
for raw in (b'', b'\0'*31):
with self.assertRaises(ValueError):
dslogic.read_cross(BytesIO(raw), self.metadata, self.options)
with self.assertRaises(ValueError):
dslogic.read_cross(BytesIO(b'\0'*32), dict(self.metadata, channels=[0,1]), self.options)
with self.assertRaises(ImportCancelled):
dslogic.read_cross(BytesIO(b'\0'*32), self.metadata, self.options, lambda: True)
def test_validation_rejects_old_driver_and_invalid_parameters(self):
for changes in (dict(device_id='dreamsourcelab-dslogic'), dict(channels=()),
dict(channels=(0,0)), dict(duration=float('nan')), dict(sample_rate=0)):
with self.assertRaises(ValueError):
replace(self.options, **changes).validate()
def test_host_does_not_inherit_frozen_gui_bootloader_state(self):
with patch.dict(os.environ, {'_MEIPASS2': 'gui-runtime', '_PYI_APPLICATION_HOME_DIR': 'gui-runtime'}):
with patch.object(dslogic.subprocess, 'Popen') as launch:
dslogic._popen(['host', 'request'])
environment = launch.call_args.kwargs['env']
self.assertNotIn('_MEIPASS2', environment)
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', environment)
def launch(self, code):
self.processes = []
def popen(arguments, **kwargs):
process = subprocess.Popen([sys.executable, '-c', code, arguments[1]], **kwargs)
self.processes.append(process)
return process
self.addCleanup(lambda: [p.kill() for p in self.processes if p.poll() is None])
return patch.object(dslogic, '_popen', side_effect=popen)
def test_real_process_scan(self):
code = "import sys,json; from pathlib import Path; p=Path(sys.argv[1]); (p/'result.json').write_text(json.dumps({'devices':[{'device_id':'dsview:2.6','name':'DSLogic Plus','channels':[0,9]}]}))"
with self.launch(code):
devices = dslogic.list_devices()
self.assertEqual(devices[0].channels, (0,9))
self.assertEqual(devices[0].device_id, 'dsview:2.6')
def test_stop_is_graceful_and_imports_result(self):
code = '''import sys,time,json,struct
from pathlib import Path
p=Path(sys.argv[1]); (p/'ready').touch()
while not (p/'stop').exists(): time.sleep(.01)
(p/'capture.bin').write_bytes(struct.pack('<QQ',6,0))
(p/'result.json').write_text(json.dumps(dict(channels=[0,9],samples=3,sample_rate=1000000,format='cross64-le')))
'''
with self.launch(code):
capture = dslogic.acquire(self.options, lambda: True, lambda: False)
self.assertEqual(capture.end, 3e-6)
self.assertEqual(self.processes[0].returncode, 0)
def test_cancel_reaps_process_and_discards_result(self):
start = time.monotonic()
with self.launch('import time; time.sleep(30)'):
with self.assertRaises(ImportCancelled):
dslogic.acquire(self.options, lambda: False, lambda: time.monotonic()-start > .1)
self.assertIsNotNone(self.processes[0].poll())
def test_native_failure_does_not_hang(self):
with self.launch('import sys; sys.stderr.write("native failure"); sys.exit(7)'):
with self.assertRaisesRegex(RuntimeError, '7'):
dslogic.acquire(self.options, lambda: False, lambda: False)
def test_error_result_is_not_a_capture(self):
code = "import sys,json; from pathlib import Path; (Path(sys.argv[1])/'result.json').write_text(json.dumps({'error':'USB disconnected'})); sys.exit(1)"
with self.launch(code):
with self.assertRaisesRegex(RuntimeError, 'USB disconnected'):
dslogic.acquire(self.options, lambda: False, lambda: False)

View File

@@ -0,0 +1,96 @@
from decimal import Decimal
from pathlib import Path
import tempfile
import unittest
import zipfile
from logic_analyzer.csv_import import convert_csv
from logic_analyzer.files import ImportCancelled, read_capture
class CsvImportTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.source = Path(self.temp.name) / 'сигналы.csv'
self.output = Path(self.temp.name) / 'сигналы.dsl'
def write(self, value):
self.source.write_text(value, encoding='utf-8-sig')
def test_regular_samples_preserve_levels_and_names(self):
self.write('Time [ns],Vin,Vstat\n0,0,1\n10,1,0\n20,0,1\n')
original = self.source.read_bytes()
info = convert_csv(self.source, self.output)
result = read_capture(self.output)
self.assertEqual(info['samplerate'], 100_000_000)
self.assertEqual([c.name for c in result.channels], ['Vin', 'Vstat'])
self.assertEqual(list(result.channels[0].edges), [1e-8, 2e-8])
self.assertEqual([result.channels[1].level_at(t) for t in [0, 1e-8, 2e-8, 5e-7]], [1, 0, 1, 1])
self.assertEqual(self.source.read_bytes(), original)
def test_sparse_transitions_preserve_short_pulses_across_blocks(self):
self.write('Time [ns],A\n0,0\n10,1\n650,0\n680,1\n710,0\n')
convert_csv(self.source, self.output, block_samples=64)
capture = read_capture(self.output)
self.assertEqual(list(capture.channels[0].edges), [1e-8, 6.5e-7, 6.8e-7, 7.1e-7])
with zipfile.ZipFile(self.output) as archive:
self.assertIn('L-0/1', archive.namelist())
self.assertEqual(len(archive.read('L-0/0')), 8)
def test_initial_snapshot_need_not_be_on_edge_clock(self):
self.write('Time [s];A\n127,632374014;0\n127,632482000;1\n127,632482400;0\n127,632483000;1\n')
info = convert_csv(self.source, self.output)
self.assertEqual(info['samplerate'], 5_000_000)
self.assertEqual(Decimal(info['origin_seconds']), Decimal('127.632374'))
capture = read_capture(self.output)
edges = capture.channels[0].edges
self.assertAlmostEqual(edges[1] - edges[0], 400e-9, places=14)
self.assertAlmostEqual(edges[2] - edges[1], 600e-9, places=14)
def test_comments_tab_delimiter_and_negative_origin(self):
self.write('; DSView export\n# data\nTime(us)\tКанал\n-1\t1\n0\t0\n1\t1\n')
info = convert_csv(self.source, self.output)
self.assertEqual(info['origin_seconds'], '-0.000001')
self.assertEqual(read_capture(self.output).channels[0].name, 'Канал')
def test_bad_csv_leaves_no_output(self):
for rows in ('0,0\n1,3.3', '0,0\n0,1', 'nan,0\n1,1', '0,0', '0,0,1\n1,1,0'):
with self.subTest(rows=rows):
self.write('Time [s],A\n' + rows + '\n')
with self.assertRaises(ValueError):
convert_csv(self.source, self.output)
self.assertFalse(self.output.exists())
def test_output_is_not_overwritten_and_size_limit_precedes_expansion(self):
self.write('Time [ns],A\n0,0\n10,1\n100000,0\n')
with self.assertRaises(ValueError):
convert_csv(self.source, self.output, max_packed_bytes=64)
self.output.write_bytes(b'old recording')
with self.assertRaises(ValueError):
convert_csv(self.source, self.output)
self.assertEqual(self.output.read_bytes(), b'old recording')
def test_cancel_during_packing_cleans_temporary_output(self):
self.write('Time [ns],A\n0,0\n10,1\n100000,0\n')
cancelled = False
def progress(value):
nonlocal cancelled
if value > 40:
cancelled = True
with self.assertRaises(ImportCancelled):
convert_csv(self.source, self.output, progress, lambda: cancelled, block_samples=64)
self.assertFalse(self.output.exists())
self.assertEqual([p.name for p in Path(self.temp.name).iterdir()], [self.source.name])
def test_changed_source_rejected_before_publication(self):
self.write('Time [ns],A\n0,0\n10,1\n20,0\n')
changed = False
def progress(value):
nonlocal changed
if value == 99 and not changed:
changed = True
self.source.write_text('changed', encoding='utf-8')
with self.assertRaisesRegex(ValueError, 'изменился'):
convert_csv(self.source, self.output, progress)
self.assertFalse(self.output.exists())