Add DSLogic capture, signal conversion and DSView decoders

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

173
tools/dsview/README.md Normal file
View File

@@ -0,0 +1,173 @@
# DSView as an embedded Windows instrument panel
Upstream: https://github.com/DreamSourceLab/DSView, revision `2e9e2c8e`.
The GPL-3.0-or-later application and its private Qt 5/Python runtime stay in
a separate process. `setgui-host.patch` adds the Windows host protocol and
fixes the missing Windows CMake inputs. No DSView DLL is loaded into Qt 6.
`python/logic_analyzer/dsview_host.py` contains the reusable environment,
process/window ownership and close-message helpers. The consuming GUI owns
the stylesheet, window surface and lifecycle.
## Build
MSYS2 UCRT64 packages (install with `pacman -S --needed`):
```text
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake
mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-qt5-base mingw-w64-ucrt-x86_64-qt5-svg
mingw-w64-ucrt-x86_64-qt5-winextras mingw-w64-ucrt-x86_64-glib2
mingw-w64-ucrt-x86_64-libusb mingw-w64-ucrt-x86_64-fftw
mingw-w64-ucrt-x86_64-boost mingw-w64-ucrt-x86_64-python
```
```powershell
python tools/dsview/build.py --source C:/path/to/DSView --output C:/path/to/runtime --msys-root C:/msys64
```
The builder checks the revision, applies the patch only after `git apply
--check`, and accepts an already applied patch. It never resets the source
checkout. Close the built DSView before rebuilding.
Successful builds write `host-build.json`, recording the recipe digest (patch,
packager, adapters and shared cores) and the native EXE digest. Consumers can
call `runtime_is_current(output)` before packaging to detect an old component
even if the host-protocol version is unchanged. SETGUI's normal EXE builder
automatically rebuilds an installed stale runtime and refuses to package it if
the update fails. A runtime built before this stamp was added is rebuilt once.
## Host protocol 1
- Create a native child surface owned by the host process.
- Start `DSView.exe --embed-parent <decimal HWND>` with its private runtime
as working directory and a sanitized environment.
- `DSVIEW_HOST_STYLE` points to the UTF-8 Qt stylesheet; `DSVIEW_CAPTURE_DIR`
supplies the initial open/save/export directory.
- Wait for `DSVIEW_READY <HWND>` on stdout, then validate both the window PID
and its parent before accepting it.
- The child fits the surface, inherits visibility, and exits if the parent
window disappears. It uses separate settings under `SET/SETGUI-DSView`.
- Write `CLOSE\n` to the child's stdin to close normally. Keep the host
alive while DSView's save dialog is open. Forced termination is only a
fallback for host shutdown or a failed start.
- Write `ADD_DECODER <id>\n` to open the normal decoder configuration dialog.
The host must be ready, in digital mode and not capturing. Stacked decoders
automatically include the matching UART/CAN base decoder.
- New hosts announce `DSVIEW_CAPABILITIES OPEN_FILE` after the ready message.
`OPEN_FILE <base64 UTF-8 path>\n` opens a DSL recording without restarting
the child. It returns `DSVIEW_FILE_RESULT ok|busy|save|error`. A capture,
save operation or modal dialog prevents replacement. Unsaved hardware data
retains the normal save prompt.
- In embedded mode File в†’ Open accepts CSV and emits
`DSVIEW_IMPORT_CSV <base64 UTF-8 path>` for conversion by SETGUI. The shared
`python/logic_analyzer/csv_import.py` streams CSV into temporary DSL v3
blocks in two passes, with progress/cancellation and bounded memory.
Times are read as decimal picoseconds; front-to-front intervals are exact.
The initial state may extend by less than one sample to align the grid;
the final state pads to 64 samples. Original offset metadata is retained.
- Embedded mode disables Qt's quit-on-last-window policy: the child is a
`Qt::Tool`, so accepting/rejecting a modal dialog must not quit the process.
- `--host-version` returns `DSView host protocol 1`. An optional
`DSVIEW_HOST_SCREENSHOT` environment variable saves one render artifact
after startup for native integration tests.
Tested SDK: GCC 16.2, Qt 5.15.19, Python 3.14.7, Windows x64. This runtime
requires Windows 10/11; it does not add Windows 7 support.
## SET decoders
`decoders/` contains the sigrok front ends for `gate_driver_timing` (1SP0635 /
1SD536F2), `set_uart` (SET v1/v2, ProtoCAN bridge), `set_can` (ProtoCAN, SET v2,
Balsam), and `pm35_uart` (TMS320F28335 MODBUS 03/06).
Their parsing cores are maintained only in `python/logic_analyzer/decoders`.
The builder copies those files verbatim to `decoders/common/setgui_decoders`
inside the runtime. No GUI imports or external Python installation are needed.
The adapters originate from `DSLogic_Logic_2/dslogic_script/dsview_decoders`.
IGBT offers `ORPHAN minimum pulse width (ns, 0 = off)` for all profiles.
Only uncorrelated pulses strictly shorter than this duration are omitted from
annotations; zero preserves the original behavior. ACK measurements/errors,
FAULT and lone edges with unknown pulse duration remain visible. This does
not modify the captured signal or debounce Vin1/Vstat.
The independent **Transistor pair** decoder measures mintime, OFF time,
deadtime in both directions, overlap, frequency and duty. It requires only
Vin1/Vin2. **IGBT 1SP/1SD** retains Vin1/Vstat ACK and full-cycle checks.
See [pair settings](decoders/transistor_pair/README.md) and
[driver settings](decoders/gate_driver_timing/README.md).
Run `python -m unittest discover -s tools/dsview/tests` for packet, CRC,
segmentation, timing-profile and sigrok API boundary tests. SETGUI's
`--check-dsview` sets `DSVIEW_HOST_CHECK` to a report file and exercises native
decoder dialogs, single/all removal with Yes/No, stacking and child lifetime.
The check saves decoder dialog screenshots alongside the report.
## Annotation search
The patch searches every enabled result column of the selected decoder and
every annotation label, including short waveform labels such as `ORPHAN`.
Matching is case insensitive; warning columns are enabled by default. Explicitly
hidden columns remain excluded until enabled in Decoding Results settings.
Enter (including the Windows popup editor), the magnifier and the next arrow
navigate forward; the previous arrow navigates backward. Matches are counted
per annotation, ordered by sample time, and wrap around. Tooltips show all
label variants. A nonempty query displays a compact match-only table: one
annotation per row, with its row title and matching label. Clearing the query
restores the source table; a query with no matches displays an empty list.
Click/arrow navigation maps each result back to the original annotation.
Numeric `AA-BB-CC` sequences are still supported; other hyphens
are literal. Search runs in short GUI-thread timer steps, invalidates indices
on model resets, and refreshes when decoding reaches 100 percent.
After rebuilding, verify with an IGBT recording containing an uncorrelated
Vstat pulse: search `ORPHAN`, `orphan`, and `Unexpected Vstat`; each should
select the same warning and show its waveform region. Also check Enter from
the popup editor, magnifier/arrow wraparound, a missing query, clearing/changing
the query during a long search, changing columns/decoders, and removing the
decoder while searching. Confirm numeric sequences and literal `CAN-FD` labels
still work. Source syntax checks do not replace this native GUI check.
## Install analyzers into standalone DSView (Windows)
The adapters in `tools/dsview/decoders` and portable cores in
`python/logic_analyzer/decoders` are shared with the embedded build.
No analyzer implementation is maintained in the consuming GUI.
```powershell
.\tools\dsview\install_decoders.ps1 -DsViewPath 'C:\Program Files\DSView'
```
Close DSView first. An elevated PowerShell is needed if the installation
folder is protected. Add `-CheckOnly` to validate source files and the target
layout without writing. Python/MSYS2 and a DSView rebuild are not required.
The target must contain DSView.exe and decoders/common.
Installs gate_driver_timing, set_uart, set_can and pm35_uart, with unchanged
cores under decoders/common/setgui_decoders. Existing unrelated files are
preserved. Replaced files are backed up under set-decoder-backups next to
DSView.exe; failed copies trigger rollback. Keep backups for manual recovery.
To restore, close DSView and copy the backup contents over decoders.
Restart DSView, add the UART or CAN base decoder and stack SET/PM35 on top.
Gate timing consumes logic channels directly. This installs decoders only;
it does not install the SETGUI host patch, menus or CSV import.
Installer tests run under Windows PowerShell against temporary targets and
check isolated imports, updates, backups, copy-failure rollback and validation:
```powershell
python -m unittest discover -s tools/dsview/tests -p test_install_decoders.py -v
```
## Decoder panel and selector
Embedded DSView does not restore standalone toolbar/dock layout. The Decoders
panel can extend to the top row of the DSView controls; its upper splitter
still allows reducing its height. SETGUI's own folder and tab rows remain
outside the native component.
The decoder selector starts with **Пользовательские**: IGBT 1SP/1SD,
Transistor pair, SET UART, SET CAN and PM35 UART. **Стандартные** contains the remaining
decoders. Search matches names and IDs in both sections and hides empty
section headings. The changes are packaged in `decoder-panel.patch`.

137
tools/dsview/build.py Normal file
View File

