feat(gas-logger): добавь непрерывный логгер и JSON-карту

This commit is contained in:
2026-10-01 22:54:29 +03:00
parent 675ce063f0
commit f7a6f767e1
22 changed files with 1250 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
"""Experimental GAS recorder map and transport-independent snapshot client.
read(address, count) -> words; write(address, value) must await a confirmed
success or raise. Run download in the transport's worker, never a Qt UI slot.
No retries of writes: a timed-out snapshot may already exist on the device.
"""
from __future__ import annotations
import json
import re
import zlib
from pathlib import Path
MAGIC, VERSION = 0x474C, 1
DATA_OFFSET, HEADER_WORDS = 0x100, 5
def validate_map(data):
if data.get('format') != 'set-gas-logger' or data.get('version') != VERSION:
raise ValueError('Unsupported GAS logger map')
def integer(value, low, high):
if type(value) is not int or not low <= value <= high:
raise ValueError('Invalid map integer')
return value
base = integer(data['service']['base'], 0, 65535)
if data['service']['data_offset'] != DATA_OFFSET:
raise ValueError('Invalid snapshot data offset')
capacity = integer(data['capacity'], 1, 65535)
channels = data['channels']
if not 1 <= len(channels) <= 64:
raise ValueError('Invalid channel count')
end = base + DATA_OFFSET + capacity * (len(channels) + HEADER_WORDS)
if end > 65536:
raise ValueError('Logger window exceeds GAS')
seen = {key: set() for key in ('key', 'gas', 'modbus', 'can')}
for channel in channels:
if not re.fullmatch(r'[a-z][a-z0-9_]*', channel['key']):
raise ValueError('Invalid channel key')
if not isinstance(channel['name'], str) or not channel['name'].strip():
raise ValueError('Missing channel name')
if channel['type'] not in ('u16', 'i16'):
raise ValueError('Unsupported channel type')
if channel['can']['protocol'] != 'protocan_gas':
raise ValueError('CAN mapping must use ProtoCAN GAS')
if channel['modbus']['function'] != 3:
raise ValueError('Modbus mapping must use FC03')
for key in seen:
value = channel[key]
if key in ('modbus', 'can'):
value = value['address']
if key != 'key':
integer(value, 0, 65535)
if base <= value < end:
raise ValueError('Signal overlaps logger service')
if value in seen[key]:
raise ValueError('Duplicate ' + key)
seen[key].add(value)
source = channel['source']
if source['space'] not in ('pm35_modbus', 'pm67_cache'):
raise ValueError('Unknown PM35 source')
integer(source['address'], 0, 127 if source['space'] == 'pm35_modbus' else 1)
expected = ['magic', 'version', 'flags', 'channels', 'record_words', 'capacity',
'live_count', 'snapshot_count', 'generation_lo', 'generation_hi',
'schema_lo', 'schema_hi', 'sequence_lo', 'sequence_hi',
'missed_lo', 'missed_hi', 'source_errors_lo', 'source_errors_hi',
'command', 'release_generation_lo', 'release_generation_hi']
registers = data['service']['registers']
if [(r['offset'], r['key'], r['access']) for r in registers] != [
(i, key, 'r' if i < 18 else 'w') for i, key in enumerate(expected)]:
raise ValueError('Service map does not match protocol version 1')
return data
def load_map(path=None):
path = Path(path) if path else Path(__file__).with_name('gas_logger_maps') / 'pm35.json'
return validate_map(json.loads(path.read_text(encoding='utf-8')))
def schema_id(mapping):
validate_map(mapping)
raw = json.dumps(mapping, ensure_ascii=True, sort_keys=True, separators=(',', ':')).encode('ascii')
return zlib.crc32(raw) & 0xffffffff
def c_header(mapping):
"""Generate the checked-in C mirror; --check in CI catches drift."""
validate_map(mapping)
lines = ['/* Generated from pm35.json. Do not edit. */',
'#ifndef GL_PM35_MAP_H', '#define GL_PM35_MAP_H', '#include "gas_logger.h"',
'static const gl_channel gl_pm35_channels[] = {']
for c in mapping['channels']:
lines.append(' {%dU, %dU, %dU}, /* %s */' % (
c['gas'], c['modbus']['address'], c['can']['address'], c['key']))
lines += ['};', 'static const uint16_t gl_pm35_source_space[] = { ' + ', '.join(
'0U' if c['source']['space'] == 'pm35_modbus' else '1U' for c in mapping['channels']) + ' };',
'static const uint16_t gl_pm35_source_address[] = { ' + ', '.join(
str(c['source']['address'])+'U' for c in mapping['channels'])+' };',
'static const gl_config gl_pm35_config = {',
' %dU, %dU, %dU, 0x%08XUL, gl_pm35_channels' % (
mapping['service']['base'], len(mapping['channels']), mapping['capacity'], schema_id(mapping)),
'};', '#endif', '']
return '\n'.join(lines)
def _u32(words, offset):
return words[offset] | words[offset + 1] << 16
class GasLoggerClient:
def __init__(self, read, write, mapping=None, *, block_words=120):
self.mapping = load_map() if mapping is None else validate_map(mapping)
if not 1 <= block_words <= 125:
raise ValueError('Block size must be 1..125 words')
self.read, self.write, self.block_words = read, write, block_words
self.base = self.mapping['service']['base']
def _read(self, address, count):
result = tuple(self.read(address, count))
if len(result) != count or any(type(w) is not int or not 0 <= w <= 65535 for w in result):
raise ValueError('Incomplete or invalid GAS response')
return result
def status(self):
# Classic CAN may return only four words. Generation and geometry are
# stable with one control client; the sequence/live counters are diagnostic.
words = tuple(w for offset in range(0, 18, self.block_words)
for w in self._read(self.base + offset, min(self.block_words, 18-offset)))
width = len(self.mapping['channels']) + HEADER_WORDS
if (words[:2] != (MAGIC, VERSION) or words[3] != len(self.mapping['channels'])
or words[4] != width or words[5] != self.mapping['capacity']
or _u32(words, 10) != schema_id(self.mapping)):
raise ValueError('Device logger/schema does not match JSON map')
if words[2] & ~3 or words[6] > words[5] or words[7] > words[5]:
raise ValueError('Invalid logger status')
return words
def start(self):
self.status() # never write to an unrecognized device
self.write(self.base+18, 1)
def release(self, generation):
status = self.status()
if not status[2] & 2 or _u32(status, 8) != generation:
raise ValueError('Snapshot generation changed')
self.write(self.base+19, generation & 0xffff)
self.write(self.base+20, generation >> 16)
self.write(self.base+18, 3)
def download(self, *, resume=False, cancelled=lambda: False, progress=lambda done,total: None):
"""Pin/copy/verify/release; cancellation/errors retain the pin for resume.
resume=True explicitly takes over the retained snapshot. A link failure
during release may have released it already; inspect status, don't replay.
"""
status = self.status()
if cancelled():
raise InterruptedError('Snapshot download cancelled')
if status[2] & 2:
if not resume:
raise RuntimeError('Snapshot already pinned; resume or release explicitly')
else:
if resume:
raise RuntimeError('No retained snapshot to resume')
self.write(self.base+18, 2)
status = self.status()
if not status[2] & 2 or not status[7]:
raise ValueError('Device did not pin a nonempty snapshot')
generation, count, width = _u32(status, 8), status[7], status[4]
words = []
total = count * width
while len(words) < total:
if cancelled():
raise InterruptedError('Snapshot download cancelled; snapshot retained')
words.extend(self._read(self.base+DATA_OFFSET+len(words), min(self.block_words,total-len(words))))
progress(len(words),total)
final = self.status()
if not final[2] & 2 or _u32(final,8) != generation or final[7] != count:
raise ValueError('Snapshot changed during download')
records = []
for offset in range(0,total,width):
record = {'time_ms': _u32(words,offset), 'sequence': _u32(words,offset+2), 'event': words[offset+4]}
if records and record['sequence'] != ((records[-1]['sequence']+1) & 0xffffffff):
raise ValueError('Snapshot record sequence is not contiguous')
for i,c in enumerate(self.mapping['channels']):
v=words[offset+HEADER_WORDS+i]
record[c['key']] = v-65536 if c['type']=='i16' and v>=32768 else v
records.append(record)
if cancelled():
raise InterruptedError('Snapshot download cancelled; snapshot retained')
self.release(generation)
return records

