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 @@
"""Reusable device libraries from setcorp/templates."""

View File

@@ -0,0 +1,358 @@
"""Offline analysis of timestamped edges; no resampling or hardware access."""
from __future__ import annotations
from bisect import bisect_left, bisect_right
from dataclasses import dataclass
import heapq
import math
from .files import ImportCancelled
from .decoders.gate_timing import TimingChecker
from .decoders.set_uart import StreamParser
from .decoders.pm35_uart import PM35Parser
from .decoders.set_can import legacy, Reassembler
@dataclass
class AnalysisEvent:
start: float
end: float
kind: str
text: str
details: str = ''
@dataclass
class AnalysisResult:
events: list
truncated: bool
start: float
end: float
def pulse_measurements(channel, time, start, end):
"""Measure a complete adjacent high/low cycle, without scanning the record."""
edges = channel.edges
index = bisect_right(edges, time)
result = {'level': channel.initial ^ (index & 1)}
if 0 < index < len(edges):
left, right = edges[index - 1], edges[index]
if start <= left < right <= end:
result['width'] = right - left
result['high' if result['level'] else 'low'] = right - left
# Prefer the next complete interval, otherwise the preceding one.
other = None
if index + 1 < len(edges) and edges[index + 1] <= end:
other = edges[index + 1] - right
elif index >= 2 and edges[index - 2] >= start:
other = left - edges[index - 2]
if other is not None:
result['low' if result['level'] else 'high'] = other
result['period'] = result['high'] + result['low']
result['frequency'] = 1 / result['period']
result['duty'] = 100 * result['high'] / result['period']
return result
def interval_edges(channel, a, b):
"""Count rising/falling edges in (min(a,b), max(a,b)]."""
left, right = sorted((a, b))
lo, hi = bisect_right(channel.edges, left), bisect_right(channel.edges, right)
count = hi - lo
first_rising = channel.initial ^ (lo & 1) == 0
rising = count // 2 + int(bool(count & 1) and first_rising)
return rising, count - rising
def _cancel(cancel):
if cancel():
raise ImportCancelled()
def uart_frames(channel, start, end, baud, parity='none', stops=1, inverted=False, cancel=lambda: False):
"""Yield (start, end, byte, error), sampling 8-bit UART at bit centres."""
bit = 1.0 / baud
bits = 9 + (parity != 'none') + stops
edges = channel.edges
index = bisect_left(edges, start)
while index < len(edges) and edges[index] < end:
_cancel(cancel)
time = edges[index]
level = channel.initial ^ ((index + 1) & 1) ^ inverted
if level:
index += 1
continue
finish = time + bits * bit
if finish > end + bit * 1e-6:
yield time, end, None, 'Неполный UART-байт в конце диапазона'
break
read = lambda pos: channel.level_at(time + pos * bit) ^ inverted
if read(0.5):
index += 1 # A glitch shorter than half a start bit.
continue
values = [read(1.5 + i) for i in range(8)]
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'
stop_start = 9 + (parity != 'none')
if any(read(stop_start + i + 0.5) != 1 for i in range(stops)):
error = 'Ошибка стопового бита UART / BREAK'
yield time, finish, value, error
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
def _number(bits):
value = 0
for bit in bits:
value = (value << 1) | bit
return value
def can_crc(bits):
crc = 0
for bit in bits:
feedback = ((crc >> 14) & 1) ^ bit
crc = (crc << 1) & 0x7fff
if feedback:
crc ^= 0x4599
return crc
class _CanBits:
def __init__(self, channel, time, end, bitrate, inverted, sample_point):
self.channel, self.time, self.end = channel, time, end
self.bit, self.inverted = 1 / bitrate, inverted
self.sample_point = sample_point
self.position, self.previous, self.run = 0, None, 0
self.bits = []
self.last_sample = time
def raw(self):
# Resynchronise on recessive->dominant edges near the bit boundary.
boundary = self.time + self.position * self.bit
if self.position:
lo = bisect_right(self.channel.edges, max(self.last_sample, boundary - self.bit * .2))
hi = bisect_right(self.channel.edges, boundary + self.bit * .2, lo)
for index in range(lo, hi):
level = self.channel.initial ^ ((index + 1) & 1) ^ self.inverted
if level == 0:
self.time += self.channel.edges[index] - boundary
boundary = self.channel.edges[index]
break
sample = boundary + self.sample_point * self.bit
if sample >= self.end:
raise EOFError('Неполный CAN-кадр в конце диапазона')
self.last_sample = sample
self.position += 1
return self.channel.level_at(sample) ^ self.inverted
def stuffed(self):
if self.run == 5:
value = self.raw()
if value == self.previous:
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
self.previous = value
self.bits.append(value)
return value
def take(self, count):
return _number([self.stuffed() for _ in range(count)])
def can_frames(channel, start, end, bitrate, inverted=False, sample_point=.7, cancel=lambda: False):
"""Classic CAN, strict stuffing/CRC/form validation before application decode."""
edges = channel.edges
index = bisect_left(edges, start)
bit = 1 / bitrate
while index < len(edges) and edges[index] < end:
_cancel(cancel)
time = edges[index]
level = channel.initial ^ ((index + 1) & 1) ^ inverted
previous = edges[index - 1] if index else start
# SOF follows at least three recessive intermission bits.
if level or time - previous < bit * 2.9:
index += 1
continue
reader = _CanBits(channel, time, end, bitrate, inverted, sample_point)
frame, error = None, None
try:
if reader.take(1):
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')
ident = (ident << 18) | reader.take(18)
rtr = reader.take(1)
if reader.take(2):
raise ValueError('CAN FD / reserved bits не поддерживаются')
elif reader.take(1):
raise ValueError('CAN FD / reserved bit не поддерживается')
dlc = reader.take(4)
if dlc > 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')
if reader.raw() != 1:
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')
if 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:
error = str(exc)
finish = min(end, reader.time + reader.position * bit)
yield time, finish, frame, error
index = bisect_left(edges, finish - bit * 1e-6, index + 1)
def analyze_capture(capture, options, start=None, end=None, progress=lambda n: None, cancel=lambda: False):
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('Выберите непустой интервал анализа.')
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('Лимит результатов должен быть положительным.')
def emit(a, b, kind, text, details=''):
if len(events) >= limit:
raise OverflowError()
events.append(AnalysisEvent(a, b, kind, text, details))
def report(time):
nonlocal last_progress
_cancel(cancel)
value = min(99, int((time - start) / (end - start) * 100))
if value != last_progress:
progress(value)
last_progress = value
def parsed(items):
for a, b, frame, error in items:
emit(a, b, 'error' if error else 'frame', error or frame['summary'],
'' if not frame else bytes(frame.get('raw', frame.get('payload', b''))).hex(' '))
if frame and frame.get('protocol') == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
try:
application = legacy(frame['can_id'], frame['payload'])
emit(a, b, 'frame', application['summary'])
except ValueError as exc:
emit(a, b, 'error', str(exc))
truncated = False
try:
if mode in ('1SP0635', '1SD536F2'):
other = options.get('status_channel', 1)
if other == options.get('channel', 0):
raise ValueError('Vin и Vstat должны быть разными каналами.')
status = capture.channels[other]
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
# to preserve ns precision for CSV timestamps far from zero.
tick = lambda t: round((t - start) * 1e9)
def timing(items):
for item in items:
text = item['text']
if item['kind'] == 'fault':
text += ' · аварийная обратная связь; превышение тока не подтверждено'
emit(start + item['start'] / 1e9, start + item['end'] / 1e9, item['kind'], text)
def transitions(ch, which):
first = bisect_right(ch.edges, start)
for i in range(first, bisect_right(ch.edges, end)):
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)):
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.expire(tick(end)))
if checker.status_start is not None:
emit(start + checker.status_start / 1e9, end, 'incomplete', 'Vstat: импульс не завершён в диапазоне')
for pending in checker.pending:
emit(start + pending['sample'] / 1e9, end, 'incomplete', 'Vin: диапазон закончился до тайм-аута ACK')
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.')
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'
else options.get('gap_ms', 100) / 1000)
last = None
for a, b, value, error in uart_frames(channel, start, end, baud, parity, stops, options.get('inverted', False), cancel):
report(a)
if parser and last is not None and gap > 0 and a - last >= gap:
parsed(parser.flush())
last = b
if error:
if parser:
parsed(parser.flush())
emit(a, b, 'error', error)
elif parser:
parsed(parser.feed(value, a, b))
else:
emit(a, b, 'byte', 'UART 0x%02X' % value, chr(value) if 32 <= value < 127 else '')
if parser:
parsed(parser.flush())
elif mode in ('CAN', 'SET CAN'):
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.')
reassembler = Reassembler()
for a, b, frame, error in can_frames(channel, start, end, bitrate, options.get('inverted', False), sample_point, cancel):
report(a)
if error:
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: сборка прервана ошибкой шины')
reassembler.pending.clear()
continue
ident, data = frame['ident'], frame['data']
emit(a, b, 'can', 'CAN %s ID=%08X DLC=%d %s %s' % (
'EXT' if frame['extended'] else 'STD', ident, frame['dlc'],
'RTR' if frame['remote'] else 'DATA', 'ACK' if frame['ack'] else 'NACK'), data.hex(' '))
if mode == 'SET CAN' and frame['extended'] and not frame['remote']:
protocol = options.get('protocol', 'protocan')
if protocol == 'set-v2':
parsed(reassembler.feed(ident, data, a, b, b * 1000))
else:
try:
result = legacy(ident, data, protocol)
if result:
emit(a, b, 'frame', result['summary'], data.hex(' '))
except ValueError as exc:
emit(a, b, 'error', str(exc))
for pending in reassembler.pending.values():
emit(pending['start'], end, 'incomplete', 'Неполная сборка SET CAN в конце диапазона')
else:
raise ValueError('Неизвестный анализатор: ' + mode)
except OverflowError:
truncated = True
_cancel(cancel)
progress(100)
events.sort(key=lambda event: (event.start, event.end))
return AnalysisResult(events, truncated, start, end)

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View File

