diff --git a/README.md b/README.md index c6268bd..55dbe63 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ templates/ c/ библиотеки на C99: заголовок, реализация, README, где есть — порт и тесты python/ модули на чистом Python 3.9+, только stdlib + tools/ общие инструменты сборки и выпуска прошивок ``` Пошаговая раскладка нового проекта и выбор портов для STM32F103, STM32G431 @@ -48,6 +49,12 @@ templates/ | [`python/protocan`](python/protocan) | разбор ProtoCAN, транспортный кадр моста, кадр SETGUI, кодеки каталога | stdlib, Python 3.9+ | | [`python/protocan/trends.py`](python/protocan/trends.py) | общие настройки графиков, ограниченная история, ctypes-декодер GAS/raw CAN | stdlib, опционально SETProtocol DLL/SO | +### Инструменты + +| Инструмент | Что делает | +|---|---| +| [`tools/firmware-publish`](tools/firmware-publish) | единый BAT и конфигурации для проверки и публикации `.hex` Keil / `.bin` CCS 12 в каталоге SETGUI | + Общие тренды для Android GUI и SETGUI: [формат JSON, C99-ядро и адаптеры](c/set-protocol/docs/GUI_TRENDS.md). Общие масштабирование и маркеры: [C99, JNI и Python/Qt](c/set-protocol/docs/GUI_PLOT.md). diff --git a/c/set-protocol/docs/GUI_PLOT.md b/c/set-protocol/docs/GUI_PLOT.md index d57233b..52234d8 100644 --- a/c/set-protocol/docs/GUI_PLOT.md +++ b/c/set-protocol/docs/GUI_PLOT.md @@ -3,7 +3,8 @@ Численная логика находится в `include/set_plot.h` и `src/set_plot.c`. Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL -для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`. +для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`; +новые операции добавляются без изменения существующих значений и сигнатур. | Общее в templates | Адаптер приложения | |---|---| @@ -13,6 +14,7 @@ | Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал | | Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения | | Разность B−A, D−C и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала | +| Проверка абсолютных границ X/Y для фиксации осей | Диалог ввода и хранение отдельных границ времени/FFT | | Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника | Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля; @@ -59,8 +61,9 @@ SETGUI: `ui/plot_interaction.py` адаптирует общий модуль к используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу, а маркеры уровня Y относятся к аналоговому полю. -Расчёт FFT не является частью этого модуля. Android вычисляет спектр через -`set_spectrum.c`; вкладка спектра SETGUI получает готовые уровни в дБмВ от +Расчёт FFT не является частью этого модуля. Android и SETGUI вычисляют спектр через +`set_spectrum.c`; там же находится общий поиск доминирующего узкополосного пика, +а трёхсекундный таймер его отображения остаётся состоянием GUI. Вкладка спектра SETGUI получает готовые уровни в дБмВ от прибора. Общая интерактивная часть не меняет эти данные или единицы. ## Проверка и сборка diff --git a/c/set-protocol/docs/GUI_TRENDS.md b/c/set-protocol/docs/GUI_TRENDS.md index 6a3819b..2898b89 100644 --- a/c/set-protocol/docs/GUI_TRENDS.md +++ b/c/set-protocol/docs/GUI_TRENDS.md @@ -12,7 +12,7 @@ | `include/set_trends.h`, `src/set_trends.c` | C99: фильтрация GAS/raw CAN, signed/unsigned word, payload подписки SET GUI; использует `pcan_id` и ABI export macro | | `ports/android/kotlin/ru/setcorp/setprotocol/trends/` | Модель, валидация JSON, ограниченная история, GAS_WATCH; JVM + org.json, без Android/Compose | | `ports/android/setprotocol_jni.c` | Только преобразование JNI-аргументов | -| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends`; Python 3.9+, stdlib, без Qt | +| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends` (word/CAN/GAS request/ack/data); Python 3.9+, stdlib, без Qt | | `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками | ## Формат файла @@ -44,6 +44,8 @@ UTF-8 JSON: `format = "setflash-trends"`, `version = 1`, `profiles` — слов CAN — пассивный приём, без записи GAS и автоматической отправки запросов. GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены. +Полный `GAS_WATCH_DATA`, включая 32-битную метку прибора, разбирается функцией +`set_trend_watch_decode`; UI не знает смещений полей и endian. SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь список: при частичном принятии нельзя определить пропущенные адреса, поэтому строить график по смещённым индексам запрещено. При паузе порт приложения diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index 2ae6847..8e0b006 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -17,10 +17,11 @@ enum set_plot_operation { 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_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ + SET_PLOT_DB_DELTA = 7, /* A,B -> 20*log10(abs(B/A)); zero is invalid */ + SET_PLOT_LIMITS = 8 /* xMin,xMax,yMin,yMax -> validated unchanged limits */ }; -/** Version of this plot ABI, independently of the transport ABI. */ +/** Version of this additive plot ABI, independently of the transport ABI. */ PCAN_ABI_API uint32_t set_plot_abi_version(void); /** Evaluates one operation. Returns output count, or 0 for invalid arguments. * TRANSFORM rejects malformed viewports; invalid gesture values return the diff --git a/c/set-protocol/include/set_spectrum.h b/c/set-protocol/include/set_spectrum.h index 554c1a5..8747924 100644 --- a/c/set-protocol/include/set_spectrum.h +++ b/c/set-protocol/include/set_spectrum.h @@ -28,6 +28,15 @@ PCAN_ABI_API int set_spectrum_analyze(const double *times, const double *values, size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean, double *amplitudes, size_t capacity, double *meta); +/** Find the strongest non-DC local maximum above both the absolute floor and + * relative_threshold * median(non-DC amplitudes). The caller supplies scratch + * storage of at least count-1 doubles. peak = {frequency_hz, amplitude}. + * Returns 1 when found, 0 when no narrow-band peak exists, -1 on invalid input. + */ +PCAN_ABI_API int set_spectrum_dominant_peak(const double *amplitudes, size_t count, + double bin_hz, double relative_threshold, double absolute_floor, + double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity); + #ifdef __cplusplus } #endif diff --git a/c/set-protocol/include/set_trends.h b/c/set-protocol/include/set_trends.h index 1693d4a..b187f0c 100644 --- a/c/set-protocol/include/set_trends.h +++ b/c/set-protocol/include/set_trends.h @@ -48,6 +48,13 @@ PCAN_ABI_API int set_trend_watch_ack( PCAN_ABI_API int set_trend_watch_values( const uint8_t *payload, size_t size, uint16_t *words, size_t capacity); +/** Decode the complete GAS_WATCH_DATA payload including its device timestamp. + * This is the preferred ABI for GUI ports; the older values-only symbol remains + * available for binary compatibility. + */ +PCAN_ABI_API int set_trend_watch_decode(const uint8_t *payload, size_t size, + uint32_t *timestamp_ms, uint16_t *words, size_t capacity); + #ifdef __cplusplus } #endif 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 4e7b830..cc08f61 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 @@ -16,12 +16,18 @@ object NativeSetProtocol { /** {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? + /** {frequencyHz, amplitude}, empty when no narrow-band peak is present. */ + external fun nativeSpectrumPeak(amplitudes: DoubleArray, binHz: Double, + relativeThreshold: Double, absoluteFloor: Double): 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 nativeTrendWatchAck(input: ByteArray, period: Int, count: Int): Boolean external fun nativeTrendWatchValues(input: ByteArray): IntArray? + /** {unsigned timestampMs, word0, ...}. */ + external fun nativeTrendWatchDecode(input: ByteArray): LongArray? external fun nativePackId( priority: Int, route: Int, diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt index 5a814ad..924ed9c 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt @@ -10,37 +10,28 @@ object GuiGasWatch { fun request(periodMs: Int, addresses: List): 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) } - } + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray())) } fun validateAck(payload: ByteArray, periodMs: Int, count: Int) { - require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == count) { + require(NativeSetProtocol.available && NativeSetProtocol.nativeTrendWatchAck(payload, periodMs, count)) { "Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно" } } fun values(payload: ByteArray, expectedCount: Int): List { - 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) } - } + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + val values = requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList() 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() + data class Sample(val timestampMs: Long, val values: List) + fun decode(payload: ByteArray, expectedCount: Int): Sample { + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + val decoded = requireNotNull(NativeSetProtocol.nativeTrendWatchDecode(payload)) { "Повреждён GAS_WATCH_DATA" } + require(decoded.size == expectedCount + 1) { "Число значений GAS не соответствует подписке" } + return Sample(decoded[0], decoded.drop(1).map(Long::toInt)) } } 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 92f264b..1ce5a53 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 @@ -32,3 +32,10 @@ fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, ran 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() + +data class PlotLimits(val xMin: Double, val xMax: Double, val yMin: Double, val yMax: Double) { + fun validated(): PlotLimits { + val values = NativePlot.call(8, xMin, xMax, yMin, yMax) + return PlotLimits(values[0], values[1], values[2], values[3]) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt index 5f02bd3..cf8679c 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt @@ -34,6 +34,8 @@ data class TrendSpectrum( val binHz: Double get() = if (size > 0) sampleRate / size else 0.0 } +data class SpectrumPeak(val frequencyHz: Double, val amplitude: Double) + /** Math is implemented once in C and used unchanged by JNI and ctypes. */ object SpectrumAnalyzer { fun analyze(points: List, options: SpectrumOptions): TrendSpectrum { @@ -64,4 +66,12 @@ object SpectrumAnalyzer { return TrendSpectrum(output[1].toInt(), output[2], output[3], if (error == null) output.drop(4) else emptyList(), error) } + + fun dominantPeak(spectrum: TrendSpectrum, relativeThreshold: Double = 3.0, + absoluteFloor: Double = 1e-6): SpectrumPeak? { + if (spectrum.error != null || spectrum.size <= 0 || spectrum.amplitudes.size < 3) return null + val result = NativeSetProtocol.nativeSpectrumPeak(spectrum.amplitudes.toDoubleArray(), + spectrum.binHz, relativeThreshold, absoluteFloor) ?: return null + return result.takeIf { it.size == 2 }?.let { SpectrumPeak(it[0], it[1]) } + } } diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index d95566a..c96b2ce 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -367,6 +367,30 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue( (uint8_t)flags, (const uint8_t *)data, (size_t)size); } +JNIEXPORT jdoubleArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrumPeak( + JNIEnv *env, jobject self, jdoubleArray input, jdouble bin_hz, + jdouble relative_threshold, jdouble absolute_floor) +{ + (void)self; + jsize count = input == NULL ? 0 : (*env)->GetArrayLength(env, input); + if (count < 3 || count > (jsize)(SET_SPECTRUM_MAX / 2U + 1U)) return NULL; + double *buffer = (double *)malloc(sizeof(double) * (size_t)(count * 2 - 1)); + if (buffer == NULL) return NULL; + double *amplitudes = buffer, *scratch = buffer + count, peak[2]; + (*env)->GetDoubleArrayRegion(env, input, 0, count, amplitudes); + int status = (*env)->ExceptionCheck(env) ? -1 : set_spectrum_dominant_peak( + amplitudes, (size_t)count, bin_hz, relative_threshold, absolute_floor, + scratch, (size_t)count - 1U, peak, 2U); + jdoubleArray result = NULL; + if (status >= 0) { + result = (*env)->NewDoubleArray(env, status == 1 ? 2 : 0); + if (result != NULL && status == 1) (*env)->SetDoubleArrayRegion(env, result, 0, 2, peak); + } + free(buffer); + return result; +} + JNIEXPORT jbyteArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( JNIEnv *env, jobject self, jint period, jintArray input) @@ -389,6 +413,20 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( return result; } +JNIEXPORT jboolean JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchAck( + JNIEnv *env, jobject self, jbyteArray input, jint period, jint count) +{ + (void)self; + if (input == NULL || period < 0 || period > 65535 || count < 0) return JNI_FALSE; + jsize size = (*env)->GetArrayLength(env, input); + if (size != 4) return JNI_FALSE; + jbyte payload[4]; + (*env)->GetByteArrayRegion(env, input, 0, size, payload); + return set_trend_watch_ack((const uint8_t *)payload, (size_t)size, + (uint16_t)period, (size_t)count) ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT jintArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( JNIEnv *env, jobject self, jbyteArray input) @@ -408,6 +446,28 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( return result; } +JNIEXPORT jlongArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchDecode( + JNIEnv *env, jobject self, jbyteArray input) +{ + (void)self; + jsize size = input == NULL ? 0 : (*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]; + uint32_t timestamp = 0U; + jlong values[1U + SET_TREND_WATCH_MAX]; + (*env)->GetByteArrayRegion(env, input, 0, size, payload); + int count = set_trend_watch_decode((const uint8_t *)payload, (size_t)size, + ×tamp, words, SET_TREND_WATCH_MAX); + if (count < 0) return NULL; + values[0] = (jlong)timestamp; + for (int i = 0; i < count; ++i) values[i + 1] = (jlong)words[i]; + jlongArray result = (*env)->NewLongArray(env, count + 1); + if (result != NULL) (*env)->SetLongArrayRegion(env, result, 0, count + 1, values); + return result; +} + typedef struct { uint8_t *storage; size_t storage_size; diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt index d9918d2..af6962b 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt @@ -36,4 +36,12 @@ class PlotViewportTest { } SpectrumOptions().validate() } + @Test fun absoluteLimitsAndDominantPeakUseNativeCore() { + assertEquals(PlotLimits(0.0, 500.0, -1.0, 1.0), + PlotLimits(0.0, 500.0, -1.0, 1.0).validated()) + assertTrue(runCatching { PlotLimits(1.0, 1.0, -1.0, 1.0).validated() }.isFailure) + val spectrum = TrendSpectrum(12, 60.0, 0.0, + listOf(10.0, .01, .02, .8, .03, .4, .02)) + assertEquals(15.0, SpectrumAnalyzer.dominantPeak(spectrum)!!.frequencyHz, 0.0) + } } diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 66ed13c..ab9d99f 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -11,10 +11,10 @@ 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, 2}; + static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2, 4}; double span, fraction; - if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] || - cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; + if (op > SET_PLOT_LIMITS || !v || !out || n != sizes[op] || + cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 || @@ -69,6 +69,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t if (v[0] == 0 || v[1] == 0) return 0; out[0] = 20 * log10(fabs(v[1] / v[0])); break; + case SET_PLOT_LIMITS: + if (v[0] >= v[1] || v[2] >= v[3]) return 0; + out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3]; + return 4; default: return 0; } return isfinite(out[0]) ? 1 : 0; diff --git a/c/set-protocol/src/set_spectrum.c b/c/set-protocol/src/set_spectrum.c index a73003e..259f0ec 100644 --- a/c/set-protocol/src/set_spectrum.c +++ b/c/set-protocol/src/set_spectrum.c @@ -4,6 +4,13 @@ #define PI 3.14159265358979323846 +static int compare_double(const void *left, const void *right) +{ + const double a = *(const double *)left; + const double b = *(const double *)right; + return (a > b) - (a < b); +} + static double window_value(int window, size_t index, size_t n) { double phase = 2.0 * PI * (double)index / (double)n; @@ -124,3 +131,35 @@ int set_spectrum_analyze(const double *times, const double *values, size_t count free(scratch); return result; } + +int set_spectrum_dominant_peak(const double *amplitudes, size_t count, + double bin_hz, double relative_threshold, double absolute_floor, + double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity) +{ + size_t i, usable = 0U, peak_bin = 0U; + double peak_amplitude = -1.0, median, threshold; + if (amplitudes == NULL || scratch == NULL || peak == NULL || count < 3U || + scratch_capacity < count - 1U || peak_capacity < 2U || !isfinite(bin_hz) || + bin_hz <= 0.0 || !isfinite(relative_threshold) || relative_threshold <= 0.0 || + !isfinite(absolute_floor) || absolute_floor < 0.0) return -1; + for (i = 1U; i < count; ++i) { + if (isfinite(amplitudes[i]) && amplitudes[i] >= 0.0) + scratch[usable++] = amplitudes[i]; + } + if (usable < 2U) return 0; + qsort(scratch, usable, sizeof(double), compare_double); + median = scratch[usable / 2U]; + for (i = 1U; i + 1U < count; ++i) { + const double value = amplitudes[i]; + if (isfinite(value) && isfinite(amplitudes[i - 1U]) && isfinite(amplitudes[i + 1U]) && + value >= amplitudes[i - 1U] && value > amplitudes[i + 1U] && value > peak_amplitude) { + peak_bin = i; + peak_amplitude = value; + } + } + threshold = fmax(absolute_floor, median * relative_threshold); + if (peak_bin == 0U || peak_amplitude < threshold) return 0; + peak[0] = (double)peak_bin * bin_hz; + peak[1] = peak_amplitude; + return 1; +} diff --git a/c/set-protocol/src/set_trends.c b/c/set-protocol/src/set_trends.c index c1c2203..4c3da43 100644 --- a/c/set-protocol/src/set_trends.c +++ b/c/set-protocol/src/set_trends.c @@ -6,6 +6,12 @@ static uint16_t get16(const uint8_t *p) return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); } +static uint32_t get32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + static void put16(uint8_t *p, uint16_t value) { p[0] = (uint8_t)value; @@ -63,6 +69,12 @@ int set_trend_watch_ack(const uint8_t *payload, size_t size, uint16_t period_ms, } int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity) +{ + return set_trend_watch_decode(payload, size, NULL, words, capacity); +} + +int set_trend_watch_decode(const uint8_t *payload, size_t size, uint32_t *timestamp_ms, + uint16_t *words, size_t capacity) { size_t i, count; if (payload == NULL || size < 6U) return -1; @@ -70,5 +82,6 @@ int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, if (count > SET_TREND_WATCH_MAX || size != 6U + count * 2U || count > capacity || (count > 0U && words == NULL)) return -1; for (i = 0U; i < count; ++i) words[i] = get16(payload + 6U + i * 2U); + if (timestamp_ms != NULL) *timestamp_ms = get32(payload); return (int)count; } diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index 1e8715d..a06e11f 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -23,6 +23,8 @@ {"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":"absolute_limits","op":8,"input":[0,500,-2,2],"output":[0,500,-2,2]}, + {"name":"reversed_x_limits","op":8,"input":[1,1,-2,2],"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/c/set-protocol/tests/test_plot.c b/c/set-protocol/tests/test_plot.c index e606956..640621c 100644 --- a/c/set-protocol/tests/test_plot.c +++ b/c/set-protocol/tests/test_plot.c @@ -26,6 +26,13 @@ int main(void) { assert(output[0] == y[0]); assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1); assert(output[0] == -4); + { + const double limits[] = {0, 500, -2, 2}; + const double reversed[] = {1, 1, -2, 2}; + assert(set_plot_eval(SET_PLOT_LIMITS, limits, 4, output, 4) == 4); + assert(output[0] == 0 && output[1] == 500 && output[2] == -2 && output[3] == 2); + assert(set_plot_eval(SET_PLOT_LIMITS, reversed, 4, output, 4) == 0); + } puts("shared plot: OK"); return 0; } diff --git a/c/set-protocol/tests/test_spectrum.c b/c/set-protocol/tests/test_spectrum.c index 0de2e94..64a36b7 100644 --- a/c/set-protocol/tests/test_spectrum.c +++ b/c/set-protocol/tests/test_spectrum.c @@ -17,6 +17,15 @@ int main(void) assert(set_spectrum_analyze(times, values, 32, 32, 0, SET_FILTER_LOW_PASS, 0, 16, 1, output, 17, meta) == SET_SPECTRUM_CUTOFF); times[12] = times[11]; assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_TIMING); + { + const double amplitudes[] = {10.0, .01, .02, .8, .03, .4, .02}; + const double noise[] = {0, .10, .12, .11, .09, .10}; + double scratch[6], peak[2]; + assert(set_spectrum_dominant_peak(amplitudes, 7, 5, 3, 1e-6, scratch, 6, peak, 2) == 1); + assert(peak[0] == 15 && peak[1] == .8); + assert(set_spectrum_dominant_peak(noise, 6, 1, 3, 1e-6, scratch, 6, peak, 2) == 0); + assert(set_spectrum_dominant_peak(amplitudes, 7, 0, 3, 1e-6, scratch, 6, peak, 2) == -1); + } puts("shared spectrum: OK"); return 0; } diff --git a/c/set-protocol/tests/test_trends.c b/c/set-protocol/tests/test_trends.c index 97f10ee..f357bd2 100644 --- a/c/set-protocol/tests/test_trends.c +++ b/c/set-protocol/tests/test_trends.c @@ -34,8 +34,11 @@ int main(void) assert(!set_trend_watch_ack(expected, 4, 1000, 3)); const uint8_t packet[] = {1, 2, 3, 4, 2, 0, 52, 18, 255, 255}; uint16_t words[64]; + uint32_t timestamp = 0; assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2); assert(words[0] == 0x1234 && words[1] == 0xFFFF); + assert(set_trend_watch_decode(packet, sizeof(packet), ×tamp, words, 64) == 2); + assert(timestamp == 0x04030201UL); assert(set_trend_watch_values(packet, sizeof(packet) - 1, words, 64) == -1); assert(set_trend_watch_values(packet, sizeof(packet), words, 1) == -1); puts("Shared trend tests passed"); diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 67248f5..26bfeaf 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -67,6 +67,10 @@ class PlotMath: except ValueError: return None + def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds": + """Validate absolute axis limits in the shared core.""" + return Bounds(*self.call(8, left, right, bottom, top)) + @dataclass(frozen=True) class Viewport: @@ -92,6 +96,9 @@ class Bounds: bottom: float top: float + def validated(self, core: PlotMath) -> "Bounds": + return core.limits(self.left, self.right, self.bottom, self.top) + def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float: return core.call(2, value, self.bottom if horizontal else self.left, self.top if horizontal else self.right, int(horizontal))[0] diff --git a/python/protocan/spectrum.py b/python/protocan/spectrum.py index 1ce87ec..07b1eb5 100644 --- a/python/protocan/spectrum.py +++ b/python/protocan/spectrum.py @@ -37,6 +37,12 @@ class Spectrum: return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes))) +@dataclass(frozen=True) +class SpectrumPeak: + frequency_hz: float + amplitude: float + + class NativeSpectrum: def __init__(self, library: ctypes.CDLL): self.lib = library @@ -46,6 +52,11 @@ class NativeSpectrum: ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int, pointer, ctypes.c_size_t, pointer] self._analyze.restype = ctypes.c_int + self._peak = library.set_spectrum_dominant_peak + self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double, + ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t, + pointer, ctypes.c_size_t] + self._peak.restype = ctypes.c_int def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE, low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum: @@ -69,3 +80,15 @@ class NativeSpectrum: raise ValueError(message.get(status, "FFT failed")) n = int(meta[0]) return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1])) + + def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0, + absolute_floor: float = 1e-6) -> SpectrumPeak | None: + amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes) + scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))() + output = (ctypes.c_double * 2)() + status = self._peak(amplitudes, len(spectrum.amplitudes), + spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor, + scratch, len(scratch), output, 2) + if status < 0: + raise ValueError("Invalid spectrum peak input") + return SpectrumPeak(output[0], output[1]) if status else None diff --git a/python/protocan/trends.py b/python/protocan/trends.py index e047e1b..71b6f38 100644 --- a/python/protocan/trends.py +++ b/python/protocan/trends.py @@ -54,6 +54,8 @@ class TrendSignal: deviceType: int = 7 device: int = 13 byteOffset: int = 0 + multiplier: float = 1.0 + iq: int = 0 extended: bool = True @classmethod @@ -72,9 +74,11 @@ class TrendSignal: for field in ("id", "name", "source", "address", "color", "valueType"): if type(getattr(self, field)) is not str: raise ValueError(f"{field} must be a string") - for field in ("order", "deviceType", "device", "byteOffset"): + for field in ("order", "deviceType", "device", "byteOffset", "iq"): if type(getattr(self, field)) is not int: raise ValueError(f"{field} must be an integer") + if type(self.multiplier) not in (int, float) or not math.isfinite(self.multiplier): + raise ValueError("multiplier must be a finite number") if type(self.visible) is not bool or type(self.extended) is not bool: raise ValueError("Visibility and CAN format must be boolean") if not self.id.strip() or len(self.id) > 80 or not 1 <= self.order <= 9999: @@ -98,12 +102,11 @@ class TrendSignal: raise ValueError("Invalid ProtoCAN device") if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6: raise ValueError("Word offset must be 0..6") + if not 0 <= self.iq <= 30: + raise ValueError("IQ must be 0..30") - def word_value(self, word: int) -> float: - if not 0 <= word <= 65535: - raise ValueError("Not a 16-bit word") - return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word) - + def display_value(self, raw_value: float) -> float: + return raw_value * self.multiplier / (1 << self.iq) def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None: ids = set() @@ -138,14 +141,15 @@ def decode_settings(text: str) -> dict[str, list[TrendSignal]]: raise ValueError("profiles must be an object") result = {} fields = set(TrendSignal.__dataclass_fields__) + required_fields = fields - {"multiplier", "iq"} for profile, items in profiles.items(): if not isinstance(items, list) or len(items) > MAX_SIGNALS: raise ValueError("Expected up to 64 signals") signals = [] for item in items: - if not isinstance(item, dict) or not fields <= item.keys(): + if not isinstance(item, dict) or not required_fields <= item.keys(): raise ValueError("Missing signal fields") - signals.append(TrendSignal(**{key: item[key] for key in fields})) + signals.append(TrendSignal(**{key: item[key] for key in fields if key in item})) result[profile] = signals validate_settings(result) return result @@ -178,6 +182,20 @@ class NativeTrends: ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t] self.decode.restype = ctypes.c_int32 + self._word = library.set_trend_word_value + self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8] + self._word.restype = ctypes.c_int32 + self._watch_request = library.set_trend_watch_request + self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16), + ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t] + self._watch_request.restype = ctypes.c_size_t + self._watch_ack = library.set_trend_watch_ack + self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t] + self._watch_ack.restype = ctypes.c_int + self._watch_decode = library.set_trend_watch_decode + self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t] + self._watch_decode.restype = ctypes.c_int def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None: if signal.source not in ("CAN_GAS", "CAN_RAW"): @@ -191,3 +209,33 @@ class NativeTrends: signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags, payload, len(data)) return None if value == -2147483648 else float(value) + + def word_value(self, signal: TrendSignal, word: int) -> float: + if not 0 <= word <= 65535: + raise ValueError("Not a 16-bit word") + return float(self._word(word, signal.valueType == "INT16")) + + def watch_request(self, period_ms: int, addresses: list[int]) -> bytes: + if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any( + type(address) is not int or not 0 <= address <= 65535 for address in addresses): + raise ValueError("Invalid GAS watch request") + source = (ctypes.c_uint16 * len(addresses))(*addresses) + output = (ctypes.c_uint8 * (4 + len(addresses) * 2))() + size = self._watch_request(period_ms, source, len(addresses), output, len(output)) + if not size: + raise ValueError("Invalid GAS watch request") + return bytes(output[:size]) + + def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None: + data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) + if not self._watch_ack(data, len(payload), period_ms, count): + raise ValueError("Device did not accept the complete GAS subscription") + + def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]: + data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) + timestamp = ctypes.c_uint32() + words = (ctypes.c_uint16 * MAX_SIGNALS)() + count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS) + if count < 0 or expected_count is not None and count != expected_count: + raise ValueError("Invalid GAS watch data") + return timestamp.value, list(words[:count]) diff --git a/python/setprotocol/firmware_publish.py b/python/setprotocol/firmware_publish.py new file mode 100644 index 0000000..c1f86a2 --- /dev/null +++ b/python/setprotocol/firmware_publish.py @@ -0,0 +1,145 @@ +"""Reusable helpers for publishing the shared firmware release catalog.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from .firmware_catalog import ( + MAX_MANIFEST_BYTES, + SUPPORTED_TRANSPORTS, + parse_firmware_catalog, +) + +MAX_FIRMWARE_BYTES = 128 * 1024 * 1024 + + +@dataclass(frozen=True) +class FirmwarePublication: + """Metadata required to publish one firmware image.""" + + path: Path + product: str + version_name: str + version_code: int + transport: str + base_address: int | None = None + notes: str = "" + + def validate(self) -> None: + if not self.path.is_file(): + raise ValueError(f"Firmware file is missing: {self.path}") + if self.path.suffix.lower() not in {".bin", ".hex"}: + raise ValueError("Firmware file must have a .bin or .hex extension") + size = self.path.stat().st_size + if size <= 0: + raise ValueError("Firmware file is empty") + if size > MAX_FIRMWARE_BYTES: + raise ValueError("Firmware file exceeds the maximum size") + if not self.product.strip(): + raise ValueError("Firmware product is empty") + if not self.version_name.strip(): + raise ValueError("Firmware version name is empty") + if not 0 <= self.version_code <= 0x7FFFFFFF: + raise ValueError( + "Firmware version code must be between 0 and 2147483647" + ) + if self.transport not in SUPPORTED_TRANSPORTS: + raise ValueError("Unsupported firmware transport") + if ( + self.base_address is not None + and not 0 <= self.base_address <= 0xFFFFFFFF + ): + raise ValueError("Firmware base address is outside the uint32 range") + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_release_tag_part(value: str) -> str: + source = value.strip() + part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.") + digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8] + if not part: + return digest + return part if part == source else f"{part}-{digest}" + + +def firmware_release_tag(publication: FirmwarePublication) -> str: + return "firmware-%s-v%s" % ( + safe_release_tag_part(publication.product), + safe_release_tag_part(publication.version_name), + ) + + +def firmware_release_entry( + publication: FirmwarePublication, image_url: str, sha256: str +) -> dict: + result = { + "product": publication.product.strip(), + "versionCode": publication.version_code, + "versionName": publication.version_name.strip(), + "imageUrl": image_url, + "fileName": publication.path.name, + "sha256": sha256, + "transport": publication.transport, + "notes": publication.notes.strip(), + } + if publication.base_address is not None: + result["baseAddress"] = f"0x{publication.base_address:08X}" + return result + + +def firmware_entry_identity(entry: dict) -> tuple[str, int, str]: + return ( + str(entry.get("product", entry.get("device", ""))).strip().casefold(), + int(entry.get("versionCode", 0)), + str(entry.get("transport", "rs485")).strip().lower(), + ) + + +def update_firmware_manifest(manifest: dict, entry: dict) -> dict: + """Insert or replace one release without disturbing other manifest data.""" + result = dict(manifest) + existing = manifest.get("firmware") + firmware = dict(existing) if isinstance(existing, dict) else {} + rows = firmware.get("releases") if isinstance(existing, dict) else existing + releases = ( + [dict(row) for row in rows if isinstance(row, dict)] + if isinstance(rows, list) + else [] + ) + identity = firmware_entry_identity(entry) + releases = [ + row for row in releases if firmware_entry_identity(row) != identity + ] + releases.append(dict(entry)) + releases.sort( + key=lambda row: ( + str(row.get("product", row.get("device", ""))).casefold(), + -int(row.get("versionCode", 0)), + str(row.get("transport", "rs485")), + ) + ) + previous_rows = firmware.get("releases") + changed = releases != previous_rows + firmware["catalogVersion"] = ( + int(firmware.get("catalogVersion", 0)) + int(changed) + ) + firmware["releases"] = releases + result["firmware"] = firmware + + encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8") + if len(encoded) > MAX_MANIFEST_BYTES: + raise ValueError("Updated update.json exceeds the maximum size") + parse_firmware_catalog(encoded, "https://catalog.invalid/update.json") + return result + diff --git a/python/tests/test_firmware_publish.py b/python/tests/test_firmware_publish.py new file mode 100644 index 0000000..a50b354 --- /dev/null +++ b/python/tests/test_firmware_publish.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from setprotocol.firmware_publish import ( + FirmwarePublication, + firmware_release_entry, + firmware_release_tag, + update_firmware_manifest, +) + + +class FirmwarePublishTests(unittest.TestCase): + def publication(self, path: Path, **overrides) -> FirmwarePublication: + fields = { + "path": path, + "product": "F103DS18", + "version_name": "1.1.0", + "version_code": 0x00010100, + "transport": "can", + "base_address": 0x08003000, + "notes": "Verified release", + } + fields.update(overrides) + return FirmwarePublication(**fields) + + def test_publication_validates_file_and_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + image = Path(temporary) / "image.hex" + image.write_text(":00000001FF\n", encoding="ascii") + self.publication(image).validate() + with self.assertRaisesRegex(ValueError, "Unsupported"): + self.publication(image, transport="unknown").validate() + + def test_entry_and_tag_are_deterministic(self) -> None: + publication = self.publication(Path("image.hex")) + entry = firmware_release_entry( + publication, "https://example.test/image.hex", "ab" * 32 + ) + self.assertEqual( + firmware_release_tag(publication), "firmware-F103DS18-v1.1.0" + ) + self.assertEqual(entry["baseAddress"], "0x08003000") + + def test_update_preserves_sections_and_replaces_same_release(self) -> None: + first = { + "product": "Device", + "versionCode": 7, + "versionName": "1.2.3", + "imageUrl": "https://example.test/old.bin", + "fileName": "old.bin", + "sha256": "11" * 32, + "transport": "rs485", + } + manifest = update_firmware_manifest( + {"windows": {"versionCode": 8}}, first + ) + replacement = { + **first, + "imageUrl": "https://example.test/new.bin", + "fileName": "new.bin", + "sha256": "22" * 32, + } + updated = update_firmware_manifest(manifest, replacement) + self.assertEqual(updated["windows"], {"versionCode": 8}) + self.assertEqual(len(updated["firmware"]["releases"]), 1) + self.assertEqual( + updated["firmware"]["releases"][0]["sha256"], "22" * 32 + ) + self.assertEqual(updated["firmware"]["catalogVersion"], 2) + + def test_legacy_array_is_migrated_without_data_loss(self) -> None: + legacy = { + "product": "Legacy", + "versionCode": 1, + "versionName": "1.0", + "imageUrl": "https://example.test/legacy.bin", + "fileName": "legacy.bin", + "sha256": "33" * 32, + "transport": "rs485", + } + current = {**legacy, "product": "Current", "versionCode": 2} + updated = update_firmware_manifest({"firmware": [legacy]}, current) + self.assertCountEqual( + [row["product"] for row in updated["firmware"]["releases"]], + ["Legacy", "Current"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index a2ba188..ba3281f 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -56,6 +56,9 @@ class PlotTests(unittest.TestCase): self.assertAlmostEqual(20, self.core.db_delta(1, 10)) self.assertAlmostEqual(-20, self.core.db_delta(10, 1)) self.assertIsNone(self.core.db_delta(0, 1)) + self.assertEqual(Bounds(0, 500, -2, 2), self.core.limits(0, 500, -2, 2)) + with self.assertRaises(ValueError): + self.core.limits(1, 1, -2, 2) if __name__ == "__main__": diff --git a/python/tests/test_spectrum.py b/python/tests/test_spectrum.py index febddd0..60be5ac 100644 --- a/python/tests/test_spectrum.py +++ b/python/tests/test_spectrum.py @@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase): peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__) self.assertEqual(64, result.frequencies[peak]) self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9) + detected = self.core.dominant_peak(result) + self.assertAlmostEqual(64, detected.frequency_hz, places=9) + self.assertAlmostEqual(3.25, detected.amplitude, places=9) def test_dc_and_nyquist_are_not_doubled(self): times, _ = self.sample() diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py index b18474b..3613823 100644 --- a/python/tests/test_trends.py +++ b/python/tests/test_trends.py @@ -14,6 +14,16 @@ FIXTURE = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/t class TrendTests(unittest.TestCase): + def test_multiplier_and_iq_scale_display_and_old_json_defaults(self): + signal = TrendSignal("scaled", multiplier=2.5, iq=3) + self.assertEqual(10.0, signal.display_value(32.0)) + signal.validate("TMS2812") + payload = json.loads(encode_settings({"TMS2812": [signal]})) + payload["profiles"]["TMS2812"][0].pop("multiplier") + payload["profiles"]["TMS2812"][0].pop("iq") + restored = decode_settings(json.dumps(payload))["TMS2812"][0] + self.assertEqual((1.0, 0), (restored.multiplier, restored.iq)) + def test_shared_kotlin_fixture_and_round_trip(self): settings = decode_settings(FIXTURE.read_text(encoding="utf-8")) self.assertEqual(5, sum(map(len, settings.values()))) @@ -57,7 +67,6 @@ class TrendTests(unittest.TestCase): for value in ("-1", "+1", "FF", "0x", "1.0", "256"): with self.assertRaises(ValueError): parse_address(value, 255) - self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534)) def test_bounded_history(self): history = TrendHistory() @@ -81,6 +90,13 @@ class TrendTests(unittest.TestCase): self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data)) raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2) self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data)) + self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534)) + self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]), + core.watch_request(1000, [0x1234, 0xFFFF])) + core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2) + timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2) + self.assertEqual(0x04030201, timestamp) + self.assertEqual([0x1234, 0xFFFF], words) if __name__ == "__main__": diff --git a/tools/firmware-publish/PUBLISH_FIRMWARE.bat b/tools/firmware-publish/PUBLISH_FIRMWARE.bat new file mode 100644 index 0000000..af08934 --- /dev/null +++ b/tools/firmware-publish/PUBLISH_FIRMWARE.bat @@ -0,0 +1,221 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +set "FW_TOOL_DIR=%~dp0" +set "FW_MODE=--preflight" +set "FW_CONFIG=" +set "FW_ASSUME_YES=0" +set "FW_ALLOW_DIRTY=0" + +:parse_arguments +if "%~1"=="" goto arguments_done +if /i "%~1"=="--preflight" ( + set "FW_MODE=--preflight" + shift + goto parse_arguments +) +if /i "%~1"=="--publish" ( + set "FW_MODE=--publish" + shift + goto parse_arguments +) +if /i "%~1"=="--yes" ( + set "FW_ASSUME_YES=1" + shift + goto parse_arguments +) +if /i "%~1"=="--allow-dirty" ( + set "FW_ALLOW_DIRTY=1" + shift + goto parse_arguments +) +if /i "%~1"=="--config" ( + if "%~2"=="" goto missing_config_value + set "FW_CONFIG=%~2" + shift + shift + goto parse_arguments +) +if /i "%~1"=="--help" goto usage +if /i "%~1"=="-h" goto usage +echo ERROR: unknown argument: %~1 +goto usage_error + +:missing_config_value +echo ERROR: --config requires a path. +goto usage_error + +:arguments_done +if not defined FW_CONFIG ( + if exist "%CD%\firmware-release.cmd" ( + set "FW_CONFIG=%CD%\firmware-release.cmd" + ) else ( + set "FW_CONFIG=%FW_TOOL_DIR%firmware-release.cmd" + ) +) +for %%I in ("%FW_CONFIG%") do set "FW_CONFIG=%%~fI" +if not exist "%FW_CONFIG%" ( + echo ERROR: publication config not found: + echo %FW_CONFIG% + echo Copy one of the examples to the firmware project root as + echo firmware-release.cmd, then edit its values. + exit /b 2 +) + +for %%I in ("%FW_CONFIG%") do set "FW_CONFIG_DIR=%%~dpI" +call "%FW_CONFIG%" +if errorlevel 1 ( + echo ERROR: publication config returned an error. + exit /b 2 +) + +if not defined FW_PRODUCT goto missing_FW_PRODUCT +if not defined FW_VERSION goto missing_FW_VERSION +if not defined FW_VERSION_CODE goto missing_FW_VERSION_CODE +if not defined FW_TRANSPORTS goto missing_FW_TRANSPORTS +if not defined FW_IMAGE goto missing_FW_IMAGE +if not defined FW_PROJECT_ROOT set "FW_PROJECT_ROOT=%FW_CONFIG_DIR%" + +pushd "%FW_CONFIG_DIR%" >nul +if errorlevel 1 ( + echo ERROR: cannot enter config directory: %FW_CONFIG_DIR% + exit /b 2 +) +for %%I in ("%FW_PROJECT_ROOT%") do set "FW_PROJECT_ROOT_ABS=%%~fI" +for %%I in ("%FW_IMAGE%") do set "FW_IMAGE_ABS=%%~fI" +if defined FW_SETGUI_ROOT for %%I in ("%FW_SETGUI_ROOT%") do set "FW_SETGUI_ROOT_ABS=%%~fI" +popd + +if not defined FW_SETGUI_ROOT_ABS call :find_setgui +if not defined FW_SETGUI_ROOT_ABS ( + echo ERROR: SETGUI was not found. + echo Set FW_SETGUI_ROOT in %FW_CONFIG% + exit /b 2 +) +set "FW_SETGUI_PUBLISHER=%FW_SETGUI_ROOT_ABS%\PUBLISH_FIRMWARE.bat" +if not exist "%FW_SETGUI_PUBLISHER%" ( + echo ERROR: SETGUI publisher not found: + echo %FW_SETGUI_PUBLISHER% + exit /b 2 +) +if not exist "%FW_IMAGE_ABS%" ( + echo ERROR: firmware image not found: + echo %FW_IMAGE_ABS% + exit /b 2 +) + +for %%I in ("%FW_IMAGE_ABS%") do set "FW_IMAGE_EXT=%%~xI" +if /i not "%FW_IMAGE_EXT%"==".hex" if /i not "%FW_IMAGE_EXT%"==".bin" ( + echo ERROR: FW_IMAGE must point to a .hex or .bin file, not %FW_IMAGE_EXT%. + echo Keil must create a HEX file; CCS must run C2000 Hex Utility. + exit /b 2 +) + +echo. +echo Firmware publication +echo Mode: %FW_MODE% +echo Project: %FW_PROJECT_ROOT_ABS% +echo Image: %FW_IMAGE_ABS% +echo Product: %FW_PRODUCT% +echo Version: %FW_VERSION% ^(%FW_VERSION_CODE%^) +echo Transports: %FW_TRANSPORTS% +if defined FW_BASE_ADDRESS echo Base address: %FW_BASE_ADDRESS% +echo. + +if /i "%FW_MODE%"=="--publish" call :guard_worktree +if errorlevel 1 exit /b %errorlevel% + +if /i "%FW_MODE%"=="--publish" if "%FW_ASSUME_YES%"=="0" ( + echo This will upload the image and change the public firmware catalog. + choice /C YN /N /M "Continue [Y/N]? " + if errorlevel 2 ( + echo Publication cancelled. + exit /b 2 + ) +) + +for %%T in (%FW_TRANSPORTS:,= %) do ( + call :publish_transport "%%~T" + if errorlevel 1 goto publication_failed +) + +if /i "%FW_MODE%"=="--publish" ( + echo. + echo Publication completed and verified for all transports. +) else ( + echo. + echo Preflight passed. Run with --publish only when this build is a release. +) +exit /b 0 + +:publication_failed +echo. +echo ERROR: publication step failed; remaining transports were not processed. +exit /b 1 + +:publish_transport +set "FW_TRANSPORT=%~1" +if "%FW_TRANSPORT%"=="" exit /b 0 +echo ----- %FW_TRANSPORT% ----- +if defined FW_BASE_ADDRESS ( + call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --base-address "%FW_BASE_ADDRESS%" --notes "%FW_NOTES%" +) else ( + call "%FW_SETGUI_PUBLISHER%" %FW_MODE% --file "%FW_IMAGE_ABS%" --product "%FW_PRODUCT%" --version "%FW_VERSION%" --version-code "%FW_VERSION_CODE%" --transport "%FW_TRANSPORT%" --notes "%FW_NOTES%" +) +exit /b %errorlevel% + +:guard_worktree +where git >nul 2>nul +if errorlevel 1 ( + echo WARNING: Git is unavailable; source cleanliness was not checked. + exit /b 0 +) +git -C "%FW_PROJECT_ROOT_ABS%" rev-parse --is-inside-work-tree >nul 2>nul +if errorlevel 1 ( + echo WARNING: project is not a Git worktree; source cleanliness was not checked. + exit /b 0 +) +git -C "%FW_PROJECT_ROOT_ABS%" diff --quiet HEAD -- >nul 2>nul +if not errorlevel 1 exit /b 0 +if "%FW_ALLOW_DIRTY%"=="1" ( + echo WARNING: publishing from a worktree with tracked changes. + exit /b 0 +) +echo ERROR: tracked project files differ from HEAD. +echo Commit the release sources or repeat with --allow-dirty deliberately. +exit /b 3 + +:find_setgui +for %%I in ("%FW_CONFIG_DIR%..\SETGUI" "%FW_CONFIG_DIR%..\..\SETGUI" "%FW_CONFIG_DIR%..\..\..\SETGUI") do ( + if exist "%%~fI\PUBLISH_FIRMWARE.bat" if not defined FW_SETGUI_ROOT_ABS set "FW_SETGUI_ROOT_ABS=%%~fI" +) +exit /b 0 + +:missing_FW_PRODUCT +echo ERROR: FW_PRODUCT is not set in %FW_CONFIG% +exit /b 2 +:missing_FW_VERSION +echo ERROR: FW_VERSION is not set in %FW_CONFIG% +exit /b 2 +:missing_FW_VERSION_CODE +echo ERROR: FW_VERSION_CODE is not set in %FW_CONFIG% +exit /b 2 +:missing_FW_TRANSPORTS +echo ERROR: FW_TRANSPORTS is not set in %FW_CONFIG% +exit /b 2 +:missing_FW_IMAGE +echo ERROR: FW_IMAGE is not set in %FW_CONFIG% +exit /b 2 + +:usage +echo Usage: +echo PUBLISH_FIRMWARE.bat [--config PATH] --preflight +echo PUBLISH_FIRMWARE.bat [--config PATH] --publish [--yes] [--allow-dirty] +echo. +echo Default mode is --preflight. Default config is firmware-release.cmd +echo in the current directory or next to this script. +exit /b 0 + +:usage_error +call :usage +exit /b 2 diff --git a/tools/firmware-publish/README.md b/tools/firmware-publish/README.md new file mode 100644 index 0000000..9c27223 --- /dev/null +++ b/tools/firmware-publish/README.md @@ -0,0 +1,159 @@ +# Публикация прошивки из Keil и CCS 12 + +Этот комплект связывает проект прошивки с каталогом SETGUI. IDE по-прежнему +собирает штатный загрузочный файл, а `PUBLISH_FIRMWARE.bat`: + +1. читает метаданные выпуска из `firmware-release.cmd`; +2. проверяет наличие и формат `.hex` или `.bin`; +3. запускает локальную проверку SETGUI (`--preflight`) либо публикацию + (`--publish`); +4. для каждого транспорта загружает образ в Gitea, скачивает его обратно, + сверяет SHA-256 и только после этого обновляет `update.json`. + +Пароль в проекте не хранится. Публикатор использует учётные данные Gitea, +которые сохранены в SETGUI через окно «Версия и обновление». + +## Один раз на рабочем компьютере + +1. Соберите или запустите SETGUI и сохраните в нём логин и пароль (либо токен + вместо пароля) Gitea. +2. Убедитесь, что у SETGUI создано Python-окружение `.venv`. +3. Подключите `templates` как сабмодуль проекта либо используйте уже общий + checkout. Не делайте отдельные исправленные копии скрипта в каждом проекте. +4. Скопируйте подходящий файл из [`examples`](examples) в корень проекта под + именем `firmware-release.cmd` и исправьте значения. + +`firmware-release.cmd` содержит только метаданные: + +```bat +set "FW_PROJECT_ROOT=%~dp0" +set "FW_SETGUI_ROOT=%~dp0..\SETGUI" +set "FW_PRODUCT=F103DS18" +set "FW_VERSION=1.1.0" +set "FW_VERSION_CODE=0x00010100" +set "FW_TRANSPORTS=can rs485" +set "FW_BASE_ADDRESS=0x08003000" +set "FW_IMAGE=mdk\build\ds18b20_f103.hex" +set "FW_NOTES=Краткое описание выпуска" +``` + +Пути считаются относительно каталога `firmware-release.cmd`. Если SETGUI не +лежит рядом с проектом, укажите абсолютный `FW_SETGUI_ROOT` или системную +переменную с тем же именем. + +### Обязательные поля + +| Поле | Значение | +|---|---| +| `FW_PRODUCT` | Стабильный идентификатор изделия. Не меняйте регистр/написание между версиями. | +| `FW_VERSION` | Читаемая версия SemVer, например `1.1.0`. | +| `FW_VERSION_CODE` | Число для сравнения версий. Рекомендуется `(major << 16) + (minor << 8) + patch`: `1.1.0` = `0x00010100`. | +| `FW_TRANSPORTS` | Один или несколько транспортов через пробел: `can`, `rs485`, `tms`. | +| `FW_IMAGE` | Готовый файл `.hex` или `.bin`. `.axf` и `.out` публиковать нельзя. | + +`FW_BASE_ADDRESS` нужен для обычного бинарного образа STM32. Для Intel HEX +адрес уже записан в файле, но поле каталога всё равно лучше заполнить адресом +приложения. Для загрузочной таблицы TMS SCI8 оставьте поле пустым. + +## Keil MDK / Arm Compiler 6 + +1. В **Options for Target → Output** включите **Create HEX File**. +2. Укажите в `FW_IMAGE` реальный выходной файл, например + `mdk\build\ds18b20_f103.hex`. +3. Запустите полную сборку и локальную проверку. В `KONOR_ds18b20`, где + сабмодуль `templates` подключён как `lib`, команда выглядит так: + +```bat +call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight +``` + +В **Options for Target → User → After Build/Rebuild** можно добавить эту же +команду с `--preflight`. Если Keil запускает её из каталога `mdk`, передайте +явный путь к конфигурации, например `--config "..\firmware-release.cmd"`. + +Не ставьте `--publish` в post-build: иначе обычная сборка станет внешней +операцией и сможет перезаписать опубликованную версию. + +## Code Composer Studio 12 / C2000 + +SETGUI не преобразует `.out`. Для активной конфигурации **Debug и/или Release** +включите **C2000 Hex Utility** и сформируйте загрузочный `.bin`. Для SCI8 boot +TMS320F2812 используются параметры проекта: + +```text +--binary +--boot +--sci8 +``` + +Выход удобно складывать в `bin\${BuildArtifactFileBaseName}.bin`. Затем укажите +этот путь в `FW_IMAGE`, `tms` в `FW_TRANSPORTS`, а `FW_BASE_ADDRESS` оставьте +пустым. + +После Build выполните: + +```bat +call "..\newProject\templates\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight +``` + +Команда выше соответствует текущей раскладке `SETGIT\BALZAM_ALL` и +`SETGIT\newProject\templates`. Если `templates` подключён в сам проект как +`lib\templates`, используйте `lib\templates\tools\firmware-publish\...`. + +При желании ту же команду можно добавить в **Project Properties → Build → +Steps → Post-build steps**. В конфигурации CCS, где Hex Utility не включён, +файл `.bin` не обновится — это особенно важно отдельно проверить для Release. + +## Выпуск + +Рабочая последовательность одинакова для обеих IDE: + +```bat +rem 1. Собрать Release в IDE. + +rem 2. Проверить метаданные, размер, имя, SHA-256 и запись каталога без сети. +call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --preflight + +rem 3. Закоммитить исходники выпуска и опубликовать. +call "lib\tools\firmware-publish\PUBLISH_FIRMWARE.bat" --config "firmware-release.cmd" --publish +``` + +Перед публикацией скрипт показывает все параметры, проверяет tracked-файлы +Git и просит подтверждение. `--allow-dirty` осознанно разрешает публикацию из +изменённого worktree, а `--yes` отключает только интерактивное подтверждение +для доверенного CI: + +```bat +call PUBLISH_FIRMWARE.bat --publish --yes --allow-dirty +``` + +Повторная публикация той же комбинации `product + versionCode + transport` +заменяет запись каталога. Новый `versionCode` добавляет новую версию. Если при +нескольких транспортах сеть оборвалась посередине, исправьте причину и повторите +ту же команду: уже опубликованные записи будут безопасно заменены теми же +данными. + +## Контроль после публикации + +Успешное завершение означает, что образ: + +- загружен как asset выпуска; +- скачан обратно и совпал по SHA-256; +- записан в `firmware.releases` файла `update.json`; +- повторно прочитан и разобран тем же кодом, который использует SETGUI. + +После этого откройте в SETGUI **Версия и обновление → База прошивок → Обновить +каталог** и проверьте изделие, версию и транспорт. Для окончательной проверки +выполните загрузку на тестовое устройство именно тем транспортом, который +указан в записи. + +## Частые ошибки + +| Сообщение | Что проверить | +|---|---| +| `firmware image not found` | Сборка завершилась успешно, `FW_IMAGE` задан относительно конфигурации, нужная конфигурация IDE создаёт `.hex/.bin`. | +| `SETGUI was not found` | Исправьте `FW_SETGUI_ROOT`. | +| `Missing build environment: .venv` | Создайте окружение SETGUI и установите зависимости проекта. | +| Ошибка авторизации Gitea | Заново сохраните логин и токен в SETGUI; не записывайте токен в `.cmd`. | +| `tracked project files differ from HEAD` | Закоммитьте точные исходники выпуска либо осознанно добавьте `--allow-dirty`. | +| Версия не видна в SETGUI | Нажмите «Обновить каталог» и проверьте точное значение `FW_PRODUCT` и поддерживаемый выбранным устройством транспорт. | diff --git a/tools/firmware-publish/examples/ccs12-firmware-release.cmd b/tools/firmware-publish/examples/ccs12-firmware-release.cmd new file mode 100644 index 0000000..ef3ae21 --- /dev/null +++ b/tools/firmware-publish/examples/ccs12-firmware-release.cmd @@ -0,0 +1,15 @@ +@echo off +rem Copy this file to the firmware repository root as firmware-release.cmd. + +set "FW_PROJECT_ROOT=%~dp0" +rem This relative path matches SETGIT/BALZAM_ALL + SETGIT/newProject/SETGUI. +rem Change it if the repositories are stored differently. +set "FW_SETGUI_ROOT=%~dp0..\newProject\SETGUI" + +set "FW_PRODUCT=BALZAM-166" +set "FW_VERSION=1.0.0" +set "FW_VERSION_CODE=0x00010000" +set "FW_TRANSPORTS=tms" +set "FW_BASE_ADDRESS=" +set "FW_IMAGE=ccs12_8_proj\bin\balzam_166_ccs12_8.bin" +set "FW_NOTES=TMS320F2812 SCI8 boot image" diff --git a/tools/firmware-publish/examples/keil-firmware-release.cmd b/tools/firmware-publish/examples/keil-firmware-release.cmd new file mode 100644 index 0000000..40b7a95 --- /dev/null +++ b/tools/firmware-publish/examples/keil-firmware-release.cmd @@ -0,0 +1,13 @@ +@echo off +rem Copy this file to the firmware repository root as firmware-release.cmd. + +set "FW_PROJECT_ROOT=%~dp0" +set "FW_SETGUI_ROOT=%~dp0..\SETGUI" + +set "FW_PRODUCT=F103DS18" +set "FW_VERSION=1.1.0" +set "FW_VERSION_CODE=0x00010100" +set "FW_TRANSPORTS=can rs485" +set "FW_BASE_ADDRESS=0x08003000" +set "FW_IMAGE=mdk\build\ds18b20_f103.hex" +set "FW_NOTES=DS18B20 firmware release"