Merge remote-tracking branch 'origin/codex/trend-display-scale' into HEAD

This commit is contained in:
2026-09-05 11:43:08 +03:00
31 changed files with 972 additions and 38 deletions

View File

@@ -67,6 +67,10 @@ class PlotMath:
except ValueError:
return None
def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds":
"""Validate absolute axis limits in the shared core."""
return Bounds(*self.call(8, left, right, bottom, top))
@dataclass(frozen=True)
class Viewport:
@@ -92,6 +96,9 @@ class Bounds:
bottom: float
top: float
def validated(self, core: PlotMath) -> "Bounds":
return core.limits(self.left, self.right, self.bottom, self.top)
def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float:
return core.call(2, value, self.bottom if horizontal else self.left,
self.top if horizontal else self.right, int(horizontal))[0]

View File

@@ -37,6 +37,12 @@ class Spectrum:
return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes)))
@dataclass(frozen=True)
class SpectrumPeak:
frequency_hz: float
amplitude: float
class NativeSpectrum:
def __init__(self, library: ctypes.CDLL):
self.lib = library
@@ -46,6 +52,11 @@ class NativeSpectrum:
ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int,
pointer, ctypes.c_size_t, pointer]
self._analyze.restype = ctypes.c_int
self._peak = library.set_spectrum_dominant_peak
self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double,
ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t,
pointer, ctypes.c_size_t]
self._peak.restype = ctypes.c_int
def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE,
low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum:
@@ -69,3 +80,15 @@ class NativeSpectrum:
raise ValueError(message.get(status, "FFT failed"))
n = int(meta[0])
return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1]))
def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0,
absolute_floor: float = 1e-6) -> SpectrumPeak | None:
amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes)
scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))()
output = (ctypes.c_double * 2)()
status = self._peak(amplitudes, len(spectrum.amplitudes),
spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor,
scratch, len(scratch), output, 2)
if status < 0:
raise ValueError("Invalid spectrum peak input")
return SpectrumPeak(output[0], output[1]) if status else None

View File

@@ -54,6 +54,8 @@ class TrendSignal:
deviceType: int = 7
device: int = 13
byteOffset: int = 0
multiplier: float = 1.0
iq: int = 0
extended: bool = True
@classmethod
@@ -72,9 +74,11 @@ class TrendSignal:
for field in ("id", "name", "source", "address", "color", "valueType"):
if type(getattr(self, field)) is not str:
raise ValueError(f"{field} must be a string")
for field in ("order", "deviceType", "device", "byteOffset"):
for field in ("order", "deviceType", "device", "byteOffset", "iq"):
if type(getattr(self, field)) is not int:
raise ValueError(f"{field} must be an integer")
if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier):
raise ValueError("multiplier must be a finite number")
if type(self.visible) is not bool or type(self.extended) is not bool:
raise ValueError("Visibility and CAN format must be boolean")
if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999:
@@ -98,12 +102,11 @@ class TrendSignal:
raise ValueError("Invalid ProtoCAN device")
if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6:
raise ValueError("Word offset must be 0..6")
if not 0 <= self.iq <= 30:
raise ValueError("IQ must be 0..30")
def word_value(self, word: int) -> float:
if not 0 <= word <= 65535:
raise ValueError("Not a 16-bit word")
return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word)
def display_value(self, raw_value: float) -> float:
return raw_value * self.multiplier / (1 << self.iq)
def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None:
ids = set()
@@ -138,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]:
raise ValueError("profiles must be an object")
result = {}
fields = set(TrendSignal.__dataclass_fields__)
required_fields = fields - {"multiplier", "iq"}
for profile, items in profiles.items():
if not isinstance(items, list) or len(items) > MAX_SIGNALS:
raise ValueError("Expected up to 64 signals")
signals = []
for item in items:
if not isinstance(item, dict) or not fields <= item.keys():
if not isinstance(item, dict) or not required_fields <= item.keys():
raise ValueError("Missing signal fields")
signals.append(TrendSignal(**{key: item[key] for key in fields}))
signals.append(TrendSignal(**{key: item[key] for key in fields if key in item}))
result[profile] = signals
validate_settings(result)
return result
@@ -178,6 +182,20 @@ class NativeTrends:
ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8,
ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t]
self.decode.restype = ctypes.c_int32
self._word = library.set_trend_word_value
self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8]
self._word.restype = ctypes.c_int32
self._watch_request = library.set_trend_watch_request
self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16),
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t]
self._watch_request.restype = ctypes.c_size_t
self._watch_ack = library.set_trend_watch_ack
self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t]
self._watch_ack.restype = ctypes.c_int
self._watch_decode = library.set_trend_watch_decode
self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t]
self._watch_decode.restype = ctypes.c_int
def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None:
if signal.source not in ("CAN_GAS", "CAN_RAW"):
@@ -191,3 +209,33 @@ class NativeTrends:
signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags,
payload, len(data))
return None if value == -2147483648 else float(value)
def word_value(self, signal: TrendSignal, word: int) -> float:
if not 0 <= word <= 65535:
raise ValueError("Not a 16-bit word")
return float(self._word(word, signal.valueType == "INT16"))
def watch_request(self, period_ms: int, addresses: list[int]) -> bytes:
if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any(
type(address) is not int or not 0 <= address <= 65535 for address in addresses):
raise ValueError("Invalid GAS watch request")
source = (ctypes.c_uint16 * len(addresses))(*addresses)
output = (ctypes.c_uint8 * (4 + len(addresses) * 2))()
size = self._watch_request(period_ms, source, len(addresses), output, len(output))
if not size:
raise ValueError("Invalid GAS watch request")
return bytes(output[:size])
def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
if not self._watch_ack(data, len(payload), period_ms, count):
raise ValueError("Device did not accept the complete GAS subscription")
def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]:
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload)
timestamp = ctypes.c_uint32()
words = (ctypes.c_uint16 * MAX_SIGNALS)()
count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS)
if count < 0 or expected_count is not None and count != expected_count:
raise ValueError("Invalid GAS watch data")
return timestamp.value, list(words[:count])

