diff --git a/.gitignore b/.gitignore index 45ac40a..4eec6a8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -227,5 +227,16 @@ python_app/runtime SHARE_INTERNET_TO_PI.md CLAUDE.md -docs/ -test_end_2/ \ No newline at end of file +/docs/ +test_end_2/ + +# --- device_firmware: PlatformIO / STM32G431 cart remote --- +# build output (regenerated by `pio run`) +device_firmware/cart_firmware/.pio/ +# scope captures: raw .bin + .npz + preview .png, local-only +device_firmware/cart_firmware/captures/ +# machine-specific, regenerated by the PlatformIO extension +device_firmware/cart_firmware/.vscode/c_cpp_properties.json +device_firmware/cart_firmware/.vscode/launch.json +device_firmware/cart_firmware/.vscode/ipch/ +device_firmware/cart_firmware/.vscode/.browse.c_cpp.db* \ No newline at end of file diff --git a/device_firmware/cart_firmware/.vscode/extensions.json b/device_firmware/cart_firmware/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/device_firmware/cart_firmware/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/device_firmware/cart_firmware/docs/protocol.md b/device_firmware/cart_firmware/docs/protocol.md new file mode 100644 index 0000000..3a46833 --- /dev/null +++ b/device_firmware/cart_firmware/docs/protocol.md @@ -0,0 +1,57 @@ +# Протокол старого пульта тележки (реверс-инжиниринг) + +Снято 2026-08-20 осциллографом Hantek DPO7204C с сигнального провода пульта +(канал CH3). Инструменты: `tools/capture.py` (захват), `tools/decode.py` +(декодер), сырые данные и картинки — в `captures/`. + +## Физический уровень + +- Один сигнальный провод, логика **3.3 В**. +- **Инвертированный UART** (стандартная полярность SBUS): в покое линия + **низкая** (~0 В), импульсы вверх до ~3.3 В. +- Скорость **100 000 бод**, формат **8E2** (8 бит данных, чётность even, + 2 стоп-бита), биты LSB-first. Длительность бита 10 мкс. + +## Кадровый уровень — SBUS + +Стандартный кадр Futaba SBUS, 25 байт (3 мс на линии): + +| Смещение | Размер | Содержимое | +|---|---|---| +| 0 | 1 | Заголовок `0x0F` | +| 1 | 22 | 16 каналов × 11 бит, упакованы подряд LSB-first | +| 23 | 1 | Флаги: bit0=CH17, bit1=CH18, bit2=frame_lost, bit3=failsafe | +| 24 | 1 | Футер `0x00` | + +Распаковка каналов: 22 байта складываются в 176-битное число LSB-first, +канал N (N=0..15) = биты [11·N .. 11·N+10], диапазон значений 0–2047. + +- Кадры отправляются каждые **50 мс** (20 Гц). Это медленнее стандартного + SBUS (7/14 мс) — ответная часть с этим темпом работает. +- Флаги во всех наблюдениях = `0x00`. + +## Карта каналов (нумерация с 1) + +| Управление | Канал | Мин | Нейтраль | Макс | +|---|---|---|---|---| +| Стик вперёд/назад | **2** | 433 (назад) | 1024 | 1643 (вперёд) | +| Стик влево/вправо | **4** | 446 (влево) | 1024 | 1654 (вправо) | +| Остальные 14 | — | всегда 1024 | | | + +Диапазон осей ~±600 от нейтрали (не полная шкала SBUS). Других органов +управления на пульте нет. + +Эталонный кадр нейтрали (hex): + +``` +0F 00 04 20 00 01 08 40 00 02 10 80 00 04 20 00 01 08 40 00 02 10 80 00 00 +``` + +## Воспроизведение на STM32G431 + +- USART: 100000 бод, 8 бит + чётность even (в терминах STM32: M=1, 9-bit + с PCE=1), 2 стоп-бита, **TXINV=1** (аппаратная инверсия TX) — бит-бэнг + не нужен. +- Отправлять 25-байтовый кадр по таймеру каждые 50 мс. +- Нейтраль: все каналы 1024; управление — каналы 2 и 4 в измеренных + диапазонах. diff --git a/device_firmware/cart_firmware/platformio.ini b/device_firmware/cart_firmware/platformio.ini new file mode 100644 index 0000000..86ee5ec --- /dev/null +++ b/device_firmware/cart_firmware/platformio.ini @@ -0,0 +1,10 @@ +[env:weact_g431cb] +platform = ststm32 +board = genericSTM32G431CB +framework = arduino +upload_protocol = stlink +debug_tool = stlink +monitor_speed = 115200 +build_flags = + -DUSBCON + -DUSBD_USE_CDC diff --git a/device_firmware/cart_firmware/src/main.cpp b/device_firmware/cart_firmware/src/main.cpp new file mode 100644 index 0000000..2694f55 --- /dev/null +++ b/device_firmware/cart_firmware/src/main.cpp @@ -0,0 +1,144 @@ +// Пульт тележки: стик (АЦП PA0/PA1) -> SBUS на USART1 TX (PA9). +// Протокол: инвертированный SBUS, 100000 бод 8E2, 25 байт каждые 50 мс +// (см. docs/protocol.md). USB CDC (Serial) — отладочный вывод. +#include + +// ---- калибровка стика (АЦП 12 бит, замерено 2026-08-20) ---- +static const int X_FWD = 508, X_MID = 2014, X_BACK = 3625; // PA0 +static const int Y_RIGHT = 479, Y_MID = 1981, Y_LEFT = 3586; // PA1 +static const int DEADZONE = 30; // отсечка дребезга вокруг нейтрали + +// ---- SBUS-значения старого пульта ---- +static const uint16_t SBUS_MID = 1024; +static const uint16_t CH2_MIN = 433, CH2_MAX = 1643; // назад..вперёд +static const uint16_t CH4_MIN = 446, CH4_MAX = 1654; // влево..вправо + +// ---- expo: 0 = линейно, 1 = максимально мягкая нейтраль ---- +static const float EXPO_K = 0.4f; +// ---- общий масштаб выхода: 1.0 = диапазон старого пульта ---- +static const float RANGE_SCALE = 0.5f; + +// симметричные плечи: вперёд и назад дают одинаковый максимум +static const uint16_t CH2_SPAN = 591; // min(1643-1024, 1024-433) +static const uint16_t CH4_SPAN = 578; // min(1654-1024, 1024-446) + +static const uint32_t FRAME_PERIOD_MS = 50; +static const uint32_t PIN_X = PA0; +static const uint32_t PIN_Y = PA1; + +static UART_HandleTypeDef s_sbusUart; + +// USART1 TX = PA9 (AF7), 100000 бод, 8E2, TX инвертирован +static void sbusUartInit() { + __HAL_RCC_GPIOA_CLK_ENABLE(); + __HAL_RCC_USART1_CLK_ENABLE(); + + GPIO_InitTypeDef gpio = {}; + gpio.Pin = GPIO_PIN_9; + gpio.Mode = GPIO_MODE_AF_PP; + gpio.Pull = GPIO_NOPULL; + gpio.Speed = GPIO_SPEED_FREQ_LOW; + gpio.Alternate = GPIO_AF7_USART1; + HAL_GPIO_Init(GPIOA, &gpio); + + s_sbusUart.Instance = USART1; + s_sbusUart.Init.BaudRate = 100000; + s_sbusUart.Init.WordLength = UART_WORDLENGTH_9B; // 8 данных + чётность + s_sbusUart.Init.StopBits = UART_STOPBITS_2; + s_sbusUart.Init.Parity = UART_PARITY_EVEN; + s_sbusUart.Init.Mode = UART_MODE_TX; + s_sbusUart.Init.HwFlowCtl = UART_HWCONTROL_NONE; + s_sbusUart.Init.OverSampling = UART_OVERSAMPLING_16; + s_sbusUart.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_TXINVERT_INIT; + s_sbusUart.AdvancedInit.TxPinLevelInvert = UART_ADVFEATURE_TXINV_ENABLE; + HAL_UART_Init(&s_sbusUart); +} + +// нормализация одной оси в [-1..1] с мёртвой зоной и асимметричными плечами +static float axisNorm(int adc, int lowEnd, int mid, int highEnd) { + float x; + if (adc < mid - DEADZONE) { + x = (float)(mid - adc) / (float)(mid - lowEnd); // к lowEnd -> +1 + } else if (adc > mid + DEADZONE) { + x = -(float)(adc - mid) / (float)(highEnd - mid); // к highEnd -> -1 + } else { + return 0.0f; + } + return constrain(x, -1.0f, 1.0f); +} + +// expo-кривая: гасит чувствительность у нейтрали, сохраняет края +static float expo(float x) { + return EXPO_K * x * x * x + (1.0f - EXPO_K) * x; +} + +static uint16_t toSbus(float x, uint16_t span) { + return (uint16_t)(SBUS_MID + lroundf(x * span)); +} + +static void sbusPack(uint8_t out[25], const uint16_t ch[16]) { + out[0] = 0x0F; + memset(out + 1, 0, 22); + uint32_t bitpos = 0; + for (int n = 0; n < 16; n++) { + for (int b = 0; b < 11; b++) { + if (ch[n] & (1u << b)) + out[1 + (bitpos >> 3)] |= 1u << (bitpos & 7); + bitpos++; + } + } + out[23] = 0x00; // флаги + out[24] = 0x00; // футер +} + +static int readAvg(uint32_t pin) { + uint32_t acc = 0; + for (int i = 0; i < 16; i++) + acc += analogRead(pin); + return acc / 16; +} + +void setup() { + Serial.begin(115200); + analogReadResolution(12); + pinMode(PIN_X, INPUT_ANALOG); + pinMode(PIN_Y, INPUT_ANALOG); + sbusUartInit(); +} + +void loop() { + static uint32_t next = 0; + uint32_t now = millis(); + if (now < next) + return; + next = now + FRAME_PERIOD_MS; + + int adcX = readAvg(PIN_X); + int adcY = readAvg(PIN_Y); + // вперёд = adcX к X_FWD (вниз) -> +1; вправо = adcY к Y_RIGHT -> +1 + float fwd = axisNorm(adcX, X_FWD, X_MID, X_BACK); + float right = axisNorm(adcY, Y_RIGHT, Y_MID, Y_LEFT); + + uint16_t ch[16]; + for (int i = 0; i < 16; i++) + ch[i] = SBUS_MID; + ch[1] = toSbus(expo(fwd) * RANGE_SCALE, CH2_SPAN); // канал 2 + ch[3] = toSbus(expo(right) * RANGE_SCALE, CH4_SPAN); // канал 4 + + uint8_t frame[25]; + sbusPack(frame, ch); + HAL_UART_Transmit(&s_sbusUart, frame, sizeof(frame), 20); + + Serial.print("adc="); + Serial.print(adcX); + Serial.print(","); + Serial.print(adcY); + Serial.print(" fwd="); + Serial.print(fwd, 3); + Serial.print(" right="); + Serial.print(right, 3); + Serial.print(" ch2="); + Serial.print(ch[1]); + Serial.print(" ch4="); + Serial.println(ch[3]); +} diff --git a/device_firmware/cart_firmware/tools/analyze.py b/device_firmware/cart_firmware/tools/analyze.py new file mode 100644 index 0000000..001f67e --- /dev/null +++ b/device_firmware/cart_firmware/tools/analyze.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Analyze a captured frame: extract pulse timing structure. + +Usage: .venv/bin/python tools/analyze.py captures/.npz +""" +import sys + +import numpy as np + +path = sys.argv[1] +d = np.load(path) +volts = d["volts"] +srate = float(d["srate"]) +dt_us = 1e6 / srate + +# threshold midway between the two dominant plateaus +hi_level = np.median(volts) # idle dominates the frame -> median = idle (high) +lo_level = np.percentile(volts, 10) +thr = (hi_level + lo_level) / 2 +bits = (volts > thr).astype(np.int8) +print(f"levels: high~{hi_level:.2f} low~{lo_level:.2f} thr={thr:.2f} (raw units)") + +# run-length encode +edges = np.flatnonzero(np.diff(bits)) + 1 +starts = np.concatenate(([0], edges)) +ends = np.concatenate((edges, [len(bits)])) +levels = bits[starts] +dur_us = (ends - starts) * dt_us + +print(f"{len(levels)} runs total") +print("\nidx level dur_us (first/last runs are idle padding)") +for i, (lv, du) in enumerate(zip(levels, dur_us)): + tag = "H" if lv else "L" + print(f"{i:4d} {tag} {du:10.2f}") diff --git a/device_firmware/cart_firmware/tools/capture.py b/device_firmware/cart_firmware/tools/capture.py new file mode 100644 index 0000000..79cd9b0 --- /dev/null +++ b/device_firmware/cart_firmware/tools/capture.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Capture one single-shot CH3 frame from Hantek DPO7204C, save raw + PNG. + +Flow: query settings -> :SINGle -> poll :TRIGger:STATus? until STOP -> +WAVeform:DATA:ALL? CHANnel3 (drained completely, multi-packet aware). +Scope is left in STOP so the on-screen frame matches the saved data. + +Usage: .venv/bin/python tools/capture.py [device] +Saves captures/.bin, captures/.npz, captures/.png +""" +import os +import sys +import time + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +DEV = sys.argv[2] if len(sys.argv) > 2 else "/dev/usbtmc2" +NAME = sys.argv[1] if len(sys.argv) > 1 else "capture" +OUTDIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "captures") +os.makedirs(OUTDIR, exist_ok=True) + +FIRST_HDR = 11 + 117 # '#9'+9-digit len, then 18 bytes counters + 99 bytes info +NEXT_HDR = 11 + 18 # follow-up packets: counters only + + +def read_exact(fd, n): + buf = b"" + while len(buf) < n: + chunk = os.read(fd, min(1 << 20, n - len(buf))) + if not chunk: + raise IOError("short read from scope") + buf += chunk + return buf + + +def read_packet(fd): + head = read_exact(fd, 11) + assert head[:2] == b"#9", f"bad packet start: {head!r}" + pkt_len = int(head[2:11]) + body = read_exact(fd, pkt_len) + return head + body + + +def query(fd, cmd): + os.write(fd, cmd.encode() + b"\n") + return os.read(fd, 256).decode(errors="replace").strip() + + +fd = os.open(DEV, os.O_RDWR) +print("IDN:", query(fd, "*IDN?")) + +tdiv = float(query(fd, ":TIMebase:SCALe?")) +vdiv = float(query(fd, ":CHANnel3:SCALe?")) +voff = float(query(fd, ":CHANnel3:OFFSet?")) +print(f"tdiv={tdiv} s/div vdiv={vdiv} V/div offset={voff} V") + +os.write(fd, b":SINGle\n") +for _ in range(100): + time.sleep(0.1) + st = query(fd, ":TRIGger:STATus?") + if st == "STOP": + break +else: + sys.exit(f"scope did not reach STOP (last status: {st})") +print("status: STOP (frame captured)") + +srate = float(query(fd, ":ACQuire:SRATe?")) +print(f"srate={srate:.3e} Sa/s") + +os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n") +pkt = read_packet(fd) +total_len = int(pkt[11:20]) +info_hdr = pkt[29:FIRST_HDR] +data = pkt[FIRST_HDR:] +raw_all = pkt +while len(data) < total_len: + os.write(fd, b"WAVeform:DATA:ALL? CHANnel3\n") + p = read_packet(fd) + raw_all += p + data += p[NEXT_HDR:] +print(f"received {len(data)} samples (declared {total_len})") +print("info header hex:", info_hdr.hex(" ")) +os.close(fd) + +raw = np.frombuffer(data[:total_len], dtype=np.uint8).astype(np.float32) +# unsigned 8-bit, 25.6 levels/div, mid-screen = code 128, offset shifts zero +volts = (raw - 128.0) / 25.6 * vdiv - voff +t = np.arange(len(volts)) / srate * 1e3 # ms + +base = os.path.join(OUTDIR, NAME) +with open(base + ".bin", "wb") as f: + f.write(raw_all) +np.savez(base + ".npz", volts=volts, srate=srate, vdiv=vdiv, voff=voff, tdiv=tdiv) + +fig, axes = plt.subplots(2, 1, figsize=(16, 8)) +axes[0].plot(t, volts, lw=0.5) +axes[0].set_title(f"{NAME} — full frame ({srate:.0e} Sa/s, {vdiv} V/div, off {voff} V)") +# zoom on activity: region where signal deviates from its median +dev_idx = np.where(np.abs(volts - np.median(volts)) > 0.5)[0] +if len(dev_idx): + lo = max(0, dev_idx[0] - int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) - 100) + hi = min(len(volts), dev_idx[-1] + int(0.05 * (dev_idx[-1] - dev_idx[0] + 1)) + 100) + axes[1].plot(t[lo:hi], volts[lo:hi], lw=0.7) + axes[1].set_title("zoom on activity") +for ax in axes: + ax.set_xlabel("t, ms") + ax.set_ylabel("U, V") + ax.grid(True, alpha=0.3) +fig.tight_layout() +fig.savefig(base + ".png", dpi=110) +print("saved:", base + ".png") +print(f"range: min={volts.min():.3f} V max={volts.max():.3f} V median={np.median(volts):.3f} V") diff --git a/device_firmware/cart_firmware/tools/compare.py b/device_firmware/cart_firmware/tools/compare.py new file mode 100644 index 0000000..375d37c --- /dev/null +++ b/device_firmware/cart_firmware/tools/compare.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Rigorous comparison of two SBUS captures (old remote vs our firmware). + +Usage: .venv/bin/python tools/compare.py captures/a.npz captures/b.npz +""" +import sys + +import numpy as np + + +def extract(path): + d = np.load(path) + v = d["volts"] + srate = float(d["srate"]) + idle = np.median(v) + p10, p90 = np.percentile(v, [10, 90]) + active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90 + thr = (idle + active) / 2 + phys_hi = v > thr # physical high (pulse) + # plateau levels: median of samples well inside each state + lvl_hi = np.median(v[phys_hi]) + lvl_lo = np.median(v[~phys_hi]) + # runs + sig = phys_hi.astype(np.int8) + edges = np.flatnonzero(np.diff(sig)) + 1 + starts = np.concatenate(([0], edges)) + ends = np.concatenate((edges, [len(sig)])) + levels = sig[starts] + dur_us = (ends - starts) * 1e6 / srate + # burst envelope: first to last physical-high sample + hi_idx = np.flatnonzero(phys_hi) + envelope_us = (hi_idx[-1] - hi_idx[0] + 1) * 1e6 / srate + # inner runs (drop leading/trailing idle) + runs = [(int(l), float(du)) for l, du in zip(levels[1:-1], dur_us[1:-1])] + # UART logic: logic1 == idle state; idle here is physical low + stream = [] + for l, du in runs: + n = max(1, round(du / 10.0)) + stream.extend([1 - l] * n) # physical high -> logic 0 + stream.extend([1] * 24) + frames = [] + i = 0 + while i + 12 <= len(stream): + if stream[i] == 1: + i += 1 + continue + byte = sum(b << k for k, b in enumerate(stream[i + 1 : i + 9])) + frames.append(byte) + i += 12 + # bit clock estimate: envelope should be 299 bits (last stop bits merge w/ idle) + n_units = round(envelope_us / 10.0) + bit_us = envelope_us / n_units + return { + "lvl_hi": lvl_hi, + "lvl_lo": lvl_lo, + "runs": runs, + "frames": frames, + "envelope_us": envelope_us, + "bit_us": bit_us, + "vmin": float(v.min()), + "vmax": float(v.max()), + } + + +a_path, b_path = sys.argv[1], sys.argv[2] +A, B = extract(a_path), extract(b_path) + +print(f"{'':24s} {'A: ' + a_path:>28s} {'B: ' + b_path:>28s}") +print(f"{'bytes decoded':24s} {len(A['frames']):>28d} {len(B['frames']):>28d}") +ha = " ".join(f"{x:02X}" for x in A["frames"]) +hb = " ".join(f"{x:02X}" for x in B["frames"]) +print(f"frames identical: {A['frames'] == B['frames']}") +print(" A:", ha) +print(" B:", hb) +print(f"{'run count':24s} {len(A['runs']):>28d} {len(B['runs']):>28d}") +qa = [round(du / 10) for _, du in A["runs"]] +qb = [round(du / 10) for _, du in B["runs"]] +la = [l for l, _ in A["runs"]] +lb = [l for l, _ in B["runs"]] +print(f"quantized run pattern identical: {qa == qb and la == lb}") +print(f"{'envelope, us':24s} {A['envelope_us']:>28.2f} {B['envelope_us']:>28.2f}") +print(f"{'bit time, us':24s} {A['bit_us']:>28.4f} {B['bit_us']:>28.4f}") +print(f"{'-> baud':24s} {1e6/A['bit_us']:>28.1f} {1e6/B['bit_us']:>28.1f}") +print(f"{'high plateau, V':24s} {A['lvl_hi']:>28.3f} {B['lvl_hi']:>28.3f}") +print(f"{'low plateau, V':24s} {A['lvl_lo']:>28.3f} {B['lvl_lo']:>28.3f}") +print(f"{'abs min/max, V':24s} {A['vmin']:>14.2f}/{A['vmax']:>12.2f} {B['vmin']:>14.2f}/{B['vmax']:>12.2f}") + +# worst run deviation from ideal 10us grid +da = max(abs(du - 10 * round(du / 10)) for _, du in A["runs"]) +db = max(abs(du - 10 * round(du / 10)) for _, du in B["runs"]) +print(f"{'worst grid dev, us':24s} {da:>28.2f} {db:>28.2f}") diff --git a/device_firmware/cart_firmware/tools/decode.py b/device_firmware/cart_firmware/tools/decode.py new file mode 100644 index 0000000..9d97c67 --- /dev/null +++ b/device_firmware/cart_firmware/tools/decode.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Decode a captured frame as SBUS: UART 100 kbit/s 8E2, 25-byte frame, +16 channels x 11 bits. + +Usage: .venv/bin/python tools/decode.py captures/.npz +""" +import sys + +import numpy as np + +BIT_US = 10.0 + +path = sys.argv[1] +d = np.load(path) +volts = d["volts"] +srate = float(d["srate"]) +dt_us = 1e6 / srate + +# idle level dominates the record; UART logic 1 == idle regardless of +# physical polarity (this line is standard inverted SBUS: idle low) +idle = np.median(volts) +p10, p90 = np.percentile(volts, [10, 90]) +active = p10 if abs(p10 - idle) > abs(p90 - idle) else p90 +thr = (idle + active) / 2 +if active > idle: + sig = (volts < thr).astype(np.int8) # pulses up -> logic 0 +else: + sig = (volts > thr).astype(np.int8) + +edges = np.flatnonzero(np.diff(sig)) + 1 +starts = np.concatenate(([0], edges)) +ends = np.concatenate((edges, [len(sig)])) +levels = sig[starts] +dur_us = (ends - starts) * dt_us + +stream = [] +for lv, du in zip(levels[1:-1], dur_us[1:-1]): + stream.extend([int(lv)] * max(1, round(du / BIT_US))) +# trailing idle of last stop bits is trimmed by run cut; pad with idle-high +stream.extend([1] * 24) + +# deframe 8E2: start=0, 8 data LSB-first, even parity, 2 stop=1 +i = 0 +frames = [] +errors = [] +while i + 12 <= len(stream): + if stream[i] == 1: + i += 1 + continue + data = stream[i + 1 : i + 9] + par = stream[i + 9] + stops = stream[i + 10 : i + 12] + byte = sum(b << k for k, b in enumerate(data)) + if par != (sum(data) & 1): + errors.append((len(frames), "parity")) + if stops != [1, 1]: + errors.append((len(frames), f"stop={stops}")) + frames.append(byte) + i += 12 + +print(f"decoded {len(frames)} bytes, errors: {errors if errors else 'none'}") +print("hex:", " ".join(f"{b:02X}" for b in frames)) + +if len(frames) >= 25 and frames[0] == 0x0F: + payload = frames[1:23] + flags = frames[23] + footer = frames[24] + bits = 0 + for k, b in enumerate(payload): + bits |= b << (8 * k) + ch = [(bits >> (11 * n)) & 0x7FF for n in range(16)] + print("\nSBUS frame OK" if footer == 0x00 else f"\nfooter unexpected: {footer:02X}") + print("channels:", ch) + print(f"flags: 0x{flags:02X} (bit0=ch17 bit1=ch18 bit2=frame_lost bit3=failsafe)") +else: + print("not a valid SBUS frame (no 0x0F header)") diff --git a/device_firmware/cart_firmware/tools/scope.py b/device_firmware/cart_firmware/tools/scope.py new file mode 100644 index 0000000..9321295 --- /dev/null +++ b/device_firmware/cart_firmware/tools/scope.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Minimal SCPI helper for Hantek DPO7204C over /dev/usbtmc2.""" +import os +import sys +import time + +DEV = "/dev/usbtmc2" + + +class Scope: + def __init__(self, dev=DEV): + self.fd = os.open(dev, os.O_RDWR) + + def write(self, cmd: str): + os.write(self.fd, cmd.encode() + b"\n") + + def read(self, n=1 << 20, timeout=3.0) -> bytes: + # usbtmc read returns one transfer chunk; loop until short read + chunks = [] + end = time.time() + timeout + while time.time() < end: + try: + data = os.read(self.fd, n) + except OSError: + break + chunks.append(data) + if not data or len(data) < n: + break + return b"".join(chunks) + + def query(self, cmd: str, timeout=3.0) -> str: + self.write(cmd) + return self.read(timeout=timeout).decode(errors="replace").strip() + + def query_raw(self, cmd: str, timeout=5.0) -> bytes: + self.write(cmd) + return self.read(timeout=timeout) + + def close(self): + os.close(self.fd) + + +if __name__ == "__main__": + s = Scope() + for cmd in sys.argv[1:]: + if cmd.endswith("?"): + print(f"{cmd:40s} -> {s.query(cmd)}") + else: + s.write(cmd) + print(f"{cmd:40s} [sent]") + s.close() diff --git a/device_firmware/combined.vnafw b/device_firmware/multi_libreVNA_firmware/combined.vnafw similarity index 100% rename from device_firmware/combined.vnafw rename to device_firmware/multi_libreVNA_firmware/combined.vnafw diff --git a/device_firmware/combined_external_lo_slave.vnafw b/device_firmware/multi_libreVNA_firmware/combined_external_lo_slave.vnafw similarity index 100% rename from device_firmware/combined_external_lo_slave.vnafw rename to device_firmware/multi_libreVNA_firmware/combined_external_lo_slave.vnafw