cart_firmware_added

This commit is contained in:
Ayzen
2026-08-26 15:40:10 +03:00
parent 6ada811c2f
commit cc6d189d52
12 changed files with 604 additions and 4 deletions
+10
View File
@@ -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"
]
}
@@ -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 в измеренных
диапазонах.
@@ -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
+144
View File
@@ -0,0 +1,144 @@
// Пульт тележки: стик (АЦП PA0/PA1) -> SBUS на USART1 TX (PA9).
// Протокол: инвертированный SBUS, 100000 бод 8E2, 25 байт каждые 50 мс
// (см. docs/protocol.md). USB CDC (Serial) — отладочный вывод.
#include <Arduino.h>
// ---- калибровка стика (АЦП 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]);
}
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Analyze a captured frame: extract pulse timing structure.
Usage: .venv/bin/python tools/analyze.py captures/<name>.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}")
@@ -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 <name> [device]
Saves captures/<name>.bin, captures/<name>.npz, captures/<name>.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")
@@ -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}")
@@ -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/<name>.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)")
@@ -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()