1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-01 19:02:18 +01:00

[host] Fix memory allocation in preferences load() method

This commit is contained in:
J. Nick Koston
2025-08-30 22:17:16 -05:00
parent 983b3cb879
commit f5558c31a3
3 changed files with 260 additions and 2 deletions

View File

@@ -42,9 +42,10 @@ class HostPreferences : public ESPPreferences {
if (len > 255) if (len > 255)
return false; return false;
this->setup_(); this->setup_();
if (this->data.count(key) == 0) auto it = this->data.find(key);
if (it == this->data.end())
return false; return false;
auto vec = this->data[key]; const auto &vec = it->second;
if (vec.size() != len) if (vec.size() != len)
return false; return false;
memcpy(data, vec.data(), len); memcpy(data, vec.data(), len);

View File

@@ -0,0 +1,104 @@
esphome:
name: test_device
on_boot:
- lambda: |-
ESP_LOGD("test", "Host preferences test starting");
host:
logger:
level: DEBUG
api:
preferences:
flash_write_interval: 0s # Disable automatic saving for test control
switch:
- platform: template
name: "Test Switch"
id: test_switch
optimistic: true
restore_mode: DISABLED # Don't auto-restore for test control
number:
- platform: template
name: "Test Number"
id: test_number
min_value: 0
max_value: 100
step: 0.1
optimistic: true
restore_value: false # Don't auto-restore for test control
button:
- platform: template
name: "Save Preferences"
on_press:
- lambda: |-
// Save current values to preferences
ESPPreferenceObject switch_pref = global_preferences->make_preference<bool>(0x1234);
ESPPreferenceObject number_pref = global_preferences->make_preference<float>(0x5678);
bool switch_value = id(test_switch).state;
float number_value = id(test_number).state;
if (switch_pref.save(&switch_value)) {
ESP_LOGI("test", "Preference saved: key=switch, value=%.1f", switch_value ? 1.0 : 0.0);
}
if (number_pref.save(&number_value)) {
ESP_LOGI("test", "Preference saved: key=number, value=%.1f", number_value);
}
// Force sync to disk
global_preferences->sync();
- platform: template
name: "Load Preferences"
on_press:
- lambda: |-
// Load values from preferences
ESPPreferenceObject switch_pref = global_preferences->make_preference<bool>(0x1234);
ESPPreferenceObject number_pref = global_preferences->make_preference<float>(0x5678);
// Also try to load non-existent preferences (tests our fix)
ESPPreferenceObject fake_pref1 = global_preferences->make_preference<uint32_t>(0x9999);
ESPPreferenceObject fake_pref2 = global_preferences->make_preference<uint32_t>(0xAAAA);
bool switch_value = false;
float number_value = 0.0;
uint32_t fake_value = 0;
// These should not exist and shouldn't create map entries
fake_pref1.load(&fake_value);
fake_pref2.load(&fake_value);
if (switch_pref.load(&switch_value)) {
id(test_switch).publish_state(switch_value);
ESP_LOGI("test", "Preference loaded: key=switch, value=%.1f", switch_value ? 1.0 : 0.0);
} else {
ESP_LOGW("test", "Failed to load switch preference");
}
if (number_pref.load(&number_value)) {
id(test_number).publish_state(number_value);
ESP_LOGI("test", "Preference loaded: key=number, value=%.1f", number_value);
} else {
ESP_LOGW("test", "Failed to load number preference");
}
- platform: template
name: "Verify Preferences"
on_press:
- lambda: |-
// Verify current values match what we expect
bool switch_value = id(test_switch).state;
float number_value = id(test_number).state;
// After loading, switch should be true (1.0) and number should be 42.5
if (switch_value == true && number_value == 42.5) {
ESP_LOGI("test", "Preferences verified: values match!");
} else {
ESP_LOGE("test", "Preferences mismatch: switch=%d, number=%.1f",
switch_value, number_value);
}

View File

@@ -0,0 +1,153 @@
"""Test host preferences save and load functionality."""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import ButtonInfo, EntityInfo, NumberInfo, SwitchInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
def find_entity_by_name(
entities: list[EntityInfo], entity_type: type, name: str
) -> Any:
"""Helper to find an entity by type and name."""
return next(
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
)
@pytest.mark.asyncio
async def test_host_preferences_save_load(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that preferences are correctly saved and loaded after our optimization fix."""
loop = asyncio.get_running_loop()
log_lines: list[str] = []
preferences_saved = loop.create_future()
preferences_loaded = loop.create_future()
values_match = loop.create_future()
# Patterns to match preference logs
save_pattern = re.compile(r"Preference saved: key=(\w+), value=([0-9.]+)")
load_pattern = re.compile(r"Preference loaded: key=(\w+), value=([0-9.]+)")
verify_pattern = re.compile(r"Preferences verified: values match!")
saved_values: dict[str, float] = {}
loaded_values: dict[str, float] = {}
def check_output(line: str) -> None:
"""Check log output for preference operations."""
log_lines.append(line)
# Look for save operations
match = save_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
saved_values[key] = value
if len(saved_values) >= 2 and not preferences_saved.done():
preferences_saved.set_result(True)
# Look for load operations
match = load_pattern.search(line)
if match:
key = match.group(1)
value = float(match.group(2))
loaded_values[key] = value
if len(loaded_values) >= 2 and not preferences_loaded.done():
preferences_loaded.set_result(True)
# Look for verification
if verify_pattern.search(line) and not values_match.done():
values_match.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Get entity list
entities, _ = await client.list_entities_services()
# Find our test entities using helper
test_switch = find_entity_by_name(entities, SwitchInfo, "Test Switch")
test_number = find_entity_by_name(entities, NumberInfo, "Test Number")
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preferences")
load_button = find_entity_by_name(entities, ButtonInfo, "Load Preferences")
verify_button = find_entity_by_name(entities, ButtonInfo, "Verify Preferences")
assert test_switch is not None, "Test Switch not found"
assert test_number is not None, "Test Number not found"
assert save_button is not None, "Save Preferences button not found"
assert load_button is not None, "Load Preferences button not found"
assert verify_button is not None, "Verify Preferences button not found"
# Set initial values
client.switch_command(test_switch.key, True)
client.number_command(test_number.key, 42.5)
# Save preferences
client.button_command(save_button.key)
# Wait for save to complete
try:
await asyncio.wait_for(preferences_saved, timeout=5.0)
except TimeoutError:
pytest.fail("Preferences not saved within timeout")
# Verify we saved the expected values
assert "switch" in saved_values, f"Switch preference not saved: {saved_values}"
assert "number" in saved_values, f"Number preference not saved: {saved_values}"
assert saved_values["switch"] == 1.0, (
f"Switch value incorrect: {saved_values['switch']}"
)
assert saved_values["number"] == 42.5, (
f"Number value incorrect: {saved_values['number']}"
)
# Change the values to something else
client.switch_command(test_switch.key, False)
client.number_command(test_number.key, 13.7)
# Load preferences (should restore the saved values)
client.button_command(load_button.key)
# Wait for load to complete
try:
await asyncio.wait_for(preferences_loaded, timeout=5.0)
except TimeoutError:
pytest.fail("Preferences not loaded within timeout")
# Verify loaded values match saved values
assert "switch" in loaded_values, (
f"Switch preference not loaded: {loaded_values}"
)
assert "number" in loaded_values, (
f"Number preference not loaded: {loaded_values}"
)
assert loaded_values["switch"] == saved_values["switch"], (
f"Loaded switch value {loaded_values['switch']} doesn't match saved {saved_values['switch']}"
)
assert loaded_values["number"] == saved_values["number"], (
f"Loaded number value {loaded_values['number']} doesn't match saved {saved_values['number']}"
)
# Verify the values were actually restored
client.button_command(verify_button.key)
# Wait for verification
try:
await asyncio.wait_for(values_match, timeout=5.0)
except TimeoutError:
pytest.fail("Preference verification failed within timeout")
# Test that non-existent preferences don't crash (tests our fix)
# This will trigger load attempts for keys that don't exist
# Our fix should prevent map entries from being created
client.button_command(load_button.key)