300 lines
13 KiB
Python
300 lines
13 KiB
Python
"""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)
|