Добавить общие графики, декодер KONOR и порт STM32 bxCAN

This commit is contained in:
2026-09-04 12:22:17 +03:00
parent af82687e42
commit e163d9ba55
51 changed files with 5040 additions and 15 deletions

View File

@@ -8,6 +8,10 @@ LOCAL_SRC_FILES := \
../../src/set_can.c \
../../src/set_firmware.c \
../../src/set_telemetry.c \
../../src/set_plot.c \
set_plot_jni.c \
../../src/set_trends.c \
../../src/set_spectrum.c \
../../src/gui_catalog.c \
../../src/gui_frame.c \
../../src/pcan_abi.c \
@@ -19,5 +23,5 @@ LOCAL_SRC_FILES := \
../../src/pcan_gas.c \
setprotocol_jni.c
LOCAL_CFLAGS := -std=c99 -Wall -Wextra -Wpedantic -fvisibility=hidden
LOCAL_LDLIBS := -llog
LOCAL_LDLIBS := -llog -lm
include $(BUILD_SHARED_LIBRARY)

View File

@@ -7,4 +7,9 @@ and include this `Android.mk` from the application NDK build.
The JNI streaming parser returns fixed 15-byte records
`SEQ | FLAGS | CAN_ID_LE | DLC | DATA[8]`. Dynamic allocation is confined to
the Android adapter; the portable core remains allocation free.
the Android adapter for protocol operations; the portable protocol core remains allocation free.
`nativeSpectrum` calls the shared host DSP module `set_spectrum.c` (bounded heap
workspace, up to 2×16384 doubles). `trends/SpectrumAnalyzer` maps timestamps and
errors but does not duplicate FFT/filter math. Run it off the UI thread.
`trends/PlotViewport` is a toolkit-free normalized zoom/pan model.

View File

@@ -10,6 +10,9 @@ object MessageType {
const val FIRMWARE_END = 0x0D
const val FIRMWARE_ABORT = 0x0E
const val FIRMWARE_STATUS = 0x0F
const val GAS_CATALOG = 0x11
const val GAS_WATCH_SET = 0x12
const val GAS_WATCH_DATA = 0x13
const val SENSOR_SCAN = 0x20
const val SENSOR_LIST = 0x21
const val SENSOR_READ = 0x22
@@ -33,6 +36,9 @@ object MessageType {
FIRMWARE_END -> "FIRMWARE_END"
FIRMWARE_ABORT -> "FIRMWARE_ABORT"
FIRMWARE_STATUS -> "FIRMWARE_STATUS"
GAS_CATALOG -> "GAS_CATALOG"
GAS_WATCH_SET -> "GAS_WATCH_SET"
GAS_WATCH_DATA -> "GAS_WATCH_DATA"
SENSOR_SCAN -> "SENSOR_SCAN"
SENSOR_LIST -> "SENSOR_LIST"
SENSOR_READ -> "SENSOR_READ"

View File

@@ -10,6 +10,15 @@ object NativeSetProtocol {
}
external fun nativeAbiVersion(): Int
/** {status, N, Fs, jitter, amplitudes...}; status != 0 has no amplitudes. */
external fun nativeSpectrum(times: DoubleArray, values: DoubleArray, maxSize: Int,
window: Int, filter: Int, lowHz: Double, highHz: Double, removeMean: Boolean): DoubleArray?
external fun nativeTrendCanValue(
source: Int, address: Long, deviceType: Int, device: Int, byteOffset: Int,
extended: Boolean, signed: Boolean, canId: Long, flags: Int, input: ByteArray,
): Int
external fun nativeTrendWatchRequest(period: Int, addresses: IntArray): ByteArray?
external fun nativeTrendWatchValues(input: ByteArray): IntArray?
external fun nativePackId(
priority: Int,
route: Int,

View File

@@ -0,0 +1,46 @@
package ru.setcorp.setprotocol.trends
import ru.setcorp.setprotocol.NativeSetProtocol
/** GUI v1 GAS contract, also exposed by python/protocan/gas_catalog.py. */
object GuiGasWatch {
const val SET = 0x12
const val DATA = 0x13
const val PERIOD_MS = 1000
fun request(periodMs: Int, addresses: List<Int>): ByteArray {
require(periodMs in 0..65535 && addresses.size <= 64 && addresses.all { it in 0..65535 })
if (NativeSetProtocol.available) return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray()))
return ByteArray(4 + addresses.size * 2).also { output ->
put16(output, 0, periodMs)
put16(output, 2, addresses.size)
addresses.forEachIndexed { index, address -> put16(output, 4 + index * 2, address) }
}
}
fun validateAck(payload: ByteArray, periodMs: Int, count: Int) {
require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == count) {
"Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно"
}
}
fun values(payload: ByteArray, expectedCount: Int): List<Int> {
val values = if (NativeSetProtocol.available) {
requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList()
} else {
require(payload.size >= 6) { "GAS_WATCH_DATA короче заголовка" }
val count = read16(payload, 4)
require(count <= 64 && payload.size == 6 + count * 2) { "Неверная длина GAS_WATCH_DATA" }
List(count) { read16(payload, 6 + it * 2) }
}
require(values.size == expectedCount) { "Число значений GAS не соответствует подписке" }
return values
}
private fun read16(data: ByteArray, offset: Int): Int =
(data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
private fun put16(data: ByteArray, offset: Int, value: Int) {
data[offset] = value.toByte()
data[offset + 1] = (value ushr 8).toByte()
}
}

View File

@@ -0,0 +1,32 @@
package ru.setcorp.setprotocol.trends
/** Shared C99 plot math, also used by the Python/Qt port. No UI dependency. */
internal object NativePlot {
init {
val hostLibrary = System.getProperty("setplot.library")
if (hostLibrary != null) System.load(hostLibrary) else System.loadLibrary("setprotocol")
}
private external fun evaluate(operation: Int, input: DoubleArray): DoubleArray?
fun call(operation: Int, vararg input: Double): DoubleArray =
requireNotNull(evaluate(operation, input)) { "Invalid plot operation $operation" }
}
enum class PlotAxis { X, Y }
fun plotPinchAxis(deltaX: Double, deltaY: Double, slop: Double): PlotAxis? =
when (NativePlot.call(1, deltaX, deltaY, slop)[0].toInt()) {
1 -> PlotAxis.X; 2 -> PlotAxis.Y; else -> null
}
data class PlotBounds(val left: Double, val right: Double, val bottom: Double, val top: Double) {
fun fraction(value: Double, horizontal: Boolean): Double = NativePlot.call(2, value,
if (horizontal) bottom else left, if (horizontal) top else right, if (horizontal) 1.0 else 0.0)[0]
fun value(fraction: Double, horizontal: Boolean): Double = NativePlot.call(3, fraction,
if (horizontal) bottom else left, if (horizontal) top else right, if (horizontal) 1.0 else 0.0)[0]
fun drag(initial: Double, delta: Double, length: Double, horizontal: Boolean): Double = NativePlot.call(4,
initial, delta, length, if (horizontal) bottom else left, if (horizontal) top else right,
if (horizontal) 1.0 else 0.0)[0]
}
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]