View File

@@ -0,0 +1,145 @@
"""Reusable helpers for publishing the shared firmware release catalog."""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
from .firmware_catalog import (
MAX_MANIFEST_BYTES,
SUPPORTED_TRANSPORTS,
parse_firmware_catalog,
)
MAX_FIRMWARE_BYTES = 128 * 1024 * 1024
@dataclass(frozen=True)
class FirmwarePublication:
"""Metadata required to publish one firmware image."""
path: Path
product: str
version_name: str
version_code: int
transport: str
base_address: int | None = None
notes: str = ""
def validate(self) -> None:
if not self.path.is_file():
raise ValueError(f"Firmware file is missing: {self.path}")
if self.path.suffix.lower() not in {".bin", ".hex"}:
raise ValueError("Firmware file must have a .bin or .hex extension")
size = self.path.stat().st_size
if size <= 0:
raise ValueError("Firmware file is empty")
if size > MAX_FIRMWARE_BYTES:
raise ValueError("Firmware file exceeds the maximum size")
if not self.product.strip():
raise ValueError("Firmware product is empty")
if not self.version_name.strip():
raise ValueError("Firmware version name is empty")
if not 0 <= self.version_code <= 0x7FFFFFFF:
raise ValueError(
"Firmware version code must be between 0 and 2147483647"
)
if self.transport not in SUPPORTED_TRANSPORTS:
raise ValueError("Unsupported firmware transport")
if (
self.base_address is not None
and not 0 <= self.base_address <= 0xFFFFFFFF
):
raise ValueError("Firmware base address is outside the uint32 range")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_release_tag_part(value: str) -> str:
source = value.strip()
part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.")
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
if not part:
return digest
return part if part == source else f"{part}-{digest}"
def firmware_release_tag(publication: FirmwarePublication) -> str:
return "firmware-%s-v%s" % (
safe_release_tag_part(publication.product),
safe_release_tag_part(publication.version_name),
)
def firmware_release_entry(
publication: FirmwarePublication, image_url: str, sha256: str
) -> dict:
result = {
"product": publication.product.strip(),
"versionCode": publication.version_code,
"versionName": publication.version_name.strip(),
"imageUrl": image_url,
"fileName": publication.path.name,
"sha256": sha256,
"transport": publication.transport,
"notes": publication.notes.strip(),
}
if publication.base_address is not None:
result["baseAddress"] = f"0x{publication.base_address:08X}"
return result
def firmware_entry_identity(entry: dict) -> tuple[str, int, str]:
return (
str(entry.get("product", entry.get("device", ""))).strip().casefold(),
int(entry.get("versionCode", 0)),
str(entry.get("transport", "rs485")).strip().lower(),
)
def update_firmware_manifest(manifest: dict, entry: dict) -> dict:
"""Insert or replace one release without disturbing other manifest data."""
result = dict(manifest)
existing = manifest.get("firmware")
firmware = dict(existing) if isinstance(existing, dict) else {}
rows = firmware.get("releases") if isinstance(existing, dict) else existing
releases = (
[dict(row) for row in rows if isinstance(row, dict)]
if isinstance(rows, list)
else []
)
identity = firmware_entry_identity(entry)
releases = [
row for row in releases if firmware_entry_identity(row) != identity
]
releases.append(dict(entry))
releases.sort(
key=lambda row: (
str(row.get("product", row.get("device", ""))).casefold(),
-int(row.get("versionCode", 0)),
str(row.get("transport", "rs485")),
)
)
previous_rows = firmware.get("releases")
changed = releases != previous_rows
firmware["catalogVersion"] = (
int(firmware.get("catalogVersion", 0)) + int(changed)
)
firmware["releases"] = releases
result["firmware"] = firmware
encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8")
if len(encoded) > MAX_MANIFEST_BYTES:
raise ValueError("Updated update.json exceeds the maximum size")
parse_firmware_catalog(encoded, "https://catalog.invalid/update.json")
return result

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from setprotocol.firmware_publish import (
FirmwarePublication,
firmware_release_entry,
firmware_release_tag,
update_firmware_manifest,
)
class FirmwarePublishTests(unittest.TestCase):
def publication(self, path: Path, **overrides) -> FirmwarePublication:
fields = {
"path": path,
"product": "F103DS18",
"version_name": "1.1.0",
"version_code": 0x00010100,
"transport": "can",
"base_address": 0x08003000,
"notes": "Verified release",
}
fields.update(overrides)
return FirmwarePublication(**fields)
def test_publication_validates_file_and_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
image = Path(temporary) / "image.hex"
image.write_text(":00000001FF\n", encoding="ascii")
self.publication(image).validate()
with self.assertRaisesRegex(ValueError, "Unsupported"):
self.publication(image, transport="unknown").validate()
def test_entry_and_tag_are_deterministic(self) -> None:
publication = self.publication(Path("image.hex"))
entry = firmware_release_entry(
publication, "https://example.test/image.hex", "ab" * 32
)
self.assertEqual(
firmware_release_tag(publication), "firmware-F103DS18-v1.1.0"
)
self.assertEqual(entry["baseAddress"], "0x08003000")
def test_update_preserves_sections_and_replaces_same_release(self) -> None:
first = {
"product": "Device",
"versionCode": 7,
"versionName": "1.2.3",
"imageUrl": "https://example.test/old.bin",
"fileName": "old.bin",
"sha256": "11" * 32,
"transport": "rs485",
}
manifest = update_firmware_manifest(
{"windows": {"versionCode": 8}}, first
)
replacement = {
**first,
"imageUrl": "https://example.test/new.bin",
"fileName": "new.bin",
"sha256": "22" * 32,
}
updated = update_firmware_manifest(manifest, replacement)
self.assertEqual(updated["windows"], {"versionCode": 8})
self.assertEqual(len(updated["firmware"]["releases"]), 1)
self.assertEqual(
updated["firmware"]["releases"][0]["sha256"], "22" * 32
)
self.assertEqual(updated["firmware"]["catalogVersion"], 2)
def test_legacy_array_is_migrated_without_data_loss(self) -> None:
legacy = {
"product": "Legacy",
"versionCode": 1,
"versionName": "1.0",
"imageUrl": "https://example.test/legacy.bin",
"fileName": "legacy.bin",
"sha256": "33" * 32,
"transport": "rs485",
}
current = {**legacy, "product": "Current", "versionCode": 2}
updated = update_firmware_manifest({"firmware": [legacy]}, current)
self.assertCountEqual(
[row["product"] for row in updated["firmware"]["releases"]],
["Legacy", "Current"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -56,6 +56,9 @@ class PlotTests(unittest.TestCase):
self.assertAlmostEqual(20, self.core.db_delta(1, 10))
self.assertAlmostEqual(-20, self.core.db_delta(10, 1))
self.assertIsNone(self.core.db_delta(0, 1))
self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2))
with self.assertRaises(ValueError):
self.core.limits(1, 1, -2, 2)
if __name__ == "__main__":