@@ -0,0 +1,181 @@
"""Pure timing checker used by the DSView decoder and unit tests.
All time values in profiles are nanoseconds. The ACK delay in the vendor
data sheets is a typical value only, so the delay window is intentionally an
engineering warning threshold. ACK pulse width has specified limits and is
therefore checked as PASS/FAIL.
"""
from collections import deque
PROFILES = {
'1SP0635': {
'ack_delay_typ_ns': 250.0,
'ack_width_min_ns': 400.0,
'ack_width_typ_ns': 700.0,
'ack_width_max_ns': 1050.0,
'fault_threshold_ns': 1500.0,
},
'1SD536F2': {
'ack_delay_typ_ns': 380.0,
'ack_width_min_ns': 600.0,
'ack_width_typ_ns': 900.0,
'ack_width_max_ns': 1800.0,
# The application manual calls >1.5 us a fault, while individual
# data sheets allow ACK pulses up to 1.8 us. A correlated pulse in
# the specified ACK range wins; otherwise the longer limit is used.
'fault_threshold_ns': 1800.0,
},
}
def format_ns(value_ns):
if value_ns >= 1000000.0:
return '%.3f ms' % (value_ns / 1000000.0)
if value_ns >= 1000.0:
return '%.3f us' % (value_ns / 1000.0)
return '%.1f ns' % value_ns
class TimingChecker(object):
"""Match Vin edges to Vstat pulses and return annotation dictionaries."""
def __init__(self, samplerate, profile='1SP0635', delay_tolerance_ns=100.0,
vin_active_high=True, vstat_active_high=True,
custom=None):
if not samplerate:
raise ValueError('samplerate is required')
if profile == 'custom':
if not custom:
raise ValueError('custom profile values are required')
self.spec = dict(custom)
else:
self.spec = dict(PROFILES[profile])
self.profile = profile
self.samplerate = float(samplerate)
self.delay_tolerance_ns = float(delay_tolerance_ns)
self.vin_active_high = bool(vin_active_high)
self.vstat_active_high = bool(vstat_active_high)
self.pending = deque()
self.status_start = None
self.status_control = None
# Long enough to avoid calling a merely late ACK "missing", while
# still producing a useful annotation during a capture.
self.ack_timeout_ns = max(
self.spec['ack_delay_typ_ns'] + 3.0 * self.delay_tolerance_ns,
self.spec['ack_delay_typ_ns'] + self.spec['ack_width_max_ns'])
def samples_to_ns(self, samples):
return float(samples) * 1000000000.0 / self.samplerate
def ns_to_samples(self, value_ns):
return max(1, int(round(float(value_ns) * self.samplerate / 1000000000.0)))
def next_deadline(self):
if not self.pending:
return None
return self.pending[0]['sample'] + self.ns_to_samples(self.ack_timeout_ns)
def on_control_edge(self, sample, level):
state_on = bool(level) == self.vin_active_high
item = {
'sample': int(sample),
'level': int(level),
'edge': 'ON' if state_on else 'OFF',
}
self.pending.append(item)
return [{
'kind': 'control', 'start': int(sample), 'end': int(sample),
'text': 'Vin %s' % item['edge'], 'short': item['edge'],
}]
def expire(self, sample):
events = []
timeout_samples = self.ns_to_samples(self.ack_timeout_ns)
while self.pending and int(sample) >= self.pending[0]['sample'] + timeout_samples:
item = self.pending.popleft()
end = item['sample'] + timeout_samples
events.append({
'kind': 'missing', 'start': item['sample'], 'end': end,
'text': 'FAIL: no Vstat ACK after Vin %s (timeout %s)' %
(item['edge'], format_ns(self.ack_timeout_ns)),
'short': 'NO ACK',
})
return events
def on_status_edge(self, sample, level):
sample = int(sample)
is_active = bool(level) == self.vstat_active_high
events = self.expire(sample)
if is_active:
# Ignore a second active edge caused by an inconsistent trace.
if self.status_start is not None:
return events
self.status_start = sample
self.status_control = self.pending.popleft() if self.pending else None
if self.status_control is not None:
delay_ns = self.samples_to_ns(sample - self.status_control['sample'])
typ_ns = self.spec['ack_delay_typ_ns']
delta_ns = delay_ns - typ_ns
in_window = abs(delta_ns) <= self.delay_tolerance_ns
events.append({
'kind': 'delay_ok' if in_window else 'delay_warn',
'start': self.status_control['sample'], 'end': sample,
'delay_ns': delay_ns,
'text': '%s: ACK delay %s (typ %s, delta %+0.1f ns)' %
('PASS' if in_window else 'WARN', format_ns(delay_ns),
format_ns(typ_ns), delta_ns),
'short': '%s %s' % ('OK' if in_window else 'WARN',
format_ns(delay_ns)),
})
return events
if self.status_start is None:
events.append({
'kind': 'orphan', 'start': sample, 'end': sample,
'text': 'Unexpected inactive Vstat edge', 'short': 'Vstat?',
})
return events
start = self.status_start
control = self.status_control
width_ns = self.samples_to_ns(sample - start)
self.status_start = None
self.status_control = None
lo = self.spec['ack_width_min_ns']
hi = self.spec['ack_width_max_ns']
if control is not None and lo <= width_ns <= hi:
events.append({
'kind': 'width_ok', 'start': start, 'end': sample,
'width_ns': width_ns,
'text': 'PASS: ACK width %s (limit %s...%s)' %
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
'short': 'ACK %s' % format_ns(width_ns),
})
elif width_ns > self.spec['fault_threshold_ns']:
events.append({
'kind': 'fault', 'start': start, 'end': sample,
'width_ns': width_ns,
'text': 'FAULT: Vstat active for %s' % format_ns(width_ns),
'short': 'FAULT %s' % format_ns(width_ns),
})
elif control is None:
events.append({
'kind': 'orphan', 'start': start, 'end': sample,
'width_ns': width_ns,
'text': 'Unexpected Vstat pulse %s (no Vin edge)' % format_ns(width_ns),
'short': 'ORPHAN %s' % format_ns(width_ns),
})
else:
events.append({
'kind': 'width_fail', 'start': start, 'end': sample,
'width_ns': width_ns,
'text': 'FAIL: ACK width %s outside %s...%s' %
(format_ns(width_ns), format_ns(lo), format_ns(hi)),
'short': 'BAD ACK %s' % format_ns(width_ns),
})
return events