View File

@@ -0,0 +1,274 @@
{
"format": "set-gas-logger",
"version": 1,
"project": "pm35",
"mcu_ports": [
"tms320f2812",
"tms320f28335",
"stm32f1",
"stm32f4",
"stm32g4"
],
"address_convention": "zero_based_16_bit_registers",
"capacity": 760,
"service": {
"base": 24576,
"data_offset": 256,
"registers": [
{
"offset": 0,
"key": "magic",
"name": "Сигнатура",
"access": "r"
},
{
"offset": 1,
"key": "version",
"name": "Версия",
"access": "r"
},
{
"offset": 2,
"key": "flags",
"name": "Флаги записи и снимка",
"access": "r"
},
{
"offset": 3,
"key": "channels",
"name": "Количество каналов",
"access": "r"
},
{
"offset": 4,
"key": "record_words",
"name": "Слов в записи",
"access": "r"
},
{
"offset": 5,
"key": "capacity",
"name": "Ёмкость банка",
"access": "r"
},
{
"offset": 6,
"key": "live_count",
"name": "Записей в текущем банке",
"access": "r"
},
{
"offset": 7,
"key": "snapshot_count",
"name": "Записей в снимке",
"access": "r"
},
{
"offset": 8,
"key": "generation_lo",
"name": "Поколение: младшее слово",
"access": "r"
},
{
"offset": 9,
"key": "generation_hi",
"name": "Поколение: старшее слово",
"access": "r"
},
{
"offset": 10,
"key": "schema_lo",
"name": "CRC карты: младшее слово",
"access": "r"
},
{
"offset": 11,
"key": "schema_hi",
"name": "CRC карты: старшее слово",
"access": "r"
},
{
"offset": 12,
"key": "sequence_lo",
"name": "Номер записи: младшее слово",
"access": "r"
},
{
"offset": 13,
"key": "sequence_hi",
"name": "Номер записи: старшее слово",
"access": "r"
},
{
"offset": 14,
"key": "missed_lo",
"name": "Пропущено снимков: младшее слово",
"access": "r"
},
{
"offset": 15,
"key": "missed_hi",
"name": "Пропущено снимков: старшее слово",
"access": "r"
},
{
"offset": 16,
"key": "source_errors_lo",
"name": "Ошибки источников: младшее слово",
"access": "r"
},
{
"offset": 17,
"key": "source_errors_hi",
"name": "Ошибки источников: старшее слово",
"access": "r"
},
{
"offset": 18,
"key": "command",
"name": "Команда",
"access": "w"
},
{
"offset": 19,
"key": "release_generation_lo",
"name": "Освобождаемое поколение: младшее слово",
"access": "w"
},
{
"offset": 20,
"key": "release_generation_hi",
"name": "Освобождаемое поколение: старшее слово",
"access": "w"
}
]
},
"channels": [
{
"key": "commands",
"name": "Команды УМП",
"type": "u16",
"gas": 20480,
"modbus": {
"function": 3,
"address": 20480
},
"can": {
"protocol": "protocan_gas",
"address": 20480
},
"source": {
"space": "pm35_modbus",
"address": 127
}
},
{
"key": "current",
"name": "Задание тока, 0.1 мА",
"type": "u16",
"gas": 20481,
"modbus": {
"function": 3,
"address": 20481
},
"can": {
"protocol": "protocan_gas",
"address": 20481
},
"source": {
"space": "pm35_modbus",
"address": 28
}
},
{
"key": "dac",
"name": "Код ЦАП",
"type": "u16",
"gas": 20482,
"modbus": {
"function": 3,
"address": 20482
},
"can": {
"protocol": "protocan_gas",
"address": 20482
},
"source": {
"space": "pm35_modbus",
"address": 123
}
},
{
"key": "go",
"name": "Начальная уставка, мА",
"type": "u16",
"gas": 20483,
"modbus": {
"function": 3,
"address": 20483
},
"can": {
"protocol": "protocan_gas",
"address": 20483
},
"source": {
"space": "pm35_modbus",
"address": 80
}
},
{
"key": "stop",
"name": "Конечная уставка, мА",
"type": "u16",
"gas": 20484,
"modbus": {
"function": 3,
"address": 20484
},
"can": {
"protocol": "protocan_gas",
"address": 20484
},
"source": {
"space": "pm35_modbus",
"address": 81
}
},
{
"key": "zpt1",
"name": "ЗПТ1: U1 + U2",
"type": "u16",
"gas": 20485,
"modbus": {
"function": 3,
"address": 20485
},
"can": {
"protocol": "protocan_gas",
"address": 20485
},
"source": {
"space": "pm67_cache",
"address": 0
}
},
{
"key": "zpt2",
"name": "ЗПТ2: U3 + U4",
"type": "u16",
"gas": 20486,
"modbus": {
"function": 3,
"address": 20486
},
"can": {
"protocol": "protocan_gas",
"address": 20486
},
"source": {
"space": "pm67_cache",
"address": 1
}
}
]
}