From f6787163dcd89a239392896b57b6db4d89cb22b9 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 12:56:16 +0300 Subject: [PATCH 1/7] feat(plot): configure one or two marker pairs per axis --- .../ru/setcorp/setprotocol/trends/TrendMarkers.kt | 12 +++++++++++- .../setcorp/setprotocol/trends/PlotContractTest.kt | 3 +++ python/protocan/plot.py | 14 +++++++++++++- python/tests/test_plot.py | 4 ++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt index a319035..57dabf6 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendMarkers.kt @@ -10,6 +10,8 @@ enum class TrendMarker(val title: String, val horizontal: Boolean) { data class TrendMarkers( val xEnabled: Boolean = true, val yEnabled: Boolean = false, + val xPairs: Int = 1, + val yPairs: Int = 1, val selected: TrendMarker = TrendMarker.A, val a: Double? = null, val b: Double? = null, @@ -20,6 +22,9 @@ data class TrendMarkers( val g: Double? = null, val h: Double? = null, ) { + init { + require(xPairs in 1..2 && yPairs in 1..2) { "Количество пар маркеров должно быть от 1 до 2" } + } fun value(marker: TrendMarker): Double? = when (marker) { TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d TrendMarker.E -> e; TrendMarker.F -> f; TrendMarker.G -> g; TrendMarker.H -> h @@ -49,5 +54,10 @@ data class TrendMarkers( }.sortedWith(compareBy> { it.second } .thenBy { if (it.first == selected) 0 else 1 }).firstOrNull()?.first } - fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled + fun enabled(marker: TrendMarker): Boolean = when (marker) { + TrendMarker.A, TrendMarker.B -> xEnabled + TrendMarker.C, TrendMarker.D -> xEnabled && xPairs >= 2 + TrendMarker.E, TrendMarker.F -> yEnabled + TrendMarker.G, TrendMarker.H -> yEnabled && yPairs >= 2 + } } diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt index af427fa..803820a 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -25,6 +25,9 @@ class PlotContractTest { @Test fun markerCoordinatesSurviveZoomAndDragUsesGestureStart() { val full = PlotBounds(1000.0, 2000.0, -10.0, 10.0) val markers = TrendMarkers().positioned(full) + assertTrue(markers.enabled(TrendMarker.A)) + assertFalse(markers.enabled(TrendMarker.C)) + assertTrue(markers.copy(xPairs = 2).enabled(TrendMarker.C)) val zoomed = PlotBounds(1250.0, 1750.0, -5.0, 5.0) assertEquals(markers, markers.positioned(zoomed)) val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed) diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 127242b..9661827 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -102,6 +102,8 @@ class Bounds: class Markers: x_enabled: bool = True y_enabled: bool = False + x_pairs: int = 1 + y_pairs: int = 1 selected: Marker = Marker.A a: Optional[float] = None b: Optional[float] = None @@ -112,11 +114,21 @@ class Markers: g: Optional[float] = None h: Optional[float] = None + def __post_init__(self) -> None: + if self.x_pairs not in (1, 2) or self.y_pairs not in (1, 2): + raise ValueError("Marker pair count must be 1 or 2") + def value(self, marker: Marker) -> Optional[float]: return getattr(self, marker.name.lower()) def enabled(self, marker: Marker) -> bool: - return self.y_enabled if marker.horizontal else self.x_enabled + if marker in (Marker.A, Marker.B): + return self.x_enabled + if marker in (Marker.C, Marker.D): + return self.x_enabled and self.x_pairs >= 2 + if marker in (Marker.E, Marker.F): + return self.y_enabled + return self.y_enabled and self.y_pairs >= 2 def move(self, marker: Marker, value: float) -> "Markers": return replace(self, **{marker.name.lower(): value}) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 4470200..9fe8640 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -5,6 +5,7 @@ import math import os from pathlib import Path import unittest +from dataclasses import replace from protocan.plot import Bounds, Marker, Markers, PlotMath, Viewport @@ -31,6 +32,9 @@ class PlotTests(unittest.TestCase): def test_markers_stay_in_data_coordinates_and_cross(self): bounds = Bounds(1000, 2000, -10, 10) markers = Markers().positioned(self.core, bounds) + self.assertTrue(markers.enabled(Marker.A)) + self.assertFalse(markers.enabled(Marker.C)) + self.assertTrue(replace(markers, x_pairs=2).enabled(Marker.C)) zoomed = bounds.visible(self.core, Viewport(.25, .25, .5, .5)) self.assertEqual(markers, markers.positioned(self.core, zoomed)) moved = markers.drag(self.core, Marker.A, 50, 500, zoomed) -- 2.25.1 From d9eb7dd9ad221071f4b623fd3358485aab77ecf6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 13:50:20 +0300 Subject: [PATCH 2/7] feat(plot): lock axes and calculate marker levels in dB --- c/set-protocol/include/set_plot.h | 3 ++- .../ru/setcorp/setprotocol/trends/NativePlot.kt | 2 ++ .../ru/setcorp/setprotocol/trends/PlotViewport.kt | 6 ++++-- .../ru/setcorp/setprotocol/trends/PlotContractTest.kt | 8 ++++++++ c/set-protocol/src/set_plot.c | 8 ++++++-- c/set-protocol/tests/fixtures/plot-v1.json | 3 +++ python/protocan/plot.py | 11 ++++++++++- python/tests/test_plot.py | 7 +++++++ 8 files changed, 42 insertions(+), 6 deletions(-) diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index c249bb3..2ae6847 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -16,7 +16,8 @@ enum set_plot_operation { SET_PLOT_VALUE = 3, /* fraction,low,high,inverted -> value (not clamped) */ SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ - SET_PLOT_DELTA = 6 /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ }; /** Version of this plot ABI, independently of the transport ABI. */ diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt index 984a732..92f264b 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt @@ -30,3 +30,5 @@ data class PlotBounds(val left: Double, val right: Double, val bottom: Double, v fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, range, pixels)[0] fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] +fun plotDbDelta(a: Double, b: Double): Double? = + runCatching { NativePlot.call(7, a, b)[0] }.getOrNull() diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt index 3f53a25..7ec4442 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt @@ -1,10 +1,12 @@ package ru.setcorp.setprotocol.trends /** Normalized top-left viewport; independent of pixels, units, toolkit and samples. */ -data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0) { +data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0, + val locked: Boolean = false) { fun transform(zoomX: Double = 1.0, zoomY: Double = 1.0, panX: Double = 0.0, panY: Double = 0.0, focusX: Double = 0.5, focusY: Double = 0.5): PlotViewport { + if (locked) return this val result = NativePlot.call(0, x, y, width, height, zoomX, zoomY, panX, panY, focusX, focusY) - return PlotViewport(result[0], result[1], result[2], result[3]) + return PlotViewport(result[0], result[1], result[2], result[3], locked) } } diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt index 803820a..024b85b 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -37,4 +37,12 @@ class PlotContractTest { val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0) assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0) } + + @Test fun lockedViewportIgnoresZoomAndPanAndDbUsesAmplitudeRatio() { + val locked = PlotViewport(locked = true) + assertEquals(locked, locked.transform(zoomX = 2.0, panY = 0.2)) + assertEquals(20.0, plotDbDelta(1.0, 10.0)!!, 1e-10) + assertEquals(-20.0, plotDbDelta(10.0, 1.0)!!, 1e-10) + assertNull(plotDbDelta(0.0, 1.0)) + } } diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 3f000e3..66ed13c 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -11,9 +11,9 @@ static int finite_values(const double *v, size_t n) { uint32_t set_plot_abi_version(void) { return 1U; } size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap) { - static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3}; + static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2}; double span, fraction; - if (op > SET_PLOT_DELTA || !v || !out || n != sizes[op] || + if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] || cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; @@ -65,6 +65,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t break; } case SET_PLOT_DELTA: out[0] = (v[1] - v[0]) * v[2]; break; + case SET_PLOT_DB_DELTA: + if (v[0] == 0 || v[1] == 0) return 0; + out[0] = 20 * log10(fabs(v[1] / v[0])); + break; default: return 0; } return isfinite(out[0]) ? 1 : 0; diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index 3428ba4..1e8715d 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -20,6 +20,9 @@ {"name":"ticks","op":5,"input":[100,800],"output":[20]}, {"name":"negative_delta","op":6,"input":[10,0,1],"output":[-10]}, {"name":"milliseconds","op":6,"input":[0,0.01,1000],"output":[10]}, + {"name":"db_gain","op":7,"input":[1,10],"output":[20]}, + {"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]}, + {"name":"db_zero_reference","op":7,"input":[0,1],"output":null}, {"name":"zero_range","op":2,"input":[1,1,1,0],"output":null}, {"name":"zero_pixels","op":4,"input":[0,1,0,0,1,0],"output":null}, {"name":"bad_viewport","op":0,"input":[0,0,0,1,2,1,0,0,0.5,0.5],"output":null} diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 9661827..67248f5 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -61,6 +61,12 @@ class PlotMath: def delta(self, a: float, b: float, multiplier: float = 1) -> float: return self.call(6, a, b, multiplier)[0] + def db_delta(self, a: float, b: float) -> Optional[float]: + try: + return self.call(7, a, b)[0] + except ValueError: + return None + @dataclass(frozen=True) class Viewport: @@ -68,12 +74,15 @@ class Viewport: y: float = 0.0 width: float = 1.0 height: float = 1.0 + locked: bool = False def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1, pan_x: float = 0, pan_y: float = 0, focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport": + if self.locked: + return self return Viewport(*core.call(0, self.x, self.y, self.width, self.height, - zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y)) + zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked) @dataclass(frozen=True) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 9fe8640..a2ba188 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -50,6 +50,13 @@ class PlotTests(unittest.TestCase): with self.assertRaises(ValueError): self.core.pinch_axis(value, 10, 8) + def test_locked_viewport_and_decibel_delta(self): + locked = Viewport(locked=True) + self.assertEqual(locked, locked.transform(self.core, zoom_x=2, pan_y=.2)) + 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)) + if __name__ == "__main__": unittest.main() -- 2.25.1 From 3c4ac9963dc8504566f35abb9591017deabfb336 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:19:07 +0300 Subject: [PATCH 3/7] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20API=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20CAN=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c/set-protocol/CMakeLists.txt | 4 + c/set-protocol/include/balsam_can.h | 61 +++++ c/set-protocol/include/pcan_abi.h | 15 ++ c/set-protocol/include/setprotocol.h | 2 + c/set-protocol/ports/android/Android.mk | 1 + c/set-protocol/ports/android/README.md | 5 + .../setcorp/setprotocol/NativeSetProtocol.kt | 3 + .../setprotocol/balsam/BalsamCanProtocol.kt | 116 +++++++++ .../legacycan/LegacyCanTerminal.kt | 230 ++++++++++++++++++ .../ports/android/setprotocol_jni.c | 47 ++++ .../balsam/BalsamCanProtocolTest.kt | 20 ++ .../legacycan/LegacyCanTerminalTest.kt | 49 ++++ c/set-protocol/src/balsam_can.c | 182 ++++++++++++++ c/set-protocol/src/pcan_abi.c | 28 +++ c/set-protocol/tests/test_balsam_can.c | 30 +++ c/set-protocol/tools/build_host.py | 2 +- python/protocan/__init__.py | 2 + python/protocan/balsam.py | 98 ++++++++ python/protocan/native.py | 42 ++++ python/protocan/protocan.py | 5 + 20 files changed, 941 insertions(+), 1 deletion(-) create mode 100644 c/set-protocol/include/balsam_can.h create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt create mode 100644 c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt create mode 100644 c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt create mode 100644 c/set-protocol/src/balsam_can.c create mode 100644 c/set-protocol/tests/test_balsam_can.c create mode 100644 python/protocan/balsam.py diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt index 27f19c6..30dc23a 100644 --- a/c/set-protocol/CMakeLists.txt +++ b/c/set-protocol/CMakeLists.txt @@ -17,6 +17,7 @@ set(SETPROTOCOL_V2_SOURCES # Совместимые ProtoCAN/SETGUI v1 форматы переходного периода. set(SETPROTOCOL_LEGACY_SOURCES + src/balsam_can.c src/gui_catalog.c src/gui_frame.c src/pcan_abi.c @@ -94,6 +95,9 @@ if(SETP_BUILD_TESTS) add_executable(test_abi tests/test_abi.c) target_link_libraries(test_abi PRIVATE setprotocol_static) add_test(NAME stable_abi COMMAND test_abi) + add_executable(test_balsam_can tests/test_balsam_can.c) + target_link_libraries(test_balsam_can PRIVATE setprotocol_static) + add_test(NAME legacy_balsam_can COMMAND test_balsam_can) add_executable(test_trends tests/test_trends.c) target_link_libraries(test_trends PRIVATE setprotocol_static) add_test(NAME shared_trends COMMAND test_trends) diff --git a/c/set-protocol/include/balsam_can.h b/c/set-protocol/include/balsam_can.h new file mode 100644 index 0000000..95feacb --- /dev/null +++ b/c/set-protocol/include/balsam_can.h @@ -0,0 +1,61 @@ +/** + * @file balsam_can.h + * @brief Legacy Balsam 167 extended-CAN register frames. + * + * The wire layout is taken from Balsam_167_periph/Source/Internal/ecan.c: + * one big-endian address/mask word followed by three big-endian register words. + */ +#ifndef BALSAM_CAN_H +#define BALSAM_CAN_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define BALSAM_CAN_BASE_ID 0x00BA0000UL +#define BALSAM_CAN_NODE_COUNT 13U +#define BALSAM_CAN_DATA_OFFSET 0x10U +#define BALSAM_CAN_DLC 8U +#define BALSAM_CAN_REGISTER_COUNT 3U + +typedef enum { + BALSAM_CAN_TO_NODE = 0, + BALSAM_CAN_FROM_NODE = 1 +} balsam_can_direction_t; + +typedef struct { + uint8_t device; + uint8_t direction; + uint8_t present_mask; + uint16_t start_address; + uint16_t values[BALSAM_CAN_REGISTER_COUNT]; +} balsam_can_frame_t; + +/** Return non-zero for the command/data/terminal IDs used by Balsam 167. */ +int balsam_can_is_id(uint32_t can_id); + +/** + * Decode an 8-byte Balsam frame. Returns 1 on success, 0 for another CAN ID, + * -1 for invalid arguments and -2 for a Balsam ID with a non-8-byte payload. + */ +int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, + balsam_can_frame_t *output); + +/** Human-readable Russian name of a Balsam device. */ +const char *balsam_can_device_name(uint8_t device); + +/** + * Format the register name from the Balsam 167 data table into output. + * Returns the required length (excluding NUL); an empty string means unknown. + */ +size_t balsam_can_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size); + +#ifdef __cplusplus +} +#endif + +#endif /* BALSAM_CAN_H */ diff --git a/c/set-protocol/include/pcan_abi.h b/c/set-protocol/include/pcan_abi.h index 5e1c23a..4ce3b03 100644 --- a/c/set-protocol/include/pcan_abi.h +++ b/c/set-protocol/include/pcan_abi.h @@ -45,6 +45,14 @@ typedef struct { uint8_t payload[PCAN_ABI_GUI_PAYLOAD_MAX]; } pcan_abi_gui_frame_t; +typedef struct { + uint8_t device; + uint8_t direction; + uint8_t present_mask; + uint16_t start_address; + uint16_t values[3]; +} pcan_abi_balsam_frame_t; + PCAN_ABI_API uint32_t pcan_abi_version(void); PCAN_ABI_API uint32_t pcan_abi_id_pack(uint8_t priority, uint8_t route, @@ -58,6 +66,13 @@ PCAN_ABI_API void pcan_abi_id_unpack(uint32_t raw, uint8_t *priority, PCAN_ABI_API uint16_t pcan_abi_crc16(const uint8_t *data, size_t size); +PCAN_ABI_API int pcan_abi_balsam_decode(uint32_t can_id, + const uint8_t *data, size_t size, + pcan_abi_balsam_frame_t *output); +PCAN_ABI_API const char *pcan_abi_balsam_device_name(uint8_t device); +PCAN_ABI_API size_t pcan_abi_balsam_register_name( + uint8_t device, uint16_t address, char *output, size_t output_size); + PCAN_ABI_API size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, uint32_t can_id, const uint8_t *data, uint8_t dlc, diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h index 3cf2a3c..a680583 100644 --- a/c/set-protocol/include/setprotocol.h +++ b/c/set-protocol/include/setprotocol.h @@ -5,6 +5,8 @@ #ifndef SETPROTOCOL_H #define SETPROTOCOL_H +#include "balsam_can.h" + /* Основной SET protocol v2. */ #include "set_protocol.h" #include "set_can.h" diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk index b9c7b81..e25efce 100644 --- a/c/set-protocol/ports/android/Android.mk +++ b/c/set-protocol/ports/android/Android.mk @@ -12,6 +12,7 @@ LOCAL_SRC_FILES := \ set_plot_jni.c \ ../../src/set_trends.c \ ../../src/set_spectrum.c \ + ../../src/balsam_can.c \ ../../src/gui_catalog.c \ ../../src/gui_frame.c \ ../../src/pcan_abi.c \ diff --git a/c/set-protocol/ports/android/README.md b/c/set-protocol/ports/android/README.md index 73616aa..7ba353c 100644 --- a/c/set-protocol/ports/android/README.md +++ b/c/set-protocol/ports/android/README.md @@ -14,6 +14,11 @@ workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps an errors but does not duplicate FFT/filter math. Run it off the UI thread. `trends/PlotViewport` is a toolkit-free normalized zoom/pan model. +`legacycan/LegacyCanTerminal.kt` contains the two historical CAN_terminal wire +formats, the complete Projects.ini node/command catalog, and shared codecs for +register writes and command frames. Android UI code must use this module instead +of reproducing the Delphi byte rotation or CAN-ID routing rules. + `kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt` is a UI-independent firmware release client. It reads the optional `firmware.releases` array from the shared `update.json`, accepts only HTTPS assets, limits their size and diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt index 40707b1..5e45407 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt @@ -29,6 +29,9 @@ object NativeSetProtocol { ): Long external fun nativeUnpackId(raw: Long): IntArray? external fun nativeCrc16(input: ByteArray): Int + external fun nativeBalsamDecode(canId: Long, input: ByteArray): IntArray? + external fun nativeBalsamDeviceName(device: Int): String + external fun nativeBalsamRegisterName(device: Int, address: Int): String external fun nativeEncodeFrame( sequence: Int, flags: Int, diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt new file mode 100644 index 0000000..e1fd17d --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/balsam/BalsamCanProtocol.kt @@ -0,0 +1,116 @@ +package ru.setcorp.setprotocol.balsam + +import ru.setcorp.setprotocol.NativeSetProtocol + +data class BalsamRegister(val address: Int, val value: Int, val name: String) { + val displayName: String get() = name.ifBlank { "R%04X".format(address) } + val signedValue: Int get() = if (value < 0x8000) value else value - 0x10000 +} + +data class BalsamFrame( + val canId: Long, + val device: Int, + val deviceName: String, + val fromDevice: Boolean, + val startAddress: Int, + val presentMask: Int, + val registers: List, +) { + fun summary(): String { + val direction = if (fromDevice) "данные" else "команда" + val values = registers.joinToString { "${it.displayName}=0x%04X (%d)".format(it.value, it.signedValue) } + .ifEmpty { "нет отмеченных регистров" } + return "BALZAM · $deviceName · $direction · $values" + } +} + +/** Shared parser for Balsam_167_periph eCAN frames. */ +object BalsamCanProtocol { + const val BASE_ID = 0x00BA_0000L + const val TERMINAL_REQUEST_ID = 0x00BA_001CL + const val TERMINAL_RESPONSE_ID = 0x00BA_000CL + const val PULT_REQUEST_ID = 0x0074_5019L + const val PULT_RESPONSE_ID = 0x0074_5009L + + fun isLegacyId(canId: Long): Boolean { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + return relative in 0L..12L || relative in 0x10L..0x1CL || + canId == PULT_REQUEST_ID || canId == PULT_RESPONSE_ID + } + + fun decode(canId: Long, data: ByteArray): BalsamFrame? { + if (!isRegisterId(canId) || data.size != 8) return null + val native = if (NativeSetProtocol.available) { + NativeSetProtocol.nativeBalsamDecode(canId, data) + } else null + val words = native ?: fallbackDecode(canId, data) + val device = words[0] + val mask = words[2] + val start = words[3] + val registers = (0..2).filter { mask and (4 shr it) != 0 }.map { index -> + val address = start + index + BalsamRegister(address, words[4 + index], registerName(device, address)) + } + return BalsamFrame( + canId and 0x1FFF_FFFFL, + device, + deviceName(device), + words[1] == 1, + start, + mask, + registers, + ) + } + + fun summary(canId: Long, data: ByteArray? = null): String = + data?.let { decode(canId, it)?.summary() } ?: when (canId) { + TERMINAL_REQUEST_ID -> "BALZAM legacy · запрос терминала" + TERMINAL_RESPONSE_ID -> "BALZAM legacy · ответ терминалу" + PULT_REQUEST_ID -> "BALZAM legacy · данные пульта" + PULT_RESPONSE_ID -> "BALZAM legacy · команда пульту" + in (BASE_ID + 0x10L)..(BASE_ID + 0x1BL) -> + "BALZAM legacy · данные · ${deviceName((canId - BASE_ID - 0x0FL).toInt())}" + in BASE_ID..(BASE_ID + 0x0BL) -> + "BALZAM legacy · команда · ${deviceName((canId - BASE_ID + 1L).toInt())}" + else -> "BALZAM legacy · неизвестный ID" + } + + private fun isRegisterId(canId: Long): Boolean { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + return relative in 0L..12L || relative in 0x10L..0x1CL + } + + private fun fallbackDecode(canId: Long, data: ByteArray): IntArray { + val relative = (canId and 0x1FFF_FFFFL) - BASE_ID + val header = u16be(data, 0) + return intArrayOf( + ((relative and 0x0F) + 1).toInt(), + if (relative >= 0x10) 1 else 0, + (header ushr 13) and 7, + header and 0x1FFF, + u16be(data, 2), u16be(data, 4), u16be(data, 6), + ) + } + + private fun u16be(data: ByteArray, offset: Int): Int = + ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF) + + private fun deviceName(device: Int): String = if (NativeSetProtocol.available) { + NativeSetProtocol.nativeBalsamDeviceName(device) + } else listOf( + "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", "Силовой блок 2", + "УМП 1", "УМП 2", "Двигатель", "ВЭП", "Задатчик", "Узел 10", "Узел 11", + "Узел 12", "Терминал", + ).getOrElse(device - 1) { "Неизвестный узел" } + + private fun registerName(device: Int, address: Int): String = + if (NativeSetProtocol.available) NativeSetProtocol.nativeBalsamRegisterName(device, address) + else when { + device in 1..2 && address in 0x18..0x2B -> "Показания T° ${address - 0x17}" + device in 3..4 && address in 0x18..0x27 -> "Показания T° ${address - 0x17}" + device == 7 && address in 0x18..0x1F -> "Показания T° ${address - 0x17}" + address == 0x17 -> "Состояние джамперов" + address == 0x7F -> "Команды" + else -> "" + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt new file mode 100644 index 0000000..0a3cf26 --- /dev/null +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/legacycan/LegacyCanTerminal.kt @@ -0,0 +1,230 @@ +package ru.setcorp.setprotocol.legacycan + +/** Wire formats implemented by the historical CAN_terminal application. */ +enum class LegacyCanFormat { + /** Address and a three-bit presence mask are carried in DATA[4..5]. */ + ROTATING_THREE_WORDS, + + /** Register address is carried in CAN ID[6:0], followed by up to four words. */ + ADDRESS_IN_IDENTIFIER, +} + +enum class LegacyCanSource { TO_DEVICE, FROM_DEVICE } + +data class LegacyCanPacket( + val address: Int, + val mask: Int, + val values: List, + val source: LegacyCanSource, +) { + val presentValues: List> + get() = values.mapIndexedNotNull { index, value -> + if (formatUses(index)) address + index to value else null + } + + private fun formatUses(index: Int): Boolean = mask == 0xFF || mask and (4 shr index) != 0 +} + +data class LegacyCanWireFrame(val canId: Long, val data: ByteArray) + +data class LegacyCanRegisterValue( + val address: Int, + val value: Int = 0, + val source: LegacyCanSource? = null, + val revision: Long = 0, +) + +data class LegacyCanNode( + val index: Int, + val rsAddress: Int, + val canAddress: Int, + val rxId: Long, + val txId: Long, + val name: String, +) + +data class LegacyCanProject( + val name: String, + val format: LegacyCanFormat, + val baseId: Long, + val idOffset: Long, + val nodes: List, + val commandNames: List, +) { + fun nodeFor(canId: Long): LegacyCanNode? { + val normalized = LegacyCanTerminalProtocol.routingId(format, canId) + return nodes.firstOrNull { it.rxId == normalized || it.txId == normalized } + } +} + +/** + * Shared, UI-independent codec for the two protocols found in CAN_terminal.pas. + * Values are unsigned 16-bit words; callers can interpret them as signed with + * [signedWord]. + */ +object LegacyCanTerminalProtocol { + fun emptyRegisterBank(): List = + List(128) { LegacyCanRegisterValue(it) } + + fun applyPacket( + bank: List, + packet: LegacyCanPacket, + revision: Long, + ): List { + require(bank.size == 128) { "Банк должен содержать 128 регистров" } + val updates = packet.presentValues.toMap() + return bank.map { current -> + updates[current.address]?.let { value -> + current.copy(value = value, source = packet.source, revision = revision) + } ?: current + } + } + + fun routingId(format: LegacyCanFormat, canId: Long): Long = when (format) { + LegacyCanFormat.ROTATING_THREE_WORDS -> canId and 0x1FFF_FFFFL + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> canId and 0x1FF0_0000L + } + + fun decode( + format: LegacyCanFormat, + node: LegacyCanNode, + canId: Long, + data: ByteArray, + ): LegacyCanPacket? { + val route = routingId(format, canId) + val source = when (route) { + node.txId -> LegacyCanSource.TO_DEVICE + node.rxId -> LegacyCanSource.FROM_DEVICE + else -> return null + } + return when (format) { + LegacyCanFormat.ROTATING_THREE_WORDS -> { + if (data.size != 8) return null + val mask = (u8(data[4]) ushr 5) and 7 + val address = ((u8(data[4]) and 0x1F) shl 8) or u8(data[5]) + LegacyCanPacket(address, mask, listOf(u16be(data, 6), u16be(data, 0), u16be(data, 2)), source) + } + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> { + if (data.isEmpty() || data.size > 8 || data.size % 2 != 0) return null + LegacyCanPacket( + address = (canId and 0x7F).toInt(), + mask = 0xFF, + values = data.indices.step(2).map { u16be(data, it) }, + source = source, + ) + } + } + } + + fun encodeWrite( + format: LegacyCanFormat, + canId: Long, + address: Int, + values: List, + ): LegacyCanWireFrame { + require(address in 0..127) { "Адрес регистра должен быть в диапазоне 0..127" } + val maximum = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 4 else 3 + require(values.size in 1..maximum) { "Нужно от 1 до $maximum слов данных" } + values.forEach { require(it in 0..0xFFFF) { "Значение должно быть в диапазоне 0..65535" } } + return when (format) { + LegacyCanFormat.ADDRESS_IN_IDENTIFIER -> LegacyCanWireFrame( + (canId and 0x1FF0_0000L) + address, + values.flatMap { listOf((it ushr 8).toByte(), it.toByte()) }.toByteArray(), + ) + LegacyCanFormat.ROTATING_THREE_WORDS -> { + val padded = values + List(3 - values.size) { 0 } + val mask = when (values.size) { 1 -> 4; 2 -> 6; else -> 7 } + val data = byteArrayOf( + (padded[1] ushr 8).toByte(), padded[1].toByte(), + (padded[2] ushr 8).toByte(), padded[2].toByte(), + ((mask shl 5) or (address ushr 8)).toByte(), address.toByte(), + (padded[0] ushr 8).toByte(), padded[0].toByte(), + ) + LegacyCanWireFrame(canId and 0x1FFF_FFFFL, data) + } + } + } + + fun encodeCommand(project: LegacyCanProject, node: LegacyCanNode, commandIndex: Int): LegacyCanWireFrame { + require(commandIndex in 0..16) { "Номер команды должен быть в диапазоне 0..16" } + val value = if (commandIndex < 16) 1 shl commandIndex else 0 + return encodeWrite(project.format, node.rxId, 127, listOf(value)) + } + + fun signedWord(value: Int): Int = if (value < 0x8000) value else value - 0x10000 + + private fun u8(value: Byte): Int = value.toInt() and 0xFF + private fun u16be(data: ByteArray, offset: Int): Int = (u8(data[offset]) shl 8) or u8(data[offset + 1]) +} + +/** Project table migrated from CAN_terminal/Projects.ini (Windows-1251). */ +object LegacyCanProjects { + private val defaultCommands = listOf( + "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", + "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all", + ) + + private fun project( + name: String, + baseId: Long = 0, + offset: Long = 0x10, + format: LegacyCanFormat = LegacyCanFormat.ROTATING_THREE_WORDS, + specs: List>, + commands: List = defaultCommands, + ): LegacyCanProject { + val shift = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 20 else 0 + val actualOffset = if (format == LegacyCanFormat.ADDRESS_IN_IDENTIFIER) 1L shl 28 else offset + val nodes = specs.map { spec -> + val index = spec[0] as Int + val canAddress = spec[1] as Int + val rsAddress = spec[2] as Int + val nodeName = spec[3] as String + val routed = canAddress.toLong() shl shift + LegacyCanNode(index, rsAddress, canAddress, baseId + routed, baseId + actualOffset + routed, nodeName) + } + return LegacyCanProject(name, format, baseId, actualOffset, nodes, commands) + } + + private fun s(index: Int, can: Int, rs: Int, name: String): List = listOf(index, can, rs, name) + + val all: List = listOf( + project("Буксир", 0x0031_8200, specs = listOf( + s(0, 0, 1, "УКСС СБ"), s(1, 1, 2, "БКСС ГД"), s(2, 2, 3, "УКСВЭП"), s(3, 3, 4, "Задатчик"), + )), + project("СЭДБМ", 0x0105_1020, specs = listOf( + s(0,0,0,"УКСС СК1 СБ1"),s(1,1,1,"УКСС СК2 СБ1"),s(2,2,2,"УКСС СК3 СБ1"),s(3,3,3,"УКСС СК4 СБ1"), + s(4,4,4,"УКССВЭП СБ1"),s(5,5,5,"Задатчик СБ1"),s(6,6,6,"БТР ИТЭС"),s(8,0x20,8,"УКСС СК1 СБ2"), + s(9,0x21,9,"УКСС СК2 СБ2"),s(10,0x22,10,"УКСС СК3 СБ2"),s(11,0x23,11,"УКСС СК4 СБ2"), + s(12,0x24,12,"УКССВЭП СБ2"),s(13,0x25,13,"Задатчик СБ2"),s(14,0x26,14,"УКСС БОИН"),s(15,0x27,15,"УКСВЭП БОИН"), + ), commands = defaultCommands.toMutableList().also { it[7]="Raw"; it[8]="HiVolt" }), + project("Ледокол", 0x001C_E020, -0x20, specs = listOf( + s(0,0,1,"УКСС БВ1 ПЧ1"),s(8,1,2,"УКСС БВ1 ПЧ2"),s(1,2,3,"УКСС БВ1 ПЧ1"),s(9,3,4,"УКСС БВ2 ПЧ2"), + s(2,4,5,"УКСС БИ1 ПЧ1"),s(10,5,6,"УКСС БИ1 ПЧ2"),s(3,6,7,"УКСС БИ2 ПЧ1"),s(11,7,8,"УКСС БИ2 ПЧ2"), + s(4,8,9,"УКССВЭП1 ПЧ1"),s(12,9,10,"УКССВЭП1 ПЧ2"),s(5,10,11,"УКССВЭП2 ПЧ1"),s(13,11,12,"УКССВЭП2 ПЧ2"), + ), commands = defaultCommands.toMutableList().also { it[4]="Raw"; it[5]="Read"; it[6]="ExtLamp"; it[7]="ExtLite"; it[8]="No log" }), + project("Бальзам", 0x00BA_0000, specs = listOf( + s(0,0,1,"БКСС Тр1"),s(8,1,2,"БКСС Тр2"),s(1,2,3,"УКСС СБ1"),s(9,3,4,"УКСС СБ2"), + s(2,4,5,"УКСС УМП1"),s(10,5,6,"УКСС УМП2"),s(3,6,7,"БКСС ГД"),s(4,7,9,"Задатчик"),s(5,8,11,"УКСС ВЭП"), + ), commands = defaultCommands.toMutableList().also { it[6]="Stop";it[7]="Start";it[8]="Init";it[9]="Tune";it[10]="Secret";it[11]="Light";it[12]="Raw" }), + project("23550", 0x0023_5500, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"), + ), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }), + project("23550.X", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"УКСВЭП"),s(3,3,4,"БКСС ГД"), + ), commands = defaultCommands.toMutableList().also { it[5]="Read";it[7]="Send";it[8]="-" }), + project("23550.2", format = LegacyCanFormat.ADDRESS_IN_IDENTIFIER, specs = listOf( + s(0,0,1,"Задатчик"),s(1,1,2,"Выносной пульт"),s(2,2,3,"БКСС ГД"),s(3,4,4,"УКСС СИ СБ1"), + s(4,6,6,"УКСС СВФ СБ1"),s(5,8,8,"УКСВЭП СБ1"),s(11,5,5,"УКСС СИ СБ2"),s(12,7,7,"УКСС СВФ СБ2"), + s(13,9,9,"УКСВЭП СБ2"),s(16,0x1F,16,"BroadCast"), + ), commands = defaultCommands.toMutableList().also { it[5]="Calc";it[7]="Send" }), + project("Янтарь", 0x0021_3000, specs = listOf( + s(0,0,1,"УКСС БВ"),s(1,1,2,"УКСС БИ1"),s(2,2,3,"УКСС БИ2"),s(3,3,4,"БКСС ГД"), + s(4,4,5,"УКСВЭП"),s(5,5,6,"Задатчик"),s(6,6,7,"Выносной пульт"), + )), + project( + "23550 БСУ", 0x0CEB_0F1, -0x10, + specs = listOf(s(0,0,0,"БСУ1"),s(1,1,1,"БСУ2")), + commands = List(16) { "-" } + "Nothing at all", + ), + ) +} diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index 863c2e5..1b5c5a0 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -6,6 +6,53 @@ #include "setprotocol_abi.h" #include "set_trends.h" #include "set_spectrum.h" +#include "balsam_can.h" + +JNIEXPORT jintArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDecode( + JNIEnv *env, jobject self, jlong can_id, jbyteArray input) +{ + (void)self; + balsam_can_frame_t frame; + jsize size; + jbyte data[BALSAM_CAN_DLC]; + jint values[7]; + if (input == NULL) return NULL; + size = (*env)->GetArrayLength(env, input); + if (size != (jsize)BALSAM_CAN_DLC) return NULL; + (*env)->GetByteArrayRegion(env, input, 0, size, data); + if (balsam_can_decode((uint32_t)can_id, (const uint8_t *)data, + (size_t)size, &frame) != 1) return NULL; + values[0] = frame.device; + values[1] = frame.direction; + values[2] = frame.present_mask; + values[3] = frame.start_address; + values[4] = frame.values[0]; + values[5] = frame.values[1]; + values[6] = frame.values[2]; + jintArray result = (*env)->NewIntArray(env, 7); + if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, 7, values); + return result; +} + +JNIEXPORT jstring JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamDeviceName( + JNIEnv *env, jobject self, jint device) +{ + (void)self; + return (*env)->NewStringUTF(env, balsam_can_device_name((uint8_t)device)); +} + +JNIEXPORT jstring JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeBalsamRegisterName( + JNIEnv *env, jobject self, jint device, jint address) +{ + (void)self; + char name[128]; + balsam_can_register_name((uint8_t)device, (uint16_t)address, + name, sizeof name); + return (*env)->NewStringUTF(env, name); +} JNIEXPORT jdoubleArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrum( diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt new file mode 100644 index 0000000..305b93b --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/balsam/BalsamCanProtocolTest.kt @@ -0,0 +1,20 @@ +package ru.setcorp.setprotocol.balsam + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BalsamCanProtocolTest { + @Test + fun decodesThreeNamedSensorRegisters() { + val frame = requireNotNull(BalsamCanProtocol.decode( + 0x00BA_0010L, + byteArrayOf(0xE0.toByte(), 0x18, 0x00, 0x29, 0xFF.toByte(), 0xFE.toByte(), 0x12, 0x34), + )) + assertEquals(1, frame.device) + assertEquals(0x18, frame.startAddress) + assertEquals(listOf(41, 0xFFFE, 0x1234), frame.registers.map { it.value }) + assertEquals("Показания T° 1", frame.registers.first().displayName) + assertTrue(frame.summary().contains("Трансформатор 1")) + } +} diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt new file mode 100644 index 0000000..a4c63c0 --- /dev/null +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/legacycan/LegacyCanTerminalTest.kt @@ -0,0 +1,49 @@ +package ru.setcorp.setprotocol.legacycan + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LegacyCanTerminalTest { + @Test fun rotatingFormatRoundTripsAllWordCounts() { + val project = LegacyCanProjects.all.first { it.name == "Бальзам" } + val node = project.nodes.first() + for (values in listOf(listOf(0x1234), listOf(0x1234, 0xABCD), listOf(0x1234, 0xABCD, 0x8001))) { + val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.rxId, 0x18, values) + val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!! + assertEquals(0x18, decoded.address) + assertEquals(values, decoded.presentValues.map { it.second }) + assertEquals(LegacyCanSource.FROM_DEVICE, decoded.source) + } + } + + @Test fun addressInIdentifierRoundTripsFourWords() { + val project = LegacyCanProjects.all.first { it.name == "23550.2" } + val node = project.nodes.first { it.name == "БКСС ГД" } + val values = listOf(1, 2, 0x7FFF, 0xFFFF) + val wire = LegacyCanTerminalProtocol.encodeWrite(project.format, node.txId, 0x7F, values) + val decoded = LegacyCanTerminalProtocol.decode(project.format, node, wire.canId, wire.data)!! + assertEquals(values, decoded.values) + assertEquals(LegacyCanSource.TO_DEVICE, decoded.source) + assertEquals(-1, LegacyCanTerminalProtocol.signedWord(decoded.values.last())) + } + + @Test fun unrelatedIdDoesNotDecode() { + val project = LegacyCanProjects.all.first() + assertNull(LegacyCanTerminalProtocol.decode(project.format, project.nodes.first(), 0x123, ByteArray(8))) + } + + @Test fun packetReducerUpdatesOnlyPresentRegisters() { + val packet = LegacyCanPacket(10, 5, listOf(11, 22, 33), LegacyCanSource.FROM_DEVICE) + val bank = LegacyCanTerminalProtocol.applyPacket(LegacyCanTerminalProtocol.emptyRegisterBank(), packet, 42) + assertEquals(listOf(11, 33), listOf(bank[10].value, bank[12].value)) + assertEquals(0, bank[11].value) + assertEquals(42, bank[12].revision) + } + + @Test fun catalogContainsAllLegacyProjectsAndNodes() { + assertEquals(9, LegacyCanProjects.all.size) + assertEquals(10, LegacyCanProjects.all.first { it.name == "23550.2" }.nodes.size) + assertEquals("Start", LegacyCanProjects.all.first { it.name == "Бальзам" }.commandNames[7]) + } +} diff --git a/c/set-protocol/src/balsam_can.c b/c/set-protocol/src/balsam_can.c new file mode 100644 index 0000000..53ea9f2 --- /dev/null +++ b/c/set-protocol/src/balsam_can.c @@ -0,0 +1,182 @@ +#include "balsam_can.h" + +#include +#include + +static uint16_t get_be16(const uint8_t *data) +{ + return (uint16_t)(((uint16_t)data[0] << 8) | data[1]); +} + +static size_t copy_name(const char *name, char *output, size_t output_size) +{ + size_t length = strlen(name); + if ((output != NULL) && (output_size != 0U)) { + size_t copied = length < output_size - 1U ? length : output_size - 1U; + memcpy(output, name, copied); + output[copied] = '\0'; + } + return length; +} + +static size_t indexed_name(const char *prefix, unsigned index, + char *output, size_t output_size) +{ + int length; + if ((output == NULL) || (output_size == 0U)) { + char scratch[64]; + length = snprintf(scratch, sizeof scratch, "%s %u", prefix, index); + } else { + length = snprintf(output, output_size, "%s %u", prefix, index); + } + return length > 0 ? (size_t)length : 0U; +} + +int balsam_can_is_id(uint32_t can_id) +{ + uint32_t relative = (can_id & 0x1FFFFFFFUL) - BALSAM_CAN_BASE_ID; + return relative < BALSAM_CAN_NODE_COUNT + || (relative >= BALSAM_CAN_DATA_OFFSET + && relative < BALSAM_CAN_DATA_OFFSET + BALSAM_CAN_NODE_COUNT); +} + +int balsam_can_decode(uint32_t can_id, const uint8_t *data, size_t size, + balsam_can_frame_t *output) +{ + uint32_t relative; + uint16_t header; + if ((data == NULL) || (output == NULL)) return -1; + can_id &= 0x1FFFFFFFUL; + if (!balsam_can_is_id(can_id)) return 0; + if (size != BALSAM_CAN_DLC) return -2; + + relative = can_id - BALSAM_CAN_BASE_ID; + output->direction = relative >= BALSAM_CAN_DATA_OFFSET + ? BALSAM_CAN_FROM_NODE : BALSAM_CAN_TO_NODE; + output->device = (uint8_t)((relative & 0x0FU) + 1U); + header = get_be16(data); + output->present_mask = (uint8_t)((header >> 13) & 0x07U); + output->start_address = (uint16_t)(header & 0x1FFFU); + output->values[0] = get_be16(&data[2]); + output->values[1] = get_be16(&data[4]); + output->values[2] = get_be16(&data[6]); + return 1; +} + +const char *balsam_can_device_name(uint8_t device) +{ + static const char *const names[BALSAM_CAN_NODE_COUNT] = { + "Трансформатор 1", "Трансформатор 2", "Силовой блок 1", + "Силовой блок 2", "УМП 1", "УМП 2", "Двигатель", "ВЭП", + "Задатчик", "Узел 10", "Узел 11", "Узел 12", "Терминал" + }; + return (device >= 1U && device <= BALSAM_CAN_NODE_COUNT) + ? names[device - 1U] : "Неизвестный узел"; +} + +size_t balsam_can_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size) +{ + if ((device == 1U || device == 2U) && address < 20U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x18U && address <= 0x2BU) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x30U && address <= 0x43U) + return indexed_name("Аварийная уставка T°", address - 0x2FU, output, output_size); + if ((device == 1U || device == 2U) && address >= 0x48U && address <= 0x5BU) + return indexed_name("Предупредительная уставка T°", address - 0x47U, output, output_size); + + if ((device == 3U || device == 4U) && address < 16U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if ((device == 3U || device == 4U) && address >= 0x18U && address <= 0x27U) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + if ((device == 3U || device == 4U) && address == 0x28U) + return copy_name("Действующее Uвх1", output, output_size); + if ((device == 3U || device == 4U) && address == 0x29U) + return copy_name("Амплитудное Uвх1", output, output_size); + if ((device == 3U || device == 4U) && address == 0x2AU) + return copy_name("Действующее Uвх2", output, output_size); + if ((device == 3U || device == 4U) && address == 0x2BU) + return copy_name("Амплитудное Uвх2", output, output_size); + + if ((device == 5U || device == 6U) && address == 0U) + return copy_name("Диагностика Utr a", output, output_size); + if ((device == 5U || device == 6U) && address == 1U) + return copy_name("Диагностика Utr c", output, output_size); + if ((device == 5U || device == 6U) && address == 2U) + return copy_name("Диагностика Itr a", output, output_size); + if ((device == 5U || device == 6U) && address == 3U) + return copy_name("Диагностика Itr c", output, output_size); + if ((device == 5U || device == 6U) && address == 0x18U) + return copy_name("Действующее Utr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x19U) + return copy_name("Амплитудное Utr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1AU) + return copy_name("Действующее Itr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1BU) + return copy_name("Амплитудное Itr", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1CU) + return copy_name("Ток СИФУ, задание (mA*10)", output, output_size); + if ((device == 5U || device == 6U) && address == 0x1DU) + return copy_name("Ток СИФУ, обратная связь (mA*10)", output, output_size); + + if (device == 7U && address < 8U) + return indexed_name("Диагностика T°", address + 1U, output, output_size); + if (device == 7U && address >= 0x18U && address <= 0x1FU) + return indexed_name("Показания T°", address - 0x17U, output, output_size); + + if (device == 8U) { + static const char *const vep_names[] = { + "Диагностика 380В Ф1", "Диагностика 380В Ф2", + "Диагностика 31В Ф1", "Диагностика 31В Ф2", + "Диагностика 31В UC1", "Диагностика 31В UC2", + "Диагностика 24В ПУ", "Диагностика 27В ФА", + "Диагностика 24В ПК", "Диагностика 15В ДР", + "Диагностика +24В ДТ", "Диагностика -24В ДТ", + "Диагностика 24В ПМУ", "Диагностика T° 1", "Диагностика T° 2" + }; + static const char *const vep_values[] = { + "Показания 380В Ф1", "Показания 380В Ф2", "Показания 31В Ф1", + "Показания 31В Ф2", "Показания 31В UC1", "Показания 31В UC2", + "Показания 24В ПУ", "Показания 27В ФА", "Показания 24В ПК", + "Показания 15В ДР", "Показания +24В ДТ", "Показания -24В ДТ", + "Показания 24В ПМУ", "Показания T° 1", "Показания T° 2" + }; + if (address < sizeof vep_names / sizeof vep_names[0]) + return copy_name(vep_names[address], output, output_size); + if (address >= 0x18U && address < 0x18U + sizeof vep_values / sizeof vep_values[0]) + return copy_name(vep_values[address - 0x18U], output, output_size); + if (address == 0x10U) + return copy_name("Дискретные датчики (14 бит)", output, output_size); + } + + if (device == 9U && address == 0U) + return copy_name("Обороты ГЭД, об/мин", output, output_size); + if (device == 9U && address == 1U) + return copy_name("Обороты ГВ, об/мин", output, output_size); + if (device == 9U && address == 2U) + return copy_name("Лампы (8 бит)", output, output_size); + if (device == 9U && address == 3U) + return copy_name("Диоды (16 бит)", output, output_size); + if (device == 9U && address == 0x10U) + return copy_name("Кнопки (12 бит)", output, output_size); + + if (address == 0x16U) + return copy_name("Дискретные входы / кнопки", output, output_size); + if (address == 0x17U) + return copy_name("Состояние джамперов", output, output_size); + if (address == 0x60U) + return copy_name("Период быстрого CAN-цикла", output, output_size); + if (address == 0x61U) + return copy_name("Период медленного CAN-цикла", output, output_size); + if (address == 0x62U) + return copy_name("Яркость индикации", output, output_size); + if (address == 0x63U) + return copy_name("Период опроса OWEN", output, output_size); + if (address == 0x7EU) + return copy_name("Последний режим", output, output_size); + if (address == 0x7FU) + return copy_name("Команды", output, output_size); + + return copy_name("", output, output_size); +} diff --git a/c/set-protocol/src/pcan_abi.c b/c/set-protocol/src/pcan_abi.c index 55e8293..8094e42 100644 --- a/c/set-protocol/src/pcan_abi.c +++ b/c/set-protocol/src/pcan_abi.c @@ -3,6 +3,7 @@ #include #include "pcan_crc.h" +#include "balsam_can.h" #include "pcan_frame.h" #include "pcan_id.h" #include "gui_frame.h" @@ -60,6 +61,33 @@ uint16_t pcan_abi_crc16(const uint8_t *data, size_t size) return pcan_crc16(data, size); } +int pcan_abi_balsam_decode(uint32_t can_id, const uint8_t *data, size_t size, + pcan_abi_balsam_frame_t *output) +{ + balsam_can_frame_t decoded; + int status; + if (output == NULL) return -1; + status = balsam_can_decode(can_id, data, size, &decoded); + if (status != 1) return status; + output->device = decoded.device; + output->direction = decoded.direction; + output->present_mask = decoded.present_mask; + output->start_address = decoded.start_address; + memcpy(output->values, decoded.values, sizeof output->values); + return 1; +} + +const char *pcan_abi_balsam_device_name(uint8_t device) +{ + return balsam_can_device_name(device); +} + +size_t pcan_abi_balsam_register_name(uint8_t device, uint16_t address, + char *output, size_t output_size) +{ + return balsam_can_register_name(device, address, output, output_size); +} + size_t pcan_abi_frame_encode(uint8_t sequence, uint8_t flags, uint32_t can_id, const uint8_t *data, uint8_t dlc, uint8_t *output, size_t output_size) diff --git a/c/set-protocol/tests/test_balsam_can.c b/c/set-protocol/tests/test_balsam_can.c new file mode 100644 index 0000000..d00fe2e --- /dev/null +++ b/c/set-protocol/tests/test_balsam_can.c @@ -0,0 +1,30 @@ +#include +#include + +#include "balsam_can.h" + +#define CHECK(x) do { if (!(x)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \ +} } while (0) + +int main(void) +{ + const uint8_t packet[8] = { 0xE0, 0x18, 0x00, 0x29, 0xFF, 0xFE, 0x12, 0x34 }; + balsam_can_frame_t frame; + char name[64]; + + CHECK(balsam_can_is_id(0x00BA0010UL)); + CHECK(balsam_can_is_id(0x00BA001CUL)); + CHECK(!balsam_can_is_id(0x00BA001DUL)); + CHECK(balsam_can_decode(0x00BA0010UL, packet, sizeof packet, &frame) == 1); + CHECK(frame.device == 1U); + CHECK(frame.direction == BALSAM_CAN_FROM_NODE); + CHECK(frame.present_mask == 7U); + CHECK(frame.start_address == 0x18U); + CHECK(frame.values[0] == 41U && frame.values[1] == 0xFFFEU + && frame.values[2] == 0x1234U); + CHECK(balsam_can_register_name(1U, 0x18U, name, sizeof name) > 0U); + CHECK(strstr(name, "T° 1") != NULL); + CHECK(balsam_can_decode(0x00BA0010UL, packet, 7U, &frame) == -2); + return 0; +} diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py index a978283..2f54914 100644 --- a/c/set-protocol/tools/build_host.py +++ b/c/set-protocol/tools/build_host.py @@ -21,7 +21,7 @@ JNI_INCLUDES: list[Path] = [] SOURCES = [ ROOT / "src" / name for name in ( "set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", - "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", + "balsam_can.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c", "pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c", "pcan_gas.c", ) diff --git a/python/protocan/__init__.py b/python/protocan/__init__.py index cbba06a..d7d39f3 100644 --- a/python/protocan/__init__.py +++ b/python/protocan/__init__.py @@ -5,9 +5,11 @@ from .native import ( NativeGuiParser, NativeParser, NativeProtocol, NativeProtocolUnavailable, get_native_core, get_native_protocol, ) +from .balsam import BalsamFrame, BalsamRegister, decode as decode_balsam __all__ = [ "NativeCore", "NativeCoreUnavailable", "NativeFrame", "NativeGuiFrame", "NativeGuiParser", "NativeParser", "NativeProtocol", "NativeProtocolUnavailable", "get_native_core", "get_native_protocol", + "BalsamFrame", "BalsamRegister", "decode_balsam", ] diff --git a/python/protocan/balsam.py b/python/protocan/balsam.py new file mode 100644 index 0000000..1d602ff --- /dev/null +++ b/python/protocan/balsam.py @@ -0,0 +1,98 @@ +"""Balsam 167 legacy CAN register decoder backed by the shared C99 core.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .native import NativeProtocolUnavailable, get_native_protocol + + +BASE_ID = 0x00BA0000 +DATA_OFFSET = 0x10 +NODE_COUNT = 13 + + +@dataclass(frozen=True) +class BalsamRegister: + address: int + value: int + name: str + + @property + def signed_value(self) -> int: + return self.value if self.value < 0x8000 else self.value - 0x10000 + + +@dataclass(frozen=True) +class BalsamFrame: + can_id: int + device: int + device_name: str + from_device: bool + start_address: int + present_mask: int + registers: tuple[BalsamRegister, ...] + + @property + def summary(self) -> str: + direction = "данные" if self.from_device else "команда" + values = ", ".join( + "%s=0x%04X (%d)" % (item.name or "R%04X" % item.address, + item.value, item.signed_value) + for item in self.registers + ) or "нет отмеченных регистров" + return "BALZAM · %s · %s · %s" % (self.device_name, direction, values) + + +def is_balsam_id(can_id: int) -> bool: + relative = (can_id & 0x1FFFFFFF) - BASE_ID + return (0 <= relative < NODE_COUNT + or DATA_OFFSET <= relative < DATA_OFFSET + NODE_COUNT) + + +def decode(can_id: int, data: bytes, native=None) -> BalsamFrame | None: + """Decode one abstract Balsam frame: BE mask/address plus three BE words.""" + if native is None: + try: + native = get_native_protocol() + except NativeProtocolUnavailable: + return _decode_fallback(can_id, data) + status, decoded = native.balsam_decode(can_id, bytes(data)) + if status == 0: + return None + if status == -2: + raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes") + if status != 1 or decoded is None: + raise ValueError("invalid BALZAM CAN frame") + device, direction, mask, start, values = decoded + registers = tuple( + BalsamRegister(start + index, values[index], + native.balsam_register_name(device, start + index)) + for index in range(3) if mask & (4 >> index) + ) + return BalsamFrame(can_id & 0x1FFFFFFF, device, + native.balsam_device_name(device), direction == 1, + start, mask, registers) + + +def _decode_fallback(can_id: int, data: bytes) -> BalsamFrame | None: + if not is_balsam_id(can_id): + return None + if len(data) != 8: + raise ValueError("BALZAM CAN frame must contain exactly 8 data bytes") + relative = (can_id & 0x1FFFFFFF) - BASE_ID + device = (relative & 0x0F) + 1 + header = int.from_bytes(data[:2], "big") + values = tuple(int.from_bytes(data[offset:offset + 2], "big") + for offset in (2, 4, 6)) + names = { + 1: "Трансформатор 1", 2: "Трансформатор 2", + 3: "Силовой блок 1", 4: "Силовой блок 2", 5: "УМП 1", 6: "УМП 2", + 7: "Двигатель", 8: "ВЭП", 9: "Задатчик", 13: "Терминал", + } + start, mask = header & 0x1FFF, (header >> 13) & 7 + registers = tuple(BalsamRegister(start + i, values[i], "") + for i in range(3) if mask & (4 >> i)) + return BalsamFrame(can_id & 0x1FFFFFFF, device, + names.get(device, "Узел %d" % device), + relative >= DATA_OFFSET, start, mask, registers) diff --git a/python/protocan/native.py b/python/protocan/native.py index 40c8f11..23aad9d 100644 --- a/python/protocan/native.py +++ b/python/protocan/native.py @@ -41,6 +41,16 @@ class _AbiGuiFrame(ctypes.Structure): ] +class _AbiBalsamFrame(ctypes.Structure): + _fields_ = [ + ("device", ctypes.c_uint8), + ("direction", ctypes.c_uint8), + ("present_mask", ctypes.c_uint8), + ("start_address", ctypes.c_uint16), + ("values", ctypes.c_uint16 * 3), + ] + + @dataclass(frozen=True) class NativeFrame: sequence: int @@ -118,6 +128,17 @@ class NativeProtocol: ] lib.pcan_abi_crc16.argtypes = [ctypes.c_void_p, ctypes.c_size_t] lib.pcan_abi_crc16.restype = ctypes.c_uint16 + lib.pcan_abi_balsam_decode.argtypes = [ + ctypes.c_uint32, ctypes.c_void_p, ctypes.c_size_t, + ctypes.POINTER(_AbiBalsamFrame), + ] + lib.pcan_abi_balsam_decode.restype = ctypes.c_int + lib.pcan_abi_balsam_device_name.argtypes = [ctypes.c_uint8] + lib.pcan_abi_balsam_device_name.restype = ctypes.c_char_p + lib.pcan_abi_balsam_register_name.argtypes = [ + ctypes.c_uint8, ctypes.c_uint16, ctypes.c_void_p, ctypes.c_size_t, + ] + lib.pcan_abi_balsam_register_name.restype = ctypes.c_size_t lib.pcan_abi_frame_encode.argtypes = [ ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t, @@ -178,6 +199,27 @@ class NativeProtocol: source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None return int(self.lib.pcan_abi_crc16(source, len(data))) + def balsam_decode(self, can_id: int, data: bytes): + source = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) if data else None + output = _AbiBalsamFrame() + status = int(self.lib.pcan_abi_balsam_decode( + can_id, source, len(data), ctypes.byref(output))) + if status != 1: + return status, None + return status, (int(output.device), int(output.direction), + int(output.present_mask), int(output.start_address), + tuple(int(value) for value in output.values)) + + def balsam_device_name(self, device: int) -> str: + value = self.lib.pcan_abi_balsam_device_name(device) + return value.decode("utf-8") if value else "" + + def balsam_register_name(self, device: int, address: int) -> str: + output = ctypes.create_string_buffer(128) + self.lib.pcan_abi_balsam_register_name( + device, address, output, len(output)) + return output.value.decode("utf-8") + def encode(self, sequence: int, flags: int, can_id: int, data: bytes) -> bytes: if len(data) > 8: raise ValueError("DLC cannot exceed 8 bytes") diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py index 8a2789e..7ed69e2 100644 --- a/python/protocan/protocan.py +++ b/python/protocan/protocan.py @@ -392,6 +392,11 @@ class Decoded: registers: Optional[List[tuple]] = None #: Замечания о нарушениях протокола warnings: List[str] = field(default_factory=list) + #: Имя прикладного протокола для GUI, когда это не ProtoCAN. + protocol: str = "ProtoCAN" + #: UI labels for protocols whose identifier is not a ProtoCAN bit field. + device_label: str = "" + message_label: str = "" def _ascii(data: bytes) -> str: -- 2.25.1 From 01ffc0e496a4f20a5343f771e118667c7826441f Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:52:36 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20Python=20API=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=BE=D0=B3=D0=BE=20CAN=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/legacycan.py | 227 +++++++++++++++++++++++++++++++++ python/tests/test_legacycan.py | 39 ++++++ 2 files changed, 266 insertions(+) create mode 100644 python/protocan/legacycan.py create mode 100644 python/tests/test_legacycan.py diff --git a/python/protocan/legacycan.py b/python/protocan/legacycan.py new file mode 100644 index 0000000..2c3b405 --- /dev/null +++ b/python/protocan/legacycan.py @@ -0,0 +1,227 @@ +"""UI-independent model of the historical CAN_terminal protocol. + +The catalog is migrated from ``CAN_terminal/Projects.ini``. Both desktop +SETGUI and Android keep only presentation and transport code in the apps; +wire layouts and project metadata live in the shared templates repository. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from enum import Enum + + +class LegacyCanFormat(Enum): + ROTATING_THREE_WORDS = "rotating_three_words" + ADDRESS_IN_IDENTIFIER = "address_in_identifier" + + +class LegacyCanSource(Enum): + TO_DEVICE = "TX" + FROM_DEVICE = "RX" + + +@dataclass(frozen=True) +class LegacyCanPacket: + address: int + mask: int + values: tuple[int, ...] + source: LegacyCanSource + + @property + def present_values(self) -> tuple[tuple[int, int], ...]: + return tuple( + (self.address + index, value) + for index, value in enumerate(self.values) + if self.mask == 0xFF or self.mask & (4 >> index) + ) + + +@dataclass(frozen=True) +class LegacyCanWireFrame: + can_id: int + data: bytes + + +@dataclass(frozen=True) +class LegacyCanRegisterValue: + address: int + value: int = 0 + source: LegacyCanSource | None = None + revision: int = 0 + + +@dataclass(frozen=True) +class LegacyCanNode: + index: int + rs_address: int + can_address: int + rx_id: int + tx_id: int + name: str + + +@dataclass(frozen=True) +class LegacyCanProject: + name: str + format: LegacyCanFormat + base_id: int + id_offset: int + nodes: tuple[LegacyCanNode, ...] + command_names: tuple[str, ...] + + def node_for(self, can_id: int) -> LegacyCanNode | None: + routed = routing_id(self.format, can_id) + return next( + (node for node in self.nodes + if node.rx_id == routed or node.tx_id == routed), + None, + ) + + +def empty_register_bank() -> tuple[LegacyCanRegisterValue, ...]: + return tuple(LegacyCanRegisterValue(index) for index in range(128)) + + +def apply_packet( + bank: tuple[LegacyCanRegisterValue, ...], + packet: LegacyCanPacket, + revision: int, +) -> tuple[LegacyCanRegisterValue, ...]: + if len(bank) != 128: + raise ValueError("Банк должен содержать 128 регистров") + updates = dict(packet.present_values) + return tuple( + replace(item, value=updates[item.address], source=packet.source, + revision=revision) + if item.address in updates else item + for item in bank + ) + + +def routing_id(format_: LegacyCanFormat, can_id: int) -> int: + if format_ is LegacyCanFormat.ROTATING_THREE_WORDS: + return can_id & 0x1FFFFFFF + return can_id & 0x1FF00000 + + +def decode( + format_: LegacyCanFormat, + node: LegacyCanNode, + can_id: int, + data: bytes, +) -> LegacyCanPacket | None: + route = routing_id(format_, can_id) + if route == node.tx_id: + source = LegacyCanSource.TO_DEVICE + elif route == node.rx_id: + source = LegacyCanSource.FROM_DEVICE + else: + return None + if format_ is LegacyCanFormat.ROTATING_THREE_WORDS: + if len(data) != 8: + return None + mask = (data[4] >> 5) & 7 + address = ((data[4] & 0x1F) << 8) | data[5] + values = (_u16be(data, 6), _u16be(data, 0), _u16be(data, 2)) + return LegacyCanPacket(address, mask, values, source) + if not data or len(data) > 8 or len(data) % 2: + return None + return LegacyCanPacket( + can_id & 0x7F, + 0xFF, + tuple(_u16be(data, offset) for offset in range(0, len(data), 2)), + source, + ) + + +def encode_write( + format_: LegacyCanFormat, + can_id: int, + address: int, + values: tuple[int, ...] | list[int], +) -> LegacyCanWireFrame: + if not 0 <= address <= 127: + raise ValueError("Адрес регистра должен быть в диапазоне 0..127") + maximum = 4 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else 3 + if not 1 <= len(values) <= maximum: + raise ValueError(f"Нужно от 1 до {maximum} слов данных") + if any(not 0 <= value <= 0xFFFF for value in values): + raise ValueError("Значение должно быть в диапазоне 0..65535") + if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER: + data = b"".join(int(value).to_bytes(2, "big") for value in values) + return LegacyCanWireFrame((can_id & 0x1FF00000) + address, data) + padded = tuple(values) + (0,) * (3 - len(values)) + mask = (4, 6, 7)[len(values) - 1] + data = ( + padded[1].to_bytes(2, "big") + + padded[2].to_bytes(2, "big") + + bytes(((mask << 5) | (address >> 8), address & 0xFF)) + + padded[0].to_bytes(2, "big") + ) + return LegacyCanWireFrame(can_id & 0x1FFFFFFF, data) + + +def encode_command( + project: LegacyCanProject, + node: LegacyCanNode, + command_index: int, +) -> LegacyCanWireFrame: + if not 0 <= command_index <= 16: + raise ValueError("Номер команды должен быть в диапазоне 0..16") + value = 1 << command_index if command_index < 16 else 0 + return encode_write(project.format, node.rx_id, 127, (value,)) + + +def signed_word(value: int) -> int: + return value if value < 0x8000 else value - 0x10000 + + +def _u16be(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 2], "big") + + +_DEFAULT_COMMANDS = ( + "Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", + "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", + "Nothing at all", +) + + +def _commands(**changes: str) -> tuple[str, ...]: + result = list(_DEFAULT_COMMANDS) + for index, name in changes.items(): + result[int(index)] = name + return tuple(result) + + +def _project( + name: str, + specs: tuple[tuple[int, int, int, str], ...], + base_id: int = 0, + offset: int = 0x10, + format_: LegacyCanFormat = LegacyCanFormat.ROTATING_THREE_WORDS, + commands: tuple[str, ...] = _DEFAULT_COMMANDS, +) -> LegacyCanProject: + shift = 20 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else 0 + actual_offset = 1 << 28 if format_ is LegacyCanFormat.ADDRESS_IN_IDENTIFIER else offset + nodes = tuple( + LegacyCanNode(index, rs, can, base_id + (can << shift), + base_id + actual_offset + (can << shift), node_name) + for index, can, rs, node_name in specs + ) + return LegacyCanProject(name, format_, base_id, actual_offset, nodes, commands) + + +PROJECTS = ( + _project("Буксир", ((0,0,1,"УКСС СБ"),(1,1,2,"БКСС ГД"),(2,2,3,"УКСВЭП"),(3,3,4,"Задатчик")), base_id=0x00318200), + _project("СЭДБМ", ((0,0,0,"УКСС СК1 СБ1"),(1,1,1,"УКСС СК2 СБ1"),(2,2,2,"УКСС СК3 СБ1"),(3,3,3,"УКСС СК4 СБ1"),(4,4,4,"УКССВЭП СБ1"),(5,5,5,"Задатчик СБ1"),(6,6,6,"БТР ИТЭС"),(8,0x20,8,"УКСС СК1 СБ2"),(9,0x21,9,"УКСС СК2 СБ2"),(10,0x22,10,"УКСС СК3 СБ2"),(11,0x23,11,"УКСС СК4 СБ2"),(12,0x24,12,"УКССВЭП СБ2"),(13,0x25,13,"Задатчик СБ2"),(14,0x26,14,"УКСС БОИН"),(15,0x27,15,"УКСВЭП БОИН")), base_id=0x01051020, commands=_commands(**{"7":"Raw","8":"HiVolt"})), + _project("Ледокол", ((0,0,1,"УКСС БВ1 ПЧ1"),(8,1,2,"УКСС БВ1 ПЧ2"),(1,2,3,"УКСС БВ1 ПЧ1"),(9,3,4,"УКСС БВ2 ПЧ2"),(2,4,5,"УКСС БИ1 ПЧ1"),(10,5,6,"УКСС БИ1 ПЧ2"),(3,6,7,"УКСС БИ2 ПЧ1"),(11,7,8,"УКСС БИ2 ПЧ2"),(4,8,9,"УКССВЭП1 ПЧ1"),(12,9,10,"УКССВЭП1 ПЧ2"),(5,10,11,"УКССВЭП2 ПЧ1"),(13,11,12,"УКССВЭП2 ПЧ2")), base_id=0x001CE020, offset=-0x20, commands=_commands(**{"4":"Raw","5":"Read","6":"ExtLamp","7":"ExtLite","8":"No log"})), + _project("Бальзам", ((0,0,1,"БКСС Тр1"),(8,1,2,"БКСС Тр2"),(1,2,3,"УКСС СБ1"),(9,3,4,"УКСС СБ2"),(2,4,5,"УКСС УМП1"),(10,5,6,"УКСС УМП2"),(3,6,7,"БКСС ГД"),(4,7,9,"Задатчик"),(5,8,11,"УКСС ВЭП")), base_id=0x00BA0000, commands=_commands(**{"6":"Stop","7":"Start","8":"Init","9":"Tune","10":"Secret","11":"Light","12":"Raw"})), + _project("23550", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"УКСВЭП"),(3,3,4,"БКСС ГД")), base_id=0x00235500, commands=_commands(**{"5":"Read","7":"Send","8":"-"})), + _project("23550.X", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"УКСВЭП"),(3,3,4,"БКСС ГД")), format_=LegacyCanFormat.ADDRESS_IN_IDENTIFIER, commands=_commands(**{"5":"Read","7":"Send","8":"-"})), + _project("23550.2", ((0,0,1,"Задатчик"),(1,1,2,"Выносной пульт"),(2,2,3,"БКСС ГД"),(3,4,4,"УКСС СИ СБ1"),(4,6,6,"УКСС СВФ СБ1"),(5,8,8,"УКСВЭП СБ1"),(11,5,5,"УКСС СИ СБ2"),(12,7,7,"УКСС СВФ СБ2"),(13,9,9,"УКСВЭП СБ2"),(16,0x1F,16,"BroadCast")), format_=LegacyCanFormat.ADDRESS_IN_IDENTIFIER, commands=_commands(**{"5":"Calc","7":"Send"})), + _project("Янтарь", ((0,0,1,"УКСС БВ"),(1,1,2,"УКСС БИ1"),(2,2,3,"УКСС БИ2"),(3,3,4,"БКСС ГД"),(4,4,5,"УКСВЭП"),(5,5,6,"Задатчик"),(6,6,7,"Выносной пульт")), base_id=0x00213000), + _project("23550 БСУ", ((0,0,0,"БСУ1"),(1,1,1,"БСУ2")), base_id=0x0CEB0F1, offset=-0x10, commands=("-",) * 16 + ("Nothing at all",)), +) + diff --git a/python/tests/test_legacycan.py b/python/tests/test_legacycan.py new file mode 100644 index 0000000..ae3c004 --- /dev/null +++ b/python/tests/test_legacycan.py @@ -0,0 +1,39 @@ +from protocan.legacycan import ( + LegacyCanFormat, LegacyCanSource, PROJECTS, apply_packet, decode, + empty_register_bank, encode_command, encode_write, signed_word, +) + + +def test_catalog_matches_legacy_projects(): + assert [project.name for project in PROJECTS] == [ + "Буксир", "СЭДБМ", "Ледокол", "Бальзам", "23550", "23550.X", + "23550.2", "Янтарь", "23550 БСУ", + ] + assert len(next(project for project in PROJECTS if project.name == "СЭДБМ").nodes) == 15 + + +def test_rotating_three_word_codec_and_bank(): + project = next(project for project in PROJECTS if project.name == "Бальзам") + node = project.nodes[0] + wire = encode_write(project.format, node.rx_id, 24, (41, 0xFFFE, 0x1234)) + assert wire.data == bytes.fromhex("FF FE 12 34 E0 18 00 29") + packet = decode(project.format, node, wire.can_id, wire.data) + assert packet is not None + assert packet.present_values == ((24, 41), (25, 0xFFFE), (26, 0x1234)) + assert packet.source is LegacyCanSource.FROM_DEVICE + bank = apply_packet(empty_register_bank(), packet, 7) + assert bank[25].value == 0xFFFE + assert bank[25].revision == 7 + assert signed_word(bank[25].value) == -2 + + +def test_address_in_identifier_codec_and_command(): + project = next(project for project in PROJECTS if project.name == "23550.2") + node = project.nodes[2] + assert project.format is LegacyCanFormat.ADDRESS_IN_IDENTIFIER + wire = encode_write(project.format, node.tx_id, 17, (1, 2, 0xFFFF, 4)) + assert wire.can_id & 0x7F == 17 + assert wire.data == bytes.fromhex("00 01 00 02 FF FF 00 04") + command = encode_command(project, node, 7) + assert command.can_id & 0x7F == 127 + assert command.data == b"\x00\x80" -- 2.25.1 From 454baeed9826c8f2c4d3443ca5cabc01aa83cd6d Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 18:59:54 +0300 Subject: [PATCH 5/7] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20Periph28335?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/periph28335.py | 98 ++++++++++++++++++++++++++++++++ python/tests/test_periph28335.py | 23 ++++++++ 2 files changed, 121 insertions(+) create mode 100644 python/protocan/periph28335.py create mode 100644 python/tests/test_periph28335.py diff --git a/python/protocan/periph28335.py b/python/protocan/periph28335.py new file mode 100644 index 0000000..05a6c04 --- /dev/null +++ b/python/protocan/periph28335.py @@ -0,0 +1,98 @@ +"""Portable RS command helpers from Set_Terminal_28335. + +The byte order and CRC match ``DTrans.pas``/``UNiiefa.pas``. This module is +deliberately UI- and serial-port-independent so desktop and Android clients +can share the same request builders. +""" + +from __future__ import annotations + + +def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int: + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc & 0xFFFF + + +def with_crc(payload: bytes) -> bytes: + crc = crc16_modbus(payload) + return bytes(payload) + crc.to_bytes(2, "little") + + +def build_read_registers(controller: int, start: int, count: int) -> bytes: + _range("Адрес контроллера", controller, 0xFF) + _range("Начальный регистр", start, 0xFFFF) + if not 1 <= count <= 128 or start + count > 128: + raise ValueError("Диапазон регистров должен находиться в 0..127") + return with_crc(bytes((controller, 3)) + start.to_bytes(2, "big") + + count.to_bytes(2, "big")) + + +def build_write_register(controller: int, address: int, value: int) -> bytes: + _range("Адрес контроллера", controller, 0xFF) + _range("Адрес регистра", address, 127) + _range("Значение", value, 0xFFFF) + return with_crc(bytes((controller, 6)) + address.to_bytes(2, "big") + + value.to_bytes(2, "big")) + + +def build_command(controller: int, command_index: int) -> bytes: + if not 0 <= command_index <= 16: + raise ValueError("Номер команды должен быть в диапазоне 0..16") + value = 1 << command_index if command_index < 16 else 0 + return build_write_register(controller, 127, value) + + +def expected_read_response_size(count: int) -> int: + return count * 2 + 5 + + +def decode_read_response(data: bytes, count: int) -> tuple[int, ...]: + expected = expected_read_response_size(count) + if len(data) != expected: + raise ValueError(f"Ожидалось {expected} байт, получено {len(data)}") + if crc16_modbus(data[:-2]) != int.from_bytes(data[-2:], "little"): + raise ValueError("Ошибка CRC ответа") + # Historical replies have a three-byte header; registers are big-endian. + body = data[3:-2] + if len(body) != count * 2: + raise ValueError("Неверная длина данных ответа") + return tuple(int.from_bytes(body[offset:offset + 2], "big") + for offset in range(0, len(body), 2)) + + +def bits_lsb_first(value: int) -> tuple[bool, ...]: + _range("Значение", value, 0xFFFF) + return tuple(bool(value & (1 << bit)) for bit in range(16)) + + +def word_from_bits(bits) -> int: + values = tuple(bool(item) for item in bits) + if len(values) != 16: + raise ValueError("Должно быть ровно 16 бит") + return sum(1 << bit for bit, checked in enumerate(values) if checked) + + +def signed_word(value: int) -> int: + _range("Значение", value, 0xFFFF) + return value if value < 0x8000 else value - 0x10000 + + +def _range(name: str, value: int, maximum: int) -> None: + if not 0 <= value <= maximum: + raise ValueError(f"{name} вне диапазона 0..{maximum}") + + +PROJECT_COMMANDS = { + "По умолчанию": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Light", "Raw", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23470": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "-", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23550": ("Test", "Def", "Save", "Load", "Calibr", "Read", "Secret", "Send", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "23550.2": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Secret", "Send", "Raw", "Beep", "", "", "", "", "Log", "Reset", "Nothing at all"), + "ICE 22220.1-3": ("Test", "Zero", "Save", "Def", "Calibr", "Read", "ExtLamp", "ExtLite", "-", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "ICE 22220.4-5": ("Test", "Def", "Save", "Load", "Raw", "Read", "ExtLamp", "ExtLite", "No log", "-", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 161": ("Test", "Zero", "Save", "Def", "Calibr", "Clbr 400", "Stop", "Start", "Init", "Secret", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 162": ("Test", "Def", "Save", "Load", "Calibr", "Secret", "Stop", "Start", "Init", "Tune", "-", "-", "-", "-", "-", "Reset", "Nothing at all"), + "Бальзам 163": ("Test", "Def", "Save", "Load", "Calibr", "Calcul", "Stop", "Start", "Init", "Tune", "Secret", "-", "-", "-", "-", "Reset", "Nothing at all"), +} diff --git a/python/tests/test_periph28335.py b/python/tests/test_periph28335.py new file mode 100644 index 0000000..5338089 --- /dev/null +++ b/python/tests/test_periph28335.py @@ -0,0 +1,23 @@ +from protocan.periph28335 import ( + bits_lsb_first, build_command, build_read_registers, + build_write_register, crc16_modbus, word_from_bits, +) + + +def test_read_request_matches_delphi_byte_order(): + request = build_read_registers(16, 24, 64) + assert request[:6] == bytes.fromhex("10 03 00 18 00 40") + assert int.from_bytes(request[-2:], "little") == crc16_modbus(request[:-2]) + + +def test_write_and_command_use_register_127(): + request = build_write_register(16, 7, 0x1234) + assert request[:6] == bytes.fromhex("10 06 00 07 12 34") + command = build_command(16, 15) + assert command[:6] == bytes.fromhex("10 06 00 7F 80 00") + + +def test_bits_keep_original_lsb_first_order(): + bits = bits_lsb_first(0x8005) + assert bits[0] and bits[2] and bits[15] + assert word_from_bits(bits) == 0x8005 -- 2.25.1 From b972b6f33c02c99b89bf72ecfed7435eacdf09bf Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 19:10:06 +0300 Subject: [PATCH 6/7] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20TMS=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BF=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt | 2 +- python/setprotocol/firmware_catalog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt index ffdb56c..1148676 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/update/FirmwareCatalog.kt @@ -142,7 +142,7 @@ class FirmwareCatalogClient(private val userAgent: String) { require(fileName.matches(Regex("[A-Za-zА-Яа-яЁё0-9._ -]+\\.(bin|hex)", RegexOption.IGNORE_CASE))) { "Некорректное имя файла прошивки №${index + 1}" } - require(transport in setOf("rs485", "can", "stm32")) { "Некорректный канал прошивки №${index + 1}" } + require(transport in setOf("rs485", "can", "stm32", "tms")) { "Некорректный канал прошивки №${index + 1}" } val baseAddress = row.opt("baseAddress")?.takeUnless { it == JSONObject.NULL }?.toString()?.let(::parseAddress) add(FirmwareRelease( product, versionName, versionCode, imageUrl, fileName, sha256, diff --git a/python/setprotocol/firmware_catalog.py b/python/setprotocol/firmware_catalog.py index 2b1c2e9..ab7da72 100644 --- a/python/setprotocol/firmware_catalog.py +++ b/python/setprotocol/firmware_catalog.py @@ -9,7 +9,7 @@ from pathlib import Path from urllib.parse import urljoin, urlparse MAX_MANIFEST_BYTES = 128 * 1024 -SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32"}) +SUPPORTED_TRANSPORTS = frozenset({"rs485", "can", "stm32", "tms"}) @dataclass(frozen=True) -- 2.25.1 From d44d57a7aa6595413fad631058aee51eb0b53a79 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 19:17:27 +0300 Subject: [PATCH 7/7] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B2=20Legacy=20CAN=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/protocan/legacycan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/protocan/legacycan.py b/python/protocan/legacycan.py index 2c3b405..070c13a 100644 --- a/python/protocan/legacycan.py +++ b/python/protocan/legacycan.py @@ -224,4 +224,3 @@ PROJECTS = ( _project("Янтарь", ((0,0,1,"УКСС БВ"),(1,1,2,"УКСС БИ1"),(2,2,3,"УКСС БИ2"),(3,3,4,"БКСС ГД"),(4,4,5,"УКСВЭП"),(5,5,6,"Задатчик"),(6,6,7,"Выносной пульт")), base_id=0x00213000), _project("23550 БСУ", ((0,0,0,"БСУ1"),(1,1,1,"БСУ2")), base_id=0x0CEB0F1, offset=-0x10, commands=("-",) * 16 + ("Nothing at all",)), ) - -- 2.25.1