View File

@@ -0,0 +1,95 @@
"""PM35 MODBUS 03/06, including the legacy 128-word/zero-byte-count reply."""
from .set_uart import crc16, number
def parse_pm35(raw, role):
raw = bytes(raw)
if len(raw) < 5:
raise ValueError('Incomplete PM35 frame')
if crc16(raw[:-2], True) != number(raw[-2:]):
raise ValueError('PM35 CRC mismatch')
address, function = raw[:2]
result = dict(protocol='PM35', controller=address, function=function, raw=raw)
if function in (0x83, 0x86):
if len(raw) != 5 or role == 'request':
raise ValueError('Invalid exception response')
result['summary'] = 'PM35 node=%d exception fn=%02X code=%d' % (address, function, raw[2])
elif function == 6 or function == 3 and role == 'request':
if len(raw) != 8:
raise ValueError('PM35 request/06 must contain 8 bytes')
register, value = number(raw[2:4], 'big'), number(raw[4:6], 'big')
if register >= 128 or function == 3 and not 1 <= value <= 128 - register:
raise ValueError('PM35 register range outside 0..127')
result.update(register=register, value=value)
result['summary'] = 'PM35 node=%d %s R%d %s=%d' % (address,
'READ' if function == 3 else 'WRITE/echo', register,
'count' if function == 3 else 'value', value)
if function == 6 and register == 127:
result['summary'] += ' command_bits=%04X' % value
elif function == 3:
count = raw[2] or 256
if count % 2 or len(raw) != count + 5:
raise ValueError('Invalid PM35 response byte count')
result['values'] = [number(raw[i:i+2], 'big') for i in range(3, len(raw)-2, 2)]
result['summary'] = 'PM35 node=%d READ response %d words: %s' % (address,
len(result['values']), ' '.join('%04X' % v for v in result['values']))
else:
raise ValueError('Unsupported PM35 function')
return result
class PM35Parser:
def __init__(self, role):
self.role = role
self.buffer = []
def flush(self):
if not self.buffer:
return []
ss, es = self.buffer[0][1], self.buffer[-1][2]
self.buffer = []
return [(ss, es, None, 'Incomplete PM35 frame')]
def feed(self, value, ss, es):
self.buffer.append((value, ss, es))
events = []
while len(self.buffer) >= 3:
raw = bytes(item[0] for item in self.buffer)
fn = raw[1]
if fn not in (3, 6, 0x83, 0x86):
del self.buffer[0]
continue
role = self.role
total = 5 if fn & 128 else 8
if role == 'auto' and fn == 3:
candidates = [(8, 'request'), ((raw[2] or 256) + 5, 'response')]
found = None
for length, candidate_role in candidates:
if len(raw) >= length:
try:
found = (length, candidate_role, parse_pm35(raw[:length], candidate_role))
break
except ValueError:
pass
if found:
length, candidate_role, result = found
events.append((self.buffer[0][1], self.buffer[length-1][2], result, None))
del self.buffer[:length]
continue
if len(raw) < max(length for length, _ in candidates):
break
events.append((self.buffer[0][1], self.buffer[-1][2], None, 'Invalid PM35 frame/CRC'))
del self.buffer[0]
continue
if fn == 3 and role == 'response':
total = (raw[2] or 256) + 5
if len(raw) < total:
break
result, error = None, None
try:
result = parse_pm35(raw[:total], role)
except ValueError as exc:
error = str(exc)
events.append((self.buffer[0][1], self.buffer[total-1][2], result, error))
del self.buffer[:1 if error else total]
return events

