Compare commits

...

87 Commits

Author SHA1 Message Date
J. Nick Koston
849df4b2a8 no host 2026-01-30 03:26:03 -06:00
J. Nick Koston
5f7582ffdb override localtime() to use our timezone
By providing our own localtime() and localtime_r() implementations,
user lambdas calling ::localtime() continue to work correctly without
needing migration. This eliminates the breaking change while still
achieving the memory savings.
2026-01-30 03:25:21 -06:00
J. Nick Koston
dcd0f53027 fix clang-tidy warnings
- Add NOLINT for intentional global mutable state
- Simplify boolean return in parse_posix_tz
- Add USE_TIME_TIMEZONE define for tests
- Add NOLINT for Google Test SetUp/TearDown methods
2026-01-30 02:51:36 -06:00
J. Nick Koston
b5e073bf7f clarify comment about days_to_year_start 2026-01-30 01:52:05 -06:00
J. Nick Koston
cde2199b64 more cover 2026-01-30 01:46:57 -06:00
J. Nick Koston
a1eef9870c cleanup 2026-01-30 01:28:23 -06:00
J. Nick Koston
19e9ab253e cleanup 2026-01-30 01:24:48 -06:00
J. Nick Koston
e3a99f12e4 more edge cases 2026-01-30 01:22:32 -06:00
J. Nick Koston
d31a860bf2 fix, macos and linux disagree on ambig time 2026-01-30 01:18:16 -06:00
J. Nick Koston
cfea3472bd cleanups 2026-01-30 01:11:31 -06:00
J. Nick Koston
31859a3eb5 fix 2026-01-30 01:10:43 -06:00
J. Nick Koston
9f3e5f990f cleanups 2026-01-30 01:09:30 -06:00
J. Nick Koston
f317f58545 cleanups 2026-01-30 01:09:06 -06:00
J. Nick Koston
01c23eace3 cleanups 2026-01-30 01:06:46 -06:00
J. Nick Koston
9b8556c2b2 fix 2026-01-30 01:03:42 -06:00
J. Nick Koston
9628c213b5 make human readable 2026-01-30 01:01:21 -06:00
J. Nick Koston
07a71c412d make human readable 2026-01-30 01:00:07 -06:00
J. Nick Koston
0d736e4143 fix 2026-01-30 00:41:53 -06:00
J. Nick Koston
a93e3b6fa0 ambig time 2026-01-30 00:38:29 -06:00
J. Nick Koston
22ab20ba4c aioesphomeapi and esphome both always have M format, it was overkill 2026-01-30 00:36:17 -06:00
J. Nick Koston
6ee51b0159 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:25:42 -06:00
J. Nick Koston
e2b3186731 remove crazy over definsive edge cases that the bot wants -- they never happen and just make things larger 2026-01-30 00:23:09 -06:00
J. Nick Koston
31aa58c45d bot review 2026-01-30 00:12:46 -06:00
J. Nick Koston
a757cb3c91 bot review 2026-01-30 00:03:28 -06:00
J. Nick Koston
91ad54d864 bot review 2026-01-30 00:03:13 -06:00
J. Nick Koston
3703755e03 more fixes 2026-01-29 23:59:39 -06:00
J. Nick Koston
c1d380dee4 more fixes 2026-01-29 23:58:07 -06:00
J. Nick Koston
b2120609b9 bot review 2026-01-29 23:54:14 -06:00
J. Nick Koston
9e6e8a7ecb bot review 2026-01-29 23:51:50 -06:00
J. Nick Koston
de06b36544 bot review 2026-01-29 23:50:37 -06:00
J. Nick Koston
695df9b979 bot review 2026-01-29 23:49:07 -06:00
J. Nick Koston
aa91cdd984 no setz 2026-01-29 23:47:28 -06:00
J. Nick Koston
284a9cdab6 must set TZ 2026-01-29 23:41:41 -06:00
J. Nick Koston
77ebfc8687 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:59 -06:00
J. Nick Koston
899f2bbac5 aioesphomeapi and esphome both always have M format, it was overkill 2026-01-29 23:34:49 -06:00
J. Nick Koston
bb35e7b4b5 bad feedback from copilot 2026-01-29 23:31:09 -06:00
J. Nick Koston
64e4edd70f bad feedback from copilot 2026-01-29 23:30:33 -06:00
J. Nick Koston
300b7169ad cleanup 2026-01-29 23:29:10 -06:00
J. Nick Koston
1353dbc31e cleanup 2026-01-29 23:28:35 -06:00
J. Nick Koston
300eea034b handle trailing garbage 2026-01-29 23:26:53 -06:00
J. Nick Koston
90a06b5249 Merge branch 'dev' into posix_tz 2026-01-29 19:20:14 -10:00
J. Nick Koston
1b7b307d08 simplify 2026-01-29 22:57:17 -06:00
J. Nick Koston
a946aefbed more cover 2026-01-29 22:54:56 -06:00
J. Nick Koston
8708f96de4 less ram 2026-01-29 22:53:29 -06:00
J. Nick Koston
bd056b3b9e improve readability 2026-01-29 22:47:54 -06:00
J. Nick Koston
5d49c81e2d more cover 2026-01-29 22:42:33 -06:00
J. Nick Koston
bec7d6d223 tweak 2026-01-29 22:31:23 -06:00
J. Nick Koston
973105f2e5 tweak 2026-01-29 22:28:09 -06:00
J. Nick Koston
53fb876738 tests 2026-01-29 22:17:36 -06:00
J. Nick Koston
d2bc168f39 tweak 2026-01-29 22:07:34 -06:00
J. Nick Koston
34ec72ad49 tweak 2026-01-29 22:05:23 -06:00
J. Nick Koston
85c814b712 tweak 2026-01-29 22:02:46 -06:00
J. Nick Koston
fc951baebc tweak 2026-01-29 21:59:46 -06:00
J. Nick Koston
a1cdfe71de tweak 2026-01-29 21:54:40 -06:00
J. Nick Koston
c1971955a3 tweak 2026-01-29 21:53:43 -06:00
J. Nick Koston
e1df75fc9b tweak 2026-01-29 21:53:06 -06:00
J. Nick Koston
ea83330ab9 tweak 2026-01-29 21:52:24 -06:00
J. Nick Koston
4cdf0224ba tweak 2026-01-29 21:48:46 -06:00
Thomas Rupprecht
20edd11ca7 [pmsx003] Improvements (#13626) 2026-01-29 22:48:16 -05:00
J. Nick Koston
47f029b713 cover 2026-01-29 21:38:59 -06:00
J. Nick Koston
9a8c71a58b [logger] Fix USB Serial JTAG VFS linker errors when using UART on IDF (#13628)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 21:31:01 -06:00
J. Nick Koston
d45a20af83 tweak 2026-01-29 21:25:46 -06:00
Jonathan Swoboda
1a7435250e Merge branch 'release' into dev 2026-01-29 22:22:23 -05:00
Jonathan Swoboda
3c91d72403 Merge pull request #13632 from esphome/bump-2026.1.3
2026.1.3
2026-01-29 22:22:10 -05:00
J. Nick Koston
d37c37ef62 tweak 2026-01-29 21:19:00 -06:00
J. Nick Koston
aad3764806 posix_tz 2026-01-29 21:14:42 -06:00
Jonathan Swoboda
0a63fc6f05 Bump version to 2026.1.3 2026-01-29 21:11:09 -05:00
J. Nick Koston
50e739ee8e [http_request] Fix empty body for chunked transfer encoding responses (#13599) 2026-01-29 21:11:09 -05:00
J. Nick Koston
6c84f20491 [wifi] Fix ESP8266 yield panic when WiFi scan fails (#13603) 2026-01-29 21:11:09 -05:00
Cody Cutrer
a68506f924 [ld2450] preserve precision of angle (#13600) 2026-01-29 21:11:08 -05:00
esphomebot
a20d42ca0b Update webserver local assets to 20260127-190637 (#13573)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-01-29 21:11:08 -05:00
J. Nick Koston
4ec8846198 [web_server] Add name_id to SSE for entity ID format migration (#13535) 2026-01-29 21:11:08 -05:00
J. Nick Koston
40ea65b1c0 [socket] ESP8266: call delay(0) instead of esp_delay(0, cb) for zero timeout (#13530) 2026-01-29 21:11:08 -05:00
J. Nick Koston
f7937ef952 [ota] Improve error message when device closes connection without responding (#13562) 2026-01-29 21:11:08 -05:00
sebcaps
d6bf137026 [mhz19] Fix Uninitialized var warning message (#13526) 2026-01-29 21:11:08 -05:00
esphomebot
ed9a672f44 Update webserver local assets to 20260122-204614 (#13455)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-01-29 21:11:08 -05:00
David Woodhouse
823b5ac1ab [ch423] Add CH423 I/O expander component (#13079)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-01-29 18:16:15 -05:00
dependabot[bot]
6de2049076 Bump actions/cache from 5.0.2 to 5.0.3 in /.github/actions/restore-python (#13622)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 14:35:52 -06:00
dependabot[bot]
cd43f8474e Bump actions/cache from 5.0.2 to 5.0.3 (#13621)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 14:35:32 -06:00
J. Nick Koston
ecc0b366b3 [esp32] Reduce compile time by excluding unused IDF components (#13610) 2026-01-29 13:21:12 -06:00
tomaszduda23
6a17db8857 [nrf52,zigbee] Support for number component (#13581) 2026-01-29 11:52:46 -05:00
Keith Burzinski
0843ec6ae8 [const] Move CONF_AUDIO_DAC (#13614) 2026-01-29 04:39:40 +00:00
J. Nick Koston
74c84c8747 [esp32] Add advanced sdkconfig options to reduce build time and binary size (#13611) 2026-01-28 18:20:39 -10:00
rwrozelle
3e9a6c582e [mdns] Do not broadcast registration when using openthread component (#13592) 2026-01-28 18:16:59 -10:00
Keith Burzinski
084113926c [es8156] Add bits_per_sample validation, comment code (#13612) 2026-01-28 22:03:50 -06:00
J. Nick Koston
a5f60750c2 [tx20] Eliminate heap allocations in wind sensor (#13298) 2026-01-29 16:07:41 +13:00
Clyde Stubbs
a382383d83 [workflows] Add deprecation check (#13584) 2026-01-29 12:08:45 +13:00
62 changed files with 3508 additions and 236 deletions

View File

@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
# yamllint disable-line rule:line-length

View File

@@ -3,6 +3,7 @@ module.exports = {
BOT_COMMENT_MARKER: '<!-- auto-label-pr-bot -->',
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
TOO_BIG_MARKER: '<!-- too-big-request -->',
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
MANAGED_LABELS: [
'new-component',
@@ -27,6 +28,7 @@ module.exports = {
'breaking-change',
'developer-breaking-change',
'code-quality',
'deprecated-component'
],
DOCS_PR_PATTERNS: [

View File

@@ -251,6 +251,76 @@ async function detectPRTemplateCheckboxes(context) {
return labels;
}
// Strategy: Deprecated component detection
async function detectDeprecatedComponents(github, context, changedFiles) {
const labels = new Set();
const deprecatedInfo = [];
const { owner, repo } = context.repo;
// Compile regex once for better performance
const componentFileRegex = /^esphome\/components\/([^\/]+)\//;
// Get files that are modified or added in components directory
const componentFiles = changedFiles.filter(file => componentFileRegex.test(file));
if (componentFiles.length === 0) {
return { labels, deprecatedInfo };
}
// Extract unique component names using the same regex
const components = new Set();
for (const file of componentFiles) {
const match = file.match(componentFileRegex);
if (match) {
components.add(match[1]);
}
}
// Get PR head to fetch files from the PR branch
const prNumber = context.payload.pull_request.number;
// Check each component's __init__.py for DEPRECATED_COMPONENT constant
for (const component of components) {
const initFile = `esphome/components/${component}/__init__.py`;
try {
// Fetch file content from PR head using GitHub API
const { data: fileData } = await github.rest.repos.getContent({
owner,
repo,
path: initFile,
ref: `refs/pull/${prNumber}/head`
});
// Decode base64 content
const content = Buffer.from(fileData.content, 'base64').toString('utf8');
// Look for DEPRECATED_COMPONENT = "message" or DEPRECATED_COMPONENT = 'message'
// Support single quotes, double quotes, and triple quotes (for multiline)
const doubleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*"""([\s\S]*?)"""/s) ||
content.match(/DEPRECATED_COMPONENT\s*=\s*"((?:[^"\\]|\\.)*)"/);
const singleQuoteMatch = content.match(/DEPRECATED_COMPONENT\s*=\s*'''([\s\S]*?)'''/s) ||
content.match(/DEPRECATED_COMPONENT\s*=\s*'((?:[^'\\]|\\.)*)'/);
const deprecatedMatch = doubleQuoteMatch || singleQuoteMatch;
if (deprecatedMatch) {
labels.add('deprecated-component');
deprecatedInfo.push({
component: component,
message: deprecatedMatch[1].trim()
});
console.log(`Found deprecated component: ${component}`);
}
} catch (error) {
// Only log if it's not a simple "file not found" error (404)
if (error.status !== 404) {
console.log(`Error reading ${initFile}:`, error.message);
}
}
}
return { labels, deprecatedInfo };
}
// Strategy: Requirements detection
async function detectRequirements(allLabels, prFiles, context) {
const labels = new Set();
@@ -298,5 +368,6 @@ module.exports = {
detectCodeOwner,
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectRequirements
};

View File

@@ -11,6 +11,7 @@ const {
detectCodeOwner,
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectRequirements
} = require('./detectors');
const { handleReviews } = require('./reviews');
@@ -112,6 +113,7 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
deprecatedResult
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
@@ -124,8 +126,13 @@ module.exports = async ({ github, context }) => {
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles)
]);
// Extract deprecated component info
const deprecatedLabels = deprecatedResult.labels;
const deprecatedInfo = deprecatedResult.deprecatedInfo;
// Combine all labels
const allLabels = new Set([
...branchLabels,
@@ -139,6 +146,7 @@ module.exports = async ({ github, context }) => {
...codeOwnerLabels,
...testLabels,
...checkboxLabels,
...deprecatedLabels
]);
// Detect requirements based on all other labels
@@ -169,7 +177,7 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews
await handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
// Apply labels
await applyLabels(github, context, finalLabels);

View File

@@ -2,12 +2,29 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
DEPRECATED_COMPONENT_MARKER
} = require('./constants');
// Generate review messages
function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) {
function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) {
const messages = [];
// Deprecated component message
if (finalLabels.includes('deprecated-component') && deprecatedInfo && deprecatedInfo.length > 0) {
let message = `${DEPRECATED_COMPONENT_MARKER}\n### ⚠️ Deprecated Component\n\n`;
message += `Hey there @${prAuthor},\n`;
message += `This PR modifies one or more deprecated components. Please be aware:\n\n`;
for (const info of deprecatedInfo) {
message += `#### Component: \`${info.component}\`\n`;
message += `${info.message}\n\n`;
}
message += `Consider migrating to the recommended alternative if applicable.`;
messages.push(message);
}
// Too big message
if (finalLabels.includes('too-big')) {
const testAdditions = prFiles
@@ -54,14 +71,14 @@ function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalA
}
// Handle reviews
async function handleReviews(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) {
async function handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) {
const { owner, repo } = context.repo;
const pr_number = context.issue.number;
const prAuthor = context.payload.pull_request.user.login;
const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD);
const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD);
const hasReviewableLabels = finalLabels.some(label =>
['too-big', 'needs-codeowners'].includes(label)
['too-big', 'needs-codeowners', 'deprecated-component'].includes(label)
);
const { data: reviews } = await github.rest.pulls.listReviews({

View File

@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
# yamllint disable-line rule:line-length
@@ -157,7 +157,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -193,7 +193,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -223,7 +223,7 @@ jobs:
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -245,7 +245,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -334,14 +334,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -413,14 +413,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -502,14 +502,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -735,7 +735,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true'
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -759,7 +759,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -800,7 +800,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -847,7 +847,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}

View File

@@ -104,6 +104,7 @@ esphome/components/cc1101/* @gabest11 @lygris
esphome/components/ccs811/* @habbie
esphome/components/cd74hc4067/* @asoehlke
esphome/components/ch422g/* @clydebarrow @jesterret
esphome/components/ch423/* @dwmw2
esphome/components/chsc6x/* @kkosik20
esphome/components/climate/* @esphome/core
esphome/components/climate_ir/* @glmnet

View File

@@ -2,7 +2,7 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
from esphome.components.esp32 import get_esp32_variant
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -118,6 +118,9 @@ async def to_code(config):
cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE]))
if CORE.is_esp32:
# Re-enable ESP-IDF's ADC driver (excluded by default to save compile time)
include_builtin_idf_component("esp_adc")
if attenuation := config.get(CONF_ATTENUATION):
if attenuation == "auto":
cg.add(var.set_autorange(cg.global_ns.true))

View File

@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
import esphome.final_validate as fv
@@ -166,6 +166,9 @@ def final_validate_audio_schema(
async def to_code(config):
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
add_idf_component(
name="esphome/esp-audio-libs",
ref="2.0.3",

View File

@@ -0,0 +1,103 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.i2c import I2CBus
import esphome.config_validation as cv
from esphome.const import (
CONF_I2C_ID,
CONF_ID,
CONF_INPUT,
CONF_INVERTED,
CONF_MODE,
CONF_NUMBER,
CONF_OPEN_DRAIN,
CONF_OUTPUT,
)
from esphome.core import CORE
CODEOWNERS = ["@dwmw2"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
ch423_ns = cg.esphome_ns.namespace("ch423")
CH423Component = ch423_ns.class_("CH423Component", cg.Component, i2c.I2CDevice)
CH423GPIOPin = ch423_ns.class_(
"CH423GPIOPin", cg.GPIOPin, cg.Parented.template(CH423Component)
)
CONF_CH423 = "ch423"
# Note that no address is configurable - each register in the CH423 has a dedicated i2c address
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(CH423Component),
cv.GenerateID(CONF_I2C_ID): cv.use_id(I2CBus),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Can't use register_i2c_device because there is no CONF_ADDRESS
parent = await cg.get_variable(config[CONF_I2C_ID])
cg.add(var.set_i2c_bus(parent))
# This is used as a final validation step so that modes have been fully transformed.
def pin_mode_check(pin_config, _):
if pin_config[CONF_MODE][CONF_INPUT] and pin_config[CONF_NUMBER] >= 8:
raise cv.Invalid("CH423 only supports input on pins 0-7")
if pin_config[CONF_MODE][CONF_OPEN_DRAIN] and pin_config[CONF_NUMBER] < 8:
raise cv.Invalid("CH423 only supports open drain output on pins 8-23")
ch423_id = pin_config[CONF_CH423]
pin_num = pin_config[CONF_NUMBER]
is_output = pin_config[CONF_MODE][CONF_OUTPUT]
is_open_drain = pin_config[CONF_MODE][CONF_OPEN_DRAIN]
# Track pin modes per CH423 instance in CORE.data
ch423_modes = CORE.data.setdefault(CONF_CH423, {})
if ch423_id not in ch423_modes:
ch423_modes[ch423_id] = {"gpio_output": None, "gpo_open_drain": None}
if pin_num < 8:
# GPIO pins (0-7): all must have same direction
if ch423_modes[ch423_id]["gpio_output"] is None:
ch423_modes[ch423_id]["gpio_output"] = is_output
elif ch423_modes[ch423_id]["gpio_output"] != is_output:
raise cv.Invalid(
"CH423 GPIO pins (0-7) must all be configured as input or all as output"
)
# GPO pins (8-23): all must have same open-drain setting
elif ch423_modes[ch423_id]["gpo_open_drain"] is None:
ch423_modes[ch423_id]["gpo_open_drain"] = is_open_drain
elif ch423_modes[ch423_id]["gpo_open_drain"] != is_open_drain:
raise cv.Invalid(
"CH423 GPO pins (8-23) must all be configured as push-pull or all as open-drain"
)
CH423_PIN_SCHEMA = pins.gpio_base_schema(
CH423GPIOPin,
cv.int_range(min=0, max=23),
modes=[CONF_INPUT, CONF_OUTPUT, CONF_OPEN_DRAIN],
).extend(
{
cv.Required(CONF_CH423): cv.use_id(CH423Component),
}
)
@pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check)
async def ch423_pin_to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
parent = await cg.get_variable(config[CONF_CH423])
cg.add(var.set_parent(parent))
num = config[CONF_NUMBER]
cg.add(var.set_pin(num))
cg.add(var.set_inverted(config[CONF_INVERTED]))
cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE])))
return var

View File

@@ -0,0 +1,148 @@
#include "ch423.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::ch423 {
static constexpr uint8_t CH423_REG_SYS = 0x24; // Set system parameters (0x48 >> 1)
static constexpr uint8_t CH423_SYS_IO_OE = 0x01; // IO output enable
static constexpr uint8_t CH423_SYS_OD_EN = 0x04; // Open drain enable for OC pins
static constexpr uint8_t CH423_REG_IO = 0x30; // Write/read IO7-IO0 (0x60 >> 1)
static constexpr uint8_t CH423_REG_IO_RD = 0x26; // Read IO7-IO0 (0x4D >> 1, rounded down)
static constexpr uint8_t CH423_REG_OCL = 0x22; // Write OC7-OC0 (0x44 >> 1)
static constexpr uint8_t CH423_REG_OCH = 0x23; // Write OC15-OC8 (0x46 >> 1)
static const char *const TAG = "ch423";
void CH423Component::setup() {
// set outputs before mode
this->write_outputs_();
// Set system parameters and check for errors
bool success = this->write_reg_(CH423_REG_SYS, this->sys_params_);
// Only read inputs if pins are configured for input (IO_OE not set)
if (success && !(this->sys_params_ & CH423_SYS_IO_OE)) {
success = this->read_inputs_();
}
if (!success) {
ESP_LOGE(TAG, "CH423 not detected");
this->mark_failed();
return;
}
ESP_LOGCONFIG(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(),
this->status_has_error());
}
void CH423Component::loop() {
// Clear all the previously read flags.
this->pin_read_flags_ = 0x00;
}
void CH423Component::dump_config() {
ESP_LOGCONFIG(TAG, "CH423:");
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
}
void CH423Component::pin_mode(uint8_t pin, gpio::Flags flags) {
if (pin < 8) {
if (flags & gpio::FLAG_OUTPUT) {
this->sys_params_ |= CH423_SYS_IO_OE;
}
} else if (pin >= 8 && pin < 24) {
if (flags & gpio::FLAG_OPEN_DRAIN) {
this->sys_params_ |= CH423_SYS_OD_EN;
}
}
}
bool CH423Component::digital_read(uint8_t pin) {
if (this->pin_read_flags_ == 0 || this->pin_read_flags_ & (1 << pin)) {
// Read values on first access or in case it's being read again in the same loop
this->read_inputs_();
}
this->pin_read_flags_ |= (1 << pin);
return (this->input_bits_ & (1 << pin)) != 0;
}
void CH423Component::digital_write(uint8_t pin, bool value) {
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
this->write_outputs_();
}
bool CH423Component::read_inputs_() {
if (this->is_failed()) {
return false;
}
// reading inputs requires IO_OE to be 0
if (this->sys_params_ & CH423_SYS_IO_OE) {
return false;
}
uint8_t result = this->read_reg_(CH423_REG_IO_RD);
this->input_bits_ = result;
this->status_clear_warning();
return true;
}
// Write a register. Can't use the standard write_byte() method because there is no single pre-configured i2c address.
bool CH423Component::write_reg_(uint8_t reg, uint8_t value) {
auto err = this->bus_->write_readv(reg, &value, 1, nullptr, 0);
if (err != i2c::ERROR_OK) {
char buf[64];
ESPHOME_snprintf_P(buf, sizeof(buf), ESPHOME_PSTR("write failed for register 0x%X, error %d"), reg, err);
this->status_set_warning(buf);
return false;
}
this->status_clear_warning();
return true;
}
uint8_t CH423Component::read_reg_(uint8_t reg) {
uint8_t value;
auto err = this->bus_->write_readv(reg, nullptr, 0, &value, 1);
if (err != i2c::ERROR_OK) {
char buf[64];
ESPHOME_snprintf_P(buf, sizeof(buf), ESPHOME_PSTR("read failed for register 0x%X, error %d"), reg, err);
this->status_set_warning(buf);
return 0;
}
this->status_clear_warning();
return value;
}
bool CH423Component::write_outputs_() {
bool success = true;
// Write IO7-IO0
success &= this->write_reg_(CH423_REG_IO, static_cast<uint8_t>(this->output_bits_));
// Write OC7-OC0
success &= this->write_reg_(CH423_REG_OCL, static_cast<uint8_t>(this->output_bits_ >> 8));
// Write OC15-OC8
success &= this->write_reg_(CH423_REG_OCH, static_cast<uint8_t>(this->output_bits_ >> 16));
return success;
}
float CH423Component::get_setup_priority() const { return setup_priority::IO; }
// Run our loop() method very early in the loop, so that we cache read values
// before other components call our digital_read() method.
float CH423Component::get_loop_priority() const { return 9.0f; } // Just after WIFI
void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); }
bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; }
void CH423GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); }
size_t CH423GPIOPin::dump_summary(char *buffer, size_t len) const {
return snprintf(buffer, len, "EXIO%u via CH423", this->pin_);
}
void CH423GPIOPin::set_flags(gpio::Flags flags) {
flags_ = flags;
this->parent_->pin_mode(this->pin_, flags);
}
} // namespace esphome::ch423

View File

@@ -0,0 +1,67 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::ch423 {
class CH423Component : public Component, public i2c::I2CDevice {
public:
CH423Component() = default;
/// Check i2c availability and setup masks
void setup() override;
/// Poll for input changes periodically
void loop() override;
/// Helper function to read the value of a pin.
bool digital_read(uint8_t pin);
/// Helper function to write the value of a pin.
void digital_write(uint8_t pin, bool value);
/// Helper function to set the pin mode of a pin.
void pin_mode(uint8_t pin, gpio::Flags flags);
float get_setup_priority() const override;
float get_loop_priority() const override;
void dump_config() override;
protected:
bool write_reg_(uint8_t reg, uint8_t value);
uint8_t read_reg_(uint8_t reg);
bool read_inputs_();
bool write_outputs_();
/// The mask to write as output state - 1 means HIGH, 0 means LOW
uint32_t output_bits_{0x00};
/// Flags to check if read previously during this loop
uint8_t pin_read_flags_{0x00};
/// Copy of last read values
uint8_t input_bits_{0x00};
/// System parameters
uint8_t sys_params_{0x00};
};
/// Helper class to expose a CH423 pin as a GPIO pin.
class CH423GPIOPin : public GPIOPin {
public:
void setup() override{};
void pin_mode(gpio::Flags flags) override;
bool digital_read() override;
void digital_write(bool value) override;
size_t dump_summary(char *buffer, size_t len) const override;
void set_parent(CH423Component *parent) { parent_ = parent; }
void set_pin(uint8_t pin) { pin_ = pin; }
void set_inverted(bool inverted) { inverted_ = inverted; }
void set_flags(gpio::Flags flags);
gpio::Flags get_flags() const override { return this->flags_; }
protected:
CH423Component *parent_{};
uint8_t pin_{};
bool inverted_{};
gpio::Flags flags_{};
};
} // namespace esphome::ch423

View File

@@ -15,7 +15,7 @@ from esphome.const import (
CONF_UPDATE_INTERVAL,
SCHEDULER_DONT_RUN,
)
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, coroutine_with_priority
IS_PLATFORM_COMPONENT = True
@@ -222,3 +222,8 @@ async def display_is_displaying_page_to_code(config, condition_id, template_arg,
async def to_code(config):
cg.add_global(display_ns.using)
cg.add_define("USE_DISPLAY")
if CORE.is_esp32:
# Re-enable ESP-IDF's LCD driver (excluded by default to save compile time)
from esphome.components.esp32 import include_builtin_idf_component
include_builtin_idf_component("esp_lcd")

View File

@@ -2,7 +2,8 @@ import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.audio_dac import AudioDac
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_AUDIO_DAC, CONF_BITS_PER_SAMPLE, CONF_ID
import esphome.final_validate as fv
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["i2c"]
@@ -21,6 +22,29 @@ CONFIG_SCHEMA = (
)
def _final_validate(config):
full_config = fv.full_config.get()
# Check all speaker configurations for ones that reference this es8156
speaker_configs = full_config.get("speaker", [])
for speaker_config in speaker_configs:
audio_dac_id = speaker_config.get(CONF_AUDIO_DAC)
if (
audio_dac_id is not None
and audio_dac_id == config[CONF_ID]
and (bits_per_sample := speaker_config.get(CONF_BITS_PER_SAMPLE))
is not None
and bits_per_sample > 24
):
raise cv.Invalid(
f"ES8156 does not support more than 24 bits per sample. "
f"The speaker referencing this audio_dac has bits_per_sample set to {bits_per_sample}."
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)

View File

@@ -17,24 +17,61 @@ static const char *const TAG = "es8156";
}
void ES8156::setup() {
// REG02 MODE CONFIG 1: Enable software mode for I2C control of volume/mute
// Bit 2: SOFT_MODE_SEL=1 (software mode enabled)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG02_SCLK_MODE, 0x04));
// Analog system configuration (active-low power down bits, active-high enables)
// REG20 ANALOG SYSTEM: Configure analog signal path
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG20_ANALOG_SYS1, 0x2A));
// REG21 ANALOG SYSTEM: VSEL=0x1C (bias level ~120%), normal VREF ramp speed
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG21_ANALOG_SYS2, 0x3C));
// REG22 ANALOG SYSTEM: Line out mode (HPSW=0), OUT_MUTE=0 (not muted)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG22_ANALOG_SYS3, 0x00));
// REG24 ANALOG SYSTEM: Low power mode for VREFBUF, HPCOM, DACVRP; DAC normal power
// Bits 2:0 = 0x07: LPVREFBUF=1, LPHPCOM=1, LPDACVRP=1, LPDAC=0
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG24_ANALOG_LP, 0x07));
// REG23 ANALOG SYSTEM: Lowest bias (IBIAS_SW=0), VMIDLVL=VDDA/2, normal impedance
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG23_ANALOG_SYS4, 0x00));
// Timing and interface configuration
// REG0A/0B TIME CONTROL: Fast state machine transitions
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0A_TIME_CONTROL1, 0x01));
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0B_TIME_CONTROL2, 0x01));
// REG11 SDP INTERFACE CONFIG: Default I2S format (24-bit, I2S mode)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG11_DAC_SDP, 0x00));
// REG19 EQ CONTROL 1: EQ disabled (EQ_ON=0), EQ_BAND_NUM=2
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG19_EQ_CONTROL1, 0x20));
// REG0D P2S CONTROL: Parallel-to-serial converter settings
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG0D_P2S_CONTROL, 0x14));
// REG09 MISC CONTROL 2: Default settings
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG09_MISC_CONTROL2, 0x00));
// REG18 MISC CONTROL 3: Stereo channel routing, no inversion
// Bits 5:4 CHN_CROSS: 0=L→L/R→R, 1=L to both, 2=R to both, 3=swap L/R
// Bits 3:2: LCH_INV/RCH_INV channel inversion
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG18_MISC_CONTROL3, 0x00));
// REG08 CLOCK OFF: Enable all internal clocks (0x3F = all clock gates open)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG08_CLOCK_ON_OFF, 0x3F));
// REG00 RESET CONTROL: Reset sequence
// First: RST_DIG=1 (assert digital reset)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x02));
// Then: CSM_ON=1 (enable chip state machine), RST_DIG=1
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG00_RESET, 0x03));
// REG25 ANALOG SYSTEM: Power up analog blocks
// VMIDSEL=2 (normal VMID operation), PDN_ANA=0, ENREFR=0, ENHPCOM=0
// PDN_DACVREFGEN=0, PDN_VREFBUF=0, PDN_DAC=0 (all enabled)
ES8156_ERROR_FAILED(this->write_byte(ES8156_REG25_ANALOG_SYS5, 0x20));
}

View File

@@ -53,6 +53,7 @@ from .const import ( # noqa
KEY_BOARD,
KEY_COMPONENTS,
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
KEY_EXTRA_BUILD_FILES,
KEY_FLASH_SIZE,
KEY_FULL_CERT_BUNDLE,
@@ -86,6 +87,7 @@ IS_TARGET_PLATFORM = True
CONF_ASSERTION_LEVEL = "assertion_level"
CONF_COMPILER_OPTIMIZATION = "compiler_optimization"
CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES = "enable_idf_experimental_features"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
@@ -114,6 +116,36 @@ COMPILER_OPTIMIZATIONS = {
"SIZE": "CONFIG_COMPILER_OPTIMIZATION_SIZE",
}
# ESP-IDF components excluded by default to reduce compile time.
# Components can be re-enabled by calling include_builtin_idf_component() in to_code().
#
# Cannot be excluded (dependencies of required components):
# - "console": espressif/mdns unconditionally depends on it
# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain
DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing
"esp_adc", # ADC driver - only needed by adc component
"esp_driver_i2s", # I2S driver - only needed by i2s_audio component
"esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus
"esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch
"esp_eth", # Ethernet driver - only needed by ethernet component
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component
"esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation
"esp_https_server", # HTTPS server - ESPHome has its own web server
"esp_lcd", # LCD controller drivers - only needed by display component
"esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API
"espcoredump", # Core dump support - ESPHome has its own debug component
"fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage
"mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation
"perfmon", # Xtensa performance monitor - ESPHome has its own debug component
"protocomm", # Protocol communication for provisioning - unused by ESPHome
"spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only)
"unity", # Unit testing framework - ESPHome doesn't use IDF's testing
"wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused
"wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation
)
# ESP32 (original) chip revision options
# Setting minimum revision to 3.0 or higher:
# - Reduces flash size by excluding workaround code for older chip bugs
@@ -203,6 +235,9 @@ def set_core_data(config):
)
CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] = {}
CORE.data[KEY_ESP32][KEY_COMPONENTS] = {}
# Initialize with default exclusions - components can call include_builtin_idf_component()
# to re-enable any they need
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = set(DEFAULT_EXCLUDED_IDF_COMPONENTS)
CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse(
config[CONF_FRAMEWORK][CONF_VERSION]
)
@@ -328,6 +363,28 @@ def add_idf_component(
}
def exclude_builtin_idf_component(name: str) -> None:
"""Exclude an ESP-IDF component from the build.
This reduces compile time by skipping components that are not needed.
The component will be passed to ESP-IDF's EXCLUDE_COMPONENTS cmake variable.
Note: Components that are dependencies of other required components
cannot be excluded - ESP-IDF will still build them.
"""
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].add(name)
def include_builtin_idf_component(name: str) -> None:
"""Remove an ESP-IDF component from the exclusion list.
Call this from components that need an ESP-IDF component that is
excluded by default in DEFAULT_EXCLUDED_IDF_COMPONENTS. This ensures the
component will be built when needed.
"""
CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name)
def add_extra_script(stage: str, filename: str, path: Path):
"""Add an extra script to the project."""
key = f"{stage}:{filename}"
@@ -672,11 +729,26 @@ CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram"
CONF_HEAP_IN_IRAM = "heap_in_iram"
CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size"
CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle"
CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs"
CONF_DISABLE_OCD_AWARE = "disable_ocd_aware"
CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary"
CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs"
CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
CONF_DISABLE_FATFS = "disable_fatfs"
# VFS requirement tracking
# Components that need VFS features can call require_vfs_select() or require_vfs_dir()
# Components that need VFS features can call require_vfs_*() functions
KEY_VFS_SELECT_REQUIRED = "vfs_select_required"
KEY_VFS_DIR_REQUIRED = "vfs_dir_required"
KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required"
# Feature requirement tracking - components can call require_* functions to re-enable
# These are stored in CORE.data[KEY_ESP32] dict
KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required"
KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
def require_vfs_select() -> None:
@@ -697,6 +769,15 @@ def require_vfs_dir() -> None:
CORE.data[KEY_VFS_DIR_REQUIRED] = True
def require_vfs_termios() -> None:
"""Mark that VFS termios support is required by a component.
Call this from components that use terminal I/O functions (usb_serial_jtag_vfs_*, etc.).
This prevents CONFIG_VFS_SUPPORT_TERMIOS from being disabled.
"""
CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True
def require_full_certificate_bundle() -> None:
"""Request the full certificate bundle instead of the common-CAs-only bundle.
@@ -709,6 +790,43 @@ def require_full_certificate_bundle() -> None:
CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True
def require_usb_serial_jtag_secondary() -> None:
"""Mark that USB Serial/JTAG secondary console is required by a component.
Call this from components (e.g., logger) that need USB Serial/JTAG console output.
This prevents CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG from being disabled.
"""
CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True
def require_mbedtls_peer_cert() -> None:
"""Mark that mbedTLS peer certificate retention is required by a component.
Call this from components that need access to the peer certificate after
the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE
from being disabled.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True
def require_mbedtls_pkcs7() -> None:
"""Mark that mbedTLS PKCS#7 support is required by a component.
Call this from components that need PKCS#7 certificate validation.
This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled.
"""
CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True
def require_fatfs() -> None:
"""Mark that FATFS support is required by a component.
Call this from components that use FATFS (e.g., SD card, storage components).
This prevents FATFS from being disabled when disable_fatfs is set.
"""
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
# Match operator followed by version-like string (digit or *)
@@ -793,6 +911,19 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(
CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False
): cv.boolean,
cv.Optional(
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, default=[]
): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_DISABLE_DEBUG_STUBS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_OCD_AWARE, default=True): cv.boolean,
cv.Optional(
CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY, default=True
): cv.boolean,
cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
@@ -982,6 +1113,19 @@ def _configure_lwip_max_sockets(conf: dict) -> None:
add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets)
@coroutine_with_priority(CoroPriority.FINAL)
async def _write_exclude_components() -> None:
"""Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions."""
if KEY_ESP32 not in CORE.data:
return
excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS)
if excluded:
exclude_list = ";".join(sorted(excluded))
cg.add_platformio_option(
"board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}"
)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_yaml_idf_components(components: list[ConfigType]):
"""Add IDF components from YAML config with final priority to override code-added components."""
@@ -1195,6 +1339,11 @@ async def to_code(config):
# Apply LWIP optimization settings
advanced = conf[CONF_ADVANCED]
# Re-include any IDF components the user explicitly requested
for component_name in advanced.get(CONF_INCLUDE_BUILTIN_IDF_COMPONENTS, []):
include_builtin_idf_component(component_name)
# DHCP server: only disable if explicitly set to false
# WiFi component handles its own optimization when AP mode is not used
# When using Arduino with Ethernet, DHCP server functions must be available
@@ -1233,11 +1382,18 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False)
# Disable VFS support for termios (terminal I/O functions)
# ESPHome doesn't use termios functions on ESP32 (only used in host UART driver).
# USB Serial JTAG VFS functions require termios support.
# Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected
# as the logger output) call require_vfs_termios().
# Saves approximately 1.8KB of flash when disabled (default).
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS]
)
if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False):
# Component requires VFS termios - force enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True)
else:
# No component needs it - allow user to control (default: disabled)
add_idf_sdkconfig_option(
"CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS]
)
# Disable VFS support for select() with file descriptors
# ESPHome only uses select() with sockets via lwip_select(), which still works.
@@ -1316,6 +1472,61 @@ async def to_code(config):
add_idf_sdkconfig_option(f"CONFIG_LOG_DEFAULT_LEVEL_{conf[CONF_LOG_LEVEL]}", True)
# Disable OpenOCD debug stubs to save code size
# These are used for on-chip debugging with OpenOCD/JTAG, rarely needed for ESPHome
if advanced[CONF_DISABLE_DEBUG_STUBS]:
add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_STUBS_ENABLE", False)
# Disable OCD-aware exception handlers
# When enabled, the panic handler detects JTAG debugger and halts instead of resetting
# Most ESPHome users don't use JTAG debugging
if advanced[CONF_DISABLE_OCD_AWARE]:
add_idf_sdkconfig_option("CONFIG_ESP_DEBUG_OCDAWARE", False)
# Disable USB Serial/JTAG secondary console
# Components like logger can call require_usb_serial_jtag_secondary() to re-enable
if CORE.data[KEY_ESP32].get(KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED, False):
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG", True)
elif advanced[CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY]:
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_SECONDARY_NONE", True)
# Disable /dev/null VFS initialization
# ESPHome doesn't typically need /dev/null
if advanced[CONF_DISABLE_DEV_NULL_VFS]:
add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False)
# Disable keeping peer certificate after TLS handshake
# Saves ~4KB heap per connection, but prevents certificate inspection after handshake
# Components that need it can call require_mbedtls_peer_cert()
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False):
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True)
elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False)
# Disable PKCS#7 support in mbedTLS
# Only needed for specific certificate validation scenarios
# Components that need it can call require_mbedtls_pkcs7()
if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False):
# Component called require_mbedtls_pkcs7() - enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True)
elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]:
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False)
# Disable regi2c control functions in IRAM
# Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):
# Component called require_fatfs() - enable regardless of user setting
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -1324,6 +1535,11 @@ async def to_code(config):
if conf[CONF_COMPONENTS]:
CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS])
# Write EXCLUDE_COMPONENTS at FINAL priority after all components have had
# a chance to call include_builtin_idf_component() to re-enable components they need.
# Default exclusions are added in set_core_data() during config validation.
CORE.add_job(_write_exclude_components)
APP_PARTITION_SIZES = {
"2MB": 0x0C0000, # 768 KB

View File

@@ -6,6 +6,7 @@ KEY_FLASH_SIZE = "flash_size"
KEY_VARIANT = "variant"
KEY_SDKCONFIG_OPTIONS = "sdkconfig_options"
KEY_COMPONENTS = "components"
KEY_EXCLUDE_COMPONENTS = "exclude_components"
KEY_REPO = "repo"
KEY_REF = "ref"
KEY_REFRESH = "refresh"

View File

@@ -5,6 +5,7 @@ from esphome import pins
import esphome.codegen as cg
from esphome.components import esp32, light
from esphome.components.const import CONF_USE_PSRAM
from esphome.components.esp32 import include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
@@ -129,6 +130,9 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_rmt")
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
await light.register_light(var, config)
await cg.register_component(var, config)

View File

@@ -6,6 +6,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
get_esp32_variant,
gpio,
include_builtin_idf_component,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -266,6 +267,9 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
# Re-enable ESP-IDF's touch sensor driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_touch_sens")
touch = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(touch, config)

View File

@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
get_esp32_variant,
include_builtin_idf_component,
)
from esphome.components.network import ip_address_literal
from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface
@@ -419,6 +420,9 @@ async def to_code(config):
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
if config[CONF_TYPE] == "LAN8670":
# Add LAN867x 10BASE-T1S PHY support component
add_idf_component(name="espressif/lan867x", ref="2.0.0")

View File

@@ -155,6 +155,9 @@ async def to_code(config):
cg.add(var.set_watchdog_timeout(timeout_ms))
if CORE.is_esp32:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_http_client")
cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX]))
cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX]))
cg.add(var.set_verify_ssl(config[CONF_VERIFY_SSL]))

View File

@@ -1,6 +1,11 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
get_esp32_variant,
include_builtin_idf_component,
)
from esphome.components.esp32.const import (
VARIANT_ESP32,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
@@ -10,8 +15,6 @@ from esphome.components.esp32 import (
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
add_idf_sdkconfig_option,
get_esp32_variant,
)
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE
@@ -272,6 +275,10 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Re-enable ESP-IDF's I2S driver (excluded by default to save compile time)
include_builtin_idf_component("esp_driver_i2s")
if use_legacy():
cg.add_define("USE_I2S_LEGACY")

View File

@@ -16,6 +16,8 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
add_idf_sdkconfig_option,
get_esp32_variant,
require_usb_serial_jtag_secondary,
require_vfs_termios,
)
from esphome.components.libretiny import get_libretiny_component, get_libretiny_family
from esphome.components.libretiny.const import (
@@ -397,9 +399,15 @@ async def to_code(config):
elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG:
add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True)
cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG")
# Define platform support flags for components that need auto-detection
try:
uart_selection(USB_SERIAL_JTAG)
cg.add_define("USE_LOGGER_USB_SERIAL_JTAG")
# USB Serial JTAG code is compiled when platform supports it.
# Enable secondary USB serial JTAG console so the VFS functions are available.
if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG:
require_usb_serial_jtag_secondary()
require_vfs_termios()
except cv.Invalid:
pass
try:

View File

@@ -12,6 +12,10 @@ namespace esphome::mdns {
static const char *const TAG = "mdns";
static void register_esp32(MDNSComponent *comp, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services) {
#ifdef USE_OPENTHREAD
// OpenThread handles service registration via SRP client
// Services are compiled by MDNSComponent::compile_records_() and consumed by OpenThreadSrpComponent
#else
esp_err_t err = mdns_init();
if (err != ESP_OK) {
ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err));
@@ -41,13 +45,16 @@ static void register_esp32(MDNSComponent *comp, StaticVector<MDNSService, MDNS_S
ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err));
}
}
#endif
}
void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); }
void MDNSComponent::on_shutdown() {
#ifndef USE_OPENTHREAD
mdns_free();
delay(40); // Allow the mdns packets announcing service removal to be sent
#endif
}
} // namespace esphome::mdns

View File

@@ -4,7 +4,10 @@ from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components import logger, socket
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
include_builtin_idf_component,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -360,6 +363,8 @@ async def to_code(config):
# This enables low-latency MQTT event processing instead of waiting for select() timeout
if CORE.is_esp32:
socket.require_wake_loop_threadsafe()
# Re-enable ESP-IDF's mqtt component (excluded by default to save compile time)
include_builtin_idf_component("mqtt")
cg.add_define("USE_MQTT")
cg.add_global(mqtt_ns.using)

View File

@@ -1,7 +1,12 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import light
from esphome.components.esp32 import VARIANT_ESP32C3, VARIANT_ESP32S3, get_esp32_variant
from esphome.components.esp32 import (
VARIANT_ESP32C3,
VARIANT_ESP32S3,
get_esp32_variant,
include_builtin_idf_component,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_CHANNEL,
@@ -205,6 +210,10 @@ async def to_code(config):
has_white = "W" in config[CONF_TYPE]
method = config[CONF_METHOD]
# Re-enable ESP-IDF's RMT driver if using RMT method (excluded by default)
if CORE.is_esp32 and method[CONF_TYPE] == METHOD_ESP32_RMT:
include_builtin_idf_component("esp_driver_rmt")
method_template = METHODS[method[CONF_TYPE]].to_code(
method, config[CONF_VARIANT], config[CONF_INVERT]
)

View File

@@ -177,6 +177,8 @@ async def to_code(config):
cg.add_define("USE_NEXTION_TFT_UPLOAD")
cg.add(var.set_tft_url(config[CONF_TFT_URL]))
if CORE.is_esp32:
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_http_client")
esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True

View File

@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server
from esphome.components import mqtt, web_server, zigbee
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
@@ -189,6 +189,7 @@ validate_unit_of_measurement = cv.string_strict
_NUMBER_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
.extend(zigbee.NUMBER_SCHEMA)
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent),
@@ -214,6 +215,7 @@ _NUMBER_SCHEMA = (
_NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number"))
_NUMBER_SCHEMA.add_extra(zigbee.validate_number)
def number_schema(
@@ -277,6 +279,8 @@ async def setup_number_core_(
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
await zigbee.setup_number(var, config, min_value, max_value, step)
async def register_number(
var, config, *, min_value: float, max_value: float, step: float

View File

@@ -2,21 +2,20 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome {
namespace pmsx003 {
namespace esphome::pmsx003 {
static const char *const TAG = "pmsx003";
static const uint8_t START_CHARACTER_1 = 0x42;
static const uint8_t START_CHARACTER_2 = 0x4D;
static const uint16_t PMS_STABILISING_MS = 30000; // time taken for the sensor to become stable after power on in ms
static const uint16_t STABILISING_MS = 30000; // time taken for the sensor to become stable after power on in ms
static const uint16_t PMS_CMD_MEASUREMENT_MODE_PASSIVE =
0x0000; // use `PMS_CMD_MANUAL_MEASUREMENT` to trigger a measurement
static const uint16_t PMS_CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automatically perform measurements
static const uint16_t PMS_CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode
static const uint16_t PMS_CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode
static const uint16_t CMD_MEASUREMENT_MODE_PASSIVE =
0x0000; // use `Command::MANUAL_MEASUREMENT` to trigger a measurement
static const uint16_t CMD_MEASUREMENT_MODE_ACTIVE = 0x0001; // automatically perform measurements
static const uint16_t CMD_SLEEP_MODE_SLEEP = 0x0000; // go to sleep mode
static const uint16_t CMD_SLEEP_MODE_WAKEUP = 0x0001; // wake up from sleep mode
void PMSX003Component::setup() {}
@@ -42,7 +41,7 @@ void PMSX003Component::dump_config() {
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
if (this->update_interval_ <= PMS_STABILISING_MS) {
if (this->update_interval_ <= STABILISING_MS) {
ESP_LOGCONFIG(TAG, " Mode: active continuous (sensor default)");
} else {
ESP_LOGCONFIG(TAG, " Mode: passive with sleep/wake cycles");
@@ -55,44 +54,44 @@ void PMSX003Component::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Initialize sensor mode on first loop
if (this->initialised_ == 0) {
if (this->update_interval_ > PMS_STABILISING_MS) {
if (!this->initialised_) {
if (this->update_interval_ > STABILISING_MS) {
// Long update interval: use passive mode with sleep/wake cycles
this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_PASSIVE);
this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP);
this->send_command_(Command::MEASUREMENT_MODE, CMD_MEASUREMENT_MODE_PASSIVE);
this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_WAKEUP);
} else {
// Short/zero update interval: use active continuous mode
this->send_command_(PMS_CMD_MEASUREMENT_MODE, PMS_CMD_MEASUREMENT_MODE_ACTIVE);
this->send_command_(Command::MEASUREMENT_MODE, CMD_MEASUREMENT_MODE_ACTIVE);
}
this->initialised_ = 1;
this->initialised_ = true;
}
// If we update less often than it takes the device to stabilise, spin the fan down
// rather than running it constantly. It does take some time to stabilise, so we
// need to keep track of what state we're in.
if (this->update_interval_ > PMS_STABILISING_MS) {
if (this->update_interval_ > STABILISING_MS) {
switch (this->state_) {
case PMSX003_STATE_IDLE:
case State::IDLE:
// Power on the sensor now so it'll be ready when we hit the update time
if (now - this->last_update_ < (this->update_interval_ - PMS_STABILISING_MS))
if (now - this->last_update_ < (this->update_interval_ - STABILISING_MS))
return;
this->state_ = PMSX003_STATE_STABILISING;
this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_WAKEUP);
this->state_ = State::STABILISING;
this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_WAKEUP);
this->fan_on_time_ = now;
return;
case PMSX003_STATE_STABILISING:
case State::STABILISING:
// wait for the sensor to be stable
if (now - this->fan_on_time_ < PMS_STABILISING_MS)
if (now - this->fan_on_time_ < STABILISING_MS)
return;
// consume any command responses that are in the serial buffer
while (this->available())
this->read_byte(&this->data_[0]);
// Trigger a new read
this->send_command_(PMS_CMD_MANUAL_MEASUREMENT, 0);
this->state_ = PMSX003_STATE_WAITING;
this->send_command_(Command::MANUAL_MEASUREMENT, 0);
this->state_ = State::WAITING;
break;
case PMSX003_STATE_WAITING:
case State::WAITING:
// Just go ahead and read stuff
break;
}
@@ -180,27 +179,28 @@ optional<bool> PMSX003Component::check_byte_() {
}
bool PMSX003Component::check_payload_length_(uint16_t payload_length) {
// https://avaldebe.github.io/PyPMS/sensors/Plantower/
switch (this->type_) {
case PMSX003_TYPE_X003:
case Type::PMSX003:
// The expected payload length is typically 28 bytes.
// However, a 20-byte payload check was already present in the code.
// No official documentation was found confirming this.
// Retaining this check to avoid breaking existing behavior.
return payload_length == 28 || payload_length == 20; // 2*13+2
case PMSX003_TYPE_5003T:
case PMSX003_TYPE_5003S:
case Type::PMS5003S:
case Type::PMS5003T:
return payload_length == 28; // 2*13+2 (Data 13 not set/reserved)
case PMSX003_TYPE_5003ST:
case Type::PMS5003ST:
return payload_length == 36; // 2*17+2 (Data 16 not set/reserved)
}
return false;
}
void PMSX003Component::send_command_(PMSX0003Command cmd, uint16_t data) {
void PMSX003Component::send_command_(Command cmd, uint16_t data) {
uint8_t send_data[7] = {
START_CHARACTER_1, // Start Byte 1
START_CHARACTER_2, // Start Byte 2
cmd, // Command
static_cast<uint8_t>(cmd), // Command
uint8_t((data >> 8) & 0xFF), // Data 1
uint8_t((data >> 0) & 0xFF), // Data 2
0, // Verify Byte 1
@@ -265,7 +265,7 @@ void PMSX003Component::parse_data_() {
if (this->pm_particles_25um_sensor_ != nullptr)
this->pm_particles_25um_sensor_->publish_state(pm_particles_25um);
if (this->type_ == PMSX003_TYPE_5003T) {
if (this->type_ == Type::PMS5003T) {
ESP_LOGD(TAG,
"Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, "
"PM2.5 Particles %u Count/0.1L",
@@ -289,7 +289,7 @@ void PMSX003Component::parse_data_() {
}
// Formaldehyde
if (this->type_ == PMSX003_TYPE_5003ST || this->type_ == PMSX003_TYPE_5003S) {
if (this->type_ == Type::PMS5003S || this->type_ == Type::PMS5003ST) {
const uint16_t formaldehyde = this->get_16_bit_uint_(28);
ESP_LOGD(TAG, "Got Formaldehyde: %u µg/m^3", formaldehyde);
@@ -299,8 +299,8 @@ void PMSX003Component::parse_data_() {
}
// Temperature and Humidity
if (this->type_ == PMSX003_TYPE_5003ST || this->type_ == PMSX003_TYPE_5003T) {
const uint8_t temperature_offset = (this->type_ == PMSX003_TYPE_5003T) ? 24 : 30;
if (this->type_ == Type::PMS5003T || this->type_ == Type::PMS5003ST) {
const uint8_t temperature_offset = (this->type_ == Type::PMS5003T) ? 24 : 30;
const float temperature = static_cast<int16_t>(this->get_16_bit_uint_(temperature_offset)) / 10.0f;
const float humidity = this->get_16_bit_uint_(temperature_offset + 2) / 10.0f;
@@ -314,7 +314,7 @@ void PMSX003Component::parse_data_() {
}
// Firmware Version and Error Code
if (this->type_ == PMSX003_TYPE_5003ST) {
if (this->type_ == Type::PMS5003ST) {
const uint8_t firmware_version = this->data_[36];
const uint8_t error_code = this->data_[37];
@@ -323,13 +323,12 @@ void PMSX003Component::parse_data_() {
// Spin down the sensor again if we aren't going to need it until more time has
// passed than it takes to stabilise
if (this->update_interval_ > PMS_STABILISING_MS) {
this->send_command_(PMS_CMD_SLEEP_MODE, PMS_CMD_SLEEP_MODE_SLEEP);
this->state_ = PMSX003_STATE_IDLE;
if (this->update_interval_ > STABILISING_MS) {
this->send_command_(Command::SLEEP_MODE, CMD_SLEEP_MODE_SLEEP);
this->state_ = State::IDLE;
}
this->status_clear_warning();
}
} // namespace pmsx003
} // namespace esphome
} // namespace esphome::pmsx003

View File

@@ -5,27 +5,25 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
namespace esphome {
namespace pmsx003 {
namespace esphome::pmsx003 {
enum PMSX0003Command : uint8_t {
PMS_CMD_MEASUREMENT_MODE =
0xE1, // Data Options: `PMS_CMD_MEASUREMENT_MODE_PASSIVE`, `PMS_CMD_MEASUREMENT_MODE_ACTIVE`
PMS_CMD_MANUAL_MEASUREMENT = 0xE2,
PMS_CMD_SLEEP_MODE = 0xE4, // Data Options: `PMS_CMD_SLEEP_MODE_SLEEP`, `PMS_CMD_SLEEP_MODE_WAKEUP`
enum class Type : uint8_t {
PMSX003 = 0,
PMS5003S,
PMS5003T,
PMS5003ST,
};
enum PMSX003Type {
PMSX003_TYPE_X003 = 0,
PMSX003_TYPE_5003T,
PMSX003_TYPE_5003ST,
PMSX003_TYPE_5003S,
enum class Command : uint8_t {
MEASUREMENT_MODE = 0xE1, // Data Options: `CMD_MEASUREMENT_MODE_PASSIVE`, `CMD_MEASUREMENT_MODE_ACTIVE`
MANUAL_MEASUREMENT = 0xE2,
SLEEP_MODE = 0xE4, // Data Options: `CMD_SLEEP_MODE_SLEEP`, `CMD_SLEEP_MODE_WAKEUP`
};
enum PMSX003State {
PMSX003_STATE_IDLE = 0,
PMSX003_STATE_STABILISING,
PMSX003_STATE_WAITING,
enum class State : uint8_t {
IDLE = 0,
STABILISING,
WAITING,
};
class PMSX003Component : public uart::UARTDevice, public Component {
@@ -37,7 +35,7 @@ class PMSX003Component : public uart::UARTDevice, public Component {
void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; }
void set_type(PMSX003Type type) { this->type_ = type; }
void set_type(Type type) { this->type_ = type; }
void set_pm_1_0_std_sensor(sensor::Sensor *pm_1_0_std_sensor) { this->pm_1_0_std_sensor_ = pm_1_0_std_sensor; }
void set_pm_2_5_std_sensor(sensor::Sensor *pm_2_5_std_sensor) { this->pm_2_5_std_sensor_ = pm_2_5_std_sensor; }
@@ -77,20 +75,20 @@ class PMSX003Component : public uart::UARTDevice, public Component {
optional<bool> check_byte_();
void parse_data_();
bool check_payload_length_(uint16_t payload_length);
void send_command_(PMSX0003Command cmd, uint16_t data);
void send_command_(Command cmd, uint16_t data);
uint16_t get_16_bit_uint_(uint8_t start_index) const {
return encode_uint16(this->data_[start_index], this->data_[start_index + 1]);
}
Type type_;
State state_{State::IDLE};
bool initialised_{false};
uint8_t data_[64];
uint8_t data_index_{0};
uint8_t initialised_{0};
uint32_t fan_on_time_{0};
uint32_t last_update_{0};
uint32_t last_transmission_{0};
uint32_t update_interval_{0};
PMSX003State state_{PMSX003_STATE_IDLE};
PMSX003Type type_;
// "Standard Particle"
sensor::Sensor *pm_1_0_std_sensor_{nullptr};
@@ -118,5 +116,4 @@ class PMSX003Component : public uart::UARTDevice, public Component {
sensor::Sensor *humidity_sensor_{nullptr};
};
} // namespace pmsx003
} // namespace esphome
} // namespace esphome::pmsx003

View File

@@ -41,33 +41,33 @@ PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Com
PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor)
TYPE_PMSX003 = "PMSX003"
TYPE_PMS5003S = "PMS5003S"
TYPE_PMS5003T = "PMS5003T"
TYPE_PMS5003ST = "PMS5003ST"
TYPE_PMS5003S = "PMS5003S"
PMSX003Type = pmsx003_ns.enum("PMSX003Type")
Type = pmsx003_ns.enum("Type", is_class=True)
PMSX003_TYPES = {
TYPE_PMSX003: PMSX003Type.PMSX003_TYPE_X003,
TYPE_PMS5003T: PMSX003Type.PMSX003_TYPE_5003T,
TYPE_PMS5003ST: PMSX003Type.PMSX003_TYPE_5003ST,
TYPE_PMS5003S: PMSX003Type.PMSX003_TYPE_5003S,
TYPE_PMSX003: Type.PMSX003,
TYPE_PMS5003S: Type.PMS5003S,
TYPE_PMS5003T: Type.PMS5003T,
TYPE_PMS5003ST: Type.PMS5003ST,
}
SENSORS_TO_TYPE = {
CONF_PM_1_0: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_2_5: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_10_0: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_1_0_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_2_5_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_10_0_STD: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_0_3UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_0_5UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_1_0UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_2_5UM: [TYPE_PMSX003, TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_5_0UM: [TYPE_PMSX003, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_10_0UM: [TYPE_PMSX003, TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_FORMALDEHYDE: [TYPE_PMS5003ST, TYPE_PMS5003S],
CONF_PM_1_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_10_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_1_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_10_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_0_3UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_0_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_1_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_2_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_PM_5_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_PM_10_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST],
CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST],
CONF_HUMIDITY: [TYPE_PMS5003T, TYPE_PMS5003ST],
}

View File

@@ -170,6 +170,9 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
async def to_code(config):
pin = await cg.gpio_pin_expression(config[CONF_PIN])
if CORE.is_esp32:
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_driver_rmt")
var = cg.new_Pvariable(config[CONF_ID], pin)
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
cg.add(var.set_receive_symbols(config[CONF_RECEIVE_SYMBOLS]))

View File

@@ -112,6 +112,9 @@ async def digital_write_action_to_code(config, action_id, template_arg, args):
async def to_code(config):
pin = await cg.gpio_pin_expression(config[CONF_PIN])
if CORE.is_esp32:
# Re-enable ESP-IDF's RMT driver (excluded by default to save compile time)
esp32.include_builtin_idf_component("esp_driver_rmt")
var = cg.new_Pvariable(config[CONF_ID], pin)
cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS]))
cg.add(var.set_non_blocking(config[CONF_NON_BLOCKING]))

View File

@@ -2,7 +2,7 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import audio, audio_dac
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_ID, CONF_VOLUME
from esphome.const import CONF_AUDIO_DAC, CONF_DATA, CONF_ID, CONF_VOLUME
from esphome.core import CORE, ID
from esphome.coroutine import CoroPriority, coroutine_with_priority
@@ -11,8 +11,6 @@ CODEOWNERS = ["@jesserockz", "@kahrendt"]
IS_PLATFORM_COMPONENT = True
CONF_AUDIO_DAC = "audio_dac"
speaker_ns = cg.esphome_ns.namespace("speaker")
Speaker = speaker_ns.class_("Speaker")

View File

@@ -0,0 +1,481 @@
#include "esphome/core/defines.h"
#ifdef USE_TIME_TIMEZONE
#include "posix_tz.h"
#include <cctype>
namespace esphome::time {
// Global timezone - set once at startup, rarely changes
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state
static ParsedTimezone global_tz_{};
void set_global_tz(const ParsedTimezone &tz) { global_tz_ = tz; }
const ParsedTimezone &get_global_tz() { return global_tz_; }
namespace internal {
// Helper to parse an unsigned integer from string, updating pointer
static uint32_t parse_uint(const char *&p) {
uint32_t value = 0;
while (std::isdigit(static_cast<unsigned char>(*p))) {
value = value * 10 + (*p - '0');
p++;
}
return value;
}
bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); }
// Get days in year (avoids duplicate is_leap_year calls)
static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; }
// Convert days since epoch to year, updating days to remainder
static int __attribute__((noinline)) days_to_year(int64_t &days) {
int year = 1970;
int diy;
while (days >= (diy = days_in_year(year))) {
days -= diy;
year++;
}
while (days < 0) {
year--;
days += days_in_year(year);
}
return year;
}
// Extract just the year from a UTC epoch
static int epoch_to_year(time_t epoch) {
int64_t days = epoch / 86400;
if (epoch < 0 && epoch % 86400 != 0)
days--;
return days_to_year(days);
}
int days_in_month(int year, int month) {
switch (month) {
case 2:
return is_leap_year(year) ? 29 : 28;
case 4:
case 6:
case 9:
case 11:
return 30;
default:
return 31;
}
}
// Zeller-like algorithm for day of week (0 = Sunday)
int __attribute__((noinline)) day_of_week(int year, int month, int day) {
// Adjust for January/February
if (month < 3) {
month += 12;
year--;
}
int k = year % 100;
int j = year / 100;
int h = (day + (13 * (month + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
// Convert from Zeller (0=Sat) to standard (0=Sun)
return ((h + 6) % 7);
}
void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) {
// Days since epoch
int64_t days = epoch / 86400;
int32_t remaining_secs = epoch % 86400;
if (remaining_secs < 0) {
days--;
remaining_secs += 86400;
}
out_tm->tm_sec = remaining_secs % 60;
remaining_secs /= 60;
out_tm->tm_min = remaining_secs % 60;
out_tm->tm_hour = remaining_secs / 60;
// Day of week (Jan 1, 1970 was Thursday = 4)
out_tm->tm_wday = static_cast<int>((days + 4) % 7);
if (out_tm->tm_wday < 0)
out_tm->tm_wday += 7;
// Calculate year (updates days to day-of-year)
int year = days_to_year(days);
out_tm->tm_year = year - 1900;
out_tm->tm_yday = static_cast<int>(days);
// Calculate month and day
int month = 1;
int dim;
while (days >= (dim = days_in_month(year, month))) {
days -= dim;
month++;
}
out_tm->tm_mon = month - 1;
out_tm->tm_mday = static_cast<int>(days) + 1;
out_tm->tm_isdst = 0;
}
bool skip_tz_name(const char *&p) {
if (*p == '<') {
// Angle-bracket quoted name: <+07>, <-03>, <AEST>
p++; // skip '<'
while (*p && *p != '>') {
p++;
}
if (*p == '>') {
p++; // skip '>'
return true;
}
return false; // Unterminated
}
// Standard name: 3+ letters
const char *start = p;
while (*p && std::isalpha(static_cast<unsigned char>(*p))) {
p++;
}
return (p - start) >= 3;
}
int32_t __attribute__((noinline)) parse_offset(const char *&p) {
int sign = 1;
if (*p == '-') {
sign = -1;
p++;
} else if (*p == '+') {
p++;
}
int hours = parse_uint(p);
int minutes = 0;
int seconds = 0;
if (*p == ':') {
p++;
minutes = parse_uint(p);
if (*p == ':') {
p++;
seconds = parse_uint(p);
}
}
return sign * (hours * 3600 + minutes * 60 + seconds);
}
// Helper to parse the optional /time suffix (reuses parse_offset logic)
static void parse_transition_time(const char *&p, DSTRule &rule) {
rule.time_seconds = 2 * 3600; // Default 02:00
if (*p == '/') {
p++;
rule.time_seconds = parse_offset(p);
}
}
void __attribute__((noinline)) julian_to_month_day(int julian_day, int &out_month, int &out_day) {
// J format: day 1-365, Feb 29 is NOT counted even in leap years
// So day 60 is always March 1
// Iterate forward through months (no array needed)
int remaining = julian_day;
out_month = 1;
while (out_month <= 12) {
// Days in month for non-leap year (J format ignores leap years)
int dim = days_in_month(2001, out_month); // 2001 is non-leap year
if (remaining <= dim) {
out_day = remaining;
return;
}
remaining -= dim;
out_month++;
}
out_day = remaining;
}
void __attribute__((noinline)) day_of_year_to_month_day(int day_of_year, int year, int &out_month, int &out_day) {
// Plain format: day 0-365, Feb 29 IS counted in leap years
// Day 0 = Jan 1
int remaining = day_of_year;
out_month = 1;
while (out_month <= 12) {
int days_this_month = days_in_month(year, out_month);
if (remaining < days_this_month) {
out_day = remaining + 1;
return;
}
remaining -= days_this_month;
out_month++;
}
// Shouldn't reach here with valid input
out_month = 12;
out_day = 31;
}
bool parse_dst_rule(const char *&p, DSTRule &rule) {
rule = {}; // Zero initialize
if (*p == 'M' || *p == 'm') {
// M format: Mm.w.d (month.week.day)
rule.type = DSTRuleType::MONTH_WEEK_DAY;
p++;
rule.month = parse_uint(p);
if (rule.month < 1 || rule.month > 12)
return false;
if (*p++ != '.')
return false;
rule.week = parse_uint(p);
if (rule.week < 1 || rule.week > 5)
return false;
if (*p++ != '.')
return false;
rule.day_of_week = parse_uint(p);
if (rule.day_of_week > 6)
return false;
} else if (*p == 'J' || *p == 'j') {
// J format: Jn (Julian day 1-365, not counting Feb 29)
rule.type = DSTRuleType::JULIAN_NO_LEAP;
p++;
rule.day = parse_uint(p);
if (rule.day < 1 || rule.day > 365)
return false;
} else if (std::isdigit(static_cast<unsigned char>(*p))) {
// Plain number format: n (day 0-365, counting Feb 29)
rule.type = DSTRuleType::DAY_OF_YEAR;
rule.day = parse_uint(p);
if (rule.day > 365)
return false;
} else {
return false;
}
// Parse optional /time suffix
parse_transition_time(p, rule);
return true;
}
// Calculate days from Jan 1 of given year to given month/day
static int __attribute__((noinline)) days_from_year_start(int year, int month, int day) {
int days = day - 1;
for (int m = 1; m < month; m++) {
days += days_in_month(year, m);
}
return days;
}
// Calculate days from epoch to Jan 1 of given year (for DST transition calculations)
// Only supports years >= 1970. Timezone is either compiled in from YAML or set by
// Home Assistant, so pre-1970 dates are not a concern.
static int64_t __attribute__((noinline)) days_to_year_start(int year) {
int64_t days = 0;
for (int y = 1970; y < year; y++) {
days += days_in_year(y);
}
return days;
}
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
int month, day;
switch (rule.type) {
case DSTRuleType::MONTH_WEEK_DAY: {
// Find the nth occurrence of day_of_week in the given month
int first_dow = day_of_week(year, rule.month, 1);
// Days until first occurrence of target day
int days_until_first = (rule.day_of_week - first_dow + 7) % 7;
int first_occurrence = 1 + days_until_first;
if (rule.week == 5) {
// "Last" occurrence - find the last one in the month
int dim = days_in_month(year, rule.month);
day = first_occurrence;
while (day + 7 <= dim) {
day += 7;
}
} else {
// nth occurrence
day = first_occurrence + (rule.week - 1) * 7;
}
month = rule.month;
break;
}
case DSTRuleType::JULIAN_NO_LEAP:
// J format: day 1-365, Feb 29 not counted
julian_to_month_day(rule.day, month, day);
break;
case DSTRuleType::DAY_OF_YEAR:
// Plain format: day 0-365, Feb 29 counted
day_of_year_to_month_day(rule.day, year, month, day);
break;
case DSTRuleType::NONE:
// Should never be called with NONE, but handle it gracefully
month = 1;
day = 1;
break;
}
// Calculate days from epoch to this date
int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day);
// Convert to epoch and add transition time and base offset
return days * 86400 + rule.time_seconds + base_offset_seconds;
}
} // namespace internal
bool __attribute__((noinline)) is_in_dst(time_t utc_epoch, const ParsedTimezone &tz) {
if (!tz.has_dst()) {
return false;
}
int year = internal::epoch_to_year(utc_epoch);
// Calculate DST start and end for this year
// DST start transition happens in standard time
time_t dst_start = internal::calculate_dst_transition(year, tz.dst_start, tz.std_offset_seconds);
// DST end transition happens in daylight time
time_t dst_end = internal::calculate_dst_transition(year, tz.dst_end, tz.dst_offset_seconds);
if (dst_start < dst_end) {
// Northern hemisphere: DST is between start and end
return (utc_epoch >= dst_start && utc_epoch < dst_end);
} else {
// Southern hemisphere: DST is outside the range (wraps around year)
return (utc_epoch >= dst_start || utc_epoch < dst_end);
}
}
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) {
if (!tz_string || !*tz_string) {
return false;
}
const char *p = tz_string;
// Initialize result (dst_start/dst_end default to type=NONE, so has_dst() returns false)
result.std_offset_seconds = 0;
result.dst_offset_seconds = 0;
result.dst_start = {};
result.dst_end = {};
// Skip standard timezone name
if (!internal::skip_tz_name(p)) {
return false;
}
// Parse standard offset (required)
if (!*p || (!std::isdigit(static_cast<unsigned char>(*p)) && *p != '+' && *p != '-')) {
return false;
}
result.std_offset_seconds = internal::parse_offset(p);
// Check for DST name
if (!*p) {
return true; // No DST
}
// If next char is comma, there's no DST name but there are rules (invalid)
if (*p == ',') {
return false;
}
// Check if there's something that looks like a DST name start
// (letter or angle bracket). If not, treat as trailing garbage and return success.
if (!std::isalpha(static_cast<unsigned char>(*p)) && *p != '<') {
return true; // No DST, trailing characters ignored
}
if (!internal::skip_tz_name(p)) {
return false; // Invalid DST name (started but malformed)
}
// Optional DST offset (default is std - 1 hour)
if (*p && *p != ',' && (std::isdigit(static_cast<unsigned char>(*p)) || *p == '+' || *p == '-')) {
result.dst_offset_seconds = internal::parse_offset(p);
} else {
result.dst_offset_seconds = result.std_offset_seconds - 3600;
}
// Parse DST rules (required when DST name is present)
if (*p != ',') {
// DST name without rules - treat as no DST since we can't determine transitions
return true;
}
p++;
if (!internal::parse_dst_rule(p, result.dst_start)) {
return false;
}
// Second rule is required per POSIX
if (*p != ',') {
return false;
}
p++;
// has_dst() now returns true since dst_start.type was set by parse_dst_rule
return internal::parse_dst_rule(p, result.dst_end);
}
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) {
if (!out_tm) {
return false;
}
// Determine DST status once (avoids duplicate is_in_dst calculation)
bool in_dst = is_in_dst(utc_epoch, tz);
int32_t offset = in_dst ? tz.dst_offset_seconds : tz.std_offset_seconds;
// Apply offset (POSIX offset is positive west, so subtract to get local)
time_t local_epoch = utc_epoch - offset;
internal::epoch_to_tm_utc(local_epoch, out_tm);
out_tm->tm_isdst = in_dst ? 1 : 0;
return true;
}
} // namespace esphome::time
#ifndef USE_HOST
// Override libc's localtime functions to use our timezone on embedded platforms.
// This allows user lambdas calling ::localtime() to get correct local time
// without needing the TZ environment variable (which pulls in scanf bloat).
// On host, we use the normal TZ mechanism since there's no memory constraint.
// Thread-safe version
extern "C" struct tm *localtime_r(const time_t *timer, struct tm *result) {
if (timer == nullptr || result == nullptr) {
return nullptr;
}
esphome::time::epoch_to_local_tm(*timer, esphome::time::get_global_tz(), result);
return result;
}
// Non-thread-safe version (uses static buffer, standard libc behavior)
extern "C" struct tm *localtime(const time_t *timer) {
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static struct tm localtime_buf;
return localtime_r(timer, &localtime_buf);
}
#endif // !USE_HOST
#endif // USE_TIME_TIMEZONE

View File

@@ -0,0 +1,132 @@
#pragma once
#ifdef USE_TIME_TIMEZONE
#include <cstdint>
#include <ctime>
namespace esphome::time {
/// Type of DST transition rule
enum class DSTRuleType : uint8_t {
NONE = 0, ///< No DST rule (used to indicate no DST)
MONTH_WEEK_DAY, ///< M format: Mm.w.d (e.g., M3.2.0 = 2nd Sunday of March)
JULIAN_NO_LEAP, ///< J format: Jn (day 1-365, Feb 29 not counted)
DAY_OF_YEAR, ///< Plain number: n (day 0-365, Feb 29 counted in leap years)
};
/// Rule for DST transition (packed for 32-bit: 12 bytes)
struct DSTRule {
int32_t time_seconds; ///< Seconds after midnight (default 7200 = 2:00 AM)
uint16_t day; ///< Day of year (for JULIAN_NO_LEAP and DAY_OF_YEAR)
DSTRuleType type; ///< Type of rule
uint8_t month; ///< Month 1-12 (for MONTH_WEEK_DAY)
uint8_t week; ///< Week 1-5, 5 = last (for MONTH_WEEK_DAY)
uint8_t day_of_week; ///< Day 0-6, 0 = Sunday (for MONTH_WEEK_DAY)
};
/// Parsed POSIX timezone information (packed for 32-bit: 32 bytes)
struct ParsedTimezone {
int32_t std_offset_seconds; ///< Standard time offset from UTC in seconds (positive = west)
int32_t dst_offset_seconds; ///< DST offset from UTC in seconds
DSTRule dst_start; ///< When DST starts
DSTRule dst_end; ///< When DST ends
/// Check if this timezone has DST rules
bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; }
};
/// Parse a POSIX TZ string into a ParsedTimezone struct.
/// Supports formats like:
/// - "EST5" (simple offset, no DST)
/// - "EST5EDT,M3.2.0,M11.1.0" (with DST, M-format rules)
/// - "CST6CDT,M3.2.0/2,M11.1.0/2" (with transition times)
/// - "<+07>-7" (angle-bracket notation for special names)
/// - "IST-5:30" (half-hour offsets)
/// - "EST5EDT,J60,J300" (J-format: Julian day without leap day)
/// - "EST5EDT,60,300" (plain day number: day of year with leap day)
/// @param tz_string The POSIX TZ string to parse
/// @param result Output: the parsed timezone data
/// @return true if parsing succeeded, false on error
bool parse_posix_tz(const char *tz_string, ParsedTimezone &result);
/// Convert a UTC epoch to local time using the parsed timezone.
/// This replaces libc's localtime() to avoid scanf dependency.
/// @param utc_epoch Unix timestamp in UTC
/// @param tz The parsed timezone
/// @param[out] out_tm Output tm struct with local time
/// @return true on success
bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm);
/// Set the global timezone used by epoch_to_local_tm() when called without a timezone.
/// This is called by RealTimeClock::apply_timezone_() to enable ESPTime::from_epoch_local()
/// to work without libc's localtime().
void set_global_tz(const ParsedTimezone &tz);
/// Get the global timezone.
const ParsedTimezone &get_global_tz();
/// Check if a given UTC epoch falls within DST for the parsed timezone.
/// @param utc_epoch Unix timestamp in UTC
/// @param tz The parsed timezone
/// @return true if DST is in effect at the given time
bool is_in_dst(time_t utc_epoch, const ParsedTimezone &tz);
// Internal helper functions exposed for testing
namespace internal {
/// Skip a timezone name (letters or <...> quoted format)
/// @param p Pointer to current position, updated on return
/// @return true if a valid name was found
bool skip_tz_name(const char *&p);
/// Parse an offset in format [-]hh[:mm[:ss]]
/// @param p Pointer to current position, updated on return
/// @return Offset in seconds
int32_t parse_offset(const char *&p);
/// Parse a DST rule in format Mm.w.d[/time], Jn[/time], or n[/time]
/// @param p Pointer to current position, updated on return
/// @param rule Output: the parsed rule
/// @return true if parsing succeeded
bool parse_dst_rule(const char *&p, DSTRule &rule);
/// Convert Julian day (J format, 1-365 not counting Feb 29) to month/day
/// @param julian_day Day number 1-365
/// @param[out] month Output: month 1-12
/// @param[out] day Output: day of month
void julian_to_month_day(int julian_day, int &month, int &day);
/// Convert day of year (plain format, 0-365 counting Feb 29) to month/day
/// @param day_of_year Day number 0-365
/// @param year The year (for leap year calculation)
/// @param[out] month Output: month 1-12
/// @param[out] day Output: day of month
void day_of_year_to_month_day(int day_of_year, int year, int &month, int &day);
/// Calculate day of week for any date (0 = Sunday)
/// Uses a simplified algorithm that works for years 1970-2099
int day_of_week(int year, int month, int day);
/// Get the number of days in a month
int days_in_month(int year, int month);
/// Check if a year is a leap year
bool is_leap_year(int year);
/// Convert epoch to year/month/day/hour/min/sec (UTC)
void epoch_to_tm_utc(time_t epoch, struct tm *out_tm);
/// Calculate the epoch timestamp for a DST transition in a given year.
/// @param year The year (e.g., 2026)
/// @param rule The DST rule (month, week, day_of_week, time)
/// @param base_offset_seconds The timezone offset to apply (std or dst depending on context)
/// @return Unix epoch timestamp of the transition
time_t calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds);
} // namespace internal
} // namespace esphome::time
#endif // USE_TIME_TIMEZONE

View File

@@ -14,8 +14,8 @@
#include <sys/time.h>
#endif
#include <cerrno>
#include <cinttypes>
#include <cstdlib>
namespace esphome::time {
@@ -23,9 +23,33 @@ static const char *const TAG = "time";
RealTimeClock::RealTimeClock() = default;
ESPTime __attribute__((noinline)) RealTimeClock::now() {
#ifdef USE_TIME_TIMEZONE
time_t epoch = this->timestamp_now();
struct tm local_tm;
if (epoch_to_local_tm(epoch, get_global_tz(), &local_tm)) {
return ESPTime::from_c_tm(&local_tm, epoch);
}
// Fallback to UTC if parsing failed
return ESPTime::from_epoch_utc(epoch);
#else
return ESPTime::from_epoch_local(this->timestamp_now());
#endif
}
void RealTimeClock::dump_config() {
#ifdef USE_TIME_TIMEZONE
ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str());
const auto &tz = get_global_tz();
// POSIX offset is positive west, negate for conventional UTC+X display
int std_h = -tz.std_offset_seconds / 3600;
int std_m = (std::abs(tz.std_offset_seconds) % 3600) / 60;
if (tz.has_dst()) {
int dst_h = -tz.dst_offset_seconds / 3600;
int dst_m = (std::abs(tz.dst_offset_seconds) % 3600) / 60;
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d (DST UTC%+d:%02d)", std_h, std_m, dst_h, dst_m);
} else {
ESP_LOGCONFIG(TAG, "Timezone: UTC%+d:%02d", std_h, std_m);
}
#endif
auto time = this->now();
ESP_LOGCONFIG(TAG, "Current time: %04d-%02d-%02d %02d:%02d:%02d", time.year, time.month, time.day_of_month, time.hour,
@@ -72,11 +96,6 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
ret = settimeofday(&timev, nullptr);
}
#ifdef USE_TIME_TIMEZONE
// Move timezone back to local timezone.
this->apply_timezone_();
#endif
if (ret != 0) {
ESP_LOGW(TAG, "setimeofday() failed with code %d", ret);
}
@@ -89,9 +108,29 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) {
}
#ifdef USE_TIME_TIMEZONE
void RealTimeClock::apply_timezone_() {
setenv("TZ", this->timezone_.c_str(), 1);
void RealTimeClock::apply_timezone_(const char *tz) {
ParsedTimezone parsed{};
// Handle null or empty input - use UTC
if (tz == nullptr || *tz == '\0') {
set_global_tz(parsed);
return;
}
#ifdef USE_HOST
// On host platform, also set TZ environment variable for libc compatibility
setenv("TZ", tz, 1);
tzset();
#endif
// Parse the POSIX TZ string using our custom parser
if (!parse_posix_tz(tz, parsed)) {
ESP_LOGW(TAG, "Failed to parse timezone: %s", tz);
// parsed stays as default (UTC) on failure
}
// Set global timezone for all time conversions
set_global_tz(parsed);
}
#endif

View File

@@ -6,6 +6,9 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/time.h"
#ifdef USE_TIME_TIMEZONE
#include "posix_tz.h"
#endif
namespace esphome::time {
@@ -20,26 +23,31 @@ class RealTimeClock : public PollingComponent {
explicit RealTimeClock();
#ifdef USE_TIME_TIMEZONE
/// Set the time zone.
void set_timezone(const std::string &tz) {
this->timezone_ = tz;
this->apply_timezone_();
}
/// Set the time zone from a POSIX TZ string.
void set_timezone(const char *tz) { this->apply_timezone_(tz); }
/// Set the time zone from raw buffer, only if it differs from the current one.
/// Set the time zone from a character buffer with known length.
/// The buffer does not need to be null-terminated.
void set_timezone(const char *tz, size_t len) {
if (this->timezone_.length() != len || memcmp(this->timezone_.c_str(), tz, len) != 0) {
this->timezone_.assign(tz, len);
this->apply_timezone_();
if (tz == nullptr) {
this->apply_timezone_(nullptr);
return;
}
// Stack buffer - TZ strings from tzdata are typically short (< 50 chars)
char buf[128];
if (len >= sizeof(buf))
len = sizeof(buf) - 1;
memcpy(buf, tz, len);
buf[len] = '\0';
this->apply_timezone_(buf);
}
/// Get the time zone currently in use.
std::string get_timezone() { return this->timezone_; }
/// Set the time zone from a std::string.
void set_timezone(const std::string &tz) { this->apply_timezone_(tz.c_str()); }
#endif
/// Get the time in the currently defined timezone.
ESPTime now() { return ESPTime::from_epoch_local(this->timestamp_now()); }
ESPTime now();
/// Get the time without any time zone or DST corrections.
ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); }
@@ -58,8 +66,7 @@ class RealTimeClock : public PollingComponent {
void synchronize_epoch_(uint32_t epoch);
#ifdef USE_TIME_TIMEZONE
std::string timezone_{};
void apply_timezone_();
void apply_timezone_(const char *tz);
#endif
CallbackManager<void()> time_sync_callback_;

View File

@@ -2,7 +2,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <vector>
#include <array>
namespace esphome {
namespace tx20 {
@@ -45,25 +45,25 @@ std::string Tx20Component::get_wind_cardinal_direction() const { return this->wi
void Tx20Component::decode_and_publish_() {
ESP_LOGVV(TAG, "Decode Tx20");
std::string string_buffer;
std::string string_buffer_2;
std::vector<bool> bit_buffer;
std::array<bool, MAX_BUFFER_SIZE> bit_buffer{};
size_t bit_pos = 0;
bool current_bit = true;
// Cap at MAX_BUFFER_SIZE - 1 to prevent out-of-bounds access (buffer_index can exceed MAX_BUFFER_SIZE in ISR)
const int max_buffer_index =
std::min(static_cast<int>(this->store_.buffer_index), static_cast<int>(MAX_BUFFER_SIZE - 1));
for (int i = 1; i <= this->store_.buffer_index; i++) {
string_buffer_2 += to_string(this->store_.buffer[i]) + ", ";
for (int i = 1; i <= max_buffer_index; i++) {
uint8_t repeat = this->store_.buffer[i] / TX20_BIT_TIME;
// ignore segments at the end that were too short
string_buffer.append(repeat, current_bit ? '1' : '0');
bit_buffer.insert(bit_buffer.end(), repeat, current_bit);
for (uint8_t j = 0; j < repeat && bit_pos < MAX_BUFFER_SIZE; j++) {
bit_buffer[bit_pos++] = current_bit;
}
current_bit = !current_bit;
}
current_bit = !current_bit;
if (string_buffer.length() < MAX_BUFFER_SIZE) {
uint8_t remain = MAX_BUFFER_SIZE - string_buffer.length();
string_buffer_2 += to_string(remain) + ", ";
string_buffer.append(remain, current_bit ? '1' : '0');
bit_buffer.insert(bit_buffer.end(), remain, current_bit);
size_t bits_before_padding = bit_pos;
while (bit_pos < MAX_BUFFER_SIZE) {
bit_buffer[bit_pos++] = current_bit;
}
uint8_t tx20_sa = 0;
@@ -108,8 +108,24 @@ void Tx20Component::decode_and_publish_() {
// 2. Check received checksum matches calculated checksum
// 3. Check that Wind Direction matches Wind Direction (Inverted)
// 4. Check that Wind Speed matches Wind Speed (Inverted)
ESP_LOGVV(TAG, "BUFFER %s", string_buffer_2.c_str());
ESP_LOGVV(TAG, "Decoded bits %s", string_buffer.c_str());
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
// Build debug strings from completed data
char debug_buf[320]; // buffer values: max 40 entries * 7 chars each
size_t debug_pos = 0;
for (int i = 1; i <= max_buffer_index; i++) {
debug_pos = buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%u, ", this->store_.buffer[i]);
}
if (bits_before_padding < MAX_BUFFER_SIZE) {
buf_append_printf(debug_buf, sizeof(debug_buf), debug_pos, "%zu, ", MAX_BUFFER_SIZE - bits_before_padding);
}
char bits_buf[MAX_BUFFER_SIZE + 1];
for (size_t i = 0; i < MAX_BUFFER_SIZE; i++) {
bits_buf[i] = bit_buffer[i] ? '1' : '0';
}
bits_buf[MAX_BUFFER_SIZE] = '\0';
ESP_LOGVV(TAG, "BUFFER %s", debug_buf);
ESP_LOGVV(TAG, "Decoded bits %s", bits_buf);
#endif
if (tx20_sa == 4) {
if (chk == tx20_sd) {

View File

@@ -24,7 +24,12 @@ from .const_zephyr import (
ZigbeeComponent,
zigbee_ns,
)
from .zigbee_zephyr import zephyr_binary_sensor, zephyr_sensor, zephyr_switch
from .zigbee_zephyr import (
zephyr_binary_sensor,
zephyr_number,
zephyr_sensor,
zephyr_switch,
)
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +48,7 @@ def zigbee_set_core_data(config: ConfigType) -> ConfigType:
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_binary_sensor)
SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor)
SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch)
NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number)
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -125,6 +131,21 @@ async def setup_switch(entity: cg.MockObj, config: ConfigType) -> None:
await zephyr_setup_switch(entity, config)
async def setup_number(
entity: cg.MockObj,
config: ConfigType,
min_value: float,
max_value: float,
step: float,
) -> None:
if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL):
return
if CORE.using_zephyr:
from .zigbee_zephyr import zephyr_setup_number
await zephyr_setup_number(entity, config, min_value, max_value, step)
def consume_endpoint(config: ConfigType) -> ConfigType:
if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL):
return config
@@ -152,6 +173,10 @@ def validate_switch(config: ConfigType) -> ConfigType:
return consume_endpoint(config)
def validate_number(config: ConfigType) -> ConfigType:
return consume_endpoint(config)
ZIGBEE_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{

View File

@@ -4,6 +4,7 @@ zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
CONF_MAX_EP_NUMBER = 8
CONF_ZIGBEE_ID = "zigbee_id"
@@ -12,6 +13,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
CONF_ZIGBEE_SWITCH = "zigbee_switch"
CONF_ZIGBEE_NUMBER = "zigbee_number"
CONF_POWER_SOURCE = "power_source"
POWER_SOURCE = {
"UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN",
@@ -38,3 +40,4 @@ ZB_ZCL_CLUSTER_ID_IDENTIFY = "ZB_ZCL_CLUSTER_ID_IDENTIFY"
ZB_ZCL_CLUSTER_ID_BINARY_INPUT = "ZB_ZCL_CLUSTER_ID_BINARY_INPUT"
ZB_ZCL_CLUSTER_ID_ANALOG_INPUT = "ZB_ZCL_CLUSTER_ID_ANALOG_INPUT"
ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT = "ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT"
ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT = "ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT"

View File

@@ -0,0 +1,111 @@
#include "zigbee_number_zephyr.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_NUMBER)
#include "esphome/core/log.h"
extern "C" {
#include <zboss_api.h>
#include <zboss_api_addons.h>
#include <zb_nrf_platform.h>
#include <zigbee/zigbee_app_utils.h>
#include <zb_error_to_string.h>
}
namespace esphome::zigbee {
static const char *const TAG = "zigbee.number";
void ZigbeeNumber::setup() {
this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); });
this->number_->add_on_state_callback([this](float state) {
this->cluster_attributes_->present_value = state;
ESP_LOGD(TAG, "Set attribute endpoint: %d, present_value %f", this->endpoint_,
this->cluster_attributes_->present_value);
ZB_ZCL_SET_ATTRIBUTE(this->endpoint_, ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE,
ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, (zb_uint8_t *) &cluster_attributes_->present_value,
ZB_FALSE);
this->parent_->force_report();
});
}
void ZigbeeNumber::dump_config() {
ESP_LOGCONFIG(TAG,
"Zigbee Number\n"
" Endpoint: %d, present_value %f",
this->endpoint_, this->cluster_attributes_->present_value);
}
void ZigbeeNumber::zcl_device_cb_(zb_bufid_t bufid) {
zb_zcl_device_callback_param_t *p_device_cb_param = ZB_BUF_GET_PARAM(bufid, zb_zcl_device_callback_param_t);
zb_zcl_device_callback_id_t device_cb_id = p_device_cb_param->device_cb_id;
zb_uint16_t cluster_id = p_device_cb_param->cb_param.set_attr_value_param.cluster_id;
zb_uint16_t attr_id = p_device_cb_param->cb_param.set_attr_value_param.attr_id;
switch (device_cb_id) {
/* ZCL set attribute value */
case ZB_ZCL_SET_ATTR_VALUE_CB_ID:
if (cluster_id == ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT) {
ESP_LOGI(TAG, "Analog output attribute setting");
if (attr_id == ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID) {
float value =
*reinterpret_cast<const float *>(&p_device_cb_param->cb_param.set_attr_value_param.values.data32);
this->defer([this, value]() {
this->cluster_attributes_->present_value = value;
auto call = this->number_->make_call();
call.set_value(value);
call.perform();
});
}
} else {
/* other clusters attribute handled here */
ESP_LOGI(TAG, "Unhandled cluster attribute id: %d", cluster_id);
p_device_cb_param->status = RET_NOT_IMPLEMENTED;
}
break;
default:
p_device_cb_param->status = RET_NOT_IMPLEMENTED;
break;
}
ESP_LOGD(TAG, "%s status: %hd", __func__, p_device_cb_param->status);
}
const zb_uint8_t ZB_ZCL_ANALOG_OUTPUT_STATUS_FLAG_MAX_VALUE = 0x0F;
static zb_ret_t check_value_analog_server(zb_uint16_t attr_id, zb_uint8_t endpoint,
zb_uint8_t *value) { // NOLINT(readability-non-const-parameter)
zb_ret_t ret = RET_OK;
ZVUNUSED(endpoint);
switch (attr_id) {
case ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID:
ret = ZB_ZCL_CHECK_BOOL_VALUE(*value) ? RET_OK : RET_ERROR;
break;
case ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID:
break;
case ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID:
if (*value > ZB_ZCL_ANALOG_OUTPUT_STATUS_FLAG_MAX_VALUE) {
ret = RET_ERROR;
}
break;
default:
break;
}
return ret;
}
} // namespace esphome::zigbee
void zb_zcl_analog_output_init_server() {
zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_SERVER_ROLE,
esphome::zigbee::check_value_analog_server, (zb_zcl_cluster_write_attr_hook_t) NULL,
(zb_zcl_cluster_handler_t) NULL);
}
void zb_zcl_analog_output_init_client() {
zb_zcl_add_cluster_handlers(ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, ZB_ZCL_CLUSTER_CLIENT_ROLE,
(zb_zcl_cluster_check_value_t) NULL, (zb_zcl_cluster_write_attr_hook_t) NULL,
(zb_zcl_cluster_handler_t) NULL);
}
#endif

View File

@@ -0,0 +1,118 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_NUMBER)
#include "esphome/components/zigbee/zigbee_zephyr.h"
#include "esphome/core/component.h"
#include "esphome/components/number/number.h"
extern "C" {
#include <zboss_api.h>
#include <zboss_api_addons.h>
}
enum {
ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID = 0x001C,
ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID = 0x0041,
ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID = 0x0045,
ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID = 0x0051,
ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID = 0x0055,
ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID = 0x006A,
ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID = 0x006F,
ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID = 0x0075,
};
#define ZB_ZCL_ANALOG_OUTPUT_CLUSTER_REVISION_DEFAULT ((zb_uint16_t) 0x0001u)
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID, ZB_ZCL_ATTR_TYPE_CHAR_STRING, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \
(ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \
}
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID, ZB_ZCL_ATTR_TYPE_BOOL, \
ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
// PresentValue
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \
ZB_ZCL_ATTR_ACCESS_READ_WRITE | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
// MaxPresentValue
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \
ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
// MinPresentValue
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \
ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
// Resolution
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID, ZB_ZCL_ATTR_TYPE_SINGLE, \
ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_WRITE_OPTIONAL, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID, ZB_ZCL_ATTR_TYPE_8BITMAP, \
ZB_ZCL_ATTR_ACCESS_READ_ONLY | ZB_ZCL_ATTR_ACCESS_REPORTING, (ZB_ZCL_NON_MANUFACTURER_SPECIFIC), \
(void *) (data_ptr) \
}
#define ZB_SET_ATTR_DESCR_WITH_ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID(data_ptr) \
{ \
ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID, ZB_ZCL_ATTR_TYPE_16BIT_ENUM, ZB_ZCL_ATTR_ACCESS_READ_ONLY, \
(ZB_ZCL_NON_MANUFACTURER_SPECIFIC), (void *) (data_ptr) \
}
#define ESPHOME_ZB_ZCL_DECLARE_ANALOG_OUTPUT_ATTRIB_LIST(attr_list, out_of_service, present_value, status_flag, \
max_present_value, min_present_value, resolution, \
engineering_units, description) \
ZB_ZCL_START_DECLARE_ATTRIB_LIST_CLUSTER_REVISION(attr_list, ZB_ZCL_ANALOG_OUTPUT) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_OUT_OF_SERVICE_ID, (out_of_service)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_PRESENT_VALUE_ID, (present_value)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_STATUS_FLAG_ID, (status_flag)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_MAX_PRESENT_VALUE_ID, (max_present_value)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_MIN_PRESENT_VALUE_ID, (min_present_value)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_RESOLUTION_ID, (resolution)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_ENGINEERING_UNITS_ID, (engineering_units)) \
ZB_ZCL_SET_ATTR_DESC(ZB_ZCL_ATTR_ANALOG_OUTPUT_DESCRIPTION_ID, (description)) \
ZB_ZCL_FINISH_DECLARE_ATTRIB_LIST
void zb_zcl_analog_output_init_server();
void zb_zcl_analog_output_init_client();
#define ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT_SERVER_ROLE_INIT zb_zcl_analog_output_init_server
#define ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT_CLIENT_ROLE_INIT zb_zcl_analog_output_init_client
namespace esphome::zigbee {
class ZigbeeNumber : public ZigbeeEntity, public Component {
public:
ZigbeeNumber(number::Number *n) : number_(n) {}
void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) {
this->cluster_attributes_ = &cluster_attributes;
}
void setup() override;
void dump_config() override;
protected:
number::Number *number_;
AnalogAttrsOutput *cluster_attributes_{nullptr};
void zcl_device_cb_(zb_bufid_t bufid);
};
} // namespace esphome::zigbee
#endif

View File

@@ -50,7 +50,7 @@ void ZigbeeSwitch::zcl_device_cb_(zb_bufid_t bufid) {
if (attr_id == ZB_ZCL_ATTR_BINARY_OUTPUT_PRESENT_VALUE_ID) {
this->defer([this, value]() {
this->cluster_attributes_->present_value = value ? ZB_TRUE : ZB_FALSE;
this->switch_->publish_state(value);
this->switch_->control(value);
});
}
} else {

View File

@@ -101,8 +101,8 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) {
zb_uint16_t attr_id = p_device_cb_param->cb_param.set_attr_value_param.attr_id;
auto endpoint = p_device_cb_param->endpoint;
ESP_LOGI(TAG, "Zcl_device_cb %s id %hd, cluster_id %d, attr_id %d, endpoint: %d", __func__, device_cb_id, cluster_id,
attr_id, endpoint);
ESP_LOGI(TAG, "%s id %hd, cluster_id %d, attr_id %d, endpoint: %d", __func__, device_cb_id, cluster_id, attr_id,
endpoint);
/* Set default response value. */
p_device_cb_param->status = RET_OK;

View File

@@ -60,6 +60,12 @@ struct AnalogAttrs {
zb_uchar_t description[ZB_ZCL_MAX_STRING_SIZE];
};
struct AnalogAttrsOutput : AnalogAttrs {
float max_present_value;
float min_present_value;
float resolution;
};
class ZigbeeComponent : public Component {
public:
void setup() override;

View File

@@ -55,6 +55,7 @@ from .const_zephyr import (
CONF_WIPE_ON_BOOT,
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_NUMBER,
CONF_ZIGBEE_SENSOR,
CONF_ZIGBEE_SWITCH,
KEY_EP_NUMBER,
@@ -62,12 +63,14 @@ from .const_zephyr import (
POWER_SOURCE,
ZB_ZCL_BASIC_ATTRS_EXT_T,
ZB_ZCL_CLUSTER_ID_ANALOG_INPUT,
ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
ZB_ZCL_CLUSTER_ID_BASIC,
ZB_ZCL_CLUSTER_ID_BINARY_INPUT,
ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT,
ZB_ZCL_CLUSTER_ID_IDENTIFY,
ZB_ZCL_IDENTIFY_ATTRS_T,
AnalogAttrs,
AnalogAttrsOutput,
BinaryAttrs,
ZigbeeComponent,
zigbee_ns,
@@ -76,6 +79,7 @@ from .const_zephyr import (
ZigbeeBinarySensor = zigbee_ns.class_("ZigbeeBinarySensor", cg.Component)
ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component)
ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component)
ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component)
# BACnet engineering units mapping (ZCL uses BACnet unit codes)
# See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py
@@ -139,6 +143,15 @@ zephyr_switch = cv.Schema(
}
)
zephyr_number = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
cv.OnlyWith(CONF_ZIGBEE_NUMBER, ["nrf52", "zigbee"]): cv.declare_id(
ZigbeeNumber
),
}
)
async def zephyr_to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("ZIGBEE", True)
@@ -344,6 +357,16 @@ async def zephyr_setup_switch(entity: cg.MockObj, config: ConfigType) -> None:
CORE.add_job(_add_switch, entity, config)
async def zephyr_setup_number(
entity: cg.MockObj,
config: ConfigType,
min_value: float,
max_value: float,
step: float,
) -> None:
CORE.add_job(_add_number, entity, config, min_value, max_value, step)
def get_slot_index() -> int:
"""Find the next available endpoint slot."""
slot = next(
@@ -451,3 +474,31 @@ async def _add_switch(entity: cg.MockObj, config: ConfigType) -> None:
ZB_ZCL_CLUSTER_ID_BINARY_OUTPUT,
"ZB_HA_CUSTOM_ATTR_DEVICE_ID",
)
async def _add_number(
entity: cg.MockObj,
config: ConfigType,
min_value: float,
max_value: float,
step: float,
) -> None:
# Get BACnet engineering unit from unit_of_measurement
unit = config.get(CONF_UNIT_OF_MEASUREMENT, "")
bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS)
await _add_zigbee_ep(
entity,
config,
CONF_ZIGBEE_NUMBER,
AnalogAttrsOutput,
"ESPHOME_ZB_ZCL_DECLARE_ANALOG_OUTPUT_ATTRIB_LIST",
ZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT,
"ZB_HA_CUSTOM_ATTR_DEVICE_ID",
extra_field_values={
"max_present_value": max_value,
"min_present_value": min_value,
"resolution": step,
"engineering_units": bacnet_unit,
},
)

View File

@@ -149,6 +149,7 @@ CONF_ASSUMED_STATE = "assumed_state"
CONF_AT = "at"
CONF_ATTENUATION = "attenuation"
CONF_ATTRIBUTE = "attribute"
CONF_AUDIO_DAC = "audio_dac"
CONF_AUTH = "auth"
CONF_AUTO_CLEAR_ENABLED = "auto_clear_enabled"
CONF_AUTO_MODE = "auto_mode"

View File

@@ -2,7 +2,6 @@
#include "helpers.h"
#include <algorithm>
#include <cinttypes>
namespace esphome {
@@ -67,58 +66,123 @@ std::string ESPTime::strftime(const char *format) {
std::string ESPTime::strftime(const std::string &format) { return this->strftime(format.c_str()); }
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
int num;
const int ilen = static_cast<int>(len);
if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu:%02hhu %n", &year, &month, &day, // NOLINT
&hour, // NOLINT
&minute, // NOLINT
&second, &num) == 6 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = second;
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %02hhu:%02hhu %n", &year, &month, &day, // NOLINT
&hour, // NOLINT
&minute, &num) == 5 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = 0;
} else if (sscanf(time_to_parse, "%02hhu:%02hhu:%02hhu %n", &hour, &minute, &second, &num) == 3 && // NOLINT
num == ilen) {
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = second;
} else if (sscanf(time_to_parse, "%02hhu:%02hhu %n", &hour, &minute, &num) == 2 && // NOLINT
num == ilen) {
esp_time.hour = hour;
esp_time.minute = minute;
esp_time.second = 0;
} else if (sscanf(time_to_parse, "%04hu-%02hhu-%02hhu %n", &year, &month, &day, &num) == 3 && // NOLINT
num == ilen) {
esp_time.year = year;
esp_time.month = month;
esp_time.day_of_month = day;
} else {
return false;
// Helper to parse exactly N digits, returns false if not enough digits
static bool parse_digits(const char *&p, const char *end, int count, uint16_t &value) {
value = 0;
for (int i = 0; i < count; i++) {
if (p >= end || *p < '0' || *p > '9')
return false;
value = value * 10 + (*p - '0');
p++;
}
return true;
}
// Helper to check for expected character
static bool expect_char(const char *&p, const char *end, char expected) {
if (p >= end || *p != expected)
return false;
p++;
return true;
}
bool ESPTime::strptime(const char *time_to_parse, size_t len, ESPTime &esp_time) {
// Supported formats:
// YYYY-MM-DD HH:MM:SS (19 chars)
// YYYY-MM-DD HH:MM (16 chars)
// YYYY-MM-DD (10 chars)
// HH:MM:SS (8 chars)
// HH:MM (5 chars)
if (time_to_parse == nullptr || len == 0)
return false;
const char *p = time_to_parse;
const char *end = time_to_parse + len;
uint16_t v1, v2, v3, v4, v5, v6;
// Try date formats first (start with 4-digit year)
if (len >= 10 && time_to_parse[4] == '-') {
// YYYY-MM-DD...
if (!parse_digits(p, end, 4, v1))
return false;
if (!expect_char(p, end, '-'))
return false;
if (!parse_digits(p, end, 2, v2))
return false;
if (!expect_char(p, end, '-'))
return false;
if (!parse_digits(p, end, 2, v3))
return false;
esp_time.year = v1;
esp_time.month = v2;
esp_time.day_of_month = v3;
if (p == end) {
// YYYY-MM-DD (date only)
return true;
}
if (!expect_char(p, end, ' '))
return false;
// Continue with time part: HH:MM[:SS]
if (!parse_digits(p, end, 2, v4))
return false;
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v5))
return false;
esp_time.hour = v4;
esp_time.minute = v5;
if (p == end) {
// YYYY-MM-DD HH:MM
esp_time.second = 0;
return true;
}
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v6))
return false;
esp_time.second = v6;
return p == end; // YYYY-MM-DD HH:MM:SS
}
// Try time-only formats (HH:MM[:SS])
if (len >= 5 && time_to_parse[2] == ':') {
if (!parse_digits(p, end, 2, v1))
return false;
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v2))
return false;
esp_time.hour = v1;
esp_time.minute = v2;
if (p == end) {
// HH:MM
esp_time.second = 0;
return true;
}
if (!expect_char(p, end, ':'))
return false;
if (!parse_digits(p, end, 2, v3))
return false;
esp_time.second = v3;
return p == end; // HH:MM:SS
}
return false;
}
void ESPTime::increment_second() {
this->timestamp++;
if (!increment_time_value(this->second, 0, 60))
@@ -193,27 +257,67 @@ void ESPTime::recalc_timestamp_utc(bool use_day_of_year) {
}
void ESPTime::recalc_timestamp_local() {
struct tm tm;
#ifdef USE_TIME_TIMEZONE
// Calculate timestamp as if fields were UTC
this->recalc_timestamp_utc(false);
if (this->timestamp == -1) {
return; // Invalid time
}
tm.tm_year = this->year - 1900;
tm.tm_mon = this->month - 1;
tm.tm_mday = this->day_of_month;
tm.tm_hour = this->hour;
tm.tm_min = this->minute;
tm.tm_sec = this->second;
tm.tm_isdst = -1;
// Now convert from local to UTC by adding the offset
// POSIX: local = utc - offset, so utc = local + offset
const auto &tz = time::get_global_tz();
this->timestamp = mktime(&tm);
if (!tz.has_dst()) {
// No DST - just apply standard offset
this->timestamp += tz.std_offset_seconds;
return;
}
// Try both interpretations to match libc mktime() with tm_isdst=-1
// For ambiguous times (fall-back repeated hour), prefer standard time
// For invalid times (spring-forward skipped hour), libc normalizes forward
time_t utc_if_dst = this->timestamp + tz.dst_offset_seconds;
time_t utc_if_std = this->timestamp + tz.std_offset_seconds;
bool dst_valid = time::is_in_dst(utc_if_dst, tz);
bool std_valid = !time::is_in_dst(utc_if_std, tz);
if (dst_valid && std_valid) {
// Ambiguous time (repeated hour during fall-back) - prefer standard time
this->timestamp = utc_if_std;
} else if (dst_valid) {
// Only DST interpretation is valid
this->timestamp = utc_if_dst;
} else if (std_valid) {
// Only standard interpretation is valid
this->timestamp = utc_if_std;
} else {
// Invalid time (skipped hour during spring-forward)
// libc normalizes forward: 02:30 CST -> 08:30 UTC -> 03:30 CDT
// Using std offset achieves this since the UTC result falls during DST
this->timestamp = utc_if_std;
}
#else
// No timezone support - treat as UTC
this->recalc_timestamp_utc(false);
#endif
}
int32_t ESPTime::timezone_offset() {
#ifdef USE_TIME_TIMEZONE
time_t now = ::time(nullptr);
struct tm local_tm = *::localtime(&now);
local_tm.tm_isdst = 0; // Cause mktime to ignore daylight saving time because we want to include it in the offset.
time_t local_time = mktime(&local_tm);
struct tm utc_tm = *::gmtime(&now);
time_t utc_time = mktime(&utc_tm);
return static_cast<int32_t>(local_time - utc_time);
const auto &tz = time::get_global_tz();
// POSIX offset is positive west, but we return offset to add to UTC to get local
// So we negate the POSIX offset
if (time::is_in_dst(now, tz)) {
return -tz.dst_offset_seconds;
}
return -tz.std_offset_seconds;
#else
// No timezone support - no offset
return 0;
#endif
}
bool ESPTime::operator<(const ESPTime &other) const { return this->timestamp < other.timestamp; }

View File

@@ -7,6 +7,10 @@
#include <span>
#include <string>
#ifdef USE_TIME_TIMEZONE
#include "esphome/components/time/posix_tz.h"
#endif
namespace esphome {
template<typename T> bool increment_time_value(T &current, uint16_t begin, uint16_t end);
@@ -105,11 +109,17 @@ struct ESPTime {
* @return The generated ESPTime
*/
static ESPTime from_epoch_local(time_t epoch) {
struct tm *c_tm = ::localtime(&epoch);
if (c_tm == nullptr) {
return ESPTime{}; // Return an invalid ESPTime
#ifdef USE_TIME_TIMEZONE
struct tm local_tm;
if (time::epoch_to_local_tm(epoch, time::get_global_tz(), &local_tm)) {
return ESPTime::from_c_tm(&local_tm, epoch);
}
return ESPTime::from_c_tm(c_tm, epoch);
// Fallback to UTC if conversion failed
return ESPTime::from_epoch_utc(epoch);
#else
// No timezone support - return UTC (no TZ configured, localtime would return UTC anyway)
return ESPTime::from_epoch_utc(epoch);
#endif
}
/** Convert an UTC epoch timestamp to a UTC time ESPTime instance.
*

View File

@@ -66,6 +66,7 @@ def create_test_config(config_name: str, includes: list[str]) -> dict:
],
"build_flags": [
"-Og", # optimize for debug
"-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing
],
"debug_build_flags": [ # only for debug builds
"-g3", # max debug info

View File

@@ -0,0 +1,36 @@
ch423:
- id: ch423_hub
i2c_id: i2c_bus
binary_sensor:
- platform: gpio
id: ch423_input
name: CH423 Binary Sensor
pin:
ch423: ch423_hub
number: 1
mode: INPUT
inverted: true
- platform: gpio
id: ch423_input_2
name: CH423 Binary Sensor 2
pin:
ch423: ch423_hub
number: 0
mode: INPUT
inverted: false
output:
- platform: gpio
id: ch423_out_11
pin:
ch423: ch423_hub
number: 11
mode: OUTPUT_OPEN_DRAIN
inverted: true
- platform: gpio
id: ch423_out_23
pin:
ch423: ch423_hub
number: 23
mode: OUTPUT_OPEN_DRAIN
inverted: false

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml

View File

@@ -8,6 +8,16 @@ esp32:
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
use_full_certificate_bundle: false # Test CMN bundle (default)
include_builtin_idf_components:
- freertos # Test escape hatch (freertos is always included anyway)
disable_debug_stubs: true
disable_ocd_aware: true
disable_usb_serial_jtag_secondary: true
disable_dev_null_vfs: true
disable_mbedtls_peer_cert: true
disable_mbedtls_pkcs7: true
disable_regi2c_in_iram: true
disable_fatfs: true
wifi:
ssid: MySSID

View File

@@ -10,6 +10,14 @@ esp32:
ref: 2.7.0
advanced:
enable_idf_experimental_features: yes
disable_debug_stubs: true
disable_ocd_aware: true
disable_usb_serial_jtag_secondary: true
disable_dev_null_vfs: true
disable_mbedtls_peer_cert: true
disable_mbedtls_pkcs7: true
disable_regi2c_in_iram: true
disable_fatfs: true
ota:
platform: esphome

View File

@@ -5,6 +5,14 @@ esp32:
advanced:
execute_from_psram: true
disable_libc_locks_in_iram: true # Test default RAM optimization enabled
disable_debug_stubs: true
disable_ocd_aware: true
disable_usb_serial_jtag_secondary: true
disable_dev_null_vfs: true
disable_mbedtls_peer_cert: true
disable_mbedtls_pkcs7: true
disable_regi2c_in_iram: true
disable_fatfs: true
psram:
mode: octal

View File

@@ -8,11 +8,11 @@ sensor:
pm_10_0:
name: PM 10.0 Concentration
pm_1_0_std:
name: PM 1.0 Standard Atmospher Concentration
name: PM 1.0 Standard Atmospheric Concentration
pm_2_5_std:
name: PM 2.5 Standard Atmospher Concentration
name: PM 2.5 Standard Atmospheric Concentration
pm_10_0_std:
name: PM 10.0 Standard Atmospher Concentration
name: PM 10.0 Standard Atmospheric Concentration
pm_0_3um:
name: Particulate Count >0.3um
pm_0_5um:

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,6 @@ binary_sensor:
name: "Garage Door Open 2"
- platform: template
name: "Garage Door Open 3"
- platform: template
name: "Garage Door Open 4"
- platform: template
name: "Garage Door Internal"
internal: True
@@ -44,3 +42,11 @@ switch:
- platform: template
name: "Template Switch"
optimistic: true
number:
- platform: template
name: "Template number"
optimistic: true
min_value: 2
max_value: 100
step: 1

View File

@@ -0,0 +1,58 @@
"""Tests for ch423 component validation."""
from unittest.mock import patch
from esphome import config, yaml_util
from esphome.core import CORE
def test_ch423_mixed_gpio_modes_fails(tmp_path, capsys):
"""Test that mixing input/output on GPIO pins 0-7 fails validation."""
test_file = tmp_path / "test.yaml"
test_file.write_text("""
esphome:
name: test
esp8266:
board: esp01_1m
i2c:
sda: GPIO4
scl: GPIO5
ch423:
- id: ch423_hub
binary_sensor:
- platform: gpio
name: "CH423 Input 0"
pin:
ch423: ch423_hub
number: 0
mode: input
switch:
- platform: gpio
name: "CH423 Output 1"
pin:
ch423: ch423_hub
number: 1
mode: output
""")
parsed_yaml = yaml_util.load_yaml(test_file)
with (
patch.object(yaml_util, "load_yaml", return_value=parsed_yaml),
patch.object(CORE, "config_path", test_file),
):
result = config.read_config({})
assert result is None, "Expected validation to fail with mixed GPIO modes"
# Check that the error message mentions the GPIO pin restriction
captured = capsys.readouterr()
assert (
"GPIO pins (0-7) must all be configured as input or all as output"
in captured.out
)