Repository navigation
Releases: raylanlin/smarttune-cli
Release list
v3.5.0 — Betaflight aligned with its own tooling
Same treatment ArduPilot got in v3.4.0, now for Betaflight: cross-checked the
BF path against the firmware itself (src/main/blackbox/blackbox.c) plus
betaflight/blackbox-log-viewer and Plasmatree/PID-Analyzer. Full
item-by-item table in
docs/ALIGNMENT_BETAFLIGHT.md.
Fixed — blackbox_high_resolution was ignored (data corruption)
BF 4.4+ has a blackbox_high_resolution option that logs gyro and setpoint
with an extra digit of precision by multiplying them by 10
(blackbox.c:2313: blackboxHighResolutionScale = high_resolution ? 10.0f : 1.0f).
It applies to gyroADC, gyroUnfilt, rcCommand and setpoint
(blackbox.c:1262-1289); PID term fields are not scaled. The flag is in the
log header, and blackbox-log-viewer divides by it before display.
SmartTune ignored it, so on any high-resolution log:
- gyro / setpoint / rcCommand read 10x too large, and
- the outlier sanitiser — bounded by
rate_limits x 1.1(~2198) — then treated
every sample above ~220 deg/s real as corruption and interpolated it
away. The harder the flying, the more of the trace was silently replaced.
Everything downstream (vibration level, step response, filter and PID
recommendations) followed the corrupted signal. The header flag is now parsed
and the four affected field families are descaled; state is reported in
extras["blackbox_info"].
Changed — step response realigned to PID-Analyzer
The BF step response previously followed PTstepcalc.m's published
description (PIDtoolbox; the repo is delisted, so nothing could be verified):
2-second segments, constant-λ regularisation, segment/4 spacing — and no
window function at all. PID-Analyzer, the open and verifiable ancestor of
this algorithm family (and of the WebTools implementation we already match for
ArduPilot), uses np.hanning(self.flen) (PID-Analyzer.py:64). An unwindowed
2-second segment leaks spectrally into the deconvolution and biases the
transfer function being estimated.
Betaflight now delegates to the same verified kernel as ArduPilot
(Hanning window, 1 s frame = framelen, spacing flen/16 = superpos 16,
Gaussian-CDF SNR regularisation at cutfreq 25 Hz, cumsum over resplen
0.5 s), supplying only BF's input gating: peaks in [20, 500] deg/s, which is
exactly PID-Analyzer's low-input response (low_high_mask(max_in, 500)).
info.method is now pid_analyzer_wiener, and info.steady_state /
steady_state_ok report whether the deconvolution converged instead of hiding
a bad curve.
compute_step_response_for_axis() also stops resampling through the IMU path
by default (prefer_imu=False): for Betaflight Actual is gyroADC, so
the old default interpolated one signal onto its own timebase for nothing.
Checked — gyro scaling was already correct
The going-in suspicion was a missing gyro_scale. The viewer does apply one
(flightlog.js:1468), but the firmware writes gyro_scale = 1.0f
(blackbox.c:1513) because gyroADC comes from gyro.gyroADCf[], already in
deg/s — so reading it raw is right and the viewer's formula collapses to
identity. The header value is now recorded
(blackbox_info.gyro_scale_header) so a legacy log where it is not 1.0 is
visible rather than silent.
Added
extras["gyro_unfiltered"]— the pre-filtergyroUnfilttrace (descaled),
which is what notch targeting should be derived from.FlightData.gyrostays
the filteredgyroADCso existing vibration thresholds keep their
calibration; a--prefilternotch mode is the planned follow-up.extras["blackbox_info"]:high_resolution,high_resolution_scale,
gyro_source,has_unfiltered_gyro,gyro_scale_header.max_target_amplitudeon the shared step-response kernel (opt-in; ArduPilot
keeps WebTools' no-upper-gate behaviour).
Tests
- New
tests/test_bf_alignment.py(14 cases): high-resolution descaling of
gyro/setpoint, flag off leaves values alone, a 1500 deg/s manoeuvre no longer
clipped by the sanitiser, PID terms not descaled, unfiltered gyro exposed
and descaled, missing-gyroUnfiltreporting,gyro_scaleheader recorded,
step response using the windowed kernel, high-input (>500 deg/s) and
low-amplitude (<20 deg/s) window exclusion,gyro_adcas the default output
signal, and that the new upper gate stays opt-in for ArduPilot.
Docs
- New
docs/ALIGNMENT_BETAFLIGHT.md(sources with commit hashes, firmware line
references, what was wrong, what was verified correct, every deliberate
deviation, and how to cross-check a real.bblagainst both reference tools). - New
docs/TEST_PLAN_v3.5.md.
v3.4.0 — MAVLink telemetry log (.tlog) support
Ground-station recordings (Mission Planner / MAVProxy / QGroundControl) are now a
first-class input — with their limits stated out loud rather than papered over.
A .tlog is not a thinner .bin; it is a different source:
onboard .bin |
telemetry .tlog |
|
|---|---|---|
| written by | flight controller (SD card) | ground station (PC) |
| rate | loop rate, 400 Hz+, lossless | stream rates, 1–50 Hz, lossy |
| rate controller | PIDR/PIDP/PIDY with P/I/D terms | not streamed at all |
| gyro | IMU at 400 Hz+ | RAW_IMU / ATTITUDE, 2–50 Hz |
| coverage | whole flight | only while the link was up |
Added
smarttune/platform/ardupilot/tlog_parser.py— MAVLink telemetry parser.
A.tlogfrom an ArduPilot vehicle is ArduPilot, so it is handled inside the
existing adapter (ArduPilotAdapter.parse()dispatches on extension) — no new
platform, and every existing analyzer/report path keeps working unchanged.- PID reconstructed from
ATTITUDE_TARGET(desired body rates, rad/s→deg/s)
interpolated ontoATTITUDE(actual body rates). P/I/D terms are zero-filled
because telemetry does not carry them — the same contract as the existing
legacy-RATEfallback. NoATTITUDE_TARGET→ no PID analysis and a stated
reason, never a fabricated desired signal. - Gyro/accel/mag from
RAW_IMU/SCALED_IMU2/SCALED_IMU3(mG, mrad/s,
mgauss) orHIGHRES_IMU(SI); falls back toATTITUDEbody rates when no IMU
stream exists, and says that the trace is EKF-filtered, not raw. - Battery (
SYS_STATUS/BATTERY_STATUS), motors (SERVO_OUTPUT_RAWPWM→0-1),
GPS (GPS_RAW_INT/GLOBAL_POSITION_INT), parameters (PARAM_VALUE, mirrored
to generic keys likepid.roll.p), modes (HEARTBEAT.custom_mode→ Copter mode
names),STATUSTEXT, firmware version (AUTOPILOT_VERSION), frame type. - Structural detection (
looks_like_tlog): 8-byte big-endian timestamp + MAVLink
v1/v2 start byte + a plausible GCS epoch — a mis-named file is rejected without
parsing.
- PID reconstructed from
- Honesty layer.
extras["telemetry_notes"]+extras["log_source"]record what
the recording could and could not provide: missing P/I/D terms, sub-50 Hz rate
signals, the FFT Nyquist ceiling vs the 40–120 Hz resonance band, absent
accelerometer stream, missing parameter download, and every radio gap over 3 s.stune qualityscores telemetry lower (−15, −30 without any desired-rate signal),
lists the caveats as issues, and setsadviceto point at the onboard.bin.stune analyzeprints a "Telemetry Log Limits" block; JSON/MCP payloads carry
telemetry_notesandlog_source.
.tlogaccepted by the MCP path validator and advertised in
stune platforms/smarttune_list_platforms.
Changed — aligned with ArduPilot's own tooling
Cross-checked the ArduPilot analysis path against the reference implementations
line by line (UAVLogViewer mavlinkParser.js / modeMaps.js /
mavlinkDataExtractor.js; WebTools StreamStats.js and PIDReview.js).
Full item-by-item table, including every deliberate deviation, in
docs/ALIGNMENT_ARDUPILOT_WEBTOOLS.md.
Step response — output signal was the wrong source (affects .bin too).
PIDReview builds its test sets from PIDx.Act / RATE.R,P,Y — the rate the
controller measured (PIDReview.js:1600/1608; the Readme says "between the
target and actual signals"). SmartTune preferred IMU.Gyr whenever present,
and its docstring wrongly claimed that matched WebTools. That path also
interpolated the target onto the gyro time base and re-sampled both onto a
uniform grid — adding interpolation dynamics to the transfer function being
measured, using an unfiltered signal. compute_step_response_for_axis() now
defaults to Act (prefer_imu=False); the gyro path remains as an explicit
comparison, and the result reports info.output_signal.
settling) toward the reference implementation — the one intentional numeric
change in this release.
Step response — off-by-one in the SNR cutoff bin. findIndex(x => x >= 25)
returns the matching bin; searchsorted(..., side="right") returned the one
after it, so an FFT bin landing exactly on 25 Hz got one extra bin of
regularisation. Now side="left".
Step response — cutoff clamp for low-rate logs (deliberate deviation).
With cutfreq fixed at 25 Hz, findIndex returns -1 when 25 Hz is beyond
the spectrum → negative len_lpf → sn all ones → H ≈ 0, i.e. a flat zero
step response. WebTools only loads 400 Hz+ .bin logs so it never reaches that
branch; a .tlog at 1-50 Hz reaches it every time. The cutoff is clamped to
0.8 x Nyquist and flagged as info.cutfreq_clamped.
Telemetry — GCS heartbeats were treated as the vehicle's. A tlog carries
heartbeats from both ends of the link; Mission Planner announces itself as
MAV_TYPE_GCS. The parser read autopilot id, frame type and flight mode from
whichever heartbeat came first, so a GCS-first recording mis-detected the
vehicle and flapped between the vehicle's mode and the GCS's custom_mode.
Now only vehicle-type heartbeats are used (UAVLogViewer's validGCSs rule),
with log_source.gcs_heartbeats_ignored reporting how many were skipped.
Telemetry — mode maps are keyed by MAV_TYPE, not hardcoded to Copter:
Copter / Plane / Rover / Sub / Tracker tables copied verbatim from
modeMaps.js, plus UAVLogViewer's base_mode bit fallback (4/8/16 →
AUTO/GUIDED/STABILIZE) for types with no table. custom_mode 5 is LOITER on
Copter but FBWA on Plane — the old code called both "loiter". Raw mode names
now match the firmware strings (ALT_HOLD, not ALTHOLD) and map through the
same canonical vocabulary as the DataFlash path.
Telemetry — vehicle clock is the timeline. UAVLogViewer plots against
time_boot_ms, the flight controller's own clock; the tlog wrapper timestamp
is the PC's wall clock and carries radio/buffering jitter. Samples now resolve
to time_boot_ms (or a boot-relative time_usec, guarded against UNIX-epoch
values), and wall-clock-only messages (SYS_STATUS, PARAM_VALUE,
STATUSTEXT) are shifted onto that timeline through the median clock offset.
log_source.clock reports which clock was used.
Telemetry — one system per recording + real link-loss measurement, from
StreamStats: frames are keyed by srcSystem/srcComponent, so the parser now
locks onto the first vehicle heartbeat's system id and ignores companion
computers or a second vehicle (frames_from_other_systems, systems), and
counts dropped frames from MAVLink sequence numbers with 8-bit wrap
(frames_dropped, drop_percent, plus a note above 5%) instead of relying on
a wall-clock gap heuristic.
Telemetry — smaller alignments: param ids sanitised with UAVLogViewer's
exact regex ([^A-Za-z0-9_] stripped), STATUSTEXT.severity kept, AHRS2
accepted as a position fallback (as in UAVLogViewer, and as AHR2 already is
in our DataFlash path), MAV_TYPE table corrected against their vehicles
map (3 = coaxial, 4 = heli, 19-24 = VTOL, 29 = dodeca), and all-zero magnetometer
rows treated as "no compass" rather than a measurement.
Refused on purpose
- A
.tlogwhoseHEARTBEATreports a PX4 autopilot raisesLogFormatError
pointing at the onboard.ulg— PX4 telemetry has no rate-controller data and
the ULog does. - A recording with no analysable vehicle data raises with a precise reason — an
empty/truncated file and a GCS-only recording (logging started before the
link came up) get different hints.
Tests
- New
tests/test_tlog_parser.py(33 cases): structural sniff (accept/reject
.bin, implausible timestamp), adapter + registry auto-detection, PID
reconstruction with unit conversion, zero P/I/D contract, no-target skip with
reason, gyro/accel/mag unit conversions,ATTITUDEgyro fallback, low-rate FFT
warning, generic parameter mirroring, battery/motor/GPS/statustext/metadata,
Copter mode mapping, link-gap reporting, missing-parameter note, PX4 refusal,
empty-log refusal, and quality-layer integration. Alignment cases pin the
behaviours above: vehicle clock as timeline, wall-clock-only messages placed
via the offset, GCS-clock fallback, GCS heartbeats ignored (including
GCS-first ordering), armed-state timeline, Copter vs Plane mode maps,
base_modefallback, epochtime_usecrejection, param-id sanitising,
AHRS2 fallback, foreign-system exclusion, second-vehicle reporting,
sequence-number drop counting, sequence wrap, and the GCS-only refusal.
Docs
- README: telemetry-log section (comparison table, what is and is not possible,
commands), platform table row, auto-detection bytes, security extension list. skill/SKILL.md+skill-mcp/SKILL.md: a.tlogis a screening source —
agents must repeattelemetry_notesand must not present its numbers as
tuning-grade.- New
docs/ALIGNMENT_ARDUPILOT_WEBTOOLS.md— the full line-by-line
comparison against UAVLogViewer and WebTools, with commit hashes, what
matched, what was fixed, and every deliberate deviation (including the
unitScalevs magnitude-heuristic difference left for a later release). - New
docs/TEST_PLAN_v3.4.md.
SmartTune v3.3.1 — Search result folding + honest truncation
v3.3.1 (2026-08-13) — Search result folding + honest truncation
Field feedback from agent use: a search for "monitor" returned ~46 rows of which
~40 were numbered-instance clones (BATT2_MONITOR..BATT9_MONITOR), drowning the
handful of genuinely distinct parameters — and pushing them past the limit with
no truncation signal, so the agent concluded "all hits are battery-related"
from a silently incomplete set.
Changed
- Numbered-group folding in search results (CLI
--searchand MCP
smarttune_search_params): instance clones fold into their base parameter
with aninstanceslist (BATT_MONITOR+["BATT_", "BATT2_", ..., "BATT9_"]). Ranking order is preserved; a numbered parameter whose base is
absent from the result set (e.g.SERVO10_FUNCTION,EK3_MAG_CAL) is kept
as-is. Real effect: "monitor" on Copter-4.5 goes 219 raw → 115 distinct rows.
Display-level only —validate/get_paramalways use exact names.
Implemented once insmarttune/platform/params.py::collapse_numbered(),
shared by CLI and MCP. - Truncation is now signalled, never silent. When
returned < count, the
block carriestruncated: trueplus anote("N more distinct hits not
shown — use a more specific keyword or raise limit"). Terminal output prints
the same warning; folded rows are listed under the table. Response blocks
also carryraw_count(pre-fold) alongsidecount(distinct).
Tests
- New
tests/test_search_collapse.py(7 cases): fold into base, order
preservation, clone-before-base, numbered-without-base kept, real-table
monitor fold,instancesin CLI JSON, truncated+note contract at low limit.
SmartTune v3.3.0 — Firmware-version parameter tables + ArduPilot Copter-4.5
v3.3.0 (2026-08-13) — Firmware-version parameter tables
Added
- Multiple firmware versions per platform. Versioned tables live at
smarttune/knowledge/params/<platform>.<fw>.json; the plain<platform>.json
stays the default. Resolution:ParamTable.from_knowledge(platform, fw_version),
withavailable_versions(platform)listing what exists.- CLI:
stune params ... --fw-version copter-4.5(all modes: browse / search /
validate / batch / lint);stune paramsnow lists each platform's versions. - MCP: all six parameter tools accept
fw_version; an unknown version returns
E4011with the available list (all-platform scans skip platforms lacking
that version). - Builder:
tools/build_param_tables.py --fw-tag copter-4.5writes a versioned
table and stampsfw_versioninto its metadata.
- CLI:
- New table: ArduPilot Copter-4.5 (
ardupilot.copter-4.5.json) — 4,121
parameters / 243 groups / 1,060 enums / 186 bitmasks, generated from
Copter-4.5/Parameters.md(raylanlin/ParameterRepository). Includes per-param
descriptions, ranges, units, increments, Advanced/Standard audience, reboot/
read-only flags. Zero lint errors. The default ArduPilot table remains
Copter-4.1 (apm.pdef.json) for now; example of a real difference:
ATC_RAT_RLL_Pmax is 0.5 in 4.5 vs 0.35 in 4.1.
Removed
- MCP
smarttune_validate_param: the deprecatedstatusalias (announced in
v3.2.1) is gone — useverdict.
Tests
- New
tests/test_fw_versions.py(7 cases): platform listing excludes versioned
files, version listing, versioned load + normalization (Copter_4.5→
copter-4.5), unknown-version error carries the available list, CLI
--fw-versionend-to-end including a validate case that passes on 4.5 and
fails on the 4.1 default.
Deferred to v3.3.x
- Web UI (drop-a-JSON dashboard extending the HTML report).
build_param_tables.pynativeParameters.mdparsing (the Copter-4.5 table
was generated with an equivalent transform; the md parser needs to land in the
builder for one-command reproducibility — the acceptance plan flags this).
SmartTune v3.2.1 — Inline recommendation validation + batch validate + envelope contract tighten
v3.2.1 (2026-08-13) — Inline recommendation validation + batch validate
Follow-up release closing the v3.2.0 backlog. No analysis numbers change.
Added
- Inline recommendation validation —
analyze_log/analyze_pid/analyze_fft/
analyze_magfit(services layer, so both CLI-f jsonand MCP benefit) now attach
validated/validation_status(+validation_messageon rejection) to every
recommendation they return, checked against the platform parameter table. The
"always validate before recommending" rule becomes a mechanism instead of a discipline:
agents no longer spend onevalidate_paramround-trip per recommendation. Absence of the
field means "not checked", never "approved" (annotation fails open if the table is missing). - Batch validation — one call for a whole recommendation set:
- CLI:
stune params --validate-batch FILE|- -p <platform>(JSON array of
{"param", "value"};-reads stdin; exit 0 only if every entry is valid) - MCP:
smarttune_validate_params(recommendations, platform)(tool #16), per-entry
verdicts +all_validsummary
- CLI:
stune analyze --modules pid,fft— run a subset of modules (parity with MCP
include_modules; skipping unneeded modules speeds up analysis) and
--max-recommendations N(json output cap). Both work in text and json mode.
Fixed
- Dict-form FFT recommendations were silently dropped from JSON payloads and Markdown
reports ({generic_name: value}shape from the FFT analyzer). They are now converted to
standard recommendation entries (action: "set", platform-translated names) in both
serialize_fft_resultandFullAnalysisResult.all_recommendations. smarttune-mcpon Python 3.9 / without the[mcp]extra now prints an actionable
message (install instructions; the CLI still supports 3.9) instead of a bare
ImportErrortraceback. The module stays importable everywhere via a guarded import.
Changed — envelope contract tightened
- CLI JSON envelope
statusis strictlyok/erroragain. The domain verdict of
params --validatemoved into the payload asverdict
(ok/not_found/out_of_range/not_a_member/not_an_integer/
unverifiable). A rejection is a successful call (envelopestatus: "ok",
valid: false, exit code 1) — consumers keying "non-ok status ⇒ read .error" no longer
break.emit_result()dropped itsstatusoverride parameter. - MCP
smarttune_validate_paramnow returnsverdictalongside the oldstatuskey;
statusis deprecated and will be removed in v3.3.
Tests
- New
tests/test_inline_validation.py(7 cases): pre-validated recommendations
(ok / out_of_range / not_found), non-numeric suggestion → unverifiable, missing table
fails open, dict-FFT conversion in serialize + all_recommendations. tests/test_param_tables.py: verdict-field contract, 3 batch-validate CLI cases.tools/smoke_mcp.py: expects 16 tools, assertsverdictfield, batch tool round-trip,
and thatanalyze_logrecommendations arrive pre-validated on a real log.
Docs
- README: batch validate +
--modules/--max-recommendationsexamples, MCP tool table
13→16 with the Python 3.10+ requirement called out, contract section updated for
verdict+ inline validation. - New
docs/TEST_PLAN_v3.2.1.md— incremental acceptance spec for this release.
SmartTune v3.2.0 — Parameter table rebuild (groups + enum meanings) + validation gate fix
v3.2.0 (2026-08-12) — Parameter table rebuild (groups + enum meanings) + validation gate fix
The data side was fully rebuilt, and the code side fixes the security issue where
"validation" did not actually validate. Parameter tables are now generated
artifacts: the scraper lives in the repo (tools/build_param_tables.py) and a
linter guards them (stune params --lint).
Data: parameter tables regenerated for all three platforms (schema_version 2)
| Platform | Params | Groups | Upstream source |
|---|---|---|---|
| ArduPilot | 2,839 | 194 | apm.pdef.json (raylanlin/ParameterRepository → Copter-4.1) |
| Betaflight | 814 | 82 | src/main/cli/settings.c + fc/parameter_names.h (BF has no metadata artifact; firmware source parsed directly) |
| PX4 | 1,908 | 78 | docs/public/config/failsafe/parameters.json (output of PX4's own px4params generator) |
Old-table defects fixed (each has a corresponding regression test):
- Descriptions shifted by one column —
ARM_MAHcarriedBATT_OPTIONS' description,BATT_LOW_MAHcarriedCRT_MAH's. Names were sorted, descriptions were not; the zip misaligned. This is worse than NOT FOUND: an AI makes recommendations from the wrong description and the user never notices. - Names stripped of group prefixes, inconsistently —
MONITOR(should beBATT_MONITOR),0_BAUD(SERIAL0_BAUD),10_DIRECTION(SERVO10_DIRECTION),2SRV_IMAX, plusLOW_VOLTcoexisting withBATT_LOW_VOLT. Root cause: the scraper read// @Descriptionwithout expanding group prefixes (the@PREFIX@placeholders left in descriptions were the smoking gun), while stripping the prefix from names. Now the real firmware names are always used. - Missing @values / @bitmask — the old tables had 0
valuesfields; the AI had no way to knowBATT_MONITOR=4means "Analog Voltage and Current". Now ArduPilot's 781 enums + 112 bitmasks, PX4's 360 + 37, and Betaflight's 164 lookup tables all carry member meanings. - Wrong type inference — floats like
ACCEL_R_MAX,ACRO_Y_EXPO,ACC_BIAS_LIMwere taggedenum(which combined with the validation bug below into a security issue). - default uniformly 0.0 (invented, not scraped) — now ArduPilot/Betaflight write
nullwhen upstream does not publish defaults (unknown ≠ 0); all 1,908 PX4 params carry real upstream defaults. - category misclassification —
FL_FF,I2C_ADDR,SERIAL_NUM,FS_VOLTSRCwere all filed under battery. Category is now derived from parameter group + tuning-domain rules.
New fields: group (the firmware's own parameter group), display_name, values, bitmask, increment, user (Standard/Advanced), reboot_required, read_only, unresolved_ref. Tables carry a source provenance block (upstream file + firmware version + generation date).
Fixed — validation gate (security)
ParamTable.validate()returnedTrue, "value accepted"for enums — any value passed. Combined with the type mislabeling above, a large share of the table completely skipped range validation:ACCEL_R_MAXhas min 0.0, yetvalidate(999999)returned True. README sells this tool as a mandatory safety gate before recommending; the gate was wide open for enums.- Now:
validate_detail()returns a structured verdict —status∈ok / not_found / out_of_range / not_a_member / not_an_integer / unverifiable; enums are checked against real members, bitmasks against real bit spans, and rejections return the allowed values with meanings to the AI. - fail-closed: discrete params with neither member tables nor ranges return
valid=false, status="unverifiable"instead of silently passing. validate()keeps its(bool, str)signature for backward compatibility.
- Now:
- Packaging missed files:
knowledge/params/*.jsonwas not inpackage-data— after pip install,stune paramsand the three MCP param tools failed withFileNotFoundError. (Fixed in v3.1; recorded here.) ParamDef(**item)blew up the whole table withTypeErroron new upstream fields → switched tofrom_dict()which ignores unknown keys, forward-compatible.
Fixed — MCP reliability
- Payload slimming:
smarttune_list_paramsused to return the whole table as full objects (ArduPilot: 2,839 entries with long descriptions ≈ 600 KB per response — likely the real culprit behind that "no response" incident). It now returns slim rows (name/type/range/unit/one-liner) withlimit/offsetpaging, and refuses without a group or category, pointing to the group index first; full descriptions and enum members are served by the newsmarttune_get_param. - stdout hygiene: stdio MCP's stdout may only carry JSON-RPC. SmartTune itself writes to stderr (audited, no print leaks), but third-party parsers on the analysis path (pyulog / pymavlink / matplotlib) may print — one line of noise kills the whole stream. All service calls are now wrapped in
_quiet_stdout(), redirecting stdout to stderr. - Unified error shape: there used to be at least three (
{"error": str},{"error","code","hint"},{"valid":false,"error"}) with no retry marker. Now success is{ok: true, …}, failure is{ok: false, error_code, message, hint, retryable}so clients can tell "retryable" from "don't bother". A rejected parameter value is a successful call +valid: false, not a transport error. - Param tools no longer pay the numpy startup cost:
from smarttune.platform.params import ParamTableused to executesmarttune/platform/__init__.py→base→models.flight_data→import numpy. Param tools only use the stdlib but were dragged through the whole scientific stack by the package__init__; every MCP call_tool spawns a new process, so looking up one param loaded everything. Switched to PEP 562 module-level__getattr__lazy import; public API unchanged.
Added
tools/build_param_tables.py— the missing scraper, now in the repo. One generation path per platform, output carries provenance blocks;--checkruns the linter directly. Pure stdlib.smarttune/platform/param_lint.py— parameter table health check:name_shape/suffix_collision/placeholder_leak/discrete_without_members/constant_default/range_inverted/enum_key_not_int/empty_description/duplicate_name. Every rule maps to a real incident. Current tables: 0 errors.- MCP
smarttune_list_param_groups— see the group index first (80–200 groups with counts/categories/sample members), then drill in; replaces "pull the whole table at once". - MCP
smarttune_get_param— full definition of a single param (description + range + default + enum meanings). - CLI group browsing:
stune params ap --groups,--group ATC_,--lint, and ranked--search(exact name > prefix > substring > display name > description > enum label);stune params --search "analog voltage"findsBATT_MONITOR. Listing a platform without filters shows the group index instead of dumping 2,839 rows. - All
stune paramssub-modes support-f json.
Tests
- New
tests/test_param_tables.py(37 cases): schema/provenance/lint zero errors for all three tables, names are real firmware names, descriptions not shifted, defaults not invented, enum member meanings, fail-closed validation, group index and in-group queries, search ranking, slim/full payload shapes, and 8 CLI end-to-end cases.
Verification
- New
docs/TEST_PLAN_v3.2.md— an executable acceptance spec, item by item (static checks / unit tests / parameter table data regression /
9 validation-gate cases / JSON contract / MCP contract & payload size limits / lazy loading / wheel contents / zero analysis-value regression),
with pass criteria, failure report format, and a "known limitations (not bugs)" list. - New
tools/smoke_mcp.py— an MCP stdio smoke test with a built-in JSON-RPC client: checks 15 tools,
payload size limits, unified error shape, the validation gate, and any stdout noise makes it fail outright
(the detection mechanism for the last "no response" incident). No mcp client library dependency.
Docs
- README:
stune paramssection rewritten as "group → param → validate"; new Parameter tables subsection (three-platform source table + regeneration commands + linter notes); MCP tool table 13 → 15 with payload contract. skill-mcp/SKILL.md: tool list and param query workflow updated in sync.
v3.0.3 — R5 Performance + R6 Cross-Module Contract Fixes
What's New in v3.0.3
R5 — Performance Optimization (bit-identical, zero numerical change)
- magfit: New
ned_to_body_batch()batch quaternion rotation +np.bincountbin weights vectorization - AP step_response_fft:
np.fullscale +np.cumsumSNR gaussian vectorization
R6 — Cross-Module Contract Bug Fixes
- 🔴 A2 generic-key injection (AP/BF):
parse()now injects generic keys (pid.roll.petc.) into params. Previously,PIDReviewer._get_current_pidalways returned 0.0 because it queried generic keys while only native names were stored — all PID parameter suggestions were silently discarded. - 🟡 BF pid_bounds: Added Betaflight integer-scale bounds (P[10,150]/I[20,220]/D[0,100]/FF[0,300]). Default AP-scale bounds (0.01~0.5) would clamp BF 45×1.1 down to 0.5.
- 🟡 thresholds shape guard: New
_is_axis_threshold_shape()validates knowledge-base thresholds are in the consumer-expected shape. Previously, allpid_rules.jsonthresholds were dead config — the analyzer always used hardcoded defaults. - New
tests/test_platform_contracts.py(8 tests): A2 injection, thresholds guard, BF bounds, BF gain not clipped.
167 tests passed
Full Changelog: v3.0.2...v3.0.3
SmartTune v3.0.2 — PX4 ULog + A1 Convergence + Label Unification
Changes since v3.0.0
v3.0.1 — 14 Architecture Review Fixes (2026-06-11)
14 critical/high severity fixes from Claude architecture review:
- C1: magfit 100% KeyError crash fixed (was silently skipped)
- C2: mGauss unit normalization across magfit pipeline
- C3: INS_HNTCH_REF semantic fix — prevents incorrect dynamic notch params
- C4: Skip current_val ≤ 0 recommendations (removes false "0.0 → 0.01" suggestions)
- C5/C6: ODI symmetric soft-iron matrix + PitchIn guard
- C7/C8: imu_dict actual construction + bidirectional overshoot + settling=-1 sentinel
- C10: step_response_fft aligned with WebTools/PID Toolbox reference implementations
- C12/C14/A3/A4: frequency band merge, ARX fallback markers, registry metadata snapshot, TYPE_CHECKING
v3.0.2 — PX4 ULog + A1 Convergence + Label Unification (2026-06-12)
PX4 Full Implementation (first time complete):
- ULog parser based on pyulog — all uORB topics mapped (vehicle_rates_setpoint, sensor_combined, ekf2_innovations, etc.)
- Param A2 dual-write contract
- sensor_combined fallback path for pre-v1.13 firmware
- PX4 knowledge base (pid_rules.json, filter_rules.json)
- validate_px4_ulog.py — one-command end-to-end validation
- `stune quality/pid/fft/sysid/analyze -i .ulg` all functional
A1 Convergence (services layer unification):
- `run_module()` unified core — CLI and services no longer have duplicate analyzer wiring
- B2 fix: fft_result now properly assigned to FullAnalysisResult
- sysid --na/--nb now actually passes through (was broken)
- filter and quality commands call services layer (D1 fix)
- t_synthetic_regression.py: 11 case golden-standard synthetic tests
Label Unification:
- vibration_level: SEVERE→POOR, CRITICAL→UNUSABLE (consistent Assessment enum)
- vibration_level_raw preserves original labels
- formatter/httm_report color mapping compatible with both old and new labels
v6.2 Synthetic Regression Fix:
- Excitation signals add 8% broadband noise (improves Wiener deconvolution conditioning)
- Steady-state tolerances widened; underdamped test uses relative assertion
- formatter `_has_vec()` guard for numpy array truthiness
Test Results
`159 passed in 2.16s` — AP/BF/PX4 real log verified
Files Changed
v3.0.1: 12 files, +743/-492
v3.0.2: 13 files, +1181/-510
SmartTune CLI v3.0.0 — Firmware-Parameter-Aware Tuning
SmartTune CLI v3.0.0 — Firmware-Parameter-Aware Tuning
New: stune params — Firmware Parameter Validation
SmartTune CLI can now query and validate firmware parameters before recommending changes, preventing agents from suggesting parameters that don't exist in the target firmware.
stune params ap # List 2,574 ArduPilot parameters
stune params bf # List 45 Betaflight parameters
stune params ATC_RAT_RLL_P # Show parameter details
stune params --search notch # Search across all platforms
stune params --validate ATC_RAT_RLL_P 0.15 -p ardupilot # Validate before recommendingNew: MCP Parameter Tools (13 total)
3 new MCP tools for parameter query and validation:
smarttune_list_params(platform, category)— List all firmware parameterssmarttune_search_params(keyword, platform)— Search by keywordsmarttune_validate_param(name, value, platform)— Validate before recommending
MCP agents can now autonomously verify parameter existence and value ranges.
Knowledge Base Architecture
Parameter tables live in smarttune/knowledge/params/*.json — data scraped from official firmware repositories:
- ArduPilot: 2,574 parameters from @PARAM annotations (100% described, 1,941 with valid ranges)
- Betaflight: 45 parameters from settings.c (BF 4.5+ names:
p_roll,gyro_lpf1_static_hz,d_max_roll) - PX4: 20 parameters from official YAML definitions
Breaking Change
Betaflight adapter's map_param_to_platform() now returns BF 4.5+ parameter names:
pid_roll_p→p_rollgyro_lowpass_hz→gyro_lpf1_static_hzd_min_roll→d_max_roll
Legacy name support maintained for reading older BBL logs.
Consistency Fixes
- ParamTable.validate() fixed for parameters without min/max range
- BF filter_transfer.py supports both old and new parameter names
- All 35 previously-undescribed AP parameters now have verified descriptions and ranges
- Count metadata corrected in knowledge base JSONs
Full Changelog
Previous v2.4.2 → v3.0.0: [compare]
SmartTune CLI v2.3.1
Patch release adding a dedicated MCP-only SmartTune skill for read-only customer-support agents.\n\n- Adds skill-mcp/SKILL.md for agents without exec/shell/write permission\n- Documents SmartTune MCP tool workflow and safety boundaries\n- Keeps v2.3.0 MCP server/runtime behavior unchanged\n\nVerification: pytest -q -> 141 passed