View File

@@ -0,0 +1,10 @@
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) {
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 {
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])
}
}

View File

@@ -0,0 +1,39 @@
package ru.setcorp.setprotocol.trends
import ru.setcorp.setprotocol.NativeSetProtocol
/** Shared JNI decoder with a JVM-only reference fallback for tests/source builds. */
object TrendDecoder {
fun canValue(signal: TrendSignal, canId: Long, flags: Int, data: ByteArray): Double? {
if (NativeSetProtocol.available && signal.source in setOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW)) {
val address = parseTrendAddress(signal.address, if (signal.source == TrendSource.CAN_GAS) 0xFFFF else 0x1FFF_FFFF) ?: return null
val value = NativeSetProtocol.nativeTrendCanValue(
if (signal.source == TrendSource.CAN_GAS) 1 else 2, address, signal.deviceType, signal.device,
signal.byteOffset, signal.extended, signal.valueType == TrendValueType.INT16,
canId, flags, data,
)
return value.takeIf { it != Int.MIN_VALUE }?.toDouble()
}
if (flags and 0x0E != 0 || data.size !in 2..8) return null
val extended = flags and 1 != 0
if (canId !in 0..(if (extended) 0x1FFF_FFFFL else 0x7FFL)) return null
val offset = when (signal.source) {
TrendSource.CAN_GAS -> {
if (!extended || data.size < 2 || data.size % 2 != 0) return null
if ((canId ushr 16) and 15L != 3L || (canId ushr 27) and 1L != 1L ||
((canId ushr 24) and 7L).toInt() != signal.deviceType ||
((canId ushr 20) and 15L).toInt() != signal.device) return null
val address = parseTrendAddress(signal.address, 0xFFFF)?.toInt() ?: return null
(address - (canId and 0xFFFF).toInt()) * 2
}
TrendSource.CAN_RAW -> {
if (extended != signal.extended || canId != parseTrendAddress(signal.address, 0x1FFF_FFFF)) return null
signal.byteOffset
}
else -> return null
}
if (offset < 0 || offset + 2 > data.size) return null
val word = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8)
return signal.valueType.decode(word)
}
}

View File

@@ -0,0 +1,54 @@
package ru.setcorp.setprotocol.trends
import kotlin.math.abs
enum class TrendMarker(val title: String, val horizontal: Boolean) {
A("A", false), B("B", false), C("C", false), D("D", false),
E("E", true), F("F", true), G("G", true), H("H", true)
}
data class TrendMarkers(
val xEnabled: Boolean = true,
val yEnabled: Boolean = false,
val selected: TrendMarker = TrendMarker.A,
val a: Double? = null,
val b: Double? = null,
val c: Double? = null,
val d: Double? = null,
val e: Double? = null,
val f: Double? = null,
val g: Double? = null,
val h: Double? = null,
) {
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
}
fun move(marker: TrendMarker, value: Double): TrendMarkers = when (marker) {
TrendMarker.A -> copy(a = value); TrendMarker.B -> copy(b = value)
TrendMarker.C -> copy(c = value); TrendMarker.D -> copy(d = value)
TrendMarker.E -> copy(e = value); TrendMarker.F -> copy(f = value)
TrendMarker.G -> copy(g = value); TrendMarker.H -> copy(h = value)
}
fun positioned(bounds: PlotBounds): TrendMarkers = copy(
a = a ?: bounds.value(0.2, false), b = b ?: bounds.value(0.4, false),
c = c ?: bounds.value(0.6, false), d = d ?: bounds.value(0.8, false),
e = e ?: bounds.value(0.2, true), f = f ?: bounds.value(0.4, true),
g = g ?: bounds.value(0.6, true), h = h ?: bounds.value(0.8, true))
fun reset(bounds: PlotBounds): TrendMarkers = copy(
a = null, b = null, c = null, d = null, e = null, f = null, g = null, h = null).positioned(bounds)
fun fraction(marker: TrendMarker, bounds: PlotBounds): Double = bounds.fraction(requireNotNull(value(marker)), marker.horizontal)
fun drag(marker: TrendMarker, delta: Double, length: Double, bounds: PlotBounds): TrendMarkers =
move(marker, bounds.drag(requireNotNull(value(marker)), delta, length, marker.horizontal)).copy(selected = marker)
fun hit(x: Double, y: Double, width: Double, height: Double, radius: Double, bounds: PlotBounds): TrendMarker? {
if (x !in 0.0..width || y !in 0.0..height) return null
return TrendMarker.entries.filter { enabled(it) && value(it) != null }.mapNotNull { marker ->
val fraction = fraction(marker, bounds)
val distance = abs(if (marker.horizontal) y - fraction * height else x - fraction * width)
if (fraction in 0.0..1.0 && distance <= radius) marker to distance else null
}.sortedWith(compareBy<Pair<TrendMarker, Double>> { it.second }
.thenBy { if (it.first == selected) 0 else 1 }).firstOrNull()?.first
}
fun enabled(marker: TrendMarker): Boolean = if (marker.horizontal) yEnabled else xEnabled
}

