"""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)