Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/development/waveshare-box-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions docs/development/waveshare-volume-build-2026-10-10.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion firmware/esphome/components/waveshare_box_audio/audio.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Expand All @@ -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_{};
};
}
7 changes: 6 additions & 1 deletion firmware/esphome/packages/hardware/waveshare-box-v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_waveshare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
29 changes: 27 additions & 2 deletions tests/waveshare_adapter_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>{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<float>{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<bool>{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);
Expand Down
13 changes: 12 additions & 1 deletion tests/waveshare_stubs/esphome/components/es8311/es8311.h
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
#pragma once
#include "esphome/core/component.h"
namespace esphome::es8311 { class ES8311 : public Component {}; }
#include <vector>
namespace esphome::es8311 {
class ES8311 : public Component {
public:
bool volume_write_ok{true};
bool unmute_write_ok{true};
unsigned unmute_writes{};
std::vector<float> volumes;
bool set_volume(float value) { volumes.push_back(value); return volume_write_ok; }
bool set_mute_off() { ++unmute_writes; return unmute_write_ok; }
};
}