View File

@@ -0,0 +1,156 @@
package ru.setcorp.setprotocol.trends
import java.util.UUID
enum class TrendSection { SIGNALS, CHART }
/** File-format IDs; host applications map their connection profiles to these IDs. */
enum class TrendProfile(val title: String) {
SET_V1("SET GUI v1"), TMS2812("TMS320F2812 / BALZAM"), CAN_BRIDGE("CAN ↔ RS485"),
GS_USB_CAN("CANgaroo / gs_usb"), SLCAN("SKLab SLCAN"), CANGAROO_SLCAN("CANgaroo / SLCAN"),
BALZAM_CAN("Старый CAN BALZAM"),
}
enum class TrendSource(val title: String) {
TMS_MEMORY("Память TMS · CMD_PEEK"),
CAN_GAS("Регистр GAS · ProtoCAN"),
CAN_RAW("Слово из CAN-кадра"),
SET_SENSOR("Температура DS18B20"),
SET_GAS("Регистр GAS · SET GUI"),
}
enum class TrendValueType(val title: String) {
UINT16("UInt16 · 0…65535"), INT16("Int16 · 32768…32767");
fun decode(word: Int): Double = when (this) {
UINT16 -> word.toDouble()
INT16 -> word.toShort().toDouble()
}
}
fun TrendProfile.trendSources(): List<TrendSource> = when (this) {
TrendProfile.TMS2812 -> listOf(TrendSource.TMS_MEMORY)
TrendProfile.SET_V1 -> listOf(TrendSource.SET_GAS, TrendSource.SET_SENSOR)
TrendProfile.BALZAM_CAN -> listOf(TrendSource.CAN_RAW)
else -> listOf(TrendSource.CAN_GAS, TrendSource.CAN_RAW)
}
/** Stable IDs keep samples attached to a signal when its name/order changes. */
data class TrendSignal(
val id: String = UUID.randomUUID().toString(),
val order: Int = 1,
val name: String = "Тренд 1",
val source: TrendSource = TrendSource.TMS_MEMORY,
val address: String = "0x00000100",
val color: String = "#2F91FF",
val visible: Boolean = true,
val valueType: TrendValueType = TrendValueType.UINT16,
val deviceType: Int = 7,
val device: Int = 13,
val byteOffset: Int = 0,
val multiplier: Double = 1.0,
val iq: Int = 0,
val extended: Boolean = true,
) {
fun validationError(profile: TrendProfile): String? = when {
id.isBlank() || id.length > 80 -> "Неверный идентификатор тренда"
order !in 1..9999 -> "Номер должен быть от 1 до 9999"
name.isBlank() || name.length > 100 -> "Имя должно содержать от 1 до 100 символов"
address.length > 64 -> "Адрес слишком длинный"
source !in profile.trendSources() -> "Источник несовместим с протоколом ${profile.title}"
!color.matches(Regex("#[0-9a-fA-F]{6}")) -> "Цвет задаётся как #RRGGBB"
source == TrendSource.SET_SENSOR -> if (normalizeRom(address).matches(Regex("[0-9A-F]{16}"))) null
else "ROM датчика должен содержать 16 HEX-цифр"
source == TrendSource.TMS_MEMORY && parseTrendAddress(address, 0xFFFF_FFFFL) == null ->
"Адрес памяти: 0…0xFFFFFFFF"
source in setOf(TrendSource.CAN_GAS, TrendSource.SET_GAS) && parseTrendAddress(address, 0xFFFF) == null ->
"Адрес GAS: 0…0xFFFF"
source == TrendSource.CAN_GAS && (deviceType !in 0..7 || device !in 0..15) ->
"Тип устройства: 0…7, номер устройства: 0…15"
source == TrendSource.CAN_RAW && parseTrendAddress(address, if (extended) 0x1FFF_FFFF else 0x7FF) == null ->
"CAN ID вне диапазона ${if (extended) "29" else "11"} бит"
source == TrendSource.CAN_RAW && byteOffset !in 0..6 -> "Смещение слова: 0…6 байт"
!multiplier.isFinite() -> "Множитель должен быть конечным числом"
iq !in 0..30 -> "IQ должен быть в диапазоне 0…30"
else -> null
}
fun displayValue(rawValue: Double): Double =
rawValue * multiplier / (1L shl iq).toDouble()
/** Only source changes invalidate the historical series. */
fun sameInput(other: TrendSignal): Boolean =
copy(
order = other.order,
name = other.name,
color = other.color,
visible = other.visible,
multiplier = other.multiplier,
iq = other.iq,
) == other
companion object {
val COLORS = listOf("#2F91FF", "#FFB547", "#52D6A4", "#F8798D", "#B79AFF", "#4AD9E8", "#E8DA68", "#E7ECF3")
fun new(profile: TrendProfile, existing: List<TrendSignal>): TrendSignal {
val order = (1..9999).first { number -> existing.none { it.order == number } }
val source = profile.trendSources().first()
return TrendSignal(
order = order, name = "Тренд $order", source = source,
address = when (source) {
TrendSource.TMS_MEMORY -> "0x00000100"
TrendSource.CAN_GAS -> "0x0000"
TrendSource.SET_GAS -> "0x0000"
TrendSource.CAN_RAW -> "0x00BA0010"
TrendSource.SET_SENSOR -> ""
},
color = COLORS[(order - 1) % COLORS.size],
)
}
}
}
fun parseTrendAddress(value: String, max: Long): Long? {
val text = value.trim()
val hex = text.startsWith("0x", ignoreCase = true)
val digits = if (hex) text.drop(2) else text
if (digits.isEmpty() || !digits.all { if (hex) it in "0123456789abcdefABCDEF" else it in '0'..'9' }) return null
return digits.toLongOrNull(if (hex) 16 else 10)?.takeIf { it in 0..max }
}
fun normalizeRom(value: String): String = value.trim().replace("-", "").replace(" ", "").uppercase()
data class TrendPoint(
val timestampMs: Long,
val value: Double,
/** Nanoseconds inside [timestampMs], preserving spacing above 1 kHz without overflowing epoch nanoseconds. */
val subMillisecondNanos: Int = 0,
) {
init { require(subMillisecondNanos in 0..999_999) }
fun nanosSince(other: TrendPoint): Long =
(timestampMs - other.timestampMs) * 1_000_000L + subMillisecondNanos - other.subMillisecondNanos
}
data class TrendUiState(
val section: TrendSection = TrendSection.SIGNALS,
val settings: Map<TrendProfile, List<TrendSignal>> = emptyMap(),
val history: Map<String, List<TrendPoint>> = emptyMap(),
val running: Boolean = false,
val status: String = "Добавьте сигналы и откройте вкладку «График»",
val fileBusy: Boolean = false,
) {
fun signals(profile: TrendProfile): List<TrendSignal> = settings[profile].orEmpty().sortedBy { it.order }
fun append(values: Map<String, Double>, timestampMs: Long): TrendUiState {
if (!running) return this
val next = history.toMutableMap()
values.forEach { (id, value) ->
if (value.isFinite()) next[id] = (next[id].orEmpty() + TrendPoint(timestampMs, value)).takeLast(MAX_POINTS)
}
return copy(history = next)
}
companion object {
const val MAX_SIGNALS = 64
const val MAX_POINTS = 600
}
}

