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>
|
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||||
parseBluetoothAutoReconnectJson(std::string_view json);
|
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.
|
* @brief serializeBluetoothScanJson — serialise scan result list.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ enum class Bt1035AtCommand {
|
|||||||
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
|
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
|
||||||
Ping, ///< AT — link check.
|
Ping, ///< AT — link check.
|
||||||
I2sMode, ///< AT+AUXCFG=3 — I2S input from ADAU1701 (mandatory).
|
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.
|
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
|
||||||
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
|
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
|
||||||
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
|
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
|
||||||
@@ -105,16 +106,40 @@ enum class Bt1035AtResponseKind {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Number of commands in bootInitSequence(). */
|
/** 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
|
* Feasycom programming guide §5.3.4: AT+A2DPCFG bit field enables optional
|
||||||
* ADAU1701 serial output word length (SigmaStudio Hardware Configuration).
|
* codecs on top of the mandatory baseline SBC (BIT0=AAC, BIT1=aptX,
|
||||||
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
|
* BIT2=aptX-LL, BIT3=aptX-HD, BIT4=aptX-Adaptive, BIT5=LDAC; BT1035 does
|
||||||
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=24-bit(01)
|
* not support BT806's FastStream). Never sent before 2026-08-24, so every
|
||||||
* -> 1 + 2 + 32 = 35.
|
* 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.
|
* @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.
|
* @brief bootInitSequence — mandatory bring-up commands in order.
|
||||||
*
|
*
|
||||||
* @dname bootInitSequence
|
* @dname bootInitSequence
|
||||||
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k24 (I2SCFG=35).
|
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k32 (I2SCFG=67).
|
||||||
* @pubstate none
|
* @pubstate none
|
||||||
*
|
*
|
||||||
* @author Michele Bigi
|
* @author Michele Bigi
|
||||||
@@ -210,6 +235,23 @@ parseBt1035A2dpEncoderResponse(std::string_view response);
|
|||||||
*/
|
*/
|
||||||
[[nodiscard]] const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept;
|
[[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.
|
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json)
|
|||||||
return static_cast<std::uint8_t>(raw);
|
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(
|
std::string serializeBluetoothScanJson(
|
||||||
const std::vector<Bt1035ScannedDevice>& devices)
|
const std::vector<Bt1035ScannedDevice>& devices)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
|||||||
return "AT\r\n";
|
return "AT\r\n";
|
||||||
case Bt1035AtCommand::I2sMode:
|
case Bt1035AtCommand::I2sMode:
|
||||||
return "AT+AUXCFG=3\r\n";
|
return "AT+AUXCFG=3\r\n";
|
||||||
case Bt1035AtCommand::I2sSlave48k24:
|
case Bt1035AtCommand::I2sSlave48k32:
|
||||||
return "AT+I2SCFG=35\r\n";
|
return "AT+I2SCFG=67\r\n";
|
||||||
|
case Bt1035AtCommand::A2dpCodecConfig:
|
||||||
|
return buildBt1035A2dpCodecConfigLine(kBt1035A2dpCodecConfigParam);
|
||||||
case Bt1035AtCommand::PairDiscoverable:
|
case Bt1035AtCommand::PairDiscoverable:
|
||||||
return "AT+PAIR=1\r\n";
|
return "AT+PAIR=1\r\n";
|
||||||
case Bt1035AtCommand::PairHidden:
|
case Bt1035AtCommand::PairHidden:
|
||||||
@@ -128,6 +130,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
|||||||
return "AT\r\n";
|
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)
|
std::string buildBt1035SetAutoConnLine(std::uint8_t times)
|
||||||
{
|
{
|
||||||
if (times > 15U) {
|
if (times > 15U) {
|
||||||
@@ -150,7 +160,8 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
|
|||||||
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
|
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
|
||||||
Bt1035AtCommand::Ping,
|
Bt1035AtCommand::Ping,
|
||||||
Bt1035AtCommand::I2sMode,
|
Bt1035AtCommand::I2sMode,
|
||||||
Bt1035AtCommand::I2sSlave48k24,
|
Bt1035AtCommand::I2sSlave48k32,
|
||||||
|
Bt1035AtCommand::A2dpCodecConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ namespace {
|
|||||||
std::cerr << "I2S mode must be in init sequence\n";
|
std::cerr << "I2S mode must be in init sequence\n";
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
|
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
|
||||||
std::cerr << "I2SCFG must be in init sequence\n";
|
std::cerr << "I2SCFG must be in init sequence\n";
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
@@ -44,9 +44,19 @@ namespace {
|
|||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
const std::string i2sCfg =
|
const std::string i2sCfg =
|
||||||
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
|
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
|
||||||
if (i2sCfg != "AT+I2SCFG=35\r\n") {
|
if (i2sCfg != "AT+I2SCFG=67\r\n") {
|
||||||
std::cerr << "I2SCFG=35 command line mismatch\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;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
const std::string reset =
|
const std::string reset =
|
||||||
|
|||||||
@@ -182,69 +182,137 @@ namespace adau1701
|
|||||||
return replay;
|
return replay;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic: log EQ band 0's live Param RAM contents. This band is
|
// Diagnostic (2026-08-24, extended from band-0-only): log EVERY EQ
|
||||||
// the fixed high-pass (SigmaStudio band 1) and applyEq() always
|
// band's live Param RAM contents (bands 0-5, 5 coefficients each --
|
||||||
// skips it -- no runtime code path ever safeloads it, so whatever
|
// B0,B1,B2,A0,A1 per paramAddrEqBandBase's 5-word stride). Band 0
|
||||||
// landed here at program-load time is what plays, permanently,
|
// is the fixed high-pass that applyEq() always skips (whatever
|
||||||
// regardless of the audio-profile API (which reports a fictional
|
// landed at program-load time plays permanently); bands 1-5 are
|
||||||
// value for it). Read straight from the chip rather than trusting
|
// runtime-safeloaded whenever the audio profile has a nonzero
|
||||||
// the compiled source, in case the two have ever diverged.
|
// 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)
|
for (unsigned i = 0U; i < 5U; ++i)
|
||||||
{
|
{
|
||||||
unsigned char raw[4U] = {0U, 0U, 0U, 0U};
|
unsigned char raw[4U] = {0U, 0U, 0U, 0U};
|
||||||
if (sigma_i2c_read(baseAddr + i, raw, sizeof(raw)) == 0)
|
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::int32_t>(
|
||||||
(static_cast<std::uint32_t>(raw[0]) << 24) |
|
(static_cast<std::uint32_t>(raw[0]) << 24) |
|
||||||
(static_cast<std::uint32_t>(raw[1]) << 16) |
|
(static_cast<std::uint32_t>(raw[1]) << 16) |
|
||||||
(static_cast<std::uint32_t>(raw[2]) << 8) |
|
(static_cast<std::uint32_t>(raw[2]) << 8) |
|
||||||
static_cast<std::uint32_t>(raw[3]));
|
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,
|
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",
|
"value=%f",
|
||||||
i, baseAddr + i,
|
band, i, baseAddr + i,
|
||||||
static_cast<unsigned>(fixpoint),
|
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>(fixpoint) /
|
||||||
static_cast<double>(1U << 23));
|
static_cast<double>(1U << 23));
|
||||||
}
|
}
|
||||||
else
|
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
|
// SerialInputRegister (0x081F) IBP override -- REMOVED 2026-08-24,
|
||||||
// BCLK edge the Si4684's I2S output actually changes data on
|
// CONFIRMED LIVE as the root cause of the multi-day hiss/
|
||||||
// (compiled DSP program default is 0x00 = IBP=0, which produced
|
// unintelligible-speech investigation. History: on 2026-08-16
|
||||||
// pure static on a strong locked signal). ILP=1 was also tried
|
// (commit 6974095) this was set to IBP=1 (0x08) alongside a
|
||||||
// (0x18) and made it worse (pure white noise again) — IBP alone
|
// SEPARATE, simultaneous fix to Si4684's PIN_CONFIG_ENABLE (which
|
||||||
// (0x08) is the correct override, confirmed live: real, recognizable
|
// had been forcing the chip's analog DAC fallback instead of real
|
||||||
// music instead of static/noise on a locked FM station. Redundant
|
// I2S output). Both fixes landed in the same commit and were
|
||||||
// with the R3_HWCONFIGURATION program write above (SigmaStudio's
|
// tested together: "static" became "music", credited to IBP=1.
|
||||||
// own export already places 0x08 at this address) — kept as a
|
// But SerialInputRegister is a SINGLE register shared by every
|
||||||
// belt-and-braces re-assert in case that specific chunk silently
|
// SDATA_INx pin on the ADAU1701 (confirmed against the datasheet
|
||||||
// failed; now checked/verified like everything else instead of
|
// 2026-08-24: one INPUT_BCLK/INPUT_LRCLK clock pair serves all
|
||||||
// fire-and-forget.
|
// 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 =
|
constexpr float kLimiterThresholdDb = -6.0F;
|
||||||
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
constexpr float kLimiterThresholdLinear = 0.50118723F; // 10^(-6/20)
|
||||||
ADI_REG_TYPE serialInFix = 0x08U;
|
const std::int32_t thresholdFixpoint =
|
||||||
if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
|
core::floatToFixpoint823(kLimiterThresholdLinear);
|
||||||
&serialInFix) != 0)
|
if (auto lim1 = safeloadFixpoint(
|
||||||
|
static_cast<unsigned>(ADDR_LIMITER1_THRESHOLD),
|
||||||
|
thresholdFixpoint);
|
||||||
|
!lim1)
|
||||||
{
|
{
|
||||||
ESP_LOGE(kTag, "SerialInputRegister override failed after retries");
|
ESP_LOGW(kTag, "Limiter1 threshold override failed");
|
||||||
return std::unexpected(Adau1701Error::DownloadFailed);
|
|
||||||
}
|
}
|
||||||
if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0)
|
if (auto lim2 = safeloadFixpoint(
|
||||||
|
static_cast<unsigned>(ADDR_LIMITER2_THRESHOLD),
|
||||||
|
thresholdFixpoint);
|
||||||
|
!lim2)
|
||||||
{
|
{
|
||||||
ESP_LOGW(kTag,
|
ESP_LOGW(kTag, "Limiter2 threshold override failed");
|
||||||
"SerialInputRegister read-back mismatch -- ACKed "
|
|
||||||
"but did not land as 0x08");
|
|
||||||
}
|
}
|
||||||
|
ESP_LOGI(kTag, "Limiter1/2 threshold set to %.1f dBFS",
|
||||||
|
static_cast<double>(kLimiterThresholdDb));
|
||||||
}
|
}
|
||||||
|
|
||||||
booted_ = true;
|
booted_ = true;
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ namespace bt1035 {
|
|||||||
struct Bt1035Pins {
|
struct Bt1035Pins {
|
||||||
int uartTx; ///< ESP32 TX -> module RX.
|
int uartTx; ///< ESP32 TX -> module RX.
|
||||||
int uartRx; ///< ESP32 RX <- module TX.
|
int uartRx; ///< ESP32 RX <- module TX.
|
||||||
int resetGpio; ///< Module RESET# (pin 8). Read-only: configured as a
|
int resetGpio; ///< Module RESET# (pin 8), active-low. Driven (not
|
||||||
///< floating input (2026-08-22), never driven — relies
|
///< floating, since 2026-08-23): held LOW together with
|
||||||
///< entirely on the module's own internal pull-up.
|
///< SYS_CTRL past the datasheet §4.8 Reset Protection
|
||||||
int sysCtlGpio; ///< SYS_CTL (pin 34). Driven HIGH once at boot, then
|
///< timeout (~1.8 s) to force a genuine power-down on
|
||||||
///< never touched again for the process lifetime.
|
///< 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
|
int ctsGpio; ///< Host->module UART_CTS (module pin 15). Diagnostic
|
||||||
///< only (2026-08-22): read-only floating input, never
|
///< only (2026-08-22): read-only floating input, never
|
||||||
///< driven — this driver does not implement hardware
|
///< driven — this driver does not implement hardware
|
||||||
@@ -249,6 +253,24 @@ public:
|
|||||||
*/
|
*/
|
||||||
[[nodiscard]] std::expected<std::uint8_t, Bt1035Error> queryAutoReconnect();
|
[[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).
|
* @brief queryPairedList — enumerate paired remotes (AT+PLIST).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ constexpr int kPostUartMs = 100;
|
|||||||
constexpr int kSysCtlAssertMs = 20;
|
constexpr int kSysCtlAssertMs = 20;
|
||||||
/** Datasheet §4.8: "Reset Protection timeout (typically greater than
|
/** Datasheet §4.8: "Reset Protection timeout (typically greater than
|
||||||
* ~1.8 s) causes the device to power down if VCHG is not present and
|
* ~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
|
* SYS_CTRL is low." RESET# and SYS_CTRL are held asserted/low together for
|
||||||
* power-up assert, to guarantee a genuine full power-down rather than a
|
* comfortably longer than that before each power-up, to guarantee a
|
||||||
* pulse too short for the module's own protection timer to act on — see
|
* genuine full power-down rather than a pulse too short for the module's
|
||||||
* resetAndInitOnce()'s comment for why this now runs on every attempt,
|
* own protection timer to act on — see resetAndInitOnce()'s comment for
|
||||||
* not just the first. */
|
* why this now runs on every attempt, not just the first. */
|
||||||
constexpr int kSysCtlDeassertMs = 2500;
|
constexpr int kSysCtlDeassertMs = 2500;
|
||||||
/** Measured live (2026-08-20, power/wiring confirmed sound with a
|
/** 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
|
* 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));
|
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()
|
std::expected<std::uint8_t, Bt1035Error> Bt1035Driver::queryAutoReconnect()
|
||||||
{
|
{
|
||||||
if (auto ready = ensureBooted(); !ready) {
|
if (auto ready = ensureBooted(); !ready) {
|
||||||
@@ -924,6 +933,32 @@ bool Bt1035Driver::waitForA2dpStreaming(int timeoutMs)
|
|||||||
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
|
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
|
||||||
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
|
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
|
||||||
if (*state == core::Bt1035A2dpState::Streaming) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const TickType_t now = xTaskGetTickCount();
|
const TickType_t now = xTaskGetTickCount();
|
||||||
@@ -974,38 +1009,35 @@ std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
|
|||||||
|
|
||||||
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
|
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
|
||||||
{
|
{
|
||||||
// RESET# (pin 8) is deliberately never driven by this driver (see
|
// 2026-08-23 fix: force a genuine power-down/restart on every retry by
|
||||||
// boot()'s GPIO config below): it's left a floating input, relying
|
// driving RESET# together with SYS_CTRL, per datasheet §4.8: "Assertion
|
||||||
// entirely on the BT1035's own "fixed strong pull-up to VDD_IO"
|
// of RESET# beyond the Reset Protection timeout (typically >~1.8 s)
|
||||||
// (datasheet §4.8), which the datasheet explicitly says means the pin
|
// causes the device to power down if VCHG is not present and SYS_CTRL
|
||||||
// "can therefore be left unconnected". Diagnostic test (2026-08-22) to
|
// is low. FSC-BT1035 then requires a SYS_CTRL assertion ... to
|
||||||
// check whether the previous external RESET# drive was contributing to
|
// restart." The prior design (2026-08-22) only pulsed SYS_CTRL and left
|
||||||
// the intermittent boot failures.
|
// RESET# floating; per §4.7, "when booted, software takes control of
|
||||||
//
|
// the internal regulators and the state of SYS_CTRL is ignored" — so
|
||||||
// SYS_CTRL (pin 34) alternative usage (2026-08-22 experiment): drive a
|
// once the module had booted once, that pulse alone could never force
|
||||||
// genuine LOW-then-HIGH cycle every time this function runs, not just
|
// a real power-cycle. Asserting RESET# is the documented way to do it.
|
||||||
// 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.
|
|
||||||
const auto sysCtlPin = static_cast<gpio_num_t>(pins_.sysCtlGpio);
|
const auto sysCtlPin = static_cast<gpio_num_t>(pins_.sysCtlGpio);
|
||||||
const auto resetPin = static_cast<gpio_num_t>(pins_.resetGpio);
|
const auto resetPin = static_cast<gpio_num_t>(pins_.resetGpio);
|
||||||
const auto ctsPin = static_cast<gpio_num_t>(pins_.ctsGpio);
|
const auto ctsPin = static_cast<gpio_num_t>(pins_.ctsGpio);
|
||||||
const auto rtsPin = static_cast<gpio_num_t>(pins_.rtsGpio);
|
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);
|
gpio_set_level(sysCtlPin, 0);
|
||||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlDeassertMs));
|
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);
|
gpio_set_level(sysCtlPin, 1);
|
||||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlAssertMs));
|
vTaskDelay(pdMS_TO_TICKS(kSysCtlAssertMs));
|
||||||
ESP_LOGI(kTag, "power-up: SYS_CTRL=%d (want 1, driven) RESET#=%d "
|
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));
|
gpio_get_level(sysCtlPin), gpio_get_level(resetPin));
|
||||||
ESP_LOGI(kTag, "before banner wait: CTS=%d (module's flow-control "
|
ESP_LOGI(kTag, "before banner wait: CTS=%d (module's flow-control "
|
||||||
"input, host side floating) RTS/PIO2=%d (module's "
|
"input, host side floating) RTS/PIO2=%d (module's "
|
||||||
@@ -1027,21 +1059,25 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// RESET# (pin 8) is never driven by this driver (2026-08-22 diagnostic
|
// RESET# (pin 8) is actively driven (2026-08-23 fix): datasheet §4.8
|
||||||
// change): configured as a pure floating input, pull-up/pull-down both
|
// states the Reset Protection timeout that forces a genuine power-down
|
||||||
// explicitly disabled, so nothing on the ESP32 side influences this
|
// is triggered by *asserting RESET#* (while SYS_CTRL is low), not by
|
||||||
// net electrically — the BT1035's own internal RESET# pull-up (§4.8)
|
// toggling SYS_CTRL alone. Leaving RESET# floating (the 2026-08-22
|
||||||
// is the only thing holding it high. GPIO_MODE_INPUT (not OUTPUT) still
|
// diagnostic change) meant that mechanism was never actually invoked —
|
||||||
// lets gpio_get_level() read it back for the diagnostic log below,
|
// every retry only pulsed SYS_CTRL, which the module ignores once
|
||||||
// without ever driving it.
|
// 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 = {};
|
gpio_config_t resetCfg = {};
|
||||||
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
|
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_up_en = GPIO_PULLUP_DISABLE;
|
||||||
resetCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
resetCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||||
if (gpio_config(&resetCfg) != ESP_OK) {
|
if (gpio_config(&resetCfg) != ESP_OK) {
|
||||||
return std::unexpected(Bt1035Error::ResetFailed);
|
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:
|
// SYS_CTRL (pin 34) stays actively driven (GPIO_MODE_INPUT_OUTPUT:
|
||||||
// the INPUT bit is what makes gpio_get_level() read the real driven
|
// 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)");
|
ESP_LOGI(kTag, "auto-link disabled (AT+LINKCFG=0,0)");
|
||||||
|
|
||||||
booted_ = true;
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1759,6 +1759,63 @@ esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
|
|||||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
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)
|
esp_err_t stationsGetHandler(httpd_req_t* req)
|
||||||
{
|
{
|
||||||
auto* ctx = routeContextFrom(req);
|
auto* ctx = routeContextFrom(req);
|
||||||
@@ -2334,6 +2391,22 @@ std::expected<void, NetError> SetupWebServer::start(
|
|||||||
};
|
};
|
||||||
httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri);
|
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 = {
|
const httpd_uri_t stationsGetUri = {
|
||||||
.uri = "/api/stations",
|
.uri = "/api/stations",
|
||||||
.method = HTTP_GET,
|
.method = HTTP_GET,
|
||||||
|
|||||||
@@ -107,6 +107,30 @@ public:
|
|||||||
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
|
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
|
||||||
std::uint8_t times);
|
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.
|
* @brief scanNearby — classic BT/EDR discovery, cancelling pairing.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -148,6 +148,18 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
|
|||||||
return driver_.setAutoReconnect(times);
|
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>
|
std::expected<std::vector<core::Bt1035ScannedDevice>, bt1035::Bt1035Error>
|
||||||
BluetoothService::scanNearby(std::uint8_t scanSeconds)
|
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
|
persistence, see `Eeprom24aa::writeFmAntCap`/`writeDabAntCap`) so it
|
||||||
survives a reboot without a firmware reflash. `recalibrateXtal()`
|
survives a reboot without a firmware reflash. `recalibrateXtal()`
|
||||||
(`Si4684Driver.cpp`) already does the live re-boot-with-new-params part;
|
(`Si4684Driver.cpp`) already does the live re-boot-with-new-params part;
|
||||||
what's missing is EEPROM persistence for CTUN/XTAL_FREQ (ANTCAP already
|
what's missing is EEPROM persistence for **all three** crystal
|
||||||
persists this way — the xtal calibration should follow the same shape,
|
calibration parameters -- `ibias`, `ctun`, AND `xtalFreqHz` (not just
|
||||||
likely a new EEPROM word address alongside the existing FM/DAB ANTCAP
|
XTAL_FREQ; confirmed explicitly 2026-08-24 that all three need to
|
||||||
ones) and doing the FREQOFF-averaging/damping/convergence-loop logic
|
persist, not only the one this session happened to tune) -- plus loading
|
||||||
in firmware (or keeping it host-side and just adding the EEPROM-persist
|
them at boot the same way `main.cpp` already loads the saved FM/DAB
|
||||||
step at the end — decide when picked up).
|
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
|
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
|
it isn't lost. See `docs/si4684-rf-investigation-report.md`'s 2026-08-23
|
||||||
|
|||||||
Reference in New Issue
Block a user