@@ -0,0 +1,137 @@
"""Build and package DSView host protocol 1 using an existing MSYS2 UCRT64 SDK."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
REVISION = '2e9e2c8e'
def recipe_digest():
"""Identify the patch, packaging recipe, adapters and their shared cores."""
recipe = Path(__file__).resolve().parent
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
inputs = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h']
inputs += sorted((recipe / 'decoders').rglob('*.py'))
inputs += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
digest = hashlib.sha256()
for item in inputs:
digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0')
digest.update(hashlib.sha256(item.read_bytes()).digest())
return digest.hexdigest()
def runtime_is_current(output):
output = Path(output)
try:
stamp = json.loads((output / 'host-build.json').read_text(encoding='utf-8'))
return (stamp.get('recipe') == recipe_digest()
and stamp.get('executable') == hashlib.sha256((output / 'DSView.exe').read_bytes()).hexdigest()
and (output / 'host-protocol-1').read_text(encoding='utf-8').strip() == 'DSView host protocol 1')
except (OSError, ValueError, AttributeError):
return False
def package_decoders(output):
"""Stage sigrok adapters and the canonical parsing cores without forks."""
recipe = Path(__file__).resolve().parent
decoders = Path(output) / 'decoders'
shutil.copytree(recipe / 'decoders', decoders, dirs_exist_ok=True,
ignore=shutil.ignore_patterns('__pycache__'))
common = decoders / 'common/setgui_decoders'
common.mkdir(parents=True, exist_ok=True)
(common / '__init__.py').write_text('', encoding='utf-8')
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
shutil.copy2(core / (name + '.py'), common / (name + '.py'))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--msys-root', type=Path, default=Path(r'C:\setcorp\tools\msys64'))
args = parser.parse_args()
source, output = args.source.resolve(), args.output.resolve()
prefix = args.msys_root.resolve() / 'ucrt64'
if not (prefix / 'bin/cmake.exe').is_file():
raise SystemExit('Install the MSYS2 UCRT64 dependencies listed in README.md first.')
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=source, text=True).strip()
if not revision.startswith(REVISION):
raise SystemExit('Expected DSView revision ' + REVISION + ', got ' + revision)
for retired_name in ('driver-summary.patch', 'gate-pair-checkbox.patch'):
retired = Path(__file__).with_name(retired_name)
if subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(retired)],
cwd=source, capture_output=True).returncode == 0:
subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', str(retired)], cwd=source, check=True)
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
patch = Path(__file__).with_name(patch_name)
applied = subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(patch)], cwd=source, capture_output=True)
if applied.returncode:
subprocess.run(['git', 'apply', '--ignore-space-change', '--check', str(patch)], cwd=source, check=True)
subprocess.run(['git', 'apply', '--ignore-space-change', str(patch)], cwd=source, check=True)
env = dict(os.environ)
env['PATH'] = os.pathsep.join([str(prefix/'bin'), str(args.msys_root/'usr/bin'), env.get('PATH', '')])
env['PKG_CONFIG_PATH'] = str(prefix/'lib/pkgconfig')
build = source/'build-win64'
subprocess.run([str(prefix/'bin/cmake.exe'), '-S', str(source), '-B', str(build), '-G', 'Ninja',
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5', '-DCMAKE_CXX_STANDARD=11',
'-DCMAKE_POLICY_DEFAULT_CMP0167=OLD', '-DCMAKE_PREFIX_PATH='+prefix.as_posix()],
env=env, check=True)
subprocess.run([str(prefix/'bin/cmake.exe'), '--build', str(build), '--parallel', '6'], env=env, check=True)
output.mkdir(parents=True, exist_ok=True)
shutil.copy2(source/'build.dir/DSView.exe', output/'DSView.exe')
for relative in ('DSView/res', 'DSView/demo', 'libsigrokdecode4DSL/decoders', 'lang'):
item = source/relative
shutil.copytree(item, output/item.name, dirs_exist_ok=True, ignore=shutil.ignore_patterns('__pycache__'))
package_decoders(output)
for name in ('COPYING', 'NEWS25', 'NEWS31', 'ug25.pdf', 'ug31.pdf'):
shutil.copy2(source/name, output/name)
stdlib = sorted((prefix/'lib').glob('python3.*'))
stdlib = [item for item in stdlib if (item/'encodings').is_dir()]
if len(stdlib) != 1:
raise RuntimeError('Expected exactly one Python standard library in UCRT64')
shutil.copytree(stdlib[0], output/'lib'/stdlib[0].name, dirs_exist_ok=True,
ignore=shutil.ignore_patterns('__pycache__', 'site-packages', 'test', 'tests', 'idlelib', 'tkinter', 'ensurepip'))
for name in ('platforms', 'imageformats', 'iconengines', 'styles'):
shutil.copytree(prefix/'share/qt5/plugins'/name, output/name, dirs_exist_ok=True)
shutil.copytree(prefix/'share/licenses', output/'licenses', dirs_exist_ok=True)
(output/'qt.conf').write_text('[Paths]\nPrefix=.\nPlugins=.\n', encoding='utf-8')
queue = [p for p in output.rglob('*') if p.suffix.lower() in ('.exe', '.dll', '.pyd')]
seen = set()
while queue:
binary = queue.pop()
if binary in seen:
continue
seen.add(binary)
info = subprocess.check_output([str(prefix/'bin/objdump.exe'), '-p', str(binary)], env=env, text=True, errors='replace')
for dll in re.findall(r'DLL Name:\s*(\S+)', info):
dependency, target = prefix/'bin'/dll, output/dll
if dependency.is_file():
if target not in seen:
shutil.copy2(dependency, target)
queue.append(target)
elif not (Path(os.environ['SystemRoot'])/'System32'/dll).is_file() and not dll.lower().startswith(('api-ms-', 'ext-ms-')):
raise RuntimeError('Missing dependency: ' + dll)
check_env = dict(os.environ)
check_env['PATH'] = str(Path(os.environ['SystemRoot'])/'System32')
version = subprocess.check_output([str(output/'DSView.exe'), '--host-version'], env=check_env, text=True).strip()
if version != 'DSView host protocol 1':
raise RuntimeError('Host protocol self-check failed: ' + version)
(output/'host-protocol-1').write_text(version+'\n', encoding='utf-8')
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
shutil.copy2(Path(__file__).with_name(patch_name), output/patch_name)
(output/'SOURCE.txt').write_text('https://github.com/DreamSourceLab/DSView\nCommit: '+revision+'\nPatches: setgui-host.patch, decoder-panel.patch, windows-usb-events.patch\nLicense: GPL-3.0-or-later (COPYING)\n', encoding='utf-8')
(output/'host-build.json').write_text(json.dumps({
'recipe': recipe_digest(),
'executable': hashlib.sha256((output/'DSView.exe').read_bytes()).hexdigest(),
}, indent=2) + '\n', encoding='utf-8')
print('DSView runtime:', output)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,119 @@
--- a/DSView/pv/dock/searchcombobox.h
+++ b/DSView/pv/dock/searchcombobox.h
@@ -58,6 +58,7 @@
public:
QString _id;
QString _name;
+ QString _section;
void *_data_handle;
QWidget *_control;
};
@@ -74,7 +75,7 @@
void ShowDlg(QWidget *editline);
- void AddDataItem(QString id, QString name, void *data_handle);
+ void AddDataItem(QString id, QString name, void *data_handle, QString section = QString());
inline void SetItemClickHandle(ISearchItemClick *click){
_item_click = click;
@@ -95,6 +96,7 @@
std::vector<SearchDataItem*> _items;
ISearchItemClick *_item_click;
QScrollArea *_scroll;
+ std::vector<std::pair<QString, QWidget*> > _sections;
};
#endif // SEARCHCOMBOBOX_H
--- a/DSView/pv/dock/searchcombobox.cpp
+++ b/DSView/pv/dock/searchcombobox.cpp
@@ -25,6 +25,7 @@
#include <QPoint>
#include <QLineEdit>
#include <QScrollBar>
+#include <QLabel>
#include "../config/appconfig.h"
#include "../appcontrol.h"
#include "../ui/fn.h"
@@ -107,8 +108,17 @@
QFont font = this->font();
font.setPointSizeF(AppConfig::Instance().appOptions.fontSize);
+ QString section;
for(auto o : _items)
- {
+ {
+ if (!o->_section.isEmpty() && o->_section != section) {
+ section = o->_section;
+ auto heading = new QLabel(section, listPanel);
+ heading->setFont(font);
+ heading->setStyleSheet("font-weight: bold; padding: 8px 4px; border-top: 1px solid #344762;");
+ listLay->addWidget(heading);
+ _sections.push_back(std::make_pair(section, heading));
+ }
ComboButtonItem *bt = new ComboButtonItem(panel, this, o);
bt->setText(o->_name);
bt->setObjectName("flat");
@@ -143,11 +153,12 @@
this->show();
}
-void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle)
+void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle, QString section)
{
SearchDataItem *item = new SearchDataItem();
item->_id = id;
item->_name = name;
+ item->_section = section;
item->_data_handle = data_handle;
this->_items.push_back(item);
}
@@ -197,5 +208,14 @@
}
}
+ for (auto section : _sections) {
+ bool visible = false;
+ for (auto item : _items)
+ if (item->_section == section.first && !item->_control->isHidden()) {
+ visible = true;
+ break;
+ }
+ section.second->setVisible(visible);
+ }
_scroll->verticalScrollBar()->setValue(0);
}
--- a/DSView/pv/dock/protocoldock.cpp
+++ b/DSView/pv/dock/protocoldock.cpp
@@ -1060,10 +1060,15 @@
{
SearchComboBox *panel = new SearchComboBox(this);
- for (auto info : _decoderInfoList)
- {
- srd_decoder *dec = (srd_decoder *)(info->_data_handle);
- panel->AddDataItem(QString(dec->id), QString(dec->name), info);
+ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart"};
+ for (bool custom : {true, false}) {
+ for (auto info : _decoderInfoList) {
+ srd_decoder *dec = (srd_decoder *)(info->_data_handle);
+ if (customIds.contains(QString(dec->id)) != custom)
+ continue;
+ panel->AddDataItem(QString(dec->id), QString(dec->name), info,
+ custom ? QString::fromUtf8("Пользовательские") : QString::fromUtf8("Стандартные"));
+ }
}
QFont font = this->font();
--- a/DSView/pv/mainwindow.cpp
+++ b/DSView/pv/mainwindow.cpp
@@ -1329,7 +1329,8 @@
// default dockwidget size
AppConfig &app = AppConfig::Instance();
QByteArray st = app.frameOptions.windowState;
- if (!st.isEmpty())
+ // Standalone toolbar rows must not reserve space above embedded docks.
+ if (!qApp->property("embeddedHost").toBool() && !st.isEmpty())
{
try
{

View File

@@ -0,0 +1,60 @@
# 1SP0635 / 1SD536F2 timing decoder for DSView
This decoder checks driver control and status responses:
- `Vin1`: optional control sent to the first gate driver (legacy channel ID `vin`);
- `Vstat`: edge acknowledgement and fault feedback returned by the driver.
Built-in data-sheet profiles:
| Profile | ACK delay (typ.) | ACK width (specified) | Long pulse |
|---|---:|---:|---:|
| 1SP0635 | 250 ns | 400...1050 ns | fault above 1.5 us |
| 1SD536F2 | 380 ns | 600...1800 ns | fault above 1.8 us* |
`*` The 1.8 us fault threshold is a project classification choice based on the
data-sheet ACK maximum, not a verified universal manufacturer fault threshold.
The previously stated 1.5 us manual threshold could not be verified.
Correlated pulses within the ACK width limits pass. Uncorrelated pulses up to
the fault threshold are warnings; pulses above it are faults. Width and fault
annotations are emitted only when the pulse ends.
The data sheets specify only a *typical* ACK delay, not minimum and maximum
limits. DSView therefore emits a warning outside the configurable tolerance
(default +/-100 ns). The ACK-width check uses the published min/max values.
## Use in SETGUI
The DSView build recipe packages this adapter together with the canonical
`python/logic_analyzer/decoders/gate_timing.py` core. In the DSView tab,
choose **Анализаторы SET → IGBT · 1SP0635 / 1SD536F2** (or search Decode for
`IGBT 1SP/1SD`). Assign Vstat and, if available, Vin1, choose the driver profile, and set `Vstat acknowledge/fault level`
to match the receiver circuit. The diagrams in the application manual show
the logical event; some optical receiver circuits invert it.
Use at least 50 MS/s; 100 MS/s or more is recommended. Never connect a DSLogic
digital input directly to the IGBT gate (+15/-10 V) or to a power-stage node.
Use the isolated optical receiver logic signals and a common logic ground.
## Separate transistor pair analyzer
This decoder analyzes only Vin1/Vstat driver responses. Vin1 is optional;
Vstat alone retains pulse/fault classification, while Vin1 enables ACK timing
and full-cycle results. Vstat is required. The channel ID `vin` remains stable.
Pair analysis was moved to **Transistor pair** in the **Пользовательские**
section. Add that decoder separately and assign Vin1/Vin2. Old pair settings
on IGBT instances must be recreated in the new decoder. The pair checkbox,
Vin2 assignment and pulse limits are no longer part of the driver decoder.
## Full pulse cycle result
The **Vin1 / ACK full cycle** waveform row displays OK, WARNING or FAULT
across each complete sequence **Vin1 ON -> ACK -> Vin1 OFF -> ACK**.
The annotation starts at Vin1 ON and ends at the end of the OFF acknowledgment.
It is emitted only after both ACKs have been classified. A delay warning makes
that cycle WARNING; missing ACK, invalid width or fault feedback makes it FAULT.
A later normal cycle can be OK independently of previous cycles.
Unfinished cycles are never marked OK. Active-low Vin1 is supported.
The results panel retains its original layout without a large summary banner.
Deadtime and mintime are provided by the separate Transistor pair analyzer.

View File

@@ -0,0 +1,3 @@
"""DSView protocol decoder for SCALE gate-driver timing checks."""
from .pd import Decoder

View File

@@ -0,0 +1,168 @@
"""DSView/libsigrokdecode front end for SCALE gate-driver timing checks."""
import sigrokdecode as srd
from common.setgui_decoders.gate_timing import TimingChecker
class SamplerateError(Exception):
pass
class Ann(object):
CONTROL = 0
DELAY_OK = 1
WIDTH_OK = 2
WARNING = 3
ERROR = 4
FAULT = 5
CYCLE_OK = 6
CYCLE_WARNING = 7
CYCLE_FAULT = 8
ANN_FOR_KIND = {
'control': Ann.CONTROL,
'delay_ok': Ann.DELAY_OK,
'width_ok': Ann.WIDTH_OK,
'delay_warn': Ann.WARNING,
'width_fail': Ann.ERROR,
'missing': Ann.ERROR,
'orphan': Ann.WARNING,
'fault': Ann.FAULT,
'cycle_ok': Ann.CYCLE_OK,
'cycle_warning': Ann.CYCLE_WARNING,
'cycle_fault': Ann.CYCLE_FAULT,
}
class Decoder(srd.Decoder):
api_version = 3
id = 'gate_driver_timing'
name = 'IGBT 1SP/1SD'
longname = 'Power Integrations 1SP0635 / 1SD536F2 timing checker'
desc = 'Checks driver Vin1/Vstat ACK timing, faults and full pulse cycles.'
license = 'gplv2+'
inputs = ['logic']
outputs = []
tags = ['Clock/timing', 'Util']
channels = ()
optional_channels = (
{'id': 'vin', 'name': 'Vin1', 'desc': 'First driver control input'},
{'id': 'vstat', 'name': 'Vstat', 'desc': 'Driver status/acknowledge output'},
)
options = (
{'id': 'profile', 'desc': 'Driver profile', 'default': '1SP0635',
'values': ('1SP0635', '1SD536F2', 'custom')},
{'id': 'vin_active', 'desc': 'Vin1 active level', 'default': 'high',
'values': ('high', 'low')},
{'id': 'vstat_active', 'desc': 'Vstat acknowledge/fault level', 'default': 'high',
'values': ('high', 'low')},
{'id': 'delay_tolerance_ns',
'desc': 'Warning tolerance around typical ACK delay (ns)', 'default': 100},
{'id': 'orphan_min_width_ns',
'desc': 'ORPHAN minimum pulse width (ns, 0 = off)', 'default': 0},
{'id': 'custom_delay_ns', 'desc': 'Custom typical ACK delay (ns)', 'default': 250},
{'id': 'custom_width_min_ns', 'desc': 'Custom ACK width minimum (ns)', 'default': 400},
{'id': 'custom_width_typ_ns', 'desc': 'Custom ACK width typical (ns)', 'default': 700},
{'id': 'custom_width_max_ns', 'desc': 'Custom ACK width maximum (ns)', 'default': 1050},
{'id': 'custom_fault_ns', 'desc': 'Custom fault pulse threshold (ns)', 'default': 1500},
)
annotations = (
('control', 'Vin1 edge'),
('delay-ok', 'ACK delay pass'),
('width-ok', 'ACK width pass'),
('warning', 'Warning'),
('error', 'Timing failure'),
('fault', 'Driver fault'),
('cycle-ok', 'Cycle OK'),
('cycle-warning', 'Cycle WARNING'),
('cycle-fault', 'Cycle FAULT'),
)
annotation_rows = (
('cycle', 'Vin1 / ACK full cycle', (Ann.CYCLE_OK, Ann.CYCLE_WARNING, Ann.CYCLE_FAULT)),
('vin', 'Vin1', (Ann.CONTROL,)),
('measurements', 'Measurements', (Ann.DELAY_OK, Ann.WIDTH_OK)),
('checks', 'Warnings / failures', (Ann.WARNING, Ann.ERROR, Ann.FAULT)),
)
def __init__(self):
self.reset()
# Состояние принадлежит одному запуску декодера. Сброс не должен
# переносить незавершённый кадр или частоту из предыдущей записи.
def reset(self):
self.samplerate = None
self.checker = None
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
# для перевода тайм-аутов и измерений в физические единицы.
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
self.samplerate = value
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
# Здесь регистрируются аннотации для дорожек измерений и предупреждений.
def start(self):
self.out_ann = self.register(srd.OUTPUT_ANN)
# Адаптер переводит настройки DSView в параметры общего TimingChecker.
# Пороговые алгоритмы остаются в templates/python/logic_analyzer/decoders.
def _make_checker(self):
custom = {
'ack_delay_typ_ns': float(self.options['custom_delay_ns']),
'ack_width_min_ns': float(self.options['custom_width_min_ns']),
'ack_width_typ_ns': float(self.options['custom_width_typ_ns']),
'ack_width_max_ns': float(self.options['custom_width_max_ns']),
'fault_threshold_ns': float(self.options['custom_fault_ns']),
}
return TimingChecker(
self.samplerate,
profile=self.options['profile'],
delay_tolerance_ns=float(self.options['delay_tolerance_ns']),
vin_active_high=self.options['vin_active'] == 'high',
vstat_active_high=self.options['vstat_active'] == 'high',
orphan_min_width_ns=self.options.get('orphan_min_width_ns', 0),
custom=custom, cycle_results=True)
def _put_events(self, events):
for event in events:
ann = ANN_FOR_KIND[event['kind']]
self.put(event['start'], max(event['start'], event['end']),
self.out_ann, [ann, [event['text'], event['short']]])
def decode(self):
if not self.samplerate:
raise SamplerateError('Cannot decode without samplerate.')
self.checker = self._make_checker()
has_vin1 = self.has_channel(0)
if not self.has_channel(1):
raise ValueError('Connect Vstat for driver response analysis.')
while True:
conditions = [{0: 'e'}, {1: 'e'}] if has_vin1 else [{1: 'e'}]
timer_bit = 1 << len(conditions)
deadline = self.checker.next_deadline()
if deadline is not None:
conditions.append({'skip': max(1, deadline - self.samplenum)})
# DSView mutates and returns the same native tuple on every wait.
# Release that tuple before waiting again (PyTuple_SetItem requires
# exclusive ownership); tuple(...) would not make a copy.
pins = list(self.wait(conditions))
vin, vstat = pins[:2]
now = self.samplenum
# Both channels may transition at the same sample; preserve both.
if has_vin1 and self.matched & 0b001:
self._put_events(self.checker.on_control_edge(now, vin))
if self.matched & (0b010 if has_vin1 else 0b001):
self._put_events(self.checker.on_status_edge(now, vstat))
if deadline is not None and self.matched & timer_bit:
self._put_events(self.checker.expire(now))
def end(self):
if self.checker is not None:
self._put_events(self.checker.finish_cycles(self.samplenum))

View File

@@ -0,0 +1 @@
from .pd import Decoder

View File

@@ -0,0 +1,82 @@
import sigrokdecode as srd
from common.setgui_decoders.pm35_uart import PM35Parser
class Decoder(srd.Decoder):
api_version = 3
id = 'pm35_uart'
name = 'PM35 UART'
longname = 'PM35 / TMS320F28335 terminal'
desc = 'MODBUS 03/06 register traffic and CRC16.'
license = 'gplv2+'
inputs = ['uart']
outputs = ['pm35']
tags = ['Embedded/industrial']
options = (
{'id': 'rx_role', 'desc': 'RX carries', 'default': 'response', 'values': ('request', 'response', 'auto')},
{'id': 'tx_role', 'desc': 'TX carries', 'default': 'request', 'values': ('request', 'response', 'auto')},
{'id': 'baudrate', 'desc': 'UART baudrate (RTU gap detection)', 'default': 115200},
{'id': 'bits_per_char', 'desc': 'Bits per UART character including start/stop/parity', 'default': 10},
)
annotations = (('frame', 'Frame'), ('error', 'Error'))
annotation_rows = (('frames', 'Frames', (0,)), ('errors', 'Errors', (1,)))
def __init__(self):
self.reset()
# Состояние принадлежит одному запуску декодера. Сброс не должен
# переносить незавершённый кадр или частоту из предыдущей записи.
def reset(self):
self.parsers = {}
self.last = {}
self.samplerate = None
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
# для перевода тайм-аутов и измерений в физические единицы.
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
self.samplerate = value
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
# Аннотации предназначены для дорожек, структурированные события — для стека.
def start(self):
if self.options['baudrate'] <= 0 or self.options['bits_per_char'] <= 0:
raise ValueError('Baudrate and bits per character must be positive')
self.out_ann = self.register(srd.OUTPUT_ANN)
self.out_python = self.register(srd.OUTPUT_PYTHON)
def emit(self, events, direction):
for ss, es, result, error in events:
self.put(ss, es, self.out_ann, [1 if error else 0,
[('RX', 'TX')[direction] + ' ' + (error or result['summary'])]])
if result:
result['direction'] = direction
self.put(ss, es, self.out_python, ['FRAME', result])
def decode(self, ss, es, data):
kind, direction, payload = data
if direction not in (0, 1):
return
parser = self.parsers.setdefault(direction, PM35Parser(self.options[('rx_role', 'tx_role')[direction]]))
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [1, [kind]])
return
if kind != 'FRAME':
return
if not payload[1]:
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [1, ['Invalid UART frame']])
return
# Граница MODBUS определяется паузой 3,5 символа. bits_per_char включает
# все биты символа; эта настройка должна соответствовать базовому UART.
if self.samplerate and direction in self.last:
gap = self.samplerate * 3.5 * self.options['bits_per_char'] / self.options['baudrate']
if ss - self.last[direction] >= gap:
self.emit(parser.flush(), direction)
self.last[direction] = es
if not 0 <= payload[0] <= 255:
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [1, ['Configure UART for 8 data bits']])
return
self.emit(parser.feed(payload[0], ss, es), direction)

View File

@@ -0,0 +1 @@
from .pd import Decoder

View File

@@ -0,0 +1,66 @@
import sigrokdecode as srd
from common.setgui_decoders.set_can import Reassembler, legacy
class Decoder(srd.Decoder):
api_version = 3
id = 'set_can'
name = 'SET CAN'
longname = 'SET v2 CAN / ProtoCAN / Balsam'
desc = 'Application fields and segmented SET packets over classic CAN.'
license = 'gplv2+'
inputs = ['can']
outputs = ['set']
tags = ['Embedded/industrial']
options = ({'id': 'protocol', 'desc': 'CAN protocol (select explicitly)',
'default': 'protocan', 'values': ('protocan', 'set-v2', 'balsam')},)
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
def __init__(self):
self.reset()
# Состояние принадлежит одному запуску декодера. Сброс не должен
# переносить незавершённый кадр или частоту из предыдущей записи.
def reset(self):
self.reassembler = Reassembler()
self.samplerate = None
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
# для перевода тайм-аутов и измерений в физические единицы.
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
self.samplerate = value
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
# Аннотации предназначены для дорожек, структурированные события — для стека.
def start(self):
self.out_ann = self.register(srd.OUTPUT_ANN)
self.out_python = self.register(srd.OUTPUT_PYTHON)
def decode(self, ss, es, data):
frame_type, ident, rtr, dlc, payload = data
if frame_type != 'extended' or rtr == 'remote':
return
mode = self.options['protocol']
if not 0 <= dlc <= 8 or len(payload) != dlc:
events = [(ss, es, None, 'Invalid classic CAN DLC')]
# Сегментированный SET v2 требует времени для тайм-аута сборки.
# Идентификатор, порядок сегментов и CRC проверяет общее ядро.
elif mode == 'set-v2':
if not self.samplerate:
raise ValueError('SET CAN requires samplerate for reassembly timeout')
events = self.reassembler.feed(ident, payload, ss, es, es * 1000.0 / self.samplerate)
else:
try:
result = legacy(ident, payload, mode)
events = [(ss, es, result, None)] if result else []
except ValueError as exc:
events = [(ss, es, None, str(exc))]
for start, end, result, error in events:
self.put(start, end, self.out_ann, [2 if error else 0, [error or result['summary']]])
if result:
result['can_id'] = ident
self.put(start, end, self.out_python, ['FRAME', result])
if events and payload:
self.put(ss, es, self.out_ann, [1, [' '.join('%02X' % b for b in payload)]])

View File

@@ -0,0 +1 @@
from .pd import Decoder

View File

@@ -0,0 +1,89 @@
import sigrokdecode as srd
from common.setgui_decoders.set_uart import StreamParser
class Decoder(srd.Decoder):
api_version = 3
id = 'set_uart'
name = 'SET UART'
longname = 'SET v2 / SETGUI v1 / ProtoCAN bridge'
desc = 'SET frames, fields and CRC over UART.'
license = 'gplv2+'
inputs = ['uart']
outputs = ['set']
tags = ['Embedded/industrial']
options = (
{'id': 'protocol', 'desc': 'Protocol', 'default': 'auto',
'values': ('auto', 'v2', 'v1', 'bridge')},
{'id': 'gap_ms', 'desc': 'Reset incomplete frame after gap (ms, 0 disables)', 'default': 100},
)
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
def __init__(self):
self.reset()
# Состояние принадлежит одному запуску декодера. Сброс не должен
# переносить незавершённый кадр или частоту из предыдущей записи.
def reset(self):
self.parsers = {}
self.last = {}
self.samplerate = None
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
# для перевода тайм-аутов и измерений в физические единицы.
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
self.samplerate = value
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
# Аннотации предназначены для дорожек, структурированные события — для стека.
def start(self):
self.out_ann = self.register(srd.OUTPUT_ANN)
self.out_python = self.register(srd.OUTPUT_PYTHON)
def emit(self, events, direction):
for ss, es, frame, error in events:
prefix = ('RX', 'TX')[direction] + ' '
self.put(ss, es, self.out_ann, [2 if error else 0, [prefix + (error or frame['summary'])]])
if frame:
if frame['protocol'] == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
# Imported only at decode time: both packages are installed together.
from common.setgui_decoders.set_can import legacy
try:
application = legacy(frame['can_id'], frame['payload'])
frame['application'] = application
self.put(ss, es, self.out_ann, [0, [application['summary']]])
except ValueError as exc:
self.put(ss, es, self.out_ann, [2, [str(exc)]])
frame['direction'] = direction
self.put(ss, es, self.out_python, ['FRAME', frame])
if frame['payload']:
self.put(ss, es, self.out_ann, [1, [prefix + ' '.join('%02X' % b for b in frame['payload'])]])
def decode(self, ss, es, data):
kind, direction, payload = data
if direction not in (0, 1):
return
# RX и TX имеют независимые парсеры: перемежающиеся байты двух направлений
# нельзя объединять в один прикладной кадр.
parser = self.parsers.setdefault(direction, StreamParser(self.options['protocol']))
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [2, [kind]])
return
if kind != 'FRAME':
return
if not payload[1]:
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [2, ['Invalid UART frame']])
return
gap = float(self.options['gap_ms'])
if self.samplerate and gap > 0 and direction in self.last and ss - self.last[direction] > self.samplerate * gap / 1000:
self.emit(parser.flush(), direction)
self.last[direction] = es
if not 0 <= payload[0] <= 255:
self.emit(parser.flush(), direction)
self.put(ss, es, self.out_ann, [2, ['Configure UART for 8 data bits']])
return
self.emit(parser.feed(payload[0], ss, es), direction)

View File

@@ -0,0 +1,26 @@
# Transistor pair
Independent two-channel analyzer. Assign Vin1 and Vin2; Vstat is not used.
Available in DSView's **Пользовательские** section as **Transistor pair**.
- Minimum active ON pulse duration (mintime), independently per input.
- Minimum inactive OFF duration, independently per input.
- Deadtime from Vin1 OFF to Vin2 ON and vice versa, with independent minimums.
- Active overlap is a fault, regardless of the deadtime settings.
- Period, frequency and active duty cycle for complete cycles on each input.
- Independent active-high/active-low polarity for both inputs.
All limits are nanoseconds. Zero disables the corresponding lower limit;
equality passes. Simultaneous handovers measure zero deadtime. These settings
check measured pulses and do not debounce or filter the source waveform.
Measurements occupy separate waveform rows. Timing violations appear in the
FAULT row. Unknown edges outside the capture are not fabricated; partial
pulses do not produce full pulse widths or full-cycle frequency/duty values.
Python: `PairTimingChecker` in `logic_analyzer.decoders.transistor_pair`.
Offline API: `analyze_capture(..., dict(mode='Transistor pair', channel=0,
vin2_channel=1, vin1_mintime_ns=100, deadtime_12_ns=50))`.
For Vin/Vstat ACK checks and full driver-cycle OK/WARNING/FAULT results use
the separate **IGBT 1SP/1SD** decoder.

View File

@@ -0,0 +1 @@
from .pd import Decoder

View File

@@ -0,0 +1,88 @@
"""Two-input transistor pair analyzer, independent of driver feedback."""
import sigrokdecode as srd
from common.setgui_decoders.transistor_pair import PairTimingChecker
class Decoder(srd.Decoder):
api_version = 3
id = 'transistor_pair'
name = 'Transistor pair'
longname = 'Transistor pair: mintime / deadtime / overlap'
desc = 'Checks ON/OFF pulse limits, deadtime, overlap, frequency and duty.'
license = 'gplv2+'
inputs = ['logic']
outputs = []
tags = ['Clock/timing', 'Util']
channels = (
{'id': 'vin1', 'name': 'Vin1', 'desc': 'First transistor command'},
{'id': 'vin2', 'name': 'Vin2', 'desc': 'Second transistor command'},
)
options = (
{'id': 'vin1_active', 'desc': 'Vin1 active level', 'default': 'high', 'values': ('high', 'low')},
{'id': 'vin2_active', 'desc': 'Vin2 active level', 'default': 'high', 'values': ('high', 'low')},
{'id': 'vin1_mintime_ns', 'desc': 'Vin1 minimum ON time (ns)', 'default': 0},
{'id': 'vin2_mintime_ns', 'desc': 'Vin2 minimum ON time (ns)', 'default': 0},
{'id': 'vin1_minoff_ns', 'desc': 'Vin1 minimum OFF time (ns)', 'default': 0},
{'id': 'vin2_minoff_ns', 'desc': 'Vin2 minimum OFF time (ns)', 'default': 0},
{'id': 'deadtime_12_ns', 'desc': 'Minimum deadtime Vin1 -> Vin2 (ns)', 'default': 0},
{'id': 'deadtime_21_ns', 'desc': 'Minimum deadtime Vin2 -> Vin1 (ns)', 'default': 0},
)
annotations = (
('vin1-on', 'Vin1 ON duration'), ('vin2-on', 'Vin2 ON duration'),
('deadtime', 'Deadtime'), ('failure', 'FAULT'),
('vin1-off', 'Vin1 OFF duration'), ('vin2-off', 'Vin2 OFF duration'),
('vin1-period', 'Vin1 period / frequency / duty'),
('vin2-period', 'Vin2 period / frequency / duty'),
)
annotation_rows = (
('deadtime', 'Deadtime', (2,)),
('faults', 'FAULT: overlap / timing', (3,)),
('vin1', 'Vin1 mintime / OFF', (0, 4)),
('vin2', 'Vin2 mintime / OFF', (1, 5)),
('pwm1', 'Vin1 period / frequency / duty', (6,)),
('pwm2', 'Vin2 period / frequency / duty', (7,)),
)
def __init__(self):
self.reset()
def reset(self):
self.samplerate = None
self.checker = None
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
self.samplerate = value
def start(self):
self.out_ann = self.register(srd.OUTPUT_ANN)
def _put_events(self, events):
for event in events:
kind = event['kind']
channel = event.get('channel', 1) - 1
ann = (3 if kind in ('mintime_fail', 'deadtime_fail', 'off_fail', 'overlap') else
2 if kind == 'deadtime' else channel if kind == 'mintime_ok' else
4 + channel if kind == 'off_time' else 6 + channel)
self.put(event['start'], event['end'], self.out_ann, [ann, [event['text'], event['short']]])
def decode(self):
if not self.samplerate:
raise ValueError('Cannot decode without samplerate.')
if not self.has_channel(0) or not self.has_channel(1):
raise ValueError('Connect both Vin1 and Vin2.')
numeric = {name: self.options[name] for name in (
'vin1_mintime_ns', 'vin2_mintime_ns', 'vin1_minoff_ns', 'vin2_minoff_ns',
'deadtime_12_ns', 'deadtime_21_ns')}
self.checker = PairTimingChecker(self.samplerate,
self.options['vin1_active'] == 'high', self.options['vin2_active'] == 'high', **numeric)
# Do not retain DSView's mutable native wait tuple.
pins = list(self.wait({}))
self._put_events(self.checker.update(self.samplenum, *pins))
while True:
pins = list(self.wait([{0: 'e'}, {1: 'e'}]))
self._put_events(self.checker.update(self.samplenum, *pins))
def end(self):
if self.checker is not None:
self._put_events(self.checker.finish(self.samplenum))

View File

@@ -0,0 +1,83 @@
--- a/DSView/pv/data/decoderstack.h
+++ b/DSView/pv/data/decoderstack.h
@@ -30,6 +30,7 @@
#include <QString>
#include <mutex>
+#include "decode/gate_summary.h"
#include "decode/row.h"
#include "../data/signaldata.h"
#include "decode/decoderstatus.h"
@@ -96,6 +97,7 @@
}
const char* get_root_decoder_id();
+ GateSummary gate_summary;
void add_sub_decoder(decode::Decoder *decoder);
void remove_sub_decoder(decode::Decoder *decoder);
--- a/DSView/pv/data/decoderstack.cpp
+++ b/DSView/pv/data/decoderstack.cpp
@@ -369,6 +369,7 @@
void DecoderStack::init()
{
+ gate_summary.reset();
_sample_count = 0;
_samples_decoded = 0;
_error_message = QString();
@@ -814,6 +815,9 @@
const srd_decoder *const decc = pdata->pdo->di->decoder;
assert(decc);
+ if (strcmp(decc->id, "gate_driver_timing") == 0)
+ d->gate_summary.observe(a->format());
+
auto row_iter = d->_rows.end();
// Try looking up the sub-row of this class
--- a/DSView/pv/dock/protocoldock.cpp
+++ b/DSView/pv/dock/protocoldock.cpp
@@ -190,6 +190,42 @@
match_layout->addStretch(1);
QVBoxLayout *bot_layout = new QVBoxLayout();
+ auto gate_status = new QLabel(bot_panel);
+ gate_status->setObjectName("gateDriverSummary");
+ gate_status->setAlignment(Qt::AlignCenter);
+ gate_status->setMinimumHeight(64);
+ gate_status->hide();
+ bot_layout->addWidget(gate_status);
+ auto gate_timer = new QTimer(this);
+ connect(gate_timer, &QTimer::timeout, this, [this, gate_status]() {
+ int severity = 0;
+ int drivers = 0;
+ bool complete = true;
+ for (auto trace : _session->get_decode_signals()) {
+ auto stack = trace->decoder();
+ if (QString(stack->get_root_decoder_id()) != "gate_driver_timing") continue;
+ ++drivers;
+ const bool done = !stack->IsRunning() && stack->get_progress() == 100;
+ complete = complete && done;
+ int result = stack->gate_summary.severity(done);
+ if (stack->out_of_memory() || !stack->error_message().isEmpty())
+ result = qMax(result, 1);
+ severity = qMax(severity, result);
+ }
+ gate_status->setVisible(drivers > 0);
+ if (!drivers) return;
+ const QString text = severity == 2 ? "FAULT" : severity == 1 ? "WARNING" : "OK";
+ const QString color = severity == 2 ? "#ff6262" : severity == 1 ? "#ffc857" : "#41d687";
+ if (gate_status->text() != text) {
+ gate_status->setText(text);
+ gate_status->setStyleSheet("QLabel#gateDriverSummary { font-size: 32px; font-weight: bold; "
+ "padding: 8px; border: 2px solid " + color + "; border-radius: 6px; color: " + color + "; }");
+ }
+ gate_status->setToolTip(complete
+ ? QString::fromUtf8("Итог всех анализаторов драйвера за всю запись. FAULT: ошибки ACK, Vstat или входов; WARNING: предупреждения, неполные события или нет подтверждений; OK: проверки пройдены.")
+ : QString::fromUtf8("Анализ ещё не завершён. Итог OK пока недоступен."));
+ });
+ gate_timer->start(250);
bot_layout->addLayout(bot_title_layout);
bot_layout->addLayout(ann_search_layout);
bot_layout->addLayout(match_layout);

View File

@@ -0,0 +1,35 @@
--- a/DSView/pv/prop/binding/decoderoptions.cpp
+++ b/DSView/pv/prop/binding/decoderoptions.cpp
@@ -27,6 +27,7 @@
#include "../../data/decoderstack.h"
#include "../../data/decode/decoder.h"
+#include "../bool.h"
#include "../double.h"
#include "../enum.h"
#include "../int.h"
@@ -85,7 +86,23 @@
Property *prop = NULL;
- if (opt->values)
+ if (strcmp(dec->id, "gate_driver_timing") == 0 &&
+ strcmp(opt->id, "pair_analysis") == 0) {
+ prop = new Bool(name, name,
+ [getter]() -> GVariant* {
+ GVariant *value = getter();
+ const bool enabled = value && g_variant_get_int64(value) != 0;
+ if (value) g_variant_unref(value);
+ return g_variant_ref_sink(g_variant_new_boolean(enabled));
+ },
+ [setter](GVariant *value) {
+ GVariant *number = g_variant_ref_sink(g_variant_new_int64(
+ g_variant_get_boolean(value) ? 1 : 0));
+ setter(number);
+ g_variant_unref(number);
+ });
+ }
+ else if (opt->values)
prop = bind_enum(name, opt, getter, setter);
else if (g_variant_is_of_type(opt->def, G_VARIANT_TYPE("d")))
prop = new Double(name, name, 2, "",none, none, getter, setter);

View File

@@ -0,0 +1,151 @@
[CmdletBinding()]
param(
[string]$DsViewPath = (Join-Path $env:ProgramFiles 'DSView'),
[switch]$CheckOnly
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2
# Resolve source paths relative to templates, never the caller working directory.
# Standalone DSView and SETGUI use the same adapters and parsing cores.
$templateRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
$targetRoot = [IO.Path]::GetFullPath($DsViewPath)
$decoderRoot = Join-Path $targetRoot 'decoders'
# Use .NET for compatibility with PowerShell lacking Get-FileHash.
# Release both stream and hashing provider even if reading fails.
function Get-ContentHash([string]$Path) {
$algorithm = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try {
return [BitConverter]::ToString($algorithm.ComputeHash($stream))
} finally {
$stream.Dispose()
$algorithm.Dispose()
}
}
# Check all existing ancestors: a junction under decoders/common could
# redirect a local-looking update outside the chosen installation.
function Assert-PlainPath([string]$Path) {
$cursor = [IO.Path]::GetFullPath($Path)
$first = $true
while ($cursor) {
if (Test-Path -LiteralPath $cursor) {
$item = Get-Item -LiteralPath $cursor -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
throw "Symbolic links and junctions are not supported: $cursor"
}
if (-not $first -and -not $item.PSIsContainer) {
throw "Expected a parent directory: $cursor"
}
}
$parent = [IO.Path]::GetDirectoryName($cursor)
if ($parent -eq $cursor) { break }
$cursor = $parent
$first = $false
}
}
Assert-PlainPath $targetRoot
$exe = Join-Path $targetRoot 'DSView.exe'
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
throw "DSView.exe not found in '$targetRoot'. Pass -DsViewPath with the installed DSView directory."
}
if (-not (Test-Path -LiteralPath (Join-Path $decoderRoot 'common') -PathType Container)) {
throw "DSView decoders/common is missing. Select a complete DSView installation."
}
# An explicit allowlist excludes tests, bytecode and unrelated modules.
# The private common/setgui_decoders layout matches the embedded build.
$files = @()
foreach ($name in @('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
foreach ($leaf in @('__init__.py', 'pd.py')) {
$files += [pscustomobject]@{
Source = Join-Path $PSScriptRoot "decoders/$name/$leaf"
Relative = "$name/$leaf"
}
}
}
foreach ($name in @('__init__', 'gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
$files += [pscustomobject]@{
Source = Join-Path $templateRoot "python/logic_analyzer/decoders/$name.py"
Relative = "common/setgui_decoders/$name.py"
}
}
# Validate the entire payload and destination before changing any decoder.
foreach ($file in $files) {
if (-not (Test-Path -LiteralPath $file.Source -PathType Leaf)) {
throw "Missing templates source: $($file.Source). Initialize the templates submodule."
}
$destination = Join-Path $decoderRoot $file.Relative
Assert-PlainPath $destination
if (Test-Path -LiteralPath $destination -PathType Container) {
throw "Expected a file, found a directory: $destination"
}
if ([IO.Path]::GetFullPath($file.Source) -eq [IO.Path]::GetFullPath($destination)) {
throw 'The installation destination must differ from the templates source.'
}
}
# Read-only validation does not create backups or check elevated write access.
# Real installation performs its own write-access check before copying.
if ($CheckOnly) {
Write-Output "Validated $($files.Count) files for $targetRoot. No files changed."
return
}
# A mapped/running Windows EXE cannot be opened for writing. Never terminate DSView.
try {
$handle = [IO.File]::Open($exe, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
$handle.Dispose()
} catch {
throw "Close DSView and check write permissions for '$targetRoot'. For Program Files, run PowerShell as administrator. $($_.Exception.Message)"
}
# Keep each previous version outside decoder discovery directories.
# Retain backups after success for manual recovery, not only after errors.
$backup = Join-Path $targetRoot ('set-decoder-backups/' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N'))
Assert-PlainPath $backup
# Back up all existing files before writing the first replacement.
foreach ($file in $files) {
$destination = Join-Path $decoderRoot $file.Relative
if (Test-Path -LiteralPath $destination -PathType Leaf) {
$saved = Join-Path $backup $file.Relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($saved)) | Out-Null
Copy-Item -LiteralPath $destination -Destination $saved
}
}
# Track files before copying: a failed copy may leave a partial destination.
# Rollback must include that file as well as completed replacements.
$attempted = @()
try {
foreach ($file in $files) {
$destination = Join-Path $decoderRoot $file.Relative
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
$attempted += $file
Copy-Item -LiteralPath $file.Source -Destination $destination -Force
if ((Get-ContentHash $file.Source) -ne (Get-ContentHash $destination)) {
throw "Copy verification failed: $destination"
}
}
} catch {
$failure = $_
# Restore overwritten files and remove only new files from our allowlist.
# Never recursively delete decoder folders or remove unrelated analyzers.
foreach ($file in $attempted) {
$destination = Join-Path $decoderRoot $file.Relative
$saved = Join-Path $backup $file.Relative
try {
if (Test-Path -LiteralPath $saved -PathType Leaf) {
Copy-Item -LiteralPath $saved -Destination $destination -Force
} elseif (Test-Path -LiteralPath $destination -PathType Leaf) {
Remove-Item -LiteralPath $destination -Force
}
} catch {
Write-Warning "Could not restore $destination. Retained backup: $backup"
}
}
throw $failure
}
Write-Output "Installed: IGBT 1SP/1SD, SET UART, SET CAN, PM35 UART in $decoderRoot"
if (Test-Path -LiteralPath $backup) { Write-Output "Previous files retained in: $backup" }
Write-Output 'Restart DSView. Add UART/CAN first, then stack the corresponding SET decoder. No external Python is required.'

View File

@@ -0,0 +1,29 @@
#pragma once
#include <atomic>
#include <cstdint>
// Annotation IDs are the public gate_driver_timing decoder contract.
// Independent of row visibility and text search; one summary per decode run.
class GateSummary {
public:
void reset() {
controls = 0; acknowledgments = 0; warnings = 0; faults = 0;
}
void observe(int annotation) {
switch (annotation) {
case 0: ++controls; break;
case 2: ++acknowledgments; break;
case 3: ++warnings; break;
case 4: case 5: ++faults; break;
}
}
// 0 = OK, 1 = WARNING / incomplete, 2 = FAULT.
int severity(bool complete) const {
if (faults.load()) return 2;
if (!complete || warnings.load() || !acknowledgments.load() ||
controls.load() != acknowledgments.load()) return 1;
return 0;
}
private:
std::atomic<uint64_t> controls{0}, acknowledgments{0}, warnings{0}, faults{0};
};

View File

@@ -0,0 +1,1686 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cc82669f..575eb851 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -190,6 +190,10 @@ if(Qt5Core_FOUND)
#set(QT_LIBRARIES Qt5::Gui Qt5::Widgets Qt5::WinExtras)
set(QT_INCLUDE_DIRS ${Qt5Gui_INCLUDE_DIRS} ${Qt5Widgets_INCLUDE_DIRS})
set(QT_LIBRARIES Qt5::Gui Qt5::Widgets)
+ if(WIN32)
+ find_package(Qt5WinExtras REQUIRED)
+ list(APPEND QT_LIBRARIES Qt5::WinExtras)
+ endif()
add_definitions(${Qt5Gui_DEFINITIONS} ${Qt5Widgets_DEFINITIONS})
else()
find_package(Qt6Core QUIET)
@@ -551,10 +555,13 @@ set(DSView_RESOURCES
if(WIN32)
# Use the DSView icon for the DSView.exe executable.
+ add_definitions(-DUNICODE -D_UNICODE)
set(CMAKE_RC_COMPILE_OBJECT "${CMAKE_RC_COMPILER} -O coff -I${CMAKE_CURRENT_SOURCE_DIR} <SOURCE> <OBJECT>")
enable_language(RC)
# app icon
list(APPEND DSView_SOURCES applogo.rc)
+ list(APPEND DSView_SOURCES DSView/pv/winnativewidget.cpp DSView/pv/winshadow.cpp)
+ list(APPEND DSView_HEADERS DSView/pv/winshadow.h)
endif()
if(Qt5Core_FOUND)
diff --git a/DSView/main.cpp b/DSView/main.cpp
index 748a59d9..58d2d155 100644
--- a/DSView/main.cpp
+++ b/DSView/main.cpp
@@ -64,6 +64,7 @@ int main(int argc, char *argv[])
const char *open_file = NULL;
int logLevel = -1;
bool bStoreLog = false;
+ quintptr embedParent = 0;
//----------------------rebuild command param
#ifdef _WIN32
@@ -103,6 +104,8 @@ int main(int argc, char *argv[])
{"version", no_argument, 0, 'v'},
{"storelog", no_argument, 0, 's'},
{"help", no_argument, 0, 'h'},
+ {"embed-parent", required_argument, 0, 1000},
+ {"host-version", no_argument, 0, 1001},
{0, 0, 0, 0}
};
@@ -113,6 +116,20 @@ int main(int argc, char *argv[])
switch (c)
{
+ case 1000: {
+ bool ok = false;
+ embedParent = QString::fromUtf8(optarg).toULongLong(&ok, 0);
+#ifdef _WIN32
+ if (!ok || !embedParent || !IsWindow(reinterpret_cast<HWND>(embedParent)))
+#else
+ if (true)
+#endif
+ return 2;
+ break;
+ }
+ case 1001:
+ printf("DSView host protocol 1\n");
+ return 0;
case 'l': // log level
logLevel = atoi(optarg);
break;
@@ -174,6 +191,14 @@ bool bHighScale = true;
QApplication::setApplicationName("DSView");
QApplication::setOrganizationName("DreamSourceLab");
QApplication::setOrganizationDomain("www.DreamSourceLab.com");
+ if (embedParent) {
+ QApplication::setOrganizationName("SET");
+ QApplication::setApplicationName("SETGUI-DSView");
+ a.setProperty("embeddedHost", true);
+ // The native child uses Qt::Tool and is not a primary Qt window.
+ // Closing a modal dialog must not end the hosted application's loop.
+ a.setQuitOnLastWindowClosed(false);
+ }
//----------------------init log
dsv_log_init(); // Don't call before QApplication be inited
@@ -199,6 +224,17 @@ bool bHighScale = true;
AppControl *control = AppControl::Instance();
AppConfig &app = AppConfig::Instance();
app.LoadAll(); //load app config
+ if (embedParent) {
+ app.frameOptions.style = "dark";
+ app.appOptions.fontSize = 10;
+ QString captureDir = QString::fromUtf8(qgetenv("DSVIEW_CAPTURE_DIR"));
+ if (QDir(captureDir).exists() && !captureDir.isEmpty()) {
+ app.userHistory.saveDir = captureDir;
+ app.userHistory.openDir = captureDir;
+ app.userHistory.exportDir = captureDir;
+ app.userHistory.screenShotPath = captureDir;
+ }
+ }
LangResource::Instance()->Load(app.frameOptions.language);
if (app.appOptions.ableSaveLog){
@@ -237,8 +273,23 @@ bool bHighScale = true;
{
pv::MainFrame w;
control->Start();
- w.ShowFormInit();
- w.ShowHelpDocAsync(); //to show the dailog for open help document
+#ifdef _WIN32
+ if (embedParent) {
+ if (!w.ShowEmbedded(embedParent)) return 2;
+ printf("DSVIEW_READY %llu\n", static_cast<unsigned long long>(w.winId()));
+ printf("DSVIEW_CAPABILITIES OPEN_FILE\n");
+ fflush(stdout);
+ // Optional render artifact for integration smoke tests.
+ const QString screenshot = QString::fromUtf8(qgetenv("DSVIEW_HOST_SCREENSHOT"));
+ if (!screenshot.isEmpty()) QTimer::singleShot(2500, &w, [&w, screenshot]() {
+ w.grab().save(screenshot);
+ });
+ } else
+#endif
+ {
+ w.ShowFormInit();
+ w.ShowHelpDocAsync();
+ }
ret = a.exec(); //Run the application
control->Stop();
diff --git a/DSView/pv/config/appconfig.cpp b/DSView/pv/config/appconfig.cpp
index 1e318306..b4a7f5ea 100644
--- a/DSView/pv/config/appconfig.cpp
+++ b/DSView/pv/config/appconfig.cpp
@@ -438,8 +438,9 @@ bool AppConfig::IsDarkStyle()
return false;
}
-QColor AppConfig::GetStyleColor()
-{
+QColor AppConfig::GetStyleColor()
+{
+ if (qApp->property("embeddedHost").toBool()) return QColor("#10151d");
if (IsDarkStyle()){
return QColor(38, 38, 38);
}
@@ -551,4 +552,4 @@ QString GetProfileDir()
#else
return QStandardPaths::writableLocation(QStandardPaths::DataLocation);
#endif
-}
\ No newline at end of file
+}
diff --git a/DSView/pv/data/decode/annotation.cpp b/DSView/pv/data/decode/annotation.cpp
index f285247c..7da1849c 100644
--- a/DSView/pv/data/decode/annotation.cpp
+++ b/DSView/pv/data/decode/annotation.cpp
@@ -179,6 +179,16 @@ const std::vector<QString>& Annotation::annotations() const
return resItem.cvt_lines;
}
+bool Annotation::contains_text(const QString &text) const
+{
+ // The waveform can display any of these labels, not just the longest one.
+ for (const auto &label : annotations()) {
+ if (label.contains(text, Qt::CaseInsensitive))
+ return true;
+ }
+ return false;
+}
+
bool Annotation::is_numberic()
{
AnnotationSourceItem *resItem = _status->m_resTable.GetItem(_resIndex);
diff --git a/DSView/pv/data/decode/annotation.h b/DSView/pv/data/decode/annotation.h
index a5b0e440..06067395 100644
--- a/DSView/pv/data/decode/annotation.h
+++ b/DSView/pv/data/decode/annotation.h
@@ -66,6 +66,8 @@ public:
const std::vector<QString>& annotations() const;
+ bool contains_text(const QString &text) const;
+
private:
uint64_t _start_sample;
uint64_t _end_sample;
diff --git a/DSView/pv/data/decodermodel.cpp b/DSView/pv/data/decodermodel.cpp
index 61710e54..ee814a76 100644
--- a/DSView/pv/data/decodermodel.cpp
+++ b/DSView/pv/data/decodermodel.cpp
@@ -32,6 +32,58 @@ using namespace std;
namespace pv {
namespace data {
+AnnotationMatchModel::AnnotationMatchModel(QAbstractItemModel *source, QObject *parent)
+ : QAbstractTableModel(parent), _source(source)
+{
+ connect(source, &QAbstractItemModel::modelAboutToBeReset, this, [this]() {
+ setMatches({}, QString());
+ });
+}
+
+int AnnotationMatchModel::rowCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : int(_matches.size());
+}
+
+int AnnotationMatchModel::columnCount(const QModelIndex &parent) const
+{
+ return parent.isValid() ? 0 : 2;
+}
+
+QModelIndex AnnotationMatchModel::sourceIndex(int row) const
+{
+ return _source && row >= 0 && row < int(_matches.size()) ? _matches[row] : QModelIndex();
+}
+
+void AnnotationMatchModel::setMatches(const std::vector<QModelIndex> &matches, const QString &term)
+{
+ beginResetModel();
+ _matches = matches;
+ _term = term;
+ endResetModel();
+}
+
+QVariant AnnotationMatchModel::data(const QModelIndex &index, int role) const
+{
+ const QModelIndex source = sourceIndex(index.row());
+ if (!index.isValid() || !source.isValid()) return QVariant();
+ if (role == Qt::DisplayRole && index.column() == 0)
+ return _source->headerData(source.column(), Qt::Horizontal, role);
+ if (role == Qt::DisplayRole) {
+ const auto labels = source.data(Qt::ToolTipRole).toString().split('\n');
+ for (const auto &label : labels)
+ if (label.contains(_term, Qt::CaseInsensitive)) return label;
+ }
+ return source.data(role);
+}
+
+QVariant AnnotationMatchModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+ if (role != Qt::DisplayRole) return QVariant();
+ if (orientation == Qt::Vertical) return section + 1;
+ return section == 0 ? tr("Annotation") : tr("Match");
+}
+
DecoderModel::DecoderModel(QObject *parent)
: QAbstractTableModel(parent),
_decoder_stack(NULL)
@@ -68,17 +120,51 @@ QVariant DecoderModel::data(const QModelIndex &index, int role) const
if (role == Qt::TextAlignmentRole) {
return int(Qt::AlignLeft | Qt::AlignVCenter);
}
- else if (role == Qt::DisplayRole) {
+ else if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
if (_decoder_stack) {
pv::data::decode::Annotation ann;
if (_decoder_stack->list_annotation(&ann, index.column(), index.row())) {
- return ann.annotations().at(0);
+ const auto &labels = ann.annotations();
+ if (role == Qt::DisplayRole)
+ return labels.empty() ? QVariant() : QVariant(labels.front());
+ QStringList text;
+ for (const auto &label : labels)
+ if (!text.contains(label)) text.append(label);
+ return text.join("\n");
}
}
}
return QVariant();
}
+bool DecoderModel::annotation_matches(int column, int row, const QStringList &terms) const
+{
+ if (!_decoder_stack || terms.isEmpty() || terms.first().isEmpty())
+ return false;
+ decode::Annotation ann;
+ if (!_decoder_stack->list_annotation(&ann, column, row) ||
+ !ann.contains_text(terms.first()))
+ return false;
+
+ if (terms.size() == 1)
+ return true;
+
+ // Retain the numeric AA-BB-CC sequence search in each individual column.
+ // Text containing a hyphen is handled as one literal term by the caller.
+ if (!ann.is_numberic())
+ return false;
+ uint64_t next_row = uint64_t(row) + 1;
+ for (int term = 1; term < terms.size(); ++term) {
+ do {
+ if (!_decoder_stack->list_annotation(&ann, column, next_row++))
+ return false;
+ } while (!ann.is_numberic());
+ if (!ann.contains_text(terms.at(term)))
+ return false;
+ }
+ return true;
+}
+
QVariant DecoderModel::headerData(int section,
Qt::Orientation orientation,
int role) const
diff --git a/DSView/pv/data/decodermodel.h b/DSView/pv/data/decodermodel.h
index d35849f4..f2f15887 100644
--- a/DSView/pv/data/decodermodel.h
+++ b/DSView/pv/data/decodermodel.h
@@ -23,6 +23,8 @@
#define DSVIEW_PV_DATA_DECODERMODEL_H
#include <QAbstractTableModel>
+#include <QStringList>
+#include <QPointer>
#include "decode/rowdata.h"
@@ -31,6 +33,24 @@ namespace data {
class DecoderStack;
+// One matched annotation per row; columns in DecoderModel have independent
+// timelines, so filtering its row numbers would also expose unrelated events.
+class AnnotationMatchModel : public QAbstractTableModel
+{
+public:
+ AnnotationMatchModel(QAbstractItemModel *source, QObject *parent);
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex &index, int role) const override;
+ QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
+ void setMatches(const std::vector<QModelIndex> &matches, const QString &term);
+ QModelIndex sourceIndex(int row) const;
+private:
+ QPointer<QAbstractItemModel> _source;
+ std::vector<QModelIndex> _matches;
+ QString _term;
+};
+
namespace decode {
class Annotation;
class Decoder;
@@ -47,6 +67,8 @@ public:
QVariant data(const QModelIndex &index, int role) const;
QVariant headerData(int section, Qt::Orientation orientation,int role) const;
+ bool annotation_matches(int column, int row, const QStringList &terms) const;
+
void setDecoderStack(DecoderStack *decoder_stack);
inline DecoderStack* getDecoderStack(){
diff --git a/DSView/pv/data/decoderstack.cpp b/DSView/pv/data/decoderstack.cpp
index cb6b0b9a..ca46182f 100644
--- a/DSView/pv/data/decoderstack.cpp
+++ b/DSView/pv/data/decoderstack.cpp
@@ -168,8 +168,7 @@ void DecoderStack::build_row()
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
if (iter == _rows_gshow.end()) {
_rows_gshow[row] = true;
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
- row.title().contains("warning", Qt::CaseInsensitive)) {
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
_rows_lshow[row] = false;
} else {
_rows_lshow[row] = true;
@@ -192,8 +191,7 @@ void DecoderStack::build_row()
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
if (iter == _rows_gshow.end()) {
_rows_gshow[row] = true;
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
- row.title().contains("warning", Qt::CaseInsensitive)) {
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
_rows_lshow[row] = false;
} else {
_rows_lshow[row] = true;
diff --git a/DSView/pv/dialogs/deviceoptions.cpp b/DSView/pv/dialogs/deviceoptions.cpp
index 7d4ae1c4..efe19693 100644
--- a/DSView/pv/dialogs/deviceoptions.cpp
+++ b/DSView/pv/dialogs/deviceoptions.cpp
@@ -392,18 +392,15 @@ void DeviceOptions::logic_probes(QVBoxLayout &layout)
QPushButton *enable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_ENABLE_ALL), "Enable All"));
QPushButton *disable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_DISABLE_ALL), "Disable All"));
- enable_all_probes->setMaximumHeight(33);
- disable_all_probes->setMaximumHeight(33);
+ // Let Qt include the full label and themed padding in the preferred size.
+ enable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+ disable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
enable_all_probes->setFont(font);
- disable_all_probes->setFont(font);
-
- int bt_width = enable_all_probes->fontMetrics().horizontalAdvance(enable_all_probes->text()) + 20;
- enable_all_probes->setMaximumWidth(bt_width);
- disable_all_probes->setMaximumWidth(bt_width);
-
- this->update_font();
-
- contentHeight += enable_all_probes->sizeHint().height();
+ disable_all_probes->setFont(font);
+
+ this->update_font();
+
+ contentHeight += qMax(enable_all_probes->sizeHint().height(), disable_all_probes->sizeHint().height());
contentHeight += channel_line_height * row2 + 50;
connect(enable_all_probes, SIGNAL(clicked()),
diff --git a/DSView/pv/dock/keywordlineedit.cpp b/DSView/pv/dock/keywordlineedit.cpp
index 2c2e4722..9a33e11a 100644
--- a/DSView/pv/dock/keywordlineedit.cpp
+++ b/DSView/pv/dock/keywordlineedit.cpp
@@ -353,6 +353,10 @@ void PopupLineEdit::showPupopInput()
_popup_input = input;
connect(input, SIGNAL(sig_inputEnd(QString)), this, SLOT(onPopupInputEditEnd(QString)));
+ // The popup owns keyboard input on Windows. Forward Enter after it commits
+ // the text and closes, so actions connected to the outer editor still run.
+ connect(line, &QLineEdit::returnPressed, this, &QLineEdit::returnPressed,
+ Qt::QueuedConnection);
if (_is_number_mode){
connect(line, SIGNAL(valueChanged(int)), this, SLOT(onPopupInputValueChanged(int)));
diff --git a/DSView/pv/dock/protocoldock.cpp b/DSView/pv/dock/protocoldock.cpp
index 648eb5b8..1a023707 100644
--- a/DSView/pv/dock/protocoldock.cpp
+++ b/DSView/pv/dock/protocoldock.cpp
@@ -37,14 +37,13 @@
#include <QHeaderView>
#include <QScrollBar>
#include <QRegularExpression>
-#include <QFuture>
-#include <QProgressDialog>
-#include <QtConcurrent/QtConcurrent>
+#include <QElapsedTimer>
#include <QSizePolicy>
#include <assert.h>
#include <map>
#include <string>
#include <algorithm>
+#include <limits>
#include <QTableWidgetItem>
#include <QHeaderView>
#include "../ui/msgbox.h"
@@ -56,6 +55,11 @@
#include "../ui/langresource.h"
#include "../appcontrol.h"
#include "../ui/fn.h"
+#include "../dialogs/decoderoptionsdlg.h"
+#include "../dialogs/dsmessagebox.h"
+#include <QApplication>
+#include <QFile>
+#include <QTimer>
using namespace std;
@@ -69,9 +73,10 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
_view(view)
{
_session = session;
- _cur_search_index = -1;
- _search_edited = false;
_pro_add_button = NULL;
+ _search_timer = new QTimer(this);
+ _search_timer->setSingleShot(true);
+ connect(_search_timer, &QTimer::timeout, this, &ProtocolDock::search_step);
//-----------------------------get protocol list
GSList *l = const_cast<GSList*>(srd_decoder_list());
@@ -160,7 +165,6 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
_ann_search_edit = new PopupLineEdit(bot_panel);
_ann_search_button->setFixedWidth(_ann_search_button->height());
- _ann_search_button->setDisabled(true);
QHBoxLayout *ann_search_layout = new QHBoxLayout();
ann_search_layout->setSpacing(2);
@@ -170,6 +174,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
ann_search_layout->addWidget(_nxt_button);
_table_view = new QTableView(bot_panel);
+ _match_model = new data::AnnotationMatchModel(_session->get_decoder_model(), this);
_table_view->setModel(_session->get_decoder_model());
_table_view->setAlternatingRowColors(true);
_table_view->setShowGrid(false);
@@ -191,12 +196,32 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
bot_layout->addWidget(_table_view);
bot_panel->setLayout(bot_layout);
+ // Keep decoder rows scrollable so their combined minimum height does not
+ // prevent the user from dragging the divider upwards.
+ auto top_scroll = new QScrollArea(this);
+ top_scroll->setFrameShape(QFrame::NoFrame);
+ top_scroll->setWidgetResizable(true);
+ top_scroll->setMinimumHeight(70);
+ top_scroll->setWidget(top_panel);
+
QSplitter *split_widget = new QSplitter(this);
- split_widget->insertWidget(0, top_panel);
+ split_widget->insertWidget(0, top_scroll);
split_widget->insertWidget(1, bot_panel);
split_widget->setOrientation(Qt::Vertical);
split_widget->setCollapsible(0, false);
split_widget->setCollapsible(1, false);
+ split_widget->setHandleWidth(10);
+ split_widget->setStretchFactor(0, 0);
+ split_widget->setStretchFactor(1, 1);
+ split_widget->setSizes({180, 420});
+ split_widget->handle(1)->setCursor(Qt::SizeVerCursor);
+ split_widget->handle(1)->setToolTip(tr("Drag up or down to resize Decoders and Decoding Results"));
+ if (qApp->property("embeddedHost").toBool()) {
+ split_widget->setStyleSheet(
+ "QSplitter#protocolWidget::handle:vertical { background: #243247; "
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
+ "QSplitter#protocolWidget::handle:vertical:hover { background: #1570d8; }");
+ }
this->setWidgetResizable(true);
this->setWidget(split_widget);
@@ -207,6 +232,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
connect(_bot_set_button, SIGNAL(clicked()),this, SLOT(set_model()));
connect(_pre_button, SIGNAL(clicked()),this, SLOT(search_pre()));
connect(_nxt_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
+ connect(_ann_search_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
connect(_pro_add_button, SIGNAL(clicked()),this, SLOT(on_add_protocol()));
connect(_del_all_button, SIGNAL(clicked()),this, SLOT(on_del_all_protocol()));
@@ -218,7 +244,12 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
connect(_pro_search_button, SIGNAL(clicked()), this, SLOT(show_protocol_select()));
- connect(_ann_search_edit, SIGNAL(editingFinished()), this, SLOT(search_changed()));
+ connect(_ann_search_edit, &QLineEdit::textChanged, this, &ProtocolDock::search_changed);
+ connect(_ann_search_edit, &QLineEdit::returnPressed, this, &ProtocolDock::search_nxt);
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelAboutToBeReset,
+ this, &ProtocolDock::invalidate_search);
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelReset,
+ this, &ProtocolDock::search_done);
ADD_UI(this);
@@ -241,6 +272,12 @@ ProtocolDock::~ProtocolDock()
void ProtocolDock::retranslateUi()
{
_ann_search_edit->setPlaceholderText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCH), "search"));
+ _ann_search_edit->setToolTip(tr("Search all enabled result columns, including short waveform labels.\n"
+ "Case insensitive. Enter or the magnifier: next match.\n"
+ "Numeric sequence: AA-BB-CC."));
+ _ann_search_button->setToolTip(tr("Find next (Enter)"));
+ _pre_button->setToolTip(tr("Previous match"));
+ _nxt_button->setToolTip(tr("Next match"));
_matchs_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_MATCHING_ITEMS), "Matching Items:"));
_bot_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_LIST_VIEWER), "Protocol List Viewer"));
_pro_keyword_edit->ResetText();
@@ -290,6 +327,89 @@ int ProtocolDock::get_protocol_index_by_id(QString id)
return -1;
}
+bool ProtocolDock::add_host_decoder(const QString &id)
+{
+ if (_session->is_working() || _session->get_device()->get_work_mode() != LOGIC ||
+ QApplication::activeModalWidget() || get_protocol_index_by_id(id) < 0)
+ return false;
+ _selected_protocol_id = id;
+ const size_t count = _protocol_lay_items.size();
+ on_add_protocol();
+ return _protocol_lay_items.size() > count;
+}
+
+// Opt-in integration check used by SETGUI --check-dsview. Exercise the same
+// modal dialogs and removal paths as the right-hand decoder panel.
+void ProtocolDock::check_host_decoders()
+{
+ const QString report = QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK"));
+ if (report.isEmpty()) return;
+ auto log = [report](const QString &message) {
+ QFile file(report);
+ if (file.open(QIODevice::WriteOnly | QIODevice::Append))
+ file.write((message + "\n").toUtf8());
+ };
+ auto timer = new QTimer(this);
+ int phase = 0;
+ connect(timer, &QTimer::timeout, this, [this, timer, phase, log, report]() mutable {
+ bool ok = true;
+ auto answer = [this](QMessageBox::StandardButton button) {
+ QTimer::singleShot(80, this, [button]() {
+ auto dialog = qobject_cast<dialogs::DSMessageBox*>(QApplication::activeModalWidget());
+ if (dialog && dialog->mBox()->button(button))
+ dialog->mBox()->button(button)->click();
+ });
+ };
+ if (phase < 4) {
+ const auto before = _protocol_lay_items.size();
+ if (before == 0) ok = false;
+ else {
+ answer(phase % 2 ? QMessageBox::Yes : QMessageBox::No);
+ if (phase < 2) OnProtocolDelete(_protocol_lay_items.front());
+ else on_del_all_protocol();
+ const auto after = _protocol_lay_items.size();
+ ok = phase % 2 == 0 ? after == before : after == (phase == 1 ? before - 1 : 0);
+ }
+ } else if (phase < 8) {
+ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart"})[phase - 4];
+ if (get_protocol_index_by_id(id) < 0) ok = false;
+ else {
+ QTimer::singleShot(80, this, [report, id]() {
+ auto dialog = qobject_cast<dialogs::DecoderOptionsDlg*>(QApplication::activeModalWidget());
+ if (!dialog) return;
+ int channel = 1;
+ for (auto combo : dialog->findChildren<QComboBox*>()) {
+ if (combo->count() > 1 && combo->itemText(0) == "-" && combo->itemData(0).toInt() == -1)
+ combo->setCurrentIndex(qMin(channel++, combo->count() - 1));
+ }
+ dialog->grab().save(report + "." + id + ".png");
+ QMetaObject::invokeMethod(dialog, "on_accept", Qt::DirectConnection);
+ });
+ ok = add_host_decoder(id);
+ if (ok) {
+ auto trace = _session->get_decode_signals().back();
+ ok = QString(trace->decoder()->stack().back()->decoder()->id) == id;
+ }
+ }
+ } else if (phase == 8) {
+ answer(QMessageBox::Yes);
+ on_del_all_protocol();
+ ok = _protocol_lay_items.empty() && _session->get_decode_signals().empty();
+ } else {
+ log("PASS");
+ timer->stop();
+ timer->deleteLater();
+ return;
+ }
+ log(QString("phase %1: %2").arg(phase++).arg(ok ? "ok" : "FAIL"));
+ if (!ok) {
+ timer->stop();
+ timer->deleteLater();
+ }
+ });
+ timer->start(350);
+}
+
void ProtocolDock::on_add_protocol()
{
if (_decoderInfoList.size() == 0){
@@ -471,7 +591,7 @@ void ProtocolDock::decoded_progress(int progress)
index++;
}
- if (progress == 0 || progress % 10 == 1){
+ if (progress == 0 || progress == 100 || progress % 10 == 1){
update_model();
}
}
@@ -481,8 +601,6 @@ void ProtocolDock::set_model()
pv::dialogs::ProtocolList *protocollist_dlg = new pv::dialogs::ProtocolList(this, _session);
protocollist_dlg->exec();
resize_table_view(_session->get_decoder_model());
- _model_proxy.setSourceModel(_session->get_decoder_model());
- search_done();
// clear mark_index of all DecoderStacks
const auto &decode_sigs = _session->get_decode_signals();
@@ -513,15 +631,13 @@ void ProtocolDock::update_model()
if (index >= decode_sigs.size())
decoder_model->setDecoderStack(decode_sigs.at(0)->decoder());
}
- _model_proxy.setSourceModel(decoder_model);
- search_done();
resize_table_view(decoder_model);
}
void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
{
if (decoder_model->getDecoderStack()) {
- for (int i = 0; i < decoder_model->columnCount(QModelIndex()) - 1; i++) {
+ for (int i = 0; i < _table_view->model()->columnCount(QModelIndex()) - 1; i++) {
_table_view->resizeColumnToContents(i);
if (_table_view->columnWidth(i) > 200)
_table_view->setColumnWidth(i, 200);
@@ -535,8 +651,11 @@ void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
}
}
-void ProtocolDock::item_clicked(const QModelIndex &index)
+void ProtocolDock::item_clicked(const QModelIndex &table_index)
{
+ const QModelIndex index = table_index.model() == _match_model
+ ? _match_model->sourceIndex(table_index.row()) : table_index;
+ if (!index.isValid()) return;
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
auto decoder_stack = decoder_model->getDecoderStack();
@@ -555,41 +674,16 @@ void ProtocolDock::item_clicked(const QModelIndex &index)
}
}
- _table_view->resizeRowToContents(index.row());
- if (index.column() != _model_proxy.filterKeyColumn()) {
- _model_proxy.setFilterKeyColumn(index.column());
- _model_proxy.setSourceModel(decoder_model);
- search_done();
- }
- QModelIndex filterIndex = _model_proxy.mapFromSource(index);
- if (filterIndex.isValid()) {
- _cur_search_index = filterIndex.row();
- } else {
- if (_model_proxy.rowCount() == 0) {
- _cur_search_index = -1;
- } else {
- uint64_t up = 0;
- uint64_t dn = _model_proxy.rowCount() - 1;
- do {
- uint64_t md = (up + dn)/2;
- QModelIndex curIndex = _model_proxy.mapToSource(_model_proxy.index(md,_model_proxy.filterKeyColumn()));
- if (index.row() == curIndex.row()) {
- _cur_search_index = md;
- break;
- } else if (md == up) {
- if (curIndex.row() < index.row() && up < dn) {
- QModelIndex nxtIndex = _model_proxy.mapToSource(_model_proxy.index(md+1,_model_proxy.filterKeyColumn()));
- if (nxtIndex.row() < index.row())
- md++;
- }
- _cur_search_index = md + ((curIndex.row() < index.row()) ? 0.5 : -0.5);
- break;
- } else if (curIndex.row() < index.row()) {
- up = md;
- } else if (curIndex.row() > index.row()) {
- dn = md;
- }
- }while(1);
+ _table_view->resizeRowToContents(table_index.row());
+ // A table click must not silently restrict subsequent searches to this column.
+ if (_search_index >= 0 && _search_index < int(_search_matches.size()) &&
+ _search_matches[_search_index].index == index)
+ return;
+ _search_index = -1;
+ for (size_t i = 0; i < _search_matches.size(); ++i) {
+ if (_search_matches[i].index == index) {
+ _search_index = int(i);
+ break;
}
}
}
@@ -618,6 +712,21 @@ void ProtocolDock::export_table_view()
void ProtocolDock::nav_table_view()
{
+ if (_table_view->model() == _match_model) {
+ if (_search_running || _search_matches.empty()) return;
+ auto stack = _session->get_decoder_model()->getDecoderStack();
+ if (!stack) return;
+ const uint64_t offset = _view.offset() * (stack->samplerate() * _view.scale());
+ auto match = std::lower_bound(_search_matches.begin(), _search_matches.end(), offset,
+ [](const SearchMatch &item, uint64_t sample) { return item.sample < sample; });
+ _search_index = match == _search_matches.end() ? int(_search_matches.size()) - 1
+ : int(match - _search_matches.begin());
+ const QModelIndex index = _match_model->index(_search_index, 1);
+ _table_view->setCurrentIndex(index);
+ _table_view->scrollTo(index);
+ item_clicked(index);
+ return;
+ }
uint64_t row_index = 0;
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
@@ -625,7 +734,8 @@ void ProtocolDock::nav_table_view()
if (decoder_stack) {
uint64_t offset = _view.offset() * (decoder_stack->samplerate() * _view.scale());
std::map<const pv::data::decode::Row, bool> rows = decoder_stack->get_rows_lshow();
- int column = _model_proxy.filterKeyColumn();
+ const int selected_column = qMax(0, _table_view->currentIndex().column());
+ int column = selected_column;
for (std::map<const pv::data::decode::Row, bool>::const_iterator i = rows.begin();
i != rows.end(); i++) {
if ((*i).second && column-- == 0) {
@@ -633,7 +743,7 @@ void ProtocolDock::nav_table_view()
break;
}
}
- QModelIndex index = _model_proxy.mapToSource(_model_proxy.index(row_index, _model_proxy.filterKeyColumn()));
+ QModelIndex index = decoder_model->index(row_index, selected_column);
if(index.isValid()){
@@ -660,196 +770,141 @@ void ProtocolDock::nav_table_view()
void ProtocolDock::search_pre()
{
- search_update();
- // now the proxy only contains rows that match the name
- // let's take the pre one and map it to the original model
- if (_model_proxy.rowCount() == 0) {
- _table_view->scrollToTop();
- _table_view->clearSelection();
- _matchs_label->setText(QString::number(0));
- _cur_search_index = -1;
- return;
- }
- int i = 0;
- uint64_t rowCount = _model_proxy.rowCount();
- QModelIndex matchingIndex;
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
-
- auto decoder_stack = decoder_model->getDecoderStack();
- do {
- _cur_search_index--;
- if (_cur_search_index <= -1 || _cur_search_index >= _model_proxy.rowCount())
- _cur_search_index = _model_proxy.rowCount() - 1;
-
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(ceil(_cur_search_index),_model_proxy.filterKeyColumn()));
- if (!decoder_stack || !matchingIndex.isValid())
- break;
- i = 1;
- uint64_t row = matchingIndex.row() + 1;
- uint64_t col = matchingIndex.column();
- pv::data::decode::Annotation ann;
- bool ann_valid = false;
-
- while(i < _str_list.size()) {
- QString nxt = _str_list.at(i);
-
- do {
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
- row++;
- }
- while(ann_valid && !ann.is_numberic());
-
- if (ann_valid){
- QString source = ann.annotations().at(0);
- if (source.contains(nxt))
- i++;
- else
- break;
- }
- else{
- break;
- }
- }
- }
- while(i < _str_list.size() && --rowCount);
-
- if(i >= _str_list.size() && matchingIndex.isValid()){
- _table_view->scrollTo(matchingIndex);
- _table_view->setCurrentIndex(matchingIndex);
- _table_view->clicked(matchingIndex);
- } else {
- _table_view->scrollToTop();
- _table_view->clearSelection();
- _matchs_label->setText(QString::number(0));
- _cur_search_index = -1;
- }
+ navigate_search(-1);
}
void ProtocolDock::search_nxt()
{
- search_update();
- // now the proxy only contains rows that match the name
- // let's take the pre one and map it to the original model
- if (_model_proxy.rowCount() == 0) {
- _table_view->scrollToTop();
- _table_view->clearSelection();
- _matchs_label->setText(QString::number(0));
- _cur_search_index = -1;
+ navigate_search(1);
+}
+
+void ProtocolDock::navigate_search(int direction)
+{
+ if (_search_running) {
+ // Finish the current query first; repeated presses do not restart it.
+ _search_direction = direction;
+ _search_timer->start(0);
return;
}
-
- int i = 0;
- uint64_t rowCount = _model_proxy.rowCount();
- QModelIndex matchingIndex;
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
- auto decoder_stack = decoder_model->getDecoderStack();
-
- if (decoder_stack == NULL){
- dsv_err("decoder_stack is null");
+ if (_search_matches.empty())
return;
- }
-
- do {
- _cur_search_index++;
- if (_cur_search_index < 0 || _cur_search_index >= _model_proxy.rowCount())
- _cur_search_index = 0;
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(floor(_cur_search_index),_model_proxy.filterKeyColumn()));
-
- if (!matchingIndex.isValid())
- break;
-
- i = 1;
- uint64_t row = matchingIndex.row() + 1;
- uint64_t col = matchingIndex.column();
- pv::data::decode::Annotation ann;
- bool ann_valid = false;
-
- while(i < _str_list.size()) {
- QString nxt = _str_list.at(i);
+ const int count = int(_search_matches.size());
+ if (_search_index < 0)
+ _search_index = direction > 0 ? 0 : count - 1;
+ else
+ _search_index = (_search_index + direction + count) % count;
+ const QModelIndex index = _match_model->index(_search_index, 1);
+ _table_view->setCurrentIndex(index);
+ _table_view->scrollTo(index);
+ item_clicked(index);
+}
- do {
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
- row++;
- }
- while(ann_valid && !ann.is_numberic());
-
- if (ann_valid){
- QString source = ann.annotations().at(0);
- if (source.contains(nxt))
- i++;
- else
- break;
- }
- else{
- break;
- }
- }
- }while(i < _str_list.size() && --rowCount);
-
- if(i >= _str_list.size() && matchingIndex.isValid()){
- _table_view->scrollTo(matchingIndex);
- _table_view->setCurrentIndex(matchingIndex);
- _table_view->clicked(matchingIndex);
- } else {
- _table_view->scrollToTop();
- _table_view->clearSelection();
- _matchs_label->setText(QString::number(0));
- _cur_search_index = -1;
- }
+void ProtocolDock::invalidate_search()
+{
+ _search_timer->stop();
+ _search_running = false;
+ _search_direction = 0;
+ _search_index = -1;
+ _search_matches.clear();
+ _match_model->setMatches({}, QString());
+ _search_column_sizes.clear();
+ _search_row = 0;
+ _search_column = 0;
+ _matchs_label->setText(QString::number(0));
}
void ProtocolDock::search_done()
{
- QString str = _ann_search_edit->text().trimmed();
- QRegularExpression rx("(-)");
- _str_list = str.split(rx);
- _model_proxy.setFilterFixedString(_str_list.first());
- if (_str_list.size() > 1)
- _matchs_label->setText("...");
- else
- _matchs_label->setText(QString::number(_model_proxy.rowCount()));
+ const int pending_direction = _search_direction;
+ invalidate_search();
+ const QString text = _ann_search_edit->text().trimmed();
+ auto decoder_model = _session->get_decoder_model();
+ QAbstractItemModel *table_model = text.isEmpty()
+ ? static_cast<QAbstractItemModel*>(decoder_model) : _match_model;
+ if (_table_view->model() != table_model)
+ _table_view->setModel(table_model);
+ auto decoder_stack = decoder_model->getDecoderStack();
+ if (text.isEmpty() || !decoder_stack)
+ return;
+
+ // Only numeric sequences use '-'. Labels like CAN-FD and negative numbers
+ // must remain literal text. Always match every waveform label variant.
+ static const QRegularExpression sequence(
+ "^[0-9A-Fa-f]+(?:\\s*-\\s*[0-9A-Fa-f]+)+$");
+ _search_terms = sequence.match(text).hasMatch()
+ ? text.split(QRegularExpression("\\s*-\\s*")) : QStringList{text};
+ for (int col = 0; col < decoder_model->columnCount(QModelIndex()); ++col)
+ _search_column_sizes.push_back(int(qMin<uint64_t>(
+ decoder_stack->list_annotation_size(col), std::numeric_limits<int>::max())));
+ _search_running = true;
+ _search_direction = pending_direction;
+ _matchs_label->setText("...");
+ _search_timer->start(0);
}
void ProtocolDock::search_changed()
{
- _search_edited = true;
- _matchs_label->setText("...");
+ // A new query cancels any pending navigation for the old text.
+ _search_direction = 0;
+ search_done();
+ if (_search_running)
+ _search_timer->start(180);
}
-void ProtocolDock::search_update()
+void ProtocolDock::search_step()
{
- if (!_search_edited)
+ if (!_search_running)
return;
-
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
-
+ auto decoder_model = _session->get_decoder_model();
auto decoder_stack = decoder_model->getDecoderStack();
- if (!decoder_stack)
+ if (!decoder_stack) {
+ invalidate_search();
return;
+ }
- if (decoder_stack->list_annotation_size(_model_proxy.filterKeyColumn()) > ProgressRows) {
- QFuture<void> future;
- future = QtConcurrent::run([&]{
- search_done();
- });
- Qt::WindowFlags flags = Qt::CustomizeWindowHint;
- QProgressDialog dlg(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCHING), "Searching..."),
- L_S(STR_PAGE_DLG, S_ID(IDS_DLG_CANCEL), "Cancel"),0,0,this,flags);
- dlg.setWindowModality(Qt::WindowModal);
- dlg.setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint |
- Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint);
- dlg.setCancelButton(NULL);
-
- QFutureWatcher<void> watcher;
- connect(&watcher,SIGNAL(finished()),&dlg,SLOT(cancel()));
- watcher.setFuture(future);
-
- dlg.exec();
- } else {
- search_done();
+ // Keep Qt models and widgets on the GUI thread, yielding between chunks.
+ // Model resets stop this timer and discard indices before a decoder dies.
+ QElapsedTimer elapsed;
+ elapsed.start();
+ while (_search_column < int(_search_column_sizes.size())) {
+ if (_search_row >= _search_column_sizes[_search_column]) {
+ ++_search_column;
+ _search_row = 0;
+ continue;
+ }
+ const int row = _search_row++;
+ if (decoder_model->annotation_matches(_search_column, row, _search_terms)) {
+ pv::data::decode::Annotation ann;
+ if (decoder_stack->list_annotation(&ann, _search_column, row))
+ _search_matches.push_back({decoder_model->index(row, _search_column),
+ ann.start_sample()});
+ }
+ if (elapsed.elapsed() >= 8) {
+ _matchs_label->setText(QString("%1...").arg(qulonglong(_search_matches.size())));
+ _search_timer->start(1);
+ return;
+ }
}
- _search_edited = false;
+
+ // Result columns have independent row numbers. Navigate in time order.
+ std::sort(_search_matches.begin(), _search_matches.end(),
+ [](const SearchMatch &a, const SearchMatch &b) {
+ if (a.sample != b.sample) return a.sample < b.sample;
+ if (a.index.column() != b.index.column()) return a.index.column() < b.index.column();
+ return a.index.row() < b.index.row();
+ });
+ _search_running = false;
+ std::vector<QModelIndex> matches;
+ matches.reserve(_search_matches.size());
+ for (const auto &match : _search_matches) matches.push_back(match.index);
+ _match_model->setMatches(matches, _search_terms.first());
+ resize_table_view(decoder_model);
+ _matchs_label->setText(QString::number(qulonglong(_search_matches.size())));
+ const int direction = _search_direction;
+ _search_direction = 0;
+ if (direction)
+ navigate_search(direction);
}
//-------------------IProtocolItemLayerCallback
@@ -1100,7 +1155,8 @@ void ProtocolDock::UpdateFont()
QRect rc = fm.boundingRect(str);
int lineHeight = rc.height() + 15;
- _pro_keyword_edit->setFixedHeight(rc.height() + 5);
+ // Include the line edit's styled padding and border so glyphs are not clipped.
+ _pro_keyword_edit->setFixedHeight(qMax(rc.height() + 5, _pro_keyword_edit->sizeHint().height()));
int pannelHeight = lineHeight * _protocol_lay_items.size() + _pro_keyword_edit->height();
if (pannelHeight < 100){
diff --git a/DSView/pv/dock/protocoldock.h b/DSView/pv/dock/protocoldock.h
index 0da4c4cc..2bcceeb8 100644
--- a/DSView/pv/dock/protocoldock.h
+++ b/DSView/pv/dock/protocoldock.h
@@ -33,9 +33,8 @@
#include <QScrollArea>
#include <QSplitter>
#include <QTableView>
-#include <QSortFilterProxyModel>
+#include <QTimer>
#include <vector>
-#include <mutex>
#include <list>
#include "../data/decodermodel.h"
#include "protocolitemlayer.h"
@@ -75,14 +74,13 @@ public IUiWindow
{
Q_OBJECT
-public:
- static const uint64_t ProgressRows = 100000;
-
public:
ProtocolDock(QWidget *parent, view::View &view, SigSession *session);
~ProtocolDock();
void del_all_protocol();
+ bool add_host_decoder(const QString &id);
+ void check_host_decoders();
bool add_protocol_by_id(QString id, bool silent, std::list<pv::data::decode::Decoder*> &sub_decoders);
void reset_view();
@@ -119,6 +117,8 @@ private:
void UpdateFont() override;
void adjustPannelSize();
+ void invalidate_search();
+ void navigate_search(int direction);
signals:
void protocol_updated();
@@ -139,18 +139,29 @@ private slots:
void search_nxt();
void search_done();
void search_changed();
- void search_update();
+ void search_step();
void show_protocol_select();
private:
SigSession *_session;
view::View &_view;
- QSortFilterProxyModel _model_proxy;
- int _cur_search_index;
- QStringList _str_list;
+ struct SearchMatch {
+ QModelIndex index;
+ uint64_t sample;
+ };
+ std::vector<SearchMatch> _search_matches;
+ std::vector<int> _search_column_sizes;
+ QTimer *_search_timer;
+ QStringList _search_terms;
+ int _search_row = 0;
+ int _search_column = 0;
+ int _search_index = -1;
+ int _search_direction = 0;
+ bool _search_running = false;
QWidget *_top_panel;
QTableView *_table_view;
+ data::AnnotationMatchModel *_match_model;
QPushButton *_pre_button;
QPushButton *_nxt_button;
PopupLineEdit *_ann_search_edit;
@@ -172,8 +183,6 @@ private:
QString _selected_protocol_id;
XToolButton *_pro_search_button;
- mutable std::mutex _search_mutex;
- bool _search_edited;
};
} // namespace dock
diff --git a/DSView/pv/mainframe.cpp b/DSView/pv/mainframe.cpp
index 9f92d42b..f493c90e 100644
--- a/DSView/pv/mainframe.cpp
+++ b/DSView/pv/mainframe.cpp
@@ -44,7 +44,8 @@
#include <QFile>
#include <QGuiApplication>
#include <QFont>
-#include <algorithm>
+#include <algorithm>
+#include <cstdio>
#include <QWindow>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
@@ -97,7 +98,8 @@ MainFrame::MainFrame()
#ifdef _WIN32
setWindowFlags(Qt::FramelessWindowHint);
_is_win32_parent_window = true;
- _taskBtn = NULL;
+ _taskBtn = NULL;
+ _taskPrg = NULL;
isWin32 = true;
#else
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint);
@@ -297,7 +299,7 @@ void MainFrame::resizeEvent(QResizeEvent *event)
void MainFrame::closeEvent(QCloseEvent *event)
{
- writeSettings();
+ if (!qApp->property("embeddedHost").toBool()) writeSettings();
if (_mainWindow->able_to_close()){
@@ -309,7 +311,8 @@ void MainFrame::closeEvent(QCloseEvent *event)
}
#endif
- event->accept();
+ event->accept();
+ if (qApp->property("embeddedHost").toBool()) qApp->quit();
}
else{
event->ignore();
@@ -645,7 +648,7 @@ void MainFrame::writeSettings()
dsv_info("Save form, x:%d, y:%d, w:%d, h:%d", x, y, w, h);
}
-void MainFrame::ShowFormInit()
+void MainFrame::ShowFormInit()
{
ReadSettings();
@@ -697,9 +700,80 @@ void MainFrame::ShowFormInit()
AttachNativeWindow();
}
#endif
-}
-
-void MainFrame::AttachNativeWindow()
+}
+
+#ifdef _WIN32
+bool MainFrame::ShowEmbedded(quintptr parentId)
+{
+ const HWND parent = reinterpret_cast<HWND>(parentId);
+ DWORD parentPid = 0;
+ GetWindowThreadProcessId(parent, &parentPid);
+ if (!parentPid || !IsWindow(parent)) return false;
+ _titleBar->hide();
+ _titleBar->EnableAbleDrag(false);
+ setMinimumSize(0, 0);
+ _mainWindow->setMinimumSize(0, 0);
+ setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
+ const HWND child = reinterpret_cast<HWND>(winId());
+ SetWindowLongPtr(child, GWL_STYLE, WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS);
+ SetWindowLongPtr(child, GWL_EXSTYLE, GetWindowLongPtr(child, GWL_EXSTYLE) & ~WS_EX_APPWINDOW);
+ SetLastError(0);
+ SetParent(child, parent);
+ if (GetLastError() != 0 || GetParent(child) != parent) return false;
+ _mainWindow->restore_dock();
+ PopupDlgList::SetCurrentScreen(QGuiApplication::primaryScreen());
+ QByteArray commandBuffer;
+ auto fit = [this, parent, child, parentPid, commandBuffer]() mutable {
+ DWORD currentPid = 0;
+ GetWindowThreadProcessId(parent, &currentPid);
+ if (!IsWindow(parent) || currentPid != parentPid) {
+ qApp->quit();
+ return;
+ }
+ DWORD available = 0;
+ const HANDLE input = GetStdHandle(STD_INPUT_HANDLE);
+ if (PeekNamedPipe(input, NULL, 0, NULL, &available, NULL) && available) {
+ char data[256];
+ DWORD count = 0;
+ if (ReadFile(input, data, qMin<DWORD>(available, sizeof(data)), &count, NULL)) {
+ commandBuffer.append(data, count);
+ int newline;
+ while ((newline = commandBuffer.indexOf('\n')) >= 0) {
+ const QByteArray command = commandBuffer.left(newline).trimmed();
+ commandBuffer.remove(0, newline + 1);
+ if (command == "CLOSE") close();
+ else if (command.startsWith("ADD_DECODER "))
+ _mainWindow->addHostDecoder(QString::fromUtf8(command.mid(12)));
+ else if (command.startsWith("OPEN_FILE ")) {
+ const QString path = QString::fromUtf8(QByteArray::fromBase64(command.mid(10)));
+ printf("DSVIEW_FILE_RESULT %s\n", _mainWindow->openHostFile(path));
+ fflush(stdout);
+ }
+ }
+ if (commandBuffer.size() > 262144) commandBuffer.clear();
+ }
+ }
+ RECT rect, childRect;
+ GetClientRect(parent, &rect);
+ GetWindowRect(child, &childRect);
+ const int width = rect.right - rect.left;
+ const int height = rect.bottom - rect.top;
+ if (width > 0 && height > 0 &&
+ (childRect.right - childRect.left != width || childRect.bottom - childRect.top != height))
+ ::MoveWindow(child, 0, 0, width, height, TRUE);
+ };
+ QTimer *hostTimer = new QTimer(this);
+ connect(hostTimer, &QTimer::timeout, this, fit);
+ hostTimer->start(50);
+ fit();
+ QFrame::show();
+ if (!qgetenv("DSVIEW_HOST_CHECK").isEmpty())
+ QTimer::singleShot(3500, _mainWindow, &MainWindow::checkHostDecoders);
+ return true;
+}
+#endif
+
+void MainFrame::AttachNativeWindow()
{
#ifdef _WIN32
@@ -1022,7 +1096,7 @@ void MainFrame::ReadSettings()
void MainFrame::showEvent(QShowEvent *event)
{
// Taskbar Progress Effert for Win7 and Above
- if (_taskBtn && _taskBtn->window() == NULL) {
+ if (!qApp->property("embeddedHost").toBool() && _taskBtn && _taskBtn->window() == NULL) {
_taskBtn->setWindow(windowHandle());
_taskPrg = _taskBtn->progress();
}
@@ -1032,8 +1106,9 @@ void MainFrame::showEvent(QShowEvent *event)
void MainFrame::setTaskbarProgress(int progress)
{
-#ifdef _WIN32
- if (progress > 0) {
+#ifdef _WIN32
+ if (!_taskPrg) return;
+ if (progress > 0) {
_taskPrg->setVisible(true);
_taskPrg->setValue(progress);
} else {
@@ -1107,8 +1182,14 @@ QWidget* MainFrame::GetBodyView()
}
#ifdef _WIN32
-bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
-{
+bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
+{
+ if (qApp->property("embeddedHost").toBool() &&
+ static_cast<MSG*>(message)->message == WM_CLOSE) {
+ QTimer::singleShot(0, this, [this]() { close(); });
+ *result = 0;
+ return true;
+ }
if (_parentNativeWidget != NULL)
{
MSG *msg = static_cast<MSG*>(message);
diff --git a/DSView/pv/mainframe.h b/DSView/pv/mainframe.h
index 0c2ec3ee..76f91faa 100644
--- a/DSView/pv/mainframe.h
+++ b/DSView/pv/mainframe.h
@@ -99,7 +99,10 @@ public:
public:
MainFrame();
- void ShowFormInit();
+ void ShowFormInit();
+#ifdef _WIN32
+ bool ShowEmbedded(quintptr parentId);
+#endif
void ShowHelpDocAsync();
bool IsMaxsized();
diff --git a/DSView/pv/mainwindow.cpp b/DSView/pv/mainwindow.cpp
index 74dc348b..c6800415 100644
--- a/DSView/pv/mainwindow.cpp
+++ b/DSView/pv/mainwindow.cpp
@@ -22,12 +22,16 @@
#include <QAction>
#include <QButtonGroup>
-#include <QFileDialog>
+#include <QFileDialog>
+#include <QFileInfo>
#include <QMessageBox>
#include <QMenu>
#include <QMenuBar>
#include <QStatusBar>
-#include <QVBoxLayout>
+#include <QVBoxLayout>
+#include <QScrollArea>
+#include <QScrollBar>
+#include <QSplitter>
#include <QWidget>
#include <QDesktopServices>
#include <QKeyEvent>
@@ -108,8 +112,60 @@
namespace pv
{
- namespace{
- QString tmp_file;
+ namespace{
+ QString tmp_file;
+
+ // Keep the instrument controls above the waveform, rather than above
+ // the right-hand docks. Narrow hosts scroll instead of wrapping Help.
+ class EmbeddedToolbars : public QScrollArea {
+ public:
+ EmbeddedToolbars(const QList<QToolBar*> &bars, QWidget *parent)
+ : QScrollArea(parent), _bars(bars)
+ {
+ setFrameShape(QFrame::NoFrame);
+ setWidgetResizable(true);
+ setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ auto panel = new QWidget(this);
+ auto layout = new QHBoxLayout(panel);
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(0);
+ layout->setSizeConstraint(QLayout::SetMinAndMaxSize);
+ for (auto bar : _bars) {
+ layout->addWidget(bar);
+ bar->installEventFilter(this);
+ }
+ layout->addStretch(1);
+ setWidget(panel);
+ panel->installEventFilter(this);
+ updateSize();
+ }
+ protected:
+ bool eventFilter(QObject *object, QEvent *event) override {
+ if (event->type() == QEvent::LayoutRequest)
+ updateSize();
+ return QScrollArea::eventFilter(object, event);
+ }
+ void resizeEvent(QResizeEvent *event) override {
+ QScrollArea::resizeEvent(event);
+ updateSize();
+ }
+ private:
+ void updateSize() {
+ int height = 0;
+ int width = 0;
+ for (auto bar : _bars) {
+ const QSize hint = bar->sizeHint();
+ bar->setMinimumSize(hint);
+ width += hint.width();
+ height = qMax(height, hint.height());
+ }
+ if (width > viewport()->width())
+ height += horizontalScrollBar()->sizeHint().height();
+ setFixedHeight(height);
+ }
+ QList<QToolBar*> _bars;
+ };
}
MainWindow::MainWindow(toolbars::TitleBar *title_bar, QWidget *parent)
@@ -189,10 +245,15 @@ namespace pv
setIconSize(QSize(40, 40));
- addToolBar(_sampling_bar);
- addToolBar(_trig_bar);
- addToolBar(_file_bar);
- addToolBar(_logo_bar);
+ if (qApp->property("embeddedHost").toBool()) {
+ _vertical_layout->insertWidget(0, new EmbeddedToolbars(
+ {_sampling_bar, _trig_bar, _file_bar, _logo_bar}, _central_widget));
+ } else {
+ addToolBar(_sampling_bar);
+ addToolBar(_trig_bar);
+ addToolBar(_file_bar);
+ addToolBar(_logo_bar);
+ }
// Setup the dockWidget
_protocol_dock = new QDockWidget(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_DOCK_TITLE), "Decode Protocol"), this);
@@ -200,8 +261,43 @@ namespace pv
_protocol_dock->setFeatures(QDockWidget::DockWidgetMovable);
_protocol_dock->setAllowedAreas(Qt::RightDockWidgetArea);
_protocol_dock->setVisible(false);
- _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
- _protocol_dock->setWidget(_protocol_widget);
+ _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
+ if (qApp->property("embeddedHost").toBool()) {
+ // The dock occupies the right column, but its visible panel can be
+ // shortened from the top. Move the title with the whole panel.
+ _protocol_dock->setTitleBarWidget(new QWidget(_protocol_dock));
+ _protocol_dock->setFeatures(QDockWidget::NoDockWidgetFeatures);
+ auto outer = new QSplitter(Qt::Vertical, _protocol_dock);
+ outer->setObjectName("decoderOuterSplit");
+ auto space = new QWidget(outer);
+ space->setMinimumHeight(0);
+ auto panel = new QWidget(outer);
+ auto panel_layout = new QVBoxLayout(panel);
+ panel_layout->setContentsMargins(0, 0, 0, 0);
+ panel_layout->setSpacing(0);
+ auto title = new QLabel(_protocol_dock->windowTitle(), panel);
+ title->setStyleSheet("background: #151d28; padding: 5px 10px;");
+ connect(_protocol_dock, &QWidget::windowTitleChanged, title, &QLabel::setText);
+ panel_layout->addWidget(title);
+ panel_layout->addWidget(_protocol_widget, 1);
+ outer->addWidget(space);
+ outer->addWidget(panel);
+ outer->setCollapsible(0, true);
+ outer->setCollapsible(1, false);
+ outer->setHandleWidth(10);
+ outer->setStretchFactor(0, 0);
+ outer->setStretchFactor(1, 1);
+ outer->setSizes({0, 600});
+ outer->handle(1)->setCursor(Qt::SizeVerCursor);
+ outer->handle(1)->setToolTip(tr("Drag the top edge to resize the entire Decoders panel"));
+ outer->setStyleSheet(
+ "QSplitter#decoderOuterSplit::handle:vertical { background: #243247; "
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
+ "QSplitter#decoderOuterSplit::handle:vertical:hover { background: #1570d8; }");
+ _protocol_dock->setWidget(outer);
+ } else {
+ _protocol_dock->setWidget(_protocol_widget);
+ }
_session->set_decoder_pannel(_protocol_widget);
@@ -479,7 +575,38 @@ namespace pv
return true;
}
- void MainWindow::on_protocol(bool visible)
+ const char *MainWindow::openHostFile(const QString &path)
+ {
+ if (_session->is_working() || _session->is_saving() || QApplication::activeModalWidget())
+ return "busy";
+ if (!QFileInfo(path).isFile() || QFileInfo(path).suffix().compare("dsl", Qt::CaseInsensitive))
+ return "error";
+ if (confirm_to_store_data()) {
+ on_save();
+ return "save";
+ }
+ if (_session->is_working() || _session->is_saving()) return "busy";
+ if (_device_agent->is_hardware()) save_config();
+ try {
+ return _session->set_file(path) ? "ok" : "error";
+ } catch (...) {
+ return "error";
+ }
+ }
+
+ void MainWindow::addHostDecoder(const QString &id)
+ {
+ on_protocol(true);
+ _protocol_widget->add_host_decoder(id);
+ }
+
+ void MainWindow::checkHostDecoders()
+ {
+ on_protocol(true);
+ _protocol_widget->check_host_decoders();
+ }
+
+ void MainWindow::on_protocol(bool visible)
{
_protocol_dock->setVisible(visible);
@@ -1414,8 +1541,9 @@ namespace pv
_session->update_lang_text();
}
- void MainWindow::switchTheme(QString style)
- {
+ void MainWindow::switchTheme(QString style)
+ {
+ if (qApp->property("embeddedHost").toBool()) style = "dark";
AppConfig &app = AppConfig::Instance();
if (app.frameOptions.style != style)
@@ -1427,7 +1555,25 @@ namespace pv
QString qssRes = ":/" + style + ".qss";
QFile qss(qssRes);
qss.open(QFile::ReadOnly | QFile::Text);
- qApp->setStyleSheet(qss.readAll());
+ QString stylesheet = QString::fromUtf8(qss.readAll());
+ if (qApp->property("embeddedHost").toBool()) {
+ QFile hostStyle(QString::fromUtf8(qgetenv("DSVIEW_HOST_STYLE")));
+ if (hostStyle.open(QFile::ReadOnly | QFile::Text))
+ stylesheet += QString::fromUtf8(hostStyle.readAll());
+ QPalette palette;
+ palette.setColor(QPalette::Window, QColor("#10151d"));
+ palette.setColor(QPalette::Base, QColor("#111c29"));
+ palette.setColor(QPalette::AlternateBase, QColor("#151d28"));
+ palette.setColor(QPalette::WindowText, QColor("#dce6f2"));
+ palette.setColor(QPalette::Text, QColor("#dce6f2"));
+ palette.setColor(QPalette::Button, QColor("#243247"));
+ palette.setColor(QPalette::ButtonText, QColor("#dce6f2"));
+ palette.setColor(QPalette::Highlight, QColor("#1570d8"));
+ palette.setColor(QPalette::HighlightedText, Qt::white);
+ qApp->setPalette(palette);
+ qApp->setFont(QFont("Segoe UI", 10));
+ }
+ qApp->setStyleSheet(stylesheet);
qss.close();
UiManager::Instance()->Update(UI_UPDATE_ACTION_THEME);
diff --git a/DSView/pv/mainwindow.h b/DSView/pv/mainwindow.h
index a43e0427..8212fe14 100644
--- a/DSView/pv/mainwindow.h
+++ b/DSView/pv/mainwindow.h
@@ -93,7 +93,10 @@ public:
public:
explicit MainWindow(toolbars::TitleBar *title_bar, QWidget *parent = 0);
- void openDoc();
+ void openDoc();
+ void addHostDecoder(const QString &id);
+ const char *openHostFile(const QString &path);
+ void checkHostDecoders();
public slots:
void switchTheme(QString style);
diff --git a/DSView/pv/toolbars/filebar.cpp b/DSView/pv/toolbars/filebar.cpp
index f562431c..792c6971 100644
--- a/DSView/pv/toolbars/filebar.cpp
+++ b/DSView/pv/toolbars/filebar.cpp
@@ -24,6 +24,7 @@
#include <QFileDialog>
#include <deque>
#include <QApplication>
+#include <cstdio>
#include "filebar.h"
#include "../ui/msgbox.h"
@@ -146,7 +147,9 @@ void FileBar::on_actionOpen_triggered()
this,
L_S(STR_PAGE_DLG, S_ID(IDS_DLG_OPEN_FILE), "Open File"),
app.userHistory.openDir,
- "DSView Data (*.dsl)");
+ qApp->property("embeddedHost").toBool()
+ ? "Digital captures (*.dsl *.csv);;DSView Data (*.dsl);;CSV (*.csv)"
+ : "DSView Data (*.dsl)");
if (!file_name.isEmpty()) {
QString fname = path::GetDirectoryName(file_name);
@@ -155,7 +158,12 @@ void FileBar::on_actionOpen_triggered()
app.SaveHistory();
}
- sig_load_file(file_name);
+ if (qApp->property("embeddedHost").toBool() && file_name.endsWith(".csv", Qt::CaseInsensitive)) {
+ printf("DSVIEW_IMPORT_CSV %s\n", file_name.toUtf8().toBase64().constData());
+ fflush(stdout);
+ } else {
+ sig_load_file(file_name);
+ }
}
}
diff --git a/DSView/pv/toolbars/logobar.cpp b/DSView/pv/toolbars/logobar.cpp
index 4b3b492c..95c28b49 100644
--- a/DSView/pv/toolbars/logobar.cpp
+++ b/DSView/pv/toolbars/logobar.cpp
@@ -106,13 +106,17 @@ LogoBar::LogoBar(SigSession *session, QWidget *parent) :
_logo_button.setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
_logo_button.setPopupMode(QToolButton::InstantPopup);
- QWidget *spacer = new QWidget(this);
- spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
- addWidget(spacer);
+ if (!qApp->property("embeddedHost").toBool()) {
+ QWidget *spacer = new QWidget(this);
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ addWidget(spacer);
+ }
addWidget(&_logo_button);
- QWidget *margin = new QWidget(this);
- margin->setMinimumWidth(20);
- addWidget(margin);
+ if (!qApp->property("embeddedHost").toBool()) {
+ QWidget *margin = new QWidget(this);
+ margin->setMinimumWidth(20);
+ addWidget(margin);
+ }
connect(_action_en, SIGNAL(triggered()), this, SLOT(on_actionEn_triggered()));
connect(_action_cn, SIGNAL(triggered()), this, SLOT(on_actionCn_triggered()));

View File

@@ -0,0 +1,24 @@
#include "gate_summary.h"
#include <cassert>
int main() {
GateSummary summary;
assert(summary.severity(true) == 1); // Empty captures are not OK.
summary.observe(0); summary.observe(1); summary.observe(2);
assert(summary.severity(false) == 1);
assert(summary.severity(true) == 0);
summary.observe(0); // A command without a complete ACK at EOF.
assert(summary.severity(true) == 1);
summary.observe(2);
assert(summary.severity(true) == 0);
summary.observe(3);
assert(summary.severity(true) == 1);
summary.observe(4);
assert(summary.severity(false) == 2);
assert(summary.severity(true) == 2);
summary.observe(2); // Later good events never hide earlier faults.
assert(summary.severity(true) == 2);
summary.reset();
assert(summary.severity(true) == 1);
summary.observe(5);
assert(summary.severity(true) == 2);
}

View File

@@ -0,0 +1,438 @@
"""Wire vectors and DSView API boundary tests; no sigrok installation needed."""
import binascii
import importlib
from pathlib import Path
import struct
import sys
import types
import tempfile
import importlib.util
import unittest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location('dsview_build', ROOT / 'build.py')
BUILD = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(BUILD)
RUNTIME = tempfile.TemporaryDirectory()
BUILD.package_decoders(Path(RUNTIME.name))
(Path(RUNTIME.name) / 'decoders/common/__init__.py').write_text('')
class FakeDecoder:
def has_channel(self, channel):
return channel < 2
def register(self, kind):
return kind
def put(self, ss, es, output, data):
self.emitted.append((ss, es, output, data))
stub = types.ModuleType('sigrokdecode')
stub.Decoder = FakeDecoder
stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = 0, 1, 2
previous = sys.modules.get('sigrokdecode')
sys.modules['sigrokdecode'] = stub
sys.path.insert(0, str(Path(RUNTIME.name) / 'decoders'))
try:
uart = importlib.import_module('set_uart.pd')
wire = importlib.import_module('common.setgui_decoders.set_uart')
can = importlib.import_module('set_can.pd')
cancore = importlib.import_module('common.setgui_decoders.set_can')
pm = importlib.import_module('pm35_uart.pd')
gate = importlib.import_module('gate_driver_timing.pd')
pair_decoder = importlib.import_module('transistor_pair.pd')
pmcore = importlib.import_module('common.setgui_decoders.pm35_uart')
finally:
sys.path.pop(0)
if previous is None:
del sys.modules['sigrokdecode']
else:
sys.modules['sigrokdecode'] = previous
PING = bytes.fromhex('A5 5A 02 08 01 00 00 00 2A 00 34 12 00 00 33 EC 33 04')
BRIDGE = bytes.fromhex('AA 55 08 01 01 67 45 23 01 AA BB FE 14')
def crc32_frame(header, payload=b''):
data = header + payload
return data + struct.pack('<I', binascii.crc32(data[2:]) & 0xffffffff)
def modbus(raw):
return raw + struct.pack('<H', wire.crc16(raw, True))
def feed(parser, raw):
events = []
for i, byte in enumerate(raw):
events.extend(parser.feed(byte, i * 10, i * 10 + 9))
return events
def instance(module, **options):
decoder = module.Decoder()
decoder.options = {item['id']: item['default'] for item in decoder.options}
decoder.options.update(options)
decoder.emitted = []
decoder.metadata(2, 1000000)
decoder.start()
return decoder
def uart_bytes(decoder, raw, direction=0, base=0):
for i, byte in enumerate(raw):
ss = base + i * 100
decoder.decode(ss, ss+80, ('DATA', direction, (byte, [])))
decoder.decode(ss, ss+90, ('FRAME', direction, (byte, True)))
def frames(decoder):
return [item[3][1] for item in decoder.emitted if item[2] == 1]
class WireTests(unittest.TestCase):
def test_reference_crc(self):
self.assertEqual(wire.crc16(b'123456789'), 0x29b1)
self.assertEqual(wire.crc16(b'123456789', True), 0x4b37)
def test_documented_ping(self):
result = wire.parse_frame(PING)
self.assertEqual((result['destination'], result['sequence']), (42, 0x1234))
def test_documented_bridge(self):
result = wire.parse_frame(BRIDGE)
self.assertEqual((result['can_id'], result['payload']), (0x1234567, b'\xaa\xbb'))
def test_v1_big_endian_header(self):
frame = crc32_frame(bytes.fromhex('a55a010c12340003'), b'abc')
result = wire.parse_frame(frame)
self.assertEqual(result['sequence'], 0x1234)
self.assertIn('FIRMWARE_DATA', result['summary'])
def test_noise_crc_and_resync(self):
damaged = bytearray(PING)
damaged[-1] ^= 1
events = feed(wire.StreamParser(), b'noise\xa5' + damaged + PING + BRIDGE)
self.assertEqual(sum(e[2] is not None for e in events), 2)
self.assertTrue(any(e[3] and 'CRC' in e[3] for e in events))
def test_bad_version_and_length(self):
events = feed(wire.StreamParser(), bytes.fromhex('a55aff a55a010100000201') + PING)
self.assertEqual(sum(e[2] is not None for e in events), 1)
self.assertGreaterEqual(sum(bool(e[3]) for e in events), 2)
def test_max_payload(self):
raw = crc32_frame(bytes.fromhex('a55a020009000000000001000002'), bytes(range(256))*2)
events = feed(wire.StreamParser(), raw)
self.assertEqual(len(events), 1)
self.assertEqual(len(events[0][2]['payload']), 512)
def test_unknown_message_preserved(self):
raw = crc32_frame(bytes.fromhex('a55a020034120000000001000000'))
self.assertEqual(wire.parse_frame(raw)['message_type'], 0x1234)
def test_uart_directions_are_independent(self):
decoder = instance(uart)
for i, byte in enumerate(PING):
decoder.decode(i*10, i*10+9, ('FRAME', 0, (byte, True)))
decoder.decode(i*10, i*10+9, ('FRAME', 1, (byte, True)))
self.assertEqual([f['direction'] for f in frames(decoder)], [0, 1])
def test_uart_invalid_last_byte_not_emitted(self):
decoder = instance(uart)
uart_bytes(decoder, PING[:-1])
decoder.decode(2000, 2090, ('FRAME', 0, (PING[-1], False)))
self.assertFalse(frames(decoder))
uart_bytes(decoder, PING, base=3000)
self.assertEqual(len(frames(decoder)), 1)
def test_uart_gap_discards_partial(self):
decoder = instance(uart)
uart_bytes(decoder, PING[:10])
uart_bytes(decoder, PING, base=200000)
self.assertEqual(len(frames(decoder)), 1)
class CanTests(unittest.TestCase):
def segments(self, packet=PING):
yield bytes([0x10]) + struct.pack('<H', len(packet)) + packet[:5]
for i, offset in enumerate(range(5, len(packet), 7), 1):
yield bytes([0x20 | (i & 15)]) + packet[offset:offset+7]
def test_set_reassembly(self):
decoder = instance(can, protocol='set-v2')
for i, data in enumerate(self.segments()):
decoder.decode(i*1000, i*1000+900, ('extended', 0x122a0000, 'data', len(data), list(data)))
self.assertEqual(frames(decoder)[-1]['raw'], PING)
def test_missing_segment(self):
reader = cancore.Reassembler()
parts = list(self.segments())
reader.feed(0x122a0000, parts[0], 0, 9, 0)
events = reader.feed(0x122a0000, parts[2], 10, 19, 1)
self.assertIn('sequence', events[-1][3])
self.assertFalse(reader.pending)
def test_timeout_and_new_first(self):
reader = cancore.Reassembler()
first = next(self.segments())
reader.feed(0x122a0000, first, 0, 9, 0)
events = reader.feed(0x122a0000, first, 20, 29, 500)
self.assertIn('timeout', events[0][3])
self.assertEqual(reader.pending[0x122a0000]['start'], 20)
def test_id_mismatch(self):
reader = cancore.Reassembler()
for i, part in enumerate(self.segments()):
events = reader.feed(0x122b0000, part, i*10, i*10+9, i)
self.assertIn('disagree', events[-1][3])
def test_sequence_wrap(self):
packet = crc32_frame(bytes.fromhex('a55a0200010000002a0001008000'), bytes(128))
reader = cancore.Reassembler()
for i, part in enumerate(self.segments(packet)):
events = reader.feed(0x122a0000, part, i*10, i*10+9, i)
self.assertEqual(events[-1][2]['raw'], packet)
def test_interleaved_channels(self):
reader = cancore.Reassembler()
complete = []
for i, part in enumerate(self.segments()):
for channel in (0, 1):
complete += reader.feed(0x122a0000 | channel, part, i*10, i*10+9, i)
self.assertEqual(sum(bool(e[2] and 'raw' in e[2]) for e in complete), 2)
def test_gas(self):
result = cancore.legacy(0x1b530010, b'\x34\x12\xfe\xff')
self.assertEqual(result['registers'], [(16, 0x1234), (17, 0xfffe)])
with self.assertRaises(ValueError):
cancore.legacy(0x1b530010, b'\x00')
def test_balsam_mask_and_endianness(self):
result = cancore.legacy(0xba0010, bytes.fromhex('a01012340000ffff'), 'balsam')
self.assertEqual(result['registers'], [(16, 0x1234), (18, 0xffff)])
self.assertIsNone(cancore.legacy(0xba000f, bytes(8), 'balsam'))
def test_ignore_standard_and_remote(self):
decoder = instance(can)
decoder.decode(0, 9, ('standard', 0x123, 'data', 0, []))
decoder.decode(10, 19, ('extended', 0x1b530010, 'remote', 0, []))
self.assertFalse(decoder.emitted)
def test_boot_vectors(self):
self.assertIn('ENTER_BOOT', cancore.legacy(0x13590702, b'')['summary'])
self.assertIn('offset=0x8', cancore.legacy(0x135b0001, bytes.fromhex('1011121314151617'))['summary'])
self.assertIn('status=0', cancore.legacy(0x1b5c0702, bytes.fromhex('00ff000000000000'))['summary'])
class PM35Tests(unittest.TestCase):
def test_single_wire_auto(self):
reader = pmcore.PM35Parser('auto')
raw = modbus(bytes.fromhex('100300000002')) + modbus(bytes.fromhex('1003041234ffff'))
events = feed(reader, raw)
self.assertEqual(len(events), 2)
self.assertTrue(all(event[2] for event in events))
def test_request_and_response(self):
request = modbus(bytes.fromhex('100300000002'))
response = modbus(bytes.fromhex('1003041234ffff'))
decoder = instance(pm)
uart_bytes(decoder, request, 1)
uart_bytes(decoder, response, 0, 10000)
self.assertEqual(len(frames(decoder)), 2)
self.assertEqual(frames(decoder)[1]['values'], [0x1234, 65535])
def test_128_word_legacy_reply(self):
events = feed(pmcore.PM35Parser('response'), modbus(b'\x10\x03\x00' + bytes(256)))
self.assertEqual(len(events[0][2]['values']), 128)
def test_write_command(self):
result = pmcore.parse_pm35(modbus(bytes.fromhex('1006007f8000')), 'request')
self.assertIn('command_bits=8000', result['summary'])
def test_exception(self):
result = pmcore.parse_pm35(modbus(bytes.fromhex('108302')), 'response')
self.assertIn('exception', result['summary'])
def test_crc_and_range_errors(self):
with self.assertRaisesRegex(ValueError, 'CRC'):
pmcore.parse_pm35(bytes.fromhex('1003000000010000'), 'request')
with self.assertRaisesRegex(ValueError, 'range'):
pmcore.parse_pm35(modbus(bytes.fromhex('1003007f0002')), 'request')
class GateAdapterTests(unittest.TestCase):
def test_wait_releases_dsview_shared_pin_tuple(self):
for channels, pair in (((1,), 0), ((0, 1), 0), ((0, 1), 1)):
with self.subTest(channels=channels, pair=pair):
decoder = instance(pair_decoder if pair else gate)
decoder.has_channel = lambda channel: channel in channels
shared_pins = tuple([0, 0])
calls = []
def wait(conditions):
# Native DSView owns one reference and PyTuple_SetItem
# rejects any reference retained by the previous caller.
self.assertEqual(sys.getrefcount(shared_pins), 2)
if len(calls) == 3:
raise StopIteration
calls.append(conditions)
decoder.samplenum = len(calls) * 10
decoder.matched = 0
return shared_pins
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
self.assertEqual(len(calls), 3)
def test_orphan_filter_boundaries_profiles_and_polarities(self):
for profile in ('1SP0635', '1SD536F2'):
for active_high in (True, False):
with self.subTest(profile=profile, active_high=active_high):
checker = gate.TimingChecker(1_000_000_000, profile=profile,
vstat_active_high=active_high, orphan_min_width_ns=50)
for start, width, expected in ((100, 20, []), (200, 49, []),
(300, 50, ['orphan']), (400, 51, ['orphan'])):
self.assertEqual(checker.on_status_edge(start, int(active_high)), [])
events = checker.on_status_edge(start + width, int(not active_high))
self.assertEqual([event['kind'] for event in events], expected)
self.assertIsNone(checker.status_start)
def test_orphan_filter_preserves_ack_errors_faults_and_missing_ack(self):
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=10_000)
checker.on_control_edge(0, 1)
self.assertEqual(checker.on_status_edge(250, 1)[0]['kind'], 'delay_ok')
self.assertEqual(checker.on_status_edge(950, 0)[0]['kind'], 'width_ok')
checker.on_control_edge(1000, 0)
checker.on_status_edge(1250, 1)
self.assertEqual(checker.on_status_edge(1270, 0)[0]['kind'], 'width_fail')
checker.on_status_edge(2000, 1)
self.assertEqual(checker.on_status_edge(4000, 0)[0]['kind'], 'fault')
checker.on_control_edge(5000, 1)
self.assertEqual(checker.expire(checker.next_deadline())[0]['kind'], 'missing')
def test_orphan_filter_default_disabled_and_unknown_duration_retained(self):
checker = gate.TimingChecker(1_000_000_000)
checker.on_status_edge(10, 1)
self.assertEqual(checker.on_status_edge(30, 0)[0]['short'], 'ORPHAN 20.0 ns')
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=50)
# A recording starting within a pulse gives no measurable pulse width.
self.assertEqual(checker.on_status_edge(10, 0)[0]['short'], 'Vstat?')
def test_orphan_filter_rejects_invalid_thresholds(self):
for value in (-1, float('nan'), float('inf')):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'ORPHAN'):
gate.TimingChecker(100_000_000, orphan_min_width_ns=value)
def test_orphan_filter_option_through_native_edge_api(self):
decoder = instance(gate, orphan_min_width_ns=50)
decoder.metadata(2, 100_000_000)
decoder.samplenum = 0
samples = iter([(10, 1), (12, 0), (20, 1), (25, 0)])
def wait(conditions):
decoder.samplenum, level = next(samples)
decoder.matched = 2
return 0, level
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
self.assertEqual(len(decoder.emitted), 1)
self.assertEqual(decoder.emitted[0][:2], (20, 25))
self.assertEqual(decoder.emitted[0][3][1][1], 'ORPHAN 50.0 ns')
def test_profiles_through_native_edge_api(self):
for profile, active, edges, expected in [
('1SP0635', 'high', [(100, 1, 0, 1), (125, 1, 1, 2), (195, 1, 0, 2)], 700),
('1SD536F2', 'low', [(20, 1, 1, 1), (58, 1, 0, 2), (148, 1, 1, 2)], 900),
]:
with self.subTest(profile=profile):
decoder = instance(gate, profile=profile, vstat_active=active)
decoder.metadata(2, 100_000_000)
decoder.samplenum = 0
samples = iter(edges)
def wait(conditions):
decoder.samplenum, vin, vstat, decoder.matched = next(samples)
return vin, vstat
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 1, 2])
self.assertIn(str(expected), decoder.emitted[-1][3][1][0])
def test_driver_has_no_pair_controls(self):
decoder = instance(gate)
self.assertNotIn('pair_analysis', decoder.options)
self.assertNotIn('vin2_mintime_ns', decoder.options)
self.assertEqual([c['id'] for c in gate.Decoder.optional_channels], ['vin', 'vstat'])
def test_pair_decoder_without_vstat(self):
decoder = instance(pair_decoder, vin2_mintime_ns=200, deadtime_12_ns=50)
decoder.metadata(2, 1_000_000_000)
samples = iter([(0, (0, 0)), (10, (1, 0)), (110, (0, 0)),
(140, (0, 1)), (240, (0, 0)), (280, (1, 0))])
def wait(conditions):
decoder.samplenum, pins = next(samples)
return pins
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
text = ' '.join(item[3][1][0] for item in decoder.emitted)
self.assertIn('Deadtime Vin1 -> Vin2: 30.0 ns', text)
self.assertIn('FAIL: Vin2 active', text)
self.assertIn('duty', text)
self.assertNotIn('ACK', text)
self.assertTrue(any(e[3][0] == 3 for e in decoder.emitted))
decoder.reset()
self.assertIsNone(decoder.checker)
def test_status_only_and_pair_requires_two_inputs(self):
decoder = instance(gate)
decoder.has_channel = lambda channel: channel == 1
samples = iter([(10, 1), (1010, 0)])
def wait(conditions):
self.assertEqual(conditions, [{1: 'e'}])
decoder.samplenum, status = next(samples)
decoder.matched = 1
return (255, status, 255)
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
self.assertEqual(len(decoder.emitted), 1)
decoder = instance(pair_decoder)
decoder.has_channel = lambda channel: channel == 0
with self.assertRaisesRegex(ValueError, 'Vin1 and Vin2'):
decoder.decode()
def test_missing_ack_timer_emits_error(self):
decoder = instance(gate)
decoder.metadata(2, 100_000_000)
decoder.samplenum = 0
calls = []
def wait(conditions):
calls.append(conditions)
if len(calls) == 1:
decoder.samplenum, decoder.matched = 100, 1
elif len(calls) == 2:
decoder.samplenum += conditions[2]['skip']
decoder.matched = 4
else:
raise StopIteration
return 1, 0
decoder.wait = wait
with self.assertRaises(StopIteration):
decoder.decode()
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 4])
def test_packaged_core_matches_canonical_templates(self):
canonical = ROOT.parents[1] / 'python/logic_analyzer/decoders'
packaged = Path(RUNTIME.name) / 'decoders/common/setgui_decoders'
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
self.assertEqual((canonical / (name + '.py')).read_bytes(),
(packaged / (name + '.py')).read_bytes())
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,21 @@
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
COMPILER = shutil.which('g++') or (r'C:/setcorp/tools/msys64/ucrt64/bin/g++.exe' if os.name == 'nt' else None)
@unittest.skipUnless(COMPILER and Path(COMPILER).is_file(), 'C++ compiler required')
class GateSummaryTests(unittest.TestCase):
def test_verdict_priority_completion_and_reset(self):
with tempfile.TemporaryDirectory() as temporary:
binary = Path(temporary) / ('summary.exe' if os.name == 'nt' else 'summary')
env = dict(os.environ)
env['PATH'] = str(Path(COMPILER).parent) + os.pathsep + env.get('PATH', '')
subprocess.run([COMPILER, '-std=c++11', '-static', '-I', str(ROOT / 'native'),
str(ROOT / 'tests/gate_summary_test.cpp'), '-o', str(binary)],
env=env, check=True, capture_output=True)
subprocess.run([str(binary)], env=env, check=True, capture_output=True)

View File

@@ -0,0 +1,156 @@
import importlib.util
from pathlib import Path
import unittest
MODULE_PATH = (Path(__file__).resolve().parents[3] / 'python/logic_analyzer/decoders/gate_timing.py')
SPEC = importlib.util.spec_from_file_location('gate_driver_timing_core', MODULE_PATH)
TIMING = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(TIMING)
def kinds(events):
return [item['kind'] for item in events]
class TimingCheckerTests(unittest.TestCase):
def test_1sp0635_nominal_ack_at_100_mhz(self):
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
checker.on_control_edge(100, 1)
self.assertEqual(kinds(checker.on_status_edge(125, 1)), ['delay_ok'])
result = checker.on_status_edge(195, 0)
self.assertEqual(kinds(result), ['width_ok'])
self.assertEqual(result[0]['width_ns'], 700.0)
def test_1sd536f2_nominal_ack_at_100_mhz_active_low(self):
checker = TIMING.TimingChecker(
100_000_000, profile='1SD536F2', vstat_active_high=False)
checker.on_control_edge(20, 1)
self.assertEqual(kinds(checker.on_status_edge(58, 0)), ['delay_ok'])
result = checker.on_status_edge(148, 1)
self.assertEqual(kinds(result), ['width_ok'])
self.assertEqual(result[0]['width_ns'], 900.0)
def test_late_delay_is_warning_but_valid_width_passes(self):
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
checker.on_control_edge(0, 1)
self.assertEqual(kinds(checker.on_status_edge(50, 1)), ['delay_warn'])
self.assertEqual(kinds(checker.on_status_edge(120, 0)), ['width_ok'])
def test_too_short_ack_fails(self):
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
checker.on_control_edge(0, 1)
checker.on_status_edge(25, 1)
self.assertEqual(kinds(checker.on_status_edge(55, 0)), ['width_fail'])
def test_long_status_is_fault(self):
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
checker.on_control_edge(0, 1)
checker.on_status_edge(25, 1)
self.assertEqual(kinds(checker.on_status_edge(225, 0)), ['fault'])
def test_missing_ack_expires(self):
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
checker.on_control_edge(10, 0)
deadline = checker.next_deadline()
self.assertIsNotNone(deadline)
self.assertEqual(kinds(checker.expire(deadline)), ['missing'])
if __name__ == '__main__':
unittest.main()
class InputTimingTests(unittest.TestCase):
def test_both_directions_and_independent_minimums(self):
c = TIMING.InputTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=200)
c.update(0, 0, 0)
c.update(10, 1, 0)
self.assertEqual(kinds(c.update(110, 0, 0)), ['mintime_ok'])
event = c.update(140, 0, 1)[0]
self.assertEqual(event['duration_ns'], 30)
self.assertIn('Vin1 -> Vin2', event['text'])
self.assertEqual(kinds(c.update(240, 0, 0)), ['mintime_fail'])
event = c.update(280, 1, 0)[0]
self.assertEqual(event['duration_ns'], 40)
self.assertIn('Vin2 -> Vin1', event['text'])
def test_atomic_handover_and_active_low(self):
c = TIMING.InputTimingChecker(1e9, False, False)
c.update(0, 1, 1)
c.update(10, 0, 1)
events = c.update(20, 1, 0)
self.assertEqual(kinds(events), ['mintime_ok', 'deadtime'])
self.assertEqual(events[1]['duration_ns'], 0)
self.assertEqual(kinds(c.update(30, 0, 1)), ['mintime_ok', 'deadtime'])
def test_overlap_and_capture_boundaries(self):
c = TIMING.InputTimingChecker(1e9)
c.update(0, 1, 0)
c.update(10, 1, 1)
events = c.update(25, 0, 1)
self.assertEqual(kinds(events), ['overlap'])
self.assertEqual(events[0]['duration_ns'], 15)
c.update(30, 1, 1)
self.assertEqual(kinds(c.finish(50)), ['overlap'])
c = TIMING.InputTimingChecker(1e9)
c.update(0, 0, 0)
self.assertEqual(c.update(10, 1, 0), [])
self.assertEqual(c.finish(20), [])
def test_no_stale_deadtime_after_same_input_restarts(self):
c = TIMING.InputTimingChecker(1e9)
for t, a, b in [(0, 0, 0), (10, 1, 0), (20, 0, 0), (30, 1, 0), (40, 0, 0)]:
c.update(t, a, b)
self.assertEqual(c.update(50, 0, 1)[0]['duration_ns'], 10)
def test_invalid_minimums(self):
for value in (-1, float('nan'), float('inf')):
with self.assertRaises(ValueError):
TIMING.InputTimingChecker(1e9, vin2_mintime_ns=value)
class CycleResultTests(unittest.TestCase):
def cycle(self, checker, origin=0, delay=250, width=700, inverted=False):
events = checker.on_control_edge(origin + 100, not inverted)
events += checker.on_status_edge(origin + 100 + delay, 1)
events += checker.on_status_edge(origin + 100 + delay + width, 0)
events += checker.on_control_edge(origin + 3000, inverted)
events += checker.on_status_edge(origin + 3250, 1)
events += checker.on_status_edge(origin + 3950, 0)
return [e for e in events if e['kind'].startswith('cycle_')]
def test_complete_cycle_range_and_active_low(self):
for inverted in (False, True):
checker = TIMING.TimingChecker(1e9, cycle_results=True, vin_active_high=not inverted)
events = self.cycle(checker, inverted=inverted)
self.assertEqual(len(events), 1)
self.assertEqual((events[0]['kind'], events[0]['start'], events[0]['end']), ('cycle_ok', 100, 3950))
def test_warning_fault_and_next_cycle_recovery(self):
for delay, width, verdict in [(500, 700, 'warning'), (250, 100, 'fault'), (250, 2000, 'fault')]:
checker = TIMING.TimingChecker(1e9, cycle_results=True)
self.assertEqual(self.cycle(checker, delay=delay, width=width)[0]['kind'], 'cycle_' + verdict)
self.assertEqual(self.cycle(checker, origin=10000)[0]['kind'], 'cycle_ok')
def test_missing_ack_and_clipped_capture(self):
checker = TIMING.TimingChecker(1e9, cycle_results=True)
checker.on_control_edge(100, 1)
checker.expire(1500)
checker.on_control_edge(3000, 0)
checker.on_status_edge(3250, 1)
events = checker.on_status_edge(3950, 0)
self.assertEqual(events[-1]['kind'], 'cycle_fault')
checker.on_control_edge(10000, 1)
self.assertEqual(checker.finish_cycles(10100)[0]['kind'], 'cycle_warning')
self.assertEqual(checker.finish_cycles(10100), [])
def test_no_ok_before_second_ack_finishes(self):
checker = TIMING.TimingChecker(1e9, cycle_results=True)
events = checker.on_control_edge(100, 1)
events += checker.on_status_edge(350, 1)
events += checker.on_status_edge(1050, 0)
events += checker.on_control_edge(3000, 0)
events += checker.on_status_edge(3250, 1)
self.assertFalse(any(e['kind'].startswith('cycle_') for e in events))
self.assertEqual(checker.on_status_edge(3950, 0)[-1]['kind'], 'cycle_ok')

View File

@@ -0,0 +1,116 @@
"""Exercise the standalone PowerShell installer without modifying installed DSView."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SHELL = shutil.which('powershell.exe') or shutil.which('pwsh')
@unittest.skipUnless(os.name == 'nt' and SHELL, 'Windows PowerShell required')
class InstallerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='dsview installer ')
self.addCleanup(self.temp.cleanup)
self.app = Path(self.temp.name)/'DSView test'
self.app.mkdir()
(self.app/'DSView.exe').write_bytes(b'fixture, not executable')
(self.app/'decoders/common').mkdir(parents=True)
(self.app/'decoders/common/__init__.py').write_text('# existing common package\n')
(self.app/'decoders/common/keep.txt').write_text('unrelated')
def run_installer(self, *extra, script=None):
return subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
str(script or ROOT/'install_decoders.ps1'),
'-DsViewPath', str(self.app), *extra], capture_output=True, text=True)
def test_install_import_and_update_backup(self):
result = self.run_installer()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
code = '''
import importlib, pathlib, sys, types
root = pathlib.Path(sys.argv[1])
sys.path.insert(0, str(root))
stub = types.ModuleType('sigrokdecode')
stub.Decoder = type('Decoder', (), {})
sys.modules['sigrokdecode'] = stub
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
module = importlib.import_module(name + '.pd')
assert module.Decoder.api_version == 3
core = importlib.import_module('common.setgui_decoders.gate_timing')
pathlib.Path(core.__file__).resolve().relative_to(root)
assert '1SP0635' in core.PROFILES
'''
check = subprocess.run([sys.executable, '-I', '-c', code, str(self.app/'decoders')],
cwd=self.app, capture_output=True, text=True)
self.assertEqual(check.returncode, 0, check.stderr)
target = self.app/'decoders/set_uart/pd.py'
target.write_text('# old decoder\n')
result = self.run_installer()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
backups = list((self.app/'set-decoder-backups').glob('*/set_uart/pd.py'))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), '# old decoder\n')
self.assertEqual(target.read_bytes(), (ROOT/'decoders/set_uart/pd.py').read_bytes())
self.assertEqual((self.app/'decoders/common/keep.txt').read_text(), 'unrelated')
self.assertEqual((self.app/'decoders/common/__init__.py').read_text(), '# existing common package\n')
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
self.assertEqual((self.app/('decoders/common/setgui_decoders/'+name+'.py')).read_bytes(),
(ROOT.parents[1]/('python/logic_analyzer/decoders/'+name+'.py')).read_bytes())
def test_check_only_does_not_install(self):
result = self.run_installer('-CheckOnly')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.app/'decoders/set_uart').exists())
self.assertFalse((self.app/'set-decoder-backups').exists())
def test_copy_failure_restores_previous_decoders(self):
old = self.app/'decoders/gate_driver_timing/pd.py'
old.parent.mkdir()
old.write_text('# original\n')
runner = Path(self.temp.name)/'inject-failure.ps1'
runner.write_text('''
param([string]$Installer, [string]$Target)
$ErrorActionPreference = 'Stop'
$global:installerTestFailed = $false
function Copy-Item {
param([string]$LiteralPath, [string]$Destination, [switch]$Force)
if (-not $global:installerTestFailed -and $Destination.Replace('\\', '/').EndsWith('/decoders/set_uart/pd.py')) {
$global:installerTestFailed = $true
throw 'Injected copy failure'
}
Microsoft.PowerShell.Management\\Copy-Item -LiteralPath $LiteralPath -Destination $Destination -Force:$Force
}
& $Installer -DsViewPath $Target
''', encoding='utf-8')
result = subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
str(runner), '-Installer', str(ROOT/'install_decoders.ps1'),
'-Target', str(self.app)], capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(old.read_text(), '# original\n')
self.assertFalse((old.parent/'__init__.py').exists())
self.assertFalse((self.app/'decoders/set_uart/__init__.py').exists())
self.assertEqual(len(list((self.app/'set-decoder-backups').glob('*/gate_driver_timing/pd.py'))), 1, result.stderr)
def test_incomplete_target_is_untouched(self):
(self.app/'DSView.exe').unlink()
result = self.run_installer()
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.app/'decoders/set_uart').exists())
def test_missing_source_is_detected_before_changes(self):
source = Path(self.temp.name)/'empty-template/tools/dsview'
source.mkdir(parents=True)
shutil.copy2(ROOT/'install_decoders.ps1', source/'install_decoders.ps1')
result = self.run_installer(script=source/'install_decoders.ps1')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.app/'decoders/gate_driver_timing').exists())
self.assertFalse((self.app/'set-decoder-backups').exists())
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,55 @@
"""Prevent packaging a new GUI with a stale native DSView component."""
import hashlib
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
RECIPE = Path(__file__).resolve().parents[1] / 'build.py'
SPEC = importlib.util.spec_from_file_location('dsview_runtime_build', RECIPE)
BUILD = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(BUILD)
class RuntimeTests(unittest.TestCase):
def test_legacy_runtime_and_mismatched_binary_or_recipe_require_rebuild(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
(output / 'DSView.exe').write_bytes(b'native fixture')
(output / 'host-protocol-1').write_text('DSView host protocol 1\n')
self.assertFalse(BUILD.runtime_is_current(output))
stamp = {'recipe': 'new', 'executable': hashlib.sha256(b'native fixture').hexdigest()}
(output / 'host-build.json').write_text(json.dumps(stamp))
with patch.object(BUILD, 'recipe_digest', return_value='new'):
self.assertTrue(BUILD.runtime_is_current(output))
(output / 'DSView.exe').write_bytes(b'old native fixture')
self.assertFalse(BUILD.runtime_is_current(output))
(output / 'DSView.exe').write_bytes(b'native fixture')
with patch.object(BUILD, 'recipe_digest', return_value='changed'):
self.assertFalse(BUILD.runtime_is_current(output))
(output / 'host-build.json').write_text('partial JSON')
self.assertFalse(BUILD.runtime_is_current(output))
def test_patch_adapters_and_shared_cores_all_invalidate_runtime(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
recipe = root / 'tools/dsview'
core = root / 'python/logic_analyzer/decoders'
core.mkdir(parents=True)
(recipe / 'decoders/gate_driver_timing').mkdir(parents=True)
(recipe / 'native').mkdir()
files = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h',
recipe / 'decoders/gate_driver_timing/pd.py']
files += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
for item in files:
item.write_text('initial')
with patch.object(BUILD, '__file__', str(recipe / 'build.py')):
initial = BUILD.recipe_digest()
for item in files:
with self.subTest(file=item.name):
item.write_text('changed')
self.assertNotEqual(initial, BUILD.recipe_digest())
item.write_text('initial')
self.assertEqual(initial, BUILD.recipe_digest())

View File

@@ -0,0 +1,58 @@
import sys
from pathlib import Path
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'python'))
from logic_analyzer.decoders.transistor_pair import PairTimingChecker
class TransistorPairTests(unittest.TestCase):
def test_directional_deadtime_limits_and_equality(self):
c = PairTimingChecker(1e9, deadtime_12_ns=30, deadtime_21_ns=50)
c.update(0, 0, 0)
c.update(10, 1, 0)
c.update(110, 0, 0)
events = c.update(140, 0, 1)
self.assertEqual(events[0]['kind'], 'deadtime')
self.assertEqual(events[0]['duration_ns'], 30)
c.update(240, 0, 0)
self.assertEqual(c.update(280, 1, 0)[0]['kind'], 'deadtime_fail')
def test_independent_on_off_times_frequency_and_duty(self):
c = PairTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=101,
vin1_minoff_ns=201, vin2_minoff_ns=50)
c.update(0, 0, 0)
c.update(10, 1, 0)
self.assertEqual(c.update(110, 0, 0)[0]['kind'], 'mintime_ok')
c.update(140, 0, 1)
self.assertEqual(c.update(240, 0, 0)[0]['kind'], 'mintime_fail')
events = c.update(310, 1, 0)
self.assertTrue(any(e['kind'] == 'off_fail' for e in events))
period = next(e for e in events if e['kind'] == 'period')
self.assertAlmostEqual(period['frequency_hz'], 1e9/300)
self.assertAlmostEqual(period['duty_percent'], 100/3)
self.assertEqual((period['start'], period['end']), (10, 310))
def test_atomic_handover_active_low_and_overlap(self):
c = PairTimingChecker(1e9, False, False, deadtime_12_ns=1)
c.update(0, 1, 1)
c.update(10, 0, 1)
events = c.update(20, 1, 0)
self.assertTrue(any(e['kind'] == 'deadtime_fail' and e['duration_ns'] == 0 for e in events))
c.update(30, 0, 0)
self.assertEqual(c.finish(50)[0]['kind'], 'overlap')
self.assertTrue(any(e['kind'] == 'overlap' for e in c.update(60, 1, 0)))
def test_capture_boundary_does_not_invent_complete_pulses(self):
c = PairTimingChecker(1e9)
self.assertEqual(c.update(0, 1, 0), [])
self.assertEqual(c.update(10, 0, 0), [])
events = c.update(20, 1, 0)
self.assertEqual([e['kind'] for e in events], ['off_time'])
self.assertEqual(c.finish(30), [])
def test_invalid_limits(self):
for name in ('deadtime_12_ns', 'deadtime_21_ns', 'vin1_minoff_ns', 'vin2_minoff_ns'):
for value in (-1, float('nan'), float('inf')):
with self.subTest(name=name, value=value), self.assertRaises(ValueError):
PairTimingChecker(1e9, **{name: value})