View File

@@ -0,0 +1,100 @@
package ru.setcorp.setprotocol.trends
import org.json.JSONArray
import org.json.JSONObject
/** Versioned, human-readable configuration. Import is validated before replacing any state. */
object TrendSettingsJson {
const val MAX_FILE_BYTES = 1024 * 1024
fun encode(settings: Map<TrendProfile, List<TrendSignal>>): String {
validate(settings)
val profiles = JSONObject()
settings.forEach { (profile, signals) ->
profiles.put(profile.name, JSONArray().apply {
signals.sortedBy { it.order }.forEach { signal ->
put(JSONObject().apply {
put("id", signal.id)
put("order", signal.order)
put("name", signal.name)
put("source", signal.source.name)
put("address", signal.address)
put("color", signal.color)
put("visible", signal.visible)
put("valueType", signal.valueType.name)
put("deviceType", signal.deviceType)
put("device", signal.device)
put("byteOffset", signal.byteOffset)
put("multiplier", signal.multiplier)
put("iq", signal.iq)
put("extended", signal.extended)
})
}
})
}
return JSONObject().put("format", "setflash-trends").put("version", 1).put("profiles", profiles).toString(2)
}
fun decode(text: String): Map<TrendProfile, List<TrendSignal>> {
require(text.toByteArray(Charsets.UTF_8).size <= MAX_FILE_BYTES) { "Файл настроек больше 1 МБ" }
val root = JSONObject(text.removePrefix("\uFEFF"))
require(root.strictString("format") == "setflash-trends" && root.strictInt("version") == 1) {
"Неизвестный формат или версия настроек графиков"
}
val profiles = root.getJSONObject("profiles")
val result = profiles.keys().asSequence().associate { key ->
val profile = TrendProfile.entries.firstOrNull { it.name == key }
?: error("Неизвестный протокол: $key")
val array = profiles.getJSONArray(key)
require(array.length() <= TrendUiState.MAX_SIGNALS) { "Не более 64 сигналов на протокол" }
profile to (0 until array.length()).map { index ->
val item = array.getJSONObject(index)
TrendSignal(
id = item.strictString("id"), order = item.strictInt("order"), name = item.strictString("name"),
source = TrendSource.valueOf(item.strictString("source")), address = item.strictString("address"),
color = item.strictString("color"), visible = item.strictBoolean("visible"),
valueType = TrendValueType.valueOf(item.strictString("valueType")),
deviceType = item.strictInt("deviceType"), device = item.strictInt("device"),
byteOffset = item.strictInt("byteOffset"),
multiplier = if (item.has("multiplier")) item.strictDouble("multiplier") else 1.0,
iq = if (item.has("iq")) item.strictInt("iq") else 0,
extended = item.strictBoolean("extended"),
)
}
}
validate(result)
return result
}
private fun JSONObject.strictString(key: String): String =
get(key).let { require(it is String) { "$key должен быть строкой" }; it }
private fun JSONObject.strictBoolean(key: String): Boolean =
get(key).let { require(it is Boolean) { "$key должен быть логическим флагом" }; it }
private fun JSONObject.strictInt(key: String): Int {
val value = get(key)
require(value is Int || value is Long) { "$key должен быть целым числом" }
val number = (value as Number).toLong()
require(number in Int.MIN_VALUE..Int.MAX_VALUE) { "$key вне диапазона" }
return number.toInt()
}
private fun JSONObject.strictDouble(key: String): Double {
val value = get(key)
require(value is Number) { "$key должен быть числом" }
return value.toDouble().also { require(it.isFinite()) { "$key должен быть конечным числом" } }
}
fun validate(settings: Map<TrendProfile, List<TrendSignal>>) {
val allIds = settings.values.flatten().map { it.id }
require(allIds.distinct().size == allIds.size) { "Идентификаторы трендов не должны повторяться" }
settings.forEach { (profile, signals) ->
require(signals.size <= TrendUiState.MAX_SIGNALS) { "Не более 64 сигналов на протокол" }
require(signals.map { it.order }.distinct().size == signals.size) { "Номера трендов одного протокола должны различаться" }
signals.forEach { signal ->
require(signal.validationError(profile) == null) { "${signal.name}: ${signal.validationError(profile)}" }
}
}
}
}

