[audio_http][core] Add ca_certificate_path to audio_http media source (#19392)

This commit is contained in:
TheSwert
2026-10-07 09:28:10 -10:00
committed by GitHub
parent 6dec759261
commit 2560a4436e
8 changed files with 171 additions and 0 deletions
@@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() {
" Persistent Ring Buffer: %s\n"
" Decoder Task Stack in PSRAM: %s",
this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_));
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr));
#endif
}
void AudioHTTPMediaSource::setup() {
@@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() {
config.reader_stack_size = READER_TASK_STACK_SIZE;
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
// micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle.
if (this->http_ca_certificate_ != nullptr) {
config.http_ca_certificate = this->http_ca_certificate_;
}
#endif
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
if (this->decoder_ == nullptr) {
@@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component,
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; }
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
/// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs,
/// replacing the built-in certificate bundle.
void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; }
#endif
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
@@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component,
std::atomic<bool> pause_{false};
bool decoder_task_stack_in_psram_{false};
bool persistent_ring_buffer_{false};
#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE
const char *http_ca_certificate_{nullptr};
#endif
};
} // namespace esphome::audio_http
@@ -1,13 +1,22 @@
import logging
from pathlib import Path
import esphome.codegen as cg
from esphome.components import audio, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
import esphome.final_validate as fv
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer"
CONF_CA_CERTIFICATE_PATH = "ca_certificate_path"
CONF_HTTP_REQUEST = "http_request"
CONF_VERIFY_SSL = "verify_ssl"
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
@@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType:
# Default to the CA certificate configured on the http_request component so
# HTTPS playback verifies against the same trust anchor without repeating
# the option on every source. Needed because audio_http sources are often
# declared by device packages and cannot be extended from the device config.
# The PEM replaces the built-in certificate bundle as the sole trust anchor,
# so make the inheritance visible in the log.
if CONF_CA_CERTIFICATE_PATH in config:
return config
fconf = fv.full_config.get()
if CONF_HTTP_REQUEST not in fconf:
return config
http_request_config = fconf[CONF_HTTP_REQUEST]
# Mirror http_request's own semantics: it only applies its CA when SSL
# verification is enabled, so neither does the inheritance.
if not http_request_config.get(CONF_VERIFY_SSL, True):
return config
if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH):
_LOGGER.info(
"audio_http source '%s' is inheriting ca_certificate_path from the "
"http_request component; HTTPS playback will verify against that PEM "
"instead of the built-in certificate bundle",
config[CONF_ID].id,
)
# Already validated by http_request's schema; the value is a resolved Path.
config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path
return config
FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
@@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER]))
# Embed the certificate content, like http_request does. Passed to
# micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as
# the sole trust anchor for HTTPS playback URLs instead of the certificate
# bundle.
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE")
with Path(ca_cert_path).open(encoding="utf-8") as f:
ca_cert_content = f.read()
cg.add(var.set_http_ca_certificate(ca_cert_content))
+1
View File
@@ -256,6 +256,7 @@
#define AUDIO_FILE_MAX_FILES 4
#define USE_AUDIO_DAC
#define USE_AUDIO_FLAC_SUPPORT
#define USE_AUDIO_HTTP_CA_CERTIFICATE
#define USE_AUDIO_MP3_SUPPORT
#define USE_AUDIO_OPUS_SUPPORT
#define USE_AUDIO_WAV_SUPPORT
@@ -0,0 +1,6 @@
media_source:
- platform: audio_http
id: audio_http_ca_source
ca_certificate_path: $component_dir/test_ca.pem
<<: !include common.yaml
@@ -0,0 +1,13 @@
# http_request requires a network component; audio_http's common.yaml declares none.
wifi:
ssid: MySSID
password: password1
http_request:
ca_certificate_path: $component_dir/test_ca.pem
media_source:
- platform: audio_http
id: audio_http_inherit_ca_source
<<: !include common.yaml
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu
dXNlZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM
BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5mMUB1hOgLmlnXtsvcGMP
XkhAqZaR0dDPW5OS8VEopWLJCX9Y0cvNCqiDI8cnP8pP8XJGU1hGLvA5PJzWnWZz
AgMBAAGjUzBRMB0GA1UdDgQWBBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAfBgNVHSME
GDAWgBR5oQ9KqFeZOdBuAJrXxEP0dqzPtTAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
SIb3DQEBCwUAA0EAKqZFf6+f8FPDbKyPCpssquojgn7fEXqr/I/yz0R5CowGdMms
H3WH3aKP4lLSHdPTBtfIoJi3gEIZjFxp3S1TWw==
-----END CERTIFICATE-----
@@ -0,0 +1,71 @@
"""Tests for the audio_http media source's ca_certificate_path inheritance."""
from pathlib import Path
from esphome.components.audio_http.media_source import _inherit_ca_certificate_path
from esphome.core import ID
import esphome.final_validate as fv
from esphome.types import ConfigType
def _source_config() -> ConfigType:
return {"id": ID("audio_http_source")}
def _run(full_config: dict, config: ConfigType) -> ConfigType:
token = fv.full_config.set(full_config)
try:
return _inherit_ca_certificate_path(config)
finally:
fv.full_config.reset(token)
def test_explicit_option_wins() -> None:
"""A source with its own ca_certificate_path is left untouched."""
config = {"id": ID("s"), "ca_certificate_path": Path("own.pem")}
result = _run(
{"http_request": {"verify_ssl": True, "ca_certificate_path": Path("hr.pem")}},
config,
)
assert result["ca_certificate_path"] == Path("own.pem")
def test_inherited_from_http_request() -> None:
"""Without its own option, the source picks up http_request's CA path."""
result = _run(
{
"http_request": {
"verify_ssl": True,
"ca_certificate_path": Path("hr.pem"),
}
},
_source_config(),
)
assert result["ca_certificate_path"] == Path("hr.pem")
def test_no_http_request_component() -> None:
"""No http_request block means no inheritance."""
result = _run({}, _source_config())
assert "ca_certificate_path" not in result
def test_http_request_without_ca() -> None:
"""An http_request block without ca_certificate_path means no inheritance."""
result = _run({"http_request": {"verify_ssl": True}}, _source_config())
assert "ca_certificate_path" not in result
def test_not_inherited_when_verify_ssl_disabled() -> None:
"""verify_ssl: false disables the CA for http_request itself, so the
inheritance must skip it too instead of silently pinning playback."""
result = _run(
{
"http_request": {
"verify_ssl": False,
"ca_certificate_path": Path("hr.pem"),
}
},
_source_config(),
)
assert "ca_certificate_path" not in result