mirror of
https://github.com/esphome/esphome.git
synced 2025-11-18 15:55:46 +00:00
Compare commits
46 Commits
voice_get_
...
fix-packag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdcbe0687 | ||
|
|
56c5d272ed | ||
|
|
63f100a8ca | ||
|
|
ea4e5fd7bd | ||
|
|
12e9c5e60e | ||
|
|
3d82c5baf7 | ||
|
|
6f5e36ffc3 | ||
|
|
118b1d8593 | ||
|
|
319ba4a504 | ||
|
|
ae8336c268 | ||
|
|
1b38518c63 | ||
|
|
c00977df54 | ||
|
|
255b5a3abd | ||
|
|
dd732dd155 | ||
|
|
22fec4329f | ||
|
|
8f1c4634ec | ||
|
|
c15f1a9be8 | ||
|
|
11b53096a6 | ||
|
|
6a18367949 | ||
|
|
a59b1494d8 | ||
|
|
e6ce5c58d1 | ||
|
|
ebc0f5f7c9 | ||
|
|
0f87e7508b | ||
|
|
862bbb7fe1 | ||
|
|
020cea80b2 | ||
|
|
9c146a7070 | ||
|
|
afbd3f77af | ||
|
|
1e1fefbd0a | ||
|
|
1a2057df30 | ||
|
|
87ca8784ef | ||
|
|
a186c1062f | ||
|
|
ea38237f29 | ||
|
|
6aff1394ad | ||
|
|
0e34d1b64d | ||
|
|
1483cee0fb | ||
|
|
8c1bd2fd85 | ||
|
|
ea609dc0f6 | ||
|
|
913095f6be | ||
|
|
bb24ad4a30 | ||
|
|
0d612fecfc | ||
|
|
9c235b4140 | ||
|
|
70cb1793f3 | ||
|
|
3bdd351d49 | ||
|
|
b0ea3f57de | ||
|
|
c9312d5c27 | ||
|
|
33fea90c19 |
111
.github/workflows/ci-memory-impact-comment.yml
vendored
Normal file
111
.github/workflows/ci-memory-impact-comment.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: Memory Impact Comment (Forks)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
memory-impact-comment:
|
||||
name: Post memory impact comment (fork PRs only)
|
||||
runs-on: ubuntu-24.04
|
||||
# Only run for PRs from forks that had successful CI runs
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name != github.repository
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
run: |
|
||||
# Get PR details by searching for PR with matching head SHA
|
||||
# The workflow_run.pull_requests field is often empty for forks
|
||||
# Use paginate to handle repos with many open PRs
|
||||
head_sha="${{ github.event.workflow_run.head_sha }}"
|
||||
pr_data=$(gh api --paginate "/repos/${{ github.repository }}/pulls" \
|
||||
--jq ".[] | select(.head.sha == \"$head_sha\") | {number: .number, base_ref: .base.ref}" \
|
||||
| head -n 1)
|
||||
|
||||
if [ -z "$pr_data" ]; then
|
||||
echo "No PR found for SHA $head_sha, skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pr_number=$(echo "$pr_data" | jq -r '.number')
|
||||
base_ref=$(echo "$pr_data" | jq -r '.base_ref')
|
||||
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "Found PR #$pr_number targeting base branch: $base_ref"
|
||||
|
||||
- name: Check out code from base repository
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
# Always check out from the base repository (esphome/esphome), never from forks
|
||||
# Use the PR's target branch to ensure we run trusted code from the main repo
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ steps.pr.outputs.base_ref }}
|
||||
|
||||
- name: Restore Python
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache-key: ${{ hashFiles('.cache-key') }}
|
||||
|
||||
- name: Download memory analysis artifacts
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
run: |
|
||||
run_id="${{ github.event.workflow_run.id }}"
|
||||
echo "Downloading artifacts from workflow run $run_id"
|
||||
|
||||
mkdir -p memory-analysis
|
||||
|
||||
# Download target analysis artifact
|
||||
if gh run download --name "memory-analysis-target" --dir memory-analysis --repo "${{ github.repository }}" "$run_id"; then
|
||||
echo "Downloaded memory-analysis-target artifact."
|
||||
else
|
||||
echo "No memory-analysis-target artifact found."
|
||||
fi
|
||||
|
||||
# Download PR analysis artifact
|
||||
if gh run download --name "memory-analysis-pr" --dir memory-analysis --repo "${{ github.repository }}" "$run_id"; then
|
||||
echo "Downloaded memory-analysis-pr artifact."
|
||||
else
|
||||
echo "No memory-analysis-pr artifact found."
|
||||
fi
|
||||
|
||||
- name: Check if artifacts exist
|
||||
id: check
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
run: |
|
||||
if [ -f ./memory-analysis/memory-analysis-target.json ] && [ -f ./memory-analysis/memory-analysis-pr.json ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Memory analysis artifacts not found, skipping comment"
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.pr.outputs.skip != 'true' && steps.check.outputs.found == 'true'
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
# Pass PR number and JSON file paths directly to Python script
|
||||
# Let Python parse the JSON to avoid shell injection risks
|
||||
# The script will validate and sanitize all inputs
|
||||
python script/ci_memory_impact_comment.py \
|
||||
--pr-number "$PR_NUMBER" \
|
||||
--target-json ./memory-analysis/memory-analysis-target.json \
|
||||
--pr-json ./memory-analysis/memory-analysis-pr.json
|
||||
65
.github/workflows/ci.yml
vendored
65
.github/workflows/ci.yml
vendored
@@ -641,6 +641,12 @@ jobs:
|
||||
--output-env \
|
||||
--output-json memory-analysis-target.json
|
||||
|
||||
# Add metadata to JSON before caching
|
||||
python script/ci_add_metadata_to_json.py \
|
||||
--json-file memory-analysis-target.json \
|
||||
--components "$components" \
|
||||
--platform "$platform"
|
||||
|
||||
- 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
@@ -720,6 +726,13 @@ jobs:
|
||||
python script/ci_memory_impact_extract.py \
|
||||
--output-env \
|
||||
--output-json memory-analysis-pr.json
|
||||
|
||||
# Add metadata to JSON (components and platform are in shell variables above)
|
||||
python script/ci_add_metadata_to_json.py \
|
||||
--json-file memory-analysis-pr.json \
|
||||
--components "$components" \
|
||||
--platform "$platform"
|
||||
|
||||
- name: Upload memory analysis JSON
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -736,10 +749,12 @@ jobs:
|
||||
- determine-jobs
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -762,52 +777,16 @@ jobs:
|
||||
continue-on-error: true
|
||||
- name: Post or update PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMPONENTS: ${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}
|
||||
PLATFORM: ${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}
|
||||
TARGET_RAM: ${{ needs.memory-impact-target-branch.outputs.ram_usage }}
|
||||
TARGET_FLASH: ${{ needs.memory-impact-target-branch.outputs.flash_usage }}
|
||||
PR_RAM: ${{ needs.memory-impact-pr-branch.outputs.ram_usage }}
|
||||
PR_FLASH: ${{ needs.memory-impact-pr-branch.outputs.flash_usage }}
|
||||
TARGET_CACHE_HIT: ${{ needs.memory-impact-target-branch.outputs.cache_hit }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
# Check if analysis JSON files exist
|
||||
target_json_arg=""
|
||||
pr_json_arg=""
|
||||
|
||||
if [ -f ./memory-analysis/memory-analysis-target.json ]; then
|
||||
echo "Found target analysis JSON"
|
||||
target_json_arg="--target-json ./memory-analysis/memory-analysis-target.json"
|
||||
else
|
||||
echo "No target analysis JSON found"
|
||||
fi
|
||||
|
||||
if [ -f ./memory-analysis/memory-analysis-pr.json ]; then
|
||||
echo "Found PR analysis JSON"
|
||||
pr_json_arg="--pr-json ./memory-analysis/memory-analysis-pr.json"
|
||||
else
|
||||
echo "No PR analysis JSON found"
|
||||
fi
|
||||
|
||||
# Add cache flag if target was cached
|
||||
cache_flag=""
|
||||
if [ "$TARGET_CACHE_HIT" == "true" ]; then
|
||||
cache_flag="--target-cache-hit"
|
||||
fi
|
||||
|
||||
# Pass JSON file paths directly to Python script
|
||||
# All data is extracted from JSON files for security
|
||||
python script/ci_memory_impact_comment.py \
|
||||
--pr-number "${{ github.event.pull_request.number }}" \
|
||||
--components "$COMPONENTS" \
|
||||
--platform "$PLATFORM" \
|
||||
--target-ram "$TARGET_RAM" \
|
||||
--target-flash "$TARGET_FLASH" \
|
||||
--pr-ram "$PR_RAM" \
|
||||
--pr-flash "$PR_FLASH" \
|
||||
$target_json_arg \
|
||||
$pr_json_arg \
|
||||
$cache_flag
|
||||
--pr-number "$PR_NUMBER" \
|
||||
--target-json ./memory-analysis/memory-analysis-target.json \
|
||||
--pr-json ./memory-analysis/memory-analysis-pr.json
|
||||
|
||||
ci-status:
|
||||
name: CI Status
|
||||
|
||||
@@ -62,6 +62,40 @@ from esphome.util import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Special non-component keys that appear in configs
|
||||
_NON_COMPONENT_KEYS = frozenset(
|
||||
{
|
||||
CONF_ESPHOME,
|
||||
"substitutions",
|
||||
"packages",
|
||||
"globals",
|
||||
"external_components",
|
||||
"<<",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def detect_external_components(config: ConfigType) -> set[str]:
|
||||
"""Detect external/custom components in the configuration.
|
||||
|
||||
External components are those that appear in the config but are not
|
||||
part of ESPHome's built-in components and are not special config keys.
|
||||
|
||||
Args:
|
||||
config: The ESPHome configuration dictionary
|
||||
|
||||
Returns:
|
||||
A set of external component names
|
||||
"""
|
||||
from esphome.analyze_memory.helpers import get_esphome_components
|
||||
|
||||
builtin_components = get_esphome_components()
|
||||
return {
|
||||
key
|
||||
for key in config
|
||||
if key not in builtin_components and key not in _NON_COMPONENT_KEYS
|
||||
}
|
||||
|
||||
|
||||
class ArgsProtocol(Protocol):
|
||||
device: list[str] | None
|
||||
@@ -892,6 +926,54 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
"""Analyze memory usage by component.
|
||||
|
||||
This command compiles the configuration and performs memory analysis.
|
||||
Compilation is fast if sources haven't changed (just relinking).
|
||||
"""
|
||||
from esphome import platformio_api
|
||||
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
exit_code = compile_program(args, config)
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
_LOGGER.info("Successfully compiled program.")
|
||||
|
||||
# Get idedata for analysis
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
if idedata is None:
|
||||
_LOGGER.error("Failed to get IDE data for memory analysis")
|
||||
return 1
|
||||
|
||||
firmware_elf = Path(idedata.firmware_elf_path)
|
||||
|
||||
# Extract external components from config
|
||||
external_components = detect_external_components(config)
|
||||
_LOGGER.debug("Detected external components: %s", external_components)
|
||||
|
||||
# Perform memory analysis
|
||||
_LOGGER.info("Analyzing memory usage...")
|
||||
analyzer = MemoryAnalyzerCLI(
|
||||
str(firmware_elf),
|
||||
idedata.objdump_path,
|
||||
idedata.readelf_path,
|
||||
external_components,
|
||||
)
|
||||
analyzer.analyze()
|
||||
|
||||
# Generate and display report
|
||||
report = analyzer.generate_report()
|
||||
print()
|
||||
print(report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
@@ -1007,6 +1089,7 @@ POST_CONFIG_ACTIONS = {
|
||||
"idedata": command_idedata,
|
||||
"rename": command_rename,
|
||||
"discover": command_discover,
|
||||
"analyze-memory": command_analyze_memory,
|
||||
}
|
||||
|
||||
SIMPLE_CONFIG_ACTIONS = [
|
||||
@@ -1292,6 +1375,14 @@ def parse_args(argv):
|
||||
)
|
||||
parser_rename.add_argument("name", help="The new name for the device.", type=str)
|
||||
|
||||
parser_analyze_memory = subparsers.add_parser(
|
||||
"analyze-memory",
|
||||
help="Analyze memory usage by component.",
|
||||
)
|
||||
parser_analyze_memory.add_argument(
|
||||
"configuration", help="Your YAML configuration file(s).", nargs="+"
|
||||
)
|
||||
|
||||
# Keep backward compatibility with the old command line format of
|
||||
# esphome <config> <command>.
|
||||
#
|
||||
|
||||
@@ -28,7 +28,7 @@ class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode
|
||||
void dump_config() override;
|
||||
climate::ClimateTraits traits() override {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::ClimateMode::CLIMATE_MODE_HEAT});
|
||||
traits.set_visual_min_temperature(25.0);
|
||||
traits.set_visual_max_temperature(100.0);
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace bang_bang {
|
||||
|
||||
static const char *const TAG = "bang_bang.climate";
|
||||
|
||||
BangBangClimate::BangBangClimate()
|
||||
: idle_trigger_(new Trigger<>()), cool_trigger_(new Trigger<>()), heat_trigger_(new Trigger<>()) {}
|
||||
|
||||
void BangBangClimate::setup() {
|
||||
this->sensor_->add_on_state_callback([this](float state) {
|
||||
this->current_temperature = state;
|
||||
@@ -31,53 +34,63 @@ void BangBangClimate::setup() {
|
||||
restore->to_call(this).perform();
|
||||
} else {
|
||||
// restore from defaults, change_away handles those for us
|
||||
if (supports_cool_ && supports_heat_) {
|
||||
if (this->supports_cool_ && this->supports_heat_) {
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
} else if (supports_cool_) {
|
||||
} else if (this->supports_cool_) {
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
} else if (supports_heat_) {
|
||||
} else if (this->supports_heat_) {
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
}
|
||||
this->change_away_(false);
|
||||
}
|
||||
}
|
||||
|
||||
void BangBangClimate::control(const climate::ClimateCall &call) {
|
||||
if (call.get_mode().has_value())
|
||||
if (call.get_mode().has_value()) {
|
||||
this->mode = *call.get_mode();
|
||||
if (call.get_target_temperature_low().has_value())
|
||||
}
|
||||
if (call.get_target_temperature_low().has_value()) {
|
||||
this->target_temperature_low = *call.get_target_temperature_low();
|
||||
if (call.get_target_temperature_high().has_value())
|
||||
}
|
||||
if (call.get_target_temperature_high().has_value()) {
|
||||
this->target_temperature_high = *call.get_target_temperature_high();
|
||||
if (call.get_preset().has_value())
|
||||
}
|
||||
if (call.get_preset().has_value()) {
|
||||
this->change_away_(*call.get_preset() == climate::CLIMATE_PRESET_AWAY);
|
||||
}
|
||||
|
||||
this->compute_state_();
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
climate::ClimateTraits BangBangClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
if (this->humidity_sensor_ != nullptr)
|
||||
traits.set_supports_current_humidity(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE |
|
||||
climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
|
||||
if (this->humidity_sensor_ != nullptr) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
|
||||
}
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
});
|
||||
if (supports_cool_)
|
||||
if (this->supports_cool_) {
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
|
||||
if (supports_heat_)
|
||||
}
|
||||
if (this->supports_heat_) {
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
|
||||
if (supports_cool_ && supports_heat_)
|
||||
}
|
||||
if (this->supports_cool_ && this->supports_heat_) {
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL);
|
||||
traits.set_supports_two_point_target_temperature(true);
|
||||
if (supports_away_) {
|
||||
}
|
||||
if (this->supports_away_) {
|
||||
traits.set_supported_presets({
|
||||
climate::CLIMATE_PRESET_HOME,
|
||||
climate::CLIMATE_PRESET_AWAY,
|
||||
});
|
||||
}
|
||||
traits.set_supports_action(true);
|
||||
return traits;
|
||||
}
|
||||
|
||||
void BangBangClimate::compute_state_() {
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
this->switch_to_action_(climate::CLIMATE_ACTION_OFF);
|
||||
@@ -122,6 +135,7 @@ void BangBangClimate::compute_state_() {
|
||||
|
||||
this->switch_to_action_(target_action);
|
||||
}
|
||||
|
||||
void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
|
||||
if (action == this->action) {
|
||||
// already in target mode
|
||||
@@ -166,6 +180,7 @@ void BangBangClimate::switch_to_action_(climate::ClimateAction action) {
|
||||
this->prev_trigger_ = trig;
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void BangBangClimate::change_away_(bool away) {
|
||||
if (!away) {
|
||||
this->target_temperature_low = this->normal_config_.default_temperature_low;
|
||||
@@ -176,22 +191,26 @@ void BangBangClimate::change_away_(bool away) {
|
||||
}
|
||||
this->preset = away ? climate::CLIMATE_PRESET_AWAY : climate::CLIMATE_PRESET_HOME;
|
||||
}
|
||||
|
||||
void BangBangClimate::set_normal_config(const BangBangClimateTargetTempConfig &normal_config) {
|
||||
this->normal_config_ = normal_config;
|
||||
}
|
||||
|
||||
void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &away_config) {
|
||||
this->supports_away_ = true;
|
||||
this->away_config_ = away_config;
|
||||
}
|
||||
BangBangClimate::BangBangClimate()
|
||||
: idle_trigger_(new Trigger<>()), cool_trigger_(new Trigger<>()), heat_trigger_(new Trigger<>()) {}
|
||||
|
||||
void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; }
|
||||
void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
|
||||
|
||||
Trigger<> *BangBangClimate::get_idle_trigger() const { return this->idle_trigger_; }
|
||||
Trigger<> *BangBangClimate::get_cool_trigger() const { return this->cool_trigger_; }
|
||||
void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; }
|
||||
Trigger<> *BangBangClimate::get_heat_trigger() const { return this->heat_trigger_; }
|
||||
|
||||
void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; }
|
||||
void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; }
|
||||
|
||||
void BangBangClimate::dump_config() {
|
||||
LOG_CLIMATE("", "Bang Bang Climate", this);
|
||||
ESP_LOGCONFIG(TAG,
|
||||
|
||||
@@ -25,14 +25,15 @@ class BangBangClimate : public climate::Climate, public Component {
|
||||
|
||||
void set_sensor(sensor::Sensor *sensor);
|
||||
void set_humidity_sensor(sensor::Sensor *humidity_sensor);
|
||||
Trigger<> *get_idle_trigger() const;
|
||||
Trigger<> *get_cool_trigger() const;
|
||||
void set_supports_cool(bool supports_cool);
|
||||
Trigger<> *get_heat_trigger() const;
|
||||
void set_supports_heat(bool supports_heat);
|
||||
void set_normal_config(const BangBangClimateTargetTempConfig &normal_config);
|
||||
void set_away_config(const BangBangClimateTargetTempConfig &away_config);
|
||||
|
||||
Trigger<> *get_idle_trigger() const;
|
||||
Trigger<> *get_cool_trigger() const;
|
||||
Trigger<> *get_heat_trigger() const;
|
||||
|
||||
protected:
|
||||
/// Override control to change settings of the climate device.
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
@@ -56,16 +57,10 @@ class BangBangClimate : public climate::Climate, public Component {
|
||||
*
|
||||
* In idle mode, the controller is assumed to have both heating and cooling disabled.
|
||||
*/
|
||||
Trigger<> *idle_trigger_;
|
||||
Trigger<> *idle_trigger_{nullptr};
|
||||
/** The trigger to call when the controller should switch to cooling mode.
|
||||
*/
|
||||
Trigger<> *cool_trigger_;
|
||||
/** Whether the controller supports cooling.
|
||||
*
|
||||
* A false value for this attribute means that the controller has no cooling action
|
||||
* (for example a thermostat, where only heating and not-heating is possible).
|
||||
*/
|
||||
bool supports_cool_{false};
|
||||
Trigger<> *cool_trigger_{nullptr};
|
||||
/** The trigger to call when the controller should switch to heating mode.
|
||||
*
|
||||
* A null value for this attribute means that the controller has no heating action
|
||||
@@ -73,15 +68,23 @@ class BangBangClimate : public climate::Climate, public Component {
|
||||
* (blinds open) is possible.
|
||||
*/
|
||||
Trigger<> *heat_trigger_{nullptr};
|
||||
bool supports_heat_{false};
|
||||
/** A reference to the trigger that was previously active.
|
||||
*
|
||||
* This is so that the previous trigger can be stopped before enabling a new one.
|
||||
*/
|
||||
Trigger<> *prev_trigger_{nullptr};
|
||||
|
||||
BangBangClimateTargetTempConfig normal_config_{};
|
||||
/** Whether the controller supports cooling/heating
|
||||
*
|
||||
* A false value for this attribute means that the controller has no respective action
|
||||
* (for example a thermostat, where only heating and not-heating is possible).
|
||||
*/
|
||||
bool supports_cool_{false};
|
||||
bool supports_heat_{false};
|
||||
|
||||
bool supports_away_{false};
|
||||
|
||||
BangBangClimateTargetTempConfig normal_config_{};
|
||||
BangBangClimateTargetTempConfig away_config_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -33,8 +33,7 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli
|
||||
|
||||
climate::ClimateTraits traits() override {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_action(true);
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION | climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
climate::CLIMATE_MODE_HEAT,
|
||||
|
||||
@@ -77,6 +77,9 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
}
|
||||
} else {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
// For non-notify characteristics, trigger an immediate read after service discovery
|
||||
// to avoid peripherals disconnecting due to inactivity
|
||||
this->update();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
}
|
||||
} else {
|
||||
this->node_state = espbt::ClientState::ESTABLISHED;
|
||||
// For non-notify characteristics, trigger an immediate read after service discovery
|
||||
// to avoid peripherals disconnecting due to inactivity
|
||||
this->update();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,42 @@ namespace climate {
|
||||
|
||||
static const char *const TAG = "climate";
|
||||
|
||||
// Memory-efficient lookup tables
|
||||
struct StringToUint8 {
|
||||
const char *str;
|
||||
const uint8_t value;
|
||||
};
|
||||
|
||||
constexpr StringToUint8 CLIMATE_MODES_BY_STR[] = {
|
||||
{"OFF", CLIMATE_MODE_OFF},
|
||||
{"AUTO", CLIMATE_MODE_AUTO},
|
||||
{"COOL", CLIMATE_MODE_COOL},
|
||||
{"HEAT", CLIMATE_MODE_HEAT},
|
||||
{"FAN_ONLY", CLIMATE_MODE_FAN_ONLY},
|
||||
{"DRY", CLIMATE_MODE_DRY},
|
||||
{"HEAT_COOL", CLIMATE_MODE_HEAT_COOL},
|
||||
};
|
||||
|
||||
constexpr StringToUint8 CLIMATE_FAN_MODES_BY_STR[] = {
|
||||
{"ON", CLIMATE_FAN_ON}, {"OFF", CLIMATE_FAN_OFF}, {"AUTO", CLIMATE_FAN_AUTO},
|
||||
{"LOW", CLIMATE_FAN_LOW}, {"MEDIUM", CLIMATE_FAN_MEDIUM}, {"HIGH", CLIMATE_FAN_HIGH},
|
||||
{"MIDDLE", CLIMATE_FAN_MIDDLE}, {"FOCUS", CLIMATE_FAN_FOCUS}, {"DIFFUSE", CLIMATE_FAN_DIFFUSE},
|
||||
{"QUIET", CLIMATE_FAN_QUIET},
|
||||
};
|
||||
|
||||
constexpr StringToUint8 CLIMATE_PRESETS_BY_STR[] = {
|
||||
{"ECO", CLIMATE_PRESET_ECO}, {"AWAY", CLIMATE_PRESET_AWAY}, {"BOOST", CLIMATE_PRESET_BOOST},
|
||||
{"COMFORT", CLIMATE_PRESET_COMFORT}, {"HOME", CLIMATE_PRESET_HOME}, {"SLEEP", CLIMATE_PRESET_SLEEP},
|
||||
{"ACTIVITY", CLIMATE_PRESET_ACTIVITY}, {"NONE", CLIMATE_PRESET_NONE},
|
||||
};
|
||||
|
||||
constexpr StringToUint8 CLIMATE_SWING_MODES_BY_STR[] = {
|
||||
{"OFF", CLIMATE_SWING_OFF},
|
||||
{"BOTH", CLIMATE_SWING_BOTH},
|
||||
{"VERTICAL", CLIMATE_SWING_VERTICAL},
|
||||
{"HORIZONTAL", CLIMATE_SWING_HORIZONTAL},
|
||||
};
|
||||
|
||||
void ClimateCall::perform() {
|
||||
this->parent_->control_callback_.call(*this);
|
||||
ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str());
|
||||
@@ -50,47 +86,46 @@ void ClimateCall::perform() {
|
||||
}
|
||||
this->parent_->control(*this);
|
||||
}
|
||||
|
||||
void ClimateCall::validate_() {
|
||||
auto traits = this->parent_->get_traits();
|
||||
if (this->mode_.has_value()) {
|
||||
auto mode = *this->mode_;
|
||||
if (!traits.supports_mode(mode)) {
|
||||
ESP_LOGW(TAG, " Mode %s is not supported by this device!", LOG_STR_ARG(climate_mode_to_string(mode)));
|
||||
ESP_LOGW(TAG, " Mode %s not supported", LOG_STR_ARG(climate_mode_to_string(mode)));
|
||||
this->mode_.reset();
|
||||
}
|
||||
}
|
||||
if (this->custom_fan_mode_.has_value()) {
|
||||
auto custom_fan_mode = *this->custom_fan_mode_;
|
||||
if (!traits.supports_custom_fan_mode(custom_fan_mode)) {
|
||||
ESP_LOGW(TAG, " Fan Mode %s is not supported by this device!", custom_fan_mode.c_str());
|
||||
ESP_LOGW(TAG, " Fan Mode %s not supported", custom_fan_mode.c_str());
|
||||
this->custom_fan_mode_.reset();
|
||||
}
|
||||
} else if (this->fan_mode_.has_value()) {
|
||||
auto fan_mode = *this->fan_mode_;
|
||||
if (!traits.supports_fan_mode(fan_mode)) {
|
||||
ESP_LOGW(TAG, " Fan Mode %s is not supported by this device!",
|
||||
LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
|
||||
ESP_LOGW(TAG, " Fan Mode %s not supported", LOG_STR_ARG(climate_fan_mode_to_string(fan_mode)));
|
||||
this->fan_mode_.reset();
|
||||
}
|
||||
}
|
||||
if (this->custom_preset_.has_value()) {
|
||||
auto custom_preset = *this->custom_preset_;
|
||||
if (!traits.supports_custom_preset(custom_preset)) {
|
||||
ESP_LOGW(TAG, " Preset %s is not supported by this device!", custom_preset.c_str());
|
||||
ESP_LOGW(TAG, " Preset %s not supported", custom_preset.c_str());
|
||||
this->custom_preset_.reset();
|
||||
}
|
||||
} else if (this->preset_.has_value()) {
|
||||
auto preset = *this->preset_;
|
||||
if (!traits.supports_preset(preset)) {
|
||||
ESP_LOGW(TAG, " Preset %s is not supported by this device!", LOG_STR_ARG(climate_preset_to_string(preset)));
|
||||
ESP_LOGW(TAG, " Preset %s not supported", LOG_STR_ARG(climate_preset_to_string(preset)));
|
||||
this->preset_.reset();
|
||||
}
|
||||
}
|
||||
if (this->swing_mode_.has_value()) {
|
||||
auto swing_mode = *this->swing_mode_;
|
||||
if (!traits.supports_swing_mode(swing_mode)) {
|
||||
ESP_LOGW(TAG, " Swing Mode %s is not supported by this device!",
|
||||
LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
|
||||
ESP_LOGW(TAG, " Swing Mode %s not supported", LOG_STR_ARG(climate_swing_mode_to_string(swing_mode)));
|
||||
this->swing_mode_.reset();
|
||||
}
|
||||
}
|
||||
@@ -99,159 +134,127 @@ void ClimateCall::validate_() {
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
|
||||
ESP_LOGW(TAG, " Cannot set target temperature for climate device "
|
||||
"with two-point target temperature!");
|
||||
"with two-point target temperature");
|
||||
this->target_temperature_.reset();
|
||||
} else if (std::isnan(target)) {
|
||||
ESP_LOGW(TAG, " Target temperature must not be NAN!");
|
||||
ESP_LOGW(TAG, " Target temperature must not be NAN");
|
||||
this->target_temperature_.reset();
|
||||
}
|
||||
}
|
||||
if (this->target_temperature_low_.has_value() || this->target_temperature_high_.has_value()) {
|
||||
if (!traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
|
||||
ESP_LOGW(TAG, " Cannot set low/high target temperature for this device!");
|
||||
ESP_LOGW(TAG, " Cannot set low/high target temperature");
|
||||
this->target_temperature_low_.reset();
|
||||
this->target_temperature_high_.reset();
|
||||
}
|
||||
}
|
||||
if (this->target_temperature_low_.has_value() && std::isnan(*this->target_temperature_low_)) {
|
||||
ESP_LOGW(TAG, " Target temperature low must not be NAN!");
|
||||
ESP_LOGW(TAG, " Target temperature low must not be NAN");
|
||||
this->target_temperature_low_.reset();
|
||||
}
|
||||
if (this->target_temperature_high_.has_value() && std::isnan(*this->target_temperature_high_)) {
|
||||
ESP_LOGW(TAG, " Target temperature low must not be NAN!");
|
||||
ESP_LOGW(TAG, " Target temperature high must not be NAN");
|
||||
this->target_temperature_high_.reset();
|
||||
}
|
||||
if (this->target_temperature_low_.has_value() && this->target_temperature_high_.has_value()) {
|
||||
float low = *this->target_temperature_low_;
|
||||
float high = *this->target_temperature_high_;
|
||||
if (low > high) {
|
||||
ESP_LOGW(TAG, " Target temperature low %.2f must be smaller than target temperature high %.2f!", low, high);
|
||||
ESP_LOGW(TAG, " Target temperature low %.2f must be less than target temperature high %.2f", low, high);
|
||||
this->target_temperature_low_.reset();
|
||||
this->target_temperature_high_.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_mode(ClimateMode mode) {
|
||||
this->mode_ = mode;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_mode(const std::string &mode) {
|
||||
if (str_equals_case_insensitive(mode, "OFF")) {
|
||||
this->set_mode(CLIMATE_MODE_OFF);
|
||||
} else if (str_equals_case_insensitive(mode, "AUTO")) {
|
||||
this->set_mode(CLIMATE_MODE_AUTO);
|
||||
} else if (str_equals_case_insensitive(mode, "COOL")) {
|
||||
this->set_mode(CLIMATE_MODE_COOL);
|
||||
} else if (str_equals_case_insensitive(mode, "HEAT")) {
|
||||
this->set_mode(CLIMATE_MODE_HEAT);
|
||||
} else if (str_equals_case_insensitive(mode, "FAN_ONLY")) {
|
||||
this->set_mode(CLIMATE_MODE_FAN_ONLY);
|
||||
} else if (str_equals_case_insensitive(mode, "DRY")) {
|
||||
this->set_mode(CLIMATE_MODE_DRY);
|
||||
} else if (str_equals_case_insensitive(mode, "HEAT_COOL")) {
|
||||
this->set_mode(CLIMATE_MODE_HEAT_COOL);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str());
|
||||
for (const auto &mode_entry : CLIMATE_MODES_BY_STR) {
|
||||
if (str_equals_case_insensitive(mode, mode_entry.str)) {
|
||||
this->set_mode(static_cast<ClimateMode>(mode_entry.value));
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized mode %s", this->parent_->get_name().c_str(), mode.c_str());
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_fan_mode(ClimateFanMode fan_mode) {
|
||||
this->fan_mode_ = fan_mode;
|
||||
this->custom_fan_mode_.reset();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_fan_mode(const std::string &fan_mode) {
|
||||
if (str_equals_case_insensitive(fan_mode, "ON")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_ON);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "OFF")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_OFF);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "AUTO")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_AUTO);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "LOW")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_LOW);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "MEDIUM")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_MEDIUM);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "HIGH")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_HIGH);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "MIDDLE")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_MIDDLE);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "FOCUS")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_FOCUS);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "DIFFUSE")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_DIFFUSE);
|
||||
} else if (str_equals_case_insensitive(fan_mode, "QUIET")) {
|
||||
this->set_fan_mode(CLIMATE_FAN_QUIET);
|
||||
} else {
|
||||
if (this->parent_->get_traits().supports_custom_fan_mode(fan_mode)) {
|
||||
this->custom_fan_mode_ = fan_mode;
|
||||
this->fan_mode_.reset();
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), fan_mode.c_str());
|
||||
for (const auto &mode_entry : CLIMATE_FAN_MODES_BY_STR) {
|
||||
if (str_equals_case_insensitive(fan_mode, mode_entry.str)) {
|
||||
this->set_fan_mode(static_cast<ClimateFanMode>(mode_entry.value));
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
if (this->parent_->get_traits().supports_custom_fan_mode(fan_mode)) {
|
||||
this->custom_fan_mode_ = fan_mode;
|
||||
this->fan_mode_.reset();
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized fan mode %s", this->parent_->get_name().c_str(), fan_mode.c_str());
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_fan_mode(optional<std::string> fan_mode) {
|
||||
if (fan_mode.has_value()) {
|
||||
this->set_fan_mode(fan_mode.value());
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_preset(ClimatePreset preset) {
|
||||
this->preset_ = preset;
|
||||
this->custom_preset_.reset();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_preset(const std::string &preset) {
|
||||
if (str_equals_case_insensitive(preset, "ECO")) {
|
||||
this->set_preset(CLIMATE_PRESET_ECO);
|
||||
} else if (str_equals_case_insensitive(preset, "AWAY")) {
|
||||
this->set_preset(CLIMATE_PRESET_AWAY);
|
||||
} else if (str_equals_case_insensitive(preset, "BOOST")) {
|
||||
this->set_preset(CLIMATE_PRESET_BOOST);
|
||||
} else if (str_equals_case_insensitive(preset, "COMFORT")) {
|
||||
this->set_preset(CLIMATE_PRESET_COMFORT);
|
||||
} else if (str_equals_case_insensitive(preset, "HOME")) {
|
||||
this->set_preset(CLIMATE_PRESET_HOME);
|
||||
} else if (str_equals_case_insensitive(preset, "SLEEP")) {
|
||||
this->set_preset(CLIMATE_PRESET_SLEEP);
|
||||
} else if (str_equals_case_insensitive(preset, "ACTIVITY")) {
|
||||
this->set_preset(CLIMATE_PRESET_ACTIVITY);
|
||||
} else if (str_equals_case_insensitive(preset, "NONE")) {
|
||||
this->set_preset(CLIMATE_PRESET_NONE);
|
||||
} else {
|
||||
if (this->parent_->get_traits().supports_custom_preset(preset)) {
|
||||
this->custom_preset_ = preset;
|
||||
this->preset_.reset();
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), preset.c_str());
|
||||
for (const auto &preset_entry : CLIMATE_PRESETS_BY_STR) {
|
||||
if (str_equals_case_insensitive(preset, preset_entry.str)) {
|
||||
this->set_preset(static_cast<ClimatePreset>(preset_entry.value));
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
if (this->parent_->get_traits().supports_custom_preset(preset)) {
|
||||
this->custom_preset_ = preset;
|
||||
this->preset_.reset();
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized preset %s", this->parent_->get_name().c_str(), preset.c_str());
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_preset(optional<std::string> preset) {
|
||||
if (preset.has_value()) {
|
||||
this->set_preset(preset.value());
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_swing_mode(ClimateSwingMode swing_mode) {
|
||||
this->swing_mode_ = swing_mode;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_swing_mode(const std::string &swing_mode) {
|
||||
if (str_equals_case_insensitive(swing_mode, "OFF")) {
|
||||
this->set_swing_mode(CLIMATE_SWING_OFF);
|
||||
} else if (str_equals_case_insensitive(swing_mode, "BOTH")) {
|
||||
this->set_swing_mode(CLIMATE_SWING_BOTH);
|
||||
} else if (str_equals_case_insensitive(swing_mode, "VERTICAL")) {
|
||||
this->set_swing_mode(CLIMATE_SWING_VERTICAL);
|
||||
} else if (str_equals_case_insensitive(swing_mode, "HORIZONTAL")) {
|
||||
this->set_swing_mode(CLIMATE_SWING_HORIZONTAL);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %s", this->parent_->get_name().c_str(), swing_mode.c_str());
|
||||
for (const auto &mode_entry : CLIMATE_SWING_MODES_BY_STR) {
|
||||
if (str_equals_case_insensitive(swing_mode, mode_entry.str)) {
|
||||
this->set_swing_mode(static_cast<ClimateSwingMode>(mode_entry.value));
|
||||
return *this;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "'%s' - Unrecognized swing mode %s", this->parent_->get_name().c_str(), swing_mode.c_str());
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -259,59 +262,71 @@ ClimateCall &ClimateCall::set_target_temperature(float target_temperature) {
|
||||
this->target_temperature_ = target_temperature;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_temperature_low(float target_temperature_low) {
|
||||
this->target_temperature_low_ = target_temperature_low;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_temperature_high(float target_temperature_high) {
|
||||
this->target_temperature_high_ = target_temperature_high;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_humidity(float target_humidity) {
|
||||
this->target_humidity_ = target_humidity;
|
||||
return *this;
|
||||
}
|
||||
|
||||
const optional<ClimateMode> &ClimateCall::get_mode() const { return this->mode_; }
|
||||
const optional<float> &ClimateCall::get_target_temperature() const { return this->target_temperature_; }
|
||||
const optional<float> &ClimateCall::get_target_temperature_low() const { return this->target_temperature_low_; }
|
||||
const optional<float> &ClimateCall::get_target_temperature_high() const { return this->target_temperature_high_; }
|
||||
const optional<float> &ClimateCall::get_target_humidity() const { return this->target_humidity_; }
|
||||
|
||||
const optional<ClimateMode> &ClimateCall::get_mode() const { return this->mode_; }
|
||||
const optional<ClimateFanMode> &ClimateCall::get_fan_mode() const { return this->fan_mode_; }
|
||||
const optional<std::string> &ClimateCall::get_custom_fan_mode() const { return this->custom_fan_mode_; }
|
||||
const optional<ClimatePreset> &ClimateCall::get_preset() const { return this->preset_; }
|
||||
const optional<std::string> &ClimateCall::get_custom_preset() const { return this->custom_preset_; }
|
||||
const optional<ClimateSwingMode> &ClimateCall::get_swing_mode() const { return this->swing_mode_; }
|
||||
const optional<ClimatePreset> &ClimateCall::get_preset() const { return this->preset_; }
|
||||
const optional<std::string> &ClimateCall::get_custom_fan_mode() const { return this->custom_fan_mode_; }
|
||||
const optional<std::string> &ClimateCall::get_custom_preset() const { return this->custom_preset_; }
|
||||
|
||||
ClimateCall &ClimateCall::set_target_temperature_high(optional<float> target_temperature_high) {
|
||||
this->target_temperature_high_ = target_temperature_high;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_temperature_low(optional<float> target_temperature_low) {
|
||||
this->target_temperature_low_ = target_temperature_low;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_temperature(optional<float> target_temperature) {
|
||||
this->target_temperature_ = target_temperature;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_target_humidity(optional<float> target_humidity) {
|
||||
this->target_humidity_ = target_humidity;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_mode(optional<ClimateMode> mode) {
|
||||
this->mode_ = mode;
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_fan_mode(optional<ClimateFanMode> fan_mode) {
|
||||
this->fan_mode_ = fan_mode;
|
||||
this->custom_fan_mode_.reset();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_preset(optional<ClimatePreset> preset) {
|
||||
this->preset_ = preset;
|
||||
this->custom_preset_.reset();
|
||||
return *this;
|
||||
}
|
||||
|
||||
ClimateCall &ClimateCall::set_swing_mode(optional<ClimateSwingMode> swing_mode) {
|
||||
this->swing_mode_ = swing_mode;
|
||||
return *this;
|
||||
@@ -336,6 +351,7 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
|
||||
return {};
|
||||
return recovered;
|
||||
}
|
||||
|
||||
void Climate::save_state_() {
|
||||
#if (defined(USE_ESP_IDF) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \
|
||||
!defined(CLANG_TIDY)
|
||||
@@ -398,6 +414,7 @@ void Climate::save_state_() {
|
||||
|
||||
this->rtc_.save(&state);
|
||||
}
|
||||
|
||||
void Climate::publish_state() {
|
||||
ESP_LOGD(TAG, "'%s' - Sending state:", this->name_.c_str());
|
||||
auto traits = this->get_traits();
|
||||
@@ -469,16 +486,20 @@ ClimateTraits Climate::get_traits() {
|
||||
void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) {
|
||||
this->visual_min_temperature_override_ = visual_min_temperature_override;
|
||||
}
|
||||
|
||||
void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) {
|
||||
this->visual_max_temperature_override_ = visual_max_temperature_override;
|
||||
}
|
||||
|
||||
void Climate::set_visual_temperature_step_override(float target, float current) {
|
||||
this->visual_target_temperature_step_override_ = target;
|
||||
this->visual_current_temperature_step_override_ = current;
|
||||
}
|
||||
|
||||
void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) {
|
||||
this->visual_min_humidity_override_ = visual_min_humidity_override;
|
||||
}
|
||||
|
||||
void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) {
|
||||
this->visual_max_humidity_override_ = visual_max_humidity_override;
|
||||
}
|
||||
@@ -510,6 +531,7 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
void ClimateDeviceRestoreState::apply(Climate *climate) {
|
||||
auto traits = climate->get_traits();
|
||||
climate->mode = this->mode;
|
||||
@@ -579,68 +601,68 @@ void Climate::dump_traits_(const char *tag) {
|
||||
auto traits = this->get_traits();
|
||||
ESP_LOGCONFIG(tag, "ClimateTraits:");
|
||||
ESP_LOGCONFIG(tag,
|
||||
" [x] Visual settings:\n"
|
||||
" - Min temperature: %.1f\n"
|
||||
" - Max temperature: %.1f\n"
|
||||
" - Temperature step:\n"
|
||||
" Target: %.1f",
|
||||
" Visual settings:\n"
|
||||
" - Min temperature: %.1f\n"
|
||||
" - Max temperature: %.1f\n"
|
||||
" - Temperature step:\n"
|
||||
" Target: %.1f",
|
||||
traits.get_visual_min_temperature(), traits.get_visual_max_temperature(),
|
||||
traits.get_visual_target_temperature_step());
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
|
||||
ESP_LOGCONFIG(tag, " Current: %.1f", traits.get_visual_current_temperature_step());
|
||||
ESP_LOGCONFIG(tag, " Current: %.1f", traits.get_visual_current_temperature_step());
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY |
|
||||
climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
|
||||
ESP_LOGCONFIG(tag,
|
||||
" - Min humidity: %.0f\n"
|
||||
" - Max humidity: %.0f",
|
||||
" - Min humidity: %.0f\n"
|
||||
" - Max humidity: %.0f",
|
||||
traits.get_visual_min_humidity(), traits.get_visual_max_humidity());
|
||||
}
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supports two-point target temperature");
|
||||
ESP_LOGCONFIG(tag, " Supports two-point target temperature");
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supports current temperature");
|
||||
ESP_LOGCONFIG(tag, " Supports current temperature");
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supports target humidity");
|
||||
ESP_LOGCONFIG(tag, " Supports target humidity");
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supports current humidity");
|
||||
ESP_LOGCONFIG(tag, " Supports current humidity");
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supports action");
|
||||
ESP_LOGCONFIG(tag, " Supports action");
|
||||
}
|
||||
if (!traits.get_supported_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported modes:");
|
||||
ESP_LOGCONFIG(tag, " Supported modes:");
|
||||
for (ClimateMode m : traits.get_supported_modes())
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m)));
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m)));
|
||||
}
|
||||
if (!traits.get_supported_fan_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported fan modes:");
|
||||
ESP_LOGCONFIG(tag, " Supported fan modes:");
|
||||
for (ClimateFanMode m : traits.get_supported_fan_modes())
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m)));
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m)));
|
||||
}
|
||||
if (!traits.get_supported_custom_fan_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported custom fan modes:");
|
||||
ESP_LOGCONFIG(tag, " Supported custom fan modes:");
|
||||
for (const std::string &s : traits.get_supported_custom_fan_modes())
|
||||
ESP_LOGCONFIG(tag, " - %s", s.c_str());
|
||||
ESP_LOGCONFIG(tag, " - %s", s.c_str());
|
||||
}
|
||||
if (!traits.get_supported_presets().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported presets:");
|
||||
ESP_LOGCONFIG(tag, " Supported presets:");
|
||||
for (ClimatePreset p : traits.get_supported_presets())
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p)));
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p)));
|
||||
}
|
||||
if (!traits.get_supported_custom_presets().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported custom presets:");
|
||||
ESP_LOGCONFIG(tag, " Supported custom presets:");
|
||||
for (const std::string &s : traits.get_supported_custom_presets())
|
||||
ESP_LOGCONFIG(tag, " - %s", s.c_str());
|
||||
ESP_LOGCONFIG(tag, " - %s", s.c_str());
|
||||
}
|
||||
if (!traits.get_supported_swing_modes().empty()) {
|
||||
ESP_LOGCONFIG(tag, " [x] Supported swing modes:");
|
||||
ESP_LOGCONFIG(tag, " Supported swing modes:");
|
||||
for (ClimateSwingMode m : traits.get_supported_swing_modes())
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m)));
|
||||
ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,30 +93,31 @@ class ClimateCall {
|
||||
|
||||
void perform();
|
||||
|
||||
const optional<ClimateMode> &get_mode() const;
|
||||
const optional<float> &get_target_temperature() const;
|
||||
const optional<float> &get_target_temperature_low() const;
|
||||
const optional<float> &get_target_temperature_high() const;
|
||||
const optional<float> &get_target_humidity() const;
|
||||
|
||||
const optional<ClimateMode> &get_mode() const;
|
||||
const optional<ClimateFanMode> &get_fan_mode() const;
|
||||
const optional<ClimateSwingMode> &get_swing_mode() const;
|
||||
const optional<std::string> &get_custom_fan_mode() const;
|
||||
const optional<ClimatePreset> &get_preset() const;
|
||||
const optional<std::string> &get_custom_fan_mode() const;
|
||||
const optional<std::string> &get_custom_preset() const;
|
||||
|
||||
protected:
|
||||
void validate_();
|
||||
|
||||
Climate *const parent_;
|
||||
optional<ClimateMode> mode_;
|
||||
optional<float> target_temperature_;
|
||||
optional<float> target_temperature_low_;
|
||||
optional<float> target_temperature_high_;
|
||||
optional<float> target_humidity_;
|
||||
optional<ClimateMode> mode_;
|
||||
optional<ClimateFanMode> fan_mode_;
|
||||
optional<ClimateSwingMode> swing_mode_;
|
||||
optional<std::string> custom_fan_mode_;
|
||||
optional<ClimatePreset> preset_;
|
||||
optional<std::string> custom_fan_mode_;
|
||||
optional<std::string> custom_preset_;
|
||||
};
|
||||
|
||||
@@ -169,47 +170,6 @@ class Climate : public EntityBase {
|
||||
public:
|
||||
Climate() {}
|
||||
|
||||
/// The active mode of the climate device.
|
||||
ClimateMode mode{CLIMATE_MODE_OFF};
|
||||
|
||||
/// The active state of the climate device.
|
||||
ClimateAction action{CLIMATE_ACTION_OFF};
|
||||
|
||||
/// The current temperature of the climate device, as reported from the integration.
|
||||
float current_temperature{NAN};
|
||||
|
||||
/// The current humidity of the climate device, as reported from the integration.
|
||||
float current_humidity{NAN};
|
||||
|
||||
union {
|
||||
/// The target temperature of the climate device.
|
||||
float target_temperature;
|
||||
struct {
|
||||
/// The minimum target temperature of the climate device, for climate devices with split target temperature.
|
||||
float target_temperature_low{NAN};
|
||||
/// The maximum target temperature of the climate device, for climate devices with split target temperature.
|
||||
float target_temperature_high{NAN};
|
||||
};
|
||||
};
|
||||
|
||||
/// The target humidity of the climate device.
|
||||
float target_humidity;
|
||||
|
||||
/// The active fan mode of the climate device.
|
||||
optional<ClimateFanMode> fan_mode;
|
||||
|
||||
/// The active swing mode of the climate device.
|
||||
ClimateSwingMode swing_mode;
|
||||
|
||||
/// The active custom fan mode of the climate device.
|
||||
optional<std::string> custom_fan_mode;
|
||||
|
||||
/// The active preset of the climate device.
|
||||
optional<ClimatePreset> preset;
|
||||
|
||||
/// The active custom preset mode of the climate device.
|
||||
optional<std::string> custom_preset;
|
||||
|
||||
/** Add a callback for the climate device state, each time the state of the climate device is updated
|
||||
* (using publish_state), this callback will be called.
|
||||
*
|
||||
@@ -251,6 +211,47 @@ class Climate : public EntityBase {
|
||||
void set_visual_min_humidity_override(float visual_min_humidity_override);
|
||||
void set_visual_max_humidity_override(float visual_max_humidity_override);
|
||||
|
||||
/// The current temperature of the climate device, as reported from the integration.
|
||||
float current_temperature{NAN};
|
||||
|
||||
/// The current humidity of the climate device, as reported from the integration.
|
||||
float current_humidity{NAN};
|
||||
|
||||
union {
|
||||
/// The target temperature of the climate device.
|
||||
float target_temperature;
|
||||
struct {
|
||||
/// The minimum target temperature of the climate device, for climate devices with split target temperature.
|
||||
float target_temperature_low{NAN};
|
||||
/// The maximum target temperature of the climate device, for climate devices with split target temperature.
|
||||
float target_temperature_high{NAN};
|
||||
};
|
||||
};
|
||||
|
||||
/// The target humidity of the climate device.
|
||||
float target_humidity;
|
||||
|
||||
/// The active fan mode of the climate device.
|
||||
optional<ClimateFanMode> fan_mode;
|
||||
|
||||
/// The active preset of the climate device.
|
||||
optional<ClimatePreset> preset;
|
||||
|
||||
/// The active custom fan mode of the climate device.
|
||||
optional<std::string> custom_fan_mode;
|
||||
|
||||
/// The active custom preset mode of the climate device.
|
||||
optional<std::string> custom_preset;
|
||||
|
||||
/// The active mode of the climate device.
|
||||
ClimateMode mode{CLIMATE_MODE_OFF};
|
||||
|
||||
/// The active state of the climate device.
|
||||
ClimateAction action{CLIMATE_ACTION_OFF};
|
||||
|
||||
/// The active swing mode of the climate device.
|
||||
ClimateSwingMode swing_mode{CLIMATE_SWING_OFF};
|
||||
|
||||
protected:
|
||||
friend ClimateCall;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "climate_mode.h"
|
||||
#include <set>
|
||||
#include "climate_mode.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -109,44 +109,12 @@ class ClimateTraits {
|
||||
|
||||
void set_supported_modes(std::set<ClimateMode> modes) { this->supported_modes_ = std::move(modes); }
|
||||
void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_auto_mode(bool supports_auto_mode) { set_mode_support_(CLIMATE_MODE_AUTO, supports_auto_mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_cool_mode(bool supports_cool_mode) { set_mode_support_(CLIMATE_MODE_COOL, supports_cool_mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_heat_mode(bool supports_heat_mode) { set_mode_support_(CLIMATE_MODE_HEAT, supports_heat_mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_heat_cool_mode(bool supported) { set_mode_support_(CLIMATE_MODE_HEAT_COOL, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_fan_only_mode(bool supports_fan_only_mode) {
|
||||
set_mode_support_(CLIMATE_MODE_FAN_ONLY, supports_fan_only_mode);
|
||||
}
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20")
|
||||
void set_supports_dry_mode(bool supports_dry_mode) { set_mode_support_(CLIMATE_MODE_DRY, supports_dry_mode); }
|
||||
bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); }
|
||||
const std::set<ClimateMode> &get_supported_modes() const { return this->supported_modes_; }
|
||||
|
||||
void set_supported_fan_modes(std::set<ClimateFanMode> modes) { this->supported_fan_modes_ = std::move(modes); }
|
||||
void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); }
|
||||
void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.insert(mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_on(bool supported) { set_fan_mode_support_(CLIMATE_FAN_ON, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_off(bool supported) { set_fan_mode_support_(CLIMATE_FAN_OFF, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_auto(bool supported) { set_fan_mode_support_(CLIMATE_FAN_AUTO, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_low(bool supported) { set_fan_mode_support_(CLIMATE_FAN_LOW, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_medium(bool supported) { set_fan_mode_support_(CLIMATE_FAN_MEDIUM, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_high(bool supported) { set_fan_mode_support_(CLIMATE_FAN_HIGH, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_middle(bool supported) { set_fan_mode_support_(CLIMATE_FAN_MIDDLE, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_focus(bool supported) { set_fan_mode_support_(CLIMATE_FAN_FOCUS, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_fan_modes() instead", "v1.20")
|
||||
void set_supports_fan_mode_diffuse(bool supported) { set_fan_mode_support_(CLIMATE_FAN_DIFFUSE, supported); }
|
||||
bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); }
|
||||
bool get_supports_fan_modes() const {
|
||||
return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty();
|
||||
@@ -178,16 +146,6 @@ class ClimateTraits {
|
||||
|
||||
void set_supported_swing_modes(std::set<ClimateSwingMode> modes) { this->supported_swing_modes_ = std::move(modes); }
|
||||
void add_supported_swing_mode(ClimateSwingMode mode) { this->supported_swing_modes_.insert(mode); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
|
||||
void set_supports_swing_mode_off(bool supported) { set_swing_mode_support_(CLIMATE_SWING_OFF, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
|
||||
void set_supports_swing_mode_both(bool supported) { set_swing_mode_support_(CLIMATE_SWING_BOTH, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
|
||||
void set_supports_swing_mode_vertical(bool supported) { set_swing_mode_support_(CLIMATE_SWING_VERTICAL, supported); }
|
||||
ESPDEPRECATED("This method is deprecated, use set_supported_swing_modes() instead", "v1.20")
|
||||
void set_supports_swing_mode_horizontal(bool supported) {
|
||||
set_swing_mode_support_(CLIMATE_SWING_HORIZONTAL, supported);
|
||||
}
|
||||
bool supports_swing_mode(ClimateSwingMode swing_mode) const { return this->supported_swing_modes_.count(swing_mode); }
|
||||
bool get_supports_swing_modes() const { return !this->supported_swing_modes_.empty(); }
|
||||
const std::set<ClimateSwingMode> &get_supported_swing_modes() const { return this->supported_swing_modes_; }
|
||||
|
||||
@@ -8,7 +8,10 @@ static const char *const TAG = "climate_ir";
|
||||
|
||||
climate::ClimateTraits ClimateIR::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(this->sensor_ != nullptr);
|
||||
if (this->sensor_ != nullptr) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
|
||||
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL});
|
||||
if (this->supports_cool_)
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_COOL);
|
||||
@@ -19,7 +22,6 @@ climate::ClimateTraits ClimateIR::traits() {
|
||||
if (this->supports_fan_only_)
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_FAN_ONLY);
|
||||
|
||||
traits.set_supports_two_point_target_temperature(false);
|
||||
traits.set_visual_min_temperature(this->minimum_temperature_);
|
||||
traits.set_visual_max_temperature(this->maximum_temperature_);
|
||||
traits.set_visual_temperature_step(this->temperature_step_);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "cover.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <strings.h>
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace cover {
|
||||
@@ -144,21 +144,7 @@ CoverCall &CoverCall::set_stop(bool stop) {
|
||||
bool CoverCall::get_stop() const { return this->stop_; }
|
||||
|
||||
CoverCall Cover::make_call() { return {this}; }
|
||||
void Cover::open() {
|
||||
auto call = this->make_call();
|
||||
call.set_command_open();
|
||||
call.perform();
|
||||
}
|
||||
void Cover::close() {
|
||||
auto call = this->make_call();
|
||||
call.set_command_close();
|
||||
call.perform();
|
||||
}
|
||||
void Cover::stop() {
|
||||
auto call = this->make_call();
|
||||
call.set_command_stop();
|
||||
call.perform();
|
||||
}
|
||||
|
||||
void Cover::add_on_state_callback(std::function<void()> &&f) { this->state_callback_.add(std::move(f)); }
|
||||
void Cover::publish_state(bool save) {
|
||||
this->position = clamp(this->position, 0.0f, 1.0f);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/preferences.h"
|
||||
|
||||
#include "cover_traits.h"
|
||||
|
||||
namespace esphome {
|
||||
@@ -125,25 +126,6 @@ class Cover : public EntityBase, public EntityBase_DeviceClass {
|
||||
|
||||
/// Construct a new cover call used to control the cover.
|
||||
CoverCall make_call();
|
||||
/** Open the cover.
|
||||
*
|
||||
* This is a legacy method and may be removed later, please use `.make_call()` instead.
|
||||
*/
|
||||
ESPDEPRECATED("open() is deprecated, use make_call().set_command_open().perform() instead.", "2021.9")
|
||||
void open();
|
||||
/** Close the cover.
|
||||
*
|
||||
* This is a legacy method and may be removed later, please use `.make_call()` instead.
|
||||
*/
|
||||
ESPDEPRECATED("close() is deprecated, use make_call().set_command_close().perform() instead.", "2021.9")
|
||||
void close();
|
||||
/** Stop the cover.
|
||||
*
|
||||
* This is a legacy method and may be removed later, please use `.make_call()` instead.
|
||||
* As per solution from issue #2885 the call should include perform()
|
||||
*/
|
||||
ESPDEPRECATED("stop() is deprecated, use make_call().set_command_stop().perform() instead.", "2021.9")
|
||||
void stop();
|
||||
|
||||
void add_on_state_callback(std::function<void()> &&f);
|
||||
|
||||
|
||||
@@ -241,9 +241,7 @@ uint8_t DaikinArcClimate::humidity_() {
|
||||
|
||||
climate::ClimateTraits DaikinArcClimate::traits() {
|
||||
climate::ClimateTraits traits = climate_ir::ClimateIR::traits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_supports_current_humidity(false);
|
||||
traits.set_supports_target_humidity(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY);
|
||||
traits.set_visual_min_humidity(38);
|
||||
traits.set_visual_max_humidity(52);
|
||||
return traits;
|
||||
|
||||
@@ -82,16 +82,14 @@ class DemoClimate : public climate::Climate, public Component {
|
||||
climate::ClimateTraits traits{};
|
||||
switch (type_) {
|
||||
case DemoClimateType::TYPE_1:
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
climate::CLIMATE_MODE_HEAT,
|
||||
});
|
||||
traits.set_supports_action(true);
|
||||
traits.set_visual_temperature_step(0.5);
|
||||
break;
|
||||
case DemoClimateType::TYPE_2:
|
||||
traits.set_supports_current_temperature(false);
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
climate::CLIMATE_MODE_HEAT,
|
||||
@@ -100,7 +98,7 @@ class DemoClimate : public climate::Climate, public Component {
|
||||
climate::CLIMATE_MODE_DRY,
|
||||
climate::CLIMATE_MODE_FAN_ONLY,
|
||||
});
|
||||
traits.set_supports_action(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
|
||||
traits.set_supported_fan_modes({
|
||||
climate::CLIMATE_FAN_ON,
|
||||
climate::CLIMATE_FAN_OFF,
|
||||
@@ -123,8 +121,8 @@ class DemoClimate : public climate::Climate, public Component {
|
||||
traits.set_supported_custom_presets({"My Preset"});
|
||||
break;
|
||||
case DemoClimateType::TYPE_3:
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_supports_two_point_target_temperature(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE |
|
||||
climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE);
|
||||
traits.set_supported_modes({
|
||||
climate::CLIMATE_MODE_OFF,
|
||||
climate::CLIMATE_MODE_COOL,
|
||||
|
||||
@@ -103,7 +103,7 @@ bool EPaperBase::is_idle_() {
|
||||
if (this->busy_pin_ == nullptr) {
|
||||
return true;
|
||||
}
|
||||
return !this->busy_pin_->digital_read();
|
||||
return this->busy_pin_->digital_read();
|
||||
}
|
||||
|
||||
void EPaperBase::reset() {
|
||||
|
||||
@@ -779,6 +779,16 @@ async def to_code(config):
|
||||
Path(__file__).parent / "post_build.py.script",
|
||||
)
|
||||
|
||||
# In testing mode, add IRAM fix script to allow linking grouped component tests
|
||||
# Similar to ESP8266's approach but for ESP-IDF
|
||||
if CORE.testing_mode:
|
||||
cg.add_build_flag("-DESPHOME_TESTING_MODE")
|
||||
add_extra_script(
|
||||
"pre",
|
||||
"iram_fix.py",
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
cg.add_platformio_option("framework", "espidf")
|
||||
cg.add_build_flag("-DUSE_ESP_IDF")
|
||||
@@ -805,6 +815,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_AUTOSTART_ARDUINO", True)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True)
|
||||
add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
|
||||
|
||||
cg.add_build_flag("-Wno-nonnull-compare")
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <esp_timer.h>
|
||||
#include <soc/rtc.h>
|
||||
@@ -52,6 +53,16 @@ void arch_init() {
|
||||
disableCore1WDT();
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// If the bootloader was compiled with CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the current
|
||||
// partition will get rolled back unless it is marked as valid.
|
||||
esp_ota_img_states_t state;
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
if (esp_ota_get_state_partition(running, &state) == ESP_OK) {
|
||||
if (state == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
void IRAM_ATTR HOT arch_feed_wdt() { esp_task_wdt_reset(); }
|
||||
|
||||
|
||||
71
esphome/components/esp32/iram_fix.py.script
Normal file
71
esphome/components/esp32/iram_fix.py.script
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
# pylint: disable=E0602
|
||||
Import("env") # noqa
|
||||
|
||||
# IRAM size for testing mode (2MB - large enough to accommodate grouped tests)
|
||||
TESTING_IRAM_SIZE = 0x200000
|
||||
|
||||
|
||||
def patch_idf_linker_script(source, target, env):
|
||||
"""Patch ESP-IDF linker script to increase IRAM size for testing mode."""
|
||||
# Check if we're in testing mode by looking for the define
|
||||
build_flags = env.get("BUILD_FLAGS", [])
|
||||
testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags)
|
||||
|
||||
if not testing_mode:
|
||||
return
|
||||
|
||||
# For ESP-IDF, the linker scripts are generated in the build directory
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
|
||||
# The memory.ld file is directly in the build directory
|
||||
memory_ld = os.path.join(build_dir, "memory.ld")
|
||||
|
||||
if not os.path.exists(memory_ld):
|
||||
print(f"ESPHome: Warning - could not find linker script at {memory_ld}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(memory_ld, "r") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
print(f"ESPHome: Error reading linker script: {e}")
|
||||
return
|
||||
|
||||
# Check if this file contains iram0_0_seg
|
||||
if 'iram0_0_seg' not in content:
|
||||
print(f"ESPHome: Warning - iram0_0_seg not found in {memory_ld}")
|
||||
return
|
||||
|
||||
# Look for iram0_0_seg definition and increase its length
|
||||
# ESP-IDF format can be:
|
||||
# iram0_0_seg (RX) : org = 0x40080000, len = 0x20000 + 0x0
|
||||
# or more complex with nested parentheses:
|
||||
# iram0_0_seg (RX) : org = (0x40370000 + 0x4000), len = (((0x403CB700 - (0x40378000 - 0x3FC88000)) - 0x3FC88000) + 0x8000 - 0x4000)
|
||||
# We want to change len to TESTING_IRAM_SIZE for testing
|
||||
|
||||
# Use a more robust approach: find the line and manually parse it
|
||||
lines = content.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'iram0_0_seg' in line and 'len' in line:
|
||||
# Find the position of "len = " and replace everything after it until the end of the statement
|
||||
match = re.search(r'(iram0_0_seg\s*\([^)]*\)\s*:\s*org\s*=\s*(?:\([^)]+\)|0x[0-9a-fA-F]+)\s*,\s*len\s*=\s*)(.+?)(\s*)$', line)
|
||||
if match:
|
||||
lines[i] = f"{match.group(1)}{TESTING_IRAM_SIZE:#x}{match.group(3)}"
|
||||
break
|
||||
|
||||
updated = '\n'.join(lines)
|
||||
|
||||
if updated != content:
|
||||
with open(memory_ld, "w") as f:
|
||||
f.write(updated)
|
||||
print(f"ESPHome: Patched IRAM size to {TESTING_IRAM_SIZE:#x} in {memory_ld} for testing mode")
|
||||
else:
|
||||
print(f"ESPHome: Warning - could not patch iram0_0_seg in {memory_ld}")
|
||||
|
||||
|
||||
# Hook into the build process before linking
|
||||
# For ESP-IDF, we need to run this after the linker scripts are generated
|
||||
env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", patch_idf_linker_script)
|
||||
@@ -1,11 +1,11 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor, esp32_ble, output
|
||||
from esphome.components import binary_sensor, esp32_ble, improv_base, output
|
||||
from esphome.components.esp32_ble import BTLoggers
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_server"]
|
||||
AUTO_LOAD = ["esp32_ble_server", "improv_base"]
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
DEPENDENCIES = ["wifi", "esp32"]
|
||||
|
||||
@@ -20,6 +20,7 @@ CONF_ON_STOP = "on_stop"
|
||||
CONF_STATUS_INDICATOR = "status_indicator"
|
||||
CONF_WIFI_TIMEOUT = "wifi_timeout"
|
||||
|
||||
|
||||
improv_ns = cg.esphome_ns.namespace("improv")
|
||||
Error = improv_ns.enum("Error")
|
||||
State = improv_ns.enum("State")
|
||||
@@ -43,55 +44,63 @@ ESP32ImprovStoppedTrigger = esp32_improv_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ESP32ImprovComponent),
|
||||
cv.Required(CONF_AUTHORIZER): cv.Any(
|
||||
cv.none, cv.use_id(binary_sensor.BinarySensor)
|
||||
),
|
||||
cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput),
|
||||
cv.Optional(
|
||||
CONF_IDENTIFY_DURATION, default="10s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_AUTHORIZED_DURATION, default="1min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_WIFI_TIMEOUT, default="1min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovProvisionedTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovProvisioningTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_START): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStartTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESP32ImprovStateTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_STOP): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovStoppedTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ESP32ImprovComponent),
|
||||
cv.Required(CONF_AUTHORIZER): cv.Any(
|
||||
cv.none, cv.use_id(binary_sensor.BinarySensor)
|
||||
),
|
||||
cv.Optional(CONF_STATUS_INDICATOR): cv.use_id(output.BinaryOutput),
|
||||
cv.Optional(
|
||||
CONF_IDENTIFY_DURATION, default="10s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_AUTHORIZED_DURATION, default="1min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(
|
||||
CONF_WIFI_TIMEOUT, default="1min"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovProvisionedTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovProvisioningTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_START): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovStartTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovStateTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_STOP): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
ESP32ImprovStoppedTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(improv_base.IMPROV_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
@@ -102,7 +111,8 @@ async def to_code(config):
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add_define("USE_IMPROV")
|
||||
cg.add_library("improv/Improv", "1.2.4")
|
||||
|
||||
await improv_base.setup_improv_core(var, config)
|
||||
|
||||
cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION]))
|
||||
cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION]))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#include "esp32_improv_component.h"
|
||||
|
||||
#include "esphome/components/bytebuffer/bytebuffer.h"
|
||||
#include "esphome/components/esp32_ble/ble.h"
|
||||
#include "esphome/components/esp32_ble_server/ble_2902.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/components/bytebuffer/bytebuffer.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
@@ -384,7 +384,16 @@ void ESP32ImprovComponent::check_wifi_connection_() {
|
||||
this->connecting_sta_ = {};
|
||||
this->cancel_timeout("wifi-connect-timeout");
|
||||
|
||||
std::vector<std::string> urls = {ESPHOME_MY_LINK};
|
||||
std::vector<std::string> urls;
|
||||
|
||||
// Add next_url if configured (should be first per Improv BLE spec)
|
||||
std::string next_url = this->get_formatted_next_url_();
|
||||
if (!next_url.empty()) {
|
||||
urls.push_back(next_url);
|
||||
}
|
||||
|
||||
// Add default URLs for backward compatibility
|
||||
urls.emplace_back(ESPHOME_MY_LINK);
|
||||
#ifdef USE_WEBSERVER
|
||||
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
|
||||
if (ip.is_ip4()) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "esphome/components/esp32_ble_server/ble_characteristic.h"
|
||||
#include "esphome/components/esp32_ble_server/ble_server.h"
|
||||
#include "esphome/components/improv_base/improv_base.h"
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
|
||||
#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
|
||||
@@ -32,7 +33,7 @@ namespace esp32_improv {
|
||||
|
||||
using namespace esp32_ble_server;
|
||||
|
||||
class ESP32ImprovComponent : public Component {
|
||||
class ESP32ImprovComponent : public Component, public improv_base::ImprovBase {
|
||||
public:
|
||||
ESP32ImprovComponent();
|
||||
void dump_config() override;
|
||||
|
||||
@@ -38,7 +38,6 @@ IS_PLATFORM_COMPONENT = True
|
||||
|
||||
fan_ns = cg.esphome_ns.namespace("fan")
|
||||
Fan = fan_ns.class_("Fan", cg.EntityBase)
|
||||
FanState = fan_ns.class_("Fan", Fan, cg.Component)
|
||||
|
||||
FanDirection = fan_ns.enum("FanDirection", is_class=True)
|
||||
FAN_DIRECTION_ENUM = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "fan_state.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "fan.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace fan {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "fan_state.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace fan {
|
||||
|
||||
static const char *const TAG = "fan";
|
||||
|
||||
void FanState::setup() {
|
||||
auto restore = this->restore_state_();
|
||||
if (restore)
|
||||
restore->to_call(*this).perform();
|
||||
}
|
||||
float FanState::get_setup_priority() const { return setup_priority::DATA - 1.0f; }
|
||||
|
||||
} // namespace fan
|
||||
} // namespace esphome
|
||||
@@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "fan.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace fan {
|
||||
|
||||
enum ESPDEPRECATED("LegacyFanDirection members are deprecated, use FanDirection instead.",
|
||||
"2022.2") LegacyFanDirection {
|
||||
FAN_DIRECTION_FORWARD = 0,
|
||||
FAN_DIRECTION_REVERSE = 1
|
||||
};
|
||||
|
||||
class ESPDEPRECATED("FanState is deprecated, use Fan instead.", "2022.2") FanState : public Fan, public Component {
|
||||
public:
|
||||
FanState() = default;
|
||||
|
||||
/// Get the traits of this fan.
|
||||
FanTraits get_traits() override { return this->traits_; }
|
||||
/// Set the traits of this fan (i.e. what features it supports).
|
||||
void set_traits(const FanTraits &traits) { this->traits_ = traits; }
|
||||
|
||||
void setup() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
protected:
|
||||
void control(const FanCall &call) override { this->publish_state(); }
|
||||
|
||||
FanTraits traits_{};
|
||||
};
|
||||
|
||||
} // namespace fan
|
||||
} // namespace esphome
|
||||
@@ -65,7 +65,7 @@ HaierClimateBase::HaierClimateBase()
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH});
|
||||
this->traits_.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH,
|
||||
climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL});
|
||||
this->traits_.set_supports_current_temperature(true);
|
||||
this->traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
|
||||
HaierClimateBase::~HaierClimateBase() {}
|
||||
|
||||
@@ -10,27 +10,36 @@ std::string ImprovBase::get_formatted_next_url_() {
|
||||
if (this->next_url_.empty()) {
|
||||
return "";
|
||||
}
|
||||
std::string copy = this->next_url_;
|
||||
// Device name
|
||||
std::size_t pos = this->next_url_.find("{{device_name}}");
|
||||
if (pos != std::string::npos) {
|
||||
const std::string &device_name = App.get_name();
|
||||
copy.replace(pos, 15, device_name);
|
||||
|
||||
std::string formatted_url = this->next_url_;
|
||||
|
||||
// Replace all occurrences of {{device_name}}
|
||||
const std::string device_name_placeholder = "{{device_name}}";
|
||||
const std::string &device_name = App.get_name();
|
||||
size_t pos = 0;
|
||||
while ((pos = formatted_url.find(device_name_placeholder, pos)) != std::string::npos) {
|
||||
formatted_url.replace(pos, device_name_placeholder.length(), device_name);
|
||||
pos += device_name.length();
|
||||
}
|
||||
|
||||
// Ip address
|
||||
pos = this->next_url_.find("{{ip_address}}");
|
||||
if (pos != std::string::npos) {
|
||||
for (auto &ip : network::get_ip_addresses()) {
|
||||
if (ip.is_ip4()) {
|
||||
std::string ipa = ip.str();
|
||||
copy.replace(pos, 14, ipa);
|
||||
break;
|
||||
}
|
||||
// Replace all occurrences of {{ip_address}}
|
||||
const std::string ip_address_placeholder = "{{ip_address}}";
|
||||
std::string ip_address_str;
|
||||
for (auto &ip : network::get_ip_addresses()) {
|
||||
if (ip.is_ip4()) {
|
||||
ip_address_str = ip.str();
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos = 0;
|
||||
while ((pos = formatted_url.find(ip_address_placeholder, pos)) != std::string::npos) {
|
||||
formatted_url.replace(pos, ip_address_placeholder.length(), ip_address_str);
|
||||
pos += ip_address_str.length();
|
||||
}
|
||||
|
||||
return copy;
|
||||
// Note: {{esphome_version}} is replaced at code generation time in Python
|
||||
|
||||
return formatted_url;
|
||||
}
|
||||
|
||||
} // namespace improv_base
|
||||
|
||||
@@ -14,7 +14,7 @@ void Kuntze::on_modbus_data(const std::vector<uint8_t> &data) {
|
||||
auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); };
|
||||
|
||||
this->waiting_ = false;
|
||||
ESP_LOGV(TAG, "Data: %s", hexencode(data).c_str());
|
||||
ESP_LOGV(TAG, "Data: %s", format_hex_pretty(data).c_str());
|
||||
|
||||
float value = (float) get_16bit(0);
|
||||
for (int i = 0; i < data[3]; i++)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/color.h"
|
||||
#include "esp_color_correction.h"
|
||||
#include "esp_color_view.h"
|
||||
#include "esp_range_view.h"
|
||||
#include "esphome/core/color.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "light_output.h"
|
||||
#include "light_state.h"
|
||||
#include "transformers.h"
|
||||
@@ -17,8 +17,6 @@
|
||||
namespace esphome {
|
||||
namespace light {
|
||||
|
||||
using ESPColor ESPDEPRECATED("esphome::light::ESPColor is deprecated, use esphome::Color instead.", "v1.21") = Color;
|
||||
|
||||
/// Convert the color information from a `LightColorValues` object to a `Color` object (does not apply brightness).
|
||||
Color color_from_light_color_values(LightColorValues val);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "color_mode.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -31,26 +31,6 @@ class LightTraits {
|
||||
return this->supported_color_modes_.has_capability(color_capability);
|
||||
}
|
||||
|
||||
ESPDEPRECATED("get_supports_brightness() is deprecated, use color modes instead.", "v1.21")
|
||||
bool get_supports_brightness() const { return this->supports_color_capability(ColorCapability::BRIGHTNESS); }
|
||||
ESPDEPRECATED("get_supports_rgb() is deprecated, use color modes instead.", "v1.21")
|
||||
bool get_supports_rgb() const { return this->supports_color_capability(ColorCapability::RGB); }
|
||||
ESPDEPRECATED("get_supports_rgb_white_value() is deprecated, use color modes instead.", "v1.21")
|
||||
bool get_supports_rgb_white_value() const {
|
||||
return this->supports_color_mode(ColorMode::RGB_WHITE) ||
|
||||
this->supports_color_mode(ColorMode::RGB_COLOR_TEMPERATURE);
|
||||
}
|
||||
ESPDEPRECATED("get_supports_color_temperature() is deprecated, use color modes instead.", "v1.21")
|
||||
bool get_supports_color_temperature() const {
|
||||
return this->supports_color_capability(ColorCapability::COLOR_TEMPERATURE);
|
||||
}
|
||||
ESPDEPRECATED("get_supports_color_interlock() is deprecated, use color modes instead.", "v1.21")
|
||||
bool get_supports_color_interlock() const {
|
||||
return this->supports_color_mode(ColorMode::RGB) &&
|
||||
(this->supports_color_mode(ColorMode::WHITE) || this->supports_color_mode(ColorMode::COLD_WARM_WHITE) ||
|
||||
this->supports_color_mode(ColorMode::COLOR_TEMPERATURE));
|
||||
}
|
||||
|
||||
float get_min_mireds() const { return this->min_mireds_; }
|
||||
void set_min_mireds(float min_mireds) { this->min_mireds_ = min_mireds; }
|
||||
float get_max_mireds() const { return this->max_mireds_; }
|
||||
|
||||
@@ -77,7 +77,7 @@ void AirConditioner::control(const ClimateCall &call) {
|
||||
|
||||
ClimateTraits AirConditioner::traits() {
|
||||
auto traits = ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
traits.set_visual_min_temperature(17);
|
||||
traits.set_visual_max_temperature(30);
|
||||
traits.set_visual_temperature_step(0.5);
|
||||
|
||||
@@ -30,6 +30,19 @@ wave_4_3 = DriverChip(
|
||||
"blue": [14, 38, 18, 17, 10],
|
||||
},
|
||||
)
|
||||
|
||||
wave_4_3.extend(
|
||||
"WAVESHARE-5-1024X600",
|
||||
width=1024,
|
||||
height=600,
|
||||
hsync_back_porch=145,
|
||||
hsync_front_porch=170,
|
||||
hsync_pulse_width=30,
|
||||
vsync_back_porch=23,
|
||||
vsync_front_porch=12,
|
||||
vsync_pulse_width=2,
|
||||
)
|
||||
|
||||
wave_4_3.extend(
|
||||
"ESP32-S3-TOUCH-LCD-7-800X480",
|
||||
enable_pin=[{"ch422g": None, "number": 2}, {"ch422g": None, "number": 6}],
|
||||
|
||||
@@ -52,8 +52,9 @@ const uint8_t MITSUBISHI_BYTE16 = 0x00;
|
||||
|
||||
climate::ClimateTraits MitsubishiClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(this->sensor_ != nullptr);
|
||||
traits.set_supports_action(false);
|
||||
if (this->sensor_ != nullptr) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
traits.set_visual_min_temperature(MITSUBISHI_TEMP_MIN);
|
||||
traits.set_visual_max_temperature(MITSUBISHI_TEMP_MAX);
|
||||
traits.set_visual_temperature_step(1.0f);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#ifdef USE_MQTT
|
||||
#ifdef USE_FAN
|
||||
|
||||
#include "esphome/components/fan/fan_state.h"
|
||||
#include "esphome/components/fan/fan.h"
|
||||
#include "mqtt_component.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -69,6 +69,12 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
|
||||
if (traits.supports_color_capability(ColorCapability::BRIGHTNESS))
|
||||
root["brightness"] = true;
|
||||
|
||||
if (traits.supports_color_mode(ColorMode::COLOR_TEMPERATURE) ||
|
||||
traits.supports_color_mode(ColorMode::COLD_WARM_WHITE)) {
|
||||
root[MQTT_MIN_MIREDS] = traits.get_min_mireds();
|
||||
root[MQTT_MAX_MIREDS] = traits.get_max_mireds();
|
||||
}
|
||||
|
||||
if (this->state_->supports_effects()) {
|
||||
root["effect"] = true;
|
||||
JsonArray effect_list = root[MQTT_EFFECT_LIST].to<JsonArray>();
|
||||
|
||||
@@ -1291,9 +1291,6 @@ void Nextion::check_pending_waveform_() {
|
||||
|
||||
void Nextion::set_writer(const nextion_writer_t &writer) { this->writer_ = writer; }
|
||||
|
||||
ESPDEPRECATED("set_wait_for_ack(bool) deprecated, no effect", "v1.20")
|
||||
void Nextion::set_wait_for_ack(bool wait_for_ack) { ESP_LOGE(TAG, "Deprecated"); }
|
||||
|
||||
bool Nextion::is_updating() { return this->connection_state_.is_updating_; }
|
||||
|
||||
} // namespace nextion
|
||||
|
||||
@@ -54,11 +54,10 @@ void PIDClimate::control(const climate::ClimateCall &call) {
|
||||
}
|
||||
climate::ClimateTraits PIDClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_supports_two_point_target_temperature(false);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION);
|
||||
|
||||
if (this->humidity_sensor_ != nullptr)
|
||||
traits.set_supports_current_humidity(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY);
|
||||
|
||||
traits.set_supported_modes({climate::CLIMATE_MODE_OFF});
|
||||
if (supports_cool_())
|
||||
@@ -68,7 +67,6 @@ climate::ClimateTraits PIDClimate::traits() {
|
||||
if (supports_heat_() && supports_cool_())
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT_COOL);
|
||||
|
||||
traits.set_supports_action(true);
|
||||
return traits;
|
||||
}
|
||||
void PIDClimate::dump_config() {
|
||||
|
||||
@@ -45,13 +45,26 @@ def get_script(script_id):
|
||||
|
||||
|
||||
def check_max_runs(value):
|
||||
# Set default for queued mode to prevent unbounded queue growth
|
||||
if CONF_MAX_RUNS not in value and value[CONF_MODE] == CONF_QUEUED:
|
||||
value[CONF_MAX_RUNS] = 5
|
||||
|
||||
if CONF_MAX_RUNS not in value:
|
||||
return value
|
||||
|
||||
if value[CONF_MODE] not in [CONF_QUEUED, CONF_PARALLEL]:
|
||||
raise cv.Invalid(
|
||||
"The option 'max_runs' is only valid in 'queue' and 'parallel' mode.",
|
||||
"The option 'max_runs' is only valid in 'queued' and 'parallel' mode.",
|
||||
path=[CONF_MAX_RUNS],
|
||||
)
|
||||
|
||||
# Queued mode must have bounded queue (min 1), parallel mode can be unlimited (0)
|
||||
if value[CONF_MODE] == CONF_QUEUED and value[CONF_MAX_RUNS] < 1:
|
||||
raise cv.Invalid(
|
||||
"The option 'max_runs' must be at least 1 for queued mode.",
|
||||
path=[CONF_MAX_RUNS],
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -106,7 +119,7 @@ CONFIG_SCHEMA = automation.validate_automation(
|
||||
cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of(
|
||||
*SCRIPT_MODES, lower=True
|
||||
),
|
||||
cv.Optional(CONF_MAX_RUNS): cv.positive_int,
|
||||
cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100),
|
||||
cv.Optional(CONF_PARAMETERS, default={}): cv.Schema(
|
||||
{
|
||||
validate_parameter_name: validate_parameter_type,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <queue>
|
||||
namespace esphome {
|
||||
namespace script {
|
||||
|
||||
@@ -96,23 +97,41 @@ template<typename... Ts> class RestartScript : public Script<Ts...> {
|
||||
/** A script type that queues new instances that are created.
|
||||
*
|
||||
* Only one instance of the script can be active at a time.
|
||||
*
|
||||
* Ring buffer implementation:
|
||||
* - num_queued_ tracks the number of queued (waiting) instances, NOT including the currently running one
|
||||
* - queue_front_ points to the next item to execute (read position)
|
||||
* - Buffer size is max_runs_ - 1 (max total instances minus the running one)
|
||||
* - Write position is calculated as: (queue_front_ + num_queued_) % (max_runs_ - 1)
|
||||
* - When an item finishes, queue_front_ advances: (queue_front_ + 1) % (max_runs_ - 1)
|
||||
* - First execute() runs immediately without queuing (num_queued_ stays 0)
|
||||
* - Subsequent executes while running are queued starting at position 0
|
||||
* - Maximum total instances = max_runs_ (includes 1 running + (max_runs_ - 1) queued)
|
||||
*/
|
||||
template<typename... Ts> class QueueingScript : public Script<Ts...>, public Component {
|
||||
public:
|
||||
void execute(Ts... x) override {
|
||||
if (this->is_action_running() || this->num_runs_ > 0) {
|
||||
// num_runs_ is the number of *queued* instances, so total number of instances is
|
||||
// num_runs_ + 1
|
||||
if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) {
|
||||
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' maximum number of queued runs exceeded!"),
|
||||
if (this->is_action_running() || this->num_queued_ > 0) {
|
||||
// num_queued_ is the number of *queued* instances (waiting, not including currently running)
|
||||
// max_runs_ is the maximum *total* instances (running + queued)
|
||||
// So we reject when num_queued_ + 1 >= max_runs_ (queued + running >= max)
|
||||
if (this->num_queued_ + 1 >= this->max_runs_) {
|
||||
this->esp_logw_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' max instances (running + queued) reached!"),
|
||||
LOG_STR_ARG(this->name_));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize queue on first queued item (after capacity check)
|
||||
this->lazy_init_queue_();
|
||||
|
||||
this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' queueing new instance (mode: queued)"),
|
||||
LOG_STR_ARG(this->name_));
|
||||
this->num_runs_++;
|
||||
this->var_queue_.push(std::make_tuple(x...));
|
||||
// Ring buffer: write to (queue_front_ + num_queued_) % queue_capacity
|
||||
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
|
||||
size_t write_pos = (this->queue_front_ + this->num_queued_) % queue_capacity;
|
||||
// Use std::make_unique to replace the unique_ptr
|
||||
this->var_queue_[write_pos] = std::make_unique<std::tuple<Ts...>>(x...);
|
||||
this->num_queued_++;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,29 +141,46 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
|
||||
}
|
||||
|
||||
void stop() override {
|
||||
this->num_runs_ = 0;
|
||||
// Clear all queued items to free memory immediately
|
||||
// Resetting the array automatically destroys all unique_ptrs and their contents
|
||||
this->var_queue_.reset();
|
||||
this->num_queued_ = 0;
|
||||
this->queue_front_ = 0;
|
||||
Script<Ts...>::stop();
|
||||
}
|
||||
|
||||
void loop() override {
|
||||
if (this->num_runs_ != 0 && !this->is_action_running()) {
|
||||
this->num_runs_--;
|
||||
auto &vars = this->var_queue_.front();
|
||||
this->var_queue_.pop();
|
||||
this->trigger_tuple_(vars, typename gens<sizeof...(Ts)>::type());
|
||||
if (this->num_queued_ != 0 && !this->is_action_running()) {
|
||||
// Dequeue: decrement count, move tuple out (frees slot), advance read position
|
||||
this->num_queued_--;
|
||||
const size_t queue_capacity = static_cast<size_t>(this->max_runs_ - 1);
|
||||
auto tuple_ptr = std::move(this->var_queue_[this->queue_front_]);
|
||||
this->queue_front_ = (this->queue_front_ + 1) % queue_capacity;
|
||||
this->trigger_tuple_(*tuple_ptr, typename gens<sizeof...(Ts)>::type());
|
||||
}
|
||||
}
|
||||
|
||||
void set_max_runs(int max_runs) { max_runs_ = max_runs; }
|
||||
|
||||
protected:
|
||||
// Lazy init queue on first use - avoids setup() ordering issues and saves memory
|
||||
// if script is never executed during this boot cycle
|
||||
inline void lazy_init_queue_() {
|
||||
if (!this->var_queue_) {
|
||||
// Allocate array of max_runs_ - 1 slots for queued items (running item is separate)
|
||||
// unique_ptr array is zero-initialized, so all slots start as nullptr
|
||||
this->var_queue_ = std::make_unique<std::unique_ptr<std::tuple<Ts...>>[]>(this->max_runs_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
template<int... S> void trigger_tuple_(const std::tuple<Ts...> &tuple, seq<S...> /*unused*/) {
|
||||
this->trigger(std::get<S>(tuple)...);
|
||||
}
|
||||
|
||||
int num_runs_ = 0;
|
||||
int max_runs_ = 0;
|
||||
std::queue<std::tuple<Ts...>> var_queue_;
|
||||
int num_queued_ = 0; // Number of queued instances (not including currently running)
|
||||
int max_runs_ = 0; // Maximum total instances (running + queued)
|
||||
size_t queue_front_ = 0; // Ring buffer read position (next item to execute)
|
||||
std::unique_ptr<std::unique_ptr<std::tuple<Ts...>>[]> var_queue_; // Ring buffer of queued parameters
|
||||
};
|
||||
|
||||
/** A script type that executes new instances in parallel.
|
||||
|
||||
@@ -6,7 +6,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS
|
||||
from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base
|
||||
|
||||
from .jinja import Jinja, JinjaStr, TemplateError, TemplateRuntimeError, has_jinja
|
||||
from .jinja import Jinja, JinjaError, JinjaStr, has_jinja
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -57,17 +57,12 @@ def _expand_jinja(value, orig_value, path, jinja, ignore_missing):
|
||||
"->".join(str(x) for x in path),
|
||||
err.message,
|
||||
)
|
||||
except (
|
||||
TemplateError,
|
||||
TemplateRuntimeError,
|
||||
RuntimeError,
|
||||
ArithmeticError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
) as err:
|
||||
except JinjaError as err:
|
||||
raise cv.Invalid(
|
||||
f"{type(err).__name__} Error evaluating jinja expression '{value}': {str(err)}."
|
||||
f" See {'->'.join(str(x) for x in path)}",
|
||||
f"{err.error_name()} Error evaluating jinja expression '{value}': {str(err.parent())}."
|
||||
f"\nEvaluation stack: (most recent evaluation last)\n{err.stack_trace_str()}"
|
||||
f"\nRelevant context:\n{err.context_trace_str()}"
|
||||
f"\nSee {'->'.join(str(x) for x in path)}",
|
||||
path,
|
||||
)
|
||||
return value
|
||||
|
||||
@@ -6,6 +6,8 @@ import re
|
||||
import jinja2 as jinja
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from esphome.yaml_util import ESPLiteralValue
|
||||
|
||||
TemplateError = jinja.TemplateError
|
||||
TemplateSyntaxError = jinja.TemplateSyntaxError
|
||||
TemplateRuntimeError = jinja.TemplateRuntimeError
|
||||
@@ -26,18 +28,20 @@ def has_jinja(st):
|
||||
return detect_jinja_re.search(st) is not None
|
||||
|
||||
|
||||
# SAFE_GLOBAL_FUNCTIONS defines a allowlist of built-in functions that are considered safe to expose
|
||||
# SAFE_GLOBALS defines a allowlist of built-in functions or modules that are considered safe to expose
|
||||
# in Jinja templates or other sandboxed evaluation contexts. Only functions that do not allow
|
||||
# arbitrary code execution, file access, or other security risks are included.
|
||||
#
|
||||
# The following functions are considered safe:
|
||||
# - math: The entire math module is injected, allowing access to mathematical functions like sin, cos, sqrt, etc.
|
||||
# - ord: Converts a character to its Unicode code point integer.
|
||||
# - chr: Converts an integer to its corresponding Unicode character.
|
||||
# - len: Returns the length of a sequence or collection.
|
||||
#
|
||||
# These functions were chosen because they are pure, have no side effects, and do not provide access
|
||||
# to the file system, environment, or other potentially sensitive resources.
|
||||
SAFE_GLOBAL_FUNCTIONS = {
|
||||
SAFE_GLOBALS = {
|
||||
"math": math, # Inject entire math module
|
||||
"ord": ord,
|
||||
"chr": chr,
|
||||
"len": len,
|
||||
@@ -56,22 +60,62 @@ class JinjaStr(str):
|
||||
later in the main substitutions pass.
|
||||
"""
|
||||
|
||||
Undefined = object()
|
||||
|
||||
def __new__(cls, value: str, upvalues=None):
|
||||
obj = super().__new__(cls, value)
|
||||
obj.upvalues = upvalues or {}
|
||||
if isinstance(value, JinjaStr):
|
||||
base = str(value)
|
||||
merged = {**value.upvalues, **(upvalues or {})}
|
||||
else:
|
||||
base = value
|
||||
merged = dict(upvalues or {})
|
||||
obj = super().__new__(cls, base)
|
||||
obj.upvalues = merged
|
||||
obj.result = JinjaStr.Undefined
|
||||
return obj
|
||||
|
||||
def __init__(self, value: str, upvalues=None):
|
||||
self.upvalues = upvalues or {}
|
||||
|
||||
class JinjaError(Exception):
|
||||
def __init__(self, context_trace: dict, expr: str):
|
||||
self.context_trace = context_trace
|
||||
self.eval_stack = [expr]
|
||||
|
||||
def parent(self):
|
||||
return self.__context__
|
||||
|
||||
def error_name(self):
|
||||
return type(self.parent()).__name__
|
||||
|
||||
def context_trace_str(self):
|
||||
return "\n".join(
|
||||
f" {k} = {repr(v)} ({type(v).__name__})"
|
||||
for k, v in self.context_trace.items()
|
||||
)
|
||||
|
||||
def stack_trace_str(self):
|
||||
return "\n".join(
|
||||
f" {len(self.eval_stack) - i}: {expr}{i == 0 and ' <-- ' + self.error_name() or ''}"
|
||||
for i, expr in enumerate(self.eval_stack)
|
||||
)
|
||||
|
||||
|
||||
class Jinja:
|
||||
class TrackerContext(jinja.runtime.Context):
|
||||
def resolve_or_missing(self, key):
|
||||
val = super().resolve_or_missing(key)
|
||||
if isinstance(val, JinjaStr):
|
||||
self.environment.context_trace[key] = val
|
||||
val, _ = self.environment.expand(val)
|
||||
self.environment.context_trace[key] = val
|
||||
return val
|
||||
|
||||
|
||||
class Jinja(SandboxedEnvironment):
|
||||
"""
|
||||
Wraps a Jinja environment
|
||||
"""
|
||||
|
||||
def __init__(self, context_vars):
|
||||
self.env = SandboxedEnvironment(
|
||||
super().__init__(
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
block_start_string="<%",
|
||||
@@ -82,13 +126,20 @@ class Jinja:
|
||||
variable_end_string="}",
|
||||
undefined=jinja.StrictUndefined,
|
||||
)
|
||||
self.env.add_extension("jinja2.ext.do")
|
||||
self.env.globals["math"] = math # Inject entire math module
|
||||
self.context_class = TrackerContext
|
||||
self.add_extension("jinja2.ext.do")
|
||||
self.context_trace = {}
|
||||
self.context_vars = {**context_vars}
|
||||
self.env.globals = {
|
||||
**self.env.globals,
|
||||
for k, v in self.context_vars.items():
|
||||
if isinstance(v, ESPLiteralValue):
|
||||
continue
|
||||
if isinstance(v, str) and not isinstance(v, JinjaStr) and has_jinja(v):
|
||||
self.context_vars[k] = JinjaStr(v, self.context_vars)
|
||||
|
||||
self.globals = {
|
||||
**self.globals,
|
||||
**self.context_vars,
|
||||
**SAFE_GLOBAL_FUNCTIONS,
|
||||
**SAFE_GLOBALS,
|
||||
}
|
||||
|
||||
def safe_eval(self, expr):
|
||||
@@ -110,23 +161,43 @@ class Jinja:
|
||||
result = None
|
||||
override_vars = {}
|
||||
if isinstance(content_str, JinjaStr):
|
||||
if content_str.result is not JinjaStr.Undefined:
|
||||
return content_str.result, None
|
||||
# If `value` is already a JinjaStr, it means we are trying to evaluate it again
|
||||
# in a parent pass.
|
||||
# Hopefully, all required variables are visible now.
|
||||
override_vars = content_str.upvalues
|
||||
|
||||
old_trace = self.context_trace
|
||||
self.context_trace = {}
|
||||
try:
|
||||
template = self.env.from_string(content_str)
|
||||
template = self.from_string(content_str)
|
||||
result = self.safe_eval(template.render(override_vars))
|
||||
if isinstance(result, Undefined):
|
||||
# This happens when the expression is simply an undefined variable. Jinja does not
|
||||
# raise an exception, instead we get "Undefined".
|
||||
# Trigger an UndefinedError exception so we skip to below.
|
||||
print("" + result)
|
||||
print("" + result) # force a UndefinedError exception
|
||||
except (TemplateSyntaxError, UndefinedError) as err:
|
||||
# `content_str` contains a Jinja expression that refers to a variable that is undefined
|
||||
# in this scope. Perhaps it refers to a root substitution that is not visible yet.
|
||||
# Therefore, return the original `content_str` as a JinjaStr, which contains the variables
|
||||
# Therefore, return `content_str` as a JinjaStr, which contains the variables
|
||||
# that are actually visible to it at this point to postpone evaluation.
|
||||
return JinjaStr(content_str, {**self.context_vars, **override_vars}), err
|
||||
except JinjaError as err:
|
||||
err.context_trace = {**self.context_trace, **err.context_trace}
|
||||
err.eval_stack.append(content_str)
|
||||
raise err
|
||||
except (
|
||||
TemplateError,
|
||||
TemplateRuntimeError,
|
||||
RuntimeError,
|
||||
ArithmeticError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
) as err:
|
||||
raise JinjaError(self.context_trace, content_str) from err
|
||||
finally:
|
||||
self.context_trace = old_trace
|
||||
|
||||
if isinstance(content_str, JinjaStr):
|
||||
content_str.result = result
|
||||
|
||||
return result, None
|
||||
|
||||
@@ -283,8 +283,11 @@ void TuyaClimate::control_fan_mode_(const climate::ClimateCall &call) {
|
||||
|
||||
climate::ClimateTraits TuyaClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_action(true);
|
||||
traits.set_supports_current_temperature(this->current_temperature_id_.has_value());
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
|
||||
if (this->current_temperature_id_.has_value()) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
|
||||
if (supports_heat_)
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
|
||||
if (supports_cool_)
|
||||
|
||||
@@ -17,6 +17,12 @@ UponorSmatrixDevice = uponor_smatrix_ns.class_(
|
||||
"UponorSmatrixDevice", cg.Parented.template(UponorSmatrixComponent)
|
||||
)
|
||||
|
||||
|
||||
device_address = cv.All(
|
||||
cv.hex_int,
|
||||
cv.Range(min=0x1000000, max=0xFFFFFFFF, msg="Expected a 32 bit device address"),
|
||||
)
|
||||
|
||||
CONF_UPONOR_SMATRIX_ID = "uponor_smatrix_id"
|
||||
CONF_TIME_DEVICE_ADDRESS = "time_device_address"
|
||||
|
||||
@@ -24,9 +30,12 @@ CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(UponorSmatrixComponent),
|
||||
cv.Optional(CONF_ADDRESS): cv.hex_uint16_t,
|
||||
cv.Optional(CONF_ADDRESS): cv.invalid(
|
||||
f"The '{CONF_ADDRESS}' option has been removed. "
|
||||
"Use full 32 bit addresses in the device definitions instead."
|
||||
),
|
||||
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
cv.Optional(CONF_TIME_DEVICE_ADDRESS): cv.hex_uint16_t,
|
||||
cv.Optional(CONF_TIME_DEVICE_ADDRESS): device_address,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
@@ -47,7 +56,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_UPONOR_SMATRIX_ID): cv.use_id(UponorSmatrixComponent),
|
||||
cv.Required(CONF_ADDRESS): cv.hex_uint16_t,
|
||||
cv.Required(CONF_ADDRESS): device_address,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -58,17 +67,15 @@ async def to_code(config):
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
if address := config.get(CONF_ADDRESS):
|
||||
cg.add(var.set_system_address(address))
|
||||
if time_id := config.get(CONF_TIME_ID):
|
||||
time_ = await cg.get_variable(time_id)
|
||||
cg.add(var.set_time_id(time_))
|
||||
if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS):
|
||||
cg.add(var.set_time_device_address(time_device_address))
|
||||
if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS):
|
||||
cg.add(var.set_time_device_address(time_device_address))
|
||||
|
||||
|
||||
async def register_uponor_smatrix_device(var, config):
|
||||
parent = await cg.get_variable(config[CONF_UPONOR_SMATRIX_ID])
|
||||
cg.add(var.set_parent(parent))
|
||||
cg.add(var.set_device_address(config[CONF_ADDRESS]))
|
||||
cg.add(var.set_address(config[CONF_ADDRESS]))
|
||||
cg.add(parent.register_device(var))
|
||||
|
||||
@@ -10,7 +10,7 @@ static const char *const TAG = "uponor_smatrix.climate";
|
||||
|
||||
void UponorSmatrixClimate::dump_config() {
|
||||
LOG_CLIMATE("", "Uponor Smatrix Climate", this);
|
||||
ESP_LOGCONFIG(TAG, " Device address: 0x%04X", this->address_);
|
||||
ESP_LOGCONFIG(TAG, " Device address: 0x%08X", this->address_);
|
||||
}
|
||||
|
||||
void UponorSmatrixClimate::loop() {
|
||||
@@ -30,10 +30,9 @@ void UponorSmatrixClimate::loop() {
|
||||
|
||||
climate::ClimateTraits UponorSmatrixClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(true);
|
||||
traits.set_supports_current_humidity(true);
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY |
|
||||
climate::CLIMATE_SUPPORTS_ACTION);
|
||||
traits.set_supported_modes({climate::CLIMATE_MODE_HEAT});
|
||||
traits.set_supports_action(true);
|
||||
traits.set_supported_presets({climate::CLIMATE_PRESET_ECO});
|
||||
traits.set_visual_min_temperature(this->min_temperature_);
|
||||
traits.set_visual_max_temperature(this->max_temperature_);
|
||||
|
||||
@@ -9,7 +9,7 @@ static const char *const TAG = "uponor_smatrix.sensor";
|
||||
void UponorSmatrixSensor::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Uponor Smatrix Sensor\n"
|
||||
" Device address: 0x%04X",
|
||||
" Device address: 0x%08X",
|
||||
this->address_);
|
||||
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
|
||||
LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_);
|
||||
|
||||
@@ -18,11 +18,10 @@ void UponorSmatrixComponent::setup() {
|
||||
|
||||
void UponorSmatrixComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Uponor Smatrix");
|
||||
ESP_LOGCONFIG(TAG, " System address: 0x%04X", this->address_);
|
||||
#ifdef USE_TIME
|
||||
if (this->time_id_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " Time synchronization: YES");
|
||||
ESP_LOGCONFIG(TAG, " Time master device address: 0x%04X", this->time_device_address_);
|
||||
ESP_LOGCONFIG(TAG, " Time master device address: 0x%08X", this->time_device_address_);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -31,7 +30,7 @@ void UponorSmatrixComponent::dump_config() {
|
||||
if (!this->unknown_devices_.empty()) {
|
||||
ESP_LOGCONFIG(TAG, " Detected unknown device addresses:");
|
||||
for (auto device_address : this->unknown_devices_) {
|
||||
ESP_LOGCONFIG(TAG, " 0x%04X", device_address);
|
||||
ESP_LOGCONFIG(TAG, " 0x%08X", device_address);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +88,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t system_address = encode_uint16(packet[0], packet[1]);
|
||||
uint16_t device_address = encode_uint16(packet[2], packet[3]);
|
||||
uint32_t device_address = encode_uint32(packet[0], packet[1], packet[2], packet[3]);
|
||||
uint16_t crc = encode_uint16(packet[packet_len - 1], packet[packet_len - 2]);
|
||||
|
||||
uint16_t computed_crc = crc16(packet, packet_len - 2);
|
||||
@@ -99,24 +97,14 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Received packet: sys=%04X, dev=%04X, data=%s, crc=%04X", system_address, device_address,
|
||||
ESP_LOGV(TAG, "Received packet: addr=%08X, data=%s, crc=%04X", device_address,
|
||||
format_hex(&packet[4], packet_len - 6).c_str(), crc);
|
||||
|
||||
// Detect or check system address
|
||||
if (this->address_ == 0) {
|
||||
ESP_LOGI(TAG, "Using detected system address 0x%04X", system_address);
|
||||
this->address_ = system_address;
|
||||
} else if (this->address_ != system_address) {
|
||||
// This should never happen except if the system address was set or detected incorrectly, so warn the user.
|
||||
ESP_LOGW(TAG, "Received packet from unknown system address 0x%04X", system_address);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle packet
|
||||
size_t data_len = (packet_len - 6) / 3;
|
||||
if (data_len == 0) {
|
||||
if (packet[4] == UPONOR_ID_REQUEST)
|
||||
ESP_LOGVV(TAG, "Ignoring request packet for device 0x%04X", device_address);
|
||||
ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08X", device_address);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -141,7 +129,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
if (data[i].id == UPONOR_ID_DATETIME1)
|
||||
found_time = true;
|
||||
if (found_temperature && found_time) {
|
||||
ESP_LOGI(TAG, "Using detected time device address 0x%04X", device_address);
|
||||
ESP_LOGI(TAG, "Using detected time device address 0x%08X", device_address);
|
||||
this->time_device_address_ = device_address;
|
||||
break;
|
||||
}
|
||||
@@ -160,7 +148,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
|
||||
// Log unknown device addresses
|
||||
if (!found && !this->unknown_devices_.count(device_address)) {
|
||||
ESP_LOGI(TAG, "Received packet for unknown device address 0x%04X ", device_address);
|
||||
ESP_LOGI(TAG, "Received packet for unknown device address 0x%08X ", device_address);
|
||||
this->unknown_devices_.insert(device_address);
|
||||
}
|
||||
|
||||
@@ -168,16 +156,16 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UponorSmatrixComponent::send(uint16_t device_address, const UponorSmatrixData *data, size_t data_len) {
|
||||
if (this->address_ == 0 || device_address == 0 || data == nullptr || data_len == 0)
|
||||
bool UponorSmatrixComponent::send(uint32_t device_address, const UponorSmatrixData *data, size_t data_len) {
|
||||
if (device_address == 0 || data == nullptr || data_len == 0)
|
||||
return false;
|
||||
|
||||
// Assemble packet for send queue. All fields are big-endian except for the little-endian checksum.
|
||||
std::vector<uint8_t> packet;
|
||||
packet.reserve(6 + 3 * data_len);
|
||||
|
||||
packet.push_back(this->address_ >> 8);
|
||||
packet.push_back(this->address_ >> 0);
|
||||
packet.push_back(device_address >> 24);
|
||||
packet.push_back(device_address >> 16);
|
||||
packet.push_back(device_address >> 8);
|
||||
packet.push_back(device_address >> 0);
|
||||
|
||||
|
||||
@@ -71,23 +71,21 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
|
||||
void dump_config() override;
|
||||
void loop() override;
|
||||
|
||||
void set_system_address(uint16_t address) { this->address_ = address; }
|
||||
void register_device(UponorSmatrixDevice *device) { this->devices_.push_back(device); }
|
||||
|
||||
bool send(uint16_t device_address, const UponorSmatrixData *data, size_t data_len);
|
||||
bool send(uint32_t device_address, const UponorSmatrixData *data, size_t data_len);
|
||||
|
||||
#ifdef USE_TIME
|
||||
void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; }
|
||||
void set_time_device_address(uint16_t address) { this->time_device_address_ = address; }
|
||||
void set_time_device_address(uint32_t address) { this->time_device_address_ = address; }
|
||||
void send_time() { this->send_time_requested_ = true; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool parse_byte_(uint8_t byte);
|
||||
|
||||
uint16_t address_;
|
||||
std::vector<UponorSmatrixDevice *> devices_;
|
||||
std::set<uint16_t> unknown_devices_;
|
||||
std::set<uint32_t> unknown_devices_;
|
||||
|
||||
std::vector<uint8_t> rx_buffer_;
|
||||
std::queue<std::vector<uint8_t>> tx_queue_;
|
||||
@@ -96,7 +94,7 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
|
||||
|
||||
#ifdef USE_TIME
|
||||
time::RealTimeClock *time_id_{nullptr};
|
||||
uint16_t time_device_address_;
|
||||
uint32_t time_device_address_;
|
||||
bool send_time_requested_;
|
||||
bool do_send_time_();
|
||||
#endif
|
||||
@@ -104,7 +102,7 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component {
|
||||
|
||||
class UponorSmatrixDevice : public Parented<UponorSmatrixComponent> {
|
||||
public:
|
||||
void set_device_address(uint16_t address) { this->address_ = address; }
|
||||
void set_address(uint32_t address) { this->address_ = address; }
|
||||
|
||||
virtual void on_device_data(const UponorSmatrixData *data, size_t data_len) = 0;
|
||||
bool send(const UponorSmatrixData *data, size_t data_len) {
|
||||
@@ -113,7 +111,7 @@ class UponorSmatrixDevice : public Parented<UponorSmatrixComponent> {
|
||||
|
||||
protected:
|
||||
friend UponorSmatrixComponent;
|
||||
uint16_t address_;
|
||||
uint32_t address_;
|
||||
};
|
||||
|
||||
inline float raw_to_celsius(uint16_t raw) {
|
||||
|
||||
@@ -407,7 +407,8 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT]))
|
||||
cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE]))
|
||||
cg.add(var.set_fast_connect(config[CONF_FAST_CONNECT]))
|
||||
if config[CONF_FAST_CONNECT]:
|
||||
cg.add_define("USE_WIFI_FAST_CONNECT")
|
||||
cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN]))
|
||||
if CONF_OUTPUT_POWER in config:
|
||||
cg.add(var.set_output_power(config[CONF_OUTPUT_POWER]))
|
||||
|
||||
@@ -84,9 +84,9 @@ void WiFiComponent::start() {
|
||||
uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL;
|
||||
|
||||
this->pref_ = global_preferences->make_preference<wifi::SavedWifiSettings>(hash, true);
|
||||
if (this->fast_connect_) {
|
||||
this->fast_connect_pref_ = global_preferences->make_preference<wifi::SavedWifiFastConnectSettings>(hash + 1, false);
|
||||
}
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
this->fast_connect_pref_ = global_preferences->make_preference<wifi::SavedWifiFastConnectSettings>(hash + 1, false);
|
||||
#endif
|
||||
|
||||
SavedWifiSettings save{};
|
||||
if (this->pref_.load(&save)) {
|
||||
@@ -108,16 +108,16 @@ void WiFiComponent::start() {
|
||||
ESP_LOGV(TAG, "Setting Power Save Option failed");
|
||||
}
|
||||
|
||||
if (this->fast_connect_) {
|
||||
this->trying_loaded_ap_ = this->load_fast_connect_settings_();
|
||||
if (!this->trying_loaded_ap_) {
|
||||
this->ap_index_ = 0;
|
||||
this->selected_ap_ = this->sta_[this->ap_index_];
|
||||
}
|
||||
this->start_connecting(this->selected_ap_, false);
|
||||
} else {
|
||||
this->start_scanning();
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
this->trying_loaded_ap_ = this->load_fast_connect_settings_();
|
||||
if (!this->trying_loaded_ap_) {
|
||||
this->ap_index_ = 0;
|
||||
this->selected_ap_ = this->sta_[this->ap_index_];
|
||||
}
|
||||
this->start_connecting(this->selected_ap_, false);
|
||||
#else
|
||||
this->start_scanning();
|
||||
#endif
|
||||
#ifdef USE_WIFI_AP
|
||||
} else if (this->has_ap()) {
|
||||
this->setup_ap_config_();
|
||||
@@ -168,13 +168,20 @@ void WiFiComponent::loop() {
|
||||
case WIFI_COMPONENT_STATE_COOLDOWN: {
|
||||
this->status_set_warning(LOG_STR("waiting to reconnect"));
|
||||
if (millis() - this->action_started_ > 5000) {
|
||||
if (this->fast_connect_ || this->retry_hidden_) {
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
// NOTE: This check may not make sense here as it could interfere with AP cycling
|
||||
if (!this->selected_ap_.get_bssid().has_value())
|
||||
this->selected_ap_ = this->sta_[0];
|
||||
this->start_connecting(this->selected_ap_, false);
|
||||
#else
|
||||
if (this->retry_hidden_) {
|
||||
if (!this->selected_ap_.get_bssid().has_value())
|
||||
this->selected_ap_ = this->sta_[0];
|
||||
this->start_connecting(this->selected_ap_, false);
|
||||
} else {
|
||||
this->start_scanning();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -244,7 +251,6 @@ WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
bool WiFiComponent::has_ap() const { return this->has_ap_; }
|
||||
bool WiFiComponent::has_sta() const { return !this->sta_.empty(); }
|
||||
void WiFiComponent::set_fast_connect(bool fast_connect) { this->fast_connect_ = fast_connect; }
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
|
||||
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
@@ -607,10 +613,12 @@ void WiFiComponent::check_scanning_finished() {
|
||||
for (auto &ap : this->sta_) {
|
||||
if (res.matches(ap)) {
|
||||
res.set_matches(true);
|
||||
if (!this->has_sta_priority(res.get_bssid())) {
|
||||
this->set_sta_priority(res.get_bssid(), ap.get_priority());
|
||||
// Cache priority lookup - do single search instead of 2 separate searches
|
||||
const bssid_t &bssid = res.get_bssid();
|
||||
if (!this->has_sta_priority(bssid)) {
|
||||
this->set_sta_priority(bssid, ap.get_priority());
|
||||
}
|
||||
res.set_priority(this->get_sta_priority(res.get_bssid()));
|
||||
res.set_priority(this->get_sta_priority(bssid));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -629,8 +637,9 @@ void WiFiComponent::check_scanning_finished() {
|
||||
return;
|
||||
}
|
||||
|
||||
WiFiAP connect_params;
|
||||
WiFiScanResult scan_res = this->scan_result_[0];
|
||||
// Build connection params directly into selected_ap_ to avoid extra copy
|
||||
const WiFiScanResult &scan_res = this->scan_result_[0];
|
||||
WiFiAP &selected = this->selected_ap_;
|
||||
for (auto &config : this->sta_) {
|
||||
// search for matching STA config, at least one will match (from checks before)
|
||||
if (!scan_res.matches(config)) {
|
||||
@@ -639,37 +648,38 @@ void WiFiComponent::check_scanning_finished() {
|
||||
|
||||
if (config.get_hidden()) {
|
||||
// selected network is hidden, we use the data from the config
|
||||
connect_params.set_hidden(true);
|
||||
connect_params.set_ssid(config.get_ssid());
|
||||
// don't set BSSID and channel, there might be multiple hidden networks
|
||||
selected.set_hidden(true);
|
||||
selected.set_ssid(config.get_ssid());
|
||||
// Clear channel and BSSID for hidden networks - there might be multiple hidden networks
|
||||
// but we can't know which one is the correct one. Rely on probe-req with just SSID.
|
||||
selected.set_channel(0);
|
||||
selected.set_bssid(optional<bssid_t>{});
|
||||
} else {
|
||||
// selected network is visible, we use the data from the scan
|
||||
// limit the connect params to only connect to exactly this network
|
||||
// (network selection is done during scan phase).
|
||||
connect_params.set_hidden(false);
|
||||
connect_params.set_ssid(scan_res.get_ssid());
|
||||
connect_params.set_channel(scan_res.get_channel());
|
||||
connect_params.set_bssid(scan_res.get_bssid());
|
||||
selected.set_hidden(false);
|
||||
selected.set_ssid(scan_res.get_ssid());
|
||||
selected.set_channel(scan_res.get_channel());
|
||||
selected.set_bssid(scan_res.get_bssid());
|
||||
}
|
||||
// copy manual IP (if set)
|
||||
connect_params.set_manual_ip(config.get_manual_ip());
|
||||
selected.set_manual_ip(config.get_manual_ip());
|
||||
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
// copy EAP parameters (if set)
|
||||
connect_params.set_eap(config.get_eap());
|
||||
selected.set_eap(config.get_eap());
|
||||
#endif
|
||||
|
||||
// copy password (if set)
|
||||
connect_params.set_password(config.get_password());
|
||||
selected.set_password(config.get_password());
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
yield();
|
||||
|
||||
this->selected_ap_ = connect_params;
|
||||
this->start_connecting(connect_params, false);
|
||||
this->start_connecting(this->selected_ap_, false);
|
||||
}
|
||||
|
||||
void WiFiComponent::dump_config() {
|
||||
@@ -719,9 +729,9 @@ void WiFiComponent::check_connecting_finished() {
|
||||
this->scan_result_.shrink_to_fit();
|
||||
}
|
||||
|
||||
if (this->fast_connect_) {
|
||||
this->save_fast_connect_settings_();
|
||||
}
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
this->save_fast_connect_settings_();
|
||||
#endif
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -769,31 +779,31 @@ void WiFiComponent::retry_connect() {
|
||||
delay(10);
|
||||
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() &&
|
||||
(this->num_retried_ > 3 || this->error_from_callback_)) {
|
||||
if (this->fast_connect_) {
|
||||
if (this->trying_loaded_ap_) {
|
||||
this->trying_loaded_ap_ = false;
|
||||
this->ap_index_ = 0; // Retry from the first configured AP
|
||||
} else if (this->ap_index_ >= this->sta_.size() - 1) {
|
||||
ESP_LOGW(TAG, "No more APs to try");
|
||||
this->ap_index_ = 0;
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
// Try next AP
|
||||
this->ap_index_++;
|
||||
}
|
||||
this->num_retried_ = 0;
|
||||
this->selected_ap_ = this->sta_[this->ap_index_];
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
if (this->trying_loaded_ap_) {
|
||||
this->trying_loaded_ap_ = false;
|
||||
this->ap_index_ = 0; // Retry from the first configured AP
|
||||
} else if (this->ap_index_ >= this->sta_.size() - 1) {
|
||||
ESP_LOGW(TAG, "No more APs to try");
|
||||
this->ap_index_ = 0;
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
if (this->num_retried_ > 5) {
|
||||
// If retry failed for more than 5 times, let's restart STA
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
// Try hidden networks after 3 failed retries
|
||||
ESP_LOGD(TAG, "Retrying with hidden networks");
|
||||
this->retry_hidden_ = true;
|
||||
this->num_retried_++;
|
||||
}
|
||||
// Try next AP
|
||||
this->ap_index_++;
|
||||
}
|
||||
this->num_retried_ = 0;
|
||||
this->selected_ap_ = this->sta_[this->ap_index_];
|
||||
#else
|
||||
if (this->num_retried_ > 5) {
|
||||
// If retry failed for more than 5 times, let's restart STA
|
||||
this->restart_adapter();
|
||||
} else {
|
||||
// Try hidden networks after 3 failed retries
|
||||
ESP_LOGD(TAG, "Retrying with hidden networks");
|
||||
this->retry_hidden_ = true;
|
||||
this->num_retried_++;
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
this->num_retried_++;
|
||||
}
|
||||
@@ -839,6 +849,7 @@ bool WiFiComponent::is_esp32_improv_active_() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
bool WiFiComponent::load_fast_connect_settings_() {
|
||||
SavedWifiFastConnectSettings fast_connect_save{};
|
||||
|
||||
@@ -873,6 +884,7 @@ void WiFiComponent::save_fast_connect_settings_() {
|
||||
ESP_LOGD(TAG, "Saved fast_connect settings");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; }
|
||||
void WiFiAP::set_bssid(bssid_t bssid) { this->bssid_ = bssid; }
|
||||
@@ -902,7 +914,7 @@ WiFiScanResult::WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t c
|
||||
rssi_(rssi),
|
||||
with_auth_(with_auth),
|
||||
is_hidden_(is_hidden) {}
|
||||
bool WiFiScanResult::matches(const WiFiAP &config) {
|
||||
bool WiFiScanResult::matches(const WiFiAP &config) const {
|
||||
if (config.get_hidden()) {
|
||||
// User configured a hidden network, only match actually hidden networks
|
||||
// don't match SSID
|
||||
|
||||
@@ -170,7 +170,7 @@ class WiFiScanResult {
|
||||
public:
|
||||
WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden);
|
||||
|
||||
bool matches(const WiFiAP &config);
|
||||
bool matches(const WiFiAP &config) const;
|
||||
|
||||
bool get_matches() const;
|
||||
void set_matches(bool matches);
|
||||
@@ -240,7 +240,6 @@ class WiFiComponent : public Component {
|
||||
void start_scanning();
|
||||
void check_scanning_finished();
|
||||
void start_connecting(const WiFiAP &ap, bool two);
|
||||
void set_fast_connect(bool fast_connect);
|
||||
void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; }
|
||||
|
||||
void check_connecting_finished();
|
||||
@@ -364,8 +363,10 @@ class WiFiComponent : public Component {
|
||||
bool is_captive_portal_active_();
|
||||
bool is_esp32_improv_active_();
|
||||
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
bool load_fast_connect_settings_();
|
||||
void save_fast_connect_settings_();
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
static void wifi_event_callback(System_Event_t *event);
|
||||
@@ -399,7 +400,9 @@ class WiFiComponent : public Component {
|
||||
WiFiAP ap_;
|
||||
optional<float> output_power_;
|
||||
ESPPreferenceObject pref_;
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
ESPPreferenceObject fast_connect_pref_;
|
||||
#endif
|
||||
|
||||
// Group all 32-bit integers together
|
||||
uint32_t action_started_;
|
||||
@@ -411,14 +414,17 @@ class WiFiComponent : public Component {
|
||||
WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF};
|
||||
WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE};
|
||||
uint8_t num_retried_{0};
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
uint8_t ap_index_{0};
|
||||
#endif
|
||||
#if USE_NETWORK_IPV6
|
||||
uint8_t num_ipv6_addresses_{0};
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
|
||||
// Group all boolean values together
|
||||
bool fast_connect_{false};
|
||||
#ifdef USE_WIFI_FAST_CONNECT
|
||||
bool trying_loaded_ap_{false};
|
||||
#endif
|
||||
bool retry_hidden_{false};
|
||||
bool has_ap_{false};
|
||||
bool handled_connected_state_{false};
|
||||
|
||||
@@ -706,10 +706,10 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) {
|
||||
|
||||
this->scan_result_.init(count);
|
||||
for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) {
|
||||
WiFiScanResult res({it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
|
||||
std::string(reinterpret_cast<char *>(it->ssid), it->ssid_len), it->channel, it->rssi,
|
||||
it->authmode != AUTH_OPEN, it->is_hidden != 0);
|
||||
this->scan_result_.push_back(res);
|
||||
this->scan_result_.emplace_back(
|
||||
bssid_t{it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]},
|
||||
std::string(reinterpret_cast<char *>(it->ssid), it->ssid_len), it->channel, it->rssi, it->authmode != AUTH_OPEN,
|
||||
it->is_hidden != 0);
|
||||
}
|
||||
this->scan_done_ = true;
|
||||
}
|
||||
|
||||
@@ -789,8 +789,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
|
||||
bssid_t bssid;
|
||||
std::copy(record.bssid, record.bssid + 6, bssid.begin());
|
||||
std::string ssid(reinterpret_cast<const char *>(record.ssid));
|
||||
WiFiScanResult result(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty());
|
||||
scan_result_.push_back(result);
|
||||
scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN,
|
||||
ssid.empty());
|
||||
}
|
||||
|
||||
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) {
|
||||
|
||||
@@ -419,9 +419,9 @@ void WiFiComponent::wifi_scan_done_callback_() {
|
||||
uint8_t *bssid = WiFi.BSSID(i);
|
||||
int32_t channel = WiFi.channel(i);
|
||||
|
||||
WiFiScanResult scan({bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]}, std::string(ssid.c_str()),
|
||||
channel, rssi, authmode != WIFI_AUTH_OPEN, ssid.length() == 0);
|
||||
this->scan_result_.push_back(scan);
|
||||
this->scan_result_.emplace_back(bssid_t{bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]},
|
||||
std::string(ssid.c_str()), channel, rssi, authmode != WIFI_AUTH_OPEN,
|
||||
ssid.length() == 0);
|
||||
}
|
||||
WiFi.scanDelete();
|
||||
this->scan_done_ = true;
|
||||
|
||||
@@ -81,7 +81,9 @@ const uint32_t YASHIMA_CARRIER_FREQUENCY = 38000;
|
||||
|
||||
climate::ClimateTraits YashimaClimate::traits() {
|
||||
auto traits = climate::ClimateTraits();
|
||||
traits.set_supports_current_temperature(this->sensor_ != nullptr);
|
||||
if (this->sensor_ != nullptr) {
|
||||
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
|
||||
}
|
||||
|
||||
traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL});
|
||||
if (supports_cool_)
|
||||
@@ -89,7 +91,6 @@ climate::ClimateTraits YashimaClimate::traits() {
|
||||
if (supports_heat_)
|
||||
traits.add_supported_mode(climate::CLIMATE_MODE_HEAT);
|
||||
|
||||
traits.set_supports_two_point_target_temperature(false);
|
||||
traits.set_visual_min_temperature(YASHIMA_TEMP_MIN);
|
||||
traits.set_visual_max_temperature(YASHIMA_TEMP_MAX);
|
||||
traits.set_visual_temperature_step(1);
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from esphome import core, loader, pins, yaml_util
|
||||
from esphome.config_helpers import Extend, Remove, merge_dicts_ordered
|
||||
from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
@@ -324,13 +324,7 @@ def iter_ids(config, path=None):
|
||||
yield from iter_ids(value, path + [key])
|
||||
|
||||
|
||||
def recursive_check_replaceme(value):
|
||||
if isinstance(value, list):
|
||||
return cv.Schema([recursive_check_replaceme])(value)
|
||||
if isinstance(value, dict):
|
||||
return cv.Schema({cv.valid: recursive_check_replaceme})(value)
|
||||
if isinstance(value, ESPLiteralValue):
|
||||
pass
|
||||
def check_replaceme(value):
|
||||
if isinstance(value, str) and value == "REPLACEME":
|
||||
raise cv.Invalid(
|
||||
"Found 'REPLACEME' in configuration, this is most likely an error. "
|
||||
@@ -339,7 +333,86 @@ def recursive_check_replaceme(value):
|
||||
"If you want to use the literal REPLACEME string, "
|
||||
'please use "!literal REPLACEME"'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _build_list_index(lst):
|
||||
index = OrderedDict()
|
||||
extensions, removals = [], set()
|
||||
for item in lst:
|
||||
if item is None:
|
||||
removals.add(None)
|
||||
continue
|
||||
item_id = None
|
||||
if isinstance(item, dict) and (item_id := item.get(CONF_ID)):
|
||||
if isinstance(item_id, Extend):
|
||||
extensions.append(item)
|
||||
continue
|
||||
if isinstance(item_id, Remove):
|
||||
removals.add(item_id.value)
|
||||
continue
|
||||
if not item_id or item_id in index:
|
||||
# no id or duplicate -> pass through with identity-based key
|
||||
item_id = id(item)
|
||||
index[item_id] = item
|
||||
return index, extensions, removals
|
||||
|
||||
|
||||
def resolve_extend_remove(value, is_key=None):
|
||||
if isinstance(value, ESPLiteralValue):
|
||||
return # do not check inside literal blocks
|
||||
if isinstance(value, list):
|
||||
index, extensions, removals = _build_list_index(value)
|
||||
if extensions or removals:
|
||||
# Rebuild the original list after
|
||||
# processing all extensions and removals
|
||||
for item in extensions:
|
||||
item_id = item[CONF_ID].value
|
||||
if item_id in removals:
|
||||
continue
|
||||
old = index.get(item_id)
|
||||
if old is None:
|
||||
# Failed to find source for extension
|
||||
# Find index of item to show error at correct position
|
||||
i = next(
|
||||
(
|
||||
i
|
||||
for i, d in enumerate(value)
|
||||
if d.get(CONF_ID) == item[CONF_ID]
|
||||
)
|
||||
)
|
||||
with cv.prepend_path(i):
|
||||
raise cv.Invalid(
|
||||
f"Source for extension of ID '{item_id}' was not found."
|
||||
)
|
||||
item[CONF_ID] = item_id
|
||||
index[item_id] = merge_config(old, item)
|
||||
for item_id in removals:
|
||||
index.pop(item_id, None)
|
||||
|
||||
value[:] = index.values()
|
||||
|
||||
for i, item in enumerate(value):
|
||||
with cv.prepend_path(i):
|
||||
resolve_extend_remove(item, False)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
removals = []
|
||||
for k, v in value.items():
|
||||
with cv.prepend_path(k):
|
||||
if isinstance(v, Remove):
|
||||
removals.append(k)
|
||||
continue
|
||||
resolve_extend_remove(k, True)
|
||||
resolve_extend_remove(v, False)
|
||||
for k in removals:
|
||||
value.pop(k, None)
|
||||
return
|
||||
if is_key:
|
||||
return # do not check keys (yet)
|
||||
|
||||
check_replaceme(value)
|
||||
|
||||
return
|
||||
|
||||
|
||||
class ConfigValidationStep(abc.ABC):
|
||||
@@ -437,19 +510,6 @@ class LoadValidationStep(ConfigValidationStep):
|
||||
continue
|
||||
p_name = p_config.get("platform")
|
||||
if p_name is None:
|
||||
p_id = p_config.get(CONF_ID)
|
||||
if isinstance(p_id, Extend):
|
||||
result.add_str_error(
|
||||
f"Source for extension of ID '{p_id.value}' was not found.",
|
||||
path + [CONF_ID],
|
||||
)
|
||||
continue
|
||||
if isinstance(p_id, Remove):
|
||||
result.add_str_error(
|
||||
f"Source for removal of ID '{p_id.value}' was not found.",
|
||||
path + [CONF_ID],
|
||||
)
|
||||
continue
|
||||
result.add_str_error(
|
||||
f"'{self.domain}' requires a 'platform' key but it was not specified.",
|
||||
path,
|
||||
@@ -934,9 +994,10 @@ def validate_config(
|
||||
|
||||
CORE.raw_config = config
|
||||
|
||||
# 1.1. Check for REPLACEME special value
|
||||
# 1.1. Resolve !extend and !remove and check for REPLACEME
|
||||
# After this step, there will not be any Extend or Remove values in the config anymore
|
||||
try:
|
||||
recursive_check_replaceme(config)
|
||||
resolve_extend_remove(config)
|
||||
except vol.Invalid as err:
|
||||
result.add_error(err)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_LEVEL,
|
||||
CONF_LOGGER,
|
||||
KEY_CORE,
|
||||
@@ -75,73 +74,28 @@ class Remove:
|
||||
return isinstance(b, Remove) and self.value == b.value
|
||||
|
||||
|
||||
def merge_config(full_old, full_new):
|
||||
def merge(old, new):
|
||||
if isinstance(new, dict):
|
||||
if not isinstance(old, dict):
|
||||
return new
|
||||
# Preserve OrderedDict type by copying to OrderedDict if either input is OrderedDict
|
||||
if isinstance(old, OrderedDict) or isinstance(new, OrderedDict):
|
||||
res = OrderedDict(old)
|
||||
else:
|
||||
res = old.copy()
|
||||
for k, v in new.items():
|
||||
if isinstance(v, Remove) and k in old:
|
||||
del res[k]
|
||||
else:
|
||||
res[k] = merge(old[k], v) if k in old else v
|
||||
return res
|
||||
if isinstance(new, list):
|
||||
if not isinstance(old, list):
|
||||
return new
|
||||
res = old.copy()
|
||||
ids = {
|
||||
v_id: i
|
||||
for i, v in enumerate(res)
|
||||
if isinstance(v, dict)
|
||||
and (v_id := v.get(CONF_ID))
|
||||
and isinstance(v_id, str)
|
||||
}
|
||||
extend_ids = {
|
||||
v_id.value: i
|
||||
for i, v in enumerate(res)
|
||||
if isinstance(v, dict)
|
||||
and (v_id := v.get(CONF_ID))
|
||||
and isinstance(v_id, Extend)
|
||||
}
|
||||
|
||||
ids_to_delete = []
|
||||
for v in new:
|
||||
if isinstance(v, dict) and (new_id := v.get(CONF_ID)):
|
||||
if isinstance(new_id, Extend):
|
||||
new_id = new_id.value
|
||||
if new_id in ids:
|
||||
v[CONF_ID] = new_id
|
||||
res[ids[new_id]] = merge(res[ids[new_id]], v)
|
||||
continue
|
||||
elif isinstance(new_id, Remove):
|
||||
new_id = new_id.value
|
||||
if new_id in ids:
|
||||
ids_to_delete.append(ids[new_id])
|
||||
continue
|
||||
elif (
|
||||
new_id in extend_ids
|
||||
): # When a package is extending a non-packaged item
|
||||
extend_res = res[extend_ids[new_id]]
|
||||
extend_res[CONF_ID] = new_id
|
||||
new_v = merge(v, extend_res)
|
||||
res[extend_ids[new_id]] = new_v
|
||||
continue
|
||||
else:
|
||||
ids[new_id] = len(res)
|
||||
res.append(v)
|
||||
return [v for i, v in enumerate(res) if i not in ids_to_delete]
|
||||
if new is None:
|
||||
return old
|
||||
|
||||
def merge_config(old, new):
|
||||
if isinstance(new, Remove):
|
||||
return new
|
||||
if isinstance(new, dict):
|
||||
if not isinstance(old, dict):
|
||||
return new
|
||||
# Preserve OrderedDict type by copying to OrderedDict if either input is OrderedDict
|
||||
if isinstance(old, OrderedDict) or isinstance(new, OrderedDict):
|
||||
res = OrderedDict(old)
|
||||
else:
|
||||
res = old.copy()
|
||||
for k, v in new.items():
|
||||
res[k] = merge_config(old.get(k), v)
|
||||
return res
|
||||
if isinstance(new, list):
|
||||
if not isinstance(old, list):
|
||||
return new
|
||||
return old + new
|
||||
if new is None:
|
||||
return old
|
||||
|
||||
return merge(full_old, full_new)
|
||||
return new
|
||||
|
||||
|
||||
def filter_source_files_from_platform(
|
||||
|
||||
@@ -24,7 +24,6 @@ import voluptuous as vol
|
||||
|
||||
from esphome import core
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import Extend, Remove
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
CONF_AVAILABILITY,
|
||||
@@ -624,12 +623,6 @@ def declare_id(type):
|
||||
if value is None:
|
||||
return core.ID(None, is_declaration=True, type=type)
|
||||
|
||||
if isinstance(value, Extend):
|
||||
raise Invalid(f"Source for extension of ID '{value.value}' was not found.")
|
||||
|
||||
if isinstance(value, Remove):
|
||||
raise Invalid(f"Source for Removal of ID '{value.value}' was not found.")
|
||||
|
||||
return core.ID(validate_id_name(value), is_declaration=True, type=type)
|
||||
|
||||
return validator
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.const import (
|
||||
CONF_COMMENT,
|
||||
CONF_ESPHOME,
|
||||
CONF_ETHERNET,
|
||||
CONF_OPENTHREAD,
|
||||
CONF_PORT,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_WEB_SERVER,
|
||||
@@ -641,6 +642,9 @@ class EsphomeCore:
|
||||
if CONF_ETHERNET in self.config:
|
||||
return self.config[CONF_ETHERNET][CONF_USE_ADDRESS]
|
||||
|
||||
if CONF_OPENTHREAD in self.config:
|
||||
return f"{self.name}.local"
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
#include "esphome/components/fan/fan_state.h"
|
||||
#include "esphome/components/fan/fan.h"
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
#include "esphome/components/climate/climate.h"
|
||||
|
||||
@@ -318,7 +318,8 @@ def preload_core_config(config, result) -> str:
|
||||
target_platforms = []
|
||||
|
||||
for domain in config:
|
||||
if domain.startswith("."):
|
||||
# Skip package keys which may contain periods (e.g., "ratgdo.esphome")
|
||||
if "." in domain:
|
||||
continue
|
||||
if _is_target_platform(domain):
|
||||
target_platforms += [domain]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
#include "esphome/components/fan/fan_state.h"
|
||||
#include "esphome/components/fan/fan.h"
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
#include "esphome/components/light/light_state.h"
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
#define USE_WEBSERVER_PORT 80 // NOLINT
|
||||
#define USE_WEBSERVER_SORTING
|
||||
#define USE_WIFI_11KV_SUPPORT
|
||||
#define USE_WIFI_FAST_CONNECT
|
||||
#define USB_HOST_MAX_REQUESTS 16
|
||||
|
||||
#ifdef USE_ARDUINO
|
||||
@@ -272,6 +273,8 @@
|
||||
|
||||
#ifdef USE_NRF52
|
||||
#define USE_NRF52_DFU
|
||||
#define USE_SOFTDEVICE_ID 7
|
||||
#define USE_SOFTDEVICE_VERSION 1
|
||||
#endif
|
||||
|
||||
// Disabled feature flags
|
||||
|
||||
@@ -281,13 +281,13 @@ template<typename T> class FixedVector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emplace element without bounds checking - constructs in-place
|
||||
/// Emplace element without bounds checking - constructs in-place with arguments
|
||||
/// Caller must ensure sufficient capacity was allocated via init()
|
||||
/// Returns reference to the newly constructed element
|
||||
/// NOTE: Caller MUST ensure size_ < capacity_ before calling
|
||||
T &emplace_back() {
|
||||
// Use placement new to default-construct the object in pre-allocated memory
|
||||
new (&data_[size_]) T();
|
||||
template<typename... Args> T &emplace_back(Args &&...args) {
|
||||
// Use placement new to construct the object in pre-allocated memory
|
||||
new (&data_[size_]) T(std::forward<Args>(args)...);
|
||||
size_++;
|
||||
return data_[size_ - 1];
|
||||
}
|
||||
@@ -1158,18 +1158,4 @@ template<typename T, enable_if_t<std::is_pointer<T *>::value, int> = 0> T &id(T
|
||||
|
||||
///@}
|
||||
|
||||
/// @name Deprecated functions
|
||||
///@{
|
||||
|
||||
ESPDEPRECATED("hexencode() is deprecated, use format_hex_pretty() instead.", "2022.1")
|
||||
inline std::string hexencode(const uint8_t *data, uint32_t len) { return format_hex_pretty(data, len); }
|
||||
|
||||
template<typename T>
|
||||
ESPDEPRECATED("hexencode() is deprecated, use format_hex_pretty() instead.", "2022.1")
|
||||
std::string hexencode(const T &data) {
|
||||
return hexencode(data.data(), data.size());
|
||||
}
|
||||
|
||||
///@}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
88
script/ci_add_metadata_to_json.py
Executable file
88
script/ci_add_metadata_to_json.py
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add metadata to memory analysis JSON file.
|
||||
|
||||
This script adds components and platform metadata to an existing
|
||||
memory analysis JSON file. Used by CI to ensure all required fields are present
|
||||
for the comment script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add metadata to memory analysis JSON file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json-file",
|
||||
required=True,
|
||||
help="Path to JSON file to update",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
required=True,
|
||||
help='JSON array of component names (e.g., \'["api", "wifi"]\')',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
required=True,
|
||||
help="Platform name",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load existing JSON
|
||||
json_path = Path(args.json_file)
|
||||
if not json_path.exists():
|
||||
print(f"Error: JSON file not found: {args.json_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Error loading JSON: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Parse components
|
||||
try:
|
||||
components = json.loads(args.components)
|
||||
if not isinstance(components, list):
|
||||
print("Error: --components must be a JSON array", file=sys.stderr)
|
||||
return 1
|
||||
# Element-level validation: ensure each component is a non-empty string
|
||||
for idx, comp in enumerate(components):
|
||||
if not isinstance(comp, str) or not comp.strip():
|
||||
print(
|
||||
f"Error: component at index {idx} is not a non-empty string: {comp!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing components: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Add metadata
|
||||
data["components"] = components
|
||||
data["platform"] = args.platform
|
||||
|
||||
# Write back
|
||||
try:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f"Added metadata to {args.json_file}", file=sys.stderr)
|
||||
except OSError as e:
|
||||
print(f"Error writing JSON: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -24,6 +24,37 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
# Comment marker to identify our memory impact comments
|
||||
COMMENT_MARKER = "<!-- esphome-memory-impact-analysis -->"
|
||||
|
||||
|
||||
def run_gh_command(args: list[str], operation: str) -> subprocess.CompletedProcess:
|
||||
"""Run a gh CLI command with error handling.
|
||||
|
||||
Args:
|
||||
args: Command arguments (including 'gh')
|
||||
operation: Description of the operation for error messages
|
||||
|
||||
Returns:
|
||||
CompletedProcess result
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If command fails (with detailed error output)
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
args,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(
|
||||
f"ERROR: {operation} failed with exit code {e.returncode}", file=sys.stderr
|
||||
)
|
||||
print(f"ERROR: Command: {' '.join(args)}", file=sys.stderr)
|
||||
print(f"ERROR: stdout: {e.stdout}", file=sys.stderr)
|
||||
print(f"ERROR: stderr: {e.stderr}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
# Thresholds for emoji significance indicators (percentage)
|
||||
OVERALL_CHANGE_THRESHOLD = 1.0 # Overall RAM/Flash changes
|
||||
COMPONENT_CHANGE_THRESHOLD = 3.0 # Component breakdown changes
|
||||
@@ -238,7 +269,6 @@ def create_comment_body(
|
||||
pr_analysis: dict | None = None,
|
||||
target_symbols: dict | None = None,
|
||||
pr_symbols: dict | None = None,
|
||||
target_cache_hit: bool = False,
|
||||
) -> str:
|
||||
"""Create the comment body with memory impact analysis using Jinja2 templates.
|
||||
|
||||
@@ -253,7 +283,6 @@ def create_comment_body(
|
||||
pr_analysis: Optional component breakdown for PR branch
|
||||
target_symbols: Optional symbol map for target branch
|
||||
pr_symbols: Optional symbol map for PR branch
|
||||
target_cache_hit: Whether target branch analysis was loaded from cache
|
||||
|
||||
Returns:
|
||||
Formatted comment body
|
||||
@@ -283,7 +312,6 @@ def create_comment_body(
|
||||
"flash_change": format_change(
|
||||
target_flash, pr_flash, threshold=OVERALL_CHANGE_THRESHOLD
|
||||
),
|
||||
"target_cache_hit": target_cache_hit,
|
||||
"component_change_threshold": COMPONENT_CHANGE_THRESHOLD,
|
||||
}
|
||||
|
||||
@@ -356,7 +384,7 @@ def find_existing_comment(pr_number: str) -> str | None:
|
||||
print(f"DEBUG: Looking for existing comment on PR #{pr_number}", file=sys.stderr)
|
||||
|
||||
# Use gh api to get comments directly - this returns the numeric id field
|
||||
result = subprocess.run(
|
||||
result = run_gh_command(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
@@ -364,9 +392,7 @@ def find_existing_comment(pr_number: str) -> str | None:
|
||||
"--jq",
|
||||
".[] | {id, body}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
operation="Get PR comments",
|
||||
)
|
||||
|
||||
print(
|
||||
@@ -420,7 +446,8 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None:
|
||||
subprocess.CalledProcessError: If gh command fails
|
||||
"""
|
||||
print(f"DEBUG: Updating existing comment {comment_id}", file=sys.stderr)
|
||||
result = subprocess.run(
|
||||
print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr)
|
||||
result = run_gh_command(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
@@ -430,9 +457,7 @@ def update_existing_comment(comment_id: str, comment_body: str) -> None:
|
||||
"-f",
|
||||
f"body={comment_body}",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
operation="Update PR comment",
|
||||
)
|
||||
print(f"DEBUG: Update response: {result.stdout}", file=sys.stderr)
|
||||
|
||||
@@ -448,11 +473,10 @@ def create_new_comment(pr_number: str, comment_body: str) -> None:
|
||||
subprocess.CalledProcessError: If gh command fails
|
||||
"""
|
||||
print(f"DEBUG: Posting new comment on PR #{pr_number}", file=sys.stderr)
|
||||
result = subprocess.run(
|
||||
print(f"DEBUG: Comment body length: {len(comment_body)} bytes", file=sys.stderr)
|
||||
result = run_gh_command(
|
||||
["gh", "pr", "comment", pr_number, "--body", comment_body],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
operation="Create PR comment",
|
||||
)
|
||||
print(f"DEBUG: Post response: {result.stdout}", file=sys.stderr)
|
||||
|
||||
@@ -484,80 +508,129 @@ def main() -> int:
|
||||
description="Post or update PR comment with memory impact analysis"
|
||||
)
|
||||
parser.add_argument("--pr-number", required=True, help="PR number")
|
||||
parser.add_argument(
|
||||
"--components",
|
||||
required=True,
|
||||
help='JSON array of component names (e.g., \'["api", "wifi"]\')',
|
||||
)
|
||||
parser.add_argument("--platform", required=True, help="Platform name")
|
||||
parser.add_argument(
|
||||
"--target-ram", type=int, required=True, help="Target branch RAM usage"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-flash", type=int, required=True, help="Target branch flash usage"
|
||||
)
|
||||
parser.add_argument("--pr-ram", type=int, required=True, help="PR branch RAM usage")
|
||||
parser.add_argument(
|
||||
"--pr-flash", type=int, required=True, help="PR branch flash usage"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-json",
|
||||
help="Optional path to target branch analysis JSON (for detailed analysis)",
|
||||
required=True,
|
||||
help="Path to target branch analysis JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-json",
|
||||
help="Optional path to PR branch analysis JSON (for detailed analysis)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-cache-hit",
|
||||
action="store_true",
|
||||
help="Indicates that target branch analysis was loaded from cache",
|
||||
required=True,
|
||||
help="Path to PR branch analysis JSON file",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse components from JSON
|
||||
try:
|
||||
components = json.loads(args.components)
|
||||
if not isinstance(components, list):
|
||||
print("Error: --components must be a JSON array", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing --components JSON: {e}", file=sys.stderr)
|
||||
# Load analysis JSON files (all data comes from JSON for security)
|
||||
target_data: dict | None = load_analysis_json(args.target_json)
|
||||
if not target_data:
|
||||
print("Error: Failed to load target analysis JSON", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load analysis JSON files
|
||||
target_analysis = None
|
||||
pr_analysis = None
|
||||
target_symbols = None
|
||||
pr_symbols = None
|
||||
pr_data: dict | None = load_analysis_json(args.pr_json)
|
||||
if not pr_data:
|
||||
print("Error: Failed to load PR analysis JSON", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.target_json:
|
||||
target_data = load_analysis_json(args.target_json)
|
||||
if target_data and target_data.get("detailed_analysis"):
|
||||
target_analysis = target_data["detailed_analysis"].get("components")
|
||||
target_symbols = target_data["detailed_analysis"].get("symbols")
|
||||
# Extract detailed analysis if available
|
||||
target_analysis: dict | None = None
|
||||
pr_analysis: dict | None = None
|
||||
target_symbols: dict | None = None
|
||||
pr_symbols: dict | None = None
|
||||
|
||||
if args.pr_json:
|
||||
pr_data = load_analysis_json(args.pr_json)
|
||||
if pr_data and pr_data.get("detailed_analysis"):
|
||||
pr_analysis = pr_data["detailed_analysis"].get("components")
|
||||
pr_symbols = pr_data["detailed_analysis"].get("symbols")
|
||||
if target_data.get("detailed_analysis"):
|
||||
target_analysis = target_data["detailed_analysis"].get("components")
|
||||
target_symbols = target_data["detailed_analysis"].get("symbols")
|
||||
|
||||
if pr_data.get("detailed_analysis"):
|
||||
pr_analysis = pr_data["detailed_analysis"].get("components")
|
||||
pr_symbols = pr_data["detailed_analysis"].get("symbols")
|
||||
|
||||
# Extract all values from JSON files (prevents shell injection from PR code)
|
||||
components = target_data.get("components")
|
||||
platform = target_data.get("platform")
|
||||
target_ram = target_data.get("ram_bytes")
|
||||
target_flash = target_data.get("flash_bytes")
|
||||
pr_ram = pr_data.get("ram_bytes")
|
||||
pr_flash = pr_data.get("flash_bytes")
|
||||
|
||||
# Validate required fields and types
|
||||
missing_fields: list[str] = []
|
||||
type_errors: list[str] = []
|
||||
|
||||
if components is None:
|
||||
missing_fields.append("components")
|
||||
elif not isinstance(components, list):
|
||||
type_errors.append(
|
||||
f"components must be a list, got {type(components).__name__}"
|
||||
)
|
||||
else:
|
||||
for idx, comp in enumerate(components):
|
||||
if not isinstance(comp, str):
|
||||
type_errors.append(
|
||||
f"components[{idx}] must be a string, got {type(comp).__name__}"
|
||||
)
|
||||
if platform is None:
|
||||
missing_fields.append("platform")
|
||||
elif not isinstance(platform, str):
|
||||
type_errors.append(f"platform must be a string, got {type(platform).__name__}")
|
||||
|
||||
if target_ram is None:
|
||||
missing_fields.append("target.ram_bytes")
|
||||
elif not isinstance(target_ram, int):
|
||||
type_errors.append(
|
||||
f"target.ram_bytes must be an integer, got {type(target_ram).__name__}"
|
||||
)
|
||||
|
||||
if target_flash is None:
|
||||
missing_fields.append("target.flash_bytes")
|
||||
elif not isinstance(target_flash, int):
|
||||
type_errors.append(
|
||||
f"target.flash_bytes must be an integer, got {type(target_flash).__name__}"
|
||||
)
|
||||
|
||||
if pr_ram is None:
|
||||
missing_fields.append("pr.ram_bytes")
|
||||
elif not isinstance(pr_ram, int):
|
||||
type_errors.append(
|
||||
f"pr.ram_bytes must be an integer, got {type(pr_ram).__name__}"
|
||||
)
|
||||
|
||||
if pr_flash is None:
|
||||
missing_fields.append("pr.flash_bytes")
|
||||
elif not isinstance(pr_flash, int):
|
||||
type_errors.append(
|
||||
f"pr.flash_bytes must be an integer, got {type(pr_flash).__name__}"
|
||||
)
|
||||
|
||||
if missing_fields or type_errors:
|
||||
if missing_fields:
|
||||
print(
|
||||
f"Error: JSON files missing required fields: {', '.join(missing_fields)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if type_errors:
|
||||
print(
|
||||
f"Error: Type validation failed: {'; '.join(type_errors)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Target JSON keys: {list(target_data.keys())}", file=sys.stderr)
|
||||
print(f"PR JSON keys: {list(pr_data.keys())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Create comment body
|
||||
# Note: Memory totals (RAM/Flash) are summed across all builds if multiple were run.
|
||||
comment_body = create_comment_body(
|
||||
components=components,
|
||||
platform=args.platform,
|
||||
target_ram=args.target_ram,
|
||||
target_flash=args.target_flash,
|
||||
pr_ram=args.pr_ram,
|
||||
pr_flash=args.pr_flash,
|
||||
platform=platform,
|
||||
target_ram=target_ram,
|
||||
target_flash=target_flash,
|
||||
pr_ram=pr_ram,
|
||||
pr_flash=pr_flash,
|
||||
target_analysis=target_analysis,
|
||||
pr_analysis=pr_analysis,
|
||||
target_symbols=target_symbols,
|
||||
pr_symbols=pr_symbols,
|
||||
target_cache_hit=args.target_cache_hit,
|
||||
)
|
||||
|
||||
# Post or update comment
|
||||
|
||||
@@ -273,6 +273,9 @@ def detect_memory_impact_config(
|
||||
building a merged configuration with all changed components (like
|
||||
test_build_components.py does) to get comprehensive memory analysis.
|
||||
|
||||
For core C++ file changes without component changes, runs a fallback
|
||||
analysis using a representative component to measure the impact.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against
|
||||
|
||||
@@ -289,7 +292,7 @@ def detect_memory_impact_config(
|
||||
|
||||
# Find all changed components (excluding core and base bus components)
|
||||
changed_component_set: set[str] = set()
|
||||
has_core_changes = False
|
||||
has_core_cpp_changes = False
|
||||
|
||||
for file in files:
|
||||
component = get_component_from_path(file)
|
||||
@@ -297,22 +300,23 @@ def detect_memory_impact_config(
|
||||
# Skip base bus components as they're used across many builds
|
||||
if component not in BASE_BUS_COMPONENTS:
|
||||
changed_component_set.add(component)
|
||||
elif file.startswith("esphome/"):
|
||||
# Core ESPHome files changed (not component-specific)
|
||||
has_core_changes = True
|
||||
elif file.startswith("esphome/") and file.endswith(CPP_FILE_EXTENSIONS):
|
||||
# Core ESPHome C++ files changed (not component-specific)
|
||||
# Only C++ files affect memory usage
|
||||
has_core_cpp_changes = True
|
||||
|
||||
# If no components changed but core changed, test representative component
|
||||
# If no components changed but core C++ changed, test representative component
|
||||
force_fallback_platform = False
|
||||
if not changed_component_set and has_core_changes:
|
||||
if not changed_component_set and has_core_cpp_changes:
|
||||
print(
|
||||
f"Memory impact: No components changed, but core files changed. "
|
||||
f"Memory impact: No components changed, but core C++ files changed. "
|
||||
f"Testing {MEMORY_IMPACT_FALLBACK_COMPONENT} component on {MEMORY_IMPACT_FALLBACK_PLATFORM}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
changed_component_set.add(MEMORY_IMPACT_FALLBACK_COMPONENT)
|
||||
force_fallback_platform = True # Use fallback platform (most representative)
|
||||
elif not changed_component_set:
|
||||
# No components and no core changes
|
||||
# No components and no core C++ changes
|
||||
return {"should_run": "false"}
|
||||
|
||||
# Find components that have tests and collect their supported platforms
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from esphome.components.packages import do_packages_pass
|
||||
from esphome.config import resolve_extend_remove
|
||||
from esphome.config_helpers import Extend, Remove
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -64,13 +65,20 @@ def fixture_basic_esphome():
|
||||
return {CONF_NAME: TEST_DEVICE_NAME, CONF_PLATFORM: TEST_PLATFORM}
|
||||
|
||||
|
||||
def packages_pass(config):
|
||||
"""Wrapper around packages_pass that also resolves Extend and Remove."""
|
||||
config = do_packages_pass(config)
|
||||
resolve_extend_remove(config)
|
||||
return config
|
||||
|
||||
|
||||
def test_package_unused(basic_esphome, basic_wifi):
|
||||
"""
|
||||
Ensures do_package_pass does not change a config if packages aren't used.
|
||||
"""
|
||||
config = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == config
|
||||
|
||||
|
||||
@@ -83,7 +91,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi):
|
||||
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: basic_wifi | {CONF_URL: ""}}
|
||||
|
||||
with pytest.raises(cv.Invalid):
|
||||
do_packages_pass(config)
|
||||
packages_pass(config)
|
||||
|
||||
|
||||
def test_package_include(basic_wifi, basic_esphome):
|
||||
@@ -99,7 +107,7 @@ def test_package_include(basic_wifi, basic_esphome):
|
||||
|
||||
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -124,7 +132,7 @@ def test_package_append(basic_wifi, basic_esphome):
|
||||
},
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -148,7 +156,7 @@ def test_package_override(basic_wifi, basic_esphome):
|
||||
},
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -177,7 +185,7 @@ def test_multiple_package_order():
|
||||
},
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -233,7 +241,7 @@ def test_package_list_merge():
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -311,7 +319,7 @@ def test_package_list_merge_by_id():
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -350,13 +358,13 @@ def test_package_merge_by_id_with_list():
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_package_merge_by_missing_id():
|
||||
"""
|
||||
Ensures that components with missing IDs are not merged.
|
||||
Ensures that a validation error is thrown when trying to extend a missing ID.
|
||||
"""
|
||||
|
||||
config = {
|
||||
@@ -379,25 +387,15 @@ def test_package_merge_by_missing_id():
|
||||
],
|
||||
}
|
||||
|
||||
expected = {
|
||||
CONF_SENSOR: [
|
||||
{
|
||||
CONF_ID: TEST_SENSOR_ID_1,
|
||||
CONF_FILTERS: [{CONF_MULTIPLY: 42.0}],
|
||||
},
|
||||
{
|
||||
CONF_ID: TEST_SENSOR_ID_1,
|
||||
CONF_FILTERS: [{CONF_MULTIPLY: 10.0}],
|
||||
},
|
||||
{
|
||||
CONF_ID: Extend(TEST_SENSOR_ID_2),
|
||||
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
|
||||
},
|
||||
]
|
||||
}
|
||||
error_raised = False
|
||||
try:
|
||||
packages_pass(config)
|
||||
assert False, "Expected validation error for missing ID"
|
||||
except cv.Invalid as err:
|
||||
error_raised = True
|
||||
assert err.path == [CONF_SENSOR, 2]
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
assert actual == expected
|
||||
assert error_raised
|
||||
|
||||
|
||||
def test_package_list_remove_by_id():
|
||||
@@ -447,7 +445,7 @@ def test_package_list_remove_by_id():
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -493,7 +491,7 @@ def test_multiple_package_list_remove_by_id():
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -514,7 +512,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome):
|
||||
CONF_ESPHOME: basic_esphome,
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -545,7 +543,6 @@ def test_package_remove_by_missing_id():
|
||||
}
|
||||
|
||||
expected = {
|
||||
"missing_key": Remove(),
|
||||
CONF_SENSOR: [
|
||||
{
|
||||
CONF_ID: TEST_SENSOR_ID_1,
|
||||
@@ -555,14 +552,10 @@ def test_package_remove_by_missing_id():
|
||||
CONF_ID: TEST_SENSOR_ID_1,
|
||||
CONF_FILTERS: [{CONF_MULTIPLY: 10.0}],
|
||||
},
|
||||
{
|
||||
CONF_ID: Remove(TEST_SENSOR_ID_2),
|
||||
CONF_FILTERS: [{CONF_OFFSET: 146.0}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -634,7 +627,7 @@ def test_remote_packages_with_files_list(
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
@@ -730,5 +723,5 @@ def test_remote_packages_with_files_and_vars(
|
||||
]
|
||||
}
|
||||
|
||||
actual = do_packages_pass(config)
|
||||
actual = packages_pass(config)
|
||||
assert actual == expected
|
||||
|
||||
31
tests/components/climate/common.yaml
Normal file
31
tests/components/climate/common.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
switch:
|
||||
- platform: template
|
||||
id: climate_heater_switch
|
||||
optimistic: true
|
||||
- platform: template
|
||||
id: climate_cooler_switch
|
||||
optimistic: true
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: climate_temperature_sensor
|
||||
lambda: |-
|
||||
return 21.5;
|
||||
update_interval: 60s
|
||||
|
||||
climate:
|
||||
- platform: bang_bang
|
||||
id: climate_test_climate
|
||||
name: Test Climate
|
||||
sensor: climate_temperature_sensor
|
||||
default_target_temperature_low: 18°C
|
||||
default_target_temperature_high: 24°C
|
||||
idle_action:
|
||||
- switch.turn_off: climate_heater_switch
|
||||
- switch.turn_off: climate_cooler_switch
|
||||
cool_action:
|
||||
- switch.turn_on: climate_cooler_switch
|
||||
- switch.turn_off: climate_heater_switch
|
||||
heat_action:
|
||||
- switch.turn_on: climate_heater_switch
|
||||
- switch.turn_off: climate_cooler_switch
|
||||
1
tests/components/climate/test.esp8266-ard.yaml
Normal file
1
tests/components/climate/test.esp8266-ard.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -16,3 +16,4 @@ esp32_improv:
|
||||
authorizer: io0_button
|
||||
authorized_duration: 1min
|
||||
status_indicator: built_in_led
|
||||
next_url: "https://example.com/setup?device={{device_name}}&ip={{ip_address}}&version={{esphome_version}}"
|
||||
|
||||
@@ -11,18 +11,17 @@ time:
|
||||
- 192.168.178.1
|
||||
|
||||
uponor_smatrix:
|
||||
address: 0x110B
|
||||
time_id: sntp_time
|
||||
time_device_address: 0xDE13
|
||||
time_device_address: 0x110BDE13
|
||||
|
||||
climate:
|
||||
- platform: uponor_smatrix
|
||||
address: 0xDE13
|
||||
address: 0x110BDE13
|
||||
name: Thermostat Living Room
|
||||
|
||||
sensor:
|
||||
- platform: uponor_smatrix
|
||||
address: 0xDE13
|
||||
address: 0x110BDE13
|
||||
humidity:
|
||||
name: Thermostat Humidity Living Room
|
||||
temperature:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
wifi:
|
||||
fast_connect: true
|
||||
networks:
|
||||
- ssid: MySSID
|
||||
eap:
|
||||
|
||||
170
tests/integration/fixtures/script_queued.yaml
Normal file
170
tests/integration/fixtures/script_queued.yaml
Normal file
@@ -0,0 +1,170 @@
|
||||
esphome:
|
||||
name: test-script-queued
|
||||
|
||||
host:
|
||||
api:
|
||||
actions:
|
||||
# Test 1: Queue depth with default max_runs=5
|
||||
- action: test_queue_depth
|
||||
then:
|
||||
- logger.log: "=== TEST 1: Queue depth (max_runs=5 means 5 total, reject 6-7) ==="
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 1
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 2
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 3
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 4
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 5
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 6
|
||||
- script.execute:
|
||||
id: queue_depth_script
|
||||
value: 7
|
||||
|
||||
# Test 2: Ring buffer wrap test
|
||||
- action: test_ring_buffer
|
||||
then:
|
||||
- logger.log: "=== TEST 2: Ring buffer wrap (should process A, B, C in order) ==="
|
||||
- script.execute:
|
||||
id: wrap_script
|
||||
msg: "A"
|
||||
- script.execute:
|
||||
id: wrap_script
|
||||
msg: "B"
|
||||
- script.execute:
|
||||
id: wrap_script
|
||||
msg: "C"
|
||||
|
||||
# Test 3: Stop clears queue
|
||||
- action: test_stop_clears
|
||||
then:
|
||||
- logger.log: "=== TEST 3: Stop clears queue (should only see 1, then 'STOPPED') ==="
|
||||
- script.execute:
|
||||
id: stop_script
|
||||
num: 1
|
||||
- script.execute:
|
||||
id: stop_script
|
||||
num: 2
|
||||
- script.execute:
|
||||
id: stop_script
|
||||
num: 3
|
||||
- delay: 50ms
|
||||
- logger.log: "STOPPING script now"
|
||||
- script.stop: stop_script
|
||||
|
||||
# Test 4: Verify rejection (max_runs=3)
|
||||
- action: test_rejection
|
||||
then:
|
||||
- logger.log: "=== TEST 4: Verify rejection (max_runs=3 means 3 total, reject 4-8) ==="
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 1
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 2
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 3
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 4
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 5
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 6
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 7
|
||||
- script.execute:
|
||||
id: rejection_script
|
||||
val: 8
|
||||
|
||||
# Test 5: No parameters test
|
||||
- action: test_no_params
|
||||
then:
|
||||
- logger.log: "=== TEST 5: No params (should process 3 times) ==="
|
||||
- script.execute: no_params_script
|
||||
- script.execute: no_params_script
|
||||
- script.execute: no_params_script
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
script:
|
||||
# Test script 1: Queue depth test (default max_runs=5)
|
||||
- id: queue_depth_script
|
||||
mode: queued
|
||||
parameters:
|
||||
value: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Queue test: START item %d"
|
||||
args: ['value']
|
||||
- delay: 100ms
|
||||
- logger.log:
|
||||
format: "Queue test: END item %d"
|
||||
args: ['value']
|
||||
|
||||
# Test script 2: Ring buffer wrap test (max_runs=3)
|
||||
- id: wrap_script
|
||||
mode: queued
|
||||
max_runs: 3
|
||||
parameters:
|
||||
msg: string
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Ring buffer: START '%s'"
|
||||
args: ['msg.c_str()']
|
||||
- delay: 50ms
|
||||
- logger.log:
|
||||
format: "Ring buffer: END '%s'"
|
||||
args: ['msg.c_str()']
|
||||
|
||||
# Test script 3: Stop test
|
||||
- id: stop_script
|
||||
mode: queued
|
||||
max_runs: 5
|
||||
parameters:
|
||||
num: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Stop test: START %d"
|
||||
args: ['num']
|
||||
- delay: 100ms
|
||||
- logger.log:
|
||||
format: "Stop test: END %d"
|
||||
args: ['num']
|
||||
|
||||
# Test script 4: Rejection test (max_runs=3)
|
||||
- id: rejection_script
|
||||
mode: queued
|
||||
max_runs: 3
|
||||
parameters:
|
||||
val: int
|
||||
then:
|
||||
- logger.log:
|
||||
format: "Rejection test: START %d"
|
||||
args: ['val']
|
||||
- delay: 200ms
|
||||
- logger.log:
|
||||
format: "Rejection test: END %d"
|
||||
args: ['val']
|
||||
|
||||
# Test script 5: No parameters
|
||||
- id: no_params_script
|
||||
mode: queued
|
||||
then:
|
||||
- logger.log: "No params: START"
|
||||
- delay: 50ms
|
||||
- logger.log: "No params: END"
|
||||
@@ -281,8 +281,12 @@ async def test_noise_corrupt_encrypted_frame(
|
||||
# Check for signs that the process exited/crashed
|
||||
if "Segmentation fault" in line or "core dumped" in line:
|
||||
process_exited = True
|
||||
# Check for the expected warning about decryption failure
|
||||
# Check for the expected log about decryption failure
|
||||
# This can appear as either a VV-level log from noise or a W-level log from connection
|
||||
if (
|
||||
"[VV][api.noise" in line
|
||||
and "noise_cipherstate_decrypt failed: MAC_FAILURE" in line
|
||||
) or (
|
||||
"[W][api.connection" in line
|
||||
and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line
|
||||
):
|
||||
@@ -322,9 +326,9 @@ async def test_noise_corrupt_encrypted_frame(
|
||||
assert not process_exited, (
|
||||
"ESPHome process should not crash on corrupt encrypted frames"
|
||||
)
|
||||
# Verify we saw the expected warning message
|
||||
# Verify we saw the expected log message about decryption failure
|
||||
assert cipherstate_failed, (
|
||||
"Expected to see warning about CIPHERSTATE_DECRYPT_FAILED"
|
||||
"Expected to see log about noise_cipherstate_decrypt failure or CIPHERSTATE_DECRYPT_FAILED"
|
||||
)
|
||||
|
||||
# Verify we can still reconnect after handling the corrupt frame
|
||||
|
||||
203
tests/integration/test_script_queued.py
Normal file
203
tests/integration/test_script_queued.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Test ESPHome queued script functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_script_queued(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test comprehensive queued script functionality."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Track all test results
|
||||
test_results = {
|
||||
"queue_depth": {"processed": [], "rejections": 0},
|
||||
"ring_buffer": {"start_order": [], "end_order": []},
|
||||
"stop": {"processed": [], "stop_logged": False},
|
||||
"rejection": {"processed": [], "rejections": 0},
|
||||
"no_params": {"executions": 0},
|
||||
}
|
||||
|
||||
# Patterns for Test 1: Queue depth
|
||||
queue_start = re.compile(r"Queue test: START item (\d+)")
|
||||
queue_end = re.compile(r"Queue test: END item (\d+)")
|
||||
queue_reject = re.compile(r"Script 'queue_depth_script' max instances")
|
||||
|
||||
# Patterns for Test 2: Ring buffer
|
||||
ring_start = re.compile(r"Ring buffer: START '([A-Z])'")
|
||||
ring_end = re.compile(r"Ring buffer: END '([A-Z])'")
|
||||
|
||||
# Patterns for Test 3: Stop
|
||||
stop_start = re.compile(r"Stop test: START (\d+)")
|
||||
stop_log = re.compile(r"STOPPING script now")
|
||||
|
||||
# Patterns for Test 4: Rejection
|
||||
reject_start = re.compile(r"Rejection test: START (\d+)")
|
||||
reject_end = re.compile(r"Rejection test: END (\d+)")
|
||||
reject_reject = re.compile(r"Script 'rejection_script' max instances")
|
||||
|
||||
# Patterns for Test 5: No params
|
||||
no_params_end = re.compile(r"No params: END")
|
||||
|
||||
# Test completion futures
|
||||
test1_complete = loop.create_future()
|
||||
test2_complete = loop.create_future()
|
||||
test3_complete = loop.create_future()
|
||||
test4_complete = loop.create_future()
|
||||
test5_complete = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
"""Check log output for all test messages."""
|
||||
# Test 1: Queue depth
|
||||
if match := queue_start.search(line):
|
||||
item = int(match.group(1))
|
||||
if item not in test_results["queue_depth"]["processed"]:
|
||||
test_results["queue_depth"]["processed"].append(item)
|
||||
|
||||
if match := queue_end.search(line):
|
||||
item = int(match.group(1))
|
||||
if item == 5 and not test1_complete.done():
|
||||
test1_complete.set_result(True)
|
||||
|
||||
if queue_reject.search(line):
|
||||
test_results["queue_depth"]["rejections"] += 1
|
||||
|
||||
# Test 2: Ring buffer
|
||||
if match := ring_start.search(line):
|
||||
msg = match.group(1)
|
||||
test_results["ring_buffer"]["start_order"].append(msg)
|
||||
|
||||
if match := ring_end.search(line):
|
||||
msg = match.group(1)
|
||||
test_results["ring_buffer"]["end_order"].append(msg)
|
||||
if (
|
||||
len(test_results["ring_buffer"]["end_order"]) == 3
|
||||
and not test2_complete.done()
|
||||
):
|
||||
test2_complete.set_result(True)
|
||||
|
||||
# Test 3: Stop
|
||||
if match := stop_start.search(line):
|
||||
item = int(match.group(1))
|
||||
if item not in test_results["stop"]["processed"]:
|
||||
test_results["stop"]["processed"].append(item)
|
||||
|
||||
if stop_log.search(line):
|
||||
test_results["stop"]["stop_logged"] = True
|
||||
# Give time for any queued items to be cleared
|
||||
if not test3_complete.done():
|
||||
loop.call_later(
|
||||
0.3,
|
||||
lambda: test3_complete.set_result(True)
|
||||
if not test3_complete.done()
|
||||
else None,
|
||||
)
|
||||
|
||||
# Test 4: Rejection
|
||||
if match := reject_start.search(line):
|
||||
item = int(match.group(1))
|
||||
if item not in test_results["rejection"]["processed"]:
|
||||
test_results["rejection"]["processed"].append(item)
|
||||
|
||||
if match := reject_end.search(line):
|
||||
item = int(match.group(1))
|
||||
if item == 3 and not test4_complete.done():
|
||||
test4_complete.set_result(True)
|
||||
|
||||
if reject_reject.search(line):
|
||||
test_results["rejection"]["rejections"] += 1
|
||||
|
||||
# Test 5: No params
|
||||
if no_params_end.search(line):
|
||||
test_results["no_params"]["executions"] += 1
|
||||
if (
|
||||
test_results["no_params"]["executions"] == 3
|
||||
and not test5_complete.done()
|
||||
):
|
||||
test5_complete.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
# Get services
|
||||
_, services = await client.list_entities_services()
|
||||
|
||||
# Test 1: Queue depth limit
|
||||
test_service = next((s for s in services if s.name == "test_queue_depth"), None)
|
||||
assert test_service is not None, "test_queue_depth service not found"
|
||||
client.execute_service(test_service, {})
|
||||
await asyncio.wait_for(test1_complete, timeout=2.0)
|
||||
await asyncio.sleep(0.1) # Give time for rejections
|
||||
|
||||
# Verify Test 1
|
||||
assert sorted(test_results["queue_depth"]["processed"]) == [1, 2, 3, 4, 5], (
|
||||
f"Test 1: Expected to process items 1-5 (max_runs=5 means 5 total), got {sorted(test_results['queue_depth']['processed'])}"
|
||||
)
|
||||
assert test_results["queue_depth"]["rejections"] >= 2, (
|
||||
"Test 1: Expected at least 2 rejection warnings (items 6-7 should be rejected)"
|
||||
)
|
||||
|
||||
# Test 2: Ring buffer order
|
||||
test_service = next((s for s in services if s.name == "test_ring_buffer"), None)
|
||||
assert test_service is not None, "test_ring_buffer service not found"
|
||||
client.execute_service(test_service, {})
|
||||
await asyncio.wait_for(test2_complete, timeout=2.0)
|
||||
|
||||
# Verify Test 2
|
||||
assert test_results["ring_buffer"]["start_order"] == ["A", "B", "C"], (
|
||||
f"Test 2: Expected start order [A, B, C], got {test_results['ring_buffer']['start_order']}"
|
||||
)
|
||||
assert test_results["ring_buffer"]["end_order"] == ["A", "B", "C"], (
|
||||
f"Test 2: Expected end order [A, B, C], got {test_results['ring_buffer']['end_order']}"
|
||||
)
|
||||
|
||||
# Test 3: Stop clears queue
|
||||
test_service = next((s for s in services if s.name == "test_stop_clears"), None)
|
||||
assert test_service is not None, "test_stop_clears service not found"
|
||||
client.execute_service(test_service, {})
|
||||
await asyncio.wait_for(test3_complete, timeout=2.0)
|
||||
|
||||
# Verify Test 3
|
||||
assert test_results["stop"]["stop_logged"], (
|
||||
"Test 3: Stop command was not logged"
|
||||
)
|
||||
assert test_results["stop"]["processed"] == [1], (
|
||||
f"Test 3: Expected only item 1 to process, got {test_results['stop']['processed']}"
|
||||
)
|
||||
|
||||
# Test 4: Rejection enforcement (max_runs=3)
|
||||
test_service = next((s for s in services if s.name == "test_rejection"), None)
|
||||
assert test_service is not None, "test_rejection service not found"
|
||||
client.execute_service(test_service, {})
|
||||
await asyncio.wait_for(test4_complete, timeout=2.0)
|
||||
await asyncio.sleep(0.1) # Give time for rejections
|
||||
|
||||
# Verify Test 4
|
||||
assert sorted(test_results["rejection"]["processed"]) == [1, 2, 3], (
|
||||
f"Test 4: Expected to process items 1-3 (max_runs=3 means 3 total), got {sorted(test_results['rejection']['processed'])}"
|
||||
)
|
||||
assert test_results["rejection"]["rejections"] == 5, (
|
||||
f"Test 4: Expected 5 rejections (items 4-8), got {test_results['rejection']['rejections']}"
|
||||
)
|
||||
|
||||
# Test 5: No parameters
|
||||
test_service = next((s for s in services if s.name == "test_no_params"), None)
|
||||
assert test_service is not None, "test_no_params service not found"
|
||||
client.execute_service(test_service, {})
|
||||
await asyncio.wait_for(test5_complete, timeout=2.0)
|
||||
|
||||
# Verify Test 5
|
||||
assert test_results["no_params"]["executions"] == 3, (
|
||||
f"Test 5: Expected 3 executions, got {test_results['no_params']['executions']}"
|
||||
)
|
||||
@@ -545,7 +545,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non
|
||||
|
||||
|
||||
def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
|
||||
"""Test memory impact detection with core-only changes (no component changes)."""
|
||||
"""Test memory impact detection with core C++ changes (no component changes)."""
|
||||
# Create test directory structure with fallback component
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
@@ -554,7 +554,7 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
|
||||
api_dir.mkdir(parents=True)
|
||||
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
|
||||
|
||||
# Mock changed_files to return only core files (no component files)
|
||||
# Mock changed_files to return only core C++ files (no component files)
|
||||
with (
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(helpers, "root_path", str(tmp_path)),
|
||||
@@ -574,6 +574,35 @@ def test_detect_memory_impact_config_core_only_changes(tmp_path: Path) -> None:
|
||||
assert result["use_merged_config"] == "true"
|
||||
|
||||
|
||||
def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) -> None:
|
||||
"""Test that Python-only core changes don't trigger memory impact analysis."""
|
||||
# Create test directory structure with fallback component
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
# api component (fallback component) with esp32-idf test
|
||||
api_dir = tests_dir / "api"
|
||||
api_dir.mkdir(parents=True)
|
||||
(api_dir / "test.esp32-idf.yaml").write_text("test: api")
|
||||
|
||||
# Mock changed_files to return only core Python files (no C++ files)
|
||||
with (
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(helpers, "root_path", str(tmp_path)),
|
||||
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
||||
):
|
||||
mock_changed_files.return_value = [
|
||||
"esphome/__main__.py",
|
||||
"esphome/config.py",
|
||||
"esphome/core/config.py",
|
||||
]
|
||||
determine_jobs._component_has_tests.cache_clear()
|
||||
|
||||
result = determine_jobs.detect_memory_impact_config()
|
||||
|
||||
# Python-only changes should NOT trigger memory impact analysis
|
||||
assert result["should_run"] == "false"
|
||||
|
||||
|
||||
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
|
||||
"""Test memory impact detection when components have no common platform."""
|
||||
# Create test directory structure
|
||||
|
||||
@@ -476,6 +476,55 @@ def test_preload_core_config_multiple_platforms(setup_core: Path) -> None:
|
||||
preload_core_config(config, result)
|
||||
|
||||
|
||||
def test_preload_core_config_skips_package_keys_with_periods(setup_core: Path) -> None:
|
||||
"""Test preload_core_config skips package keys containing periods.
|
||||
|
||||
Package keys can contain periods (e.g., "ratgdo.esphome") and should be
|
||||
skipped when searching for target platforms to avoid triggering the
|
||||
assertion in get_component() that component names cannot contain periods.
|
||||
|
||||
Regression test for: https://github.com/esphome/esphome/issues/11182
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {
|
||||
CONF_NAME: "test_device",
|
||||
},
|
||||
"esp32": {},
|
||||
# Package key with period should be ignored
|
||||
"ratgdo.esphome": "github://ratgdo/esphome-ratgdo/v32disco_secplusv1.yaml",
|
||||
}
|
||||
result = {}
|
||||
|
||||
# Should not raise AssertionError from get_component()
|
||||
platform = preload_core_config(config, result)
|
||||
|
||||
assert platform == "esp32"
|
||||
assert CORE.name == "test_device"
|
||||
|
||||
|
||||
def test_preload_core_config_skips_keys_starting_with_period(setup_core: Path) -> None:
|
||||
"""Test preload_core_config skips keys starting with period.
|
||||
|
||||
Keys starting with "." are special ESPHome internal keys and should be
|
||||
skipped when searching for target platforms.
|
||||
"""
|
||||
config = {
|
||||
CONF_ESPHOME: {
|
||||
CONF_NAME: "test_device",
|
||||
},
|
||||
"esp8266": {},
|
||||
# Internal key starting with period should be ignored
|
||||
".platformio_options": {"board_build.flash_mode": "dout"},
|
||||
}
|
||||
result = {}
|
||||
|
||||
# Should not raise any errors
|
||||
platform = preload_core_config(config, result)
|
||||
|
||||
assert platform == "esp8266"
|
||||
assert CORE.name == "test_device"
|
||||
|
||||
|
||||
def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) -> None:
|
||||
"""Test include_file adds include statement for header files."""
|
||||
src_file = tmp_path / "source.h"
|
||||
|
||||
@@ -8,6 +8,7 @@ substitutions:
|
||||
area: 25
|
||||
numberOne: 1
|
||||
var1: 79
|
||||
double_width: 14
|
||||
test_list:
|
||||
- The area is 56
|
||||
- 56
|
||||
@@ -25,3 +26,4 @@ test_list:
|
||||
- ord("a") = 97
|
||||
- chr(97) = a
|
||||
- len([1,2,3]) = 3
|
||||
- width = 7, double_width = 14
|
||||
|
||||
@@ -8,6 +8,7 @@ substitutions:
|
||||
area: 25
|
||||
numberOne: 1
|
||||
var1: 79
|
||||
double_width: ${width * 2}
|
||||
|
||||
test_list:
|
||||
- "The area is ${width * height}"
|
||||
@@ -23,3 +24,4 @@ test_list:
|
||||
- ord("a") = ${ ord("a") }
|
||||
- chr(97) = ${ chr(97) }
|
||||
- len([1,2,3]) = ${ len([1,2,3]) }
|
||||
- width = ${width}, double_width = ${double_width}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
A: component1
|
||||
B: component2
|
||||
C: component3
|
||||
some_component:
|
||||
- id: component1
|
||||
value: 2
|
||||
- id: component2
|
||||
value: 5
|
||||
@@ -0,0 +1,22 @@
|
||||
substitutions:
|
||||
A: component1
|
||||
B: component2
|
||||
C: component3
|
||||
|
||||
packages:
|
||||
- some_component:
|
||||
- id: component1
|
||||
value: 1
|
||||
- id: !extend ${B}
|
||||
value: 4
|
||||
- id: !extend ${B}
|
||||
value: 5
|
||||
- id: component3
|
||||
value: 6
|
||||
|
||||
some_component:
|
||||
- id: !extend ${A}
|
||||
value: 2
|
||||
- id: component2
|
||||
value: 3
|
||||
- id: !remove ${C}
|
||||
@@ -570,6 +570,13 @@ class TestEsphomeCore:
|
||||
|
||||
assert target.address == "4.3.2.1"
|
||||
|
||||
def test_address__openthread(self, target):
|
||||
target.name = "test-device"
|
||||
target.config = {}
|
||||
target.config[const.CONF_OPENTHREAD] = {}
|
||||
|
||||
assert target.address == "test-device.local"
|
||||
|
||||
def test_is_esp32(self, target):
|
||||
target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"}
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ from esphome import platformio_api
|
||||
from esphome.__main__ import (
|
||||
Purpose,
|
||||
choose_upload_log_host,
|
||||
command_analyze_memory,
|
||||
command_clean_all,
|
||||
command_rename,
|
||||
command_update_all,
|
||||
command_wizard,
|
||||
detect_external_components,
|
||||
get_port_type,
|
||||
has_ip_address,
|
||||
has_mqtt,
|
||||
@@ -226,13 +228,47 @@ def mock_run_external_process() -> Generator[Mock]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_run_external_command() -> Generator[Mock]:
|
||||
"""Mock run_external_command for testing."""
|
||||
def mock_run_external_command_main() -> Generator[Mock]:
|
||||
"""Mock run_external_command in __main__ module (different from platformio_api)."""
|
||||
with patch("esphome.__main__.run_external_command") as mock:
|
||||
mock.return_value = 0 # Default to success
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_write_cpp() -> Generator[Mock]:
|
||||
"""Mock write_cpp for testing."""
|
||||
with patch("esphome.__main__.write_cpp") as mock:
|
||||
mock.return_value = 0 # Default to success
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_compile_program() -> Generator[Mock]:
|
||||
"""Mock compile_program for testing."""
|
||||
with patch("esphome.__main__.compile_program") as mock:
|
||||
mock.return_value = 0 # Default to success
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_esphome_components() -> Generator[Mock]:
|
||||
"""Mock get_esphome_components for testing."""
|
||||
with patch("esphome.analyze_memory.helpers.get_esphome_components") as mock:
|
||||
mock.return_value = {"logger", "api", "ota"}
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_analyzer_cli() -> Generator[Mock]:
|
||||
"""Mock MemoryAnalyzerCLI for testing."""
|
||||
with patch("esphome.analyze_memory.cli.MemoryAnalyzerCLI") as mock_class:
|
||||
mock_analyzer = MagicMock()
|
||||
mock_analyzer.generate_report.return_value = "Mock Memory Report"
|
||||
mock_class.return_value = mock_analyzer
|
||||
yield mock_class
|
||||
|
||||
|
||||
def test_choose_upload_log_host_with_string_default() -> None:
|
||||
"""Test with a single string default device."""
|
||||
setup_core()
|
||||
@@ -839,7 +875,7 @@ def test_upload_program_serial_esp8266_with_file(
|
||||
|
||||
def test_upload_using_esptool_path_conversion(
|
||||
tmp_path: Path,
|
||||
mock_run_external_command: Mock,
|
||||
mock_run_external_command_main: Mock,
|
||||
mock_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test upload_using_esptool properly converts Path objects to strings for esptool.
|
||||
@@ -875,10 +911,10 @@ def test_upload_using_esptool_path_conversion(
|
||||
assert result == 0
|
||||
|
||||
# Verify that run_external_command was called
|
||||
assert mock_run_external_command.call_count == 1
|
||||
assert mock_run_external_command_main.call_count == 1
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_run_external_command.call_args[0]
|
||||
call_args = mock_run_external_command_main.call_args[0]
|
||||
|
||||
# The first argument should be esptool.main function,
|
||||
# followed by the command arguments
|
||||
@@ -917,7 +953,7 @@ def test_upload_using_esptool_path_conversion(
|
||||
|
||||
def test_upload_using_esptool_with_file_path(
|
||||
tmp_path: Path,
|
||||
mock_run_external_command: Mock,
|
||||
mock_run_external_command_main: Mock,
|
||||
) -> None:
|
||||
"""Test upload_using_esptool with a custom file that's a Path object."""
|
||||
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test")
|
||||
@@ -934,10 +970,10 @@ def test_upload_using_esptool_with_file_path(
|
||||
assert result == 0
|
||||
|
||||
# Verify that run_external_command was called
|
||||
mock_run_external_command.assert_called_once()
|
||||
mock_run_external_command_main.assert_called_once()
|
||||
|
||||
# Get the actual call arguments
|
||||
call_args = mock_run_external_command.call_args[0]
|
||||
call_args = mock_run_external_command_main.call_args[0]
|
||||
cmd_list = list(call_args[1:]) # Skip the esptool.main function
|
||||
|
||||
# Find the firmware path in the command
|
||||
@@ -2273,3 +2309,226 @@ def test_show_logs_api_mqtt_timeout_fallback(
|
||||
|
||||
# Verify run_logs was called with only the static IP (MQTT failed)
|
||||
mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"])
|
||||
|
||||
|
||||
def test_detect_external_components_no_external(
|
||||
mock_get_esphome_components: Mock,
|
||||
) -> None:
|
||||
"""Test detect_external_components with no external components."""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "test_device"},
|
||||
"logger": {},
|
||||
"api": {},
|
||||
}
|
||||
|
||||
result = detect_external_components(config)
|
||||
|
||||
assert result == set()
|
||||
mock_get_esphome_components.assert_called_once()
|
||||
|
||||
|
||||
def test_detect_external_components_with_external(
|
||||
mock_get_esphome_components: Mock,
|
||||
) -> None:
|
||||
"""Test detect_external_components detects external components."""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "test_device"},
|
||||
"logger": {}, # Built-in
|
||||
"api": {}, # Built-in
|
||||
"my_custom_sensor": {}, # External
|
||||
"another_custom": {}, # External
|
||||
"external_components": [], # Special key, not a component
|
||||
"substitutions": {}, # Special key, not a component
|
||||
}
|
||||
|
||||
result = detect_external_components(config)
|
||||
|
||||
assert result == {"my_custom_sensor", "another_custom"}
|
||||
mock_get_esphome_components.assert_called_once()
|
||||
|
||||
|
||||
def test_detect_external_components_filters_special_keys(
|
||||
mock_get_esphome_components: Mock,
|
||||
) -> None:
|
||||
"""Test detect_external_components filters out special config keys."""
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "test_device"},
|
||||
"substitutions": {"key": "value"},
|
||||
"packages": {},
|
||||
"globals": [],
|
||||
"external_components": [],
|
||||
"<<": {}, # YAML merge key
|
||||
}
|
||||
|
||||
result = detect_external_components(config)
|
||||
|
||||
assert result == set()
|
||||
mock_get_esphome_components.assert_called_once()
|
||||
|
||||
|
||||
def test_command_analyze_memory_success(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_idedata: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
) -> None:
|
||||
"""Test command_analyze_memory with successful compilation and analysis."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
# Create firmware.elf file
|
||||
firmware_path = (
|
||||
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
|
||||
)
|
||||
firmware_path.mkdir(parents=True, exist_ok=True)
|
||||
firmware_elf = firmware_path / "firmware.elf"
|
||||
firmware_elf.write_text("mock elf file")
|
||||
|
||||
# Mock idedata
|
||||
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
|
||||
mock_idedata_obj.objdump_path = "/path/to/objdump"
|
||||
mock_idedata_obj.readelf_path = "/path/to/readelf"
|
||||
mock_get_idedata.return_value = mock_idedata_obj
|
||||
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "test_device"},
|
||||
"logger": {},
|
||||
}
|
||||
|
||||
args = MockArgs()
|
||||
|
||||
result = command_analyze_memory(args, config)
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify compilation was done
|
||||
mock_write_cpp.assert_called_once_with(config)
|
||||
mock_compile_program.assert_called_once_with(args, config)
|
||||
|
||||
# Verify analyzer was created with correct parameters
|
||||
mock_memory_analyzer_cli.assert_called_once_with(
|
||||
str(firmware_elf),
|
||||
"/path/to/objdump",
|
||||
"/path/to/readelf",
|
||||
set(), # No external components
|
||||
)
|
||||
|
||||
# Verify analysis was run
|
||||
mock_analyzer = mock_memory_analyzer_cli.return_value
|
||||
mock_analyzer.analyze.assert_called_once()
|
||||
mock_analyzer.generate_report.assert_called_once()
|
||||
|
||||
# Verify report was printed
|
||||
captured = capfd.readouterr()
|
||||
assert "Mock Memory Report" in captured.out
|
||||
|
||||
|
||||
def test_command_analyze_memory_with_external_components(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_idedata: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
) -> None:
|
||||
"""Test command_analyze_memory detects external components."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
# Create firmware.elf file
|
||||
firmware_path = (
|
||||
tmp_path / ".esphome" / "build" / "test_device" / ".pioenvs" / "test_device"
|
||||
)
|
||||
firmware_path.mkdir(parents=True, exist_ok=True)
|
||||
firmware_elf = firmware_path / "firmware.elf"
|
||||
firmware_elf.write_text("mock elf file")
|
||||
|
||||
# Mock idedata
|
||||
mock_idedata_obj = MagicMock(spec=platformio_api.IDEData)
|
||||
mock_idedata_obj.firmware_elf_path = str(firmware_elf)
|
||||
mock_idedata_obj.objdump_path = "/path/to/objdump"
|
||||
mock_idedata_obj.readelf_path = "/path/to/readelf"
|
||||
mock_get_idedata.return_value = mock_idedata_obj
|
||||
|
||||
config = {
|
||||
CONF_ESPHOME: {CONF_NAME: "test_device"},
|
||||
"logger": {},
|
||||
"my_custom_component": {"param": "value"}, # External component
|
||||
"external_components": [{"source": "github://user/repo"}], # Not a component
|
||||
}
|
||||
|
||||
args = MockArgs()
|
||||
|
||||
result = command_analyze_memory(args, config)
|
||||
|
||||
assert result == 0
|
||||
|
||||
# Verify analyzer was created with external components detected
|
||||
mock_memory_analyzer_cli.assert_called_once_with(
|
||||
str(firmware_elf),
|
||||
"/path/to/objdump",
|
||||
"/path/to/readelf",
|
||||
{"my_custom_component"}, # External component detected
|
||||
)
|
||||
|
||||
|
||||
def test_command_analyze_memory_write_cpp_fails(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
) -> None:
|
||||
"""Test command_analyze_memory when write_cpp fails."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
mock_write_cpp.return_value = 1 # Failure
|
||||
|
||||
result = command_analyze_memory(args, config)
|
||||
|
||||
assert result == 1
|
||||
mock_write_cpp.assert_called_once_with(config)
|
||||
|
||||
|
||||
def test_command_analyze_memory_compile_fails(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
) -> None:
|
||||
"""Test command_analyze_memory when compilation fails."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
mock_compile_program.return_value = 1 # Compilation failed
|
||||
|
||||
result = command_analyze_memory(args, config)
|
||||
|
||||
assert result == 1
|
||||
mock_write_cpp.assert_called_once_with(config)
|
||||
mock_compile_program.assert_called_once_with(args, config)
|
||||
|
||||
|
||||
def test_command_analyze_memory_no_idedata(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test command_analyze_memory when idedata cannot be retrieved."""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
mock_get_idedata.return_value = None # Failed to get idedata
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = command_analyze_memory(args, config)
|
||||
|
||||
assert result == 1
|
||||
assert "Failed to get IDE data for memory analysis" in caplog.text
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
from esphome import config as config_module, yaml_util
|
||||
from esphome.components import substitutions
|
||||
from esphome.config import resolve_extend_remove
|
||||
from esphome.config_helpers import merge_config
|
||||
from esphome.const import CONF_PACKAGES, CONF_SUBSTITUTIONS
|
||||
from esphome.core import CORE
|
||||
@@ -81,6 +82,8 @@ def test_substitutions_fixtures(fixture_path):
|
||||
|
||||
substitutions.do_substitution_pass(config, None)
|
||||
|
||||
resolve_extend_remove(config)
|
||||
|
||||
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
|
||||
if expected_path.is_file():
|
||||
expected = yaml_util.load_yaml(expected_path)
|
||||
|
||||
Reference in New Issue
Block a user