View File

@@ -0,0 +1,67 @@
package ru.setcorp.setprotocol.trends
import ru.setcorp.setprotocol.NativeSetProtocol
/** Enum order is the stable set_spectrum.h ABI; titles belong to this GUI adapter. */
enum class SpectrumWindow(val title: String) {
RECT("Прямоугольное"), HANN("Hann"), HAMMING("Hamming"), BLACKMAN("Blackman"), FLATTOP("Flat Top"),
}
enum class SpectrumFilter(val title: String) {
NONE("Без фильтра"), LOW_PASS("ФНЧ"), HIGH_PASS("ФВЧ"), BAND_PASS("Полосовой"), NOTCH("Режекторный"),
}
data class SpectrumOptions(
val window: SpectrumWindow = SpectrumWindow.HANN,
val filter: SpectrumFilter = SpectrumFilter.NONE,
val lowHz: Double = 10.0,
val highHz: Double = 100.0,
val removeMean: Boolean = true,
val maxSize: Int = 4096,
) {
fun validate() {
require(maxSize in 16..16384 && maxSize and (maxSize - 1) == 0) { "Размер FFT: степень 2 от 16 до 16384" }
if (filter in listOf(SpectrumFilter.HIGH_PASS, SpectrumFilter.BAND_PASS, SpectrumFilter.NOTCH))
require(lowHz.isFinite() && lowHz > 0) { "Частота должна быть больше 0" }
if (filter in listOf(SpectrumFilter.LOW_PASS, SpectrumFilter.BAND_PASS))
require(highHz.isFinite() && highHz > 0) { "Частота должна быть больше 0" }
if (filter == SpectrumFilter.BAND_PASS) require(lowHz < highHz) { "Нижняя частота должна быть меньше верхней" }
}
}
data class TrendSpectrum(
val size: Int = 0, val sampleRate: Double = 0.0, val jitter: Double = 0.0,
val amplitudes: List<Double> = emptyList(), val error: String? = null,
) {
val binHz: Double get() = if (size > 0) sampleRate / size else 0.0
}
/** Math is implemented once in C and used unchanged by JNI and ctypes. */
object SpectrumAnalyzer {
fun analyze(points: List<TrendPoint>, options: SpectrumOptions): TrendSpectrum {
options.validate()
if (points.size < 16) return TrendSpectrum(error = "Нужно минимум 16 точек")
if (!NativeSetProtocol.available) return TrendSpectrum(error = "Модуль FFT недоступен")
val count = minOf(points.size, options.maxSize)
val start = points.size - count
val origin = points[start]
val times = DoubleArray(count) { points[start + it].nanosSince(origin) / 1_000_000_000.0 }
val values = DoubleArray(count) { points[start + it].value }
val output = NativeSetProtocol.nativeSpectrum(times, values, options.maxSize,
options.window.ordinal, options.filter.ordinal, options.lowHz, options.highHz, options.removeMean)
?: return TrendSpectrum(error = "Не удалось вычислить FFT")
return decode(output)
}
internal fun decode(output: DoubleArray): TrendSpectrum {
require(output.size >= 4)
val error = when (output[0].toInt()) {
0 -> null
1 -> "Нужно минимум 16 точек"
3 -> "Разрывы или неравномерные метки времени: выберите другой участок"
4 -> "Частота фильтра должна быть ниже Fs/2 = %.3f Гц".format(java.util.Locale.ROOT, output[2] / 2)
5 -> "Недостаточно памяти для FFT"
else -> "Некорректные данные FFT"
}
return TrendSpectrum(output[1].toInt(), output[2], output[3],
if (error == null) output.drop(4) else emptyList(), error)
}
}