View File

@@ -0,0 +1,124 @@
"""ProtoCAN, Balsam and SET v2 classic CAN binding."""
from .set_uart import number, parse_frame
TYPES = {0: 'BROADCAST', 1: 'DISCRETE', 2: 'ANALOG', 3: 'GAS',
4: 'MODBUS_COIL', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING',
7: 'MODBUS_INPUT', 8: 'ERROR', 9: 'BOOT_CONTROL', 10: 'BOOT_DATA_A',
11: 'BOOT_DATA_B', 12: 'BOOT_STATUS', 13: 'BOOT_DISCOVERY', 15: 'PULSE'}
BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIFY '
'COMMIT CONFIRM REBOOT ABORT QUERY_PROGRESS').split(), 1))
def legacy(ident, data, profile='protocan'):
if not 0 <= ident <= 0x1fffffff or len(data) > 8:
raise ValueError('Invalid classic CAN frame')
relative = ident - 0xba0000
if profile == 'balsam':
if not (0 <= relative < 13 or 16 <= relative < 29):
return None
if len(data) != 8:
raise ValueError('Balsam requires DLC=8')
header = number(data[:2], 'big')
address, mask = header & 0x1fff, header >> 13
registers = [(address + i, number(data[2+i*2:4+i*2], 'big'))
for i in range(3) if mask & (4 >> i)]
return dict(protocol='Balsam', registers=registers, summary=
'Balsam node=%d %s mask=%X %s' % ((relative & 15) + 1,
'data' if relative >= 16 else 'command', mask,
' '.join('R%04X=%04X' % pair for pair in registers)))
msg, body = (ident >> 16) & 15, ident & 0xffff
result = dict(protocol='ProtoCAN', priority=(ident >> 28) & 1,
route=(ident >> 27) & 1, device_type=(ident >> 24) & 7,
device_id=(ident >> 20) & 15, message_type=msg, body=body)
text = 'ProtoCAN %s dev=%d:%d route=%d priority=%d body=%04X' % (
TYPES.get(msg, 'TYPE_%X' % msg), result['device_type'], result['device_id'],
result['route'], result['priority'], body)
if msg == 3:
if len(data) % 2 or body + len(data)//2 > 65536:
raise ValueError('Invalid GAS register payload/range')
result['registers'] = [(body + i//2, number(data[i:i+2])) for i in range(0, len(data), 2)]
text += ' ' + ('READ' if not data else ' '.join('R%04X=%04X' % pair for pair in result['registers']))
elif 4 <= msg <= 7:
text += ' address=%03X count=%d' % (body >> 4, body & 15)
elif msg == 8:
text += ' info=%02X code=%02X' % (body >> 8, body & 255)
elif msg in (9, 12):
command = body & 255
text += ' session=%d command=%s' % (body >> 8, BOOT.get(command, '%02X' % command))
if msg == 12:
if len(data) != 8:
raise ValueError('BOOT_STATUS requires DLC=8')
text += ' status=%d slot=%d next_block=%d CRC32=%08X' % (data[0], data[1], number(data[2:4]), number(data[4:8]))
elif command in (3, 4):
if len(data) != 8:
raise ValueError('BOOT metadata requires DLC=8')
if command == 3:
text += ' image_size=%d CRC32=%08X' % (number(data[:4]), number(data[4:]))
else:
text += ' product=%d hw=%d..%d version=%08X' % (number(data[:2]), data[2], data[3], number(data[4:]))
elif command in BOOT and data:
raise ValueError('BOOT control requires DLC=0')
elif msg in (10, 11):
if len(data) != 8:
raise ValueError('BOOT_DATA requires DLC=8')
text += ' block=%d offset=0x%X' % (body, body * 8)
elif msg in (0, 1, 2):
shift = 4 if msg == 0 else 12
text += ' type=%X value=%X' % (body >> shift, body & ((1 << shift) - 1))
result['summary'] = text
return result
class Reassembler:
def __init__(self):
self.pending = {}
def feed(self, ident, data, ss, es, now_ms):
events = []
for key, state in list(self.pending.items()):
if now_ms - state['time'] >= 500:
events.append((state['start'], es, None, 'SET CAN timeout ID=%08X' % key))
del self.pending[key]
if ident >> 24 != 0x12:
return events
try:
if not 1 <= len(data) <= 8:
raise ValueError('Invalid SET CAN DLC')
pci = data[0] & 0xf0
if pci == 0x30:
if len(data) != 3 or data[0] & 15 > 2 or data[2] > 127:
raise ValueError('Invalid FLOW_CONTROL')
events.append((ss, es, dict(protocol='SET CAN', summary='FLOW_CONTROL status=%d block=%d st_min=%d ms' % (data[0] & 15, data[1], data[2])), None))
elif pci == 0x10:
if data[0] != 0x10 or len(data) != 8 or not 18 <= number(data[1:3]) <= 530:
raise ValueError('Invalid FIRST segment')
old = self.pending.pop(ident, None)
if old:
events.append((old['start'], es, None, 'FIRST replaced incomplete packet'))
if len(self.pending) >= 64:
raise ValueError('Too many concurrent SET CAN packets (64)')
self.pending[ident] = dict(start=ss, time=now_ms, total=number(data[1:3]),
data=bytearray(data[3:]), sequence=1)
events.append((ss, es, dict(protocol='SET CAN', summary='FIRST length=%d' % number(data[1:3])), None))
elif pci == 0x20:
state = self.pending.get(ident)
if not state or len(data) < 2 or data[0] & 15 != state['sequence']:
raise ValueError('Unexpected CONSECUTIVE sequence')
state['data'].extend(data[1:])
state['time'], state['sequence'] = now_ms, (state['sequence'] + 1) & 15
if len(state['data']) > state['total']:
raise ValueError('SET CAN packet exceeds advertised length')
if len(state['data']) == state['total']:
packet = parse_frame(state['data'])
if packet['protocol'] != 'SET v2' or packet['source'] != (ident >> 8) & 255 or packet['destination'] != (ident >> 16) & 255 or bool(packet['flags'] & 32) != bool(ident & 128):
raise ValueError('CAN ID and SET header disagree')
events.append((state['start'], es, packet, None))
del self.pending[ident]
else:
events.append((ss, es, dict(protocol='SET CAN', summary='CONSECUTIVE SN=%d' % (data[0] & 15)), None))
else:
raise ValueError('Unknown SET CAN PCI')
except ValueError as exc:
state = self.pending.pop(ident, None)
events.append((state['start'] if state else ss, es, None, str(exc)))
return events

View File

@@ -0,0 +1,133 @@
"""Portable wire readers based on templates/c/set-protocol (no native DLL)."""
import binascii
V2 = {1: 'PING', 2: 'DEVICE_INFO', 3: 'CAPABILITIES', 8: 'DIAGNOSTICS',
9: 'READ', 10: 'WRITE', 16: 'LOG_READ', 17: 'CATALOG',
18: 'SUBSCRIBE', 19: 'PUBLISH', 20: 'UNSUBSCRIBE'}
V2.update(dict((256 + i, 'FW_' + name) for i, name in enumerate(
('BEGIN', 'DATA', 'END', 'ABORT', 'STATUS', 'ACTIVATE'))))
V1 = dict(enumerate(('PING DEVICE_INFO GET_OBJECT SET_OBJECT GET_OBJECT_LIST '
'GET_OBJECT_INFO COMMAND_STATUS DIAGNOSTICS READ_REGISTERS WRITE_REGISTERS '
'FIRMWARE_BEGIN FIRMWARE_DATA FIRMWARE_END FIRMWARE_ABORT FIRMWARE_STATUS '
'READ_LOGS GAS_CATALOG GAS_WATCH_SET GAS_WATCH_DATA').split(), 1))
V1.update({128: 'NACK', 129: 'ACK', 130: 'ERROR'})
V1.update(dict(enumerate(('SENSOR_SCAN SENSOR_LIST SENSOR_READ SENSOR_DATA '
'SET_USER_BYTES SET_RESOLUTION SET_POLL_PERIOD SEND_ID_CAN UI_KEY UI_READ '
'UI_STATE EEPROM_SCAN EEPROM_INFO EEPROM_READ EEPROM_LIST').split(), 32)))
def number(data, order='little'):
return int.from_bytes(bytes(data), order)
def crc16(data, modbus=False):
crc = 0xffff
for byte in data:
crc ^= byte if modbus else byte << 8
for _ in range(8):
if modbus:
crc = (crc >> 1) ^ (0xa001 if crc & 1 else 0)
else:
crc = ((crc << 1) ^ (0x1021 if crc & 0x8000 else 0)) & 0xffff
return crc
def parse_frame(raw):
raw = bytes(raw)
bridge = raw[:2] == b'\xaa\x55'
if bridge:
if len(raw) < 11 or not 6 <= raw[2] <= 14 or len(raw) != raw[2] + 5:
raise ValueError('Invalid ProtoCAN length')
crc, received = crc16(raw[2:-2]), number(raw[-2:])
ident, flags = number(raw[5:9]), raw[4]
if ident > (0x1fffffff if flags & 1 else 0x7ff) or flags & 0xf0:
raise ValueError('Invalid ProtoCAN ID/flags')
result = dict(protocol='ProtoCAN bridge', sequence=raw[3], flags=flags,
can_id=ident, payload=raw[9:-2])
result['summary'] = 'ProtoCAN seq=%d ID=%08X flags=%02X' % (raw[3], ident, flags)
else:
if len(raw) < 12 or raw[:2] != b'\xa5\x5a' or raw[2] not in (1, 2):
raise ValueError('Invalid SET signature/version')
version = raw[2]
header = 14 if version == 2 else 8
if len(raw) < header + 4:
raise ValueError('Incomplete SET header')
size = number(raw[12:14]) if version == 2 else number(raw[6:8], 'big')
if size > 512 or len(raw) != header + size + 4:
raise ValueError('Invalid SET payload length')
crc, received = binascii.crc32(raw[2:-4]) & 0xffffffff, number(raw[-4:])
msg = number(raw[4:6]) if version == 2 else raw[3]
seq = number(raw[10:12]) if version == 2 else number(raw[4:6], 'big')
result = dict(protocol='SET v2' if version == 2 else 'SETGUI v1',
message_type=msg, sequence=seq, payload=raw[header:-4])
name = (V2 if version == 2 else V1).get(msg, 'TYPE_%04X' % msg)
result['summary'] = '%s %s seq=%d len=%d' % (result['protocol'], name, seq, size)
if version == 2:
result.update(source=number(raw[6:8]), destination=number(raw[8:10]), flags=raw[3])
result['summary'] += ' %04X->%04X flags=%02X' % (result['source'], result['destination'], raw[3])
if raw[3] & 0xc0:
raise ValueError('Reserved SET flags')
if raw[3] & 1:
if size < 2:
raise ValueError('SET response missing status')
result['status'] = number(result['payload'][:2])
result['summary'] += ' status=%d' % result['status']
if crc != received:
raise ValueError('CRC mismatch: received %X expected %X' % (received, crc))
result['raw'] = raw
return result
class StreamParser:
"""Bounded byte stream; keep sample positions during resynchronization."""
def __init__(self, mode='auto'):
self.mode = mode
self.buffer = []
def feed(self, value, start, end):
self.buffer.append((value, start, end))
events = []
while len(self.buffer) >= 3:
raw = bytes(item[0] for item in self.buffer)
bridge = raw[:2] == b'\xaa\x55' and self.mode in ('auto', 'bridge')
modern = raw[:2] == b'\xa5\x5a' and self.mode != 'bridge'
if not bridge and not modern:
del self.buffer[0]
continue
error = None
if bridge:
total = raw[2] + 5
if not 6 <= raw[2] <= 14:
error = 'Invalid ProtoCAN length'
else:
if raw[2] not in (1, 2) or self.mode in ('v1', 'v2') and raw[2] != int(self.mode[1]):
error = 'Unexpected SET version'
header = 14 if raw[2] == 2 else 8
if not error and len(raw) < header:
break
size = number(raw[12:14]) if raw[2] == 2 else number(raw[6:8], 'big')
total = header + size + 4
if size > 512:
error = 'Payload exceeds 512 bytes'
if not error and len(raw) < total:
break
last = min(len(raw), total) - 1
result = None
if not error:
try:
result = parse_frame(raw[:total])
except ValueError as exc:
error = str(exc)
events.append((self.buffer[0][1], self.buffer[last][2], result, error))
del self.buffer[:1 if error else total]
return events
def flush(self):
if not self.buffer:
return []
pending = self.buffer
self.buffer = []
if pending[0][0] not in (0xa5, 0xaa):
return []
return [(pending[0][1], pending[-1][2], None, 'Incomplete frame')]

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)

View File

@@ -0,0 +1,155 @@
"""Local Logic 2 acquisition through the optional Saleae Automation API."""
from __future__ import annotations
from array import array
from contextlib import suppress
from dataclasses import dataclass
import json
import math
from pathlib import Path
import struct
import sys
import tempfile
import time
from logic_analyzer.files import DigitalCapture, DigitalChannel, ImportCancelled
@dataclass(frozen=True)
class SaleaeSettings:
device_id: str
channels: tuple = (0, 1)
sample_rate: int = 24000000
duration: float = 1.0 # Wall-clock stop timer; zero means manual stop.
threshold: float = None
buffer_mb: int = 256
port: int = 10430
def validate(self):
if not self.device_id:
raise ValueError('Выберите устройство Saleae.')
if (not self.channels or len(set(self.channels)) != len(self.channels)
or any(type(c) is not int or not 0 <= c <= 15 for c in self.channels)):
raise ValueError('Выберите цифровые каналы D0…D15 без повторений.')
if not 1 <= self.sample_rate <= 500000000:
raise ValueError('Частота должна быть от 1 до 500000000 выборок/с.')
if not math.isfinite(self.duration) or not 0 <= self.duration <= 3600:
raise ValueError('Таймер должен быть от 0 до 3600 с.')
if self.threshold not in (None, 1.2, 1.8, 3.3):
raise ValueError('Неподдерживаемый порог цифрового входа.')
if not 16 <= self.buffer_mb <= 4096 or not 1 <= self.port <= 65535:
raise ValueError('Некорректный буфер или порт Automation API.')
def automation_api():
try:
from saleae import automation
except ImportError as exc:
raise RuntimeError('Не установлен logic2-automation. Для запуска из Python: '
'pip install logic2-automation; для EXE нужна сборка с этим пакетом.') from exc
return automation
def connect(api, port):
# Bound every RPC, including stop/export/close, when Logic 2 stops responding.
config = json.dumps({'methodConfig': [{'name': [{}], 'timeout': '30s'}]})
try:
return api.Manager.connect(port=port, connect_timeout_seconds=3,
grpc_channel_arguments=[('grpc.service_config', config)])
except Exception as exc:
raise RuntimeError('Нет связи с Logic 2 на localhost:%d. Запустите Logic 2 и '
'включите Preferences → Enable Automation Server. %s' % (port, exc)) from exc
def list_devices(port=10430, simulation=False):
api = automation_api()
with connect(api, port) as manager:
return manager.get_devices(include_simulation_devices=simulation)
def read_binary(directory, channels, cancel=lambda: False):
"""Read documented Saleae digital binary v0, preserving constant tails."""
result, bounds = [], None
header = struct.Struct('<8siiIddQ')
for channel in channels:
path = Path(directory) / ('digital_%d.bin' % channel)
with path.open('rb') as stream:
raw = stream.read(header.size)
if len(raw) != header.size:
raise ValueError('Обрезанный заголовок Saleae: ' + path.name)
magic, version, kind, initial, start, end, count = header.unpack(raw)
if magic != b'<SALEAE>' or version != 0 or kind != 0:
raise ValueError('Неподдерживаемый бинарный экспорт Saleae (нужен digital v0).')
if (initial not in (0, 1) or not math.isfinite(start) or not math.isfinite(end)
or end < start or path.stat().st_size != header.size + count * 8):
raise ValueError('Повреждённый бинарный экспорт Saleae.')
if bounds is not None and bounds != (start, end):
raise ValueError('Границы каналов Saleae не совпадают.')
bounds = start, end
edges = array('d')
previous = start
while count:
if cancel():
raise ImportCancelled()
size = min(count, 65536)
block = array('d')
block.frombytes(stream.read(size * 8))
if sys.byteorder != 'little':
block.byteswap()
for edge in block:
if not math.isfinite(edge) or edge < previous or edge > end:
raise ValueError('Некорректное время фронта Saleae.')
previous = edge
edges.extend(block)
count -= size
result.append(DigitalChannel('D%d' % channel, initial, edges))
if not result:
raise ValueError('Нет цифровых каналов Saleae.')
return DigitalCapture('', 'Saleae ONLINE', result, *bounds)
def acquire(settings, stop, cancel, status=lambda text: None):
"""Stop exactly once; cancel discards data, stop imports the captured window."""
settings.validate()
api = automation_api()
status('Подключение к Logic 2…')
with connect(api, settings.port) as manager:
if cancel():
raise ImportCancelled()
device = api.LogicDeviceConfiguration(
enabled_digital_channels=list(settings.channels),
digital_sample_rate=settings.sample_rate,
digital_threshold_volts=settings.threshold)
configuration = api.CaptureConfiguration(buffer_size_megabytes=settings.buffer_mb,
capture_mode=api.ManualCaptureMode())
capture = manager.start_capture(device_id=settings.device_id,
device_configuration=device,
capture_configuration=configuration)
stopped = False
try:
status('Захват Saleae… Нажмите «Стоп и показать» для завершения.')
deadline = time.monotonic() + settings.duration if settings.duration else None
while not stop() and not cancel():
if deadline is not None and time.monotonic() >= deadline:
break
time.sleep(.02)
stopped = True # Never retry stop(), even if it raises.
capture.stop()
if cancel():
raise ImportCancelled()
status('Получение цифровых каналов…')
with tempfile.TemporaryDirectory(prefix='setgui-saleae-') as directory:
capture.export_raw_data_binary(directory, digital_channels=list(settings.channels),
analog_channels=[])
result = read_binary(directory, settings.channels, cancel)
if cancel():
raise ImportCancelled()
result.source = 'Saleae %s · %g MS/s' % (settings.device_id, settings.sample_rate / 1e6)
result.sample_rate = settings.sample_rate
return result
finally:
if not stopped:
with suppress(Exception):
capture.stop()
with suppress(Exception):
capture.close()