Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

View File

@@ -0,0 +1,299 @@
"""Digital capture readers. Store edges, never expand captures to sample arrays.
DSView layout: DreamSourceLab/DSView libsigrok4DSL/session_driver.c.
SAL v1 layout is validated against example/digitOSC; v3 RLE layout reference:
https://github.com/nemanjan00/sigrok2sal/blob/master/SAL_FORMAT.md
Unknown binary versions fail explicitly instead of guessing their layout.
"""
from __future__ import annotations
from array import array
from bisect import bisect_right
import configparser
import csv
from dataclasses import dataclass
import json
import math
from pathlib import Path
import re
import struct
import zipfile
class ImportCancelled(Exception):
pass
@dataclass
class DigitalChannel:
name: str
initial: int
edges: array
def level_at(self, time):
return self.initial ^ (bisect_right(self.edges, time) & 1)
@dataclass
class DigitalCapture:
source: str
format: str
channels: list
start: float
end: float
sample_rate: float = 0
def _check(cancel):
if cancel():
raise ImportCancelled()
def read_capture(path, progress=lambda value: None, cancel=lambda: False):
path = Path(path)
try:
if path.suffix.lower() == '.csv':
result = _csv(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.')
except (KeyError, struct.error, zipfile.BadZipFile, EOFError,
configparser.Error, UnicodeError) as exc:
raise ValueError('Повреждённый или неподдерживаемый файл: %s' % exc) from exc
_check(cancel)
if not result.channels or not math.isfinite(result.start) or not math.isfinite(result.end):
raise ValueError('Файл не содержит цифровой записи.')
progress(100)
return result
def _csv(path, progress, cancel):
size = max(1, path.stat().st_size)
consumed = 0
channels = None
previous = []
start = end = None
dsview = False
with path.open(encoding='utf-8-sig', newline='') as stream:
for number, line in enumerate(stream, 1):
consumed += len(line)
if number % 4096 == 0:
_check(cancel)
progress(min(99, int(consumed * 100 / size)))
if not line.strip():
continue
if line.lstrip().startswith((';', '#')):
dsview |= 'DSView' in line
continue
if channels is None:
delimiter = max((',', ';', '\t'), key=line.count)
names = [v.strip() for v in next(csv.reader([line], delimiter=delimiter))]
match = re.fullmatch(r'(?:time|timestamp)\s*(?:[\[(](s|ms|us|µs|ns)[\])])?', names[0], re.I)
if not match or not 2 <= len(names) <= 65 or any(not n for n in names):
raise ValueError('Ожидается CSV: Time [s], затем цифровые каналы (0/1).')
if len(set(names)) != len(names):
raise ValueError('Повторяющиеся имена каналов CSV.')
factor = {'s': 1, 'ms': 1e-3, 'us': 1e-6, 'µs': 1e-6, 'ns': 1e-9}[(match[1] or 's').lower()]
channels = [DigitalChannel(name, 0, array('d')) for name in names[1:]]
continue
row = next(csv.reader([line], delimiter=delimiter))
try:
time = float(row[0].strip().replace(',', '.')) * factor
values = [v.strip() for v in row[1:]]
if len(values) != len(channels) or any(v not in ('0', '1') for v in values):
raise ValueError('цифровые уровни должны быть 0 или 1')
if not math.isfinite(time) or (end is not None and time <= end):
raise ValueError('время должно строго возрастать и быть конечным')
except (ValueError, IndexError) as exc:
raise ValueError('CSV, строка %d: %s' % (number, exc)) from exc
if start is None:
start = time
for channel, value in zip(channels, values):
channel.initial = int(value)
else:
for i, channel in enumerate(channels):
if values[i] != previous[i]:
channel.edges.append(time)
previous = values
end = time
if start is None:
raise ValueError('CSV не содержит данных.')
return DigitalCapture(str(path), 'DSLogic CSV' if dsview else 'Logic 2 CSV', channels, start, end)
class _Binary:
def __init__(self, data):
self.data, self.pos = data, 0
def take(self, size):
if size < 0 or self.pos + size > len(self.data):
raise ValueError('Обрезанный двоичный блок.')
value = self.data[self.pos:self.pos + size]
self.pos += size
return value
def unpack(self, fmt):
return struct.unpack('<' + fmt, self.take(struct.calcsize('<' + fmt)))
def _runs(data):
pos = 0
while pos < len(data):
value = data[pos]
pos += 1
if value >= 128:
raise ValueError('Некорректный RLE SAL.')
if value & 64:
value &= 63
for _ in range(9):
if pos == len(data):
raise ValueError('Обрезанный RLE SAL.')
byte = data[pos]
pos += 1
value = (value << 7) | (byte & 127)
if not byte & 128:
break
else:
raise ValueError('Слишком длинный RLE SAL.')
yield value + 1
def _sal_channel(data, name, cancel):
reader = _Binary(data)
if reader.take(8) != b'<SALEAE>':
raise ValueError('Неверная сигнатура SAL.')
version, kind = reader.unpack('II')
if version not in (1, 3, 4) or kind != 100:
raise ValueError('Не поддерживается цифровой SAL версии %d, тип %d.' % (version, kind))
schema, rate, unix_ms, fractional_ms = reader.unpack('BdQd')
if schema != 1 or not math.isfinite(rate) or rate <= 0:
raise ValueError('Некорректный заголовок канала SAL.')
for _ in range(2):
optional, = reader.unpack('B')
if optional not in (0, 1):
raise ValueError('Неизвестный заголовок SAL.')
if optional:
reader.take(8)
count, = reader.unpack('Q')
if not 0 < count <= len(data) // 26:
raise ValueError('Неверное число блоков SAL.')
channel = DigitalChannel(name, 0, array('d'))
previous_end = None
level = 0
for chunk in range(count):
_check(cancel)
if version == 1:
begin, end, samples, chunk_rate, scale, length = reader.unpack('6Q')
if samples != end - begin or chunk_rate != rate or scale != 1:
raise ValueError('Неподдерживаемая шкала блока SAL.')
encoded = reader.take(length)
entries, = reader.unpack('Q')
if not entries:
raise ValueError('В SAL отсутствует индекс начального уровня.')
index = reader.take(entries * 20)
offset, byte_offset, initial = struct.unpack_from('<QQI', index)
if offset or byte_offset:
raise ValueError('Некорректный индекс SAL.')
else:
begin, end, initial, flags, length = reader.unpack('QQBBQ')
if flags:
raise ValueError('Неизвестные флаги SAL.')
encoded = reader.take(length)
if initial not in (0, 1) or end <= begin or (previous_end is not None and begin != previous_end):
raise ValueError('Некорректные границы или уровень блока SAL.')
if chunk == 0:
first = begin
channel.initial = initial
elif initial != level:
channel.edges.append(begin / rate)
level = initial
position = begin
for run_index, run in enumerate(_runs(encoded)):
if run_index % 8192 == 0:
_check(cancel)
position += run
if position > end:
raise ValueError('RLE выходит за границы блока SAL.')
if position < end:
channel.edges.append(position / rate)
level ^= 1
if position != end:
raise ValueError('Неполный RLE блока SAL.')
previous_end = end
if reader.pos != len(data):
raise ValueError('Неизвестные дополнительные данные SAL.')
return channel, first / rate, previous_end / rate, rate
def _sal(path, archive, progress, cancel):
meta = json.loads(archive.read('meta.json'))
names = {}
for row in meta.get('data', {}).get('rowsSettings', []):
channel = row.get('channel', {})
if channel.get('type') == 'Digital':
names[channel.get('deviceChannel')] = row.get('name')
files = sorted((int(m[1]), name) for name in archive.namelist()
for m in [re.fullmatch(r'digital-(\d+)\.bin', name)] if m)
if not files:
raise ValueError('В SAL нет цифровых каналов.')
channels, bounds = [], []
for i, (index, filename) in enumerate(files):
_check(cancel)
channel, start, end, rate = _sal_channel(archive.read(filename), names.get(index) or 'D%d' % index, cancel)
channels.append(channel)
bounds.append((start, end, rate))
progress(int((i + 1) * 100 / len(files)))
if any(bound != bounds[0] for bound in bounds):
raise ValueError('SAL с разными временными границами каналов не поддерживается.')
return DigitalCapture(str(path), 'Logic 2 SAL', channels, *bounds[0])
def _dsl(path, archive, progress, cancel):
config = configparser.ConfigParser(interpolation=None)
config.read_string(archive.read('header').decode('utf-8-sig'))
version = config.getint('version', 'version')
header = config['header']
if version not in (2, 3) or header.getint('device mode', 0) != 0:
raise ValueError('Поддерживаются цифровые сессии DSView DSL версий 2 и 3.')
match = re.fullmatch(r'\s*([\d.]+)\s*([kKmMgG]?)(?:Hz)?\s*', header['samplerate'])
if not match:
raise ValueError('Неверная частота DSL.')
rate = float(match[1]) * {'': 1, 'k': 1e3, 'm': 1e6, 'g': 1e9}[match[2].lower()]
samples = header.getint('total samples')
blocks = header.getint('total blocks')
if not math.isfinite(rate) or rate <= 0 or samples <= 0 or blocks <= 0:
raise ValueError('Некорректный размер или частота DSL.')
probes = sorted((int(m[1]), name) for key, name in header.items()
for m in [re.fullmatch(r'probe(\d+)', key)] if m)
channels = []
for i, (index, name) in enumerate(probes):
channel = DigitalChannel(name, 0, array('d'))
position, level = 0, 0
for block in range(blocks):
_check(cancel)
# DSView v3 stores enabled channels in sequential directories.
directory = i if version == 3 else index
data = archive.read('L-%d/%d' % (directory, block))
for offset, byte in enumerate(data):
if offset % 65536 == 0:
_check(cancel)
if position >= samples:
break
if position == 0:
channel.initial = level = byte & 1
changes = byte ^ ((byte << 1) & 255 | level)
while changes:
bit = (changes & -changes).bit_length() - 1
if position + bit < samples:
channel.edges.append((position + bit) / rate)
changes &= changes - 1
level = byte >> 7
position += 8
if position < samples:
raise ValueError('Обрезанные данные DSL, канал %s.' % name)
channels.append(channel)
progress(int((i + 1) * 100 / max(1, len(probes))))
return DigitalCapture(str(path), 'DSLogic DSL', channels, 0, samples / rate, rate)