Add DSLogic capture, signal conversion and DSView decoders
This commit is contained in:
@@ -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)
|
||||
|
||||
255
python/logic_analyzer/conversion.py
Normal file
255
python/logic_analyzer/conversion.py
Normal 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)
|
||||
211
python/logic_analyzer/csv_import.py
Normal file
211
python/logic_analyzer/csv_import.py
Normal 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)
|
||||
132
python/logic_analyzer/csv_options.py
Normal file
132
python/logic_analyzer/csv_options.py
Normal 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()
|
||||
@@ -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 []
|
||||
|
||||
56
python/logic_analyzer/decoders/transistor_pair.py
Normal file
56
python/logic_analyzer/decoders/transistor_pair.py
Normal 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
|
||||
205
python/logic_analyzer/dslogic.py
Normal file
205
python/logic_analyzer/dslogic.py
Normal 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
|
||||
106
python/logic_analyzer/dslogic_trigger.py
Normal file
106
python/logic_analyzer/dslogic_trigger.py
Normal 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)
|
||||
242
python/logic_analyzer/dsview_helper.py
Normal file
242
python/logic_analyzer/dsview_helper.py
Normal 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()
|
||||
87
python/logic_analyzer/dsview_host.py
Normal file
87
python/logic_analyzer/dsview_host.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
187
python/logic_analyzer/sal_metadata.py
Normal file
187
python/logic_analyzer/sal_metadata.py
Normal 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)
|
||||
59
python/tests/test_dslogic_trigger.py
Normal file
59
python/tests/test_dslogic_trigger.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
104
python/tests/test_logic_analyzer_dslogic.py
Normal file
104
python/tests/test_logic_analyzer_dslogic.py
Normal 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)
|
||||
96
python/tests/test_logic_csv_import.py
Normal file
96
python/tests/test_logic_csv_import.py
Normal 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())
|
||||
Reference in New Issue
Block a user