Files
DigiRadio/Software/tools/si4684_antenna_calibration.py
T
micheleandClaude Sonnet 5 fae2305164 Fix Si4684 pitch distortion: uncalibrated crystal (CTUN/XTAL_FREQ)
Root cause of FM/DAB "stonata" audio: xtalCtun=31/nominal XTAL_FREQ
defaults were never measured against this board's actual crystal
(Abracon ABM8-19.200MHZ-10-1-U-T, CL=10pF, two external 15pF load
caps). Fixed via CTUN trim by ear (31->0) plus a new live calibration
loop that reads the Si4684's own FM_RSQ FREQOFF field (broadcast
carriers are GPS-locked, so it's a free precision frequency reference)
to trim XTAL_FREQ with no lab equipment. Converged to CTUN=0,
XTAL_FREQ=19199750 Hz, residual -3/-4 ppm cross-checked on two
stations.

New tools/infrastructure (kept, not one-off):
- Si4684Driver::recalibrateXtal() + POST /api/tuner/xtal-calibrate:
  live crystal re-trim without an ESP32 reflash.
- FM_RSQ FREQOFF exposed as "freqoff_ppm" in GET /api/tuner/status.
- tools/si4684_xtal_calibration.py: automates the trim loop.
- tools/si4684_antenna_calibration.py: AN851 Appendix A ANTCAP/VARM/VARB
  sweep tool (same session, separate calibration).
- CONFIG_ESP32_I2S_TEST_TONE (off by default): isolates Si4684-specific
  audio issues from shared-downstream ones by writing a tone directly
  over the I2S bus the Si4684 also uses.
- SIGMA_WRITE_REGISTER_BLOCK/sigma_safeload_block now retry on NACK and
  verify via read-back instead of firing I2C writes blind.
- FM de-emphasis set to European 50us (was left at the US 75us default).
- DAB_ACF_ENABLE restored to its previous 0x0000 with a citation
  explaining why (tested the datasheet default of 0x0003, made things
  audibly worse).

