diff --git a/docs/development/waveshare-box-v2.md b/docs/development/waveshare-box-v2.md index 9db1a75..d95fc08 100644 --- a/docs/development/waveshare-box-v2.md +++ b/docs/development/waveshare-box-v2.md @@ -79,6 +79,26 @@ physical validation. ESPHome adds pixel format, orientation, inversion, sleep-out and display-on commands. Its QSPI path requires a 16-bit framebuffer (259,200 bytes); allocator placement and recovery must be measured on-device. +### Volume + +Waveshare uses fixed ES8311 output at 0 dB (register `0xBF`) and ESPHome's +software volume control. The amplifier stays off until the fixed-gain and codec +unmute writes succeed. A failed write prevents playback readiness until restart. + +The saved 0–100% volume controls software attenuation: zero is silent, 80% +applies approximately −9.8 dB, and 100% passes the recording unchanged. The +Waveshare profile overrides the shared 60% ceiling; the reference Voice Pyramid +mapping is unchanged. Saved settings retain their values, so existing devices +will sound louder at the same percentage after this change. Start attended +listening checks at a low setting before increasing volume. + +This curve follows the [pinned ESPHome speaker implementation](https://github.com/esphome/esphome/blob/2026.9.0/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp). +Physical loudness, distortion and runtime memory under concurrent playback, +networking and display activity require separate attended validation. The +earlier dated listening results apply to the previous volume mapping. +See the [volume build checks](waveshare-volume-build-2026-10-10.md) for firmware +size comparisons, automated coverage and pending physical validation. + ### RTC and offline restart The Waveshare profiles restore UTC once at startup from the onboard PCF85063A diff --git a/docs/development/waveshare-volume-build-2026-10-10.md b/docs/development/waveshare-volume-build-2026-10-10.md new file mode 100644 index 0000000..f8518df --- /dev/null +++ b/docs/development/waveshare-volume-build-2026-10-10.md @@ -0,0 +1,42 @@ +# Waveshare volume build checks — 2026-10-10 + +The Waveshare Box V2 volume change replaces the inherited 60% codec ceiling +with fixed 0 dB codec output and ESPHome software attenuation. Codec gain and +unmute writes must succeed before the amplifier is enabled. Saved settings and +shared audio retain their existing formats; the reference Voice Pyramid mapping +is unchanged. + +## Automated validation + +All 18 CTests passed with UndefinedBehaviorSanitizer, and all 107 Python tests +passed with the pinned ESPHome environment and resolved ArduinoJson headers. +Adapter tests cover gain/unmute failures, amplifier gating, volume requests and +playback readiness. Generated configuration checks cover all three Waveshare +profiles and the reference profile's existing 60% ceiling and DAC binding. + +The before builds use main revision `1378dc2`; the after builds add the volume +change. Both use the same configurations, compile-only fixtures, ESPHome +2026.9.0, ESP-IDF 5.5.5 and pinned compiler. Each variant passed +`tools/check_feasibility.py`, including dependency, partition and image checks. + +| Variant | Before OTA bytes | After OTA bytes | Delta | Remaining 1.5 MiB budget | +| --- | ---: | ---: | ---: | ---: | +| Audio | 1,212,816 | 1,212,912 | +96 | 359,952 | +| Display | 1,262,784 | 1,262,864 | +80 | 310,000 | +| Diagnostics | 1,349,712 | 1,349,792 | +80 | 223,072 | + +Both 2 MiB application slots and the separate 3.5 MiB shared audio partition +remain unchanged. Compile-only fixture builds must never be installed or played. + +## Physical validation pending + +These checks do not establish audible loudness, distortion, sustained playback +or runtime heap/PSRAM headroom. Attended testing must use the approved real +recordings and a separately verified application-only candidate, starting at low +volume before testing 50%, 80% and 100%. Verify silence at zero, volume changes, +Stop, concurrent network/display activity and cold-restart persistence. + +Reidentify the live unit and preserve settings, prayer history, shared audio and +fresh compatible recovery according to the +[hardware runbook](../../firmware/esphome/provisioning/HARDWARE_TEST.md). +The earlier dated Waveshare listening results cover the previous volume mapping. diff --git a/firmware/esphome/components/waveshare_box_audio/audio.h b/firmware/esphome/components/waveshare_box_audio/audio.h index 210ce31..3dc09d8 100644 --- a/firmware/esphome/components/waveshare_box_audio/audio.h +++ b/firmware/esphome/components/waveshare_box_audio/audio.h @@ -14,6 +14,13 @@ class Audio : public Component, public ::openathan::Playback { float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override { amplifier_->setup(); amplifier_->digital_write(false); } void loop() override { + if (!gain_configured_ && !is_failed() && dac_ && !dac_->is_failed() && + playback_ && playback_->ready()) { + // ESPHome 2026.9.0 maps 0.75 to ES8311 register 0xBF (0 dB). + // The speaker controls volume in software; never amplify the samples. + if (dac_->set_volume(0.75f) && dac_->set_mute_off()) gain_configured_ = true; + else mark_failed(); + } const bool enable = healthy_(); if (enable != enabled_) { amplifier_->digital_write(enable); enabled_ = enable; } } @@ -29,10 +36,14 @@ class Audio : public Component, public ::openathan::Playback { return healthy_() && playback_->request_volume_percent(percent); } private: - bool healthy_() const { return dac_ && !dac_->is_failed() && playback_ && playback_->ready(); } + bool healthy_() const { + return !is_failed() && gain_configured_ && dac_ && !dac_->is_failed() && + playback_ && playback_->ready(); + } openathan_audio::PartitionAudio *playback_{}; es8311::ES8311 *dac_{}; GPIOPin *amplifier_{}; bool enabled_{}; + bool gain_configured_{}; }; } diff --git a/firmware/esphome/packages/hardware/waveshare-box-v2.yaml b/firmware/esphome/packages/hardware/waveshare-box-v2.yaml index 17a3397..023a306 100644 --- a/firmware/esphome/packages/hardware/waveshare-box-v2.yaml +++ b/firmware/esphome/packages/hardware/waveshare-box-v2.yaml @@ -44,7 +44,12 @@ speaker: bits_per_sample: 16bit sample_rate: 44100 channel: mono - audio_dac: waveshare_dac + # Keep the codec at fixed 0 dB through waveshare_box_audio. Without an + # audio_dac binding, ESPHome applies its existing software volume curve. + +media_player: + - id: !extend athan_player + volume_max: 100% waveshare_box_audio: id: waveshare_audio diff --git a/tests/test_waveshare.py b/tests/test_waveshare.py index 34e6470..07c1fe2 100644 --- a/tests/test_waveshare.py +++ b/tests/test_waveshare.py @@ -78,3 +78,29 @@ def test_generated_board_and_profile_guards(self): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn('#define OPENATHAN_USB_OWNS_DRIVER\n', (build / 'src/esphome/core/defines.h').read_text()) self.assertIn('athan_status_screen->set_stop_button(false)', (build / 'src/main.cpp').read_text()) + + def test_volume_routes_for_each_profile(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + # Included diagnostic profiles fall back to the main config's secrets. + (root / 'secrets.yaml').write_text('diagnostic_api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=\n') + for profile in ('waveshare/audio', 'waveshare/development', + 'waveshare/diagnostics', 'openathan'): + with self.subTest(profile=profile): + config = root / 'volume.yaml' + config.write_text(f"packages:\n product: !include {ROOT / 'firmware/esphome' / (profile + '.yaml')}\n") + build = root / profile.replace('/', '-') + result = subprocess.run([sys.executable, '-m', 'esphome', 'compile', + '--only-generate', str(config)], + env=dict(os.environ, ESPHOME_BUILD_PATH=str(build)), + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + name = 'openathan-test' if profile.startswith('waveshare/') else 'openathan' + source = (build / name / 'src/main.cpp').read_text() + if profile.startswith('waveshare/'): + self.assertIn('athan_player->set_volume_max(1.0f)', source) + self.assertIn('waveshare_audio->set_dac(waveshare_dac)', source) + self.assertNotIn('waveshare_speaker->set_audio_dac(', source) + else: + self.assertIn('athan_player->set_volume_max(0.6f)', source) + self.assertIn('pyramid_speaker->set_audio_dac(pyramid_dac)', source) diff --git a/tests/waveshare_adapter_tests.cpp b/tests/waveshare_adapter_tests.cpp index 37e2329..07bb578 100644 --- a/tests/waveshare_adapter_tests.cpp +++ b/tests/waveshare_adapter_tests.cpp @@ -8,20 +8,45 @@ int main() { audio.set_amplifier(&pin); audio.set_dac(&dac); audio.set_playback(&playback); audio.setup(); assert(pin.initialized && pin.levels == std::vector{false}); audio.loop(); assert(!audio.ready() && pin.levels.size() == 1); + assert(dac.volumes.empty()); assert(!audio.start(::openathan::Track::NORMAL) && playback.starts == 0); assert(!audio.request_volume_percent(30)); playback.available = true; - // Startup can apply saved volume before power is enabled; playback waits for it. - assert(audio.request_volume_percent(30) && audio.volume_percent() == 30 && !audio.ready()); + // Wait for fixed codec gain before accepting volume or enabling playback. + assert(!audio.request_volume_percent(30) && !audio.volume_percent()); audio.loop(); assert(audio.ready() && pin.levels.back()); + assert(dac.volumes == std::vector{0.75f} && dac.unmute_writes == 1); + for (unsigned percent : {0U, 30U, 80U, 100U}) { + assert(audio.request_volume_percent(percent) && audio.volume_percent() == percent); + } audio.loop(); assert(pin.levels.size() == 2); + assert(dac.volumes.size() == 1 && dac.unmute_writes == 1); // Software owns volume/mute. assert(audio.start(::openathan::Track::FAJR) && audio.playing()); audio.stop(); assert(!audio.playing() && playback.stops == 1); playback.available = false; audio.loop(); assert(!audio.ready() && !pin.levels.back()); playback.available = true; audio.loop(); assert(audio.ready()); + assert(dac.volumes.size() == 1); dac.mark_failed(); assert(!audio.ready() && !audio.start(::openathan::Track::NORMAL)); audio.loop(); assert(!pin.levels.back()); audio.on_shutdown(); assert(!pin.levels.back()); + for (bool fail_gain : {true, false}) { + GPIOPin failed_pin; es8311::ES8311 failed_dac; + openathan_audio::PartitionAudio failed_playback; + waveshare_box_audio::Audio failed_audio; + failed_audio.set_amplifier(&failed_pin); failed_audio.set_dac(&failed_dac); + failed_audio.set_playback(&failed_playback); failed_audio.setup(); + failed_playback.available = true; + failed_dac.volume_write_ok = !fail_gain; failed_dac.unmute_write_ok = fail_gain; + failed_audio.loop(); + assert(failed_audio.is_failed() && !failed_audio.ready()); + assert(failed_pin.levels == std::vector{false}); + assert(!failed_audio.request_volume_percent(80) && !failed_audio.volume_percent()); + assert(!failed_audio.start(::openathan::Track::NORMAL) && failed_playback.starts == 0); + failed_dac.volume_write_ok = true; failed_dac.unmute_write_ok = true; + failed_audio.loop(); + assert(failed_dac.volumes.size() == 1 && !failed_audio.ready()); + assert(failed_dac.unmute_writes == (fail_gain ? 0U : 1U)); + } output::FloatOutput pwm; display::Display canvas; waveshare_box_display::Backlight light; light.set_output(&pwm); light.set_display(&canvas); light.setup(); assert(!light.is_failed() && pwm.levels.back() == 0.5f); diff --git a/tests/waveshare_stubs/esphome/components/es8311/es8311.h b/tests/waveshare_stubs/esphome/components/es8311/es8311.h index e70c19e..fd5a9ad 100644 --- a/tests/waveshare_stubs/esphome/components/es8311/es8311.h +++ b/tests/waveshare_stubs/esphome/components/es8311/es8311.h @@ -1,3 +1,14 @@ #pragma once #include "esphome/core/component.h" -namespace esphome::es8311 { class ES8311 : public Component {}; } +#include +namespace esphome::es8311 { +class ES8311 : public Component { + public: + bool volume_write_ok{true}; + bool unmute_write_ok{true}; + unsigned unmute_writes{}; + std::vector volumes; + bool set_volume(float value) { volumes.push_back(value); return volume_write_ok; } + bool set_mute_off() { ++unmute_writes; return unmute_write_ok; } +}; +}