Merge remote-tracking branch 'origin/codex/trend-display-scale' into HEAD
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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 получает готовые уровни в дБмВ от
|
||||
прибора. Общая интерактивная часть не меняет эти данные или единицы.
|
||||
|
||||
## Проверка и сборка
|
||||
|
||||
@@ -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 должен подтвердить весь
|
||||
список: при частичном принятии нельзя определить пропущенные адреса, поэтому
|
||||
строить график по смещённым индексам запрещено. При паузе порт приложения
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,37 +10,28 @@ object GuiGasWatch {
|
||||
|
||||
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) }
|
||||
}
|
||||
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<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) }
|
||||
}
|
||||
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<Int>)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TrendPoint>, 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]) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
2
c/set-protocol/tests/fixtures/plot-v1.json
vendored
2
c/set-protocol/tests/fixtures/plot-v1.json
vendored
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
145
python/setprotocol/firmware_publish.py
Normal file
145
python/setprotocol/firmware_publish.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Reusable helpers for publishing the shared firmware release catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .firmware_catalog import (
|
||||
MAX_MANIFEST_BYTES,
|
||||
SUPPORTED_TRANSPORTS,
|
||||
parse_firmware_catalog,
|
||||
)
|
||||
|
||||
MAX_FIRMWARE_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwarePublication:
|
||||
"""Metadata required to publish one firmware image."""
|
||||
|
||||
path: Path
|
||||
product: str
|
||||
version_name: str
|
||||
version_code: int
|
||||
transport: str
|
||||
base_address: int | None = None
|
||||
notes: str = ""
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.path.is_file():
|
||||
raise ValueError(f"Firmware file is missing: {self.path}")
|
||||
if self.path.suffix.lower() not in {".bin", ".hex"}:
|
||||
raise ValueError("Firmware file must have a .bin or .hex extension")
|
||||
size = self.path.stat().st_size
|
||||
if size <= 0:
|
||||
raise ValueError("Firmware file is empty")
|
||||
if size > MAX_FIRMWARE_BYTES:
|
||||
raise ValueError("Firmware file exceeds the maximum size")
|
||||
if not self.product.strip():
|
||||
raise ValueError("Firmware product is empty")
|
||||
if not self.version_name.strip():
|
||||
raise ValueError("Firmware version name is empty")
|
||||
if not 0 <= self.version_code <= 0x7FFFFFFF:
|
||||
raise ValueError(
|
||||
"Firmware version code must be between 0 and 2147483647"
|
||||
)
|
||||
if self.transport not in SUPPORTED_TRANSPORTS:
|
||||
raise ValueError("Unsupported firmware transport")
|
||||
if (
|
||||
self.base_address is not None
|
||||
and not 0 <= self.base_address <= 0xFFFFFFFF
|
||||
):
|
||||
raise ValueError("Firmware base address is outside the uint32 range")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def safe_release_tag_part(value: str) -> str:
|
||||
source = value.strip()
|
||||
part = re.sub(r"[^A-Za-z0-9._-]+", "-", source).strip("-.")
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:8]
|
||||
if not part:
|
||||
return digest
|
||||
return part if part == source else f"{part}-{digest}"
|
||||
|
||||
|
||||
def firmware_release_tag(publication: FirmwarePublication) -> str:
|
||||
return "firmware-%s-v%s" % (
|
||||
safe_release_tag_part(publication.product),
|
||||
safe_release_tag_part(publication.version_name),
|
||||
)
|
||||
|
||||
|
||||
def firmware_release_entry(
|
||||
publication: FirmwarePublication, image_url: str, sha256: str
|
||||
) -> dict:
|
||||
result = {
|
||||
"product": publication.product.strip(),
|
||||
"versionCode": publication.version_code,
|
||||
"versionName": publication.version_name.strip(),
|
||||
"imageUrl": image_url,
|
||||
"fileName": publication.path.name,
|
||||
"sha256": sha256,
|
||||
"transport": publication.transport,
|
||||
"notes": publication.notes.strip(),
|
||||
}
|
||||
if publication.base_address is not None:
|
||||
result["baseAddress"] = f"0x{publication.base_address:08X}"
|
||||
return result
|
||||
|
||||
|
||||
def firmware_entry_identity(entry: dict) -> tuple[str, int, str]:
|
||||
return (
|
||||
str(entry.get("product", entry.get("device", ""))).strip().casefold(),
|
||||
int(entry.get("versionCode", 0)),
|
||||
str(entry.get("transport", "rs485")).strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
def update_firmware_manifest(manifest: dict, entry: dict) -> dict:
|
||||
"""Insert or replace one release without disturbing other manifest data."""
|
||||
result = dict(manifest)
|
||||
existing = manifest.get("firmware")
|
||||
firmware = dict(existing) if isinstance(existing, dict) else {}
|
||||
rows = firmware.get("releases") if isinstance(existing, dict) else existing
|
||||
releases = (
|
||||
[dict(row) for row in rows if isinstance(row, dict)]
|
||||
if isinstance(rows, list)
|
||||
else []
|
||||
)
|
||||
identity = firmware_entry_identity(entry)
|
||||
releases = [
|
||||
row for row in releases if firmware_entry_identity(row) != identity
|
||||
]
|
||||
releases.append(dict(entry))
|
||||
releases.sort(
|
||||
key=lambda row: (
|
||||
str(row.get("product", row.get("device", ""))).casefold(),
|
||||
-int(row.get("versionCode", 0)),
|
||||
str(row.get("transport", "rs485")),
|
||||
)
|
||||
)
|
||||
previous_rows = firmware.get("releases")
|
||||
changed = releases != previous_rows
|
||||
firmware["catalogVersion"] = (
|
||||
int(firmware.get("catalogVersion", 0)) + int(changed)
|
||||
)
|
||||
firmware["releases"] = releases
|
||||
result["firmware"] = firmware
|
||||
|
||||
encoded = (json.dumps(result, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
if len(encoded) > MAX_MANIFEST_BYTES:
|
||||
raise ValueError("Updated update.json exceeds the maximum size")
|
||||
parse_firmware_catalog(encoded, "https://catalog.invalid/update.json")
|
||||
return result
|
||||
|
||||
93
python/tests/test_firmware_publish.py
Normal file
93
python/tests/test_firmware_publish.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from setprotocol.firmware_publish import (
|
||||
FirmwarePublication,
|
||||
firmware_release_entry,
|
||||
firmware_release_tag,
|
||||
update_firmware_manifest,
|
||||
)
|
||||
|
||||
|
||||
class FirmwarePublishTests(unittest.TestCase):
|
||||
def publication(self, path: Path, **overrides) -> FirmwarePublication:
|
||||
fields = {
|
||||
"path": path,
|
||||
"product": "F103DS18",
|
||||
"version_name": "1.1.0",
|
||||
"version_code": 0x00010100,
|
||||
"transport": "can",
|
||||
"base_address": 0x08003000,
|
||||
"notes": "Verified release",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return FirmwarePublication(**fields)
|
||||
|
||||
def test_publication_validates_file_and_metadata(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
image = Path(temporary) / "image.hex"
|
||||
image.write_text(":00000001FF\n", encoding="ascii")
|
||||
self.publication(image).validate()
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||
self.publication(image, transport="unknown").validate()
|
||||
|
||||
def test_entry_and_tag_are_deterministic(self) -> None:
|
||||
publication = self.publication(Path("image.hex"))
|
||||
entry = firmware_release_entry(
|
||||
publication, "https://example.test/image.hex", "ab" * 32
|
||||
)
|
||||
self.assertEqual(
|
||||
firmware_release_tag(publication), "firmware-F103DS18-v1.1.0"
|
||||
)
|
||||
self.assertEqual(entry["baseAddress"], "0x08003000")
|
||||
|
||||
def test_update_preserves_sections_and_replaces_same_release(self) -> None:
|
||||
first = {
|
||||
"product": "Device",
|
||||
"versionCode": 7,
|
||||
"versionName": "1.2.3",
|
||||
"imageUrl": "https://example.test/old.bin",
|
||||
"fileName": "old.bin",
|
||||
"sha256": "11" * 32,
|
||||
"transport": "rs485",
|
||||
}
|
||||
manifest = update_firmware_manifest(
|
||||
{"windows": {"versionCode": 8}}, first
|
||||
)
|
||||
replacement = {
|
||||
**first,
|
||||
"imageUrl": "https://example.test/new.bin",
|
||||
"fileName": "new.bin",
|
||||
"sha256": "22" * 32,
|
||||
}
|
||||
updated = update_firmware_manifest(manifest, replacement)
|
||||
self.assertEqual(updated["windows"], {"versionCode": 8})
|
||||
self.assertEqual(len(updated["firmware"]["releases"]), 1)
|
||||
self.assertEqual(
|
||||
updated["firmware"]["releases"][0]["sha256"], "22" * 32
|
||||
)
|
||||
self.assertEqual(updated["firmware"]["catalogVersion"], 2)
|
||||
|
||||
def test_legacy_array_is_migrated_without_data_loss(self) -> None:
|
||||
legacy = {
|
||||
"product": "Legacy",
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"imageUrl": "https://example.test/legacy.bin",
|
||||
"fileName": "legacy.bin",
|
||||
"sha256": "33" * 32,
|
||||
"transport": "rs485",
|
||||
}
|
||||
current = {**legacy, "product": "Current", "versionCode": 2}
|
||||
updated = update_firmware_manifest({"firmware": [legacy]}, current)
|
||||
self.assertCountEqual(
|
||||
[row["product"] for row in updated["firmware"]["releases"]],
|
||||
["Legacy", "Current"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
221
tools/firmware-publish/PUBLISH_FIRMWARE.bat
Normal file
221
tools/firmware-publish/PUBLISH_FIRMWARE.bat
Normal file
@@ -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
|
||||
159
tools/firmware-publish/README.md
Normal file
159
tools/firmware-publish/README.md
Normal file
@@ -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` и поддерживаемый выбранным устройством транспорт. |
|
||||
15
tools/firmware-publish/examples/ccs12-firmware-release.cmd
Normal file
15
tools/firmware-publish/examples/ccs12-firmware-release.cmd
Normal file
@@ -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"
|
||||
13
tools/firmware-publish/examples/keil-firmware-release.cmd
Normal file
13
tools/firmware-publish/examples/keil-firmware-release.cmd
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user