Checkpoint EEPROM, firmware analysis and instrument protocol updates
This commit is contained in:
64
python/set_devices/bin_compare.py
Normal file
64
python/set_devices/bin_compare.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Renderer-independent comparison of arbitrary binary files."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
|
||||
|
||||
def compare_sha256(image: bytes, readback: bytes):
|
||||
"""Compare a complete physical readback against an immutable BIN snapshot."""
|
||||
if not image or len(image) != len(readback):
|
||||
raise ValueError('Для SHA-256 нужен полный прочитанный диапазон размером с BIN.')
|
||||
expected = hashlib.sha256(image).hexdigest()
|
||||
actual = hashlib.sha256(readback).hexdigest()
|
||||
return expected == actual, expected, actual
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Comparison:
|
||||
left: bytes
|
||||
right: bytes
|
||||
offsets: tuple[int, ...]
|
||||
changed_bits: int
|
||||
|
||||
@property
|
||||
def changed_bytes(self) -> int:
|
||||
return len(self.offsets)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return max(len(self.left), len(self.right))
|
||||
|
||||
|
||||
def compare(left: bytes, right: bytes) -> Comparison:
|
||||
"""Count bit differences in overlap; unmatched bytes count as changed bytes."""
|
||||
common = min(len(left), len(right))
|
||||
offsets = tuple(i for i in range(common) if left[i] != right[i])
|
||||
bits = sum(bin(left[i] ^ right[i]).count('1') for i in offsets)
|
||||
return Comparison(left, right, offsets + tuple(range(common, max(len(left), len(right)))), bits)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MultiComparison:
|
||||
images: tuple[bytes, ...]
|
||||
offsets: tuple[int, ...]
|
||||
pairs: tuple
|
||||
|
||||
@property
|
||||
def size(self): return max(map(len, self.images))
|
||||
|
||||
@property
|
||||
def changed_bytes(self): return len(self.offsets)
|
||||
|
||||
|
||||
def compare_many(images):
|
||||
"""Highlight offsets where any pair differs, including missing bytes."""
|
||||
images = tuple(images)
|
||||
if len(images) not in (1, 2, 3):
|
||||
raise ValueError('Выберите от 1 до 3 BIN-файлов.')
|
||||
if len(images) == 2:
|
||||
return compare(*images)
|
||||
pairs = tuple((a, b, compare(images[a], images[b]))
|
||||
for a in range(len(images)) for b in range(a + 1, len(images)))
|
||||
offsets = tuple(sorted({offset for _, _, result in pairs for offset in result.offsets}))
|
||||
return MultiComparison(images, offsets, pairs)
|
||||
107
python/set_devices/disassembly.py
Normal file
107
python/set_devices/disassembly.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""TI C28x tool adapter. Decode instructions using TI's own assembler tools."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def align_disassembly(*texts):
|
||||
"""Align TI listings by word offset even when instruction sizes differ."""
|
||||
listings = []
|
||||
for text in texts:
|
||||
if text.startswith('Ошибка:'):
|
||||
return texts
|
||||
listing = {}
|
||||
previous = None
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^([0-9A-Fa-f]{8})\s+', line)
|
||||
if match:
|
||||
previous = int(match.group(1), 16)
|
||||
listing[previous] = line
|
||||
elif previous is not None and line.strip():
|
||||
listing[previous] += ' ' + line.strip()
|
||||
if not listing:
|
||||
return texts
|
||||
listings.append(listing)
|
||||
addresses = sorted({address for listing in listings for address in listing})
|
||||
return tuple('\n'.join(listing.get(address, f'{address:08X} —') for address in addresses)
|
||||
for listing in listings)
|
||||
|
||||
|
||||
def find_ghidra(root):
|
||||
configured = os.environ.get('GHIDRA_INSTALL_DIR', '')
|
||||
if configured and (Path(configured) / 'support/analyzeHeadless.bat').is_file():
|
||||
return configured
|
||||
return next((str(path) for path in sorted(Path(root).glob('ghidra_*'), reverse=True)
|
||||
if (path / 'support/analyzeHeadless.bat').is_file()), '')
|
||||
|
||||
|
||||
def decompile_c28x(data: bytes, tools: str, base: int = 0) -> str:
|
||||
"""Run the configured Ghidra C28x processor and decompiler locally."""
|
||||
root = Path(tools)
|
||||
headless = root / 'support/analyzeHeadless.bat'
|
||||
if not tools or not headless.is_file():
|
||||
raise ValueError('Укажите папку Ghidra с установленным процессором TMS320C28x.')
|
||||
if not data or len(data) % 2 or len(data) > 262144:
|
||||
raise ValueError('Нужен непустой чётный диапазон до 256 КиБ.')
|
||||
if not 0 <= base <= 0x3fffff:
|
||||
raise ValueError('Адрес C28x должен быть в диапазоне 0…0x3FFFFF (в словах).')
|
||||
scripts = Path(__file__).parent / 'disassembly_scripts'
|
||||
env = dict(os.environ)
|
||||
jdks = sorted(path for path in root.parent.glob('jdk*')
|
||||
if path.is_dir() and (path / 'bin/java.exe').is_file())
|
||||
if jdks:
|
||||
env['JAVA_HOME'] = str(jdks[-1])
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-decompile-') as folder:
|
||||
work = Path(folder)
|
||||
binary, output = work / 'range.bin', work / 'result.c'
|
||||
binary.write_bytes(data)
|
||||
arguments = [str(headless), str(work), 'analysis', '-import', str(binary),
|
||||
'-processor', 'TMS320C28x:LE:32:default', '-loader', 'BinaryLoader',
|
||||
'-loader-baseAddr', f'{base:x}', '-noanalysis', '-scriptPath', str(scripts),
|
||||
'-postScript', 'SetguiExportC.java', str(output), '-deleteProject']
|
||||
result = subprocess.run(arguments, env=env, capture_output=True, text=True,
|
||||
errors='replace', timeout=180, stdin=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
if result.returncode or not output.is_file():
|
||||
raise RuntimeError((result.stderr + '\n' + result.stdout)[-6000:])
|
||||
text = output.read_text(encoding='utf-8')
|
||||
if not text.rstrip().endswith('/* SETGUI_DECOMPILE_SUCCESS */'):
|
||||
raise RuntimeError('Декомпиляция не завершена. ' + (result.stderr + '\n' + result.stdout)[-6000:])
|
||||
return text.removesuffix('/* SETGUI_DECOMPILE_SUCCESS */\n')
|
||||
|
||||
|
||||
def find_c28x_tools():
|
||||
candidates = sorted(Path('C:/ti').glob('ti-cgt-c2000_*/bin'), reverse=True)
|
||||
candidates += sorted(Path('C:/ti').glob('ccs*/ccs/tools/compiler/ti-cgt-c2000_*/bin'), reverse=True)
|
||||
return next((str(path) for path in candidates
|
||||
if (path / 'cl2000.exe').is_file() and (path / 'dis2000.exe').is_file()), '')
|
||||
|
||||
|
||||
def disassemble_c28x(data: bytes, tools: str) -> str:
|
||||
"""Wrap raw little-endian words in a temporary text section for dis2000."""
|
||||
if not data or len(data) % 2:
|
||||
raise ValueError('Для C28x нужен непустой диапазон с чётным числом байтов.')
|
||||
if len(data) > 262144:
|
||||
raise ValueError('Выберите диапазон не более 256 КиБ.')
|
||||
directory = Path(tools)
|
||||
for executable in ('cl2000.exe', 'dis2000.exe'):
|
||||
if not (directory / executable).is_file():
|
||||
raise ValueError(f'Не найден {executable} в {directory}')
|
||||
with tempfile.TemporaryDirectory(prefix='setgui-disasm-') as folder:
|
||||
root = Path(folder)
|
||||
source = root / 'dump.asm'
|
||||
source.write_text(' .text\n' + '\n'.join(
|
||||
f' .word 0x{int.from_bytes(data[i:i + 2], "little"):04X}'
|
||||
for i in range(0, len(data), 2)) + '\n', encoding='ascii')
|
||||
def run(arguments):
|
||||
result = subprocess.run(arguments, cwd=root, capture_output=True,
|
||||
text=True, errors='replace', timeout=30,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
if result.returncode:
|
||||
raise RuntimeError(result.stderr or result.stdout or 'Ошибка TI tools')
|
||||
return result.stdout
|
||||
run([str(directory / 'cl2000.exe'), '--silicon_version=28', '--compile_only',
|
||||
'--output_file=dump.obj', str(source)])
|
||||
return run([str(directory / 'dis2000.exe'), '--data_as_text', 'dump.obj'])
|
||||
38
python/set_devices/disassembly_scripts/SetguiExportC.java
Normal file
38
python/set_devices/disassembly_scripts/SetguiExportC.java
Normal file
@@ -0,0 +1,38 @@
|
||||
// Local C pseudocode export for a raw, user-selected C28x code range.
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.decompiler.DecompileResults;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.FunctionIterator;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SetguiExportC extends GhidraScript {
|
||||
public void run() throws Exception {
|
||||
Address entry = currentProgram.getMinAddress();
|
||||
disassemble(entry);
|
||||
createFunction(entry, null);
|
||||
analyzeAll(currentProgram);
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
try (PrintWriter out = new PrintWriter(getScriptArgs()[0], StandardCharsets.UTF_8.name())) {
|
||||
out.println("/* Ghidra C pseudocode. Recovered types/names may be incomplete. */");
|
||||
if (!decompiler.openProgram(currentProgram))
|
||||
throw new Exception(decompiler.getLastMessage());
|
||||
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
|
||||
int count = 0;
|
||||
while (functions.hasNext() && !monitor.isCancelled()) {
|
||||
Function function = functions.next();
|
||||
if (function.isExternal()) continue;
|
||||
DecompileResults result = decompiler.decompileFunction(function, 10, monitor);
|
||||
out.println("/* Address: " + function.getEntryPoint() + " */");
|
||||
if (result.decompileCompleted() && result.getDecompiledFunction() != null) {
|
||||
out.println(result.getDecompiledFunction().getC());
|
||||
count++;
|
||||
} else out.println("/* Decompilation failed: " + result.getErrorMessage() + " */");
|
||||
}
|
||||
if (count == 0) throw new Exception("No functions decompiled. Select a range beginning at a function entry.");
|
||||
out.println("/* SETGUI_DECOMPILE_SUCCESS */");
|
||||
} finally { decompiler.dispose(); }
|
||||
}
|
||||
}
|
||||
40
python/set_devices/firmware_check.py
Normal file
40
python/set_devices/firmware_check.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Portable, read-only TMS firmware verification against an immutable BIN."""
|
||||
from protocan.tms2812 import build_upload_request, decode_upload_reply
|
||||
from .bin_compare import compare_sha256
|
||||
|
||||
|
||||
class TmsFirmwareCheck:
|
||||
def __init__(self, controller, word_address, image, block_size=256):
|
||||
self.image = bytes(image)
|
||||
if not 1 <= controller <= 255:
|
||||
raise ValueError("Для проверки нужен адрес контроллера 1..255")
|
||||
if not self.image or len(self.image) > 4 * 1024 * 1024:
|
||||
raise ValueError("Размер BIN должен быть 1..4194304 байт")
|
||||
if not 0 <= word_address <= 0xFFFFFFFF or word_address + (len(self.image) + 1) // 2 > 0x100000000:
|
||||
raise ValueError("Диапазон памяти вне 32-битного адресного пространства")
|
||||
if not 2 <= block_size <= 65534 or block_size % 2:
|
||||
raise ValueError("Размер блока должен быть чётным: 2..65534 байт")
|
||||
self.controller, self.word_address = controller, word_address
|
||||
self.block_size = block_size
|
||||
self.readback = bytearray()
|
||||
|
||||
@property
|
||||
def done(self):
|
||||
return len(self.readback) == len(self.image)
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return min(self.block_size, len(self.image) - len(self.readback))
|
||||
|
||||
def request(self):
|
||||
if self.done:
|
||||
raise ValueError("Проверка уже завершена")
|
||||
return build_upload_request(self.controller, self.word_address + len(self.readback) // 2, self.count)
|
||||
|
||||
def accept(self, reply):
|
||||
if self.done:
|
||||
raise ValueError("Проверка уже завершена")
|
||||
self.readback.extend(decode_upload_reply(reply, self.controller, self.count))
|
||||
|
||||
def result(self):
|
||||
return compare_sha256(self.image, bytes(self.readback))
|
||||
110
python/set_devices/firmware_identity.py
Normal file
110
python/set_devices/firmware_identity.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Firmware-info v1 and the adjacent release passport (no device I/O)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from protocan.periph28335 import with_crc
|
||||
|
||||
MAX_IMAGE_SIZE = 199993
|
||||
|
||||
REGISTER_ADDRESS = 0x1200
|
||||
REGISTER_COUNT = 12
|
||||
|
||||
|
||||
def read_request(controller: int) -> bytes:
|
||||
if not 1 <= controller <= 255:
|
||||
raise ValueError("Для чтения версии нужен адрес контроллера 1..255")
|
||||
return with_crc(bytes((controller, 3)) + REGISTER_ADDRESS.to_bytes(2, 'big')
|
||||
+ REGISTER_COUNT.to_bytes(2, 'big'))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareIdentity:
|
||||
version: tuple[int, int, int]
|
||||
built_at: datetime
|
||||
build_id: str
|
||||
|
||||
def __post_init__(self):
|
||||
if (len(self.version) != 3 or any(type(v) is not int or not 0 <= v <= limit
|
||||
for v, limit in zip(self.version, (255, 255, 999)))
|
||||
or not 2000 <= self.built_at.year <= 2199
|
||||
or self.built_at.utcoffset() is None
|
||||
or self.built_at.microsecond
|
||||
or len(self.build_id) != 8
|
||||
or any(not 32 <= ord(c) <= 126 for c in self.build_id)):
|
||||
raise ValueError("Некорректные данные версии прошивки")
|
||||
|
||||
def describe(self):
|
||||
version = '.'.join(map(str, self.version))
|
||||
return f"{version} · {self.built_at:%Y-%m-%d %H:%M:%S} UTC · Build ID {self.build_id}"
|
||||
|
||||
|
||||
def decode_identity(words):
|
||||
if len(words) != REGISTER_COUNT or words[0] != 1:
|
||||
raise ValueError("Неизвестный формат версии (требуется firmware-info v1)")
|
||||
if any(type(w) is not int or not 0 <= w <= 65535 for w in words):
|
||||
raise ValueError("Некорректные регистры версии")
|
||||
try:
|
||||
stamp = datetime(words[4], words[5] >> 8, words[5] & 255,
|
||||
words[6] >> 8, words[6] & 255, words[7], tzinfo=timezone.utc)
|
||||
build_id = bytes(b for w in words[8:] for b in (w >> 8, w & 255)).decode('ascii')
|
||||
return FirmwareIdentity(tuple(words[1:4]), stamp, build_id)
|
||||
except (ValueError, UnicodeError) as error:
|
||||
raise ValueError("Некорректные данные версии: " + str(error)) from error
|
||||
|
||||
|
||||
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
|
||||
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
|
||||
max_image_size=MAX_IMAGE_SIZE):
|
||||
"""Return None for legacy images; reject a present but invalid passport.
|
||||
|
||||
The caller may supply the exact bytes it will upload, avoiding a second
|
||||
read between hash verification and transmission.
|
||||
"""
|
||||
if data is None:
|
||||
with path.open('rb') as stream:
|
||||
data = stream.read(max_image_size + 1)
|
||||
if not 0 < len(data) <= max_image_size:
|
||||
raise ValueError(f"Размер бинарного файла должен быть 1..{max_image_size} байт")
|
||||
passport = Path(str(path) + '.manifest.json')
|
||||
if not passport.exists():
|
||||
return None
|
||||
try:
|
||||
with passport.open('r', encoding='utf-8-sig') as stream:
|
||||
raw = stream.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError("Слишком большой паспорт прошивки")
|
||||
meta = json.loads(raw)
|
||||
if (meta['schema'] != 1 or meta['identity_contract'] != 1
|
||||
or meta['identity_register_count'] != REGISTER_COUNT):
|
||||
raise ValueError("Неизвестный формат паспорта")
|
||||
if meta['product'] != expected_product or meta['target'] != expected_target:
|
||||
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
|
||||
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
|
||||
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
|
||||
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
|
||||
if expected_eeprom is not None and meta.get('eeprom') is not None:
|
||||
if meta['eeprom'] != expected_eeprom:
|
||||
raise ValueError(f"BIN для {meta['eeprom']}, а выбрана EEPROM {expected_eeprom}")
|
||||
stamp = datetime.fromisoformat(meta['built_at_utc'].replace('Z', '+00:00'))
|
||||
if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0:
|
||||
raise ValueError("Дата паспорта должна быть в UTC")
|
||||
return FirmwareIdentity(tuple(meta['version']), stamp, meta['build_id'])
|
||||
except (KeyError, TypeError, ValueError, AttributeError) as error:
|
||||
raise ValueError("Ошибка паспорта: " + str(error)) from error
|
||||
|
||||
|
||||
def compare_identity(device, image):
|
||||
if device is None or image is None:
|
||||
return "Сравнение недоступно: нужны версия с платы и проверенный паспорт BIN."
|
||||
if device == image:
|
||||
return "Версия, дата сборки и Build ID совпадают. Содержимое памяти платы этим не проверяется."
|
||||
if image.version > device.version:
|
||||
return "Выбранная версия новее установленной."
|
||||
if image.version < device.version:
|
||||
return "Выбранная версия старее установленной (понижение версии)."
|
||||
return "Номер версии совпадает, но дата сборки или Build ID отличаются."
|
||||
@@ -8,6 +8,8 @@ See docs/tektronix-dpo4034.md for wiring, commands and scaling.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def resource_name(interface, address, board=0):
|
||||
@@ -52,18 +54,31 @@ class DPO4034:
|
||||
try:
|
||||
import pyvisa
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Установите PyVISA и NI-VISA или TekVISA") from exc
|
||||
if getattr(sys, "frozen", False):
|
||||
message = "В сборке SETGUI недоступна PyVISA. Используйте исправленную сборку EXE."
|
||||
else:
|
||||
message = "В Python-окружении недоступна PyVISA. Установите PyVISA (для Python 3.8: версия 1.14.1)."
|
||||
raise RuntimeError(message + " Причина: " + str(exc)) from exc
|
||||
factory = pyvisa.ResourceManager
|
||||
try:
|
||||
self.manager = factory()
|
||||
try:
|
||||
self.manager = factory()
|
||||
except (OSError, ValueError) as exc:
|
||||
if self._factory is not None:
|
||||
raise
|
||||
bits = struct.calcsize("P") * 8
|
||||
raise RuntimeError(
|
||||
f"Не удалось загрузить VISA-драйвер. Установите NI-VISA или TekVISA {bits}-bit "
|
||||
"с поддержкой вашей Windows (для Win7 нужна совместимая версия). Причина: " + str(exc)
|
||||
) from exc
|
||||
self.instrument = self.manager.open_resource(resource, open_timeout=timeout_ms)
|
||||
self.instrument.timeout = timeout_ms
|
||||
self.instrument.write_termination = "\n"
|
||||
self.instrument.read_termination = "\n"
|
||||
identity = self.instrument.query("*IDN?").strip()
|
||||
fields = identity.upper().split(",")
|
||||
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "MDO4034C"):
|
||||
raise ValueError(f"Ожидался Tektronix DPO4034 или MDO4034C, получено: {identity}")
|
||||
if len(fields) < 2 or "TEKTRONIX" not in fields[0] or fields[1].strip() not in ("DPO4034", "DPO4034B", "MDO4034C"):
|
||||
raise ValueError(f"Ожидался Tektronix DPO4034, DPO4034B или MDO4034C, получено: {identity}")
|
||||
self.instrument.write("HEADER OFF")
|
||||
return identity
|
||||
except Exception:
|
||||
|
||||
@@ -54,6 +54,8 @@ class ScopeSession:
|
||||
value = CaptureFrame(value, prepare_preview(value, args[3]))
|
||||
if automatic:
|
||||
state.consumed(status)
|
||||
elif action == "sync_settings":
|
||||
value = client.settings(*args)
|
||||
elif action == "export":
|
||||
filename, waves = args
|
||||
with open(filename, "w", newline="", encoding="utf-8-sig") as stream:
|
||||
|
||||
@@ -14,6 +14,7 @@ from dataclasses import dataclass
|
||||
from protocan import periph28335 as protocol
|
||||
|
||||
BASE = 0x1000
|
||||
PROTECTION = 0x1300 # Balsam 167: versioned voltage protection/test block.
|
||||
DATA = 0x1010
|
||||
LIVE = 0x1100
|
||||
EMU_CONTROL = 0x1200
|
||||
@@ -79,7 +80,7 @@ def request(controller: int, function: int, address: int, value: int, *, record_
|
||||
raise ValueError('Неверный размер записи логгера')
|
||||
capacity = {40: 800, 42: 760}[record_words]
|
||||
if function == 3:
|
||||
valid = ((address == BASE and value == 16) or
|
||||
valid = ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and 1 <= value <= 120 and (value % WORDS == 0 or value in (42, 84))) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
|
||||
or (address == FAST_CAPABILITY and value == 4)
|
||||
or (FAST_DATA <= address < FAST_DATA + capacity * record_words
|
||||
@@ -87,7 +88,9 @@ def request(controller: int, function: int, address: int, value: int, *, record_
|
||||
and 1 <= value <= 120 and value % record_words == 0
|
||||
and address + value <= FAST_DATA + capacity * record_words))
|
||||
else:
|
||||
valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
valid = function == 6 and ((address == PROTECTION + 2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION + 3 and 1 <= value <= 60000) or
|
||||
address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
(address == EMU_CONTROL + 2 and value in (0, 1)))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос логгера УМП')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
import struct
|
||||
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL
|
||||
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL, PROTECTION
|
||||
|
||||
MARKER = 0x1F00
|
||||
MAX_WORDS = 124
|
||||
@@ -26,10 +26,12 @@ def request_ids(mode):
|
||||
def build_request(mode, token, function, address, value):
|
||||
if not 1 <= token <= 255:
|
||||
raise ValueError('Неверный номер CAN-запроса')
|
||||
valid = ((function == 3 and ((address == BASE and value == 16) or
|
||||
valid = ((function == 3 and ((address == PROTECTION and value == 8) or (address == BASE and value == 16) or
|
||||
(address == DATA and value in (40, 80, 120, 42, 84)) or
|
||||
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
|
||||
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
|
||||
(address == PROTECTION+2 and value in (0, 1, 2, 3)) or
|
||||
(address == PROTECTION+3 and 1 <= value <= 60000) or
|
||||
(address == EMU_CONTROL+2 and value in (0, 1)))))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос CAN-логгера')
|
||||
|
||||
@@ -24,7 +24,8 @@ def sample(time_ms, period_ms=20, *, repeat=True):
|
||||
else:
|
||||
current = 4
|
||||
|
||||
words = [0] * codec.WORDS
|
||||
# Extended logger record includes both DC-link voltage measurements.
|
||||
words = [0] * 42
|
||||
words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535
|
||||
words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0)
|
||||
words[3] = (int(started) << 7) | (int(reset) << 6)
|
||||
@@ -42,6 +43,14 @@ def sample(time_ms, period_ms=20, *, repeat=True):
|
||||
words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0]
|
||||
words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500]
|
||||
words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)]
|
||||
if started:
|
||||
charge = min(1.0, (phase - 1000) / 1500)
|
||||
elif phase >= 5000:
|
||||
charge = max(0.0, 1 - (phase - 5000) / 800)
|
||||
else:
|
||||
charge = 0.0
|
||||
words[40] = round(charge * (600 + 3 * math.sin(phase / 80)))
|
||||
words[41] = round(charge * (590 + 4 * math.sin(phase / 95 + .7)))
|
||||
return codec.sample(words)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user