mirror of
https://github.com/esphome/esphome.git
synced 2025-10-21 11:13:46 +01:00
Merge remote-tracking branch 'origin/light_bitmask' into light_bitmask
This commit is contained in:
@@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
||||
"download",
|
||||
f"{storage_json.name}-{file_name}",
|
||||
)
|
||||
path = storage_json.firmware_bin_path.with_name(file_name)
|
||||
|
||||
path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||
|
||||
if not path.is_file():
|
||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||
|
@@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
|
||||
esptool==5.1.0
|
||||
click==8.1.7
|
||||
esphome-dashboard==20251013.0
|
||||
aioesphomeapi==42.0.0
|
||||
aioesphomeapi==42.1.0
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.18.15 # dashboard_import
|
||||
|
@@ -8,14 +8,12 @@ sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 0.6;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
- platform: template
|
||||
id: template_temperature
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
|
@@ -5,9 +5,8 @@ sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
update_interval: 15s
|
||||
|
||||
binary_sensor:
|
||||
|
@@ -23,9 +23,8 @@ binary_sensor:
|
||||
- lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return x;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
- settle: 100ms
|
||||
- timeout: 10s
|
||||
|
||||
|
@@ -4,25 +4,22 @@ binary_sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
- platform: template
|
||||
id: bin2
|
||||
lambda: |-
|
||||
if (millis() > 20000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
- platform: template
|
||||
id: bin3
|
||||
lambda: |-
|
||||
if (millis() > 30000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
sensor:
|
||||
- platform: binary_sensor_map
|
||||
|
@@ -4,17 +4,15 @@ sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 0.6;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
- platform: template
|
||||
id: template_temperature2
|
||||
lambda: |-
|
||||
if (millis() > 20000) {
|
||||
return 0.8;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
- platform: combination
|
||||
type: kalman
|
||||
name: Kalman-filtered temperature
|
||||
|
@@ -4,9 +4,8 @@ binary_sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
sensor:
|
||||
- platform: duty_time
|
||||
|
@@ -4,9 +4,8 @@ binary_sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
|
@@ -17,9 +17,8 @@ lock:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return LOCK_STATE_LOCKED;
|
||||
} else {
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
}
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
optimistic: true
|
||||
assumed_state: false
|
||||
on_unlock:
|
||||
|
@@ -72,10 +72,9 @@ binary_sensor:
|
||||
if (id(template_sens).state > 30) {
|
||||
// Garage Door is open.
|
||||
return true;
|
||||
} else {
|
||||
// Garage Door is closed.
|
||||
return false;
|
||||
}
|
||||
// Garage Door is closed.
|
||||
return false;
|
||||
on_state:
|
||||
- mqtt.publish:
|
||||
topic: some/topic/binary_sensor
|
||||
@@ -217,9 +216,8 @@ cover:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return COVER_OPEN;
|
||||
} else {
|
||||
return COVER_CLOSED;
|
||||
}
|
||||
return COVER_CLOSED;
|
||||
open_action:
|
||||
- logger.log: open_action
|
||||
close_action:
|
||||
@@ -321,9 +319,8 @@ lock:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return LOCK_STATE_LOCKED;
|
||||
} else {
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
}
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
lock_action:
|
||||
- logger.log: lock_action
|
||||
unlock_action:
|
||||
@@ -360,9 +357,8 @@ sensor:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
update_interval: 60s
|
||||
on_value:
|
||||
- mqtt.publish:
|
||||
@@ -390,9 +386,8 @@ switch:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
turn_on_action:
|
||||
- logger.log: turn_on_action
|
||||
turn_off_action:
|
||||
@@ -436,9 +431,8 @@ valve:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return VALVE_OPEN;
|
||||
} else {
|
||||
return VALVE_CLOSED;
|
||||
}
|
||||
return VALVE_CLOSED;
|
||||
|
||||
alarm_control_panel:
|
||||
- platform: template
|
||||
|
@@ -27,9 +27,8 @@ sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
update_interval: 60s
|
||||
|
||||
climate:
|
||||
|
@@ -35,9 +35,8 @@ sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
update_interval: 60s
|
||||
|
||||
text_sensor:
|
||||
@@ -49,9 +48,8 @@ text_sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return {"Hello World"};
|
||||
} else {
|
||||
return {"Goodbye (cruel) World"};
|
||||
}
|
||||
return {"Goodbye (cruel) World"};
|
||||
update_interval: 60s
|
||||
|
||||
binary_sensor:
|
||||
@@ -60,9 +58,8 @@ binary_sensor:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
@@ -70,9 +67,8 @@ switch:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
optimistic: true
|
||||
|
||||
fan:
|
||||
@@ -85,9 +81,8 @@ cover:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return COVER_OPEN;
|
||||
} else {
|
||||
return COVER_CLOSED;
|
||||
}
|
||||
return COVER_CLOSED;
|
||||
|
||||
lock:
|
||||
- platform: template
|
||||
@@ -95,9 +90,8 @@ lock:
|
||||
lambda: |-
|
||||
if (millis() > 10000) {
|
||||
return LOCK_STATE_LOCKED;
|
||||
} else {
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
}
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
optimistic: true
|
||||
|
||||
select:
|
||||
|
@@ -59,9 +59,8 @@ binary_sensor:
|
||||
- lambda: |-
|
||||
if (id(other_binary_sensor).state) {
|
||||
return x;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
- settle: 500ms
|
||||
- timeout: 5s
|
||||
|
||||
@@ -72,9 +71,8 @@ sensor:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return 42.0;
|
||||
} else {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.0;
|
||||
update_interval: 60s
|
||||
filters:
|
||||
- calibrate_linear:
|
||||
@@ -183,9 +181,8 @@ switch:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
turn_on_action:
|
||||
- logger.log: "turn_on_action"
|
||||
turn_off_action:
|
||||
@@ -203,9 +200,8 @@ cover:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return COVER_OPEN;
|
||||
} else {
|
||||
return COVER_CLOSED;
|
||||
}
|
||||
return COVER_CLOSED;
|
||||
open_action:
|
||||
- logger.log: open_action
|
||||
close_action:
|
||||
@@ -238,9 +234,8 @@ lock:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return LOCK_STATE_LOCKED;
|
||||
} else {
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
}
|
||||
return LOCK_STATE_UNLOCKED;
|
||||
lock_action:
|
||||
- logger.log: lock_action
|
||||
unlock_action:
|
||||
@@ -255,9 +250,8 @@ valve:
|
||||
lambda: |-
|
||||
if (id(some_binary_sensor).state) {
|
||||
return VALVE_OPEN;
|
||||
} else {
|
||||
return VALVE_CLOSED;
|
||||
}
|
||||
return VALVE_CLOSED;
|
||||
open_action:
|
||||
- logger.log: open_action
|
||||
close_action:
|
||||
|
@@ -17,10 +17,10 @@ sensor:
|
||||
name: HLW8012 Voltage
|
||||
power:
|
||||
name: HLW8012 Power
|
||||
id: hlw8012_power
|
||||
id: total_daily_energy_hlw8012_power
|
||||
energy:
|
||||
name: HLW8012 Energy
|
||||
id: hlw8012_energy
|
||||
id: total_daily_energy_hlw8012_energy
|
||||
update_interval: 15s
|
||||
current_resistor: 0.001 ohm
|
||||
voltage_divider: 2351
|
||||
@@ -29,4 +29,4 @@ sensor:
|
||||
model: hlw8012
|
||||
- platform: total_daily_energy
|
||||
name: HLW8012 Total Daily Energy
|
||||
power_id: hlw8012_power
|
||||
power_id: total_daily_energy_hlw8012_power
|
||||
|
@@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport
|
||||
from .common import get_fixture_path
|
||||
|
||||
|
||||
def get_build_path(base_path: Path, device_name: str) -> Path:
|
||||
"""Get the build directory path for a device.
|
||||
|
||||
This is a test helper that constructs the standard ESPHome build directory
|
||||
structure. Note: This helper does NOT perform path traversal sanitization
|
||||
because it's only used in tests where we control the inputs. The actual
|
||||
web_server.py code handles sanitization in DownloadBinaryRequestHandler.get()
|
||||
via file_name.replace("..", "").lstrip("/").
|
||||
|
||||
Args:
|
||||
base_path: The base temporary path (typically tmp_path from pytest)
|
||||
device_name: The name of the device (should not contain path separators
|
||||
in production use, but tests may use it for specific scenarios)
|
||||
|
||||
Returns:
|
||||
Path to the build directory (.esphome/build/device_name)
|
||||
"""
|
||||
return base_path / ".esphome" / "build" / device_name
|
||||
|
||||
|
||||
class DashboardTestHelper:
|
||||
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
||||
self.io_loop = io_loop
|
||||
@@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback(
|
||||
assert response.body == b"bootloader content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_subdirectory_file(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case).
|
||||
|
||||
This is a regression test for issue #11343 where the Path migration broke
|
||||
downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'.
|
||||
|
||||
The issue was that with_name() doesn't accept path separators:
|
||||
- Before: path = storage_json.firmware_bin_path.with_name(file_name)
|
||||
ValueError: Invalid name 'zephyr/zephyr.uf2'
|
||||
- After: path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||
Works correctly with subdirectory paths
|
||||
"""
|
||||
# Create a fake nRF52 build structure with firmware in subdirectory
|
||||
build_dir = get_build_path(tmp_path, "nrf52-device")
|
||||
zephyr_dir = build_dir / "zephyr"
|
||||
zephyr_dir.mkdir(parents=True)
|
||||
|
||||
# Create the main firmware binary (would be in build root)
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"main firmware")
|
||||
|
||||
# Create the UF2 file in zephyr subdirectory (nRF52 specific)
|
||||
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||
uf2_file.write_bytes(b"nRF52 UF2 firmware content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "nrf52-device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Request the UF2 file with subdirectory path
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert response.body == b"nRF52 UF2 firmware content"
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
# Download name should be device-name + full file path
|
||||
assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_subdirectory_file_url_encoded(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path.
|
||||
|
||||
Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly
|
||||
decoded and handled, and that custom download names work with subdirectories.
|
||||
"""
|
||||
# Create a fake build structure with firmware in subdirectory
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
zephyr_dir = build_dir / "zephyr"
|
||||
zephyr_dir.mkdir(parents=True)
|
||||
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"content")
|
||||
|
||||
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||
uf2_file.write_bytes(b"content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Request with URL-encoded path and custom download name
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert "custom_name.bin" in response.headers["Content-Disposition"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
@pytest.mark.parametrize(
|
||||
"attack_path",
|
||||
[
|
||||
pytest.param("../../../secrets.yaml", id="basic_traversal"),
|
||||
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
|
||||
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
|
||||
pytest.param("/etc/passwd", id="absolute_path"),
|
||||
pytest.param("//etc/passwd", id="double_slash_absolute"),
|
||||
pytest.param("....//secrets.yaml", id="multiple_dots"),
|
||||
],
|
||||
)
|
||||
async def test_download_binary_handler_path_traversal_protection(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
attack_path: str,
|
||||
) -> None:
|
||||
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
|
||||
|
||||
Verifies that attempts to use '..' in file paths are sanitized to prevent
|
||||
accessing files outside the build directory. Tests multiple attack vectors.
|
||||
"""
|
||||
# Create build structure
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
build_dir.mkdir(parents=True)
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"firmware content")
|
||||
|
||||
# Create a sensitive file outside the build directory that should NOT be accessible
|
||||
sensitive_file = tmp_path / "secrets.yaml"
|
||||
sensitive_file.write_bytes(b"secret: my_secret_password")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Attempt path traversal attack - should be blocked
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
await dashboard.fetch(
|
||||
f"/download.bin?configuration=test.yaml&file={attack_path}",
|
||||
method="GET",
|
||||
)
|
||||
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
|
||||
assert exc_info.value.code in (404, 500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_multiple_subdirectory_levels(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test downloading files from multiple subdirectory levels.
|
||||
|
||||
Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'.
|
||||
"""
|
||||
# Create nested directory structure
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
nested_dir = build_dir / "build" / "output"
|
||||
nested_dir.mkdir(parents=True)
|
||||
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"main")
|
||||
|
||||
nested_file = nested_dir / "firmware.bin"
|
||||
nested_file.write_bytes(b"nested firmware content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=test.yaml&file=build/output/firmware.bin",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert response.body == b"nested firmware content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_request_handler_post_invalid_file(
|
||||
dashboard: DashboardTestHelper,
|
||||
|
@@ -34,10 +34,9 @@ binary_sensor:
|
||||
ESP_LOGD("test", "Button ON at %u", now);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Only log state change
|
||||
if (id(ir_remote_button).state) {
|
||||
ESP_LOGD("test", "Button OFF at %u", now);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Only log state change
|
||||
if (id(ir_remote_button).state) {
|
||||
ESP_LOGD("test", "Button OFF at %u", now);
|
||||
}
|
||||
return false;
|
||||
|
Reference in New Issue
Block a user