View File

@@ -0,0 +1,22 @@
#include <jni.h>
#include "set_plot.h"
JNIEXPORT jdoubleArray JNICALL
Java_ru_setcorp_setprotocol_trends_NativePlot_evaluate(JNIEnv *env, jobject self,
jint operation, jdoubleArray input) {
double values[10], result[4];
jsize n;
size_t count;
jdoubleArray output;
(void)self;
if (!input) return NULL;
n = (*env)->GetArrayLength(env, input);
if (n < 0 || n > 10) return NULL;
(*env)->GetDoubleArrayRegion(env, input, 0, n, values);
if ((*env)->ExceptionCheck(env)) return NULL;
count = set_plot_eval((uint32_t)operation, values, (size_t)n, result, 4);
if (!count) return NULL;
output = (*env)->NewDoubleArray(env, (jsize)count);
if (output) (*env)->SetDoubleArrayRegion(env, output, 0, (jsize)count, result);
return output;
}

View File

@@ -4,6 +4,95 @@
#include <string.h>
#include "setprotocol_abi.h"
#include "set_trends.h"
#include "set_spectrum.h"
JNIEXPORT jdoubleArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrum(
JNIEnv *env, jobject self, jdoubleArray times, jdoubleArray values, jint max_size,
jint window, jint filter, jdouble low_hz, jdouble high_hz, jboolean remove_mean)
{
(void)self;
if (times == NULL || values == NULL || max_size < 16 || max_size > (jint)SET_SPECTRUM_MAX ||
(max_size & (max_size - 1)) != 0) return NULL;
jsize count = (*env)->GetArrayLength(env, times);
if ((*env)->GetArrayLength(env, values) != count) return NULL;
/* Only the analyzed tail crosses JNI; allocations cannot grow with file length. */
jsize used = count < max_size ? count : max_size;
size_t bins = (size_t)max_size / 2 + 1;
double *buffer = (double *)calloc((size_t)used * 2 + bins + 4, sizeof(double));
if (buffer == NULL) return NULL;
double *t = buffer, *v = t + used, *out = v + used;
(*env)->GetDoubleArrayRegion(env, times, count - used, used, t);
(*env)->GetDoubleArrayRegion(env, values, count - used, used, v);
if ((*env)->ExceptionCheck(env)) { free(buffer); return NULL; }
int status = set_spectrum_analyze(t, v, (size_t)used, (size_t)max_size,
window, filter, low_hz, high_hz, remove_mean, out + 4, bins, out + 1);
out[0] = (double)status;
jsize length = status == SET_SPECTRUM_OK ? (jsize)out[1] / 2 + 5 : 4;
jdoubleArray result = (*env)->NewDoubleArray(env, length);
if (result != NULL) (*env)->SetDoubleArrayRegion(env, result, 0, length, out);
free(buffer);
return result;
}
JNIEXPORT jint JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue(
JNIEnv *env, jobject self, jint source, jlong address, jint device_type,
jint device, jint byte_offset, jboolean extended, jboolean is_signed,
jlong can_id, jint flags, jbyteArray input)
{
(void)self;
jsize size = (*env)->GetArrayLength(env, input);
if (size < 2 || size > 8) return SET_TREND_NO_VALUE;
jbyte data[8];
(*env)->GetByteArrayRegion(env, input, 0, size, data);
return set_trend_can_value((uint8_t)source, (uint32_t)address,
(uint8_t)device_type, (uint8_t)device, (uint8_t)byte_offset,
(uint8_t)extended, (uint8_t)is_signed, (uint32_t)can_id,
(uint8_t)flags, (const uint8_t *)data, (size_t)size);
}
JNIEXPORT jbyteArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest(
JNIEnv *env, jobject self, jint period, jintArray input)
{
(void)self;
jsize count = (*env)->GetArrayLength(env, input);
if (count > (jsize)SET_TREND_WATCH_MAX || period < 0 || period > 65535) return NULL;
jint values[SET_TREND_WATCH_MAX];
uint16_t addresses[SET_TREND_WATCH_MAX];
uint8_t output[4U + 2U * SET_TREND_WATCH_MAX];
(*env)->GetIntArrayRegion(env, input, 0, count, values);
for (jsize i = 0; i < count; ++i) {
if (values[i] < 0 || values[i] > 65535) return NULL;
addresses[i] = (uint16_t)values[i];
}
size_t size = set_trend_watch_request((uint16_t)period, addresses, (size_t)count, output, sizeof(output));
if (size == 0U) return NULL;
jbyteArray result = (*env)->NewByteArray(env, (jsize)size);
if (result != NULL) (*env)->SetByteArrayRegion(env, result, 0, (jsize)size, (const jbyte *)output);
return result;
}
JNIEXPORT jintArray JNICALL
Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues(
JNIEnv *env, jobject self, jbyteArray input)
{
(void)self;
jsize size = (*env)->GetArrayLength(env, input);
if (size < 6 || size > (jsize)(6U + 2U * SET_TREND_WATCH_MAX)) return NULL;
jbyte payload[6U + 2U * SET_TREND_WATCH_MAX];
uint16_t words[SET_TREND_WATCH_MAX];
jint values[SET_TREND_WATCH_MAX];
(*env)->GetByteArrayRegion(env, input, 0, size, payload);
int count = set_trend_watch_values((const uint8_t *)payload, (size_t)size, words, SET_TREND_WATCH_MAX);
if (count < 0) return NULL;
for (int i = 0; i < count; ++i) values[i] = words[i];
jintArray result = (*env)->NewIntArray(env, count);
if (result != NULL) (*env)->SetIntArrayRegion(env, result, 0, count, values);
return result;
}
typedef struct {
uint8_t *storage;

View File

@@ -0,0 +1,25 @@
package ru.setcorp.setprotocol.trends
import org.junit.Assert.*
import org.junit.Test
class GuiGasWatchTest {
@Test fun requestMatchesPythonAndCContract() {
assertArrayEquals(byteArrayOf(-24, 3, 2, 0, 0x34, 0x12, -1, -1), GuiGasWatch.request(1000, listOf(0x1234, 0xFFFF)))
assertArrayEquals(byteArrayOf(0, 0, 0, 0), GuiGasWatch.request(0, emptyList()))
assertTrue(runCatching { GuiGasWatch.request(1, (0..64).toList()) }.isFailure)
}
@Test fun partialAckMustNeverMislabelValues() {
GuiGasWatch.validateAck(byteArrayOf(-24, 3, 2, 0), 1000, 2)
assertTrue(runCatching { GuiGasWatch.validateAck(byteArrayOf(-24, 3, 1, 0), 1000, 2) }.isFailure)
assertTrue(runCatching { GuiGasWatch.validateAck(byteArrayOf(0, 0, 2, 0), 1000, 2) }.isFailure)
}
@Test fun valuesRequireExactLengthAndCount() {
val payload = byteArrayOf(1, 2, 3, 4, 2, 0, 0x34, 0x12, -1, -1)
assertEquals(listOf(0x1234, 65535), GuiGasWatch.values(payload, 2))
assertTrue(runCatching { GuiGasWatch.values(payload, 1) }.isFailure)
assertTrue(runCatching { GuiGasWatch.values(payload.dropLast(1).toByteArray(), 2) }.isFailure)
}
}

View File

@@ -0,0 +1,37 @@
package ru.setcorp.setprotocol.trends
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class PlotContractTest {
@Test fun jniUsesTheSameNumericContractAsPython() {
val text = javaClass.getResourceAsStream("/plot-v1.json")!!.bufferedReader().use { it.readText() }
val cases = JSONObject(text).getJSONArray("cases")
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val input = case.getJSONArray("input")
val result = runCatching { NativePlot.call(case.getInt("op"),
*DoubleArray(input.length()) { input.getDouble(it) }) }
if (case.isNull("output")) assertTrue(case.getString("name"), result.isFailure)
else {
val expected = case.getJSONArray("output")
assertArrayEquals(case.getString("name"), DoubleArray(expected.length()) { expected.getDouble(it) },
result.getOrThrow(), 1e-10)
}
}
}
@Test fun markerCoordinatesSurviveZoomAndDragUsesGestureStart() {
val full = PlotBounds(1000.0, 2000.0, -10.0, 10.0)
val markers = TrendMarkers().positioned(full)
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)
assertEquals(markers.a!! + 50, dragged.a!!, 1e-9)
assertEquals(markers.b, dragged.b)
assertEquals(TrendMarker.A, dragged.hit(0.0, 50.0, 500.0, 100.0, 20.0, zoomed))
val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0)
assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0)
}
}