View File

@@ -0,0 +1,96 @@
--- a/libsigrok4DSL/hardware/DSL/dslogic.c
+++ b/libsigrok4DSL/hardware/DSL/dslogic.c
@@ -1316,10 +1316,15 @@
{
int i;
sr_info("%s: remove fds from polling", __func__);
+#ifdef _WIN32
+ /* Windows libusb has no poll descriptors; remove the timer source. */
+ sr_source_remove(-1);
+#else
/* Remove fds from polling. */
for (i = 0; devc->usbfd[i] != -1; i++)
sr_source_remove(devc->usbfd[i]);
g_free(devc->usbfd);
+#endif
}
static void report_overflow(struct DSL_context *devc)
@@ -1486,6 +1491,13 @@
}
/* setup callback function for data transfer */
+#ifdef _WIN32
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
+ * thread pumps libusb via receive_data on a bounded timer instead. */
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
+ if (ret != SR_OK)
+ return ret;
+#else
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
for (i = 0; lupfd[i]; i++);
@@ -1500,7 +1512,8 @@
devc->usbfd[i] = lupfd[i]->fd;
}
devc->usbfd[i] = -1;
- g_free(lupfd);
+ libusb_free_pollfds(lupfd);
+#endif
wr_cmd.header.dest = DSL_CTL_START;
wr_cmd.header.size = 0;
--- a/libsigrok4DSL/hardware/DSL/dscope.c
+++ b/libsigrok4DSL/hardware/DSL/dscope.c
@@ -1901,10 +1901,15 @@
{
int i;
sr_info("%s: remove fds from polling", __func__);
+#ifdef _WIN32
+ /* Windows libusb has no poll descriptors; remove the timer source. */
+ sr_source_remove(-1);
+#else
/* Remove fds from polling. */
for (i = 0; devc->usbfd[i] != -1; i++)
sr_source_remove(devc->usbfd[i]);
g_free(devc->usbfd);
+#endif
}
static int receive_data(int fd, int revents, const struct sr_dev_inst *sdi)
@@ -2088,6 +2093,13 @@
}
/* setup callback function for data transfer */
+#ifdef _WIN32
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
+ * thread pumps libusb via receive_data on a bounded timer instead. */
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
+ if (ret != SR_OK)
+ return ret;
+#else
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
for (i = 0; lupfd[i]; i++);
@@ -2103,7 +2115,8 @@
}
devc->usbfd[i] = -1;
- g_free(lupfd);
+ libusb_free_pollfds(lupfd);
+#endif
wr_cmd.header.dest = DSL_CTL_START;
wr_cmd.header.size = 0;
--- a/libsigrok4DSL/session.c
+++ b/libsigrok4DSL/session.c
@@ -207,7 +207,8 @@
sr_dbg("Running...");
/* Do we have real sources? */
- if (session->num_sources == 1 && session->pollfds[0].fd == -1) {
+ if (session->num_sources == 1 && session->pollfds[0].fd == -1
+ && session->sources[0].timeout == 0) {
/* Dummy source, freewheel over it. */
while (session->num_sources) {
if (session->abort_session) {