View File

@@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase):
peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__)
self.assertEqual(64, result.frequencies[peak])
self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9)
detected = self.core.dominant_peak(result)
self.assertAlmostEqual(64, detected.frequency_hz, places=9)
self.assertAlmostEqual(3.25, detected.amplitude, places=9)
def test_dc_and_nyquist_are_not_doubled(self):
times, _ = self.sample()

View File

@@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t
class TrendTests(unittest.TestCase):
def test_multiplier_and_iq_scale_display_and_old_json_defaults(self):
signal = TrendSignal("scaled", multiplier=2.5, iq=3)
self.assertEqual(10.0, signal.display_value(32.0))
signal.validate("TMS2812")
payload = json.loads(encode_settings({"TMS2812": [signal]}))
payload["profiles"]["TMS2812"][0].pop("multiplier")
payload["profiles"]["TMS2812"][0].pop("iq")
restored = decode_settings(json.dumps(payload))["TMS2812"][0]
self.assertEqual((1.0, 0), (restored.multiplier, restored.iq))
def test_shared_kotlin_fixture_and_round_trip(self):
settings = decode_settings(FIXTURE.read_text(encoding="utf-8"))
self.assertEqual(5, sum(map(len, settings.values())))
@@ -57,7 +67,6 @@ class TrendTests(unittest.TestCase):
for value in ("-1", "+1", "FF", "0x", "1.0", "256"):
with self.assertRaises(ValueError):
parse_address(value, 255)
self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534))
def test_bounded_history(self):
history = TrendHistory()
@@ -81,6 +90,13 @@ class TrendTests(unittest.TestCase):
self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data))
raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2)
self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data))
self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534))
self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]),
core.watch_request(1000, [0x1234, 0xFFFF]))
core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2)
timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2)
self.assertEqual(0x04030201, timestamp)
self.assertEqual([0x1234, 0xFFFF], words)
if __name__ == "__main__":