Добавить общие графики, декодер KONOR и порт STM32 bxCAN
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
22
c/set-protocol/ports/android/set_plot_jni.c
Normal file
22
c/set-protocol/ports/android/set_plot_jni.c
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user