# Офлайн-интерпретация CAN-захвата для ProtoCAN, BALZAM и SET v2. Выбранный формат определяет # разбор адреса и данных; сегментированные SET-пакеты требуют сборки до интерпретации полезной # нагрузки. """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: 'GAS_SLOT', 5: 'MODBUS', # Единый Modbus занимает 0x5, прежние коды 0x6 и 0x7 освобождены. 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 msg == 4: # Слотный GAS определяется номером типа, без эвристики по первым байтам данных. from protocan.gas_slots import GasSlots # Проверку Word0-маски, длины и порядка октетов выполняет единственный C-кодек. values = GasSlots().decode_address(ident, data) # Не ограничиваем анализатор адресами семейства PERIPH. result['slot'] = body # Адрес слота отделён от списка обычных регистров GAS 0x3. result['mask'] = values[0] if values else None # Пустой запрос чтения не содержит фиктивной маски. result['slot_words'] = [(index, values[index]) for index in range(1, 4) if values and values[0] & (1 << index)] # Публикуем только выбранные или достоверные значения, без служебного Word0. text += ' slot=%04X' % body + (' READ' if not values else ' mask=%04X' % values[0]) # В логе ясно различаются адрес и маска частичной записи. elif msg == 5: # Modbus использует один тип сообщения и поле функции внутри Body. from protocan.protocan import decode # Один подробный декодер проверяет длину, диапазон адресов и битовые маски. decoded = decode(ident, bytes(data)) # Передаём исходный payload без повторной реализации протокольных проверок. if decoded.warnings: # Некорректные значения должны быть видны вызывающему анализатору. raise ValueError('; '.join(decoded.warnings)) # Сохраняем подробные сообщения общей реализации. result['address'], result['count'], result['function'] = body >> 4, (body & 3) + 1, ((body >> 2) & 3) + 1 # Внешний результат содержит физические значения полей 1…4. result['registers'] = decoded.registers # Для Coil/Discrete публикуются отдельные биты, для Holding/Input — 16-битные слова. text += ' FC%d address=%03X count=%d' % (result['function'], result['address'], result['count']) # Журнал явно различает четыре банка единого Modbus. 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