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,253 @@
"""Bounded host traffic recording and synthetic DSView v3 logic captures.
No hardware timing is inferred: timestamps are host observations and overlapping
packets on one wire are serialized. Original events remain in the archive.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
from time import monotonic_ns
import zipfile
def uart_event(source: str, direction: str, data: bytes, rate: int) -> dict:
return dict(source=source, kind="uart", direction=direction, data=bytes(data),
rate=rate, time_ns=monotonic_ns())
def can_event(source: str, direction: str, identifier: int, data: bytes,
rate: int, extended: bool = True, remote: bool = False) -> dict:
return dict(source=source, kind="can", direction=direction, data=bytes(data),
rate=rate, time_ns=monotonic_ns(), identifier=identifier,
extended=extended, remote=remote)
def validate_event(event: dict) -> None:
if event['kind'] not in ('uart', 'can') or event['direction'] not in ('RX', 'TX'):
raise ValueError('Unknown traffic type/direction')
if not isinstance(event['rate'], int) or not 1 <= event['rate'] <= 10_000_000:
raise ValueError('Invalid line rate')
if not isinstance(event['time_ns'], int) or event['time_ns'] < 0:
raise ValueError('Invalid event timestamp')
if not event['source'] or any(c in event['source'] for c in '\r\n=[]'):
raise ValueError('Invalid source name')
if event['kind'] == 'can':
if len(event['data']) > 8 or not 0 <= event['identifier'] <= (0x1fffffff if event['extended'] else 0x7ff):
raise ValueError('Only classic CAN IDs and DLC 0..8 are supported')
class TrafficRecording:
MAX_EVENTS = 100_000
MAX_BYTES = 8 * 1024 * 1024
def __init__(self):
self.events: list[dict] = []
self.byte_count = 0
def append(self, event: dict) -> None:
validate_event(event)
data = bytes(event['data'])
if event['kind'] == 'uart' and not data:
return
if len(self.events) >= self.MAX_EVENTS or self.byte_count + len(data) > self.MAX_BYTES:
raise ValueError('Достигнут лимит записи: 100 000 событий / 8 МиБ данных')
self.events.append(dict(event, data=data))
self.byte_count += len(data)
def bits(value: int, count: int):
return [(value >> i) & 1 for i in range(count-1, -1, -1)]
def uart_bits(data: bytes):
for byte in data:
yield 0
for i in range(8):
yield (byte >> i) & 1
yield 1
def can_bits(event: dict):
"""CAN 2.0 data/remote frame, CRC15, stuffing, synthetic successful ACK."""
ident, remote, payload = event['identifier'], event['remote'], event['data']
if event['extended']:
raw = [0] + bits(ident >> 18, 11) + [1, 1] + bits(ident & 0x3ffff, 18) + [int(remote), 0, 0]
else:
raw = [0] + bits(ident, 11) + [int(remote), 0, 0]
raw += bits(len(payload), 4)
if not remote:
for byte in payload:
raw += bits(byte, 8)
crc = 0
for bit in raw:
feedback = ((crc >> 14) & 1) ^ bit
crc = (crc << 1) & 0x7fff
if feedback:
crc ^= 0x4599
raw += bits(crc, 15)
last, run = None, 0
for bit in raw:
yield bit
run = run + 1 if bit == last else 1
last = bit
if run == 5:
last = 1 - bit
yield last
run = 1
# CRC delimiter, reconstructed ACK, ACK delimiter, EOF, intermission.
yield from [1, 0, 1] + [1] * 10
def channel_name(event):
# A reconnect at another line rate must remain independently decodable.
return event['source'] + ('_' + event['direction'] if event['kind'] == 'uart' else '_CAN') + '_' + str(event['rate'])
class _PackedWriter:
"""LSB-first samples streamed in bounded chunks, including long idle runs."""
def __init__(self, stream, cancelled):
self.stream, self.cancelled = stream, cancelled
self.partial = self.used = 0
self.buffer = bytearray()
def _flush(self):
if self.cancelled():
raise InterruptedError('Экспорт отменён')
self.stream.write(self.buffer)
self.buffer.clear()
def run(self, level: int, count: int):
if count < 0:
raise ValueError('Non-monotonic waveform')
if self.used:
take = min(count, 8 - self.used)
if level:
self.partial |= ((1 << take)-1) << self.used
self.used += take
count -= take
if self.used == 8:
self.buffer.append(self.partial)
self.partial = self.used = 0
full, tail = divmod(count, 8)
while full:
take = min(full, 65536)
self.buffer.extend(bytes([255 if level else 0]) * take)
full -= take
if len(self.buffer) >= 65536:
self._flush()
if tail:
self.partial = (1 << tail)-1 if level else 0
self.used = tail
def finish(self):
if self.used:
raise ValueError('Unaligned sample count')
self._flush()
def export_dsl(path, events, samplerate=20_000_000, *, overwrite=False,
cancelled=lambda: False, progress=lambda value: None):
"""Atomic export. Existing destination survives failed/cancelled exports."""
path = Path(path)
if not events:
raise ValueError('Нет записанного трафика')
if path.exists() and not overwrite:
raise FileExistsError(path)
if not isinstance(samplerate, int) or not 1 <= samplerate <= 100_000_000:
raise ValueError('Частота дискретизации должна быть от 1 до 100 МГц')
groups = {}
origin = min(event['time_ns'] for event in events)
shifted = 0
for event in events:
validate_event(event)
if samplerate < event['rate'] * 8:
raise ValueError('Частота дискретизации должна быть не меньше 8× скорости линии')
groups.setdefault(channel_name(event), []).append(event)
if len(groups) > 32:
raise ValueError('DSView: максимум 32 цифровых канала')
schedule, maximum = {}, 0
for name, channel_events in groups.items():
cursor = 16
scheduled = []
for event in sorted(channel_events, key=lambda item: item['time_ns']):
if cancelled():
raise InterruptedError('Экспорт отменён')
data_bits = list(can_bits(event)) if event['kind'] == 'can' else None
bit_count = len(data_bits) if data_bits is not None else len(event['data']) * 10
wanted = 16 + (event['time_ns'] - origin) * samplerate // 1_000_000_000
start = max(cursor, wanted)
shifted += int(start > wanted)
count = (bit_count * samplerate + event['rate'] - 1) // event['rate']
scheduled.append((start, event, data_bits, bit_count))
cursor = start + count
maximum = max(maximum, cursor + 16)
schedule[name] = scheduled
padded = (maximum + 63) // 64 * 64
if padded > 2_000_000_000:
raise ValueError('Запись превышает 2 млрд отсчётов: сократите длительность или частоту')
names = list(groups)
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 = 1\n'
f'samplerate = {samplerate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
session = {'Device': 'virtual-session', 'DeviceMode': 0, 'Version': 3,
'Title': 'DSView v1.3.2', 'Sample count': str(padded),
'Sample rate': str(samplerate), 'Max Height': '1X', 'decoder': [],
'channel': [{'colour': 'default', 'enabled': True, 'index': i,
'name': name, 'strigger': 0, 'type': 10000, 'view_index': i}
for i, name in enumerate(names)]}
fd, temporary = tempfile.mkstemp(prefix='.setgui-dsl-', suffix='.tmp', dir=path.parent)
os.close(fd)
try:
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('header', header)
archive.writestr('session', json.dumps(session, ensure_ascii=False))
archive.writestr('decoders', '[]')
metadata = dict(schema='setgui.protocol-capture.v1', synthetic=True,
timing='host observation; overlapping events serialized per wire',
uart='8N1, non-inverted', can_ack='synthetic dominant ACK',
shifted_events=shifted, samplerate=samplerate,
origin_monotonic_ns=origin,
events=[dict(event, data=event['data'].hex()) for event in events])
archive.writestr('protocol-log.json', json.dumps(metadata, ensure_ascii=False))
done, last_progress = 0, -1
for index, name in enumerate(names):
with archive.open(f'L-{index}/0', 'w', force_zip64=True) as output:
writer = _PackedWriter(output, cancelled)
cursor = 0
for start, event, data_bits, bit_count in schedule[name]:
writer.run(1, start - cursor)
bitstream = data_bits if data_bits is not None else uart_bits(event['data'])
previous = 0
for i, level in enumerate(bitstream, 1):
boundary = (i * samplerate + event['rate'] - 1) // event['rate']
writer.run(level, boundary - previous)
previous = boundary
cursor = start + previous
done += 1
value = done * 95 // len(events)
if value != last_progress:
progress(value)
last_progress = value
writer.run(1, padded - cursor)
writer.finish()
if cancelled():
raise InterruptedError('Экспорт отменён')
with zipfile.ZipFile(temporary) as archive:
if archive.testzip() is not None:
raise ValueError('Ошибка проверки архива DSL')
if cancelled():
raise InterruptedError('Экспорт отменён')
if overwrite:
os.replace(temporary, path)
else:
# Atomic no-clobber publication on the same filesystem.
os.link(temporary, path)
progress(100)
finally:
Path(temporary).unlink(missing_ok=True)
return dict(samples=padded, samplerate=samplerate, channels=names, shifted_events=shifted)