View File

@@ -0,0 +1,39 @@
package ru.setcorp.setprotocol.trends
import org.junit.Assert.*
import org.junit.Test
class PlotViewportTest {
@Test fun zoomAnchorsToFingerAndAxesAreIndependent() {
val zoom = PlotViewport().transform(2.0, 1.0, focusX = 0.25)
assertEquals(0.125, zoom.x, 1e-12)
assertEquals(0.5, zoom.width, 1e-12)
assertEquals(1.0, zoom.height, 1e-12)
assertEquals(0.0, zoom.y, 1e-12)
}
@Test fun zoomAndPanClampToBoundsAndResetToFullView() {
val zoom = PlotViewport().transform(1e6, 1e6)
assertEquals(1.0 / 128, zoom.width, 1e-12)
val panned = zoom.transform(panX = 10000.0, panY = -10000.0)
assertEquals(0.0, panned.x, 1e-12)
assertEquals(1.0 - panned.height, panned.y, 1e-12)
assertEquals(PlotViewport(), panned.transform(1e-6, 1e-6))
assertEquals(zoom, zoom.transform(Double.NaN, 1.0))
assertEquals(zoom, zoom.transform(0.0, 1.0))
}
@Test fun nativeResponseAndErrorsAreDecodedWithoutInventingSpectrum() {
val result = SpectrumAnalyzer.decode(doubleArrayOf(0.0, 16.0, 100.0, 0.0) + DoubleArray(9) { 2.0 })
assertEquals(6.25, result.binHz, 0.0)
assertEquals(9, result.amplitudes.size)
val failed = SpectrumAnalyzer.decode(doubleArrayOf(4.0, 16.0, 100.0, 0.0))
assertTrue(failed.error!!.contains("50.000"))
assertTrue(failed.amplitudes.isEmpty())
}
@Test fun spectrumSettingsRejectInvalidBandsAndSizes() {
listOf(SpectrumOptions(maxSize = 30), SpectrumOptions(filter = SpectrumFilter.LOW_PASS, highHz = Double.NaN),
SpectrumOptions(filter = SpectrumFilter.BAND_PASS, lowHz = 100.0, highHz = 10.0)).forEach {
assertTrue(runCatching { it.validate() }.isFailure)
}
SpectrumOptions().validate()
}
}

