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,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')]