Merge branch 'add-codeowner-approved-label' into integration

This commit is contained in:
J. Nick Koston
2026-03-02 16:02:28 -10:00
16 changed files with 592 additions and 145 deletions
+11 -37
View File
@@ -7,6 +7,7 @@ const {
hasDashboardChanges,
hasGitHubActionsChanges,
} = require('../detect-tags');
const { fetchCodeowners } = require('../codeowners');
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
@@ -151,48 +152,21 @@ async function detectCodeOwner(github, context, changedFiles) {
const { owner, repo } = context.repo;
try {
const { data: codeownersFile } = await github.rest.repos.getContent({
owner,
repo,
path: 'CODEOWNERS',
});
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
const codeownersPatterns = await fetchCodeowners(github, owner, repo);
const prAuthor = context.payload.pull_request.user.login;
const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const codeownersRegexes = codeownersLines.map(line => {
const parts = line.split(/\s+/);
const pattern = parts[0];
const owners = parts.slice(1);
let regex;
if (pattern.endsWith('*')) {
const dir = pattern.slice(0, -1);
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
} else if (pattern.includes('*')) {
// First escape all regex special chars except *, then replace * with .*
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
regex = new RegExp(`^${regexPattern}$`);
} else {
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
}
return { regex, owners };
});
// Check if PR author is a codeowner of any changed file (last-match-wins)
for (const file of changedFiles) {
for (const { regex, owners } of codeownersRegexes) {
if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
labels.add('by-code-owner');
return labels;
let effectiveOwners = null;
for (const { regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
effectiveOwners = owners;
}
}
if (effectiveOwners && effectiveOwners.some(o => o === `@${prAuthor}`)) {
labels.add('by-code-owner');
return labels;
}
}
} catch (error) {
console.log('Failed to read or parse CODEOWNERS file:', error.message);
+143
View File
@@ -0,0 +1,143 @@
// Shared CODEOWNERS parsing and matching utilities.
//
// Used by:
// - codeowner-review-request.yml
// - codeowner-approved-label.yml
// - auto-label-pr/detectors.js (detectCodeOwner)
/**
* Convert a CODEOWNERS glob pattern to a RegExp.
*
* Handles **, *, and ? wildcards after escaping regex-special characters.
*/
function globToRegex(pattern) {
let regexStr = pattern
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1')
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace
.replace(/\*/g, '[^/]*') // single star
.replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar
.replace(/\?/g, '.');
return new RegExp('^' + regexStr + '$');
}
/**
* Parse raw CODEOWNERS file content into an array of
* { pattern, regex, owners } objects.
*
* Each `owners` entry is the raw string from the file (e.g. "@user" or
* "@esphome/core").
*/
function parseCodeowners(content) {
const lines = content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const patterns = [];
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const pattern = parts[0];
const owners = parts.slice(1);
const regex = globToRegex(pattern);
patterns.push({ pattern, regex, owners });
}
return patterns;
}
/**
* Fetch and parse the CODEOWNERS file via the GitHub API.
*
* @param {object} github - octokit instance from actions/github-script
* @param {string} owner - repo owner
* @param {string} repo - repo name
* @param {string} [ref] - git ref (SHA / branch) to read from
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
*/
async function fetchCodeowners(github, owner, repo, ref) {
const params = { owner, repo, path: 'CODEOWNERS' };
if (ref) params.ref = ref;
const { data: file } = await github.rest.repos.getContent(params);
const content = Buffer.from(file.content, 'base64').toString('utf8');
return parseCodeowners(content);
}
/**
* Classify raw owner strings into individual users and teams.
*
* @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"]
* @returns {{ users: string[], teams: string[] }}
* users login names without "@"
* teams team slugs without the "org/" prefix
*/
function classifyOwners(rawOwners) {
const users = [];
const teams = [];
for (const o of rawOwners) {
const clean = o.startsWith('@') ? o.slice(1) : o;
if (clean.includes('/')) {
teams.push(clean.split('/')[1]);
} else {
users.push(clean);
}
}
return { users, teams };
}
/**
* For each file, find its effective codeowners using GitHub's
* "last match wins" semantics, then union across all files.
*
* @param {string[]} files - list of file paths
* @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners
* @returns {{ users: Set<string>, teams: Set<string>, matchedFileCount: number }}
*/
function getEffectiveOwners(files, codeownersPatterns) {
const users = new Set();
const teams = new Set();
let matchedFileCount = 0;
for (const file of files) {
// Last matching pattern wins for each file
let effectiveOwners = null;
for (const { regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
effectiveOwners = owners;
}
}
if (effectiveOwners) {
matchedFileCount++;
const classified = classifyOwners(effectiveOwners);
for (const u of classified.users) users.add(u);
for (const t of classified.teams) teams.add(t);
}
}
return { users, teams, matchedFileCount };
}
/**
* Read and parse the CODEOWNERS file from disk.
*
* Use this when the repo is already checked out (avoids an API call).
*
* @param {string} [repoRoot='.'] - path to the repo root
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
*/
function loadCodeowners(repoRoot = '.') {
const fs = require('fs');
const path = require('path');
const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8');
return parseCodeowners(content);
}
module.exports = {
globToRegex,
parseCodeowners,
fetchCodeowners,
loadCodeowners,
classifyOwners,
getEffectiveOwners
};
@@ -0,0 +1,158 @@
# This workflow adds/removes a 'code-owner-approved' label when a
# component-specific codeowner approves (or dismisses) a PR.
# This helps maintainers prioritize PRs that have codeowner sign-off.
#
# Only component-specific codeowners count — the catch-all @esphome/core
# team is excluded so the label reflects domain-expert approval.
name: Codeowner Approved Label
on:
pull_request_review:
types: [submitted, dismissed]
permissions:
pull-requests: write
contents: read
jobs:
codeowner-approved:
name: Run
if: ${{ github.repository == 'esphome/esphome' }}
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = context.payload.pull_request.number;
const LABEL_NAME = 'code-owner-approved';
console.log(`Processing PR #${pr_number} for codeowner approval label`);
try {
// Get the list of changed files in this PR (with pagination)
const prFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner,
repo,
pull_number: pr_number
}
);
const changedFiles = prFiles.map(file => file.filename);
console.log(`Found ${changedFiles.length} changed files`);
if (changedFiles.length === 0) {
console.log('No changed files found, skipping');
return;
}
// Parse CODEOWNERS from the checked-out base branch
const codeownersPatterns = loadCodeowners();
// Get effective owners using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
// Only keep individual component-specific codeowners (exclude teams)
const componentCodeowners = effective.users;
console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
if (componentCodeowners.size === 0) {
console.log('No component-specific codeowners found for changed files');
// Remove label if present since there are no component codeowners
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`);
} catch (error) {
if (error.status !== 404) {
console.log(`Failed to remove label: ${error.message}`);
}
}
return;
}
// Get all reviews on the PR
const reviews = await github.paginate(
github.rest.pulls.listReviews,
{
owner,
repo,
pull_number: pr_number
}
);
// Get the latest review per user (reviews are returned chronologically)
const latestReviewByUser = new Map();
for (const review of reviews) {
// Skip bot reviews and comment-only reviews
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
latestReviewByUser.set(review.user.login, review);
}
// Check if any component-specific codeowner has an active approval
let hasCodeownerApproval = false;
for (const [login, review] of latestReviewByUser) {
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
console.log(`Codeowner '${login}' has approved`);
hasCodeownerApproval = true;
break;
}
}
// Get current labels to check if label is already present
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: pr_number
});
const hasLabel = currentLabels.some(label => label.name === LABEL_NAME);
if (hasCodeownerApproval && !hasLabel) {
// Add the label
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pr_number,
labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (!hasCodeownerApproval && hasLabel) {
// Remove the label
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr_number,
name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
} catch (error) {
if (error.status !== 404) {
console.log(`Failed to remove label: ${error.message}`);
}
}
} else {
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
}
} catch (error) {
console.error(error);
core.setFailed(`Failed to process codeowner approval label: ${error.message}`);
}
+18 -72
View File
@@ -24,10 +24,17 @@ jobs:
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Request reviews from component codeowners
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = context.payload.pull_request.number;
@@ -53,32 +60,10 @@ jobs:
return;
}
// Fetch CODEOWNERS file from root
const { data: codeownersFile } = await github.rest.repos.getContent({
owner,
repo,
path: 'CODEOWNERS',
ref: context.payload.pull_request.base.sha
});
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
// Parse CODEOWNERS from the checked-out base branch
const codeownersPatterns = loadCodeowners();
// Parse CODEOWNERS file to extract all patterns and their owners
const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const codeownersPatterns = [];
// Convert CODEOWNERS pattern to regex (robust glob handling)
function globToRegex(pattern) {
// Escape regex special characters except for glob wildcards
let regexStr = pattern
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars
.replace(/\*\*/g, '.*') // globstar
.replace(/\*/g, '[^/]*') // single star
.replace(/\?/g, '.'); // question mark
return new RegExp('^' + regexStr + '$');
}
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
// Helper function to create comment body
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
@@ -93,50 +78,11 @@ jobs:
}
}
for (const line of codeownersLines) {
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const pattern = parts[0];
const owners = parts.slice(1);
// Use robust glob-to-regex conversion
const regex = globToRegex(pattern);
codeownersPatterns.push({ pattern, regex, owners });
}
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
// Match changed files against CODEOWNERS patterns
const matchedOwners = new Set();
const matchedTeams = new Set();
const fileMatches = new Map(); // Track which files matched which patterns
for (const file of changedFiles) {
for (const { pattern, regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`);
if (!fileMatches.has(file)) {
fileMatches.set(file, []);
}
fileMatches.get(file).push({ pattern, owners });
// Add owners to the appropriate set (remove @ prefix)
for (const owner of owners) {
const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
if (cleanOwner.includes('/')) {
// Team mention (org/team-name)
const teamName = cleanOwner.split('/')[1];
matchedTeams.add(teamName);
} else {
// Individual user
matchedOwners.add(cleanOwner);
}
}
}
}
}
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
const matchedOwners = effective.users;
const matchedTeams = effective.teams;
const matchedFileCount = effective.matchedFileCount;
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
console.log('No codeowners found for any changed files');
@@ -247,7 +193,7 @@ jobs:
}
const totalReviewers = reviewersList.length + teamsList.length;
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`);
// Request reviews
try {
@@ -279,7 +225,7 @@ jobs:
// Only add a comment if there are new codeowners to mention (not previously pinged)
if (reviewersList.length > 0 || teamsList.length > 0) {
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true);
await github.rest.issues.createComment({
owner,
@@ -297,7 +243,7 @@ jobs:
// Only try to add a comment if there are new codeowners to mention
if (reviewersList.length > 0 || teamsList.length > 0) {
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
try {
await github.rest.issues.createComment({
+2 -2
View File
@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
category: "/language:${{matrix.language}}"
+7 -2
View File
@@ -26,14 +26,19 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
const author = context.payload.pull_request.user.login;
// Skip bot PRs (e.g. dependabot) - they have their own title format
if (author === 'dependabot[bot]') {
return;
}
// Block titles starting with "word:" or "word(scope):" patterns
const commitStylePattern = /^\w+(\(.*?\))?[!]?\s*:/;
if (commitStylePattern.test(title)) {
core.setFailed(
`PR title should not start with a "prefix:" style format.\n` +
`Please use the format: [component] Brief description\n` +
`Example: [pn532] Add health checking and auto-reset`
`Please use the format: [component] Brief description\n`
);
return;
}
+1
View File
@@ -316,6 +316,7 @@ esphome/components/mcp9808/* @k7hpn
esphome/components/md5/* @esphome/core
esphome/components/mdns/* @esphome/core
esphome/components/media_player/* @jesserockz
esphome/components/media_source/* @kahrendt
esphome/components/micro_wake_word/* @jesserockz @kahrendt
esphome/components/micronova/* @edenhaus @jorre05
esphome/components/microphone/* @jesserockz @kahrendt
+1 -1
View File
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_ID,
CONF_POWER_MODE,
CONF_RANGE,
CONF_WATCHDOG,
)
CODEOWNERS = ["@ammmze"]
@@ -57,7 +58,6 @@ FAST_FILTER = {
CONF_RAW_ANGLE = "raw_angle"
CONF_RAW_POSITION = "raw_position"
CONF_WATCHDOG = "watchdog"
CONF_SLOW_FILTER = "slow_filter"
CONF_FAST_FILTER = "fast_filter"
CONF_START_POSITION = "start_position"
@@ -23,7 +23,6 @@ AS5600Sensor = as5600_ns.class_("AS5600Sensor", sensor.Sensor, cg.PollingCompone
CONF_RAW_ANGLE = "raw_angle"
CONF_RAW_POSITION = "raw_position"
CONF_WATCHDOG = "watchdog"
CONF_SLOW_FILTER = "slow_filter"
CONF_FAST_FILTER = "fast_filter"
CONF_PWM_FREQUENCY = "pwm_frequency"
+3 -2
View File
@@ -8,6 +8,7 @@ static constexpr const char *const TAG = "lps22";
static constexpr uint8_t WHO_AM_I = 0x0F;
static constexpr uint8_t LPS22HB_ID = 0xB1;
static constexpr uint8_t LPS22HH_ID = 0xB3;
static constexpr uint8_t LPS22DF_ID = 0xB4;
static constexpr uint8_t CTRL_REG2 = 0x11;
static constexpr uint8_t CTRL_REG2_ONE_SHOT_MASK = 0b1;
static constexpr uint8_t STATUS = 0x27;
@@ -24,8 +25,8 @@ static constexpr float TEMPERATURE_SCALE = 0.01f;
void LPS22Component::setup() {
uint8_t value = 0x00;
this->read_register(WHO_AM_I, &value, 1);
if (value != LPS22HB_ID && value != LPS22HH_ID) {
ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB or LPS22HH ID", value);
if (value != LPS22HB_ID && value != LPS22HH_ID && value != LPS22DF_ID) {
ESP_LOGW(TAG, "device IDs as %02x, which isn't a known LPS22HB/HH/DF ID", value);
this->mark_failed();
}
}
@@ -0,0 +1,40 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
IS_PLATFORM_COMPONENT = True
media_source_ns = cg.esphome_ns.namespace("media_source")
MediaSource = media_source_ns.class_("MediaSource")
async def register_media_source(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
CORE.register_platform_component("media_source", var)
return var
_MEDIA_SOURCE_SCHEMA = cv.Schema({})
def media_source_schema(
class_: MockObjClass,
) -> cv.Schema:
schema = {cv.GenerateID(CONF_ID): cv.declare_id(class_)}
return _MEDIA_SOURCE_SCHEMA.extend(schema)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(media_source_ns.using)
cg.add_define("USE_MEDIA_SOURCE")
@@ -0,0 +1,159 @@
#pragma once
#include "esphome/components/audio/audio.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <string>
namespace esphome::media_source {
enum class MediaSourceState : uint8_t {
IDLE, // Not playing, ready to accept play_uri
PLAYING, // Currently playing media
PAUSED, // Playback paused, can be resumed
ERROR, // Error occurred during playback; sources are responsible for logging their own error details
};
/// @brief Commands that are sent from the orchestrator to a media source
enum class MediaSourceCommand : uint8_t {
// All sources should support these basic commands.
PLAY,
PAUSE,
STOP,
// Only sources with internal playlists will handle these; simple sources should ignore them.
NEXT,
PREVIOUS,
CLEAR_PLAYLIST,
REPEAT_ALL,
REPEAT_ONE,
REPEAT_OFF,
SHUFFLE,
UNSHUFFLE,
};
/// @brief Callbacks from a MediaSource to its orchestrator
class MediaSourceListener {
public:
virtual ~MediaSourceListener() = default;
// Callbacks that all sources use to send data and state changes to the orchestrator.
/// @brief Send audio data to the listener
virtual size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms,
const audio::AudioStreamInfo &stream_info) = 0;
/// @brief Notify listener of state changes
virtual void report_state(MediaSourceState state) = 0;
// Callbacks from smart sources requesting the orchestrator to change volume, mute, or start a new URI.
// Simple sources never invoke these.
/// @brief Request the orchestrator to change volume
virtual void request_volume(float volume) {}
/// @brief Request the orchestrator to change mute state
virtual void request_mute(bool is_muted) {}
/// @brief Request the orchestrator to play a new URI
virtual void request_play_uri(const std::string &uri) {}
};
/// @brief Abstract base class for media sources
/// MediaSource provides audio data to an orchestrator via the MediaSourceListener interface. It also receives commands
/// from the orchestrator to control playback.
class MediaSource {
public:
virtual ~MediaSource() = default;
// === Playback Control ===
/// @brief Start playing the given URI
/// Sources should validate the URI and state, returning false if the source is busy.
/// The orchestrator is responsible for stopping active sources before starting a new one.
/// @param uri URI to play; e.g., "http://stream_url"
/// @return true if playback started successfully, false otherwise
virtual bool play_uri(const std::string &uri) = 0;
/// @brief Handle playback commands (pause, stop, next, etc.)
/// @param command Command to execute
virtual void handle_command(MediaSourceCommand command) = 0;
/// @brief Whether this source manages its own playlist internally
/// Smart sources that handle next/previous/repeat/shuffle should override this to return true.
virtual bool has_internal_playlist() const { return false; }
// === State Access ===
/// @brief Get current playback state (must only be called from the main loop)
/// @return Current state of this source
MediaSourceState get_state() const { return this->state_; }
// === URI Matching ===
/// @brief Check if this source can handle the given URI
/// Each source must override this to match its supported URI scheme(s).
/// @param uri URI to check
/// @return true if this source can handle the URI
virtual bool can_handle(const std::string &uri) const = 0;
// === Listener: Source -> Orchestrator ===
/// @brief Set the listener that receives callbacks from this source
/// @param listener Pointer to the MediaSourceListener implementation
void set_listener(MediaSourceListener *listener) { this->listener_ = listener; }
/// @brief Check if a listener has been registered
bool has_listener() const { return this->listener_ != nullptr; }
/// @brief Write audio data to the listener
/// @param data Pointer to audio data buffer (not modified by this method)
/// @param length Number of bytes to write
/// @param timeout_ms Milliseconds to wait if the listener can't accept data immediately
/// @param stream_info Audio stream format information
/// @return Number of bytes written, or 0 if no listener is set
size_t write_output(const uint8_t *data, size_t length, uint32_t timeout_ms,
const audio::AudioStreamInfo &stream_info) {
if (this->listener_ != nullptr) {
return this->listener_->write_audio(data, length, timeout_ms, stream_info);
}
return 0;
}
// === Callbacks: Orchestrator -> Source ===
/// @brief Notify the source that volume changed
/// Simple sources ignore this. Override for smart sources that track volume state.
/// @param volume New volume level (0.0 to 1.0)
virtual void notify_volume_changed(float volume) {}
/// @brief Notify the source that mute state changed
/// Simple sources ignore this. Override for smart sources that track mute state.
/// @param is_muted New mute state
virtual void notify_mute_changed(bool is_muted) {}
/// @brief Notify the source about audio that has been played
/// Called when the speaker reports that audio frames have been written to the DAC.
/// Sources can override this to track playback progress for synchronization.
/// @param frames Number of audio frames that were played
/// @param timestamp System time in microseconds when the frames finished writing to the DAC
virtual void notify_audio_played(uint32_t frames, int64_t timestamp) {}
protected:
/// @brief Update state and notify listener (must only be called from the main loop)
/// This is the only way to change state_, ensuring listener notifications always fire.
/// Sources running FreeRTOS tasks should signal via event groups and call this from loop().
/// @param state New state to set
void set_state_(MediaSourceState state) {
if (this->state_ != state) {
this->state_ = state;
if (this->listener_ != nullptr) {
this->listener_->report_state(state);
}
}
}
private:
// Private to enforce the invariant that listener notifications always fire on state changes.
// All state transitions must go through set_state_() which couples the update with notification.
MediaSourceState state_{MediaSourceState::IDLE};
MediaSourceListener *listener_{nullptr};
};
} // namespace esphome::media_source
+1
View File
@@ -1094,6 +1094,7 @@ CONF_WAND_ID = "wand_id"
CONF_WARM_WHITE = "warm_white"
CONF_WARM_WHITE_COLOR_TEMPERATURE = "warm_white_color_temperature"
CONF_WARMUP_TIME = "warmup_time"
CONF_WATCHDOG = "watchdog"
CONF_WATCHDOG_THRESHOLD = "watchdog_threshold"
CONF_WATCHDOG_TIMEOUT = "watchdog_timeout"
CONF_WATER_HEATER = "water_heater"
+6
View File
@@ -659,6 +659,12 @@ class Application {
#endif
#endif // USE_LWIP_FAST_SELECT
#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
/// Wake the main event loop from any context (ISR, thread, or main loop).
/// On ESP8266: sets the socket wake flag and calls esp_schedule() to exit esp_delay() early.
static void IRAM_ATTR wake_loop_any_context() { socket::socket_wake(); }
#endif
#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
/// Wake the main event loop from any context (ISR, thread, or main loop).
/// On ESP8266: sets the socket wake flag and calls esp_schedule() to exit esp_delay() early.
+1
View File
@@ -108,6 +108,7 @@
#define MDNS_DYNAMIC_TXT_COUNT 2
#define SNTP_SERVER_COUNT 3
#define USE_MEDIA_PLAYER
#define USE_MEDIA_SOURCE
#define USE_NEXTION_TFT_UPLOAD
#define USE_NUMBER
#define USE_OUTPUT
+41 -28
View File
@@ -17,10 +17,10 @@ async def test_oversized_payload_plaintext(
) -> None:
"""Test that oversized payloads (>32768 bytes) from client cause disconnection without crashing."""
process_exited = False
helper_log_found = False
helper_log_event = asyncio.Event()
def check_logs(line: str) -> None:
nonlocal process_exited, helper_log_found
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
@@ -30,7 +30,7 @@ async def test_oversized_payload_plaintext(
and "Bad packet: message size" in line
and "exceeds maximum" in line
):
helper_log_found = True
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with api_client_connected_with_disconnect() as (client, disconnect_event):
@@ -54,10 +54,13 @@ async def test_oversized_payload_plaintext(
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
# Verify we saw the expected HELPER_LOG message
assert helper_log_found, (
"Expected to see HELPER_LOG about message size exceeding maximum"
)
# Wait for the expected log message (may arrive after disconnect event)
try:
await asyncio.wait_for(helper_log_event.wait(), timeout=2.0)
except TimeoutError:
pytest.fail(
"Expected to see HELPER_LOG about message size exceeding maximum"
)
# Try to reconnect to verify the process is still running
async with api_client_connected_with_disconnect() as (client2, _):
@@ -77,10 +80,10 @@ async def test_oversized_protobuf_message_id_plaintext(
This tests the message type limit - message IDs must fit in a uint16_t (0-65535).
"""
process_exited = False
helper_log_found = False
helper_log_event = asyncio.Event()
def check_logs(line: str) -> None:
nonlocal process_exited, helper_log_found
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
@@ -90,7 +93,7 @@ async def test_oversized_protobuf_message_id_plaintext(
and "Bad packet: message type" in line
and "exceeds maximum" in line
):
helper_log_found = True
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with api_client_connected_with_disconnect() as (client, disconnect_event):
@@ -114,10 +117,13 @@ async def test_oversized_protobuf_message_id_plaintext(
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
# Verify we saw the expected HELPER_LOG message
assert helper_log_found, (
"Expected to see HELPER_LOG about message type exceeding maximum"
)
# Wait for the expected log message (may arrive after disconnect event)
try:
await asyncio.wait_for(helper_log_event.wait(), timeout=2.0)
except TimeoutError:
pytest.fail(
"Expected to see HELPER_LOG about message type exceeding maximum"
)
# Try to reconnect to verify the process is still running
async with api_client_connected_with_disconnect() as (client2, _):
@@ -135,10 +141,10 @@ async def test_oversized_payload_noise(
"""Test that oversized payloads from client cause disconnection without crashing with noise encryption."""
noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
process_exited = False
helper_log_found = False
helper_log_event = asyncio.Event()
def check_logs(line: str) -> None:
nonlocal process_exited, helper_log_found
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
@@ -149,7 +155,7 @@ async def test_oversized_payload_noise(
and "Bad packet: message size" in line
and "exceeds maximum" in line
):
helper_log_found = True
helper_log_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
@@ -177,10 +183,13 @@ async def test_oversized_payload_noise(
# After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash"
# Verify we saw the expected HELPER_LOG message
assert helper_log_found, (
"Expected to see HELPER_LOG about message size exceeding maximum"
)
# Wait for the expected log message (may arrive after disconnect event)
try:
await asyncio.wait_for(helper_log_event.wait(), timeout=2.0)
except TimeoutError:
pytest.fail(
"Expected to see HELPER_LOG about message size exceeding maximum"
)
# Try to reconnect to verify the process is still running
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
@@ -274,10 +283,10 @@ async def test_noise_corrupt_encrypted_frame(
"""
noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
process_exited = False
cipherstate_failed = False
cipherstate_event = asyncio.Event()
def check_logs(line: str) -> None:
nonlocal process_exited, cipherstate_failed
nonlocal process_exited
# Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line:
process_exited = True
@@ -290,7 +299,7 @@ async def test_noise_corrupt_encrypted_frame(
"[W][api.connection" in line
and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line
):
cipherstate_failed = True
cipherstate_event.set()
async with run_compiled(yaml_config, line_callback=check_logs):
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
@@ -326,10 +335,14 @@ async def test_noise_corrupt_encrypted_frame(
assert not process_exited, (
"ESPHome process should not crash on corrupt encrypted frames"
)
# Verify we saw the expected log message about decryption failure
assert cipherstate_failed, (
"Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED"
)
# Wait for the expected log message (may arrive after disconnect event)
try:
await asyncio.wait_for(cipherstate_event.wait(), timeout=2.0)
except TimeoutError:
pytest.fail(
"Expected to see log about noise_cipherstate_decrypt failure"
" or CIPHERSTATE_DECRYPT_FAILED"
)
# Verify we can still reconnect after handling the corrupt frame
async with api_client_connected_with_disconnect(noise_psk=noise_key) as (