diff --git a/Software/components/core/include/core/BluetoothJson.hpp b/Software/components/core/include/core/BluetoothJson.hpp index 80c2842..84d088c 100644 --- a/Software/components/core/include/core/BluetoothJson.hpp +++ b/Software/components/core/include/core/BluetoothJson.hpp @@ -112,6 +112,34 @@ struct BluetoothConnectRequest { [[nodiscard]] std::expected 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 +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. * diff --git a/Software/components/core/include/core/Bt1035At.hpp b/Software/components/core/include/core/Bt1035At.hpp index 1e63175..a52dc54 100644 --- a/Software/components/core/include/core/Bt1035At.hpp +++ b/Software/components/core/include/core/Bt1035At.hpp @@ -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. * diff --git a/Software/components/core/src/BluetoothJson.cpp b/Software/components/core/src/BluetoothJson.cpp index 1722f94..a126130 100644 --- a/Software/components/core/src/BluetoothJson.cpp +++ b/Software/components/core/src/BluetoothJson.cpp @@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json) return static_cast(raw); } +std::expected +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(raw); +} + +std::string serializeBluetoothA2dpCodecJson(Bt1035A2dpCodec codec) +{ + std::ostringstream out; + out << "{\"codec\":\"" << a2dpCodecToken(codec) << "\"}"; + return out.str(); +} + std::string serializeBluetoothScanJson( const std::vector& devices) { diff --git a/Software/components/core/src/Bt1035At.cpp b/Software/components/core/src/Bt1035At.cpp index 2121052..11de00d 100644 --- a/Software/components/core/src/Bt1035At.cpp +++ b/Software/components/core/src/Bt1035At.cpp @@ -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 bootInitSequence() noex return std::array{ Bt1035AtCommand::Ping, Bt1035AtCommand::I2sMode, - Bt1035AtCommand::I2sSlave48k24, + Bt1035AtCommand::I2sSlave48k32, + Bt1035AtCommand::A2dpCodecConfig, }; } diff --git a/Software/components/core/test/bt1035_at_test.cpp b/Software/components/core/test/bt1035_at_test.cpp index f529165..8c7ed96 100644 --- a/Software/components/core/test/bt1035_at_test.cpp +++ b/Software/components/core/test/bt1035_at_test.cpp @@ -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 = diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index a664f70..d0924db 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -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(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( (static_cast(raw[0]) << 24) | (static_cast(raw[1]) << 16) | (static_cast(raw[2]) << 8) | static_cast(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(fixpoint), + band, i, baseAddr + i, + static_cast( + (static_cast(raw[0]) << 24) + | (static_cast(raw[1]) << 16) + | (static_cast(raw[2]) << 8) + | static_cast(raw[3])), static_cast(fixpoint) / static_cast(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(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(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(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(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(kLimiterThresholdDb)); } booted_ = true; diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index 169852f..ebd7f0e 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -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 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 setA2dpCodecConfig( + std::uint8_t bitmask); + /** * @brief queryPairedList — enumerate paired remotes (AT+PLIST). * diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index 6866f7d..c935bcb 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -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 Bt1035Driver::setAutoReconnect( return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times)); } +std::expected Bt1035Driver::setA2dpCodecConfig( + std::uint8_t bitmask) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return transmitAndExpectOk(core::buildBt1035A2dpCodecConfigLine(bitmask)); +} + std::expected 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(static_cast(*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(codec.error())); + } return true; } const TickType_t now = xTaskGetTickCount(); @@ -974,38 +1009,35 @@ std::expected Bt1035Driver::runInitSequence() std::expected 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(pins_.sysCtlGpio); const auto resetPin = static_cast(pins_.resetGpio); const auto ctsPin = static_cast(pins_.ctsGpio); const auto rtsPin = static_cast(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 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(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 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 {}; } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 0271ee1..43fbd2c 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -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 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(*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 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, diff --git a/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp index 5941331..44f9cc7 100644 --- a/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp +++ b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp @@ -107,6 +107,30 @@ public: [[nodiscard]] std::expected 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 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 + queryA2dpCodec(); + /** * @brief scanNearby — classic BT/EDR discovery, cancelling pairing. * diff --git a/Software/components/services/bluetooth/src/BluetoothService.cpp b/Software/components/services/bluetooth/src/BluetoothService.cpp index 59a9c97..4019e39 100644 --- a/Software/components/services/bluetooth/src/BluetoothService.cpp +++ b/Software/components/services/bluetooth/src/BluetoothService.cpp @@ -148,6 +148,18 @@ std::expected BluetoothService::setAutoReconnect( return driver_.setAutoReconnect(times); } +std::expected BluetoothService::setA2dpCodecConfig( + std::uint8_t bitmask) +{ + return driver_.setA2dpCodecConfig(bitmask); +} + +std::expected +BluetoothService::queryA2dpCodec() +{ + return driver_.queryA2dpEncoder(); +} + std::expected, bt1035::Bt1035Error> BluetoothService::scanNearby(std::uint8_t scanSeconds) { diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index 84a1036..45d17e0 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -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