See docs/si4684-rf-investigation-report.md's 2026-08-23 entry for the
full elimination chain and docs/TODO.md for follow-up work (persisting
calibration results to EEPROM instead of requiring a firmware edit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:30:42 +02:00

342 lines
14 KiB
Python

#!/usr/bin/env python3
"""si4684_antenna_calibration.py — AN851 Appendix A varactor-tuning sweep.
DigiRadio firmware — https://github.com/manvalan/DigiRadio
Copyright 2026 Michele Bigi
SPDX-License-Identifier: Apache-2.0
Runs the Si4684 antenna varactor calibration procedure described in AN851
("Si468x AM/AMHD-FM/FMHD-DAB/DAB+ antenna/matching network design
guidelines"), Appendix A, driving the device's existing HTTP tuner API
instead of a bench Test_Get_RSSI command:
1. At each of several test frequencies, sweep ANTCAP from --antcap-min
to --antcap-max (AN851: 1-128) via POST /api/tuner/tune, averaging
--samples (AN851: 5) reads per step via GET /api/tuner/status.
2. Pick the ANTCAP with the best average reading at each frequency.
3. Linear-fit best_antcap(frequency_MHz) = (m/1000)*frequency_MHz + b,
AN851's own formula, giving VARM (m, property 0x1710) and VARB
(b, property 0x1711) as signed 16-bit integers.
Scope and limits (read before running):
* This firmware's HTTP API has no endpoint to write VARM/VARB directly
(see components/net/src/SetupWebServer.cpp) -- only a single fixed
ANTCAP override can be persisted, via POST /api/tuner/calibrate-antenna.
This script only COMPUTES the fit and prints it; it does not write
anything to the device. Applying the result means hand-editing the
kFmTuneFeVarm/kFmTuneFeVarb (or the DAB kDabProps table) constants in
components/drivers/si4684/src/Si4684Driver.cpp and reflashing.
* DAB caveat: DAB_TUNE_FREQ (AN649 Command 0xB0) addresses frequencies by
an index into a chip-side table (default "European frequency list")
loaded via DAB_SET_FREQ_LIST. AN649's copy in this repo does not publish
that table's contents, and neither the firmware nor its HTTP API expose
the real MHz for a given freq_index. This script does NOT assume any
index<->MHz mapping -- for --band dab you must supply the real
frequency for each index yourself (e.g. read off a known ensemble's
published transmitter frequency). Do not guess; a wrong MHz value here
silently produces a wrong VARM/VARB fit.
* DAB metric caveat: AN851 Appendix A calls for RSSI at each step, but
this firmware's DAB status JSON does not expose RSSI (only
dab.fic_quality and dab.cnr_db -- see core/TunerStatus.hpp). This
script uses dab.cnr_db as the optimization metric for --band dab
instead. That is a deliberate substitution, not a datasheet value --
treat DAB fit results with more skepticism than FM ones.
* Known dead zone (docs/si4684-rf-investigation-report.md, 2026-08-20):
DAB freq_index 22 lost lock entirely at antcap 72 and 80 on this board.
This script does not skip any antcap value in range; a step with no
lock is logged as "NO LOCK" and simply excluded from that point's
average, not treated as a fatal error.
Usage:
python3 tools/si4684_antenna_calibration.py --host digiradio-XXXXXX.local \\
--band fm --point 88500 --point 98000 --point 107900
python3 tools/si4684_antenna_calibration.py --host 192.168.1.56 \\
--band dab --point 5:174928 --point 12:198160 --point 23:225648 \\
--raw-csv dab_sweep.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import socket
import statistics
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
def parse_point(band: str, raw: str) -> dict:
"""Parse one --point argument into a tune target for the given band."""
if band == "dab":
if ":" not in raw:
raise ValueError(
"DAB points must be 'freq_index:frequency_khz', e.g. 12:198160"
)
idx_s, khz_s = raw.split(":", 1)
idx = int(idx_s)
khz = int(khz_s)
if not 0 <= idx <= 37:
raise ValueError("freq_index must be 0-37 (core/TunerJson.cpp limit)")
if khz <= 0:
raise ValueError("frequency_khz must be positive")
return {
"index": idx,
"khz": khz,
"mhz": khz / 1000.0,
"label": f"idx{idx}@{khz / 1000:.3f}MHz",
}
khz = int(raw)
if not 87500 <= khz <= 108000:
raise ValueError("FM frequency_khz should be within 87500-108000")
return {"khz": khz, "mhz": khz / 1000.0, "label": f"{khz / 1000:.3f}MHz"}
def tune_payload(band: str, point: dict, antcap: int) -> dict:
if band == "dab":
return {"band": "dab", "freq_index": point["index"], "antcap": antcap}
return {"band": "fm", "frequency_khz": point["khz"], "antcap": antcap}
def extract_metric(status: dict, band: str) -> Optional[float]:
"""AN851's optimization metric: RSSI for FM, CNR for DAB (see docstring)."""
if not status.get("locked", False):
return None
if band == "fm":
fm = status.get("fm") or {}
rssi = fm.get("rssi_dbuv")
return float(rssi) if rssi is not None else None
dab = status.get("dab") or {}
cnr = dab.get("cnr_db")
return float(cnr) if cnr is not None else None
def http_request(
host: str,
method: str,
path: str,
payload: Optional[dict],
timeout: float,
retries: int,
) -> dict:
url = f"http://{host}{path}"
# The firmware's JSON parser (components/core/src/TunerJson.cpp) does
# plain substring search for e.g. `"key":` and is not whitespace-
# tolerant -- json.dumps()'s default ": "/", " separators break every
# request with "invalid_json". Compact separators match what curl's
# -d with no spaces sends, which the firmware does accept.
data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
last_err: Optional[BaseException] = None
for attempt in range(retries + 1):
try:
req = urllib.request.Request(url, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return json.loads(body) if body else {}
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
last_err = exc
if attempt < retries:
time.sleep(1.0 + attempt) # device is known to stall briefly under HTTP load
raise RuntimeError(f"{method} {path} failed after {retries + 1} attempt(s): {last_err}")
def sweep_point(
host: str,
band: str,
point: dict,
antcap_values: list,
samples: int,
settle_s: float,
sample_gap_s: float,
timeout: float,
retries: int,
raw_rows: Optional[list],
) -> list:
results = []
for antcap in antcap_values:
try:
status = http_request(
host, "POST", "/api/tuner/tune", tune_payload(band, point, antcap), timeout, retries
)
except RuntimeError as exc:
print(f" antcap={antcap:3d} tune failed: {exc}", file=sys.stderr)
results.append((antcap, None))
continue
time.sleep(settle_s)
readings = []
first = extract_metric(status, band)
if first is not None:
readings.append(first)
for _ in range(max(0, samples - 1)):
time.sleep(sample_gap_s)
try:
status = http_request(host, "GET", "/api/tuner/status", None, timeout, retries)
except RuntimeError as exc:
print(f" antcap={antcap:3d} status read failed: {exc}", file=sys.stderr)
continue
reading = extract_metric(status, band)
if reading is not None:
readings.append(reading)
if raw_rows is not None:
for i, reading in enumerate(readings):
raw_rows.append(
{"point": point["label"], "antcap": antcap, "sample": i, "metric": reading}
)
avg = statistics.mean(readings) if readings else None
state = "locked" if readings else "NO LOCK"
avg_str = f"{avg:6.2f}" if avg is not None else " - "
print(f" antcap={antcap:3d} samples={len(readings)}/{samples} avg={avg_str} [{state}]")
results.append((antcap, avg))
return results
def linear_fit(xs: list, ys: list) -> Optional[tuple]:
n = len(xs)
if n < 2:
return None
mean_x = sum(xs) / n
mean_y = sum(ys) / n
num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
den = sum((x - mean_x) ** 2 for x in xs)
if den == 0:
return None
slope = num / den
intercept = mean_y - slope * mean_x
return slope, intercept
def main() -> int:
parser = argparse.ArgumentParser(
description="AN851 Appendix A ANTCAP sweep + VARM/VARB linear fit over the tuner HTTP API.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--host", required=True, help="Device IP or mDNS host, e.g. digiradio-XXXXXX.local")
parser.add_argument("--band", required=True, choices=["fm", "dab"])
parser.add_argument(
"--point",
action="append",
required=True,
metavar="FREQ_KHZ|INDEX:FREQ_KHZ",
help="FM: frequency in kHz (e.g. 98000). DAB: freq_index:frequency_khz "
"(e.g. 12:198160) -- see script docstring for why the MHz value must "
"come from you, not this script.",
)
parser.add_argument("--antcap-min", type=int, default=1)
parser.add_argument("--antcap-max", type=int, default=128)
parser.add_argument("--antcap-step", type=int, default=1)
parser.add_argument(
"--samples", type=int, default=5, help="Reads averaged per ANTCAP step (AN851 Appendix A: 5)"
)
parser.add_argument(
"--settle-ms",
type=int,
default=150,
help="Delay after tune before the first reading (engineering margin, not from AN851)",
)
parser.add_argument("--sample-gap-ms", type=int, default=80, help="Delay between repeated status reads")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout per request, seconds")
parser.add_argument(
"--retries", type=int, default=2, help="Retries per request (device is known to stall briefly)"
)
parser.add_argument("--raw-csv", type=Path, default=None, help="Optional path to dump every raw sample")
args = parser.parse_args()
if not 1 <= args.antcap_min <= args.antcap_max <= 128:
print("error: antcap range must be within 1-128 (0 = auto, not part of the sweep)", file=sys.stderr)
return 1
points = []
for raw in args.point:
try:
points.append(parse_point(args.band, raw))
except ValueError as exc:
print(f"error: invalid --point {raw!r}: {exc}", file=sys.stderr)
return 1
antcap_values = list(range(args.antcap_min, args.antcap_max + 1, args.antcap_step))
raw_rows: Optional[list] = [] if args.raw_csv else None
fit_points = []
for point in points:
print(f"\n=== sweeping {args.band} @ {point['label']} ===")
results = sweep_point(
args.host,
args.band,
point,
antcap_values,
args.samples,
args.settle_ms / 1000.0,
args.sample_gap_ms / 1000.0,
args.timeout,
args.retries,
raw_rows,
)
valid = [(a, v) for a, v in results if v is not None]
if not valid:
print(f" WARNING: no locked reading anywhere -- excluding {point['label']} from the fit")
continue
best_antcap, best_val = max(valid, key=lambda t: t[1])
print(f" best: antcap={best_antcap} ({best_val:.2f})")
fit_points.append((point["mhz"], best_antcap, point["label"]))
if args.raw_csv:
with args.raw_csv.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["point", "antcap", "sample", "metric"])
writer.writeheader()
writer.writerows(raw_rows)
print(f"\nraw samples written to {args.raw_csv}")
print(f"\n=== AN851 Appendix A fit (band={args.band}) ===")
if len(fit_points) < 2:
print(
"Not enough locked points to fit VARM/VARB (need >= 2 usable frequencies). "
"Try different --point values or check the antenna/signal."
)
return 1
xs = [p[0] for p in fit_points]
ys = [p[1] for p in fit_points]
fit = linear_fit(xs, ys)
if fit is None:
print("Fit failed (degenerate frequency set -- all points at the same MHz?).")
return 1
slope, intercept = fit
m = round(slope * 1000)
b = round(intercept)
m_ok = -32768 <= m <= 32767
b_ok = -32768 <= b <= 32767
print(" points used: " + ", ".join(f"{label} -> antcap {a}" for _, a, label in fit_points))
print(" varactor_value = (m/1000)*frequency_MHz + b [AN851 Appendix A]")
print(f" m (slope x1000, property 0x1710) = {m}" + ("" if m_ok else " ** OUT OF int16 RANGE **"))
print(f" b (intercept, property 0x1711) = {b}" + ("" if b_ok else " ** OUT OF int16 RANGE **"))
if m_ok and b_ok:
print(f" -> 0x1710 = 0x{m & 0xFFFF:04X} 0x1711 = 0x{b & 0xFFFF:04X}")
print()
print(" Not written to the device. To apply: edit the k{Fm,Dab}TuneFeVarm/")
print(" k{Fm,Dab}TuneFeVarb constants in components/drivers/si4684/src/Si4684Driver.cpp")
print(" (current values and AN851 Appendix B citation are next to them), reflash, then")
print(" re-run this sweep with antcap=0 (auto) at the same points to confirm auto-tune")
print(" now tracks the measured optimum.")
return 0
if __name__ == "__main__":
raise SystemExit(main())