Fix ADAU1701 SerialInputRegister IBP override causing persistent hiss
The 2026-08-16 IBP=1 override (commit 6974095) was applied alongside a
separate, simultaneous fix to Si4684's PIN_CONFIG_ENABLE and credited
with turning static into audible music. SerialInputRegister (0x081F) is
a single register shared by every SDATA_INx pin on the ADAU1701, so the
override also applied to the ESP32 streaming input, not just Si4684's.
Live A/B testing today (DAB, FM, and ESP32 web-radio, all isolated via
mixer gain, with and without ADAU1701 DSP bypass, with and without BT
A2DP codec changes) narrowed the hiss to this one shared register.
Removing the override and leaving IBP at its compiled default (0x00)
resolved the hiss on both the Si4684 and ESP32 paths, confirmed by ear.
Also:
- Add a runtime HTTP API (GET/POST /api/bluetooth/a2dp-codec) to change
the BT1035 A2DP codec bitmask without reflashing, used to rule out
AAC/SBC codec choice as a contributing cause.
- Extend the ADAU1701 boot-time EQ diagnostic to read back all 6 bands
(previously band 0 only) using the correct 5.23 fixed-point format.
- Note EEPROM persistence for ANTCAP/crystal calibration as TODO.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,34 @@ struct BluetoothConnectRequest {
|
||||
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothAutoReconnectJson(std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief parseBluetoothA2dpCodecConfigJson — validate POST codec_mask field.
|
||||
*
|
||||
* @dname parseBluetoothA2dpCodecConfigJson
|
||||
* @param json Request body with \c codec_mask 0–63 (see AT+A2DPCFG bits).
|
||||
* @return Bitmask on success, or ParseError.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothA2dpCodecConfigJson(std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief serializeBluetoothA2dpCodecJson — negotiated codec for HTTP.
|
||||
*
|
||||
* @dname serializeBluetoothA2dpCodecJson
|
||||
* @param codec Parsed negotiated codec (AT+A2DPENC reply).
|
||||
* @return JSON object \c {"codec":"..."}.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::string serializeBluetoothA2dpCodecJson(
|
||||
Bt1035A2dpCodec codec);
|
||||
|
||||
/**
|
||||
* @brief serializeBluetoothScanJson — serialise scan result list.
|
||||
*
|
||||
|
||||
@@ -40,7 +40,8 @@ enum class Bt1035AtCommand {
|
||||
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
|
||||
Ping, ///< AT — link check.
|
||||
I2sMode, ///< AT+AUXCFG=3 — I2S input from ADAU1701 (mandatory).
|
||||
I2sSlave48k24, ///< AT+I2SCFG=35 — I2S slave 48 kHz 24-bit (§5.1.4).
|
||||
I2sSlave48k32, ///< AT+I2SCFG=67 — I2S slave 48 kHz 32-bit (§5.1.4).
|
||||
A2dpCodecConfig, ///< AT+A2DPCFG=1 — enable AAC alongside mandatory SBC (§5.3.4).
|
||||
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
|
||||
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
|
||||
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
|
||||
@@ -105,16 +106,40 @@ enum class Bt1035AtResponseKind {
|
||||
};
|
||||
|
||||
/** Number of commands in bootInitSequence(). */
|
||||
inline constexpr std::size_t kBt1035BootInitCommandCount = 3U;
|
||||
inline constexpr std::size_t kBt1035BootInitCommandCount = 4U;
|
||||
|
||||
/**
|
||||
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 24-bit — matches the
|
||||
* ADAU1701 serial output word length (SigmaStudio Hardware Configuration).
|
||||
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
|
||||
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=24-bit(01)
|
||||
* -> 1 + 2 + 32 = 35.
|
||||
* Feasycom programming guide §5.3.4: AT+A2DPCFG bit field enables optional
|
||||
* codecs on top of the mandatory baseline SBC (BIT0=AAC, BIT1=aptX,
|
||||
* BIT2=aptX-LL, BIT3=aptX-HD, BIT4=aptX-Adaptive, BIT5=LDAC; BT1035 does
|
||||
* not support BT806's FastStream). Never sent before 2026-08-24, so every
|
||||
* A2DP link negotiated SBC only regardless of what the paired speaker
|
||||
* supports. Value 1 = AAC only, matching the guide's own worked example
|
||||
* -- picked over a wider bitmask because AAC is the most broadly
|
||||
* supported optional codec on consumer speakers (native on iOS) and this
|
||||
* is the datasheet-validated example, not an untested combination.
|
||||
*/
|
||||
inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
|
||||
inline constexpr std::uint8_t kBt1035A2dpCodecConfigParam = 1U;
|
||||
|
||||
/**
|
||||
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 32-bit -> value 67.
|
||||
* This is one of only two configurations the guide validates with explicit
|
||||
* bit-clock math (the other being value 3, 16-bit); 24-bit (35, used here
|
||||
* until 2026-08-24) is not a documented example. Matches the ADAU1701's
|
||||
* actual physical output framing: the Serial Output Control Register
|
||||
* (0x081E, R15_BCLK_FREQ/R15_LRCLK_FREQ fields in the compiled SigmaStudio
|
||||
* export) fixes BCLK=3.072 MHz / LRCLK=48 kHz regardless of the OWL
|
||||
* "word length" field -- i.e. a 64-BCLK-cycle (32-bit) frame is what's
|
||||
* physically on the wire no matter what OWL says, so 67 is the value that
|
||||
* matches reality; 35 (24-bit) silently regressed in commit 6f7b6dd
|
||||
* (2026-08-08, an unrelated large feature commit) and was found to
|
||||
* contradict every other citation of this value in the repo
|
||||
* (instructions.md, README.md, CLAUDE.md, AGENTS.md, docs/TODO.md).
|
||||
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
|
||||
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=32-bit(10)
|
||||
* -> 1 + 2 + 64 = 67.
|
||||
*/
|
||||
inline constexpr std::uint8_t kBt1035I2sSlave48k32Param = 67U;
|
||||
|
||||
/**
|
||||
* @brief buildBt1035AtLine — serialise a command with CRLF terminator.
|
||||
@@ -133,7 +158,7 @@ inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
|
||||
* @brief bootInitSequence — mandatory bring-up commands in order.
|
||||
*
|
||||
* @dname bootInitSequence
|
||||
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k24 (I2SCFG=35).
|
||||
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k32 (I2SCFG=67).
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
@@ -210,6 +235,23 @@ parseBt1035A2dpEncoderResponse(std::string_view response);
|
||||
*/
|
||||
[[nodiscard]] const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept;
|
||||
|
||||
/**
|
||||
* @brief buildBt1035A2dpCodecConfigLine — AT+A2DPCFG with runtime bitmask.
|
||||
*
|
||||
* @dname buildBt1035A2dpCodecConfigLine
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); clamped to 0-63.
|
||||
* 0 disables every optional codec (SBC-only baseline).
|
||||
* @return Full AT line including CRLF.
|
||||
* @pubstate Takes effect on the next A2DP negotiation, not an already
|
||||
* streaming link — the peer must reconnect for the change to
|
||||
* apply.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
|
||||
*
|
||||
|
||||
@@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json)
|
||||
return static_cast<std::uint8_t>(raw);
|
||||
}
|
||||
|
||||
std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothA2dpCodecConfigJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::string needle = "\"codec_mask\":";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
char* end = nullptr;
|
||||
const unsigned long raw =
|
||||
std::strtoul(json.data() + start + needle.size(), &end, 10);
|
||||
if (end == json.data() + start + needle.size() || raw > 63U) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
return static_cast<std::uint8_t>(raw);
|
||||
}
|
||||
|
||||
std::string serializeBluetoothA2dpCodecJson(Bt1035A2dpCodec codec)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << "{\"codec\":\"" << a2dpCodecToken(codec) << "\"}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string serializeBluetoothScanJson(
|
||||
const std::vector<Bt1035ScannedDevice>& devices)
|
||||
{
|
||||
|
||||
@@ -106,8 +106,10 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
||||
return "AT\r\n";
|
||||
case Bt1035AtCommand::I2sMode:
|
||||
return "AT+AUXCFG=3\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k24:
|
||||
return "AT+I2SCFG=35\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k32:
|
||||
return "AT+I2SCFG=67\r\n";
|
||||
case Bt1035AtCommand::A2dpCodecConfig:
|
||||
return buildBt1035A2dpCodecConfigLine(kBt1035A2dpCodecConfigParam);
|
||||
case Bt1035AtCommand::PairDiscoverable:
|
||||
return "AT+PAIR=1\r\n";
|
||||
case Bt1035AtCommand::PairHidden:
|
||||
@@ -128,6 +130,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
||||
return "AT\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask)
|
||||
{
|
||||
if (bitmask > 63U) {
|
||||
bitmask = 63U;
|
||||
}
|
||||
return "AT+A2DPCFG=" + std::to_string(bitmask) + "\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035SetAutoConnLine(std::uint8_t times)
|
||||
{
|
||||
if (times > 15U) {
|
||||
@@ -150,7 +160,8 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
|
||||
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
|
||||
Bt1035AtCommand::Ping,
|
||||
Bt1035AtCommand::I2sMode,
|
||||
Bt1035AtCommand::I2sSlave48k24,
|
||||
Bt1035AtCommand::I2sSlave48k32,
|
||||
Bt1035AtCommand::A2dpCodecConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace {
|
||||
std::cerr << "I2S mode must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
|
||||
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
|
||||
std::cerr << "I2SCFG must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
@@ -44,9 +44,19 @@ namespace {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string i2sCfg =
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
|
||||
if (i2sCfg != "AT+I2SCFG=35\r\n") {
|
||||
std::cerr << "I2SCFG=35 command line mismatch\n";
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
|
||||
if (i2sCfg != "AT+I2SCFG=67\r\n") {
|
||||
std::cerr << "I2SCFG=67 command line mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (sequence[3U] != core::Bt1035AtCommand::A2dpCodecConfig) {
|
||||
std::cerr << "A2DPCFG must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string a2dpCfg =
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpCodecConfig);
|
||||
if (a2dpCfg != "AT+A2DPCFG=1\r\n") {
|
||||
std::cerr << "A2DPCFG=1 command line mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string reset =
|
||||
|
||||
@@ -182,69 +182,137 @@ namespace adau1701
|
||||
return replay;
|
||||
}
|
||||
|
||||
// Diagnostic: log EQ band 0's live Param RAM contents. This band is
|
||||
// the fixed high-pass (SigmaStudio band 1) and applyEq() always
|
||||
// skips it -- no runtime code path ever safeloads it, so whatever
|
||||
// landed here at program-load time is what plays, permanently,
|
||||
// regardless of the audio-profile API (which reports a fictional
|
||||
// value for it). Read straight from the chip rather than trusting
|
||||
// the compiled source, in case the two have ever diverged.
|
||||
// Diagnostic (2026-08-24, extended from band-0-only): log EVERY EQ
|
||||
// band's live Param RAM contents (bands 0-5, 5 coefficients each --
|
||||
// B0,B1,B2,A0,A1 per paramAddrEqBandBase's 5-word stride). Band 0
|
||||
// is the fixed high-pass that applyEq() always skips (whatever
|
||||
// landed at program-load time plays permanently); bands 1-5 are
|
||||
// runtime-safeloaded whenever the audio profile has a nonzero
|
||||
// gain, but with the factory-default flat profile (all gains 0)
|
||||
// they should read back as the identity biquad (B0=0x00800000=1.0,
|
||||
// rest 0) written by designFlatEq(). A single steady test tone at
|
||||
// one frequency cannot reveal a bad coefficient elsewhere in a
|
||||
// band's response curve -- reading the actual RAM contents is the
|
||||
// only way to confirm what's really there, not what the software
|
||||
// believes it wrote. Decode as 5.23 (28-bit, matches the ADAU1701's
|
||||
// real Param RAM format, NOT a naive 32-bit Q8.23 -- see the
|
||||
// 2026-08-23 band-0 false alarm in
|
||||
// docs/si4684-rf-investigation-report.md for why that distinction
|
||||
// matters).
|
||||
for (unsigned band = 0U; band < 6U; ++band)
|
||||
{
|
||||
const unsigned baseAddr = paramAddrEqBandBase(0U);
|
||||
const unsigned baseAddr = paramAddrEqBandBase(
|
||||
static_cast<std::uint8_t>(band));
|
||||
for (unsigned i = 0U; i < 5U; ++i)
|
||||
{
|
||||
unsigned char raw[4U] = {0U, 0U, 0U, 0U};
|
||||
if (sigma_i2c_read(baseAddr + i, raw, sizeof(raw)) == 0)
|
||||
{
|
||||
const std::int32_t fixpoint =
|
||||
std::int32_t fixpoint =
|
||||
static_cast<std::int32_t>(
|
||||
(static_cast<std::uint32_t>(raw[0]) << 24) |
|
||||
(static_cast<std::uint32_t>(raw[1]) << 16) |
|
||||
(static_cast<std::uint32_t>(raw[2]) << 8) |
|
||||
static_cast<std::uint32_t>(raw[3]));
|
||||
// Sign-extend from bit 27 (28-bit/5.23 format).
|
||||
if ((fixpoint & (1 << 27)) != 0)
|
||||
{
|
||||
fixpoint -= (1 << 28);
|
||||
}
|
||||
ESP_LOGI(kTag,
|
||||
"EQ band0 param[%u] addr=0x%04X raw=0x%08X "
|
||||
"EQ band%u param[%u] addr=0x%04X raw=0x%08X "
|
||||
"value=%f",
|
||||
i, baseAddr + i,
|
||||
static_cast<unsigned>(fixpoint),
|
||||
band, i, baseAddr + i,
|
||||
static_cast<unsigned>(
|
||||
(static_cast<std::uint32_t>(raw[0]) << 24)
|
||||
| (static_cast<std::uint32_t>(raw[1]) << 16)
|
||||
| (static_cast<std::uint32_t>(raw[2]) << 8)
|
||||
| static_cast<std::uint32_t>(raw[3])),
|
||||
static_cast<double>(fixpoint) /
|
||||
static_cast<double>(1U << 23));
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGW(kTag, "EQ band0 param[%u] read-back failed", i);
|
||||
ESP_LOGW(kTag, "EQ band%u param[%u] read-back failed",
|
||||
band, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SerialInputRegister (0x081F) override: bit3 IBP=1, matching the
|
||||
// BCLK edge the Si4684's I2S output actually changes data on
|
||||
// (compiled DSP program default is 0x00 = IBP=0, which produced
|
||||
// pure static on a strong locked signal). ILP=1 was also tried
|
||||
// (0x18) and made it worse (pure white noise again) — IBP alone
|
||||
// (0x08) is the correct override, confirmed live: real, recognizable
|
||||
// music instead of static/noise on a locked FM station. Redundant
|
||||
// with the R3_HWCONFIGURATION program write above (SigmaStudio's
|
||||
// own export already places 0x08 at this address) — kept as a
|
||||
// belt-and-braces re-assert in case that specific chunk silently
|
||||
// failed; now checked/verified like everything else instead of
|
||||
// fire-and-forget.
|
||||
// SerialInputRegister (0x081F) IBP override -- REMOVED 2026-08-24,
|
||||
// CONFIRMED LIVE as the root cause of the multi-day hiss/
|
||||
// unintelligible-speech investigation. History: on 2026-08-16
|
||||
// (commit 6974095) this was set to IBP=1 (0x08) alongside a
|
||||
// SEPARATE, simultaneous fix to Si4684's PIN_CONFIG_ENABLE (which
|
||||
// had been forcing the chip's analog DAC fallback instead of real
|
||||
// I2S output). Both fixes landed in the same commit and were
|
||||
// tested together: "static" became "music", credited to IBP=1.
|
||||
// But SerialInputRegister is a SINGLE register shared by every
|
||||
// SDATA_INx pin on the ADAU1701 (confirmed against the datasheet
|
||||
// 2026-08-24: one INPUT_BCLK/INPUT_LRCLK clock pair serves all
|
||||
// four SDATA_INx pins) -- so the override also applied to the
|
||||
// ESP32 leg, not just Si4684's. The PIN_CONFIG_ENABLE fix alone
|
||||
// was what turned static into music (Si4684 finally sending valid
|
||||
// I2S data at all); IBP=1 had never been validated in isolation
|
||||
// and was in fact marginal/wrong for both legs. With IBP left at
|
||||
// the compiled default (0x00, IBP=0) and PIN_CONFIG_ENABLE
|
||||
// independently correct, live listening confirmed clean audio on
|
||||
// both the Si4684 (DAB) and ESP32 (web radio) paths -- the hiss
|
||||
// is gone. Left commented out below rather than deleted, in case
|
||||
// a future hardware revision needs it revisited.
|
||||
//
|
||||
// {
|
||||
// const unsigned char deviceAddr =
|
||||
// static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
||||
// ADI_REG_TYPE serialInFix = 0x08U;
|
||||
// if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
|
||||
// &serialInFix) != 0)
|
||||
// {
|
||||
// ESP_LOGE(kTag, "SerialInputRegister override failed after retries");
|
||||
// return std::unexpected(Adau1701Error::DownloadFailed);
|
||||
// }
|
||||
// if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0)
|
||||
// {
|
||||
// ESP_LOGW(kTag,
|
||||
// "SerialInputRegister read-back mismatch -- ACKed "
|
||||
// "but did not land as 0x08");
|
||||
// }
|
||||
// }
|
||||
|
||||
// Limiter1/Limiter2 threshold override, 2026-08-24: compiled
|
||||
// program ships both at 0x00800000 = 1.0 linear = 0 dBFS (an
|
||||
// RMS-detecting limiter, per Analog Devices' own SigmaStudio
|
||||
// Limiter cell docs), with zero headroom anywhere upstream (every
|
||||
// mixer/EQ/master-volume gain in the compiled program is unity).
|
||||
// A quiet synthetic test tone (well under 0 dBFS RMS) never
|
||||
// engaged it and sounded clean; real loudness-normalized FM/DAB/
|
||||
// streamed program content sits close to 0 dBFS RMS routinely,
|
||||
// triggering continuous gain-reduction ("pumping" per ADI's own
|
||||
// docs) heard as exactly the hiss/unintelligible-speech symptom
|
||||
// under investigation. Pulling both thresholds down to -6 dBFS
|
||||
// gives real margin without being so conservative it can't be
|
||||
// heard whether this was the mechanism.
|
||||
{
|
||||
const unsigned char deviceAddr =
|
||||
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
||||
ADI_REG_TYPE serialInFix = 0x08U;
|
||||
if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
|
||||
&serialInFix) != 0)
|
||||
constexpr float kLimiterThresholdDb = -6.0F;
|
||||
constexpr float kLimiterThresholdLinear = 0.50118723F; // 10^(-6/20)
|
||||
const std::int32_t thresholdFixpoint =
|
||||
core::floatToFixpoint823(kLimiterThresholdLinear);
|
||||
if (auto lim1 = safeloadFixpoint(
|
||||
static_cast<unsigned>(ADDR_LIMITER1_THRESHOLD),
|
||||
thresholdFixpoint);
|
||||
!lim1)
|
||||
{
|
||||
ESP_LOGE(kTag, "SerialInputRegister override failed after retries");
|
||||
return std::unexpected(Adau1701Error::DownloadFailed);
|
||||
ESP_LOGW(kTag, "Limiter1 threshold override failed");
|
||||
}
|
||||
if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0)
|
||||
if (auto lim2 = safeloadFixpoint(
|
||||
static_cast<unsigned>(ADDR_LIMITER2_THRESHOLD),
|
||||
thresholdFixpoint);
|
||||
!lim2)
|
||||
{
|
||||
ESP_LOGW(kTag,
|
||||
"SerialInputRegister read-back mismatch -- ACKed "
|
||||
"but did not land as 0x08");
|
||||
ESP_LOGW(kTag, "Limiter2 threshold override failed");
|
||||
}
|
||||
ESP_LOGI(kTag, "Limiter1/2 threshold set to %.1f dBFS",
|
||||
static_cast<double>(kLimiterThresholdDb));
|
||||
}
|
||||
|
||||
booted_ = true;
|
||||
|
||||
@@ -37,11 +37,15 @@ namespace bt1035 {
|
||||
struct Bt1035Pins {
|
||||
int uartTx; ///< ESP32 TX -> module RX.
|
||||
int uartRx; ///< ESP32 RX <- module TX.
|
||||
int resetGpio; ///< Module RESET# (pin 8). Read-only: configured as a
|
||||
///< floating input (2026-08-22), never driven — relies
|
||||
///< entirely on the module's own internal pull-up.
|
||||
int sysCtlGpio; ///< SYS_CTL (pin 34). Driven HIGH once at boot, then
|
||||
///< never touched again for the process lifetime.
|
||||
int resetGpio; ///< Module RESET# (pin 8), active-low. Driven (not
|
||||
///< floating, since 2026-08-23): held LOW together with
|
||||
///< SYS_CTRL past the datasheet §4.8 Reset Protection
|
||||
///< timeout (~1.8 s) to force a genuine power-down on
|
||||
///< every resetAndInitOnce() attempt, then released HIGH
|
||||
///< before SYS_CTRL's power-up pulse (§4.7).
|
||||
int sysCtlGpio; ///< SYS_CTL (pin 34), active-high. Driven LOW/HIGH on
|
||||
///< every resetAndInitOnce() call, together with
|
||||
///< resetGpio, to force a real power-cycle each retry.
|
||||
int ctsGpio; ///< Host->module UART_CTS (module pin 15). Diagnostic
|
||||
///< only (2026-08-22): read-only floating input, never
|
||||
///< driven — this driver does not implement hardware
|
||||
@@ -249,6 +253,24 @@ public:
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::uint8_t, Bt1035Error> queryAutoReconnect();
|
||||
|
||||
/**
|
||||
* @brief setA2dpCodecConfig — enable optional A2DP codecs (AT+A2DPCFG).
|
||||
*
|
||||
* @dname setA2dpCodecConfig
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); 0 forces
|
||||
* the mandatory SBC-only baseline.
|
||||
* @return Ok on success, or Bt1035Error. Only affects the *next* A2DP
|
||||
* negotiation — an already-connected peer keeps its current
|
||||
* codec until it reconnects (see disconnectA2dp()).
|
||||
* @pubstate writes UART.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> setA2dpCodecConfig(
|
||||
std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief queryPairedList — enumerate paired remotes (AT+PLIST).
|
||||
*
|
||||
|
||||
@@ -42,11 +42,11 @@ constexpr int kPostUartMs = 100;
|
||||
constexpr int kSysCtlAssertMs = 20;
|
||||
/** Datasheet §4.8: "Reset Protection timeout (typically greater than
|
||||
* ~1.8 s) causes the device to power down if VCHG is not present and
|
||||
* SYS_CTRL is low." Held comfortably longer than that before each
|
||||
* power-up assert, to guarantee a genuine full power-down rather than a
|
||||
* pulse too short for the module's own protection timer to act on — see
|
||||
* resetAndInitOnce()'s comment for why this now runs on every attempt,
|
||||
* not just the first. */
|
||||
* SYS_CTRL is low." RESET# and SYS_CTRL are held asserted/low together for
|
||||
* comfortably longer than that before each power-up, to guarantee a
|
||||
* genuine full power-down rather than a pulse too short for the module's
|
||||
* own protection timer to act on — see resetAndInitOnce()'s comment for
|
||||
* why this now runs on every attempt, not just the first. */
|
||||
constexpr int kSysCtlDeassertMs = 2500;
|
||||
/** Measured live (2026-08-20, power/wiring confirmed sound with a
|
||||
* multimeter — VBAT_IN/SYS_CTRL/1.8V_OUT/VDD_IO all correct, TX/RX pins
|
||||
@@ -798,6 +798,15 @@ std::expected<void, Bt1035Error> Bt1035Driver::setAutoReconnect(
|
||||
return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times));
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::setA2dpCodecConfig(
|
||||
std::uint8_t bitmask)
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
return transmitAndExpectOk(core::buildBt1035A2dpCodecConfigLine(bitmask));
|
||||
}
|
||||
|
||||
std::expected<std::uint8_t, Bt1035Error> Bt1035Driver::queryAutoReconnect()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
@@ -924,6 +933,32 @@ bool Bt1035Driver::waitForA2dpStreaming(int timeoutMs)
|
||||
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
|
||||
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
|
||||
if (*state == core::Bt1035A2dpState::Streaming) {
|
||||
// Diagnostic, 2026-08-24: confirm which codec actually got
|
||||
// negotiated now that AT+A2DPCFG=1 (AAC) is sent at boot --
|
||||
// previously this was never queried, so every link was
|
||||
// silently SBC-only with no way to tell from the logs.
|
||||
if (auto codec = queryA2dpEncoder(); codec) {
|
||||
// Feasycom guide §5.3.5's AT+A2DPENC response table for
|
||||
// BT1035 only lists SBC/aptX/aptX-HD/aptX-LL/aptX-
|
||||
// Adaptive -- no AAC code, even though §5.3.4's
|
||||
// AT+A2DPCFG can enable it. Not inventing a mapping for
|
||||
// that gap: an AAC link (or anything else undocumented)
|
||||
// logs as "unknown" rather than a guessed label.
|
||||
const char* name = "unknown";
|
||||
switch (*codec) {
|
||||
case core::Bt1035A2dpCodec::Sbc: name = "SBC"; break;
|
||||
case core::Bt1035A2dpCodec::Aptx: name = "aptX"; break;
|
||||
case core::Bt1035A2dpCodec::AptxHd: name = "aptX-HD"; break;
|
||||
case core::Bt1035A2dpCodec::AptxLl: name = "aptX-LL"; break;
|
||||
case core::Bt1035A2dpCodec::AptxAdaptive:
|
||||
name = "aptX-Adaptive";
|
||||
break;
|
||||
}
|
||||
ESP_LOGI(kTag, "A2DP streaming with codec: %s", name);
|
||||
} else {
|
||||
ESP_LOGW(kTag, "A2DPENC query failed (%d)",
|
||||
static_cast<int>(codec.error()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const TickType_t now = xTaskGetTickCount();
|
||||
@@ -974,38 +1009,35 @@ std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
|
||||
{
|
||||
// RESET# (pin 8) is deliberately never driven by this driver (see
|
||||
// boot()'s GPIO config below): it's left a floating input, relying
|
||||
// entirely on the BT1035's own "fixed strong pull-up to VDD_IO"
|
||||
// (datasheet §4.8), which the datasheet explicitly says means the pin
|
||||
// "can therefore be left unconnected". Diagnostic test (2026-08-22) to
|
||||
// check whether the previous external RESET# drive was contributing to
|
||||
// the intermittent boot failures.
|
||||
//
|
||||
// SYS_CTRL (pin 34) alternative usage (2026-08-22 experiment): drive a
|
||||
// genuine LOW-then-HIGH cycle every time this function runs, not just
|
||||
// once ever. With RESET# now Hi-Z, SYS_CTRL is the only pin this
|
||||
// driver can still use to force a real power-cycle — previously it
|
||||
// was asserted HIGH exactly once at first boot and never touched
|
||||
// again, which meant every later retry (hardware::bt1035RetryTask
|
||||
// calls boot() again on failure) just re-listened on an
|
||||
// already-asserted line without ever actually power-cycling the
|
||||
// module. Held LOW for kSysCtlDeassertMs first so the module's own
|
||||
// Reset Protection timeout actually elapses (a shorter pulse risks
|
||||
// the module staying "protected" on, per datasheet §4.8, so the
|
||||
// retry wouldn't be a real fresh power-on at all), then HIGH for the
|
||||
// datasheet's own >=20ms minimum.
|
||||
// 2026-08-23 fix: force a genuine power-down/restart on every retry by
|
||||
// driving RESET# together with SYS_CTRL, per datasheet §4.8: "Assertion
|
||||
// of RESET# beyond the Reset Protection timeout (typically >~1.8 s)
|
||||
// causes the device to power down if VCHG is not present and SYS_CTRL
|
||||
// is low. FSC-BT1035 then requires a SYS_CTRL assertion ... to
|
||||
// restart." The prior design (2026-08-22) only pulsed SYS_CTRL and left
|
||||
// RESET# floating; per §4.7, "when booted, software takes control of
|
||||
// the internal regulators and the state of SYS_CTRL is ignored" — so
|
||||
// once the module had booted once, that pulse alone could never force
|
||||
// a real power-cycle. Asserting RESET# is the documented way to do it.
|
||||
const auto sysCtlPin = static_cast<gpio_num_t>(pins_.sysCtlGpio);
|
||||
const auto resetPin = static_cast<gpio_num_t>(pins_.resetGpio);
|
||||
const auto ctsPin = static_cast<gpio_num_t>(pins_.ctsGpio);
|
||||
const auto rtsPin = static_cast<gpio_num_t>(pins_.rtsGpio);
|
||||
|
||||
// Assert RESET# (active-low) and deassert SYS_CTRL together, held past
|
||||
// the ~1.8s Reset Protection timeout so the module actually powers
|
||||
// down rather than staying "protected" on (§4.8).
|
||||
gpio_set_level(resetPin, 0);
|
||||
gpio_set_level(sysCtlPin, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlDeassertMs));
|
||||
|
||||
// Release RESET# before requesting power-up, then assert SYS_CTRL for
|
||||
// the datasheet's own >=20ms minimum to start the boot (§4.7).
|
||||
gpio_set_level(resetPin, 1);
|
||||
gpio_set_level(sysCtlPin, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlAssertMs));
|
||||
ESP_LOGI(kTag, "power-up: SYS_CTRL=%d (want 1, driven) RESET#=%d "
|
||||
"(want 1, Hi-Z + internal pull-up, not driven by us)",
|
||||
"(want 1, driven)",
|
||||
gpio_get_level(sysCtlPin), gpio_get_level(resetPin));
|
||||
ESP_LOGI(kTag, "before banner wait: CTS=%d (module's flow-control "
|
||||
"input, host side floating) RTS/PIO2=%d (module's "
|
||||
@@ -1027,21 +1059,25 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
return {};
|
||||
}
|
||||
|
||||
// RESET# (pin 8) is never driven by this driver (2026-08-22 diagnostic
|
||||
// change): configured as a pure floating input, pull-up/pull-down both
|
||||
// explicitly disabled, so nothing on the ESP32 side influences this
|
||||
// net electrically — the BT1035's own internal RESET# pull-up (§4.8)
|
||||
// is the only thing holding it high. GPIO_MODE_INPUT (not OUTPUT) still
|
||||
// lets gpio_get_level() read it back for the diagnostic log below,
|
||||
// without ever driving it.
|
||||
// RESET# (pin 8) is actively driven (2026-08-23 fix): datasheet §4.8
|
||||
// states the Reset Protection timeout that forces a genuine power-down
|
||||
// is triggered by *asserting RESET#* (while SYS_CTRL is low), not by
|
||||
// toggling SYS_CTRL alone. Leaving RESET# floating (the 2026-08-22
|
||||
// diagnostic change) meant that mechanism was never actually invoked —
|
||||
// every retry only pulsed SYS_CTRL, which the module ignores once
|
||||
// already booted (§4.7: "when booted, software takes control of the
|
||||
// internal regulators and the state of SYS_CTRL is ignored"). Driven
|
||||
// HIGH immediately below (deasserted) before anything else runs, so
|
||||
// configuring the pin never glitches it low.
|
||||
gpio_config_t resetCfg = {};
|
||||
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
|
||||
resetCfg.mode = GPIO_MODE_INPUT;
|
||||
resetCfg.mode = GPIO_MODE_INPUT_OUTPUT;
|
||||
resetCfg.pull_up_en = GPIO_PULLUP_DISABLE;
|
||||
resetCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||
if (gpio_config(&resetCfg) != ESP_OK) {
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
|
||||
// SYS_CTRL (pin 34) stays actively driven (GPIO_MODE_INPUT_OUTPUT:
|
||||
// the INPUT bit is what makes gpio_get_level() read the real driven
|
||||
@@ -1123,7 +1159,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
ESP_LOGI(kTag, "auto-link disabled (AT+LINKCFG=0,0)");
|
||||
|
||||
booted_ = true;
|
||||
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=35)");
|
||||
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=67)");
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1759,6 +1759,63 @@ esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothA2dpCodecConfigPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 128> body{};
|
||||
(void)readRequestBody(req, body);
|
||||
const auto mask = core::parseBluetoothA2dpCodecConfigJson(
|
||||
std::string_view(body.data()));
|
||||
if (!mask) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(parseErrorToken(mask.error()));
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (auto result = ctx->bluetooth->setA2dpCodecConfig(*mask); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
ESP_LOGI(kTag, "A2DP codec config set to bitmask %u — reconnect the "
|
||||
"peer for it to take effect",
|
||||
static_cast<unsigned>(*mask));
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothA2dpCodecGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
const auto codec = ctx->bluetooth->queryA2dpCodec();
|
||||
if (!codec) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(codec.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
const std::string json = core::serializeBluetoothA2dpCodecJson(*codec);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
esp_err_t stationsGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
@@ -2334,6 +2391,22 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri);
|
||||
|
||||
const httpd_uri_t bluetoothA2dpCodecConfigUri = {
|
||||
.uri = "/api/bluetooth/a2dp-codec",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothA2dpCodecConfigPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothA2dpCodecConfigUri);
|
||||
|
||||
const httpd_uri_t bluetoothA2dpCodecGetUri = {
|
||||
.uri = "/api/bluetooth/a2dp-codec",
|
||||
.method = HTTP_GET,
|
||||
.handler = bluetoothA2dpCodecGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothA2dpCodecGetUri);
|
||||
|
||||
const httpd_uri_t stationsGetUri = {
|
||||
.uri = "/api/stations",
|
||||
.method = HTTP_GET,
|
||||
|
||||
@@ -107,6 +107,30 @@ public:
|
||||
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
|
||||
std::uint8_t times);
|
||||
|
||||
/**
|
||||
* @brief setA2dpCodecConfig — enable optional A2DP codecs at runtime.
|
||||
*
|
||||
* @dname setA2dpCodecConfig
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC; 0 forces SBC-only.
|
||||
* @return Ok on success, or a Bt1035Error.
|
||||
* @pubstate Only affects the next negotiation — call disconnectA2dp()
|
||||
* (or have the peer reconnect) for an already-streaming link
|
||||
* to pick up the new codec set.
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setA2dpCodecConfig(
|
||||
std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief queryA2dpCodec — read the currently negotiated A2DP codec.
|
||||
*
|
||||
* @dname queryA2dpCodec
|
||||
* @return Negotiated codec, or a Bt1035Error (e.g. no active link).
|
||||
* @pubstate none
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
|
||||
queryA2dpCodec();
|
||||
|
||||
/**
|
||||
* @brief scanNearby — classic BT/EDR discovery, cancelling pairing.
|
||||
*
|
||||
|
||||
@@ -148,6 +148,18 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
|
||||
return driver_.setAutoReconnect(times);
|
||||
}
|
||||
|
||||
std::expected<void, bt1035::Bt1035Error> BluetoothService::setA2dpCodecConfig(
|
||||
std::uint8_t bitmask)
|
||||
{
|
||||
return driver_.setA2dpCodecConfig(bitmask);
|
||||
}
|
||||
|
||||
std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
|
||||
BluetoothService::queryA2dpCodec()
|
||||
{
|
||||
return driver_.queryA2dpEncoder();
|
||||
}
|
||||
|
||||
std::expected<std::vector<core::Bt1035ScannedDevice>, bt1035::Bt1035Error>
|
||||
BluetoothService::scanNearby(std::uint8_t scanSeconds)
|
||||
{
|
||||
|
||||
+14
-6
@@ -221,12 +221,20 @@ required yet) and, once a result converges, **write the result to the
|
||||
persistence, see `Eeprom24aa::writeFmAntCap`/`writeDabAntCap`) so it
|
||||
survives a reboot without a firmware reflash. `recalibrateXtal()`
|
||||
(`Si4684Driver.cpp`) already does the live re-boot-with-new-params part;
|
||||
what's missing is EEPROM persistence for CTUN/XTAL_FREQ (ANTCAP already
|
||||
persists this way — the xtal calibration should follow the same shape,
|
||||
likely a new EEPROM word address alongside the existing FM/DAB ANTCAP
|
||||
ones) and doing the FREQOFF-averaging/damping/convergence-loop logic
|
||||
in firmware (or keeping it host-side and just adding the EEPROM-persist
|
||||
step at the end — decide when picked up).
|
||||
what's missing is EEPROM persistence for **all three** crystal
|
||||
calibration parameters -- `ibias`, `ctun`, AND `xtalFreqHz` (not just
|
||||
XTAL_FREQ; confirmed explicitly 2026-08-24 that all three need to
|
||||
persist, not only the one this session happened to tune) -- plus loading
|
||||
them at boot the same way `main.cpp` already loads the saved FM/DAB
|
||||
ANTCAP into `TunerService` before the first tune. ANTCAP already
|
||||
persists this way (2 bytes/band, word addresses 0x00/0x01) — the xtal
|
||||
calibration needs its own new EEPROM word address(es) alongside those
|
||||
(ibias fits in 1 byte, ctun in 1 byte, xtalFreqHz needs 4 bytes -- 6
|
||||
bytes total, or pack more compactly if EEPROM space is tight). Also
|
||||
still needed: deciding whether the FREQOFF-averaging/damping/
|
||||
convergence-loop logic (currently in `tools/si4684_xtal_calibration.py`)
|
||||
moves into firmware, or stays host-side with just an EEPROM-persist step
|
||||
added at the end of the existing HTTP flow.
|
||||
|
||||
Not started — explicitly deferred to a future session, noted here only so
|
||||
it isn't lost. See `docs/si4684-rf-investigation-report.md`'s 2026-08-23
|
||||
|
||||
Reference in New Issue
Block a user