View File

@@ -0,0 +1,87 @@
package ru.setcorp.setprotocol.trends
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class TrendSettingsJsonTest {
@Test fun readsTheSameFixtureAsDesktopPython() {
val text = requireNotNull(javaClass.getResourceAsStream("/trends-v1.json")).bufferedReader().use { it.readText() }
val decoded = TrendSettingsJson.decode(text)
assertEquals(5, decoded.values.sumOf { it.size })
assertEquals("Ток фазы А", decoded.getValue(TrendProfile.TMS2812).single().name)
assertEquals(TrendSource.SET_GAS, decoded.getValue(TrendProfile.SET_V1).first().source)
assertEquals(decoded, TrendSettingsJson.decode(TrendSettingsJson.encode(decoded)))
}
@Test fun rejectsCoercionOfBooleanFractionAndStringNumbers() {
val json = TrendSettingsJson.encode(settings)
listOf("1", 1.5, true).forEach { value ->
val root = JSONObject(json)
root.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).put("order", value)
expectInvalid { TrendSettingsJson.decode(root.toString()) }
}
expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("version", 1.5).toString()) }
}
private val signal = TrendSignal(id = "tms-1", name = "Ток \"фаза А\"", color = "#FF1234", visible = false)
private val settings = mapOf(TrendProfile.TMS2812 to listOf(signal))
private fun expectInvalid(block: () -> Unit) {
assertTrue("Invalid settings must fail", runCatching(block).isFailure)
}
@Test fun roundTripIncludesEveryFieldAndIndependentProtocols() {
val full = settings + mapOf(
TrendProfile.CAN_BRIDGE to listOf(TrendSignal(id = "gas", source = TrendSource.CAN_GAS, address = "0x0123", device = 3, deviceType = 2, valueType = TrendValueType.INT16)),
TrendProfile.BALZAM_CAN to listOf(TrendSignal(id = "raw", source = TrendSource.CAN_RAW, address = "0x321", extended = false, byteOffset = 6)),
TrendProfile.SET_V1 to listOf(TrendSignal(id = "sensor", source = TrendSource.SET_SENSOR, address = "28-01-02-03-04-05-06-07")),
)
assertEquals(full, TrendSettingsJson.decode(TrendSettingsJson.encode(full)))
}
@Test fun supportsEmptyConfigurationAndUtf8Bom() {
assertTrue(TrendSettingsJson.decode(TrendSettingsJson.encode(emptyMap())).isEmpty())
assertEquals(settings, TrendSettingsJson.decode("\uFEFF" + TrendSettingsJson.encode(settings)))
}
@Test fun encodingSortsByOrder() {
val unordered = mapOf(TrendProfile.TMS2812 to listOf(signal.copy(id = "b", order = 2), signal))
assertEquals(listOf(1, 2), TrendSettingsJson.decode(TrendSettingsJson.encode(unordered)).getValue(TrendProfile.TMS2812).map { it.order })
}
@Test fun rejectsUnknownVersionFormatProfileAndMissingFields() {
val json = TrendSettingsJson.encode(settings)
expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("version", 2).toString()) }
expectInvalid { TrendSettingsJson.decode(JSONObject(json).put("format", "other").toString()) }
expectInvalid { TrendSettingsJson.decode(json.replace("TMS2812", "UNKNOWN")) }
val missing = JSONObject(json)
missing.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).remove("address")
expectInvalid { TrendSettingsJson.decode(missing.toString()) }
}
@Test fun rejectsDuplicateOrderAndCrossProtocolIds() {
expectInvalid { TrendSettingsJson.encode(mapOf(TrendProfile.TMS2812 to listOf(signal, signal.copy(id = "another")))) }
expectInvalid { TrendSettingsJson.encode(settings + (TrendProfile.CAN_BRIDGE to listOf(signal.copy(source = TrendSource.CAN_GAS)))) }
}
@Test fun importRunsValidationBeforeReturningAnySettings() {
val validJson = TrendSettingsJson.encode(settings)
listOf("address" to "0x100000000", "color" to "#BAD", "source" to "CAN_GAS", "name" to "").forEach { (field, value) ->
val bad = JSONObject(validJson)
bad.getJSONObject("profiles").getJSONArray("TMS2812").getJSONObject(0).put(field, value)
expectInvalid { TrendSettingsJson.decode(bad.toString()) }
}
assertEquals(settings, TrendSettingsJson.decode(validJson))
}
@Test fun enforcesSignalCountAndFileSizeLimits() {
expectInvalid {
TrendSettingsJson.encode(mapOf(TrendProfile.TMS2812 to (1..65).map { signal.copy(id = "$it", order = it) }))
}
expectInvalid { TrendSettingsJson.decode(" ".repeat(TrendSettingsJson.MAX_FILE_BYTES + 1)) }
}
@Test fun malformedJsonIsNotAccepted() {
listOf("", "not JSON", "[]", "{\"format\":").forEach { text -> expectInvalid { TrendSettingsJson.decode(text) } }
}
}