Добавить протокол Altera Logic и общие клиенты прошивки

This commit is contained in:
2026-09-19 07:12:09 +03:00
parent def3eb08f3
commit 80ba17d77d
38 changed files with 3271 additions and 10 deletions

View File

@@ -0,0 +1,57 @@
"""Golden RTL vectors through the Python FFI (no Python production codec)."""
import tempfile
import unittest
from pathlib import Path
from altera_logic import NativeAnalyzer
class AnalyzerTests(unittest.TestCase):
def test_rtl_info_and_first_configuration_vector(self):
core = NativeAnalyzer()
self.assertEqual(core.next_request(), bytes.fromhex("A5 01 00 00 00 A4"))
self.assertEqual(core.next_request(), b"")
for byte in bytes.fromhex("5A 81 00 10 00 10 32 01 E8"):
core.feed(bytes([byte]))
self.assertEqual(core.state, core.READY)
with self.assertRaises(ValueError):
core.start(49, 1, 0, 1, 1)
core.start(49, 0, 0, 1, 1)
self.assertEqual(core.next_request(), bytes.fromhex("A5 02 31 00 00 96"))
core.feed(bytes.fromhex("5A 82 00 D8"))
self.assertEqual(core.next_request(), bytes.fromhex("A5 03 00 00 00 A6"))
def test_error_response_is_short_even_for_info(self):
core = NativeAnalyzer()
core.next_request()
core.feed(bytes.fromhex("5A 81 01 DA"))
self.assertEqual(core.state, core.ERROR)
self.assertEqual(core.get(1), 3)
def test_partial_response_times_out_without_retry(self):
core = NativeAnalyzer()
core.next_request()
core.feed(bytes.fromhex("5A 81 00"))
core.tick(1000)
self.assertEqual(core.get(1), 4)
self.assertEqual(core.next_request(), b"")
def test_demo_export_uses_capture_rate_not_current_ui_settings(self):
core = NativeAnalyzer()
core.reset(demo=True)
core.start(49)
capture = core.capture()
self.assertEqual(len(capture.samples), 4096)
self.assertEqual(capture.sample_rate, 1000000)
self.assertEqual(capture.trigger_index, 2048)
self.assertTrue(capture.demo)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/"capture.csv"
capture.save_csv(path)
rows = path.read_text().splitlines()
self.assertEqual(len(rows), 4097)
self.assertIn("time_s", rows[0])
self.assertEqual(rows[2049].split(",")[1], "0.0")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,92 @@
import tempfile
import unittest
from pathlib import Path
from altera_logic.stream import NativeStream
class StreamTests(unittest.TestCase):
def test_can_uart_identical_and_duplicates(self):
source, can, uart = NativeStream(), NativeStream(), NativeStream()
self.assertEqual((source.device_type, source.device_id, source.device_name), (6, 14, "Altera Logic"))
ident, data = source.metadata_packet(7, 1000000)
self.assertEqual(ident, 0x1EE3FE00)
self.assertEqual(data, bytes.fromhex("01 4C 07 00 40 42 0F 00"))
can.feed_can(ident, data)
_, raw = source.metadata_packet(7, 1000000, uart=True)
for byte in raw:
uart.feed_uart(bytes([byte]))
for i in range(100):
ident, data = source.data_packet(7, i, i)
can.feed_can(ident, data)
_, raw = source.data_packet(7, i, i, uart=True)
uart.feed_uart(raw)
can.feed_uart(raw) # Duplicate publication through the second transport.
self.assertEqual(can.snapshot(), uart.snapshot())
self.assertEqual(can.stats["duplicates"], 100)
self.assertEqual(can.stats["received"], 100)
def test_gap_old_session_and_counter_wrap(self):
s = NativeStream()
s.feed_can(*s.metadata_packet(10, 1000000))
s.feed_can(*s.data_packet(10, 0xfffffffe, 1))
s.feed_can(*s.data_packet(10, 1, 2))
snap = s.snapshot()
self.assertEqual(snap.indices, (0xfffffffe, 0x100000001))
self.assertEqual(s.stats["missing"], 2)
self.assertEqual(snap.breaks, (1, 1))
s.feed_can(*s.metadata_packet(11, 2000000))
s.feed_can(*s.data_packet(10, 5, 3))
s.feed_can(*s.metadata_packet(10, 1000000))
self.assertEqual(s.stats["session"], 11)
self.assertEqual(s.snapshot().samples, ())
s.feed_can(*s.data_packet(11, 0, 0x8001))
self.assertEqual(s.snapshot().samples, (0x8001,))
def test_malformed_filter_crc_and_recovery(self):
s = NativeStream()
ident, meta = s.metadata_packet(1, 1000)
s.feed_can(ident, meta, extended=False)
s.feed_can(ident, meta, remote=True)
s.feed_can(ident ^ (1 << 20), meta)
self.assertFalse(s.stats["has_meta"])
raw = bytearray(s.metadata_packet(1, 1000, uart=True)[1])
raw[-1] ^= 1
s.feed_uart(bytes(raw))
self.assertEqual(s.stats["crc_errors"], 1)
s.feed_uart(s.metadata_packet(1, 1000, uart=True)[1])
self.assertTrue(s.stats["has_meta"])
ident, data = s.data_packet(1, 0, 5)
s.feed_can(ident, data[:-1])
self.assertEqual(s.stats["invalid"], 1)
self.assertEqual(s.stats["count"], 0)
def test_bounded_history_and_export(self):
s = NativeStream()
for _ in range(3):
s.demo_step(4096)
snap = s.snapshot(demo=True)
self.assertEqual(len(snap.samples), 8192)
self.assertEqual(snap.indices[0], 4096)
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/"stream.csv"
snap.save_csv(path)
rows = path.read_text().splitlines()
self.assertEqual(len(rows), 8193)
self.assertIn("gap_before", rows[0])
self.assertEqual(rows[1].split(",")[1], "4096")
def test_sparse_trace_does_not_connect_across_lost_samples(self):
s = NativeStream()
s.feed_can(*s.metadata_packet(1, 1000000))
for index, value in ((0, 0), (1, 1), (4, 0)):
s.feed_can(*s.data_packet(1, index, value))
trace = s.snapshot().traces[0]
self.assertEqual(trace[0], (0, 0, 1))
self.assertIn((1, 0, 0), trace)
self.assertIn((1, 1, 0), trace)
self.assertIn((4, 0, 1), trace)
self.assertNotIn((4, 1, 0), trace)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,135 @@
"""Android wire compatibility and complete host-side flashing transactions."""
from dataclasses import replace
from types import SimpleNamespace
import struct
import zlib
import pytest
from protocan import tms_firmware as tms
from protocan.can_boot import CanBootTarget, CanBootTransfer
from protocan.protocan import ProtoCanId
from protocan.transport import build_frame
def reply(command, payload=b"", tail=2):
return tms.packet(10, command, payload) + bytes(tail)
def test_android_packet_layout_and_crc():
assert tms.crc16_modbus(b"123456789") == 0x4B37
assert tms.init_load(10, 0xA0000, 0x1234)[:10] == bytes.fromhex("0a3a00000a0034120000")
assert tms.load_data(10, b"\1\2\3\4")[:6] == bytes.fromhex("0a3301020304")
assert tms.tflash(10, 0xA0000, 0x110000, 0x123456)[:14] == bytes.fromhex("0a3700000a000000110056341200")
assert tms.extend(10, 0xA0000, 0, 2, 10, 3)[:16] == bytes.fromhex("0a3c00000a0000000000020000000a03")
@pytest.mark.parametrize("command,payload,tail", [(58, b"", 2), (52, b"abcdef", 4)])
def test_only_reserved_tail_may_be_missing(command, payload, tail):
full = reply(command, payload, tail)
for n in range(tail + 1):
assert tms.normalize_reply(full[:len(full)-n], 10, command, len(full)) == full
assert tms.normalize_reply(full[:len(full)-tail-1], 10, command, len(full)) is None
corrupt = bytearray(full)
corrupt[2] ^= 1
assert tms.normalize_reply(bytes(corrupt), 10, command, len(full)) is None
assert tms.normalize_reply(full, 11, command, len(full)) is None
def run_tms(data, target, corrupt=False):
plan = tms.programming_steps(data, target)
response = None
requests = []
while True:
try:
request, size, timeout, title, percent = plan.send(response)
except StopIteration:
return requests
requests.append(request)
command = request[1]
if command == 52:
address, count = struct.unpack_from("<II", request, 2)
offset = (address-target.flash)*2
chunk = data[offset:offset+count].ljust(count, b"\xff")
if corrupt:
chunk = bytes([chunk[0] ^ 1]) + chunk[1:]
response = reply(command, chunk, 4)
assert len(response) == size
elif command == 60:
response = reply(command, struct.pack("<III", (len(data)+1)//2, 0, 0))
else:
response = reply(command)
def test_tms_word_offsets_and_odd_final_byte_readback():
requests = run_tms(bytes(range(256)) + b"x", tms.TmsTarget())
assert [p[1] for p in requests] == [58, 51, 58, 51, 55, 52, 52]
assert struct.unpack_from("<II", requests[2], 2) == (0xA0080, 1)
assert struct.unpack_from("<II", requests[-1], 2) == (0x110080, 2)
with pytest.raises(ValueError, match="не пройдена"):
run_tms(b"test", tms.TmsTarget(), corrupt=True)
@pytest.mark.parametrize("kind,codes", [("spartan2e", [6, 17]), ("spartan6", [10])])
def test_peripheral_flashing_and_verification(kind, codes):
packets = run_tms(b"test", tms.TmsTarget(kind=kind, flash=0, board=3))
assert [p[14] for p in packets if p[1] == 60] == codes
assert all(p[15] == 3 for p in packets if p[1] == 60)
def test_load_only_never_writes_flash():
assert [p[1] for p in run_tms(b"test", tms.TmsTarget(load_only=True))] == [58, 51]
@pytest.mark.parametrize("target,size", [(tms.TmsTarget(flash=0), 4), (tms.TmsTarget(), 0),
(tms.TmsTarget(block_size=257), 4), (tms.TmsTarget(flash=0x17FFFF), 3),
(tms.TmsTarget(ram=0xFFFFFFFF), 4)])
def test_invalid_targets_fail_before_transmission(target, size):
with pytest.raises(ValueError):
target.validate(size)
def image(data):
return SimpleNamespace(data=data, crc32=zlib.crc32(data), version=7)
def status(t, cmd, code=0, slot=1, expected=0):
target = t.target
can_id = ProtoCanId.build(1, 1, target.device_type, target.device, 12, target.session_id << 8 | cmd)
return build_frame(can_id, struct.pack("<BBHI", code, slot, expected, 0), to_can=False)
def enter_data(t):
assert ProtoCanId.parse(t.start()[0].can_id).msg_type == 9
for cmd in (2, 3, 4):
t.handle_status(status(t, cmd))
return t.handle_status(status(t, 5))[0]
def test_legacy_can_full_transaction_and_retransmission():
t = CanBootTransfer(image(bytes(range(130))), CanBootTarget())
frames = enter_data(t)
assert len(frames) == 16
assert all(ProtoCanId.parse(f.can_id).msg_type == 11 for f in frames)
frames, _ = t.handle_status(status(t, 0, code=8, expected=8))
assert ProtoCanId.parse(frames[0].can_id).body == 8
assert frames[-1].data == bytes([128,129]) + b"\xff" * 6
frames, _ = t.handle_status(status(t, 0, expected=17))
assert t.stage == "verify"
for cmd in (6, 7, 9):
t.handle_status(status(t, cmd))
assert t.finished and t.percent == 100
def test_can_rejects_unsent_blocks_wrong_slot_and_foreign_session():
t = CanBootTransfer(image(bytes(200)), CanBootTarget())
enter_data(t)
with pytest.raises(RuntimeError, match="непереданный"):
t.handle_status(status(t, 0, expected=20))
foreign = CanBootTransfer(t.image, replace(t.target, session_id=2))
assert not t.accepts(status(foreign, 0))
t = CanBootTransfer(image(bytes(8)), CanBootTarget())
t.handle_status(status(t, 2))
t.handle_status(status(t, 3))
with pytest.raises(RuntimeError, match="слот"):
t.handle_status(status(t, 4, slot=2))

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
import base64
import hashlib
import io
import json
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock
from urllib.error import HTTPError
from setprotocol.firmware_catalog import FirmwareRelease
from setprotocol.firmware_database import (
Credentials, FirmwareDatabase, GiteaFirmwarePublisher, GiteaRepository, HttpsClient,
)
from setprotocol.firmware_publish import FirmwarePublication
class Response(io.BytesIO):
def __init__(self, data, headers=None):
super().__init__(data)
self.headers = headers or {}
class Server:
"""In-memory HTTP boundary; no real credentials, network or repository writes."""
def __init__(self, *, bad_image=False, conflict=False, missing_release=False):
self.manifest = {"windows": {"versionCode": 42}, "firmware": {"releases": []}}
self.assets = {}
self.calls = []
self.bad_image = bad_image
self.conflict = conflict
self.missing_release = missing_release
def open(self, url, *, method="GET", data=None, **kwargs):
self.calls.append((method, url))
if self.missing_release and "/releases/tags/" in url:
raise HTTPError(url, 404, "Not found", {}, None)
if "/releases/download/" in url:
return Response(b"wrong" if self.bad_image else self.assets[url.rsplit("/", 1)[1]])
if "/raw/branch/" in url:
result = self.manifest
elif "/contents/" in url:
if method == "PUT":
if self.conflict:
raise HTTPError(url, 409, "Conflict", {}, None)
payload = json.loads(data)
assert payload["sha"] == "revision-1"
self.manifest = json.loads(base64.b64decode(payload["content"]))
result = {"sha": "revision-1", "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()}
elif "/assets" in url:
if method == "POST":
self.assets[url.split("?name=", 1)[1]] = data
result = {"id": 2}
else:
result = [{"name": name, "id": index} for index, name in enumerate(self.assets)]
else:
result = {"id": 1}
return Response(json.dumps(result).encode())
class FirmwareDatabaseTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.image = self.root / "image.bin"
self.image.write_bytes(b"test firmware")
self.repository = GiteaRepository("https://git.example", "team", "images")
self.publication = FirmwarePublication(self.image, "DEVICE", "1.2.3", 0x10203, "can")
def test_publish_read_and_download_round_trip_and_idempotency(self):
server = Server()
publisher = GiteaFirmwarePublisher(self.repository, client=server)
entry = publisher.publish(self.publication)
self.assertEqual(server.manifest["windows"], {"versionCode": 42})
self.assertEqual(server.manifest["firmware"]["catalogVersion"], 1)
first_commit = next(i for i, (method, _) in enumerate(server.calls) if method == "PUT")
first_verification = next(i for i, (_, url) in enumerate(server.calls) if "/releases/download/" in url)
self.assertLess(first_verification, first_commit)
publisher.publish(self.publication)
self.assertEqual(sum(method == "PUT" for method, _ in server.calls), 1)
self.assertEqual(len(server.assets), 1)
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=server)
rows = database.read_catalog(product="device", transport="can")
self.assertEqual(len(rows), 1)
self.assertEqual(database.read_catalog(transport="rs485"), [])
progress = []
downloaded = database.download(rows[0], progress.append)
self.assertEqual(downloaded.read_bytes(), self.image.read_bytes())
self.assertEqual(progress[-1], 100)
count = len(server.calls)
self.assertEqual(database.download(rows[0]), downloaded)
self.assertEqual(len(server.calls), count)
self.assertEqual(entry["fileName"], "image.bin")
def test_failed_image_verification_never_writes_catalog(self):
server = Server(bad_image=True)
with self.assertRaisesRegex(ValueError, "SHA-256"):
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
self.assertFalse(any(method == "PUT" for method, _ in server.calls))
def test_rebuilt_image_keeps_old_asset_and_manifest_on_conflict(self):
server = Server()
publisher = GiteaFirmwarePublisher(self.repository, client=server)
publisher.publish(self.publication)
before = json.dumps(server.manifest)
self.image.write_bytes(b"new firmware")
server.conflict = True
with self.assertRaises(HTTPError) as caught:
publisher.publish(self.publication)
caught.exception.close()
self.assertEqual(json.dumps(server.manifest), before)
self.assertEqual(len(server.assets), 2)
self.assertFalse(any(method == "DELETE" for method, _ in server.calls))
def test_preflight_is_offline(self):
server = Server()
GiteaFirmwarePublisher(self.repository, client=server).preflight(self.publication)
self.assertEqual(server.calls, [])
def test_missing_release_is_created(self):
server = Server(missing_release=True)
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
self.assertTrue(any(method == "POST" and url.endswith("/releases")
for method, url in server.calls))
def test_download_size_limit_and_catalog_readback_failure(self):
from setprotocol.firmware_publish import MAX_FIRMWARE_BYTES
client = Mock()
client.open.return_value = Response(b"", {"Content-Length": str(MAX_FIRMWARE_BYTES + 1)})
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin", "ab" * 32, "a.bin")
with self.assertRaisesRegex(ValueError, "maximum size"):
database.download(release)
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
server = Server()
original_open = server.open
def ignore_manifest_write(url, **kwargs):
if kwargs.get("method") == "PUT":
return Response(b"{}")
return original_open(url, **kwargs)
server.open = ignore_manifest_write
with self.assertRaisesRegex(RuntimeError, "readback"):
GiteaFirmwarePublisher(self.repository, client=server).publish(self.publication)
def test_bad_download_cleans_staging_and_rejects_unsafe_filename(self):
release = FirmwareRelease("D", "1", 1, "https://git.example/a.bin",
hashlib.sha256(b"good").hexdigest(), "a.bin")
client = Mock()
client.open.return_value = Response(b"bad")
database = FirmwareDatabase(self.repository.manifest_url, self.root / "cache", client=client)
with self.assertRaisesRegex(ValueError, "SHA-256"):
database.download(release)
self.assertEqual(list((self.root / "cache").rglob("*.part")), [])
with self.assertRaises(ValueError):
database.download(replace(release, file_name="../escaped.bin"))
self.assertEqual(client.open.call_count, 1)
def test_https_credentials_do_not_follow_cross_origin_redirect(self):
client = HttpsClient("https://git.example", Credentials("test", "secret"))
client.opener = Mock()
client.opener.open.side_effect = [
HTTPError("https://git.example/a", 302, "Redirect", {"Location": "https://cdn.example/a"}, None),
Response(b"image"),
]
client.open("https://git.example/a").close()
first, second = [call.args[0] for call in client.opener.open.call_args_list]
self.assertIn("Authorization", first.headers)
self.assertNotIn("Authorization", second.headers)
def test_http_redirect_and_write_redirect_are_rejected(self):
for method, destination in (("GET", "http://git.example/a"), ("POST", "https://cdn.example/a")):
with self.subTest(method=method):
client = HttpsClient("https://git.example")
client.opener = Mock()
client.opener.open.side_effect = HTTPError(
"https://git.example/a", 302, "Redirect", {"Location": destination}, None)
with self.assertRaises((ValueError, HTTPError)) as caught:
client.open("https://git.example/a", method=method)
if isinstance(caught.exception, HTTPError):
caught.exception.close()
self.assertEqual(client.opener.open.call_count, 1)
if __name__ == "__main__":
unittest.main()