1
0
mirror of https://github.com/esphome/esphome.git synced 2025-09-22 13:12:22 +01:00

Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2025-09-19 22:44:16 -06:00
104 changed files with 1620 additions and 1005 deletions

View File

@@ -11,7 +11,7 @@ ci:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.13.0 rev: v0.13.1
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff

View File

@@ -548,3 +548,4 @@ esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr/* @tomaszduda23
esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zio_ultrasonic/* @kahrendt
esphome/components/zwave_proxy/* @kbx81

View File

@@ -6,6 +6,7 @@ import getpass
import importlib import importlib
import logging import logging
import os import os
from pathlib import Path
import re import re
import sys import sys
import time import time
@@ -452,7 +453,7 @@ def upload_using_esptool(
"detect", "detect",
] ]
for img in flash_images: for img in flash_images:
cmd += [img.offset, img.path] cmd += [img.offset, str(img.path)]
if os.environ.get("ESPHOME_USE_SUBPROCESS") is None: if os.environ.get("ESPHOME_USE_SUBPROCESS") is None:
import esptool import esptool
@@ -538,7 +539,10 @@ def upload_program(
remote_port = int(ota_conf[CONF_PORT]) remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD, "") password = ota_conf.get(CONF_PASSWORD, "")
binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin if getattr(args, "file", None) is not None:
binary = Path(args.file)
else:
binary = CORE.firmware_bin
# MQTT address resolution # MQTT address resolution
if get_port_type(host) in ("MQTT", "MQTTIP"): if get_port_type(host) in ("MQTT", "MQTTIP"):
@@ -605,7 +609,7 @@ def clean_mqtt(config: ConfigType, args: ArgsProtocol) -> int | None:
def command_wizard(args: ArgsProtocol) -> int | None: def command_wizard(args: ArgsProtocol) -> int | None:
from esphome import wizard from esphome import wizard
return wizard.wizard(args.configuration) return wizard.wizard(Path(args.configuration))
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
@@ -825,7 +829,8 @@ def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
for c in args.name: new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS: if c not in ALLOWED_NAME_CHARS:
print( print(
color( color(
@@ -836,8 +841,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
) )
return 1 return 1
# Load existing yaml file # Load existing yaml file
with open(CORE.config_path, mode="r+", encoding="utf-8") as raw_file: raw_contents = CORE.config_path.read_text(encoding="utf-8")
raw_contents = raw_file.read()
yaml = yaml_util.load_yaml(CORE.config_path) yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
@@ -852,7 +856,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
if match is None: if match is None:
new_raw = re.sub( new_raw = re.sub(
rf"name:\s+[\"']?{old_name}[\"']?", rf"name:\s+[\"']?{old_name}[\"']?",
f'name: "{args.name}"', f'name: "{new_name}"',
raw_contents, raw_contents,
) )
else: else:
@@ -872,29 +876,28 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_raw = re.sub( new_raw = re.sub(
rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?", rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?",
f'\\1: "{args.name}"', f'\\1: "{new_name}"',
raw_contents, raw_contents,
flags=re.MULTILINE, flags=re.MULTILINE,
) )
new_path = os.path.join(CORE.config_dir, args.name + ".yaml") new_path: Path = CORE.config_dir / (new_name + ".yaml")
print( print(
f"Updating {color(AnsiFore.CYAN, CORE.config_path)} to {color(AnsiFore.CYAN, new_path)}" f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
) )
print() print()
with open(new_path, mode="w", encoding="utf-8") as new_file: new_path.write_text(new_raw, encoding="utf-8")
new_file.write(new_raw)
rc = run_external_process("esphome", "config", new_path) rc = run_external_process("esphome", "config", str(new_path))
if rc != 0: if rc != 0:
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
os.remove(new_path) new_path.unlink()
return 1 return 1
cli_args = [ cli_args = [
"run", "run",
new_path, str(new_path),
"--no-logs", "--no-logs",
"--device", "--device",
CORE.address, CORE.address,
@@ -908,11 +911,11 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
except KeyboardInterrupt: except KeyboardInterrupt:
rc = 1 rc = 1
if rc != 0: if rc != 0:
os.remove(new_path) new_path.unlink()
return 1 return 1
if CORE.config_path != new_path: if CORE.config_path != new_path:
os.remove(CORE.config_path) CORE.config_path.unlink()
print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print() print()
@@ -1280,7 +1283,8 @@ def run_esphome(argv):
_LOGGER.info("ESPHome %s", const.__version__) _LOGGER.info("ESPHome %s", const.__version__)
for conf_path in args.configuration: for conf_path in args.configuration:
if any(os.path.basename(conf_path) == x for x in SECRETS_FILES): conf_path = Path(conf_path)
if any(conf_path.name == x for x in SECRETS_FILES):
_LOGGER.warning("Skipping secrets file %s", conf_path) _LOGGER.warning("Skipping secrets file %s", conf_path)
continue continue

View File

@@ -1,5 +1,3 @@
import os
from esphome.const import __version__ from esphome.const import __version__
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed from esphome.helpers import mkdir_p, read_file, write_file_if_changed
@@ -63,7 +61,7 @@ def write_ini(content):
update_storage_json() update_storage_json()
path = CORE.relative_build_path("platformio.ini") path = CORE.relative_build_path("platformio.ini")
if os.path.isfile(path): if path.is_file():
text = read_file(path) text = read_file(path)
content_format = find_begin_end( content_format = find_begin_end(
text, INI_AUTO_GENERATE_BEGIN, INI_AUTO_GENERATE_END text, INI_AUTO_GENERATE_BEGIN, INI_AUTO_GENERATE_END

View File

@@ -66,6 +66,9 @@ service APIConnection {
rpc voice_assistant_set_configuration(VoiceAssistantSetConfiguration) returns (void) {} rpc voice_assistant_set_configuration(VoiceAssistantSetConfiguration) returns (void) {}
rpc alarm_control_panel_command (AlarmControlPanelCommandRequest) returns (void) {} rpc alarm_control_panel_command (AlarmControlPanelCommandRequest) returns (void) {}
rpc zwave_proxy_frame(ZWaveProxyFrame) returns (void) {}
rpc zwave_proxy_request(ZWaveProxyRequest) returns (void) {}
} }
@@ -254,6 +257,10 @@ message DeviceInfoResponse {
// Top-level area info to phase out suggested_area // Top-level area info to phase out suggested_area
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
} }
message ListEntitiesRequest { message ListEntitiesRequest {
@@ -2276,3 +2283,26 @@ message UpdateCommandRequest {
UpdateCommand command = 2; UpdateCommand command = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
} }
// ==================== Z-WAVE ====================
message ZWaveProxyFrame {
option (id) = 128;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZWAVE_PROXY";
option (no_delay) = true;
bytes data = 1 [(fixed_array_size) = 257];
}
enum ZWaveProxyRequestType {
ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0;
ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1;
}
message ZWaveProxyRequest {
option (id) = 129;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_ZWAVE_PROXY";
ZWaveProxyRequestType type = 1;
}

View File

@@ -30,6 +30,9 @@
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
#include "esphome/components/voice_assistant/voice_assistant.h" #include "esphome/components/voice_assistant/voice_assistant.h"
#endif #endif
#ifdef USE_ZWAVE_PROXY
#include "esphome/components/zwave_proxy/zwave_proxy.h"
#endif
namespace esphome::api { namespace esphome::api {
@@ -1203,7 +1206,16 @@ void APIConnection::voice_assistant_set_configuration(const VoiceAssistantSetCon
voice_assistant::global_voice_assistant->on_set_configuration(msg.active_wake_words); voice_assistant::global_voice_assistant->on_set_configuration(msg.active_wake_words);
} }
} }
#endif
#ifdef USE_ZWAVE_PROXY
void APIConnection::zwave_proxy_frame(const ZWaveProxyFrame &msg) {
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
}
void APIConnection::zwave_proxy_request(const ZWaveProxyRequest &msg) {
zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
}
#endif #endif
#ifdef USE_ALARM_CONTROL_PANEL #ifdef USE_ALARM_CONTROL_PANEL
@@ -1460,6 +1472,10 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) {
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
resp.voice_assistant_feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); resp.voice_assistant_feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
#endif #endif
#ifdef USE_ZWAVE_PROXY
resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
resp.zwave_home_id = zwave_proxy::global_zwave_proxy->get_home_id();
#endif
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
resp.api_encryption_supported = true; resp.api_encryption_supported = true;
#endif #endif

View File

@@ -171,6 +171,11 @@ class APIConnection final : public APIServerConnection {
void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override; void voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override;
#endif #endif
#ifdef USE_ZWAVE_PROXY
void zwave_proxy_frame(const ZWaveProxyFrame &msg) override;
void zwave_proxy_request(const ZWaveProxyRequest &msg) override;
#endif
#ifdef USE_ALARM_CONTROL_PANEL #ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override; void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) override;

View File

@@ -129,6 +129,12 @@ void DeviceInfoResponse::encode(ProtoWriteBuffer buffer) const {
#ifdef USE_AREAS #ifdef USE_AREAS
buffer.encode_message(22, this->area); buffer.encode_message(22, this->area);
#endif #endif
#ifdef USE_ZWAVE_PROXY
buffer.encode_uint32(23, this->zwave_proxy_feature_flags);
#endif
#ifdef USE_ZWAVE_PROXY
buffer.encode_uint32(24, this->zwave_home_id);
#endif
} }
void DeviceInfoResponse::calculate_size(ProtoSize &size) const { void DeviceInfoResponse::calculate_size(ProtoSize &size) const {
#ifdef USE_API_PASSWORD #ifdef USE_API_PASSWORD
@@ -181,6 +187,12 @@ void DeviceInfoResponse::calculate_size(ProtoSize &size) const {
#ifdef USE_AREAS #ifdef USE_AREAS
size.add_message_object(2, this->area); size.add_message_object(2, this->area);
#endif #endif
#ifdef USE_ZWAVE_PROXY
size.add_uint32(2, this->zwave_proxy_feature_flags);
#endif
#ifdef USE_ZWAVE_PROXY
size.add_uint32(2, this->zwave_home_id);
#endif
} }
#ifdef USE_BINARY_SENSOR #ifdef USE_BINARY_SENSOR
void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const { void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const {
@@ -3013,5 +3025,35 @@ bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) {
return true; return true;
} }
#endif #endif
#ifdef USE_ZWAVE_PROXY
bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 1: {
const std::string &data_str = value.as_string();
this->data_len = data_str.size();
if (this->data_len > 257) {
this->data_len = 257;
}
memcpy(this->data, data_str.data(), this->data_len);
break;
}
default:
return false;
}
return true;
}
void ZWaveProxyFrame::encode(ProtoWriteBuffer buffer) const { buffer.encode_bytes(1, this->data, this->data_len); }
void ZWaveProxyFrame::calculate_size(ProtoSize &size) const { size.add_length(1, this->data_len); }
bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) {
switch (field_id) {
case 1:
this->type = static_cast<enums::ZWaveProxyRequestType>(value.as_uint32());
break;
default:
return false;
}
return true;
}
#endif
} // namespace esphome::api } // namespace esphome::api

View File

@@ -276,6 +276,12 @@ enum UpdateCommand : uint32_t {
UPDATE_COMMAND_CHECK = 2, UPDATE_COMMAND_CHECK = 2,
}; };
#endif #endif
#ifdef USE_ZWAVE_PROXY
enum ZWaveProxyRequestType : uint32_t {
ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE = 0,
ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
};
#endif
} // namespace enums } // namespace enums
@@ -492,7 +498,7 @@ class DeviceInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 10; static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint8_t ESTIMATED_SIZE = 247; static constexpr uint16_t ESTIMATED_SIZE = 257;
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "device_info_response"; } const char *message_name() const override { return "device_info_response"; }
#endif #endif
@@ -552,6 +558,12 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif #endif
#ifdef USE_AREAS #ifdef USE_AREAS
AreaInfo area{}; AreaInfo area{};
#endif
#ifdef USE_ZWAVE_PROXY
uint32_t zwave_proxy_feature_flags{0};
#endif
#ifdef USE_ZWAVE_PROXY
uint32_t zwave_home_id{0};
#endif #endif
void encode(ProtoWriteBuffer buffer) const override; void encode(ProtoWriteBuffer buffer) const override;
void calculate_size(ProtoSize &size) const override; void calculate_size(ProtoSize &size) const override;
@@ -2913,5 +2925,40 @@ class UpdateCommandRequest final : public CommandProtoMessage {
bool decode_varint(uint32_t field_id, ProtoVarInt value) override; bool decode_varint(uint32_t field_id, ProtoVarInt value) override;
}; };
#endif #endif
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyFrame final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 128;
static constexpr uint8_t ESTIMATED_SIZE = 33;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "z_wave_proxy_frame"; }
#endif
uint8_t data[257]{};
uint16_t data_len{0};
void encode(ProtoWriteBuffer buffer) const override;
void calculate_size(ProtoSize &size) const override;
#ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override;
#endif
protected:
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
};
class ZWaveProxyRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 129;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "z_wave_proxy_request"; }
#endif
enums::ZWaveProxyRequestType type{};
#ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, ProtoVarInt value) override;
};
#endif
} // namespace esphome::api } // namespace esphome::api

View File

@@ -655,6 +655,18 @@ template<> const char *proto_enum_to_string<enums::UpdateCommand>(enums::UpdateC
} }
} }
#endif #endif
#ifdef USE_ZWAVE_PROXY
template<> const char *proto_enum_to_string<enums::ZWaveProxyRequestType>(enums::ZWaveProxyRequestType value) {
switch (value) {
case enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE:
return "ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE";
case enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
return "ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE";
default:
return "UNKNOWN";
}
}
#endif
void HelloRequest::dump_to(std::string &out) const { void HelloRequest::dump_to(std::string &out) const {
MessageDumpHelper helper(out, "HelloRequest"); MessageDumpHelper helper(out, "HelloRequest");
@@ -754,6 +766,12 @@ void DeviceInfoResponse::dump_to(std::string &out) const {
this->area.dump_to(out); this->area.dump_to(out);
out.append("\n"); out.append("\n");
#endif #endif
#ifdef USE_ZWAVE_PROXY
dump_field(out, "zwave_proxy_feature_flags", this->zwave_proxy_feature_flags);
#endif
#ifdef USE_ZWAVE_PROXY
dump_field(out, "zwave_home_id", this->zwave_home_id);
#endif
} }
void ListEntitiesRequest::dump_to(std::string &out) const { out.append("ListEntitiesRequest {}"); } void ListEntitiesRequest::dump_to(std::string &out) const { out.append("ListEntitiesRequest {}"); }
void ListEntitiesDoneResponse::dump_to(std::string &out) const { out.append("ListEntitiesDoneResponse {}"); } void ListEntitiesDoneResponse::dump_to(std::string &out) const { out.append("ListEntitiesDoneResponse {}"); }
@@ -2107,6 +2125,18 @@ void UpdateCommandRequest::dump_to(std::string &out) const {
#endif #endif
} }
#endif #endif
#ifdef USE_ZWAVE_PROXY
void ZWaveProxyFrame::dump_to(std::string &out) const {
MessageDumpHelper helper(out, "ZWaveProxyFrame");
out.append(" data: ");
out.append(format_hex_pretty(this->data, this->data_len));
out.append("\n");
}
void ZWaveProxyRequest::dump_to(std::string &out) const {
MessageDumpHelper helper(out, "ZWaveProxyRequest");
dump_field(out, "type", static_cast<enums::ZWaveProxyRequestType>(this->type));
}
#endif
} // namespace esphome::api } // namespace esphome::api

View File

@@ -588,6 +588,28 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
this->on_bluetooth_scanner_set_mode_request(msg); this->on_bluetooth_scanner_set_mode_request(msg);
break; break;
} }
#endif
#ifdef USE_ZWAVE_PROXY
case ZWaveProxyFrame::MESSAGE_TYPE: {
ZWaveProxyFrame msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
ESP_LOGVV(TAG, "on_z_wave_proxy_frame: %s", msg.dump().c_str());
#endif
this->on_z_wave_proxy_frame(msg);
break;
}
#endif
#ifdef USE_ZWAVE_PROXY
case ZWaveProxyRequest::MESSAGE_TYPE: {
ZWaveProxyRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
ESP_LOGVV(TAG, "on_z_wave_proxy_request: %s", msg.dump().c_str());
#endif
this->on_z_wave_proxy_request(msg);
break;
}
#endif #endif
default: default:
break; break;
@@ -899,5 +921,19 @@ void APIServerConnection::on_alarm_control_panel_command_request(const AlarmCont
} }
} }
#endif #endif
#ifdef USE_ZWAVE_PROXY
void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
if (this->check_authenticated_()) {
this->zwave_proxy_frame(msg);
}
}
#endif
#ifdef USE_ZWAVE_PROXY
void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
if (this->check_authenticated_()) {
this->zwave_proxy_request(msg);
}
}
#endif
} // namespace esphome::api } // namespace esphome::api

View File

@@ -207,6 +207,12 @@ class APIServerConnectionBase : public ProtoService {
#ifdef USE_UPDATE #ifdef USE_UPDATE
virtual void on_update_command_request(const UpdateCommandRequest &value){}; virtual void on_update_command_request(const UpdateCommandRequest &value){};
#endif
#ifdef USE_ZWAVE_PROXY
virtual void on_z_wave_proxy_frame(const ZWaveProxyFrame &value){};
#endif
#ifdef USE_ZWAVE_PROXY
virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
#endif #endif
protected: protected:
void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;
@@ -335,6 +341,12 @@ class APIServerConnection : public APIServerConnectionBase {
#endif #endif
#ifdef USE_ALARM_CONTROL_PANEL #ifdef USE_ALARM_CONTROL_PANEL
virtual void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) = 0; virtual void alarm_control_panel_command(const AlarmControlPanelCommandRequest &msg) = 0;
#endif
#ifdef USE_ZWAVE_PROXY
virtual void zwave_proxy_frame(const ZWaveProxyFrame &msg) = 0;
#endif
#ifdef USE_ZWAVE_PROXY
virtual void zwave_proxy_request(const ZWaveProxyRequest &msg) = 0;
#endif #endif
protected: protected:
void on_hello_request(const HelloRequest &msg) override; void on_hello_request(const HelloRequest &msg) override;
@@ -459,6 +471,12 @@ class APIServerConnection : public APIServerConnectionBase {
#ifdef USE_ALARM_CONTROL_PANEL #ifdef USE_ALARM_CONTROL_PANEL
void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override; void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override;
#endif #endif
#ifdef USE_ZWAVE_PROXY
void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override;
#endif
#ifdef USE_ZWAVE_PROXY
void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override;
#endif
}; };
} // namespace esphome::api } // namespace esphome::api

View File

@@ -10,7 +10,8 @@ from esphome.const import (
PLATFORM_LN882X, PLATFORM_LN882X,
PLATFORM_RTL87XX, PLATFORM_RTL87XX,
) )
from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
AUTO_LOAD = ["web_server_base", "ota.web_server"] AUTO_LOAD = ["web_server_base", "ota.web_server"]
DEPENDENCIES = ["wifi"] DEPENDENCIES = ["wifi"]
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL)
async def to_code(config): async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])

View File

@@ -2,7 +2,7 @@ from esphome import pins
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import i2c, touchscreen from esphome.components import i2c, touchscreen
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
CODEOWNERS = ["@jesserockz"] CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["i2c"] DEPENDENCIES = ["i2c"]
@@ -15,7 +15,7 @@ EKTF2232Touchscreen = ektf2232_ns.class_(
) )
CONF_EKTF2232_ID = "ektf2232_id" CONF_EKTF2232_ID = "ektf2232_id"
CONF_RTS_PIN = "rts_pin" CONF_RTS_PIN = "rts_pin" # To be removed before 2026.4.0
CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend(
cv.Schema( cv.Schema(
@@ -24,7 +24,10 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend(
cv.Required(CONF_INTERRUPT_PIN): cv.All( cv.Required(CONF_INTERRUPT_PIN): cv.All(
pins.internal_gpio_input_pin_schema pins.internal_gpio_input_pin_schema
), ),
cv.Required(CONF_RTS_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_RTS_PIN): cv.invalid(
f"{CONF_RTS_PIN} has been renamed to {CONF_RESET_PIN}"
),
} }
).extend(i2c.i2c_device_schema(0x15)) ).extend(i2c.i2c_device_schema(0x15))
) )
@@ -37,5 +40,5 @@ async def to_code(config):
interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
cg.add(var.set_interrupt_pin(interrupt_pin)) cg.add(var.set_interrupt_pin(interrupt_pin))
rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
cg.add(var.set_rts_pin(rts_pin)) cg.add(var.set_reset_pin(reset_pin))

View File

@@ -21,7 +21,7 @@ void EKTF2232Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
this->rts_pin_->setup(); this->reset_pin_->setup();
this->hard_reset_(); this->hard_reset_();
if (!this->soft_reset_()) { if (!this->soft_reset_()) {
@@ -98,9 +98,9 @@ bool EKTF2232Touchscreen::get_power_state() {
} }
void EKTF2232Touchscreen::hard_reset_() { void EKTF2232Touchscreen::hard_reset_() {
this->rts_pin_->digital_write(false); this->reset_pin_->digital_write(false);
delay(15); delay(15);
this->rts_pin_->digital_write(true); this->reset_pin_->digital_write(true);
delay(15); delay(15);
} }
@@ -127,7 +127,7 @@ void EKTF2232Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "EKT2232 Touchscreen:"); ESP_LOGCONFIG(TAG, "EKT2232 Touchscreen:");
LOG_I2C_DEVICE(this); LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" RTS Pin: ", this->rts_pin_); LOG_PIN(" Reset Pin: ", this->reset_pin_);
} }
} // namespace ektf2232 } // namespace ektf2232

View File

@@ -17,7 +17,7 @@ class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice {
void dump_config() override; void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; } void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
void set_power_state(bool enable); void set_power_state(bool enable);
bool get_power_state(); bool get_power_state();
@@ -28,7 +28,7 @@ class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice {
void update_touches() override; void update_touches() override;
InternalGPIOPin *interrupt_pin_; InternalGPIOPin *interrupt_pin_;
GPIOPin *rts_pin_; GPIOPin *reset_pin_;
}; };
} // namespace ektf2232 } // namespace ektf2232

View File

@@ -37,7 +37,7 @@ from esphome.const import (
) )
from esphome.core import CORE, HexInt, TimePeriod from esphome.core import CORE, HexInt, TimePeriod
import esphome.final_validate as fv import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed from esphome.helpers import copy_file_if_changed, write_file_if_changed
from esphome.types import ConfigType from esphome.types import ConfigType
from esphome.writer import clean_cmake_cache from esphome.writer import clean_cmake_cache
@@ -272,14 +272,14 @@ def add_idf_component(
} }
def add_extra_script(stage: str, filename: str, path: str): def add_extra_script(stage: str, filename: str, path: Path):
"""Add an extra script to the project.""" """Add an extra script to the project."""
key = f"{stage}:{filename}" key = f"{stage}:{filename}"
if add_extra_build_file(filename, path): if add_extra_build_file(filename, path):
cg.add_platformio_option("extra_scripts", [key]) cg.add_platformio_option("extra_scripts", [key])
def add_extra_build_file(filename: str, path: str) -> bool: def add_extra_build_file(filename: str, path: Path) -> bool:
"""Add an extra build file to the project.""" """Add an extra build file to the project."""
if filename not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]: if filename not in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES]:
CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES][filename] = { CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES][filename] = {
@@ -818,7 +818,7 @@ async def to_code(config):
add_extra_script( add_extra_script(
"post", "post",
"post_build.py", "post_build.py",
os.path.join(os.path.dirname(__file__), "post_build.py.script"), Path(__file__).parent / "post_build.py.script",
) )
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
@@ -1040,7 +1040,7 @@ def _write_sdkconfig():
def _write_idf_component_yml(): def _write_idf_component_yml():
yml_path = Path(CORE.relative_build_path("src/idf_component.yml")) yml_path = CORE.relative_build_path("src/idf_component.yml")
if CORE.data[KEY_ESP32][KEY_COMPONENTS]: if CORE.data[KEY_ESP32][KEY_COMPONENTS]:
components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS]
dependencies = {} dependencies = {}
@@ -1058,8 +1058,8 @@ def _write_idf_component_yml():
contents = "" contents = ""
if write_file_if_changed(yml_path, contents): if write_file_if_changed(yml_path, contents):
dependencies_lock = CORE.relative_build_path("dependencies.lock") dependencies_lock = CORE.relative_build_path("dependencies.lock")
if os.path.isfile(dependencies_lock): if dependencies_lock.is_file():
os.remove(dependencies_lock) dependencies_lock.unlink()
clean_cmake_cache() clean_cmake_cache()
@@ -1093,14 +1093,13 @@ def copy_files():
) )
for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values():
if file[KEY_PATH].startswith("http"): name: str = file[KEY_NAME]
path: Path = file[KEY_PATH]
if str(path).startswith("http"):
import requests import requests
mkdir_p(CORE.relative_build_path(os.path.dirname(file[KEY_NAME]))) CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True)
with open(CORE.relative_build_path(file[KEY_NAME]), "wb") as f: content = requests.get(path, timeout=30).content
f.write(requests.get(file[KEY_PATH], timeout=30).content) CORE.relative_build_path(name).write_bytes(content)
else: else:
copy_file_if_changed( copy_file_if_changed(path, CORE.relative_build_path(name))
file[KEY_PATH],
CORE.relative_build_path(file[KEY_NAME]),
)

View File

@@ -1,4 +1,5 @@
import os import os
from pathlib import Path
from esphome import pins from esphome import pins
from esphome.components import esp32 from esphome.components import esp32
@@ -97,5 +98,5 @@ async def to_code(config):
esp32.add_extra_script( esp32.add_extra_script(
"post", "post",
"esp32_hosted.py", "esp32_hosted.py",
os.path.join(os.path.dirname(__file__), "esp32_hosted.py.script"), Path(__file__).parent / "esp32_hosted.py.script",
) )

View File

@@ -1,5 +1,5 @@
import logging import logging
import os from pathlib import Path
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
@@ -259,8 +259,8 @@ async def to_code(config):
# Called by writer.py # Called by writer.py
def copy_files(): def copy_files():
dir = os.path.dirname(__file__) dir = Path(__file__).parent
post_build_file = os.path.join(dir, "post_build.py.script") post_build_file = dir / "post_build.py.script"
copy_file_if_changed( copy_file_if_changed(
post_build_file, post_build_file,
CORE.relative_build_path("post_build.py"), CORE.relative_build_path("post_build.py"),

View File

@@ -16,7 +16,8 @@ from esphome.const import (
CONF_SAFE_MODE, CONF_SAFE_MODE,
CONF_VERSION, CONF_VERSION,
) )
from esphome.core import CoroPriority, coroutine_with_priority from esphome.core import coroutine_with_priority
from esphome.coroutine import CoroPriority
import esphome.final_validate as fv import esphome.final_validate as fv
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -121,7 +122,7 @@ CONFIG_SCHEMA = (
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_port(config[CONF_PORT])) cg.add(var.set_port(config[CONF_PORT]))

View File

@@ -3,7 +3,6 @@ import functools
import hashlib import hashlib
from itertools import accumulate from itertools import accumulate
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
@@ -38,6 +37,7 @@ from esphome.const import (
) )
from esphome.core import CORE, HexInt from esphome.core import CORE, HexInt
from esphome.helpers import cpp_string_escape from esphome.helpers import cpp_string_escape
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -253,11 +253,11 @@ def validate_truetype_file(value):
return CORE.relative_config_path(cv.file_(value)) return CORE.relative_config_path(cv.file_(value))
def add_local_file(value): def add_local_file(value: ConfigType) -> ConfigType:
if value in FONT_CACHE: if value in FONT_CACHE:
return value return value
path = value[CONF_PATH] path = Path(value[CONF_PATH])
if not os.path.isfile(path): if not path.is_file():
raise cv.Invalid(f"File '{path}' not found.") raise cv.Invalid(f"File '{path}' not found.")
FONT_CACHE[value] = path FONT_CACHE[value] = path
return value return value
@@ -318,7 +318,7 @@ def download_gfont(value):
external_files.compute_local_file_dir(DOMAIN) external_files.compute_local_file_dir(DOMAIN)
/ f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
) )
if not external_files.is_file_recent(str(path), value[CONF_REFRESH]): if not external_files.is_file_recent(path, value[CONF_REFRESH]):
_LOGGER.debug("download_gfont: path=%s", path) _LOGGER.debug("download_gfont: path=%s", path)
try: try:
req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)

View File

@@ -6,6 +6,7 @@ namespace gpio {
static const char *const TAG = "gpio.binary_sensor"; static const char *const TAG = "gpio.binary_sensor";
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
static const LogString *interrupt_type_to_string(gpio::InterruptType type) { static const LogString *interrupt_type_to_string(gpio::InterruptType type) {
switch (type) { switch (type) {
case gpio::INTERRUPT_RISING_EDGE: case gpio::INTERRUPT_RISING_EDGE:
@@ -22,6 +23,7 @@ static const LogString *interrupt_type_to_string(gpio::InterruptType type) {
static const LogString *gpio_mode_to_string(bool use_interrupt) { static const LogString *gpio_mode_to_string(bool use_interrupt) {
return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling");
} }
#endif
void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) {
bool new_state = arg->isr_pin_.digital_read(); bool new_state = arg->isr_pin_.digital_read();

View File

@@ -194,7 +194,7 @@ async def to_code(config):
cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT") cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT")
elif path := config.get(CONF_CA_CERTIFICATE_PATH): elif path := config.get(CONF_CA_CERTIFICATE_PATH):
cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT") cg.add_define("CPPHTTPLIB_OPENSSL_SUPPORT")
cg.add(var.set_ca_path(path)) cg.add(var.set_ca_path(str(path)))
cg.add_build_flag("-lssl") cg.add_build_flag("-lssl")
cg.add_build_flag("-lcrypto") cg.add_build_flag("-lcrypto")

View File

@@ -3,7 +3,8 @@ import esphome.codegen as cg
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME
from esphome.core import CoroPriority, coroutine_with_priority from esphome.core import coroutine_with_priority
from esphome.coroutine import CoroPriority
from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await ota_to_code(var, config) await ota_to_code(var, config)

View File

@@ -1,6 +1,5 @@
import json import json
import logging import logging
from os.path import dirname, isfile, join
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
@@ -24,6 +23,7 @@ from esphome.const import (
__version__, __version__,
) )
from esphome.core import CORE from esphome.core import CORE
from esphome.storage_json import StorageJSON
from . import gpio # noqa from . import gpio # noqa
from .const import ( from .const import (
@@ -129,7 +129,7 @@ def only_on_family(*, supported=None, unsupported=None):
return validator_ return validator_
def get_download_types(storage_json=None): def get_download_types(storage_json: StorageJSON = None):
types = [ types = [
{ {
"title": "UF2 package (recommended)", "title": "UF2 package (recommended)",
@@ -139,11 +139,11 @@ def get_download_types(storage_json=None):
}, },
] ]
build_dir = dirname(storage_json.firmware_bin_path) build_dir = storage_json.firmware_bin_path.parent
outputs = join(build_dir, "firmware.json") outputs = build_dir / "firmware.json"
if not isfile(outputs): if not outputs.is_file():
return types return types
with open(outputs, encoding="utf-8") as f: with outputs.open(encoding="utf-8") as f:
outputs = json.load(f) outputs = json.load(f)
for output in outputs: for output in outputs:
if not output["public"]: if not output["public"]:

View File

@@ -11,7 +11,8 @@ from esphome.const import (
CONF_SERVICES, CONF_SERVICES,
PlatformFramework, PlatformFramework,
) )
from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"] DEPENDENCIES = ["network"]
@@ -72,7 +73,7 @@ def mdns_service(
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def to_code(config): async def to_code(config):
if config[CONF_DISABLED] is True: if config[CONF_DISABLED] is True:
return return

View File

@@ -10,7 +10,8 @@ from esphome.const import (
CONF_TRIGGER_ID, CONF_TRIGGER_ID,
PlatformFramework, PlatformFramework,
) )
from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
AUTO_LOAD = ["md5", "safe_mode"] AUTO_LOAD = ["md5", "safe_mode"]
@@ -82,7 +83,7 @@ BASE_OTA_SCHEMA = cv.Schema(
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config): async def to_code(config):
cg.add_define("USE_OTA") cg.add_define("USE_OTA")

View File

@@ -121,15 +121,11 @@ def transport_schema(cls):
return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)}) return TRANSPORT_SCHEMA.extend({cv.GenerateID(): cv.declare_id(cls)})
# Build a list of sensors for this platform
CORE.data[DOMAIN] = {CONF_SENSORS: []}
def get_sensors(transport_id): def get_sensors(transport_id):
"""Return the list of sensors for this platform.""" """Return the list of sensors for this platform."""
return ( return (
sensor sensor
for sensor in CORE.data[DOMAIN][CONF_SENSORS] for sensor in CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, [])
if sensor[CONF_TRANSPORT_ID] == transport_id if sensor[CONF_TRANSPORT_ID] == transport_id
) )
@@ -137,7 +133,8 @@ def get_sensors(transport_id):
def validate_packet_transport_sensor(config): def validate_packet_transport_sensor(config):
if CONF_NAME in config and CONF_INTERNAL not in config: if CONF_NAME in config and CONF_INTERNAL not in config:
raise cv.Invalid("Must provide internal: config when using name:") raise cv.Invalid("Must provide internal: config when using name:")
CORE.data[DOMAIN][CONF_SENSORS].append(config) conf_sensors = CORE.data.setdefault(DOMAIN, {}).setdefault(CONF_SENSORS, [])
conf_sensors.append(config)
return config return config

View File

@@ -1,5 +1,5 @@
import logging import logging
import os from pathlib import Path
from string import ascii_letters, digits from string import ascii_letters, digits
import esphome.codegen as cg import esphome.codegen as cg
@@ -19,7 +19,7 @@ from esphome.const import (
ThreadModel, ThreadModel,
) )
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.helpers import copy_file_if_changed, mkdir_p, read_file, write_file from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns
@@ -221,18 +221,18 @@ def generate_pio_files() -> bool:
if not files: if not files:
return False return False
for key, data in files.items(): for key, data in files.items():
pio_path = CORE.relative_build_path(f"src/pio/{key}.pio") pio_path = CORE.build_path / "src" / "pio" / f"{key}.pio"
mkdir_p(os.path.dirname(pio_path)) pio_path.parent.mkdir(parents=True, exist_ok=True)
write_file(pio_path, data) write_file_if_changed(pio_path, data)
includes.append(f"pio/{key}.pio.h") includes.append(f"pio/{key}.pio.h")
write_file( write_file_if_changed(
CORE.relative_build_path("src/pio_includes.h"), CORE.relative_build_path("src/pio_includes.h"),
"#pragma once\n" + "\n".join([f'#include "{include}"' for include in includes]), "#pragma once\n" + "\n".join([f'#include "{include}"' for include in includes]),
) )
dir = os.path.dirname(__file__) dir = Path(__file__).parent
build_pio_file = os.path.join(dir, "build_pio.py.script") build_pio_file = dir / "build_pio.py.script"
copy_file_if_changed( copy_file_if_changed(
build_pio_file, build_pio_file,
CORE.relative_build_path("build_pio.py"), CORE.relative_build_path("build_pio.py"),
@@ -243,8 +243,8 @@ def generate_pio_files() -> bool:
# Called by writer.py # Called by writer.py
def copy_files(): def copy_files():
dir = os.path.dirname(__file__) dir = Path(__file__).parent
post_build_file = os.path.join(dir, "post_build.py.script") post_build_file = dir / "post_build.py.script"
copy_file_if_changed( copy_file_if_changed(
post_build_file, post_build_file,
CORE.relative_build_path("post_build.py"), CORE.relative_build_path("post_build.py"),
@@ -252,4 +252,4 @@ def copy_files():
if generate_pio_files(): if generate_pio_files():
path = CORE.relative_src_path("esphome.h") path = CORE.relative_src_path("esphome.h")
content = read_file(path).rstrip("\n") content = read_file(path).rstrip("\n")
write_file(path, content + '\n#include "pio_includes.h"\n') write_file_if_changed(path, content + '\n#include "pio_includes.h"\n')

View File

@@ -3,7 +3,8 @@ from esphome.components.esp32 import add_idf_component
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID from esphome.const import CONF_ID
from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network", "web_server_base"] DEPENDENCIES = ["network", "web_server_base"]
@@ -22,7 +23,7 @@ CONFIG_SCHEMA = (
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA)
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await ota_to_code(var, config) await ota_to_code(var, config)

View File

@@ -1,7 +1,8 @@
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.const import CONF_ID from esphome.const import CONF_ID
from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
CODEOWNERS = ["@esphome/core"] CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"] DEPENDENCIES = ["network"]
@@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema(
) )
@coroutine_with_priority(CoroPriority.COMMUNICATION) @coroutine_with_priority(CoroPriority.WEB_SERVER_BASE)
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)

View File

@@ -1,4 +1,4 @@
import os from pathlib import Path
from typing import TypedDict from typing import TypedDict
import esphome.codegen as cg import esphome.codegen as cg
@@ -48,7 +48,7 @@ class ZephyrData(TypedDict):
bootloader: str bootloader: str
prj_conf: dict[str, tuple[PrjConfValueType, bool]] prj_conf: dict[str, tuple[PrjConfValueType, bool]]
overlay: str overlay: str
extra_build_files: dict[str, str] extra_build_files: dict[str, Path]
pm_static: list[Section] pm_static: list[Section]
user: dict[str, list[str]] user: dict[str, list[str]]
@@ -93,7 +93,7 @@ def zephyr_add_overlay(content):
zephyr_data()[KEY_OVERLAY] += content zephyr_data()[KEY_OVERLAY] += content
def add_extra_build_file(filename: str, path: str) -> bool: def add_extra_build_file(filename: str, path: Path) -> bool:
"""Add an extra build file to the project.""" """Add an extra build file to the project."""
extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES] extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES]
if filename not in extra_build_files: if filename not in extra_build_files:
@@ -102,7 +102,7 @@ def add_extra_build_file(filename: str, path: str) -> bool:
return False return False
def add_extra_script(stage: str, filename: str, path: str): def add_extra_script(stage: str, filename: str, path: Path) -> None:
"""Add an extra script to the project.""" """Add an extra script to the project."""
key = f"{stage}:{filename}" key = f"{stage}:{filename}"
if add_extra_build_file(filename, path): if add_extra_build_file(filename, path):
@@ -144,7 +144,7 @@ def zephyr_to_code(config):
add_extra_script( add_extra_script(
"pre", "pre",
"pre_build.py", "pre_build.py",
os.path.join(os.path.dirname(__file__), "pre_build.py.script"), Path(__file__).parent / "pre_build.py.script",
) )

View File

@@ -0,0 +1,43 @@
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI
import esphome.final_validate as fv
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["api", "uart"]
zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy")
ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice)
def final_validate(config):
full_config = fv.full_config.get()
if (wifi_conf := full_config.get(CONF_WIFI)) and (
wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none"
):
raise cv.Invalid(
f"{CONF_WIFI} {CONF_POWER_SAVE_MODE} must be set to 'none' when using Z-Wave proxy"
)
return config
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ZWaveProxy),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
cg.add_define("USE_ZWAVE_PROXY")

View File

@@ -0,0 +1,262 @@
#include "zwave_proxy.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
namespace esphome {
namespace zwave_proxy {
static const char *const TAG = "zwave_proxy";
static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20;
// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...]
static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value
static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum
static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) {
// Calculate Z-Wave frame checksum
// XOR all bytes between SOF and checksum position (exclusive)
// Initial value is 0xFF per Z-Wave protocol specification
uint8_t checksum = 0xFF;
for (uint8_t i = 1; i < length - 1; i++) {
checksum ^= data[i];
}
return checksum;
}
ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; }
void ZWaveProxy::setup() { this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); }
void ZWaveProxy::loop() {
if (this->response_handler_()) {
ESP_LOGV(TAG, "Handled late response");
}
if (this->api_connection_ != nullptr && (!this->api_connection_->is_connection_setup() || !api_is_connected())) {
ESP_LOGW(TAG, "Subscriber disconnected");
this->api_connection_ = nullptr; // Unsubscribe if disconnected
}
while (this->available()) {
uint8_t byte;
if (!this->read_byte(&byte)) {
this->status_set_warning("UART read failed");
return;
}
if (this->parse_byte_(byte)) {
// Check if this is a GET_NETWORK_IDS response frame
// Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...]
// We verify:
// - buffer_[0]: Start of frame marker (0x01)
// - buffer_[1]: Length field must be >= 9 to contain all required data
// - buffer_[2]: Command type (0x01 for response)
// - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS)
if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE &&
this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) {
// Extract the 4-byte Home ID starting at offset 4
// The frame parser has already validated the checksum and ensured all bytes are present
std::memcpy(this->home_id_.data(), this->buffer_.data() + 4, this->home_id_.size());
ESP_LOGI(TAG, "Home ID: %s",
format_hex_pretty(this->home_id_.data(), this->home_id_.size(), ':', false).c_str());
}
ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr));
if (this->api_connection_ != nullptr) {
// minimize copying to reduce CPU overhead
if (this->in_bootloader_) {
this->outgoing_proto_msg_.data_len = this->buffer_index_;
} else {
// If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN
this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1;
}
std::memcpy(this->outgoing_proto_msg_.data, this->buffer_.data(), this->outgoing_proto_msg_.data_len);
this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE);
}
}
}
this->status_clear_warning();
}
void ZWaveProxy::dump_config() { ESP_LOGCONFIG(TAG, "Z-Wave Proxy"); }
void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) {
switch (type) {
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE:
if (this->api_connection_ != nullptr) {
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
return;
}
this->api_connection_ = api_connection;
ESP_LOGV(TAG, "API connection is now subscribed");
break;
case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
if (this->api_connection_ != api_connection) {
ESP_LOGV(TAG, "API connection is not subscribed");
return;
}
this->api_connection_ = nullptr;
break;
default:
ESP_LOGW(TAG, "Unknown request type: %d", type);
break;
}
}
void ZWaveProxy::send_frame(const uint8_t *data, size_t length) {
if (length == 1 && data[0] == this->last_response_) {
ESP_LOGV(TAG, "Skipping sending duplicate response: 0x%02X", data[0]);
return;
}
ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty(data, length).c_str());
this->write_array(data, length);
}
void ZWaveProxy::send_simple_command_(const uint8_t command_id) {
// Send a simple Z-Wave command with no parameters
// Frame format: [SOF][LENGTH][TYPE][CMD][CHECKSUM]
// Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM)
uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00};
cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd));
this->send_frame(cmd, sizeof(cmd));
}
bool ZWaveProxy::parse_byte_(uint8_t byte) {
bool frame_completed = false;
// Basic parsing logic for received frames
switch (this->parsing_state_) {
case ZWAVE_PARSING_STATE_WAIT_START:
this->parse_start_(byte);
break;
case ZWAVE_PARSING_STATE_WAIT_LENGTH:
if (!byte) {
ESP_LOGW(TAG, "Invalid LENGTH: %u", byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK;
return false;
}
ESP_LOGVV(TAG, "Received LENGTH: %u", byte);
this->end_frame_after_ = this->buffer_index_ + byte;
ESP_LOGVV(TAG, "Calculated EOF: %u", this->end_frame_after_);
this->buffer_[this->buffer_index_++] = byte;
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_TYPE;
break;
case ZWAVE_PARSING_STATE_WAIT_TYPE:
this->buffer_[this->buffer_index_++] = byte;
ESP_LOGVV(TAG, "Received TYPE: 0x%02X", byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_COMMAND_ID;
break;
case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID:
this->buffer_[this->buffer_index_++] = byte;
ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD;
break;
case ZWAVE_PARSING_STATE_WAIT_PAYLOAD:
this->buffer_[this->buffer_index_++] = byte;
ESP_LOGVV(TAG, "Received PAYLOAD: 0x%02X", byte);
if (this->buffer_index_ >= this->end_frame_after_) {
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_CHECKSUM;
}
break;
case ZWAVE_PARSING_STATE_WAIT_CHECKSUM: {
this->buffer_[this->buffer_index_++] = byte;
auto checksum = calculate_frame_checksum(this->buffer_.data(), this->buffer_index_);
ESP_LOGVV(TAG, "CHECKSUM Received: 0x%02X - Calculated: 0x%02X", byte, checksum);
if (checksum != byte) {
ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", checksum, byte);
this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK;
} else {
this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK;
ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(this->buffer_.data(), this->buffer_index_).c_str());
frame_completed = true;
}
this->response_handler_();
break;
}
case ZWAVE_PARSING_STATE_READ_BL_MENU:
this->buffer_[this->buffer_index_++] = byte;
if (!byte) {
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
frame_completed = true;
}
break;
case ZWAVE_PARSING_STATE_SEND_ACK:
case ZWAVE_PARSING_STATE_SEND_NAK:
break; // Should not happen, handled in loop()
default:
ESP_LOGW(TAG, "Bad parsing state; resetting");
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
break;
}
return frame_completed;
}
void ZWaveProxy::parse_start_(uint8_t byte) {
this->buffer_index_ = 0;
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
switch (byte) {
case ZWAVE_FRAME_TYPE_START:
ESP_LOGVV(TAG, "Received START");
if (this->in_bootloader_) {
ESP_LOGD(TAG, "Exited bootloader mode");
this->in_bootloader_ = false;
}
this->buffer_[this->buffer_index_++] = byte;
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH;
return;
case ZWAVE_FRAME_TYPE_BL_MENU:
ESP_LOGVV(TAG, "Received BL_MENU");
if (!this->in_bootloader_) {
ESP_LOGD(TAG, "Entered bootloader mode");
this->in_bootloader_ = true;
}
this->buffer_[this->buffer_index_++] = byte;
this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU;
return;
case ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD:
ESP_LOGVV(TAG, "Received BL_BEGIN_UPLOAD");
break;
case ZWAVE_FRAME_TYPE_ACK:
ESP_LOGVV(TAG, "Received ACK");
break;
case ZWAVE_FRAME_TYPE_NAK:
ESP_LOGW(TAG, "Received NAK");
break;
case ZWAVE_FRAME_TYPE_CAN:
ESP_LOGW(TAG, "Received CAN");
break;
default:
ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte);
return;
}
// Forward response (ACK/NAK/CAN) back to client for processing
if (this->api_connection_ != nullptr) {
this->outgoing_proto_msg_.data[0] = byte;
this->outgoing_proto_msg_.data_len = 1;
this->api_connection_->send_message(this->outgoing_proto_msg_, api::ZWaveProxyFrame::MESSAGE_TYPE);
}
}
bool ZWaveProxy::response_handler_() {
switch (this->parsing_state_) {
case ZWAVE_PARSING_STATE_SEND_ACK:
this->last_response_ = ZWAVE_FRAME_TYPE_ACK;
break;
case ZWAVE_PARSING_STATE_SEND_CAN:
this->last_response_ = ZWAVE_FRAME_TYPE_CAN;
break;
case ZWAVE_PARSING_STATE_SEND_NAK:
this->last_response_ = ZWAVE_FRAME_TYPE_NAK;
break;
default:
return false; // No response handled
}
ESP_LOGVV(TAG, "Sending %s (0x%02X)", this->last_response_ == ZWAVE_FRAME_TYPE_ACK ? "ACK" : "NAK/CAN",
this->last_response_);
this->write_byte(this->last_response_);
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START;
return true;
}
ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace zwave_proxy
} // namespace esphome

View File

@@ -0,0 +1,81 @@
#pragma once
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/uart/uart.h"
#include <array>
namespace esphome {
namespace zwave_proxy {
enum ZWaveResponseTypes : uint8_t {
ZWAVE_FRAME_TYPE_ACK = 0x06,
ZWAVE_FRAME_TYPE_CAN = 0x18,
ZWAVE_FRAME_TYPE_NAK = 0x15,
ZWAVE_FRAME_TYPE_START = 0x01,
ZWAVE_FRAME_TYPE_BL_MENU = 0x0D,
ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD = 0x43,
};
enum ZWaveParsingState : uint8_t {
ZWAVE_PARSING_STATE_WAIT_START,
ZWAVE_PARSING_STATE_WAIT_LENGTH,
ZWAVE_PARSING_STATE_WAIT_TYPE,
ZWAVE_PARSING_STATE_WAIT_COMMAND_ID,
ZWAVE_PARSING_STATE_WAIT_PAYLOAD,
ZWAVE_PARSING_STATE_WAIT_CHECKSUM,
ZWAVE_PARSING_STATE_SEND_ACK,
ZWAVE_PARSING_STATE_SEND_CAN,
ZWAVE_PARSING_STATE_SEND_NAK,
ZWAVE_PARSING_STATE_READ_BL_MENU,
};
enum ZWaveProxyFeature : uint32_t {
FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0,
};
class ZWaveProxy : public uart::UARTDevice, public Component {
public:
ZWaveProxy();
void setup() override;
void loop() override;
void dump_config() override;
void zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type);
api::APIConnection *get_api_connection() { return this->api_connection_; }
uint32_t get_feature_flags() const { return ZWaveProxyFeature::FEATURE_ZWAVE_PROXY_ENABLED; }
uint32_t get_home_id() {
return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]);
}
void send_frame(const uint8_t *data, size_t length);
protected:
void send_simple_command_(uint8_t command_id);
bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer)
void parse_start_(uint8_t byte);
bool response_handler_();
api::APIConnection *api_connection_{nullptr}; // Current subscribed client
std::array<uint8_t, 4> home_id_{0, 0, 0, 0}; // Fixed buffer for home ID
std::array<uint8_t, sizeof(api::ZWaveProxyFrame::data)> buffer_; // Fixed buffer for incoming data
uint8_t buffer_index_{0}; // Index for populating the data buffer
uint8_t end_frame_after_{0}; // Payload reception ends after this index
uint8_t last_response_{0}; // Last response type sent
ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START};
bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode
// Pre-allocated message - always ready to send
api::ZWaveProxyFrame outgoing_proto_msg_;
};
extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace zwave_proxy
} // namespace esphome

View File

@@ -15,7 +15,7 @@ from ipaddress import (
ip_network, ip_network,
) )
import logging import logging
import os from pathlib import Path
import re import re
from string import ascii_letters, digits from string import ascii_letters, digits
import uuid as uuid_ import uuid as uuid_
@@ -1609,34 +1609,32 @@ def dimensions(value):
return dimensions([match.group(1), match.group(2)]) return dimensions([match.group(1), match.group(2)])
def directory(value): def directory(value: object) -> Path:
value = string(value) value = string(value)
path = CORE.relative_config_path(value) path = CORE.relative_config_path(value)
if not os.path.exists(path): if not path.exists():
raise Invalid( raise Invalid(
f"Could not find directory '{path}'. Please make sure it exists (full path: {os.path.abspath(path)})." f"Could not find directory '{path}'. Please make sure it exists (full path: {path.resolve()})."
) )
if not os.path.isdir(path): if not path.is_dir():
raise Invalid( raise Invalid(
f"Path '{path}' is not a directory (full path: {os.path.abspath(path)})." f"Path '{path}' is not a directory (full path: {path.resolve()})."
) )
return value return path
def file_(value): def file_(value: object) -> Path:
value = string(value) value = string(value)
path = CORE.relative_config_path(value) path = CORE.relative_config_path(value)
if not os.path.exists(path): if not path.exists():
raise Invalid( raise Invalid(
f"Could not find file '{path}'. Please make sure it exists (full path: {os.path.abspath(path)})." f"Could not find file '{path}'. Please make sure it exists (full path: {path.resolve()})."
) )
if not os.path.isfile(path): if not path.is_file():
raise Invalid( raise Invalid(f"Path '{path}' is not a file (full path: {path.resolve()}).")
f"Path '{path}' is not a file (full path: {os.path.abspath(path)})." return path
)
return value
ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_" ENTITY_ID_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789_"

View File

@@ -3,6 +3,7 @@ from contextlib import contextmanager
import logging import logging
import math import math
import os import os
from pathlib import Path
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -383,7 +384,7 @@ class DocumentLocation:
@classmethod @classmethod
def from_mark(cls, mark): def from_mark(cls, mark):
return cls(mark.name, mark.line, mark.column) return cls(str(mark.name), mark.line, mark.column)
def __str__(self): def __str__(self):
return f"{self.document} {self.line}:{self.column}" return f"{self.document} {self.line}:{self.column}"
@@ -538,9 +539,9 @@ class EsphomeCore:
# The first key to this dict should always be the integration name # The first key to this dict should always be the integration name
self.data = {} self.data = {}
# The relative path to the configuration YAML # The relative path to the configuration YAML
self.config_path: str | None = None self.config_path: Path | None = None
# The relative path to where all build files are stored # The relative path to where all build files are stored
self.build_path: str | None = None self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated # The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None self.config: ConfigType | None = None
# The pending tasks in the task queue (mostly for C++ generation) # The pending tasks in the task queue (mostly for C++ generation)
@@ -664,39 +665,42 @@ class EsphomeCore:
return None return None
@property @property
def config_dir(self): def config_dir(self) -> Path:
return os.path.abspath(os.path.dirname(self.config_path)) if self.config_path.is_dir():
return self.config_path.absolute()
return self.config_path.absolute().parent
@property @property
def data_dir(self): def data_dir(self) -> Path:
if is_ha_addon(): if is_ha_addon():
return os.path.join("/data") return Path("/data")
if "ESPHOME_DATA_DIR" in os.environ: if "ESPHOME_DATA_DIR" in os.environ:
return get_str_env("ESPHOME_DATA_DIR", None) return Path(get_str_env("ESPHOME_DATA_DIR", None))
return self.relative_config_path(".esphome") return self.relative_config_path(".esphome")
@property @property
def config_filename(self): def config_filename(self) -> str:
return os.path.basename(self.config_path) return self.config_path.name
def relative_config_path(self, *path): def relative_config_path(self, *path: str | Path) -> Path:
path_ = os.path.expanduser(os.path.join(*path)) path_ = Path(*path).expanduser()
return os.path.join(self.config_dir, path_) return self.config_dir / path_
def relative_internal_path(self, *path: str) -> str: def relative_internal_path(self, *path: str | Path) -> Path:
return os.path.join(self.data_dir, *path) path_ = Path(*path).expanduser()
return self.data_dir / path_
def relative_build_path(self, *path): def relative_build_path(self, *path: str | Path) -> Path:
path_ = os.path.expanduser(os.path.join(*path)) path_ = Path(*path).expanduser()
return os.path.join(self.build_path, path_) return self.build_path / path_
def relative_src_path(self, *path): def relative_src_path(self, *path: str | Path) -> Path:
return self.relative_build_path("src", *path) return self.relative_build_path("src", *path)
def relative_pioenvs_path(self, *path): def relative_pioenvs_path(self, *path: str | Path) -> Path:
return self.relative_build_path(".pioenvs", *path) return self.relative_build_path(".pioenvs", *path)
def relative_piolibdeps_path(self, *path): def relative_piolibdeps_path(self, *path: str | Path) -> Path:
return self.relative_build_path(".piolibdeps", *path) return self.relative_build_path(".piolibdeps", *path)
@property @property
@@ -709,7 +713,7 @@ class EsphomeCore:
return os.path.expanduser("~/.platformio/.cache") return os.path.expanduser("~/.platformio/.cache")
@property @property
def firmware_bin(self): def firmware_bin(self) -> Path:
if self.is_libretiny: if self.is_libretiny:
return self.relative_pioenvs_path(self.name, "firmware.uf2") return self.relative_pioenvs_path(self.name, "firmware.uf2")
return self.relative_pioenvs_path(self.name, "firmware.bin") return self.relative_pioenvs_path(self.name, "firmware.bin")

View File

@@ -136,21 +136,21 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType:
return config return config
def valid_include(value): def valid_include(value: str) -> str:
# Look for "<...>" includes # Look for "<...>" includes
if value.startswith("<") and value.endswith(">"): if value.startswith("<") and value.endswith(">"):
return value return value
try: try:
return cv.directory(value) return str(cv.directory(value))
except cv.Invalid: except cv.Invalid:
pass pass
value = cv.file_(value) path = cv.file_(value)
_, ext = os.path.splitext(value) ext = path.suffix
if ext not in VALID_INCLUDE_EXTS: if ext not in VALID_INCLUDE_EXTS:
raise cv.Invalid( raise cv.Invalid(
f"Include has invalid file extension {ext} - valid extensions are {', '.join(VALID_INCLUDE_EXTS)}" f"Include has invalid file extension {ext} - valid extensions are {', '.join(VALID_INCLUDE_EXTS)}"
) )
return value return str(path)
def valid_project_name(value: str): def valid_project_name(value: str):
@@ -311,9 +311,9 @@ def preload_core_config(config, result) -> str:
CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE] = {}
if CONF_BUILD_PATH not in conf: if CONF_BUILD_PATH not in conf:
build_path = get_str_env("ESPHOME_BUILD_PATH", "build") build_path = Path(get_str_env("ESPHOME_BUILD_PATH", "build"))
conf[CONF_BUILD_PATH] = os.path.join(build_path, CORE.name) conf[CONF_BUILD_PATH] = str(build_path / CORE.name)
CORE.build_path = CORE.relative_internal_path(conf[CONF_BUILD_PATH]) CORE.build_path = CORE.data_dir / conf[CONF_BUILD_PATH]
target_platforms = [] target_platforms = []
@@ -339,12 +339,12 @@ def preload_core_config(config, result) -> str:
return target_platforms[0] return target_platforms[0]
def include_file(path, basename): def include_file(path: Path, basename: Path):
parts = basename.split(os.path.sep) parts = basename.parts
dst = CORE.relative_src_path(*parts) dst = CORE.relative_src_path(*parts)
copy_file_if_changed(path, dst) copy_file_if_changed(path, dst)
_, ext = os.path.splitext(path) ext = path.suffix
if ext in [".h", ".hpp", ".tcc"]: if ext in [".h", ".hpp", ".tcc"]:
# Header, add include statement # Header, add include statement
cg.add_global(cg.RawStatement(f'#include "{basename}"')) cg.add_global(cg.RawStatement(f'#include "{basename}"'))
@@ -377,18 +377,18 @@ async def add_arduino_global_workaround():
@coroutine_with_priority(CoroPriority.FINAL) @coroutine_with_priority(CoroPriority.FINAL)
async def add_includes(includes): async def add_includes(includes: list[str]) -> None:
# Add includes at the very end, so that the included files can access global variables # Add includes at the very end, so that the included files can access global variables
for include in includes: for include in includes:
path = CORE.relative_config_path(include) path = CORE.relative_config_path(include)
if os.path.isdir(path): if path.is_dir():
# Directory, copy tree # Directory, copy tree
for p in walk_files(path): for p in walk_files(path):
basename = os.path.relpath(p, os.path.dirname(path)) basename = p.relative_to(path.parent)
include_file(p, basename) include_file(p, basename)
else: else:
# Copy file # Copy file
basename = os.path.basename(path) basename = Path(path.name)
include_file(path, basename) include_file(path, basename)

View File

@@ -100,6 +100,7 @@
#define USE_UART_DEBUGGER #define USE_UART_DEBUGGER
#define USE_UPDATE #define USE_UPDATE
#define USE_VALVE #define USE_VALVE
#define USE_ZWAVE_PROXY
// Feature flags which do not work for zephyr // Feature flags which do not work for zephyr
#ifndef USE_ZEPHYR #ifndef USE_ZEPHYR

View File

@@ -90,11 +90,30 @@ class CoroPriority(enum.IntEnum):
# Examples: status_led (80) # Examples: status_led (80)
STATUS = 80 STATUS = 80
# Web server infrastructure
# Examples: web_server_base (65)
WEB_SERVER_BASE = 65
# Network portal services
# Examples: captive_portal (64)
CAPTIVE_PORTAL = 64
# Communication protocols and services # Communication protocols and services
# Examples: web_server_base (65), captive_portal (64), wifi (60), ethernet (60), # Examples: wifi (60), ethernet (60)
# mdns (55), ota_updates (54), web_server_ota (52)
COMMUNICATION = 60 COMMUNICATION = 60
# Network discovery and management services
# Examples: mdns (55)
NETWORK_SERVICES = 55
# OTA update services
# Examples: ota_updates (54)
OTA_UPDATES = 54
# Web-based OTA services
# Examples: web_server_ota (52)
WEB_SERVER_OTA = 52
# Application-level services # Application-level services
# Examples: safe_mode (50) # Examples: safe_mode (50)
APPLICATION = 50 APPLICATION = 50

View File

@@ -7,7 +7,6 @@ from dataclasses import dataclass
from functools import partial from functools import partial
import json import json
import logging import logging
from pathlib import Path
import threading import threading
from typing import Any from typing import Any
@@ -108,7 +107,7 @@ class ESPHomeDashboard:
await self.loop.run_in_executor(None, self.load_ignored_devices) await self.loop.run_in_executor(None, self.load_ignored_devices)
def load_ignored_devices(self) -> None: def load_ignored_devices(self) -> None:
storage_path = Path(ignored_devices_storage_path()) storage_path = ignored_devices_storage_path()
try: try:
with storage_path.open("r", encoding="utf-8") as f_handle: with storage_path.open("r", encoding="utf-8") as f_handle:
data = json.load(f_handle) data = json.load(f_handle)
@@ -117,7 +116,7 @@ class ESPHomeDashboard:
pass pass
def save_ignored_devices(self) -> None: def save_ignored_devices(self) -> None:
storage_path = Path(ignored_devices_storage_path()) storage_path = ignored_devices_storage_path()
with storage_path.open("w", encoding="utf-8") as f_handle: with storage_path.open("w", encoding="utf-8") as f_handle:
json.dump( json.dump(
{"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle

View File

@@ -5,7 +5,7 @@ from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
import logging import logging
import os from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from esphome import const, util from esphome import const, util
@@ -287,12 +287,12 @@ class DashboardEntries:
for file in util.list_yaml_files([self._config_dir]): for file in util.list_yaml_files([self._config_dir]):
try: try:
# Prefer the json storage path if it exists # Prefer the json storage path if it exists
stat = os.stat(ext_storage_path(os.path.basename(file))) stat = ext_storage_path(file.name).stat()
except OSError: except OSError:
try: try:
# Fallback to the yaml file if the storage # Fallback to the yaml file if the storage
# file does not exist or could not be generated # file does not exist or could not be generated
stat = os.stat(file) stat = file.stat()
except OSError: except OSError:
# File was deleted, ignore # File was deleted, ignore
continue continue
@@ -329,10 +329,10 @@ class DashboardEntry:
"_to_dict", "_to_dict",
) )
def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None: def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None:
"""Initialize the DashboardEntry.""" """Initialize the DashboardEntry."""
self.path = path self.path = path
self.filename: str = os.path.basename(path) self.filename: str = path.name
self._storage_path = ext_storage_path(self.filename) self._storage_path = ext_storage_path(self.filename)
self.cache_key = cache_key self.cache_key = cache_key
self.storage: StorageJSON | None = None self.storage: StorageJSON | None = None
@@ -365,7 +365,7 @@ class DashboardEntry:
"loaded_integrations": sorted(self.loaded_integrations), "loaded_integrations": sorted(self.loaded_integrations),
"deployed_version": self.update_old, "deployed_version": self.update_old,
"current_version": self.update_new, "current_version": self.update_new,
"path": self.path, "path": str(self.path),
"comment": self.comment, "comment": self.comment,
"address": self.address, "address": self.address,
"web_port": self.web_port, "web_port": self.web_port,

View File

@@ -27,7 +27,7 @@ class DashboardSettings:
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the dashboard settings.""" """Initialize the dashboard settings."""
self.config_dir: str = "" self.config_dir: Path = None
self.password_hash: str = "" self.password_hash: str = ""
self.username: str = "" self.username: str = ""
self.using_password: bool = False self.using_password: bool = False
@@ -45,10 +45,10 @@ class DashboardSettings:
self.using_password = bool(password) self.using_password = bool(password)
if self.using_password: if self.using_password:
self.password_hash = password_hash(password) self.password_hash = password_hash(password)
self.config_dir = args.configuration self.config_dir = Path(args.configuration)
self.absolute_config_dir = Path(self.config_dir).resolve() self.absolute_config_dir = self.config_dir.resolve()
self.verbose = args.verbose self.verbose = args.verbose
CORE.config_path = os.path.join(self.config_dir, ".") CORE.config_path = self.config_dir / "."
@property @property
def relative_url(self) -> str: def relative_url(self) -> str:
@@ -81,9 +81,9 @@ class DashboardSettings:
# Compare password in constant running time (to prevent timing attacks) # Compare password in constant running time (to prevent timing attacks)
return hmac.compare_digest(self.password_hash, password_hash(password)) return hmac.compare_digest(self.password_hash, password_hash(password))
def rel_path(self, *args: Any) -> str: def rel_path(self, *args: Any) -> Path:
"""Return a path relative to the ESPHome config folder.""" """Return a path relative to the ESPHome config folder."""
joined_path = os.path.join(self.config_dir, *args) joined_path = self.config_dir / Path(*args)
# Raises ValueError if not relative to ESPHome config folder # Raises ValueError if not relative to ESPHome config folder
Path(joined_path).resolve().relative_to(self.absolute_config_dir) joined_path.resolve().relative_to(self.absolute_config_dir)
return joined_path return joined_path

View File

@@ -1,63 +0,0 @@
import logging
import os
from pathlib import Path
import tempfile
_LOGGER = logging.getLogger(__name__)
def write_utf8_file(
filename: Path,
utf8_str: str,
private: bool = False,
) -> None:
"""Write a file and rename it into place.
Writes all or nothing.
"""
write_file(filename, utf8_str.encode("utf-8"), private)
# from https://github.com/home-assistant/core/blob/dev/homeassistant/util/file.py
def write_file(
filename: Path,
utf8_data: bytes,
private: bool = False,
) -> None:
"""Write a file and rename it into place.
Writes all or nothing.
"""
tmp_filename = ""
missing_fchmod = False
try:
# Modern versions of Python tempfile create this file with mode 0o600
with tempfile.NamedTemporaryFile(
mode="wb", dir=os.path.dirname(filename), delete=False
) as fdesc:
fdesc.write(utf8_data)
tmp_filename = fdesc.name
if not private:
try:
os.fchmod(fdesc.fileno(), 0o644)
except AttributeError:
# os.fchmod is not available on Windows
missing_fchmod = True
os.replace(tmp_filename, filename)
if missing_fchmod:
os.chmod(filename, 0o644)
finally:
if os.path.exists(tmp_filename):
try:
os.remove(tmp_filename)
except OSError as err:
# If we are cleaning up then something else went wrong, so
# we should suppress likely follow-on errors in the cleanup
_LOGGER.error(
"File replacement cleanup failed for %s while saving %s: %s",
tmp_filename,
filename,
err,
)

View File

@@ -49,10 +49,10 @@ from esphome.storage_json import (
from esphome.util import get_serial_ports, shlex_quote from esphome.util import get_serial_ports, shlex_quote
from esphome.yaml_util import FastestAvailableSafeLoader from esphome.yaml_util import FastestAvailableSafeLoader
from ..helpers import write_file
from .const import DASHBOARD_COMMAND from .const import DASHBOARD_COMMAND
from .core import DASHBOARD, ESPHomeDashboard from .core import DASHBOARD, ESPHomeDashboard
from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool
from .util.file import write_file
from .util.subprocess import async_run_system_command from .util.subprocess import async_run_system_command
from .util.text import friendly_name_slugify from .util.text import friendly_name_slugify
@@ -581,7 +581,7 @@ class WizardRequestHandler(BaseHandler):
destination = settings.rel_path(filename) destination = settings.rel_path(filename)
# Check if destination file already exists # Check if destination file already exists
if os.path.exists(destination): if destination.exists():
self.set_status(409) # Conflict status code self.set_status(409) # Conflict status code
self.set_header("content-type", "application/json") self.set_header("content-type", "application/json")
self.write( self.write(
@@ -798,10 +798,9 @@ class DownloadBinaryRequestHandler(BaseHandler):
"download", "download",
f"{storage_json.name}-{file_name}", f"{storage_json.name}-{file_name}",
) )
path = os.path.dirname(storage_json.firmware_bin_path) path = storage_json.firmware_bin_path.with_name(file_name)
path = os.path.join(path, file_name)
if not Path(path).is_file(): if not path.is_file():
args = ["esphome", "idedata", settings.rel_path(configuration)] args = ["esphome", "idedata", settings.rel_path(configuration)]
rc, stdout, _ = await async_run_system_command(args) rc, stdout, _ = await async_run_system_command(args)
@@ -1016,7 +1015,7 @@ class EditRequestHandler(BaseHandler):
return return
filename = settings.rel_path(configuration) filename = settings.rel_path(configuration)
if Path(filename).resolve().parent != settings.absolute_config_dir: if filename.resolve().parent != settings.absolute_config_dir:
self.send_error(404) self.send_error(404)
return return
@@ -1039,10 +1038,6 @@ class EditRequestHandler(BaseHandler):
self.set_status(404) self.set_status(404)
return None return None
def _write_file(self, filename: str, content: bytes) -> None:
"""Write a file with the given content."""
write_file(filename, content)
@authenticated @authenticated
@bind_config @bind_config
async def post(self, configuration: str | None = None) -> None: async def post(self, configuration: str | None = None) -> None:
@@ -1052,12 +1047,12 @@ class EditRequestHandler(BaseHandler):
return return
filename = settings.rel_path(configuration) filename = settings.rel_path(configuration)
if Path(filename).resolve().parent != settings.absolute_config_dir: if filename.resolve().parent != settings.absolute_config_dir:
self.send_error(404) self.send_error(404)
return return
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._write_file, filename, self.request.body) await loop.run_in_executor(None, write_file, filename, self.request.body)
# Ensure the StorageJSON is updated as well # Ensure the StorageJSON is updated as well
DASHBOARD.entries.async_schedule_storage_json_update(filename) DASHBOARD.entries.async_schedule_storage_json_update(filename)
self.set_status(200) self.set_status(200)
@@ -1072,7 +1067,7 @@ class ArchiveRequestHandler(BaseHandler):
archive_path = archive_storage_path() archive_path = archive_storage_path()
mkdir_p(archive_path) mkdir_p(archive_path)
shutil.move(config_file, os.path.join(archive_path, configuration)) shutil.move(config_file, archive_path / configuration)
storage_json = StorageJSON.load(storage_path) storage_json = StorageJSON.load(storage_path)
if storage_json is not None and storage_json.build_path: if storage_json is not None and storage_json.build_path:
@@ -1086,7 +1081,7 @@ class UnArchiveRequestHandler(BaseHandler):
def post(self, configuration: str | None = None) -> None: def post(self, configuration: str | None = None) -> None:
config_file = settings.rel_path(configuration) config_file = settings.rel_path(configuration)
archive_path = archive_storage_path() archive_path = archive_storage_path()
shutil.move(os.path.join(archive_path, configuration), config_file) shutil.move(archive_path / configuration, config_file)
class LoginHandler(BaseHandler): class LoginHandler(BaseHandler):
@@ -1173,7 +1168,7 @@ class SecretKeysRequestHandler(BaseHandler):
for secret_filename in const.SECRETS_FILES: for secret_filename in const.SECRETS_FILES:
relative_filename = settings.rel_path(secret_filename) relative_filename = settings.rel_path(secret_filename)
if os.path.isfile(relative_filename): if relative_filename.is_file():
filename = relative_filename filename = relative_filename
break break
@@ -1206,16 +1201,17 @@ class JsonConfigRequestHandler(BaseHandler):
@bind_config @bind_config
async def get(self, configuration: str | None = None) -> None: async def get(self, configuration: str | None = None) -> None:
filename = settings.rel_path(configuration) filename = settings.rel_path(configuration)
if not os.path.isfile(filename): if not filename.is_file():
self.send_error(404) self.send_error(404)
return return
args = ["esphome", "config", filename, "--show-secrets"] args = ["esphome", "config", str(filename), "--show-secrets"]
rc, stdout, _ = await async_run_system_command(args) rc, stdout, stderr = await async_run_system_command(args)
if rc != 0: if rc != 0:
self.send_error(422) self.set_status(422)
self.write(stderr)
return return
data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown)
@@ -1224,7 +1220,7 @@ class JsonConfigRequestHandler(BaseHandler):
self.finish() self.finish()
def get_base_frontend_path() -> str: def get_base_frontend_path() -> Path:
if ENV_DEV not in os.environ: if ENV_DEV not in os.environ:
import esphome_dashboard import esphome_dashboard
@@ -1235,11 +1231,12 @@ def get_base_frontend_path() -> str:
static_path += "/" static_path += "/"
# This path can be relative, so resolve against the root or else templates don't work # This path can be relative, so resolve against the root or else templates don't work
return os.path.abspath(os.path.join(os.getcwd(), static_path, "esphome_dashboard")) path = Path(os.getcwd()) / static_path / "esphome_dashboard"
return path.resolve()
def get_static_path(*args: Iterable[str]) -> str: def get_static_path(*args: Iterable[str]) -> Path:
return os.path.join(get_base_frontend_path(), "static", *args) return get_base_frontend_path() / "static" / Path(*args)
@functools.cache @functools.cache
@@ -1256,8 +1253,7 @@ def get_static_file_url(name: str) -> str:
return base.replace("index.js", esphome_dashboard.entrypoint()) return base.replace("index.js", esphome_dashboard.entrypoint())
path = get_static_path(name) path = get_static_path(name)
with open(path, "rb") as f_handle: hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8]
hash_ = hashlib.md5(f_handle.read()).hexdigest()[:8]
return f"{base}?hash={hash_}" return f"{base}?hash={hash_}"
@@ -1357,7 +1353,7 @@ def start_web_server(
"""Start the web server listener.""" """Start the web server listener."""
trash_path = trash_storage_path() trash_path = trash_storage_path()
if os.path.exists(trash_path): if trash_path.is_dir() and trash_path.exists():
_LOGGER.info("Renaming 'trash' folder to 'archive'") _LOGGER.info("Renaming 'trash' folder to 'archive'")
archive_path = archive_storage_path() archive_path = archive_storage_path()
shutil.move(trash_path, archive_path) shutil.move(trash_path, archive_path)

View File

@@ -4,6 +4,7 @@ import gzip
import hashlib import hashlib
import io import io
import logging import logging
from pathlib import Path
import random import random
import socket import socket
import sys import sys
@@ -191,7 +192,7 @@ def send_check(sock, data, msg):
def perform_ota( def perform_ota(
sock: socket.socket, password: str, file_handle: io.IOBase, filename: str sock: socket.socket, password: str, file_handle: io.IOBase, filename: Path
) -> None: ) -> None:
file_contents = file_handle.read() file_contents = file_handle.read()
file_size = len(file_contents) file_size = len(file_contents)
@@ -309,7 +310,7 @@ def perform_ota(
def run_ota_impl_( def run_ota_impl_(
remote_host: str | list[str], remote_port: int, password: str, filename: str remote_host: str | list[str], remote_port: int, password: str, filename: Path
) -> tuple[int, str | None]: ) -> tuple[int, str | None]:
from esphome.core import CORE from esphome.core import CORE
@@ -360,7 +361,7 @@ def run_ota_impl_(
def run_ota( def run_ota(
remote_host: str | list[str], remote_port: int, password: str, filename: str remote_host: str | list[str], remote_port: int, password: str, filename: Path
) -> tuple[int, str | None]: ) -> tuple[int, str | None]:
try: try:
return run_ota_impl_(remote_host, remote_port, password, filename) return run_ota_impl_(remote_host, remote_port, password, filename)

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import logging import logging
import os
from pathlib import Path from pathlib import Path
import requests import requests
@@ -23,11 +22,11 @@ CONTENT_DISPOSITION = "content-disposition"
TEMP_DIR = "temp" TEMP_DIR = "temp"
def has_remote_file_changed(url, local_file_path): def has_remote_file_changed(url: str, local_file_path: Path) -> bool:
if os.path.exists(local_file_path): if local_file_path.exists():
_LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path)
try: try:
local_modification_time = os.path.getmtime(local_file_path) local_modification_time = local_file_path.stat().st_mtime
local_modification_time_str = datetime.utcfromtimestamp( local_modification_time_str = datetime.utcfromtimestamp(
local_modification_time local_modification_time
).strftime("%a, %d %b %Y %H:%M:%S GMT") ).strftime("%a, %d %b %Y %H:%M:%S GMT")
@@ -65,9 +64,9 @@ def has_remote_file_changed(url, local_file_path):
return True return True
def is_file_recent(file_path: str, refresh: TimePeriodSeconds) -> bool: def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool:
if os.path.exists(file_path): if file_path.exists():
creation_time = os.path.getctime(file_path) creation_time = file_path.stat().st_ctime
current_time = datetime.now().timestamp() current_time = datetime.now().timestamp()
return current_time - creation_time <= refresh.total_seconds return current_time - creation_time <= refresh.total_seconds
return False return False

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import codecs
from contextlib import suppress from contextlib import suppress
import ipaddress import ipaddress
import logging import logging
@@ -8,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import platform import platform
import re import re
import shutil
import tempfile import tempfile
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -140,16 +140,16 @@ def run_system_command(*args):
return rc, stdout, stderr return rc, stdout, stderr
def mkdir_p(path): def mkdir_p(path: Path):
if not path: if not path:
# Empty path - means create current dir # Empty path - means create current dir
return return
try: try:
os.makedirs(path) path.mkdir(parents=True, exist_ok=True)
except OSError as err: except OSError as err:
import errno import errno
if err.errno == errno.EEXIST and os.path.isdir(path): if err.errno == errno.EEXIST and path.is_dir():
pass pass
else: else:
from esphome.core import EsphomeError from esphome.core import EsphomeError
@@ -331,16 +331,15 @@ def is_ha_addon():
return get_bool_env("ESPHOME_IS_HA_ADDON") return get_bool_env("ESPHOME_IS_HA_ADDON")
def walk_files(path): def walk_files(path: Path):
for root, _, files in os.walk(path): for root, _, files in os.walk(path):
for name in files: for name in files:
yield os.path.join(root, name) yield Path(root) / name
def read_file(path): def read_file(path: Path) -> str:
try: try:
with codecs.open(path, "r", encoding="utf-8") as f_handle: return path.read_text(encoding="utf-8")
return f_handle.read()
except OSError as err: except OSError as err:
from esphome.core import EsphomeError from esphome.core import EsphomeError
@@ -351,13 +350,15 @@ def read_file(path):
raise EsphomeError(f"Error reading file {path}: {err}") from err raise EsphomeError(f"Error reading file {path}: {err}") from err
def _write_file(path: Path | str, text: str | bytes): def _write_file(
path: Path,
text: str | bytes,
private: bool = False,
) -> None:
"""Atomically writes `text` to the given path. """Atomically writes `text` to the given path.
Automatically creates all parent directories. Automatically creates all parent directories.
""" """
if not isinstance(path, Path):
path = Path(path)
data = text data = text
if isinstance(text, str): if isinstance(text, str):
data = text.encode() data = text.encode()
@@ -365,42 +366,54 @@ def _write_file(path: Path | str, text: str | bytes):
directory = path.parent directory = path.parent
directory.mkdir(exist_ok=True, parents=True) directory.mkdir(exist_ok=True, parents=True)
tmp_path = None tmp_filename: Path | None = None
missing_fchmod = False
try: try:
# Modern versions of Python tempfile create this file with mode 0o600
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="wb", dir=directory, delete=False mode="wb", dir=directory, delete=False
) as f_handle: ) as f_handle:
tmp_path = f_handle.name
f_handle.write(data) f_handle.write(data)
# Newer tempfile implementations create the file with mode 0o600 tmp_filename = Path(f_handle.name)
os.chmod(tmp_path, 0o644)
# If destination exists, will be overwritten if not private:
os.replace(tmp_path, path) try:
os.fchmod(f_handle.fileno(), 0o644)
except AttributeError:
# os.fchmod is not available on Windows
missing_fchmod = True
shutil.move(tmp_filename, path)
if missing_fchmod:
path.chmod(0o644)
finally: finally:
if tmp_path is not None and os.path.exists(tmp_path): if tmp_filename and tmp_filename.exists():
try: try:
os.remove(tmp_path) tmp_filename.unlink()
except OSError as err: except OSError as err:
_LOGGER.error("Write file cleanup failed: %s", err) # If we are cleaning up then something else went wrong, so
# we should suppress likely follow-on errors in the cleanup
_LOGGER.error(
"File replacement cleanup failed for %s while saving %s: %s",
tmp_filename,
path,
err,
)
def write_file(path: Path | str, text: str): def write_file(path: Path, text: str | bytes, private: bool = False) -> None:
try: try:
_write_file(path, text) _write_file(path, text, private=private)
except OSError as err: except OSError as err:
from esphome.core import EsphomeError from esphome.core import EsphomeError
raise EsphomeError(f"Could not write file at {path}") from err raise EsphomeError(f"Could not write file at {path}") from err
def write_file_if_changed(path: Path | str, text: str) -> bool: def write_file_if_changed(path: Path, text: str) -> bool:
"""Write text to the given path, but not if the contents match already. """Write text to the given path, but not if the contents match already.
Returns true if the file was changed. Returns true if the file was changed.
""" """
if not isinstance(path, Path):
path = Path(path)
src_content = None src_content = None
if path.is_file(): if path.is_file():
src_content = read_file(path) src_content = read_file(path)
@@ -410,12 +423,10 @@ def write_file_if_changed(path: Path | str, text: str) -> bool:
return True return True
def copy_file_if_changed(src: os.PathLike, dst: os.PathLike) -> None: def copy_file_if_changed(src: Path, dst: Path) -> None:
import shutil
if file_compare(src, dst): if file_compare(src, dst):
return return
mkdir_p(os.path.dirname(dst)) dst.parent.mkdir(parents=True, exist_ok=True)
try: try:
shutil.copyfile(src, dst) shutil.copyfile(src, dst)
except OSError as err: except OSError as err:
@@ -440,12 +451,12 @@ def list_starts_with(list_, sub):
return len(sub) <= len(list_) and all(list_[i] == x for i, x in enumerate(sub)) return len(sub) <= len(list_) and all(list_[i] == x for i, x in enumerate(sub))
def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool: def file_compare(path1: Path, path2: Path) -> bool:
"""Return True if the files path1 and path2 have the same contents.""" """Return True if the files path1 and path2 have the same contents."""
import stat import stat
try: try:
stat1, stat2 = os.stat(path1), os.stat(path2) stat1, stat2 = path1.stat(), path2.stat()
except OSError: except OSError:
# File doesn't exist or another error -> not equal # File doesn't exist or another error -> not equal
return False return False
@@ -462,7 +473,7 @@ def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool:
bufsize = 8 * 1024 bufsize = 8 * 1024
# Read files in blocks until a mismatch is found # Read files in blocks until a mismatch is found
with open(path1, "rb") as fh1, open(path2, "rb") as fh2: with path1.open("rb") as fh1, path2.open("rb") as fh2:
while True: while True:
blob1, blob2 = fh1.read(bufsize), fh2.read(bufsize) blob1, blob2 = fh1.read(bufsize), fh2.read(bufsize)
if blob1 != blob2: if blob1 != blob2:

View File

@@ -19,23 +19,25 @@ def patch_structhash():
# removed/added. This might have unintended consequences, but this improves compile # removed/added. This might have unintended consequences, but this improves compile
# times greatly when adding/removing components and a simple clean build solves # times greatly when adding/removing components and a simple clean build solves
# all issues # all issues
from os import makedirs
from os.path import getmtime, isdir, join
from platformio.run import cli, helpers from platformio.run import cli, helpers
def patched_clean_build_dir(build_dir, *args): def patched_clean_build_dir(build_dir, *args):
from platformio import fs from platformio import fs
from platformio.project.helpers import get_project_dir from platformio.project.helpers import get_project_dir
platformio_ini = join(get_project_dir(), "platformio.ini") platformio_ini = Path(get_project_dir()) / "platformio.ini"
build_dir = Path(build_dir)
# if project's config is modified # if project's config is modified
if isdir(build_dir) and getmtime(platformio_ini) > getmtime(build_dir): if (
build_dir.is_dir()
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
):
fs.rmtree(build_dir) fs.rmtree(build_dir)
if not isdir(build_dir): if not build_dir.is_dir():
makedirs(build_dir) build_dir.mkdir(parents=True)
helpers.clean_build_dir = patched_clean_build_dir helpers.clean_build_dir = patched_clean_build_dir
cli.clean_build_dir = patched_clean_build_dir cli.clean_build_dir = patched_clean_build_dir
@@ -78,9 +80,9 @@ FILTER_PLATFORMIO_LINES = [
def run_platformio_cli(*args, **kwargs) -> str | int: def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = os.path.abspath(CORE.relative_pioenvs_path()) os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
os.environ.setdefault( os.environ.setdefault(
"PLATFORMIO_LIBDEPS_DIR", os.path.abspath(CORE.relative_piolibdeps_path()) "PLATFORMIO_LIBDEPS_DIR", str(CORE.relative_piolibdeps_path().absolute())
) )
# Suppress Python syntax warnings from third-party scripts during compilation # Suppress Python syntax warnings from third-party scripts during compilation
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning") os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
@@ -99,7 +101,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:
command = ["run", "-d", CORE.build_path] command = ["run", "-d", str(CORE.build_path)]
if verbose: if verbose:
command += ["-v"] command += ["-v"]
command += list(args) command += list(args)
@@ -140,8 +142,8 @@ def _run_idedata(config):
def _load_idedata(config): def _load_idedata(config):
platformio_ini = Path(CORE.relative_build_path("platformio.ini")) platformio_ini = CORE.relative_build_path("platformio.ini")
temp_idedata = Path(CORE.relative_internal_path("idedata", f"{CORE.name}.json")) temp_idedata = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
changed = False changed = False
if ( if (
@@ -311,7 +313,7 @@ def process_stacktrace(config, line, backtrace_state):
@dataclass @dataclass
class FlashImage: class FlashImage:
path: str path: Path
offset: str offset: str
@@ -320,17 +322,17 @@ class IDEData:
self.raw = raw self.raw = raw
@property @property
def firmware_elf_path(self): def firmware_elf_path(self) -> Path:
return self.raw["prog_path"] return Path(self.raw["prog_path"])
@property @property
def firmware_bin_path(self) -> str: def firmware_bin_path(self) -> Path:
return str(Path(self.firmware_elf_path).with_suffix(".bin")) return self.firmware_elf_path.with_suffix(".bin")
@property @property
def extra_flash_images(self) -> list[FlashImage]: def extra_flash_images(self) -> list[FlashImage]:
return [ return [
FlashImage(path=entry["path"], offset=entry["offset"]) FlashImage(path=Path(entry["path"]), offset=entry["offset"])
for entry in self.raw["extra"]["flash_images"] for entry in self.raw["extra"]["flash_images"]
] ]

View File

@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import binascii import binascii
import codecs
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
import os import os
from pathlib import Path
from esphome import const from esphome import const
from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.const import CONF_DISABLED, CONF_MDNS
@@ -16,30 +16,35 @@ from esphome.types import CoreType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def storage_path() -> str: def storage_path() -> Path:
return os.path.join(CORE.data_dir, "storage", f"{CORE.config_filename}.json") return CORE.data_dir / "storage" / f"{CORE.config_filename}.json"
def ext_storage_path(config_filename: str) -> str: def ext_storage_path(config_filename: str) -> Path:
return os.path.join(CORE.data_dir, "storage", f"{config_filename}.json") return CORE.data_dir / "storage" / f"{config_filename}.json"
def esphome_storage_path() -> str: def esphome_storage_path() -> Path:
return os.path.join(CORE.data_dir, "esphome.json") return CORE.data_dir / "esphome.json"
def ignored_devices_storage_path() -> str: def ignored_devices_storage_path() -> Path:
return os.path.join(CORE.data_dir, "ignored-devices.json") return CORE.data_dir / "ignored-devices.json"
def trash_storage_path() -> str: def trash_storage_path() -> Path:
return CORE.relative_config_path("trash") return CORE.relative_config_path("trash")
def archive_storage_path() -> str: def archive_storage_path() -> Path:
return CORE.relative_config_path("archive") return CORE.relative_config_path("archive")
def _to_path_if_not_none(value: str | None) -> Path | None:
"""Convert a string to Path if it's not None."""
return Path(value) if value is not None else None
class StorageJSON: class StorageJSON:
def __init__( def __init__(
self, self,
@@ -52,8 +57,8 @@ class StorageJSON:
address: str, address: str,
web_port: int | None, web_port: int | None,
target_platform: str, target_platform: str,
build_path: str | None, build_path: Path | None,
firmware_bin_path: str | None, firmware_bin_path: Path | None,
loaded_integrations: set[str], loaded_integrations: set[str],
loaded_platforms: set[str], loaded_platforms: set[str],
no_mdns: bool, no_mdns: bool,
@@ -107,8 +112,8 @@ class StorageJSON:
"address": self.address, "address": self.address,
"web_port": self.web_port, "web_port": self.web_port,
"esp_platform": self.target_platform, "esp_platform": self.target_platform,
"build_path": self.build_path, "build_path": str(self.build_path),
"firmware_bin_path": self.firmware_bin_path, "firmware_bin_path": str(self.firmware_bin_path),
"loaded_integrations": sorted(self.loaded_integrations), "loaded_integrations": sorted(self.loaded_integrations),
"loaded_platforms": sorted(self.loaded_platforms), "loaded_platforms": sorted(self.loaded_platforms),
"no_mdns": self.no_mdns, "no_mdns": self.no_mdns,
@@ -176,8 +181,8 @@ class StorageJSON:
) )
@staticmethod @staticmethod
def _load_impl(path: str) -> StorageJSON | None: def _load_impl(path: Path) -> StorageJSON | None:
with codecs.open(path, "r", encoding="utf-8") as f_handle: with path.open("r", encoding="utf-8") as f_handle:
storage = json.load(f_handle) storage = json.load(f_handle)
storage_version = storage["storage_version"] storage_version = storage["storage_version"]
name = storage.get("name") name = storage.get("name")
@@ -190,8 +195,8 @@ class StorageJSON:
address = storage.get("address") address = storage.get("address")
web_port = storage.get("web_port") web_port = storage.get("web_port")
esp_platform = storage.get("esp_platform") esp_platform = storage.get("esp_platform")
build_path = storage.get("build_path") build_path = _to_path_if_not_none(storage.get("build_path"))
firmware_bin_path = storage.get("firmware_bin_path") firmware_bin_path = _to_path_if_not_none(storage.get("firmware_bin_path"))
loaded_integrations = set(storage.get("loaded_integrations", [])) loaded_integrations = set(storage.get("loaded_integrations", []))
loaded_platforms = set(storage.get("loaded_platforms", [])) loaded_platforms = set(storage.get("loaded_platforms", []))
no_mdns = storage.get("no_mdns", False) no_mdns = storage.get("no_mdns", False)
@@ -217,7 +222,7 @@ class StorageJSON:
) )
@staticmethod @staticmethod
def load(path: str) -> StorageJSON | None: def load(path: Path) -> StorageJSON | None:
try: try:
return StorageJSON._load_impl(path) return StorageJSON._load_impl(path)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
@@ -268,7 +273,7 @@ class EsphomeStorageJSON:
@staticmethod @staticmethod
def _load_impl(path: str) -> EsphomeStorageJSON | None: def _load_impl(path: str) -> EsphomeStorageJSON | None:
with codecs.open(path, "r", encoding="utf-8") as f_handle: with Path(path).open("r", encoding="utf-8") as f_handle:
storage = json.load(f_handle) storage = json.load(f_handle)
storage_version = storage["storage_version"] storage_version = storage["storage_version"]
cookie_secret = storage.get("cookie_secret") cookie_secret = storage.get("cookie_secret")

View File

@@ -1,7 +1,6 @@
import collections import collections
import io import io
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
import subprocess import subprocess
@@ -86,7 +85,10 @@ def safe_input(prompt=""):
return input() return input()
def shlex_quote(s): def shlex_quote(s: str | Path) -> str:
# Convert Path objects to strings
if isinstance(s, Path):
s = str(s)
if not s: if not s:
return "''" return "''"
if re.search(r"[^\w@%+=:,./-]", s) is None: if re.search(r"[^\w@%+=:,./-]", s) is None:
@@ -272,25 +274,28 @@ class OrderedDict(collections.OrderedDict):
return dict(self).__repr__() return dict(self).__repr__()
def list_yaml_files(configs: list[str]) -> list[str]: def list_yaml_files(configs: list[str | Path]) -> list[Path]:
files: list[str] = [] files: list[Path] = []
for config in configs: for config in configs:
if os.path.isfile(config): config = Path(config)
if not config.exists():
raise FileNotFoundError(f"Config path '{config}' does not exist!")
if config.is_file():
files.append(config) files.append(config)
else: else:
files.extend(os.path.join(config, p) for p in os.listdir(config)) files.extend(config.glob("*"))
files = filter_yaml_files(files) files = filter_yaml_files(files)
return sorted(files) return sorted(files)
def filter_yaml_files(files: list[str]) -> list[str]: def filter_yaml_files(files: list[Path]) -> list[Path]:
return [ return [
f f
for f in files for f in files
if ( if (
os.path.splitext(f)[1] in (".yaml", ".yml") f.suffix in (".yaml", ".yml")
and os.path.basename(f) not in ("secrets.yaml", "secrets.yml") and f.name not in ("secrets.yaml", "secrets.yml")
and not os.path.basename(f).startswith(".") and not f.name.startswith(".")
) )
] ]

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from io import StringIO from io import StringIO
import json import json
import os from pathlib import Path
from typing import Any from typing import Any
from esphome.config import Config, _format_vol_invalid, validate_config from esphome.config import Config, _format_vol_invalid, validate_config
@@ -67,24 +67,24 @@ def _read_file_content_from_json_on_stdin() -> str:
return data["content"] return data["content"]
def _print_file_read_event(path: str) -> None: def _print_file_read_event(path: Path) -> None:
"""Print a file read event.""" """Print a file read event."""
print( print(
json.dumps( json.dumps(
{ {
"type": "read_file", "type": "read_file",
"path": path, "path": str(path),
} }
) )
) )
def _request_and_get_stream_on_stdin(fname: str) -> StringIO: def _request_and_get_stream_on_stdin(fname: Path) -> StringIO:
_print_file_read_event(fname) _print_file_read_event(fname)
return StringIO(_read_file_content_from_json_on_stdin()) return StringIO(_read_file_content_from_json_on_stdin())
def _vscode_loader(fname: str) -> dict[str, Any]: def _vscode_loader(fname: Path) -> dict[str, Any]:
raw_yaml_stream = _request_and_get_stream_on_stdin(fname) raw_yaml_stream = _request_and_get_stream_on_stdin(fname)
# it is required to set the name on StringIO so document on start_mark # it is required to set the name on StringIO so document on start_mark
# is set properly. Otherwise it is initialized with "<file>" # is set properly. Otherwise it is initialized with "<file>"
@@ -92,7 +92,7 @@ def _vscode_loader(fname: str) -> dict[str, Any]:
return parse_yaml(fname, raw_yaml_stream, _vscode_loader) return parse_yaml(fname, raw_yaml_stream, _vscode_loader)
def _ace_loader(fname: str) -> dict[str, Any]: def _ace_loader(fname: Path) -> dict[str, Any]:
raw_yaml_stream = _request_and_get_stream_on_stdin(fname) raw_yaml_stream = _request_and_get_stream_on_stdin(fname)
return parse_yaml(fname, raw_yaml_stream) return parse_yaml(fname, raw_yaml_stream)
@@ -120,10 +120,10 @@ def read_config(args):
return return
CORE.vscode = True CORE.vscode = True
if args.ace: # Running from ESPHome Compiler dashboard, not vscode if args.ace: # Running from ESPHome Compiler dashboard, not vscode
CORE.config_path = os.path.join(args.configuration, data["file"]) CORE.config_path = Path(args.configuration) / data["file"]
loader = _ace_loader loader = _ace_loader
else: else:
CORE.config_path = data["file"] CORE.config_path = Path(data["file"])
loader = _vscode_loader loader = _vscode_loader
file_name = CORE.config_path file_name = CORE.config_path

View File

@@ -1,4 +1,4 @@
import os from pathlib import Path
import random import random
import string import string
from typing import Literal, NotRequired, TypedDict, Unpack from typing import Literal, NotRequired, TypedDict, Unpack
@@ -213,7 +213,7 @@ class WizardWriteKwargs(TypedDict):
file_text: NotRequired[str] file_text: NotRequired[str]
def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool: def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool:
from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.bk72xx import boards as bk72xx_boards
from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp32 import boards as esp32_boards
from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.esp8266 import boards as esp8266_boards
@@ -256,13 +256,13 @@ def wizard_write(path: str, **kwargs: Unpack[WizardWriteKwargs]) -> bool:
file_text = wizard_file(**kwargs) file_text = wizard_file(**kwargs)
# Check if file already exists to prevent overwriting # Check if file already exists to prevent overwriting
if os.path.exists(path) and os.path.isfile(path): if path.exists() and path.is_file():
safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.')) safe_print(color(AnsiFore.RED, f'The file "{path}" already exists.'))
return False return False
write_file(path, file_text) write_file(path, file_text)
storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware) storage = StorageJSON.from_wizard(name, name, f"{name}.local", hardware)
storage_path = ext_storage_path(os.path.basename(path)) storage_path = ext_storage_path(path.name)
storage.save(storage_path) storage.save(storage_path)
return True return True
@@ -301,7 +301,7 @@ def strip_accents(value: str) -> str:
) )
def wizard(path: str) -> int: def wizard(path: Path) -> int:
from esphome.components.bk72xx import boards as bk72xx_boards from esphome.components.bk72xx import boards as bk72xx_boards
from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp32 import boards as esp32_boards
from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.esp8266 import boards as esp8266_boards
@@ -309,14 +309,14 @@ def wizard(path: str) -> int:
from esphome.components.rp2040 import boards as rp2040_boards from esphome.components.rp2040 import boards as rp2040_boards
from esphome.components.rtl87xx import boards as rtl87xx_boards from esphome.components.rtl87xx import boards as rtl87xx_boards
if not path.endswith(".yaml") and not path.endswith(".yml"): if path.suffix not in (".yaml", ".yml"):
safe_print( safe_print(
f"Please make your configuration file {color(AnsiFore.CYAN, path)} have the extension .yaml or .yml" f"Please make your configuration file {color(AnsiFore.CYAN, str(path))} have the extension .yaml or .yml"
) )
return 1 return 1
if os.path.exists(path): if path.exists():
safe_print( safe_print(
f"Uh oh, it seems like {color(AnsiFore.CYAN, path)} already exists, please delete that file first or chose another configuration file." f"Uh oh, it seems like {color(AnsiFore.CYAN, str(path))} already exists, please delete that file first or chose another configuration file."
) )
return 2 return 2
@@ -549,7 +549,7 @@ def wizard(path: str) -> int:
safe_print() safe_print()
safe_print( safe_print(
color(AnsiFore.CYAN, "DONE! I've now written a new configuration file to ") color(AnsiFore.CYAN, "DONE! I've now written a new configuration file to ")
+ color(AnsiFore.BOLD_CYAN, path) + color(AnsiFore.BOLD_CYAN, str(path))
) )
safe_print() safe_print()
safe_print("Next steps:") safe_print("Next steps:")

View File

@@ -1,6 +1,5 @@
import importlib import importlib
import logging import logging
import os
from pathlib import Path from pathlib import Path
import re import re
@@ -266,7 +265,7 @@ def generate_version_h():
def write_cpp(code_s): def write_cpp(code_s):
path = CORE.relative_src_path("main.cpp") path = CORE.relative_src_path("main.cpp")
if os.path.isfile(path): if path.is_file():
text = read_file(path) text = read_file(path)
code_format = find_begin_end( code_format = find_begin_end(
text, CPP_AUTO_GENERATE_BEGIN, CPP_AUTO_GENERATE_END text, CPP_AUTO_GENERATE_BEGIN, CPP_AUTO_GENERATE_END
@@ -292,28 +291,28 @@ def write_cpp(code_s):
def clean_cmake_cache(): def clean_cmake_cache():
pioenvs = CORE.relative_pioenvs_path() pioenvs = CORE.relative_pioenvs_path()
if os.path.isdir(pioenvs): if pioenvs.is_dir():
pioenvs_cmake_path = CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt") pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt"
if os.path.isfile(pioenvs_cmake_path): if pioenvs_cmake_path.is_file():
_LOGGER.info("Deleting %s", pioenvs_cmake_path) _LOGGER.info("Deleting %s", pioenvs_cmake_path)
os.remove(pioenvs_cmake_path) pioenvs_cmake_path.unlink()
def clean_build(): def clean_build():
import shutil import shutil
pioenvs = CORE.relative_pioenvs_path() pioenvs = CORE.relative_pioenvs_path()
if os.path.isdir(pioenvs): if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs) _LOGGER.info("Deleting %s", pioenvs)
shutil.rmtree(pioenvs) shutil.rmtree(pioenvs)
piolibdeps = CORE.relative_piolibdeps_path() piolibdeps = CORE.relative_piolibdeps_path()
if os.path.isdir(piolibdeps): if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps) _LOGGER.info("Deleting %s", piolibdeps)
shutil.rmtree(piolibdeps) shutil.rmtree(piolibdeps)
dependencies_lock = CORE.relative_build_path("dependencies.lock") dependencies_lock = CORE.relative_build_path("dependencies.lock")
if os.path.isfile(dependencies_lock): if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock) _LOGGER.info("Deleting %s", dependencies_lock)
os.remove(dependencies_lock) dependencies_lock.unlink()
# Clean PlatformIO cache to resolve CMake compiler detection issues # Clean PlatformIO cache to resolve CMake compiler detection issues
# This helps when toolchain paths change or get corrupted # This helps when toolchain paths change or get corrupted
@@ -324,9 +323,11 @@ def clean_build():
pass pass
else: else:
cache_dir = get_project_cache_dir() cache_dir = get_project_cache_dir()
if cache_dir and cache_dir.strip() and os.path.isdir(cache_dir): if cache_dir and cache_dir.strip():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir) cache_path = Path(cache_dir)
shutil.rmtree(cache_dir) if cache_path.is_dir():
_LOGGER.info("Deleting PlatformIO cache %s", cache_dir)
shutil.rmtree(cache_dir)
GITIGNORE_CONTENT = """# Gitignore settings for ESPHome GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
@@ -339,6 +340,5 @@ GITIGNORE_CONTENT = """# Gitignore settings for ESPHome
def write_gitignore(): def write_gitignore():
path = CORE.relative_config_path(".gitignore") path = CORE.relative_config_path(".gitignore")
if not os.path.isfile(path): if not path.is_file():
with open(file=path, mode="w", encoding="utf-8") as f: path.write_text(GITIGNORE_CONTENT, encoding="utf-8")
f.write(GITIGNORE_CONTENT)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import fnmatch
import functools import functools
import inspect import inspect
from io import BytesIO, TextIOBase, TextIOWrapper from io import BytesIO, TextIOBase, TextIOWrapper
@@ -9,6 +8,7 @@ from ipaddress import _BaseAddress, _BaseNetwork
import logging import logging
import math import math
import os import os
from pathlib import Path
from typing import Any from typing import Any
import uuid import uuid
@@ -109,7 +109,9 @@ def _add_data_ref(fn):
class ESPHomeLoaderMixin: class ESPHomeLoaderMixin:
"""Loader class that keeps track of line numbers.""" """Loader class that keeps track of line numbers."""
def __init__(self, name: str, yaml_loader: Callable[[str], dict[str, Any]]) -> None: def __init__(
self, name: Path, yaml_loader: Callable[[Path], dict[str, Any]]
) -> None:
"""Initialize the loader.""" """Initialize the loader."""
self.name = name self.name = name
self.yaml_loader = yaml_loader self.yaml_loader = yaml_loader
@@ -254,12 +256,8 @@ class ESPHomeLoaderMixin:
f"Environment variable '{node.value}' not defined", node.start_mark f"Environment variable '{node.value}' not defined", node.start_mark
) )
@property def _rel_path(self, *args: str) -> Path:
def _directory(self) -> str: return self.name.parent / Path(*args)
return os.path.dirname(self.name)
def _rel_path(self, *args: str) -> str:
return os.path.join(self._directory, *args)
@_add_data_ref @_add_data_ref
def construct_secret(self, node: yaml.Node) -> str: def construct_secret(self, node: yaml.Node) -> str:
@@ -269,8 +267,8 @@ class ESPHomeLoaderMixin:
if self.name == CORE.config_path: if self.name == CORE.config_path:
raise e raise e
try: try:
main_config_dir = os.path.dirname(CORE.config_path) main_config_dir = CORE.config_path.parent
main_secret_yml = os.path.join(main_config_dir, SECRET_YAML) main_secret_yml = main_config_dir / SECRET_YAML
secrets = self.yaml_loader(main_secret_yml) secrets = self.yaml_loader(main_secret_yml)
except EsphomeError as er: except EsphomeError as er:
raise EsphomeError(f"{e}\n{er}") from er raise EsphomeError(f"{e}\n{er}") from er
@@ -329,7 +327,7 @@ class ESPHomeLoaderMixin:
files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml")) files = filter_yaml_files(_find_files(self._rel_path(node.value), "*.yaml"))
mapping = OrderedDict() mapping = OrderedDict()
for fname in files: for fname in files:
filename = os.path.splitext(os.path.basename(fname))[0] filename = fname.stem
mapping[filename] = self.yaml_loader(fname) mapping[filename] = self.yaml_loader(fname)
return mapping return mapping
@@ -369,8 +367,8 @@ class ESPHomeLoader(ESPHomeLoaderMixin, FastestAvailableSafeLoader):
def __init__( def __init__(
self, self,
stream: TextIOBase | BytesIO, stream: TextIOBase | BytesIO,
name: str, name: Path,
yaml_loader: Callable[[str], dict[str, Any]], yaml_loader: Callable[[Path], dict[str, Any]],
) -> None: ) -> None:
FastestAvailableSafeLoader.__init__(self, stream) FastestAvailableSafeLoader.__init__(self, stream)
ESPHomeLoaderMixin.__init__(self, name, yaml_loader) ESPHomeLoaderMixin.__init__(self, name, yaml_loader)
@@ -382,8 +380,8 @@ class ESPHomePurePythonLoader(ESPHomeLoaderMixin, PurePythonLoader):
def __init__( def __init__(
self, self,
stream: TextIOBase | BytesIO, stream: TextIOBase | BytesIO,
name: str, name: Path,
yaml_loader: Callable[[str], dict[str, Any]], yaml_loader: Callable[[Path], dict[str, Any]],
) -> None: ) -> None:
PurePythonLoader.__init__(self, stream) PurePythonLoader.__init__(self, stream)
ESPHomeLoaderMixin.__init__(self, name, yaml_loader) ESPHomeLoaderMixin.__init__(self, name, yaml_loader)
@@ -414,24 +412,24 @@ for _loader in (ESPHomeLoader, ESPHomePurePythonLoader):
_loader.add_constructor("!remove", _loader.construct_remove) _loader.add_constructor("!remove", _loader.construct_remove)
def load_yaml(fname: str, clear_secrets: bool = True) -> Any: def load_yaml(fname: Path, clear_secrets: bool = True) -> Any:
if clear_secrets: if clear_secrets:
_SECRET_VALUES.clear() _SECRET_VALUES.clear()
_SECRET_CACHE.clear() _SECRET_CACHE.clear()
return _load_yaml_internal(fname) return _load_yaml_internal(fname)
def _load_yaml_internal(fname: str) -> Any: def _load_yaml_internal(fname: Path) -> Any:
"""Load a YAML file.""" """Load a YAML file."""
try: try:
with open(fname, encoding="utf-8") as f_handle: with fname.open(encoding="utf-8") as f_handle:
return parse_yaml(fname, f_handle) return parse_yaml(fname, f_handle)
except (UnicodeDecodeError, OSError) as err: except (UnicodeDecodeError, OSError) as err:
raise EsphomeError(f"Error reading file {fname}: {err}") from err raise EsphomeError(f"Error reading file {fname}: {err}") from err
def parse_yaml( def parse_yaml(
file_name: str, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal file_name: Path, file_handle: TextIOWrapper, yaml_loader=_load_yaml_internal
) -> Any: ) -> Any:
"""Parse a YAML file.""" """Parse a YAML file."""
try: try:
@@ -483,9 +481,9 @@ def substitute_vars(config, vars):
def _load_yaml_internal_with_type( def _load_yaml_internal_with_type(
loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader], loader_type: type[ESPHomeLoader] | type[ESPHomePurePythonLoader],
fname: str, fname: Path,
content: TextIOWrapper, content: TextIOWrapper,
yaml_loader: Any, yaml_loader: Callable[[Path], dict[str, Any]],
) -> Any: ) -> Any:
"""Load a YAML file.""" """Load a YAML file."""
loader = loader_type(content, fname, yaml_loader) loader = loader_type(content, fname, yaml_loader)
@@ -512,13 +510,14 @@ def _is_file_valid(name: str) -> bool:
return not name.startswith(".") return not name.startswith(".")
def _find_files(directory, pattern): def _find_files(directory: Path, pattern):
"""Recursively load files in a directory.""" """Recursively load files in a directory."""
for root, dirs, files in os.walk(directory, topdown=True): for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if _is_file_valid(d)] dirs[:] = [d for d in dirs if _is_file_valid(d)]
for basename in files: for f in files:
if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern): filename = Path(f)
filename = os.path.join(root, basename) if _is_file_valid(f) and filename.match(pattern):
filename = Path(root) / filename
yield filename yield filename
@@ -627,3 +626,4 @@ ESPHomeDumper.add_multi_representer(TimePeriod, ESPHomeDumper.represent_stringif
ESPHomeDumper.add_multi_representer(Lambda, ESPHomeDumper.represent_lambda) ESPHomeDumper.add_multi_representer(Lambda, ESPHomeDumper.represent_lambda)
ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id)
ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify)
ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify)

View File

@@ -9,10 +9,10 @@ tzlocal==5.3.1 # from time
tzdata>=2021.1 # from time tzdata>=2021.1 # from time
pyserial==3.5 pyserial==3.5
platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
esptool==5.0.2 esptool==5.1.0
click==8.1.7 click==8.1.7
esphome-dashboard==20250904.0 esphome-dashboard==20250904.0
aioesphomeapi==41.1.0 aioesphomeapi==41.4.0
zeroconf==0.147.2 zeroconf==0.147.2
puremagic==1.30 puremagic==1.30
ruamel.yaml==0.18.15 # dashboard_import ruamel.yaml==0.18.15 # dashboard_import

View File

@@ -1,6 +1,6 @@
pylint==3.3.8 pylint==3.3.8
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.13.0 # also change in .pre-commit-config.yaml when updating ruff==0.13.1 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating
pre-commit pre-commit

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import IntEnum from enum import IntEnum
import os
from pathlib import Path from pathlib import Path
import re import re
from subprocess import call from subprocess import call
@@ -1750,13 +1749,16 @@ def build_message_type(
# Add estimated size constant # Add estimated size constant
estimated_size = calculate_message_estimated_size(desc) estimated_size = calculate_message_estimated_size(desc)
# Validate that estimated_size fits in uint8_t # Use a type appropriate for estimated_size
if estimated_size > 255: estimated_size_type = (
raise ValueError( "uint8_t"
f"Estimated size {estimated_size} for {desc.name} exceeds uint8_t maximum (255)" if estimated_size <= 255
) else "uint16_t"
if estimated_size <= 65535
else "size_t"
)
public_content.append( public_content.append(
f"static constexpr uint8_t ESTIMATED_SIZE = {estimated_size};" f"static constexpr {estimated_size_type} ESTIMATED_SIZE = {estimated_size};"
) )
# Add message_name method inline in header # Add message_name method inline in header
@@ -2701,8 +2703,8 @@ static const char *const TAG = "api.service";
import clang_format import clang_format
def exec_clang_format(path: Path) -> None: def exec_clang_format(path: Path) -> None:
clang_format_path = os.path.join( clang_format_path = (
os.path.dirname(clang_format.__file__), "data", "bin", "clang-format" Path(clang_format.__file__).parent / "data" / "bin" / "clang-format"
) )
call([clang_format_path, "-i", path]) call([clang_format_path, "-i", path])

View File

@@ -39,7 +39,7 @@ esphome/core/* @esphome/core
parts = [BASE] parts = [BASE]
# Fake some directory so that get_component works # Fake some directory so that get_component works
CORE.config_path = str(root) CORE.config_path = root
CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None} CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}
codeowners = defaultdict(list) codeowners = defaultdict(list)

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import glob
import inspect import inspect
import json import json
import os import os
from pathlib import Path
import re import re
import voluptuous as vol import voluptuous as vol
@@ -70,14 +70,14 @@ def get_component_names():
component_names = ["esphome", "sensor", "esp32", "esp8266"] component_names = ["esphome", "sensor", "esp32", "esp8266"]
skip_components = [] skip_components = []
for d in os.listdir(CORE_COMPONENTS_PATH): for d in CORE_COMPONENTS_PATH.iterdir():
if ( if (
not d.startswith("__") not d.name.startswith("__")
and os.path.isdir(os.path.join(CORE_COMPONENTS_PATH, d)) and d.is_dir()
and d not in component_names and d.name not in component_names
and d not in skip_components and d.name not in skip_components
): ):
component_names.append(d) component_names.append(d.name)
return sorted(component_names) return sorted(component_names)
@@ -121,7 +121,7 @@ from esphome.util import Registry # noqa: E402
def write_file(name, obj): def write_file(name, obj):
full_path = os.path.join(args.output_path, name + ".json") full_path = Path(args.output_path) / f"{name}.json"
if JSON_DUMP_PRETTY: if JSON_DUMP_PRETTY:
json_str = json.dumps(obj, indent=2) json_str = json.dumps(obj, indent=2)
else: else:
@@ -131,9 +131,10 @@ def write_file(name, obj):
def delete_extra_files(keep_names): def delete_extra_files(keep_names):
for d in os.listdir(args.output_path): output_path = Path(args.output_path)
if d.endswith(".json") and d[:-5] not in keep_names: for d in output_path.iterdir():
os.remove(os.path.join(args.output_path, d)) if d.suffix == ".json" and d.stem not in keep_names:
d.unlink()
print(f"Deleted {d}") print(f"Deleted {d}")
@@ -367,13 +368,11 @@ def get_logger_tags():
"scheduler", "scheduler",
"api.service", "api.service",
] ]
for x in os.walk(CORE_COMPONENTS_PATH): for file in CORE_COMPONENTS_PATH.rglob("*.cpp"):
for y in glob.glob(os.path.join(x[0], "*.cpp")): data = file.read_text()
with open(y, encoding="utf-8") as file: match = pattern.search(data)
data = file.read() if match:
match = pattern.search(data) tags.append(match.group(1))
if match:
tags.append(match.group(1))
return tags return tags

View File

@@ -6,6 +6,7 @@ import collections
import fnmatch import fnmatch
import functools import functools
import os.path import os.path
from pathlib import Path
import re import re
import sys import sys
import time import time
@@ -75,12 +76,12 @@ ignore_types = (
LINT_FILE_CHECKS = [] LINT_FILE_CHECKS = []
LINT_CONTENT_CHECKS = [] LINT_CONTENT_CHECKS = []
LINT_POST_CHECKS = [] LINT_POST_CHECKS = []
EXECUTABLE_BIT = {} EXECUTABLE_BIT: dict[str, int] = {}
errors = collections.defaultdict(list) errors: collections.defaultdict[Path, list] = collections.defaultdict(list)
def add_errors(fname, errs): def add_errors(fname: Path, errs: list[tuple[int, int, str] | None]) -> None:
if not isinstance(errs, list): if not isinstance(errs, list):
errs = [errs] errs = [errs]
for err in errs: for err in errs:
@@ -246,8 +247,8 @@ def lint_ext_check(fname):
".github/copilot-instructions.md", ".github/copilot-instructions.md",
] ]
) )
def lint_executable_bit(fname): def lint_executable_bit(fname: Path) -> str | None:
ex = EXECUTABLE_BIT[fname] ex = EXECUTABLE_BIT[str(fname)]
if ex != 100644: if ex != 100644:
return ( return (
f"File has invalid executable bit {ex}. If running from a windows machine please " f"File has invalid executable bit {ex}. If running from a windows machine please "
@@ -506,8 +507,8 @@ def lint_constants_usage():
return errs return errs
def relative_cpp_search_text(fname, content): def relative_cpp_search_text(fname: Path, content) -> str:
parts = fname.split("/") parts = fname.parts
integration = parts[2] integration = parts[2]
return f'#include "esphome/components/{integration}' return f'#include "esphome/components/{integration}'
@@ -524,8 +525,8 @@ def lint_relative_cpp_import(fname, line, col, content):
) )
def relative_py_search_text(fname, content): def relative_py_search_text(fname: Path, content: str) -> str:
parts = fname.split("/") parts = fname.parts
integration = parts[2] integration = parts[2]
return f"esphome.components.{integration}" return f"esphome.components.{integration}"
@@ -591,10 +592,8 @@ def lint_relative_py_import(fname, line, col, content):
"esphome/components/http_request/httplib.h", "esphome/components/http_request/httplib.h",
], ],
) )
def lint_namespace(fname, content): def lint_namespace(fname: Path, content: str) -> str | None:
expected_name = re.match( expected_name = fname.parts[2]
r"^esphome/components/([^/]+)/.*", fname.replace(os.path.sep, "/")
).group(1)
# Check for both old style and C++17 nested namespace syntax # Check for both old style and C++17 nested namespace syntax
search_old = f"namespace {expected_name}" search_old = f"namespace {expected_name}"
search_new = f"namespace esphome::{expected_name}" search_new = f"namespace esphome::{expected_name}"
@@ -733,9 +732,9 @@ def main():
files.sort() files.sort()
for fname in files: for fname in files:
_, ext = os.path.splitext(fname) fname = Path(fname)
run_checks(LINT_FILE_CHECKS, fname, fname) run_checks(LINT_FILE_CHECKS, fname, fname)
if ext in ignore_types: if fname.suffix in ignore_types:
continue continue
try: try:
with codecs.open(fname, "r", encoding="utf-8") as f_handle: with codecs.open(fname, "r", encoding="utf-8") as f_handle:

View File

@@ -52,10 +52,10 @@ def styled(color: str | tuple[str, ...], msg: str, reset: bool = True) -> str:
return prefix + msg + suffix return prefix + msg + suffix
def print_error_for_file(file: str, body: str | None) -> None: def print_error_for_file(file: str | Path, body: str | None) -> None:
print( print(
styled(colorama.Fore.GREEN, "### File ") styled(colorama.Fore.GREEN, "### File ")
+ styled((colorama.Fore.GREEN, colorama.Style.BRIGHT), file) + styled((colorama.Fore.GREEN, colorama.Style.BRIGHT), str(file))
) )
print() print()
if body is not None: if body is not None:
@@ -513,7 +513,7 @@ def get_all_dependencies(component_names: set[str]) -> set[str]:
# Set up fake config path for component loading # Set up fake config path for component loading
root = Path(__file__).parent.parent root = Path(__file__).parent.parent
CORE.config_path = str(root) CORE.config_path = root
CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE] = {}
# Keep finding dependencies until no new ones are found # Keep finding dependencies until no new ones are found
@@ -553,7 +553,7 @@ def get_components_from_integration_fixtures() -> set[str]:
fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures"
for yaml_file in fixtures_dir.glob("*.yaml"): for yaml_file in fixtures_dir.glob("*.yaml"):
config: dict[str, any] | None = yaml_util.load_yaml(str(yaml_file)) config: dict[str, any] | None = yaml_util.load_yaml(yaml_file)
if not config: if not config:
continue continue

View File

@@ -50,7 +50,7 @@ def create_components_graph():
root = Path(__file__).parent.parent root = Path(__file__).parent.parent
components_dir = root / "esphome" / "components" components_dir = root / "esphome" / "components"
# Fake some directory so that get_component works # Fake some directory so that get_component works
CORE.config_path = str(root) CORE.config_path = root
# Various configuration to capture different outcomes used by `AUTO_LOAD` function. # Various configuration to capture different outcomes used by `AUTO_LOAD` function.
TARGET_CONFIGURATIONS = [ TARGET_CONFIGURATIONS = [
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}, {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None},

View File

@@ -42,9 +42,9 @@ def config_path(request: pytest.FixtureRequest) -> Generator[None]:
if config_dir.exists(): if config_dir.exists():
# Set config_path to a dummy yaml file in the config directory # Set config_path to a dummy yaml file in the config directory
# This ensures CORE.config_dir points to the config directory # This ensures CORE.config_dir points to the config directory
CORE.config_path = str(config_dir / "dummy.yaml") CORE.config_path = config_dir / "dummy.yaml"
else: else:
CORE.config_path = str(Path(request.fspath).parent / "dummy.yaml") CORE.config_path = Path(request.fspath).parent / "dummy.yaml"
yield yield
CORE.config_path = original_path CORE.config_path = original_path
@@ -131,7 +131,7 @@ def generate_main() -> Generator[Callable[[str | Path], str]]:
"""Generates the C++ main.cpp from a given yaml file and returns it in string form.""" """Generates the C++ main.cpp from a given yaml file and returns it in string form."""
def generator(path: str | Path) -> str: def generator(path: str | Path) -> str:
CORE.config_path = str(path) CORE.config_path = Path(path)
CORE.config = read_config({}) CORE.config = read_config({})
generate_cpp_contents(CORE.config) generate_cpp_contents(CORE.config)
return CORE.cpp_main_section return CORE.cpp_main_section

View File

@@ -7,7 +7,7 @@ display:
- platform: ssd1306_i2c - platform: ssd1306_i2c
id: ssd1306_display id: ssd1306_display
model: SSD1306_128X64 model: SSD1306_128X64
reset_pin: ${reset_pin} reset_pin: ${display_reset_pin}
pages: pages:
- id: page1 - id: page1
lambda: |- lambda: |-
@@ -16,7 +16,7 @@ display:
touchscreen: touchscreen:
- platform: ektf2232 - platform: ektf2232
interrupt_pin: ${interrupt_pin} interrupt_pin: ${interrupt_pin}
rts_pin: ${rts_pin} reset_pin: ${touch_reset_pin}
display: ssd1306_display display: ssd1306_display
on_touch: on_touch:
- logger.log: - logger.log:

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO16 scl_pin: GPIO16
sda_pin: GPIO17 sda_pin: GPIO17
reset_pin: GPIO13 display_reset_pin: GPIO13
interrupt_pin: GPIO14 interrupt_pin: GPIO14
rts_pin: GPIO15 touch_reset_pin: GPIO15
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO5 scl_pin: GPIO5
sda_pin: GPIO4 sda_pin: GPIO4
reset_pin: GPIO3 display_reset_pin: GPIO3
interrupt_pin: GPIO6 interrupt_pin: GPIO6
rts_pin: GPIO7 touch_reset_pin: GPIO7
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO5 scl_pin: GPIO5
sda_pin: GPIO4 sda_pin: GPIO4
reset_pin: GPIO3 display_reset_pin: GPIO3
interrupt_pin: GPIO6 interrupt_pin: GPIO6
rts_pin: GPIO7 touch_reset_pin: GPIO7
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO16 scl_pin: GPIO16
sda_pin: GPIO17 sda_pin: GPIO17
reset_pin: GPIO13 display_reset_pin: GPIO13
interrupt_pin: GPIO14 interrupt_pin: GPIO14
rts_pin: GPIO15 touch_reset_pin: GPIO15
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO5 scl_pin: GPIO5
sda_pin: GPIO4 sda_pin: GPIO4
reset_pin: GPIO3 display_reset_pin: GPIO3
interrupt_pin: GPIO12 interrupt_pin: GPIO12
rts_pin: GPIO13 touch_reset_pin: GPIO13
<<: !include common.yaml <<: !include common.yaml

View File

@@ -1,8 +1,8 @@
substitutions: substitutions:
scl_pin: GPIO5 scl_pin: GPIO5
sda_pin: GPIO4 sda_pin: GPIO4
reset_pin: GPIO3 display_reset_pin: GPIO3
interrupt_pin: GPIO6 interrupt_pin: GPIO6
rts_pin: GPIO7 touch_reset_pin: GPIO7
<<: !include common.yaml <<: !include common.yaml

View File

@@ -0,0 +1,42 @@
# Comprehensive ESP8266 test for mdns with multiple network components
# Tests the complete priority chain:
# wifi (60) -> mdns (55) -> ota (54) -> web_server_ota (52)
esphome:
name: mdns-comprehensive-test
esp8266:
board: esp01_1m
logger:
level: DEBUG
wifi:
ssid: MySSID
password: password1
# web_server_base should run at priority 65 (before wifi)
web_server:
port: 80
# mdns should run at priority 55 (after wifi at 60)
mdns:
services:
- service: _http
protocol: _tcp
port: 80
# OTA should run at priority 54 (after mdns)
ota:
- platform: esphome
password: "otapassword"
# Test status LED at priority 80
status_led:
pin:
number: GPIO2
inverted: true
# Include API at priority 40
api:
password: "apipassword"

View File

@@ -0,0 +1,15 @@
wifi:
ssid: MySSID
password: password1
power_save_mode: none
uart:
- id: uart_zwave_proxy
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 115200
api:
zwave_proxy:
id: zw_proxy

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
<<: !include common.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
<<: !include common.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
<<: !include common.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
<<: !include common.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
<<: !include common.yaml

View File

@@ -0,0 +1,5 @@
substitutions:
tx_pin: GPIO4
rx_pin: GPIO5
<<: !include common.yaml

View File

@@ -22,7 +22,7 @@ def create_cache_key() -> tuple[int, int, float, int]:
def setup_core(): def setup_core():
"""Set up CORE for testing.""" """Set up CORE for testing."""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
CORE.config_path = str(Path(tmpdir) / "test.yaml") CORE.config_path = Path(tmpdir) / "test.yaml"
yield yield
CORE.reset() CORE.reset()
@@ -44,7 +44,7 @@ async def dashboard_entries(mock_settings: MagicMock) -> DashboardEntries:
def test_dashboard_entry_path_initialization() -> None: def test_dashboard_entry_path_initialization() -> None:
"""Test DashboardEntry initializes with path correctly.""" """Test DashboardEntry initializes with path correctly."""
test_path = "/test/config/device.yaml" test_path = Path("/test/config/device.yaml")
cache_key = create_cache_key() cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key) entry = DashboardEntry(test_path, cache_key)
@@ -59,21 +59,21 @@ def test_dashboard_entry_path_with_absolute_path() -> None:
test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml"
cache_key = create_cache_key() cache_key = create_cache_key()
entry = DashboardEntry(str(test_path), cache_key) entry = DashboardEntry(test_path, cache_key)
assert entry.path == str(test_path) assert entry.path == test_path
assert Path(entry.path).is_absolute() assert entry.path.is_absolute()
def test_dashboard_entry_path_with_relative_path() -> None: def test_dashboard_entry_path_with_relative_path() -> None:
"""Test DashboardEntry handles relative paths.""" """Test DashboardEntry handles relative paths."""
test_path = "configs/device.yaml" test_path = Path("configs/device.yaml")
cache_key = create_cache_key() cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key) entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path assert entry.path == test_path
assert not Path(entry.path).is_absolute() assert not entry.path.is_absolute()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -81,12 +81,12 @@ async def test_dashboard_entries_get_by_path(
dashboard_entries: DashboardEntries, dashboard_entries: DashboardEntries,
) -> None: ) -> None:
"""Test getting entry by path.""" """Test getting entry by path."""
test_path = "/test/config/device.yaml" test_path = Path("/test/config/device.yaml")
entry = DashboardEntry(test_path, create_cache_key()) entry = DashboardEntry(test_path, create_cache_key())
dashboard_entries._entries[test_path] = entry dashboard_entries._entries[str(test_path)] = entry
result = dashboard_entries.get(test_path) result = dashboard_entries.get(str(test_path))
assert result == entry assert result == entry
@@ -104,12 +104,12 @@ async def test_dashboard_entries_path_normalization(
dashboard_entries: DashboardEntries, dashboard_entries: DashboardEntries,
) -> None: ) -> None:
"""Test that paths are handled consistently.""" """Test that paths are handled consistently."""
path1 = "/test/config/device.yaml" path1 = Path("/test/config/device.yaml")
entry = DashboardEntry(path1, create_cache_key()) entry = DashboardEntry(path1, create_cache_key())
dashboard_entries._entries[path1] = entry dashboard_entries._entries[str(path1)] = entry
result = dashboard_entries.get(path1) result = dashboard_entries.get(str(path1))
assert result == entry assert result == entry
@@ -118,12 +118,12 @@ async def test_dashboard_entries_path_with_spaces(
dashboard_entries: DashboardEntries, dashboard_entries: DashboardEntries,
) -> None: ) -> None:
"""Test handling paths with spaces.""" """Test handling paths with spaces."""
test_path = "/test/config/my device.yaml" test_path = Path("/test/config/my device.yaml")
entry = DashboardEntry(test_path, create_cache_key()) entry = DashboardEntry(test_path, create_cache_key())
dashboard_entries._entries[test_path] = entry dashboard_entries._entries[str(test_path)] = entry
result = dashboard_entries.get(test_path) result = dashboard_entries.get(str(test_path))
assert result == entry assert result == entry
assert result.path == test_path assert result.path == test_path
@@ -133,18 +133,18 @@ async def test_dashboard_entries_path_with_special_chars(
dashboard_entries: DashboardEntries, dashboard_entries: DashboardEntries,
) -> None: ) -> None:
"""Test handling paths with special characters.""" """Test handling paths with special characters."""
test_path = "/test/config/device-01_test.yaml" test_path = Path("/test/config/device-01_test.yaml")
entry = DashboardEntry(test_path, create_cache_key()) entry = DashboardEntry(test_path, create_cache_key())
dashboard_entries._entries[test_path] = entry dashboard_entries._entries[str(test_path)] = entry
result = dashboard_entries.get(test_path) result = dashboard_entries.get(str(test_path))
assert result == entry assert result == entry
def test_dashboard_entries_windows_path() -> None: def test_dashboard_entries_windows_path() -> None:
"""Test handling Windows-style paths.""" """Test handling Windows-style paths."""
test_path = r"C:\Users\test\esphome\device.yaml" test_path = Path(r"C:\Users\test\esphome\device.yaml")
cache_key = create_cache_key() cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key) entry = DashboardEntry(test_path, cache_key)
@@ -157,28 +157,28 @@ async def test_dashboard_entries_path_to_cache_key_mapping(
dashboard_entries: DashboardEntries, dashboard_entries: DashboardEntries,
) -> None: ) -> None:
"""Test internal entries storage with paths and cache keys.""" """Test internal entries storage with paths and cache keys."""
path1 = "/test/config/device1.yaml" path1 = Path("/test/config/device1.yaml")
path2 = "/test/config/device2.yaml" path2 = Path("/test/config/device2.yaml")
entry1 = DashboardEntry(path1, create_cache_key()) entry1 = DashboardEntry(path1, create_cache_key())
entry2 = DashboardEntry(path2, (1, 1, 1.0, 1)) entry2 = DashboardEntry(path2, (1, 1, 1.0, 1))
dashboard_entries._entries[path1] = entry1 dashboard_entries._entries[str(path1)] = entry1
dashboard_entries._entries[path2] = entry2 dashboard_entries._entries[str(path2)] = entry2
assert path1 in dashboard_entries._entries assert str(path1) in dashboard_entries._entries
assert path2 in dashboard_entries._entries assert str(path2) in dashboard_entries._entries
assert dashboard_entries._entries[path1].cache_key == create_cache_key() assert dashboard_entries._entries[str(path1)].cache_key == create_cache_key()
assert dashboard_entries._entries[path2].cache_key == (1, 1, 1.0, 1) assert dashboard_entries._entries[str(path2)].cache_key == (1, 1, 1.0, 1)
def test_dashboard_entry_path_property() -> None: def test_dashboard_entry_path_property() -> None:
"""Test that path property returns expected value.""" """Test that path property returns expected value."""
test_path = "/test/config/device.yaml" test_path = Path("/test/config/device.yaml")
entry = DashboardEntry(test_path, create_cache_key()) entry = DashboardEntry(test_path, create_cache_key())
assert entry.path == test_path assert entry.path == test_path
assert isinstance(entry.path, str) assert isinstance(entry.path, Path)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -187,14 +187,14 @@ async def test_dashboard_entries_all_returns_entries_with_paths(
) -> None: ) -> None:
"""Test that all() returns entries with their paths intact.""" """Test that all() returns entries with their paths intact."""
paths = [ paths = [
"/test/config/device1.yaml", Path("/test/config/device1.yaml"),
"/test/config/device2.yaml", Path("/test/config/device2.yaml"),
"/test/config/subfolder/device3.yaml", Path("/test/config/subfolder/device3.yaml"),
] ]
for path in paths: for path in paths:
entry = DashboardEntry(path, create_cache_key()) entry = DashboardEntry(path, create_cache_key())
dashboard_entries._entries[path] = entry dashboard_entries._entries[str(path)] = entry
all_entries = dashboard_entries.async_all() all_entries = dashboard_entries.async_all()

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
import tempfile import tempfile
@@ -17,7 +16,7 @@ def dashboard_settings(tmp_path: Path) -> DashboardSettings:
settings = DashboardSettings() settings = DashboardSettings()
# Resolve symlinks to ensure paths match # Resolve symlinks to ensure paths match
resolved_dir = tmp_path.resolve() resolved_dir = tmp_path.resolve()
settings.config_dir = str(resolved_dir) settings.config_dir = resolved_dir
settings.absolute_config_dir = resolved_dir settings.absolute_config_dir = resolved_dir
return settings return settings
@@ -26,7 +25,7 @@ def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with simple relative path.""" """Test rel_path with simple relative path."""
result = dashboard_settings.rel_path("config.yaml") result = dashboard_settings.rel_path("config.yaml")
expected = str(Path(dashboard_settings.config_dir) / "config.yaml") expected = dashboard_settings.config_dir / "config.yaml"
assert result == expected assert result == expected
@@ -34,9 +33,7 @@ def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) ->
"""Test rel_path with multiple path components.""" """Test rel_path with multiple path components."""
result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") result = dashboard_settings.rel_path("subfolder", "device", "config.yaml")
expected = str( expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml"
Path(dashboard_settings.config_dir) / "subfolder" / "device" / "config.yaml"
)
assert result == expected assert result == expected
@@ -55,7 +52,7 @@ def test_rel_path_absolute_path_within_config(
internal_path.touch() internal_path.touch()
result = dashboard_settings.rel_path("internal.yaml") result = dashboard_settings.rel_path("internal.yaml")
expected = str(Path(dashboard_settings.config_dir) / "internal.yaml") expected = dashboard_settings.config_dir / "internal.yaml"
assert result == expected assert result == expected
@@ -80,7 +77,7 @@ def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> No
path_obj = Path("subfolder") / "config.yaml" path_obj = Path("subfolder") / "config.yaml"
result = dashboard_settings.rel_path(path_obj) result = dashboard_settings.rel_path(path_obj)
expected = str(Path(dashboard_settings.config_dir) / "subfolder" / "config.yaml") expected = dashboard_settings.config_dir / "subfolder" / "config.yaml"
assert result == expected assert result == expected
@@ -93,9 +90,7 @@ def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> N
assert result1 == result2 assert result1 == result2
# Also test that the result is as expected # Also test that the result is as expected
expected = os.path.join( expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml"
dashboard_settings.config_dir, "folder", "subfolder", "file.yaml"
)
assert result1 == expected assert result1 == expected
@@ -103,7 +98,7 @@ def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles paths with spaces.""" """Test rel_path handles paths with spaces."""
result = dashboard_settings.rel_path("my folder", "my config.yaml") result = dashboard_settings.rel_path("my folder", "my config.yaml")
expected = str(Path(dashboard_settings.config_dir) / "my folder" / "my config.yaml") expected = dashboard_settings.config_dir / "my folder" / "my config.yaml"
assert result == expected assert result == expected
@@ -111,15 +106,13 @@ def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -
"""Test rel_path handles paths with special characters.""" """Test rel_path handles paths with special characters."""
result = dashboard_settings.rel_path("device-01_test", "config.yaml") result = dashboard_settings.rel_path("device-01_test", "config.yaml")
expected = str( expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml"
Path(dashboard_settings.config_dir) / "device-01_test" / "config.yaml"
)
assert result == expected assert result == expected
def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None:
"""Test that config_dir can be accessed and used with Path operations.""" """Test that config_dir can be accessed and used with Path operations."""
config_path = Path(dashboard_settings.config_dir) config_path = dashboard_settings.config_dir
assert config_path.exists() assert config_path.exists()
assert config_path.is_dir() assert config_path.is_dir()
@@ -141,7 +134,7 @@ def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -
symlink = dashboard_settings.absolute_config_dir / "link.yaml" symlink = dashboard_settings.absolute_config_dir / "link.yaml"
symlink.symlink_to(target) symlink.symlink_to(target)
result = dashboard_settings.rel_path("link.yaml") result = dashboard_settings.rel_path("link.yaml")
expected = str(Path(dashboard_settings.config_dir) / "link.yaml") expected = dashboard_settings.config_dir / "link.yaml"
assert result == expected assert result == expected
@@ -157,12 +150,12 @@ def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings)
def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles None arguments gracefully.""" """Test rel_path handles None arguments gracefully."""
result = dashboard_settings.rel_path("None") result = dashboard_settings.rel_path("None")
expected = str(Path(dashboard_settings.config_dir) / "None") expected = dashboard_settings.config_dir / "None"
assert result == expected assert result == expected
def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles numeric arguments.""" """Test rel_path handles numeric arguments."""
result = dashboard_settings.rel_path("123", "456.789") result = dashboard_settings.rel_path("123", "456.789")
expected = str(Path(dashboard_settings.config_dir) / "123" / "456.789") expected = dashboard_settings.config_dir / "123" / "456.789"
assert result == expected assert result == expected

View File

@@ -49,7 +49,7 @@ def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]:
"""Fixture to mock trash_storage_path.""" """Fixture to mock trash_storage_path."""
trash_dir = tmp_path / "trash" trash_dir = tmp_path / "trash"
with patch( with patch(
"esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir
) as mock: ) as mock:
yield mock yield mock
@@ -60,7 +60,7 @@ def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]:
archive_dir = tmp_path / "archive" archive_dir = tmp_path / "archive"
with patch( with patch(
"esphome.dashboard.web_server.archive_storage_path", "esphome.dashboard.web_server.archive_storage_path",
return_value=str(archive_dir), return_value=archive_dir,
) as mock: ) as mock:
yield mock yield mock
@@ -257,7 +257,7 @@ async def test_download_binary_handler_with_file(
# Mock storage JSON # Mock storage JSON
mock_storage = Mock() mock_storage = Mock()
mock_storage.name = "test_device" mock_storage.name = "test_device"
mock_storage.firmware_bin_path = str(firmware_file) mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch( response = await dashboard.fetch(
@@ -289,7 +289,7 @@ async def test_download_binary_handler_compressed(
# Mock storage JSON # Mock storage JSON
mock_storage = Mock() mock_storage = Mock()
mock_storage.name = "test_device" mock_storage.name = "test_device"
mock_storage.firmware_bin_path = str(firmware_file) mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch( response = await dashboard.fetch(
@@ -321,7 +321,7 @@ async def test_download_binary_handler_custom_download_name(
# Mock storage JSON # Mock storage JSON
mock_storage = Mock() mock_storage = Mock()
mock_storage.name = "test_device" mock_storage.name = "test_device"
mock_storage.firmware_bin_path = str(firmware_file) mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch( response = await dashboard.fetch(
@@ -355,7 +355,7 @@ async def test_download_binary_handler_idedata_fallback(
# Mock storage JSON # Mock storage JSON
mock_storage = Mock() mock_storage = Mock()
mock_storage.name = "test_device" mock_storage.name = "test_device"
mock_storage.firmware_bin_path = str(firmware_file) mock_storage.firmware_bin_path = firmware_file
mock_storage_json.load.return_value = mock_storage mock_storage_json.load.return_value = mock_storage
# Mock idedata response # Mock idedata response
@@ -402,7 +402,7 @@ async def test_edit_request_handler_post_existing(
test_file.write_text("esphome:\n name: original\n") test_file.write_text("esphome:\n name: original\n")
# Configure the mock settings # Configure the mock settings
mock_dashboard_settings.rel_path.return_value = str(test_file) mock_dashboard_settings.rel_path.return_value = test_file
mock_dashboard_settings.absolute_config_dir = test_file.parent mock_dashboard_settings.absolute_config_dir = test_file.parent
new_content = "esphome:\n name: modified\n" new_content = "esphome:\n name: modified\n"
@@ -426,7 +426,7 @@ async def test_unarchive_request_handler(
) -> None: ) -> None:
"""Test the UnArchiveRequestHandler.post method.""" """Test the UnArchiveRequestHandler.post method."""
# Set up an archived file # Set up an archived file
archive_dir = Path(mock_archive_storage_path.return_value) archive_dir = mock_archive_storage_path.return_value
archive_dir.mkdir(parents=True, exist_ok=True) archive_dir.mkdir(parents=True, exist_ok=True)
archived_file = archive_dir / "archived.yaml" archived_file = archive_dir / "archived.yaml"
archived_file.write_text("test content") archived_file.write_text("test content")
@@ -435,7 +435,7 @@ async def test_unarchive_request_handler(
config_dir = tmp_path / "config" config_dir = tmp_path / "config"
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
destination_file = config_dir / "archived.yaml" destination_file = config_dir / "archived.yaml"
mock_dashboard_settings.rel_path.return_value = str(destination_file) mock_dashboard_settings.rel_path.return_value = destination_file
response = await dashboard.fetch( response = await dashboard.fetch(
"/unarchive?configuration=archived.yaml", "/unarchive?configuration=archived.yaml",
@@ -474,7 +474,7 @@ async def test_secret_keys_handler_with_file(
# Configure mock to return our temp secrets file # Configure mock to return our temp secrets file
# Since the file actually exists, os.path.isfile will return True naturally # Since the file actually exists, os.path.isfile will return True naturally
mock_dashboard_settings.rel_path.return_value = str(secrets_file) mock_dashboard_settings.rel_path.return_value = secrets_file
response = await dashboard.fetch("/secret_keys", method="GET") response = await dashboard.fetch("/secret_keys", method="GET")
assert response.code == 200 assert response.code == 200
@@ -538,8 +538,8 @@ def test_start_web_server_with_address_port(
) -> None: ) -> None:
"""Test the start_web_server function with address and port.""" """Test the start_web_server function with address and port."""
app = Mock() app = Mock()
trash_dir = Path(mock_trash_storage_path.return_value) trash_dir = mock_trash_storage_path.return_value
archive_dir = Path(mock_archive_storage_path.return_value) archive_dir = mock_archive_storage_path.return_value
# Create trash dir to test migration # Create trash dir to test migration
trash_dir.mkdir() trash_dir.mkdir()
@@ -643,12 +643,12 @@ async def test_archive_handler_with_build_folder(
(build_folder / ".pioenvs").mkdir() (build_folder / ".pioenvs").mkdir()
mock_dashboard_settings.config_dir = str(config_dir) mock_dashboard_settings.config_dir = str(config_dir)
mock_dashboard_settings.rel_path.return_value = str(test_config) mock_dashboard_settings.rel_path.return_value = test_config
mock_archive_storage_path.return_value = str(archive_dir) mock_archive_storage_path.return_value = archive_dir
mock_storage = MagicMock() mock_storage = MagicMock()
mock_storage.name = "test_device" mock_storage.name = "test_device"
mock_storage.build_path = str(build_folder) mock_storage.build_path = build_folder
mock_storage_json.load.return_value = mock_storage mock_storage_json.load.return_value = mock_storage
response = await dashboard.fetch( response = await dashboard.fetch(
@@ -686,8 +686,8 @@ async def test_archive_handler_no_build_folder(
test_config.write_text("esphome:\n name: test_device\n") test_config.write_text("esphome:\n name: test_device\n")
mock_dashboard_settings.config_dir = str(config_dir) mock_dashboard_settings.config_dir = str(config_dir)
mock_dashboard_settings.rel_path.return_value = str(test_config) mock_dashboard_settings.rel_path.return_value = test_config
mock_archive_storage_path.return_value = str(archive_dir) mock_archive_storage_path.return_value = archive_dir
mock_storage = MagicMock() mock_storage = MagicMock()
mock_storage.name = "test_device" mock_storage.name = "test_device"

View File

@@ -13,14 +13,14 @@ from esphome.dashboard import web_server
def test_get_base_frontend_path_production() -> None: def test_get_base_frontend_path_production() -> None:
"""Test get_base_frontend_path in production mode.""" """Test get_base_frontend_path in production mode."""
mock_module = MagicMock() mock_module = MagicMock()
mock_module.where.return_value = "/usr/local/lib/esphome_dashboard" mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard")
with ( with (
patch.dict(os.environ, {}, clear=True), patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}), patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
): ):
result = web_server.get_base_frontend_path() result = web_server.get_base_frontend_path()
assert result == "/usr/local/lib/esphome_dashboard" assert result == Path("/usr/local/lib/esphome_dashboard")
mock_module.where.assert_called_once() mock_module.where.assert_called_once()
@@ -31,13 +31,12 @@ def test_get_base_frontend_path_dev_mode() -> None:
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path() result = web_server.get_base_frontend_path()
# The function uses os.path.abspath which doesn't resolve symlinks # The function uses Path.resolve() which resolves symlinks
# We need to match that behavior
# The actual function adds "/" to the path, so we simulate that # The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = os.path.abspath( expected = (
os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
) ).resolve()
assert result == expected assert result == expected
@@ -48,8 +47,8 @@ def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None:
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path() result = web_server.get_base_frontend_path()
# The function uses os.path.abspath which doesn't resolve symlinks # The function uses Path.resolve() which resolves symlinks
expected = os.path.abspath(str(Path.cwd() / test_path / "esphome_dashboard")) expected = (Path.cwd() / test_path / "esphome_dashboard").resolve()
assert result == expected assert result == expected
@@ -60,76 +59,72 @@ def test_get_base_frontend_path_dev_mode_relative_path() -> None:
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path() result = web_server.get_base_frontend_path()
# The function uses os.path.abspath which doesn't resolve symlinks # The function uses Path.resolve() which resolves symlinks
# We need to match that behavior
# The actual function adds "/" to the path, so we simulate that # The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = os.path.abspath( expected = (
os.path.join(os.getcwd(), test_path_with_slash, "esphome_dashboard") Path(os.getcwd()) / test_path_with_slash / "esphome_dashboard"
) ).resolve()
assert result == expected assert result == expected
assert Path(result).is_absolute() assert result.is_absolute()
def test_get_static_path_single_component() -> None: def test_get_static_path_single_component() -> None:
"""Test get_static_path with single path component.""" """Test get_static_path with single path component."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("file.js") result = web_server.get_static_path("file.js")
assert result == os.path.join("/base/frontend", "static", "file.js") assert result == Path("/base/frontend") / "static" / "file.js"
def test_get_static_path_multiple_components() -> None: def test_get_static_path_multiple_components() -> None:
"""Test get_static_path with multiple path components.""" """Test get_static_path with multiple path components."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js", "esphome", "index.js") result = web_server.get_static_path("js", "esphome", "index.js")
assert result == os.path.join( assert (
"/base/frontend", "static", "js", "esphome", "index.js" result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js"
) )
def test_get_static_path_empty_args() -> None: def test_get_static_path_empty_args() -> None:
"""Test get_static_path with no arguments.""" """Test get_static_path with no arguments."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path() result = web_server.get_static_path()
assert result == os.path.join("/base/frontend", "static") assert result == Path("/base/frontend") / "static"
def test_get_static_path_with_pathlib_path() -> None: def test_get_static_path_with_pathlib_path() -> None:
"""Test get_static_path with Path objects.""" """Test get_static_path with Path objects."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
path_obj = Path("js") / "app.js" path_obj = Path("js") / "app.js"
result = web_server.get_static_path(str(path_obj)) result = web_server.get_static_path(str(path_obj))
assert result == os.path.join("/base/frontend", "static", "js", "app.js") assert result == Path("/base/frontend") / "static" / "js" / "app.js"
def test_get_static_file_url_production() -> None: def test_get_static_file_url_production() -> None:
"""Test get_static_file_url in production mode.""" """Test get_static_file_url in production mode."""
web_server.get_static_file_url.cache_clear() web_server.get_static_file_url.cache_clear()
mock_module = MagicMock() mock_module = MagicMock()
mock_file = MagicMock() mock_path = MagicMock(spec=Path)
mock_file.read.return_value = b"test content" mock_path.read_bytes.return_value = b"test content"
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=None)
with ( with (
patch.dict(os.environ, {}, clear=True), patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}), patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, patch("esphome.dashboard.web_server.get_static_path") as mock_get_path,
patch("esphome.dashboard.web_server.open", create=True, return_value=mock_file),
): ):
mock_get_path.return_value = "/fake/path/js/app.js" mock_get_path.return_value = mock_path
result = web_server.get_static_file_url("js/app.js") result = web_server.get_static_file_url("js/app.js")
assert result.startswith("./static/js/app.js?hash=") assert result.startswith("./static/js/app.js?hash=")
@@ -182,26 +177,26 @@ def test_load_file_compressed_path(tmp_path: Path) -> None:
def test_path_normalization_in_static_path() -> None: def test_path_normalization_in_static_path() -> None:
"""Test that paths are normalized correctly.""" """Test that paths are normalized correctly."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
# Test with separate components # Test with separate components
result1 = web_server.get_static_path("js", "app.js") result1 = web_server.get_static_path("js", "app.js")
result2 = web_server.get_static_path("js", "app.js") result2 = web_server.get_static_path("js", "app.js")
assert result1 == result2 assert result1 == result2
assert result1 == os.path.join("/base/frontend", "static", "js", "app.js") assert result1 == Path("/base/frontend") / "static" / "js" / "app.js"
def test_windows_path_handling() -> None: def test_windows_path_handling() -> None:
"""Test handling of Windows-style paths.""" """Test handling of Windows-style paths."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = r"C:\Program Files\esphome\frontend" mock_base.return_value = Path(r"C:\Program Files\esphome\frontend")
result = web_server.get_static_path("js", "app.js") result = web_server.get_static_path("js", "app.js")
# os.path.join should handle this correctly on the platform # Path should handle this correctly on the platform
expected = os.path.join( expected = (
r"C:\Program Files\esphome\frontend", "static", "js", "app.js" Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js"
) )
assert result == expected assert result == expected
@@ -209,22 +204,20 @@ def test_windows_path_handling() -> None:
def test_path_with_special_characters() -> None: def test_path_with_special_characters() -> None:
"""Test paths with special characters.""" """Test paths with special characters."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/frontend" mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js-modules", "app_v1.0.js") result = web_server.get_static_path("js-modules", "app_v1.0.js")
assert result == os.path.join( assert (
"/base/frontend", "static", "js-modules", "app_v1.0.js" result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js"
) )
def test_path_with_spaces() -> None: def test_path_with_spaces() -> None:
"""Test paths with spaces.""" """Test paths with spaces."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = "/base/my frontend" mock_base.return_value = Path("/base/my frontend")
result = web_server.get_static_path("my js", "my app.js") result = web_server.get_static_path("my js", "my app.js")
assert result == os.path.join( assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js"
"/base/my frontend", "static", "my js", "my app.js"
)

View File

@@ -1,56 +0,0 @@
import os
from pathlib import Path
from unittest.mock import patch
import py
import pytest
from esphome.dashboard.util.file import write_file, write_utf8_file
def test_write_utf8_file(tmp_path: Path) -> None:
write_utf8_file(tmp_path.joinpath("foo.txt"), "foo")
assert tmp_path.joinpath("foo.txt").read_text() == "foo"
with pytest.raises(OSError):
write_utf8_file(Path("/dev/not-writable"), "bar")
def test_write_file(tmp_path: Path) -> None:
write_file(tmp_path.joinpath("foo.txt"), b"foo")
assert tmp_path.joinpath("foo.txt").read_text() == "foo"
def test_write_utf8_file_fails_at_rename(
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that if rename fails not not remove, we do not log the failed cleanup."""
test_dir = tmpdir.mkdir("files")
test_file = Path(test_dir / "test.json")
with (
pytest.raises(OSError),
patch("esphome.dashboard.util.file.os.replace", side_effect=OSError),
):
write_utf8_file(test_file, '{"some":"data"}', False)
assert not os.path.exists(test_file)
assert "File replacement cleanup failed" not in caplog.text
def test_write_utf8_file_fails_at_rename_and_remove(
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that if rename and remove both fail, we log the failed cleanup."""
test_dir = tmpdir.mkdir("files")
test_file = Path(test_dir / "test.json")
with (
pytest.raises(OSError),
patch("esphome.dashboard.util.file.os.remove", side_effect=OSError),
patch("esphome.dashboard.util.file.os.replace", side_effect=OSError),
):
write_utf8_file(test_file, '{"some":"data"}', False)
assert "File replacement cleanup failed" in caplog.text

View File

@@ -271,7 +271,7 @@ async def compile_esphome(
def _read_config_and_get_binary(): def _read_config_and_get_binary():
CORE.reset() # Reset CORE state between test runs CORE.reset() # Reset CORE state between test runs
CORE.config_path = str(config_path) CORE.config_path = config_path
config = esphome.config.read_config( config = esphome.config.read_config(
{"command": "compile", "config": str(config_path)} {"command": "compile", "config": str(config_path)}
) )

View File

@@ -172,7 +172,7 @@ def test_write_ini_no_change_when_content_same(
# write_file_if_changed should be called with the same content # write_file_if_changed should be called with the same content
mock_write_file_if_changed.assert_called_once() mock_write_file_if_changed.assert_called_once()
call_args = mock_write_file_if_changed.call_args[0] call_args = mock_write_file_if_changed.call_args[0]
assert call_args[0] == str(ini_file) assert call_args[0] == ini_file
assert content in call_args[1] assert content in call_args[1]

View File

@@ -43,7 +43,7 @@ def fixture_path() -> Path:
@pytest.fixture @pytest.fixture
def setup_core(tmp_path: Path) -> Path: def setup_core(tmp_path: Path) -> Path:
"""Set up CORE with test paths.""" """Set up CORE with test paths."""
CORE.config_path = str(tmp_path / "test.yaml") CORE.config_path = tmp_path / "test.yaml"
return tmp_path return tmp_path

View File

@@ -10,7 +10,7 @@ from esphome.core import CORE
def load_config_from_yaml( def load_config_from_yaml(
yaml_file: Callable[[str], str], yaml_content: str yaml_file: Callable[[str], Path], yaml_content: str
) -> Config | None: ) -> Config | None:
"""Load configuration from YAML content.""" """Load configuration from YAML content."""
yaml_path = yaml_file(yaml_content) yaml_path = yaml_file(yaml_content)
@@ -25,7 +25,7 @@ def load_config_from_yaml(
def load_config_from_fixture( def load_config_from_fixture(
yaml_file: Callable[[str], str], fixture_name: str, fixtures_dir: Path yaml_file: Callable[[str], Path], fixture_name: str, fixtures_dir: Path
) -> Config | None: ) -> Config | None:
"""Load configuration from a fixture file.""" """Load configuration from a fixture file."""
fixture_path = fixtures_dir / fixture_name fixture_path = fixtures_dir / fixture_name

View File

@@ -7,12 +7,12 @@ import pytest
@pytest.fixture @pytest.fixture
def yaml_file(tmp_path: Path) -> Callable[[str], str]: def yaml_file(tmp_path: Path) -> Callable[[str], Path]:
"""Create a temporary YAML file for testing.""" """Create a temporary YAML file for testing."""
def _yaml_file(content: str) -> str: def _yaml_file(content: str) -> Path:
yaml_path = tmp_path / "test.yaml" yaml_path = tmp_path / "test.yaml"
yaml_path.write_text(content) yaml_path.write_text(content)
return str(yaml_path) return yaml_path
return _yaml_file return _yaml_file

View File

@@ -289,7 +289,7 @@ def test_valid_include_with_angle_brackets() -> None:
def test_valid_include_with_valid_file(tmp_path: Path) -> None: def test_valid_include_with_valid_file(tmp_path: Path) -> None:
"""Test valid_include accepts valid include files.""" """Test valid_include accepts valid include files."""
CORE.config_path = str(tmp_path / "test.yaml") CORE.config_path = tmp_path / "test.yaml"
include_file = tmp_path / "include.h" include_file = tmp_path / "include.h"
include_file.touch() include_file.touch()
@@ -298,7 +298,7 @@ def test_valid_include_with_valid_file(tmp_path: Path) -> None:
def test_valid_include_with_valid_directory(tmp_path: Path) -> None: def test_valid_include_with_valid_directory(tmp_path: Path) -> None:
"""Test valid_include accepts valid directories.""" """Test valid_include accepts valid directories."""
CORE.config_path = str(tmp_path / "test.yaml") CORE.config_path = tmp_path / "test.yaml"
include_dir = tmp_path / "includes" include_dir = tmp_path / "includes"
include_dir.mkdir() include_dir.mkdir()
@@ -307,7 +307,7 @@ def test_valid_include_with_valid_directory(tmp_path: Path) -> None:
def test_valid_include_invalid_extension(tmp_path: Path) -> None: def test_valid_include_invalid_extension(tmp_path: Path) -> None:
"""Test valid_include rejects files with invalid extensions.""" """Test valid_include rejects files with invalid extensions."""
CORE.config_path = str(tmp_path / "test.yaml") CORE.config_path = tmp_path / "test.yaml"
invalid_file = tmp_path / "file.txt" invalid_file = tmp_path / "file.txt"
invalid_file.touch() invalid_file.touch()
@@ -481,7 +481,7 @@ def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) ->
src_file = tmp_path / "source.h" src_file = tmp_path / "source.h"
src_file.write_text("// Header content") src_file.write_text("// Header content")
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
with patch("esphome.core.config.cg") as mock_cg: with patch("esphome.core.config.cg") as mock_cg:
# Mock RawStatement to capture the text # Mock RawStatement to capture the text
@@ -494,7 +494,7 @@ def test_include_file_header(tmp_path: Path, mock_copy_file_if_changed: Mock) ->
mock_cg.RawStatement.side_effect = raw_statement_side_effect mock_cg.RawStatement.side_effect = raw_statement_side_effect
config.include_file(str(src_file), "test.h") config.include_file(src_file, Path("test.h"))
mock_copy_file_if_changed.assert_called_once() mock_copy_file_if_changed.assert_called_once()
mock_cg.add_global.assert_called_once() mock_cg.add_global.assert_called_once()
@@ -507,10 +507,10 @@ def test_include_file_cpp(tmp_path: Path, mock_copy_file_if_changed: Mock) -> No
src_file = tmp_path / "source.cpp" src_file = tmp_path / "source.cpp"
src_file.write_text("// CPP content") src_file.write_text("// CPP content")
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
with patch("esphome.core.config.cg") as mock_cg: with patch("esphome.core.config.cg") as mock_cg:
config.include_file(str(src_file), "test.cpp") config.include_file(src_file, Path("test.cpp"))
mock_copy_file_if_changed.assert_called_once() mock_copy_file_if_changed.assert_called_once()
# Should not add include statement for .cpp files # Should not add include statement for .cpp files
@@ -602,8 +602,8 @@ async def test_add_includes_with_single_file(
mock_cg_with_include_capture: tuple[Mock, list[str]], mock_cg_with_include_capture: tuple[Mock, list[str]],
) -> None: ) -> None:
"""Test add_includes copies a single header file to build directory.""" """Test add_includes copies a single header file to build directory."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create include file # Create include file
@@ -617,7 +617,7 @@ async def test_add_includes_with_single_file(
# Verify copy_file_if_changed was called to copy the file # Verify copy_file_if_changed was called to copy the file
# Note: add_includes adds files to a src/ subdirectory # Note: add_includes adds files to a src/ subdirectory
mock_copy_file_if_changed.assert_called_once_with( mock_copy_file_if_changed.assert_called_once_with(
str(include_file), str(Path(CORE.build_path) / "src" / "my_header.h") include_file, CORE.build_path / "src" / "my_header.h"
) )
# Verify include statement was added # Verify include statement was added
@@ -632,8 +632,8 @@ async def test_add_includes_with_directory_unix(
mock_cg_with_include_capture: tuple[Mock, list[str]], mock_cg_with_include_capture: tuple[Mock, list[str]],
) -> None: ) -> None:
"""Test add_includes copies all files from a directory on Unix.""" """Test add_includes copies all files from a directory on Unix."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create include directory with files # Create include directory with files
@@ -677,8 +677,8 @@ async def test_add_includes_with_directory_windows(
mock_cg_with_include_capture: tuple[Mock, list[str]], mock_cg_with_include_capture: tuple[Mock, list[str]],
) -> None: ) -> None:
"""Test add_includes copies all files from a directory on Windows.""" """Test add_includes copies all files from a directory on Windows."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create include directory with files # Create include directory with files
@@ -719,8 +719,8 @@ async def test_add_includes_with_multiple_sources(
tmp_path: Path, mock_copy_file_if_changed: Mock tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None: ) -> None:
"""Test add_includes with multiple files and directories.""" """Test add_includes with multiple files and directories."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create various include sources # Create various include sources
@@ -747,8 +747,8 @@ async def test_add_includes_empty_directory(
tmp_path: Path, mock_copy_file_if_changed: Mock tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None: ) -> None:
"""Test add_includes with an empty directory doesn't fail.""" """Test add_includes with an empty directory doesn't fail."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create empty directory # Create empty directory
@@ -769,8 +769,8 @@ async def test_add_includes_preserves_directory_structure_unix(
tmp_path: Path, mock_copy_file_if_changed: Mock tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None: ) -> None:
"""Test that add_includes preserves relative directory structure on Unix.""" """Test that add_includes preserves relative directory structure on Unix."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create nested directory structure # Create nested directory structure
@@ -793,8 +793,8 @@ async def test_add_includes_preserves_directory_structure_unix(
dest_paths = [call[0][1] for call in calls] dest_paths = [call[0][1] for call in calls]
# Check that relative paths are preserved # Check that relative paths are preserved
assert any("lib/src/core.h" in path for path in dest_paths) assert any("lib/src/core.h" in str(path) for path in dest_paths)
assert any("lib/utils/helper.h" in path for path in dest_paths) assert any("lib/utils/helper.h" in str(path) for path in dest_paths)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -803,8 +803,8 @@ async def test_add_includes_preserves_directory_structure_windows(
tmp_path: Path, mock_copy_file_if_changed: Mock tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None: ) -> None:
"""Test that add_includes preserves relative directory structure on Windows.""" """Test that add_includes preserves relative directory structure on Windows."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create nested directory structure # Create nested directory structure
@@ -827,8 +827,8 @@ async def test_add_includes_preserves_directory_structure_windows(
dest_paths = [call[0][1] for call in calls] dest_paths = [call[0][1] for call in calls]
# Check that relative paths are preserved # Check that relative paths are preserved
assert any("lib\\src\\core.h" in path for path in dest_paths) assert any("lib\\src\\core.h" in str(path) for path in dest_paths)
assert any("lib\\utils\\helper.h" in path for path in dest_paths) assert any("lib\\utils\\helper.h" in str(path) for path in dest_paths)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -836,8 +836,8 @@ async def test_add_includes_overwrites_existing_files(
tmp_path: Path, mock_copy_file_if_changed: Mock tmp_path: Path, mock_copy_file_if_changed: Mock
) -> None: ) -> None:
"""Test that add_includes overwrites existing files in build directory.""" """Test that add_includes overwrites existing files in build directory."""
CORE.config_path = str(tmp_path / "config.yaml") CORE.config_path = tmp_path / "config.yaml"
CORE.build_path = str(tmp_path / "build") CORE.build_path = tmp_path / "build"
os.makedirs(CORE.build_path, exist_ok=True) os.makedirs(CORE.build_path, exist_ok=True)
# Create include file # Create include file
@@ -850,5 +850,5 @@ async def test_add_includes_overwrites_existing_files(
# Verify copy_file_if_changed was called (it handles overwriting) # Verify copy_file_if_changed was called (it handles overwriting)
# Note: add_includes adds files to a src/ subdirectory # Note: add_includes adds files to a src/ subdirectory
mock_copy_file_if_changed.assert_called_once_with( mock_copy_file_if_changed.assert_called_once_with(
str(include_file), str(Path(CORE.build_path) / "src" / "header.h") include_file, CORE.build_path / "src" / "header.h"
) )

View File

@@ -15,7 +15,7 @@ def test_directory_valid_path(setup_core: Path) -> None:
result = cv.directory("test_directory") result = cv.directory("test_directory")
assert result == "test_directory" assert result == test_dir
def test_directory_absolute_path(setup_core: Path) -> None: def test_directory_absolute_path(setup_core: Path) -> None:
@@ -25,7 +25,7 @@ def test_directory_absolute_path(setup_core: Path) -> None:
result = cv.directory(str(test_dir)) result = cv.directory(str(test_dir))
assert result == str(test_dir) assert result == test_dir
def test_directory_nonexistent_path(setup_core: Path) -> None: def test_directory_nonexistent_path(setup_core: Path) -> None:
@@ -52,7 +52,7 @@ def test_directory_with_parent_directory(setup_core: Path) -> None:
result = cv.directory("parent/child/grandchild") result = cv.directory("parent/child/grandchild")
assert result == "parent/child/grandchild" assert result == nested_dir
def test_file_valid_path(setup_core: Path) -> None: def test_file_valid_path(setup_core: Path) -> None:
@@ -62,7 +62,7 @@ def test_file_valid_path(setup_core: Path) -> None:
result = cv.file_("test_file.yaml") result = cv.file_("test_file.yaml")
assert result == "test_file.yaml" assert result == test_file
def test_file_absolute_path(setup_core: Path) -> None: def test_file_absolute_path(setup_core: Path) -> None:
@@ -72,7 +72,7 @@ def test_file_absolute_path(setup_core: Path) -> None:
result = cv.file_(str(test_file)) result = cv.file_(str(test_file))
assert result == str(test_file) assert result == test_file
def test_file_nonexistent_path(setup_core: Path) -> None: def test_file_nonexistent_path(setup_core: Path) -> None:
@@ -99,7 +99,7 @@ def test_file_with_parent_directory(setup_core: Path) -> None:
result = cv.file_("configs/sensors/temperature.yaml") result = cv.file_("configs/sensors/temperature.yaml")
assert result == "configs/sensors/temperature.yaml" assert result == test_file
def test_directory_handles_trailing_slash(setup_core: Path) -> None: def test_directory_handles_trailing_slash(setup_core: Path) -> None:
@@ -108,29 +108,29 @@ def test_directory_handles_trailing_slash(setup_core: Path) -> None:
test_dir.mkdir() test_dir.mkdir()
result = cv.directory("test_dir/") result = cv.directory("test_dir/")
assert result == "test_dir/" assert result == test_dir
result = cv.directory("test_dir") result = cv.directory("test_dir")
assert result == "test_dir" assert result == test_dir
def test_file_handles_various_extensions(setup_core: Path) -> None: def test_file_handles_various_extensions(setup_core: Path) -> None:
"""Test file_ validator works with different file extensions.""" """Test file_ validator works with different file extensions."""
yaml_file = setup_core / "config.yaml" yaml_file = setup_core / "config.yaml"
yaml_file.write_text("yaml content") yaml_file.write_text("yaml content")
assert cv.file_("config.yaml") == "config.yaml" assert cv.file_("config.yaml") == yaml_file
yml_file = setup_core / "config.yml" yml_file = setup_core / "config.yml"
yml_file.write_text("yml content") yml_file.write_text("yml content")
assert cv.file_("config.yml") == "config.yml" assert cv.file_("config.yml") == yml_file
txt_file = setup_core / "readme.txt" txt_file = setup_core / "readme.txt"
txt_file.write_text("text content") txt_file.write_text("text content")
assert cv.file_("readme.txt") == "readme.txt" assert cv.file_("readme.txt") == txt_file
no_ext_file = setup_core / "LICENSE" no_ext_file = setup_core / "LICENSE"
no_ext_file.write_text("license content") no_ext_file.write_text("license content")
assert cv.file_("LICENSE") == "LICENSE" assert cv.file_("LICENSE") == no_ext_file
def test_directory_with_symlink(setup_core: Path) -> None: def test_directory_with_symlink(setup_core: Path) -> None:
@@ -142,7 +142,7 @@ def test_directory_with_symlink(setup_core: Path) -> None:
symlink_dir.symlink_to(actual_dir) symlink_dir.symlink_to(actual_dir)
result = cv.directory("symlink_directory") result = cv.directory("symlink_directory")
assert result == "symlink_directory" assert result == symlink_dir
def test_file_with_symlink(setup_core: Path) -> None: def test_file_with_symlink(setup_core: Path) -> None:
@@ -154,7 +154,7 @@ def test_file_with_symlink(setup_core: Path) -> None:
symlink_file.symlink_to(actual_file) symlink_file.symlink_to(actual_file)
result = cv.file_("symlink_file.txt") result = cv.file_("symlink_file.txt")
assert result == "symlink_file.txt" assert result == symlink_file
def test_directory_error_shows_full_path(setup_core: Path) -> None: def test_directory_error_shows_full_path(setup_core: Path) -> None:
@@ -175,7 +175,7 @@ def test_directory_with_spaces_in_name(setup_core: Path) -> None:
dir_with_spaces.mkdir() dir_with_spaces.mkdir()
result = cv.directory("my test directory") result = cv.directory("my test directory")
assert result == "my test directory" assert result == dir_with_spaces
def test_file_with_spaces_in_name(setup_core: Path) -> None: def test_file_with_spaces_in_name(setup_core: Path) -> None:
@@ -184,4 +184,4 @@ def test_file_with_spaces_in_name(setup_core: Path) -> None:
file_with_spaces.write_text("content") file_with_spaces.write_text("content")
result = cv.file_("my test file.yaml") result = cv.file_("my test file.yaml")
assert result == "my test file.yaml" assert result == file_with_spaces

View File

@@ -1,4 +1,5 @@
import os import os
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from hypothesis import given from hypothesis import given
@@ -536,8 +537,8 @@ class TestEsphomeCore:
@pytest.fixture @pytest.fixture
def target(self, fixture_path): def target(self, fixture_path):
target = core.EsphomeCore() target = core.EsphomeCore()
target.build_path = "foo/build" target.build_path = Path("foo/build")
target.config_path = "foo/config" target.config_path = Path("foo/config")
return target return target
def test_reset(self, target): def test_reset(self, target):
@@ -584,33 +585,33 @@ class TestEsphomeCore:
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
def test_data_dir_default_unix(self, target): def test_data_dir_default_unix(self, target):
"""Test data_dir returns .esphome in config directory by default on Unix.""" """Test data_dir returns .esphome in config directory by default on Unix."""
target.config_path = "/home/user/config.yaml" target.config_path = Path("/home/user/config.yaml")
assert target.data_dir == "/home/user/.esphome" assert target.data_dir == Path("/home/user/.esphome")
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test")
def test_data_dir_default_windows(self, target): def test_data_dir_default_windows(self, target):
"""Test data_dir returns .esphome in config directory by default on Windows.""" """Test data_dir returns .esphome in config directory by default on Windows."""
target.config_path = "D:\\home\\user\\config.yaml" target.config_path = Path("D:\\home\\user\\config.yaml")
assert target.data_dir == "D:\\home\\user\\.esphome" assert target.data_dir == Path("D:\\home\\user\\.esphome")
def test_data_dir_ha_addon(self, target): def test_data_dir_ha_addon(self, target):
"""Test data_dir returns /data when running as Home Assistant addon.""" """Test data_dir returns /data when running as Home Assistant addon."""
target.config_path = "/config/test.yaml" target.config_path = Path("/config/test.yaml")
with patch.dict(os.environ, {"ESPHOME_IS_HA_ADDON": "true"}): with patch.dict(os.environ, {"ESPHOME_IS_HA_ADDON": "true"}):
assert target.data_dir == "/data" assert target.data_dir == Path("/data")
def test_data_dir_env_override(self, target): def test_data_dir_env_override(self, target):
"""Test data_dir uses ESPHOME_DATA_DIR environment variable when set.""" """Test data_dir uses ESPHOME_DATA_DIR environment variable when set."""
target.config_path = "/home/user/config.yaml" target.config_path = Path("/home/user/config.yaml")
with patch.dict(os.environ, {"ESPHOME_DATA_DIR": "/custom/data/path"}): with patch.dict(os.environ, {"ESPHOME_DATA_DIR": "/custom/data/path"}):
assert target.data_dir == "/custom/data/path" assert target.data_dir == Path("/custom/data/path")
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") @pytest.mark.skipif(os.name == "nt", reason="Unix-specific test")
def test_data_dir_priority_unix(self, target): def test_data_dir_priority_unix(self, target):
"""Test data_dir priority on Unix: HA addon > env var > default.""" """Test data_dir priority on Unix: HA addon > env var > default."""
target.config_path = "/config/test.yaml" target.config_path = Path("/config/test.yaml")
expected_default = "/config/.esphome" expected_default = "/config/.esphome"
# Test HA addon takes priority over env var # Test HA addon takes priority over env var
@@ -618,26 +619,26 @@ class TestEsphomeCore:
os.environ, os.environ,
{"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"},
): ):
assert target.data_dir == "/data" assert target.data_dir == Path("/data")
# Test env var is used when not HA addon # Test env var is used when not HA addon
with patch.dict( with patch.dict(
os.environ, os.environ,
{"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"},
): ):
assert target.data_dir == "/custom/path" assert target.data_dir == Path("/custom/path")
# Test default when neither is set # Test default when neither is set
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
# Ensure these env vars are not set # Ensure these env vars are not set
os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_IS_HA_ADDON", None)
os.environ.pop("ESPHOME_DATA_DIR", None) os.environ.pop("ESPHOME_DATA_DIR", None)
assert target.data_dir == expected_default assert target.data_dir == Path(expected_default)
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") @pytest.mark.skipif(os.name != "nt", reason="Windows-specific test")
def test_data_dir_priority_windows(self, target): def test_data_dir_priority_windows(self, target):
"""Test data_dir priority on Windows: HA addon > env var > default.""" """Test data_dir priority on Windows: HA addon > env var > default."""
target.config_path = "D:\\config\\test.yaml" target.config_path = Path("D:\\config\\test.yaml")
expected_default = "D:\\config\\.esphome" expected_default = "D:\\config\\.esphome"
# Test HA addon takes priority over env var # Test HA addon takes priority over env var
@@ -645,21 +646,21 @@ class TestEsphomeCore:
os.environ, os.environ,
{"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"}, {"ESPHOME_IS_HA_ADDON": "true", "ESPHOME_DATA_DIR": "/custom/path"},
): ):
assert target.data_dir == "/data" assert target.data_dir == Path("/data")
# Test env var is used when not HA addon # Test env var is used when not HA addon
with patch.dict( with patch.dict(
os.environ, os.environ,
{"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"}, {"ESPHOME_IS_HA_ADDON": "false", "ESPHOME_DATA_DIR": "/custom/path"},
): ):
assert target.data_dir == "/custom/path" assert target.data_dir == Path("/custom/path")
# Test default when neither is set # Test default when neither is set
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
# Ensure these env vars are not set # Ensure these env vars are not set
os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_IS_HA_ADDON", None)
os.environ.pop("ESPHOME_DATA_DIR", None) os.environ.pop("ESPHOME_DATA_DIR", None)
assert target.data_dir == expected_default assert target.data_dir == Path(expected_default)
def test_platformio_cache_dir_with_env_var(self): def test_platformio_cache_dir_with_env_var(self):
"""Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set.""" """Test platformio_cache_dir when PLATFORMIO_CACHE_DIR env var is set."""

View File

@@ -13,7 +13,12 @@ def test_coro_priority_enum_values() -> None:
assert CoroPriority.CORE == 100 assert CoroPriority.CORE == 100
assert CoroPriority.DIAGNOSTICS == 90 assert CoroPriority.DIAGNOSTICS == 90
assert CoroPriority.STATUS == 80 assert CoroPriority.STATUS == 80
assert CoroPriority.WEB_SERVER_BASE == 65
assert CoroPriority.CAPTIVE_PORTAL == 64
assert CoroPriority.COMMUNICATION == 60 assert CoroPriority.COMMUNICATION == 60
assert CoroPriority.NETWORK_SERVICES == 55
assert CoroPriority.OTA_UPDATES == 54
assert CoroPriority.WEB_SERVER_OTA == 52
assert CoroPriority.APPLICATION == 50 assert CoroPriority.APPLICATION == 50
assert CoroPriority.WEB == 40 assert CoroPriority.WEB == 40
assert CoroPriority.AUTOMATION == 30 assert CoroPriority.AUTOMATION == 30
@@ -70,7 +75,12 @@ def test_float_and_enum_are_interchangeable() -> None:
(CoroPriority.CORE, 100.0), (CoroPriority.CORE, 100.0),
(CoroPriority.DIAGNOSTICS, 90.0), (CoroPriority.DIAGNOSTICS, 90.0),
(CoroPriority.STATUS, 80.0), (CoroPriority.STATUS, 80.0),
(CoroPriority.WEB_SERVER_BASE, 65.0),
(CoroPriority.CAPTIVE_PORTAL, 64.0),
(CoroPriority.COMMUNICATION, 60.0), (CoroPriority.COMMUNICATION, 60.0),
(CoroPriority.NETWORK_SERVICES, 55.0),
(CoroPriority.OTA_UPDATES, 54.0),
(CoroPriority.WEB_SERVER_OTA, 52.0),
(CoroPriority.APPLICATION, 50.0), (CoroPriority.APPLICATION, 50.0),
(CoroPriority.WEB, 40.0), (CoroPriority.WEB, 40.0),
(CoroPriority.AUTOMATION, 30.0), (CoroPriority.AUTOMATION, 30.0),
@@ -164,8 +174,13 @@ def test_enum_priority_comparison() -> None:
assert CoroPriority.NETWORK_TRANSPORT > CoroPriority.CORE assert CoroPriority.NETWORK_TRANSPORT > CoroPriority.CORE
assert CoroPriority.CORE > CoroPriority.DIAGNOSTICS assert CoroPriority.CORE > CoroPriority.DIAGNOSTICS
assert CoroPriority.DIAGNOSTICS > CoroPriority.STATUS assert CoroPriority.DIAGNOSTICS > CoroPriority.STATUS
assert CoroPriority.STATUS > CoroPriority.COMMUNICATION assert CoroPriority.STATUS > CoroPriority.WEB_SERVER_BASE
assert CoroPriority.COMMUNICATION > CoroPriority.APPLICATION assert CoroPriority.WEB_SERVER_BASE > CoroPriority.CAPTIVE_PORTAL
assert CoroPriority.CAPTIVE_PORTAL > CoroPriority.COMMUNICATION
assert CoroPriority.COMMUNICATION > CoroPriority.NETWORK_SERVICES
assert CoroPriority.NETWORK_SERVICES > CoroPriority.OTA_UPDATES
assert CoroPriority.OTA_UPDATES > CoroPriority.WEB_SERVER_OTA
assert CoroPriority.WEB_SERVER_OTA > CoroPriority.APPLICATION
assert CoroPriority.APPLICATION > CoroPriority.WEB assert CoroPriority.APPLICATION > CoroPriority.WEB
assert CoroPriority.WEB > CoroPriority.AUTOMATION assert CoroPriority.WEB > CoroPriority.AUTOMATION
assert CoroPriority.AUTOMATION > CoroPriority.BUS assert CoroPriority.AUTOMATION > CoroPriority.BUS

View File

@@ -42,7 +42,7 @@ def test_is_file_recent_with_recent_file(setup_core: Path) -> None:
refresh = TimePeriod(seconds=3600) refresh = TimePeriod(seconds=3600)
result = external_files.is_file_recent(str(test_file), refresh) result = external_files.is_file_recent(test_file, refresh)
assert result is True assert result is True
@@ -53,11 +53,13 @@ def test_is_file_recent_with_old_file(setup_core: Path) -> None:
test_file.write_text("content") test_file.write_text("content")
old_time = time.time() - 7200 old_time = time.time() - 7200
mock_stat = MagicMock()
mock_stat.st_ctime = old_time
with patch("os.path.getctime", return_value=old_time): with patch.object(Path, "stat", return_value=mock_stat):
refresh = TimePeriod(seconds=3600) refresh = TimePeriod(seconds=3600)
result = external_files.is_file_recent(str(test_file), refresh) result = external_files.is_file_recent(test_file, refresh)
assert result is False assert result is False
@@ -67,7 +69,7 @@ def test_is_file_recent_nonexistent_file(setup_core: Path) -> None:
test_file = setup_core / "nonexistent.txt" test_file = setup_core / "nonexistent.txt"
refresh = TimePeriod(seconds=3600) refresh = TimePeriod(seconds=3600)
result = external_files.is_file_recent(str(test_file), refresh) result = external_files.is_file_recent(test_file, refresh)
assert result is False assert result is False
@@ -77,10 +79,12 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None:
test_file = setup_core / "test.txt" test_file = setup_core / "test.txt"
test_file.write_text("content") test_file.write_text("content")
# Mock getctime to return a time 10 seconds ago # Mock stat to return a time 10 seconds ago
with patch("os.path.getctime", return_value=time.time() - 10): mock_stat = MagicMock()
mock_stat.st_ctime = time.time() - 10
with patch.object(Path, "stat", return_value=mock_stat):
refresh = TimePeriod(seconds=0) refresh = TimePeriod(seconds=0)
result = external_files.is_file_recent(str(test_file), refresh) result = external_files.is_file_recent(test_file, refresh)
assert result is False assert result is False
@@ -97,7 +101,7 @@ def test_has_remote_file_changed_not_modified(
mock_head.return_value = mock_response mock_head.return_value = mock_response
url = "https://example.com/file.txt" url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, str(test_file)) result = external_files.has_remote_file_changed(url, test_file)
assert result is False assert result is False
mock_head.assert_called_once() mock_head.assert_called_once()
@@ -121,7 +125,7 @@ def test_has_remote_file_changed_modified(
mock_head.return_value = mock_response mock_head.return_value = mock_response
url = "https://example.com/file.txt" url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, str(test_file)) result = external_files.has_remote_file_changed(url, test_file)
assert result is True assert result is True
@@ -131,7 +135,7 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None:
test_file = setup_core / "nonexistent.txt" test_file = setup_core / "nonexistent.txt"
url = "https://example.com/file.txt" url = "https://example.com/file.txt"
result = external_files.has_remote_file_changed(url, str(test_file)) result = external_files.has_remote_file_changed(url, test_file)
assert result is True assert result is True
@@ -149,7 +153,7 @@ def test_has_remote_file_changed_network_error(
url = "https://example.com/file.txt" url = "https://example.com/file.txt"
with pytest.raises(Invalid, match="Could not check if.*Network error"): with pytest.raises(Invalid, match="Could not check if.*Network error"):
external_files.has_remote_file_changed(url, str(test_file)) external_files.has_remote_file_changed(url, test_file)
@patch("esphome.external_files.requests.head") @patch("esphome.external_files.requests.head")
@@ -165,7 +169,7 @@ def test_has_remote_file_changed_timeout(
mock_head.return_value = mock_response mock_head.return_value = mock_response
url = "https://example.com/file.txt" url = "https://example.com/file.txt"
external_files.has_remote_file_changed(url, str(test_file)) external_files.has_remote_file_changed(url, test_file)
call_args = mock_head.call_args call_args = mock_head.call_args
assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT
@@ -191,6 +195,6 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None:
refresh = TimePeriod(seconds=3600.5) refresh = TimePeriod(seconds=3600.5)
result = external_files.is_file_recent(str(test_file), refresh) result = external_files.is_file_recent(test_file, refresh)
assert result is True assert result is True

View File

@@ -154,11 +154,11 @@ def test_walk_files(fixture_path):
actual = list(helpers.walk_files(path)) actual = list(helpers.walk_files(path))
# Ensure paths start with the root # Ensure paths start with the root
assert all(p.startswith(str(path)) for p in actual) assert all(p.is_relative_to(path) for p in actual)
class Test_write_file_if_changed: class Test_write_file_if_changed:
def test_src_and_dst_match(self, tmp_path): def test_src_and_dst_match(self, tmp_path: Path):
text = "A files are unique.\n" text = "A files are unique.\n"
initial = text initial = text
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -168,7 +168,7 @@ class Test_write_file_if_changed:
assert dst.read_text() == text assert dst.read_text() == text
def test_src_and_dst_do_not_match(self, tmp_path): def test_src_and_dst_do_not_match(self, tmp_path: Path):
text = "A files are unique.\n" text = "A files are unique.\n"
initial = "B files are unique.\n" initial = "B files are unique.\n"
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -178,7 +178,7 @@ class Test_write_file_if_changed:
assert dst.read_text() == text assert dst.read_text() == text
def test_dst_does_not_exist(self, tmp_path): def test_dst_does_not_exist(self, tmp_path: Path):
text = "A files are unique.\n" text = "A files are unique.\n"
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -188,7 +188,7 @@ class Test_write_file_if_changed:
class Test_copy_file_if_changed: class Test_copy_file_if_changed:
def test_src_and_dst_match(self, tmp_path, fixture_path): def test_src_and_dst_match(self, tmp_path: Path, fixture_path: Path):
src = fixture_path / "helpers" / "file-a.txt" src = fixture_path / "helpers" / "file-a.txt"
initial = fixture_path / "helpers" / "file-a.txt" initial = fixture_path / "helpers" / "file-a.txt"
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -197,7 +197,7 @@ class Test_copy_file_if_changed:
helpers.copy_file_if_changed(src, dst) helpers.copy_file_if_changed(src, dst)
def test_src_and_dst_do_not_match(self, tmp_path, fixture_path): def test_src_and_dst_do_not_match(self, tmp_path: Path, fixture_path: Path):
src = fixture_path / "helpers" / "file-a.txt" src = fixture_path / "helpers" / "file-a.txt"
initial = fixture_path / "helpers" / "file-c.txt" initial = fixture_path / "helpers" / "file-c.txt"
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -208,7 +208,7 @@ class Test_copy_file_if_changed:
assert src.read_text() == dst.read_text() assert src.read_text() == dst.read_text()
def test_dst_does_not_exist(self, tmp_path, fixture_path): def test_dst_does_not_exist(self, tmp_path: Path, fixture_path: Path):
src = fixture_path / "helpers" / "file-a.txt" src = fixture_path / "helpers" / "file-a.txt"
dst = tmp_path / "file-a.txt" dst = tmp_path / "file-a.txt"
@@ -604,9 +604,8 @@ def test_mkdir_p_with_existing_file_raises_error(tmp_path: Path) -> None:
helpers.mkdir_p(dir_path) helpers.mkdir_p(dir_path)
@pytest.mark.skipif(os.name == "nt", reason="Unix-specific test") def test_read_file(tmp_path: Path) -> None:
def test_read_file_unix(tmp_path: Path) -> None: """Test read_file reads file content correctly."""
"""Test read_file reads file content correctly on Unix."""
# Test reading regular file # Test reading regular file
test_file = tmp_path / "test.txt" test_file = tmp_path / "test.txt"
expected_content = "Test content\nLine 2\n" expected_content = "Test content\nLine 2\n"
@@ -624,31 +623,10 @@ def test_read_file_unix(tmp_path: Path) -> None:
assert content == utf8_content assert content == utf8_content
@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test")
def test_read_file_windows(tmp_path: Path) -> None:
"""Test read_file reads file content correctly on Windows."""
# Test reading regular file
test_file = tmp_path / "test.txt"
expected_content = "Test content\nLine 2\n"
test_file.write_text(expected_content)
content = helpers.read_file(test_file)
# On Windows, text mode reading converts \n to \r\n
assert content == expected_content.replace("\n", "\r\n")
# Test reading file with UTF-8 characters
utf8_file = tmp_path / "utf8.txt"
utf8_content = "Hello 世界 🌍"
utf8_file.write_text(utf8_content, encoding="utf-8")
content = helpers.read_file(utf8_file)
assert content == utf8_content
def test_read_file_not_found() -> None: def test_read_file_not_found() -> None:
"""Test read_file raises error for non-existent file.""" """Test read_file raises error for non-existent file."""
with pytest.raises(EsphomeError, match=r"Error reading file"): with pytest.raises(EsphomeError, match=r"Error reading file"):
helpers.read_file("/nonexistent/file.txt") helpers.read_file(Path("/nonexistent/file.txt"))
def test_read_file_unicode_decode_error(tmp_path: Path) -> None: def test_read_file_unicode_decode_error(tmp_path: Path) -> None:

View File

@@ -885,7 +885,7 @@ def test_upload_program_ota_success(
assert exit_code == 0 assert exit_code == 0
assert host == "192.168.1.100" assert host == "192.168.1.100"
expected_firmware = str( expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
) )
mock_run_ota.assert_called_once_with( mock_run_ota.assert_called_once_with(
@@ -919,7 +919,9 @@ def test_upload_program_ota_with_file_arg(
assert exit_code == 0 assert exit_code == 0
assert host == "192.168.1.100" assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", "custom.bin") mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, "", Path("custom.bin")
)
def test_upload_program_ota_no_config( def test_upload_program_ota_no_config(
@@ -972,7 +974,7 @@ def test_upload_program_ota_with_mqtt_resolution(
assert exit_code == 0 assert exit_code == 0
assert host == "192.168.1.100" assert host == "192.168.1.100"
mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client")
expected_firmware = str( expected_firmware = (
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
) )
mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware)
@@ -1382,7 +1384,7 @@ def test_command_wizard(tmp_path: Path) -> None:
result = command_wizard(args) result = command_wizard(args)
assert result == 0 assert result == 0
mock_wizard.assert_called_once_with(str(config_file)) mock_wizard.assert_called_once_with(config_file)
def test_command_rename_invalid_characters( def test_command_rename_invalid_characters(
@@ -1407,7 +1409,7 @@ def test_command_rename_complex_yaml(
config_file = tmp_path / "test.yaml" config_file = tmp_path / "test.yaml"
config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n")
setup_core(tmp_path=tmp_path) setup_core(tmp_path=tmp_path)
CORE.config_path = str(config_file) CORE.config_path = config_file
args = MockArgs(name="newname") args = MockArgs(name="newname")
result = command_rename(args, {}) result = command_rename(args, {})
@@ -1436,7 +1438,7 @@ wifi:
password: "test1234" password: "test1234"
""") """)
setup_core(tmp_path=tmp_path) setup_core(tmp_path=tmp_path)
CORE.config_path = str(config_file) CORE.config_path = config_file
# Set up CORE.config to avoid ValueError when accessing CORE.address # Set up CORE.config to avoid ValueError when accessing CORE.address
CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}}
@@ -1486,7 +1488,7 @@ esp32:
board: nodemcu-32s board: nodemcu-32s
""") """)
setup_core(tmp_path=tmp_path) setup_core(tmp_path=tmp_path)
CORE.config_path = str(config_file) CORE.config_path = config_file
# Set up CORE.config to avoid ValueError when accessing CORE.address # Set up CORE.config to avoid ValueError when accessing CORE.address
CORE.config = { CORE.config = {
@@ -1523,7 +1525,7 @@ esp32:
board: nodemcu-32s board: nodemcu-32s
""") """)
setup_core(tmp_path=tmp_path) setup_core(tmp_path=tmp_path)
CORE.config_path = str(config_file) CORE.config_path = config_file
args = MockArgs(name="newname", dashboard=False) args = MockArgs(name="newname", dashboard=False)

View File

@@ -15,45 +15,45 @@ from esphome.core import CORE, EsphomeError
def test_idedata_firmware_elf_path(setup_core: Path) -> None: def test_idedata_firmware_elf_path(setup_core: Path) -> None:
"""Test IDEData.firmware_elf_path returns correct path.""" """Test IDEData.firmware_elf_path returns correct path."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
raw_data = {"prog_path": "/path/to/firmware.elf"} raw_data = {"prog_path": "/path/to/firmware.elf"}
idedata = platformio_api.IDEData(raw_data) idedata = platformio_api.IDEData(raw_data)
assert idedata.firmware_elf_path == "/path/to/firmware.elf" assert idedata.firmware_elf_path == Path("/path/to/firmware.elf")
def test_idedata_firmware_bin_path(setup_core: Path) -> None: def test_idedata_firmware_bin_path(setup_core: Path) -> None:
"""Test IDEData.firmware_bin_path returns Path with .bin extension.""" """Test IDEData.firmware_bin_path returns Path with .bin extension."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
prog_path = str(Path("/path/to/firmware.elf")) prog_path = str(Path("/path/to/firmware.elf"))
raw_data = {"prog_path": prog_path} raw_data = {"prog_path": prog_path}
idedata = platformio_api.IDEData(raw_data) idedata = platformio_api.IDEData(raw_data)
result = idedata.firmware_bin_path result = idedata.firmware_bin_path
assert isinstance(result, str) assert isinstance(result, Path)
expected = str(Path("/path/to/firmware.bin")) expected = Path("/path/to/firmware.bin")
assert result == expected assert result == expected
assert result.endswith(".bin") assert str(result).endswith(".bin")
def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None: def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None:
"""Test firmware_bin_path preserves the directory structure.""" """Test firmware_bin_path preserves the directory structure."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
prog_path = str(Path("/complex/path/to/build/firmware.elf")) prog_path = str(Path("/complex/path/to/build/firmware.elf"))
raw_data = {"prog_path": prog_path} raw_data = {"prog_path": prog_path}
idedata = platformio_api.IDEData(raw_data) idedata = platformio_api.IDEData(raw_data)
result = idedata.firmware_bin_path result = idedata.firmware_bin_path
expected = str(Path("/complex/path/to/build/firmware.bin")) expected = Path("/complex/path/to/build/firmware.bin")
assert result == expected assert result == expected
def test_idedata_extra_flash_images(setup_core: Path) -> None: def test_idedata_extra_flash_images(setup_core: Path) -> None:
"""Test IDEData.extra_flash_images returns list of FlashImage objects.""" """Test IDEData.extra_flash_images returns list of FlashImage objects."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
raw_data = { raw_data = {
"prog_path": "/path/to/firmware.elf", "prog_path": "/path/to/firmware.elf",
@@ -69,15 +69,15 @@ def test_idedata_extra_flash_images(setup_core: Path) -> None:
images = idedata.extra_flash_images images = idedata.extra_flash_images
assert len(images) == 2 assert len(images) == 2
assert all(isinstance(img, platformio_api.FlashImage) for img in images) assert all(isinstance(img, platformio_api.FlashImage) for img in images)
assert images[0].path == "/path/to/bootloader.bin" assert images[0].path == Path("/path/to/bootloader.bin")
assert images[0].offset == "0x1000" assert images[0].offset == "0x1000"
assert images[1].path == "/path/to/partition.bin" assert images[1].path == Path("/path/to/partition.bin")
assert images[1].offset == "0x8000" assert images[1].offset == "0x8000"
def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: def test_idedata_extra_flash_images_empty(setup_core: Path) -> None:
"""Test extra_flash_images returns empty list when no extra images.""" """Test extra_flash_images returns empty list when no extra images."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}}
idedata = platformio_api.IDEData(raw_data) idedata = platformio_api.IDEData(raw_data)
@@ -88,7 +88,7 @@ def test_idedata_extra_flash_images_empty(setup_core: Path) -> None:
def test_idedata_cc_path(setup_core: Path) -> None: def test_idedata_cc_path(setup_core: Path) -> None:
"""Test IDEData.cc_path returns compiler path.""" """Test IDEData.cc_path returns compiler path."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
raw_data = { raw_data = {
"prog_path": "/path/to/firmware.elf", "prog_path": "/path/to/firmware.elf",
@@ -104,9 +104,9 @@ def test_idedata_cc_path(setup_core: Path) -> None:
def test_flash_image_dataclass() -> None: def test_flash_image_dataclass() -> None:
"""Test FlashImage dataclass stores path and offset correctly.""" """Test FlashImage dataclass stores path and offset correctly."""
image = platformio_api.FlashImage(path="/path/to/image.bin", offset="0x10000") image = platformio_api.FlashImage(path=Path("/path/to/image.bin"), offset="0x10000")
assert image.path == "/path/to/image.bin" assert image.path == Path("/path/to/image.bin")
assert image.offset == "0x10000" assert image.offset == "0x10000"
@@ -114,7 +114,7 @@ def test_load_idedata_returns_dict(
setup_core: Path, mock_run_platformio_cli_run setup_core: Path, mock_run_platformio_cli_run
) -> None: ) -> None:
"""Test _load_idedata returns parsed idedata dict when successful.""" """Test _load_idedata returns parsed idedata dict when successful."""
CORE.build_path = str(setup_core / "build" / "test") CORE.build_path = setup_core / "build" / "test"
CORE.name = "test" CORE.name = "test"
# Create required files # Create required files
@@ -366,7 +366,7 @@ def test_get_idedata_caches_result(
assert result1 is result2 assert result1 is result2
assert isinstance(result1, platformio_api.IDEData) assert isinstance(result1, platformio_api.IDEData)
assert result1.firmware_elf_path == "/test/firmware.elf" assert result1.firmware_elf_path == Path("/test/firmware.elf")
def test_idedata_addr2line_path_windows(setup_core: Path) -> None: def test_idedata_addr2line_path_windows(setup_core: Path) -> None:
@@ -434,9 +434,9 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
os.utime(platformio_ini, (build_mtime + 1, build_mtime + 1)) os.utime(platformio_ini, (build_mtime + 1, build_mtime + 1))
# Track if directory was removed # Track if directory was removed
removed_paths: list[str] = [] removed_paths: list[Path] = []
def track_rmtree(path: str) -> None: def track_rmtree(path: Path) -> None:
removed_paths.append(path) removed_paths.append(path)
shutil.rmtree(path) shutil.rmtree(path)
@@ -466,7 +466,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
# Verify directory was removed and recreated # Verify directory was removed and recreated
assert len(removed_paths) == 1 assert len(removed_paths) == 1
assert removed_paths[0] == str(build_dir) assert removed_paths[0] == build_dir
assert build_dir.exists() # makedirs recreated it assert build_dir.exists() # makedirs recreated it

View File

@@ -15,12 +15,12 @@ from esphome.core import CORE
def test_storage_path(setup_core: Path) -> None: def test_storage_path(setup_core: Path) -> None:
"""Test storage_path returns correct path for current config.""" """Test storage_path returns correct path for current config."""
CORE.config_path = str(setup_core / "my_device.yaml") CORE.config_path = setup_core / "my_device.yaml"
result = storage_json.storage_path() result = storage_json.storage_path()
data_dir = Path(CORE.data_dir) data_dir = Path(CORE.data_dir)
expected = str(data_dir / "storage" / "my_device.yaml.json") expected = data_dir / "storage" / "my_device.yaml.json"
assert result == expected assert result == expected
@@ -29,20 +29,20 @@ def test_ext_storage_path(setup_core: Path) -> None:
result = storage_json.ext_storage_path("other_device.yaml") result = storage_json.ext_storage_path("other_device.yaml")
data_dir = Path(CORE.data_dir) data_dir = Path(CORE.data_dir)
expected = str(data_dir / "storage" / "other_device.yaml.json") expected = data_dir / "storage" / "other_device.yaml.json"
assert result == expected assert result == expected
def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None:
"""Test ext_storage_path works with different file extensions.""" """Test ext_storage_path works with different file extensions."""
result_yml = storage_json.ext_storage_path("device.yml") result_yml = storage_json.ext_storage_path("device.yml")
assert result_yml.endswith("device.yml.json") assert str(result_yml).endswith("device.yml.json")
result_no_ext = storage_json.ext_storage_path("device") result_no_ext = storage_json.ext_storage_path("device")
assert result_no_ext.endswith("device.json") assert str(result_no_ext).endswith("device.json")
result_path = storage_json.ext_storage_path("my/device.yaml") result_path = storage_json.ext_storage_path("my/device.yaml")
assert result_path.endswith("device.yaml.json") assert str(result_path).endswith("device.yaml.json")
def test_esphome_storage_path(setup_core: Path) -> None: def test_esphome_storage_path(setup_core: Path) -> None:
@@ -50,7 +50,7 @@ def test_esphome_storage_path(setup_core: Path) -> None:
result = storage_json.esphome_storage_path() result = storage_json.esphome_storage_path()
data_dir = Path(CORE.data_dir) data_dir = Path(CORE.data_dir)
expected = str(data_dir / "esphome.json") expected = data_dir / "esphome.json"
assert result == expected assert result == expected
@@ -59,27 +59,27 @@ def test_ignored_devices_storage_path(setup_core: Path) -> None:
result = storage_json.ignored_devices_storage_path() result = storage_json.ignored_devices_storage_path()
data_dir = Path(CORE.data_dir) data_dir = Path(CORE.data_dir)
expected = str(data_dir / "ignored-devices.json") expected = data_dir / "ignored-devices.json"
assert result == expected assert result == expected
def test_trash_storage_path(setup_core: Path) -> None: def test_trash_storage_path(setup_core: Path) -> None:
"""Test trash_storage_path returns correct path.""" """Test trash_storage_path returns correct path."""
CORE.config_path = str(setup_core / "configs" / "device.yaml") CORE.config_path = setup_core / "configs" / "device.yaml"
result = storage_json.trash_storage_path() result = storage_json.trash_storage_path()
expected = str(setup_core / "configs" / "trash") expected = setup_core / "configs" / "trash"
assert result == expected assert result == expected
def test_archive_storage_path(setup_core: Path) -> None: def test_archive_storage_path(setup_core: Path) -> None:
"""Test archive_storage_path returns correct path.""" """Test archive_storage_path returns correct path."""
CORE.config_path = str(setup_core / "configs" / "device.yaml") CORE.config_path = setup_core / "configs" / "device.yaml"
result = storage_json.archive_storage_path() result = storage_json.archive_storage_path()
expected = str(setup_core / "configs" / "archive") expected = setup_core / "configs" / "archive"
assert result == expected assert result == expected
@@ -87,12 +87,12 @@ def test_storage_path_with_subdirectory(setup_core: Path) -> None:
"""Test storage paths work correctly when config is in subdirectory.""" """Test storage paths work correctly when config is in subdirectory."""
subdir = setup_core / "configs" / "basement" subdir = setup_core / "configs" / "basement"
subdir.mkdir(parents=True, exist_ok=True) subdir.mkdir(parents=True, exist_ok=True)
CORE.config_path = str(subdir / "sensor.yaml") CORE.config_path = subdir / "sensor.yaml"
result = storage_json.storage_path() result = storage_json.storage_path()
data_dir = Path(CORE.data_dir) data_dir = Path(CORE.data_dir)
expected = str(data_dir / "storage" / "sensor.yaml.json") expected = data_dir / "storage" / "sensor.yaml.json"
assert result == expected assert result == expected
@@ -173,16 +173,16 @@ def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) ->
"""Test storage paths when running as Home Assistant addon.""" """Test storage paths when running as Home Assistant addon."""
mock_is_ha_addon.return_value = True mock_is_ha_addon.return_value = True
CORE.config_path = str(tmp_path / "test.yaml") CORE.config_path = tmp_path / "test.yaml"
result = storage_json.storage_path() result = storage_json.storage_path()
# When is_ha_addon is True, CORE.data_dir returns "/data" # When is_ha_addon is True, CORE.data_dir returns "/data"
# This is the standard mount point for HA addon containers # This is the standard mount point for HA addon containers
expected = str(Path("/data") / "storage" / "test.yaml.json") expected = Path("/data") / "storage" / "test.yaml.json"
assert result == expected assert result == expected
result = storage_json.esphome_storage_path() result = storage_json.esphome_storage_path()
expected = str(Path("/data") / "esphome.json") expected = Path("/data") / "esphome.json"
assert result == expected assert result == expected
@@ -375,7 +375,7 @@ def test_storage_json_load_valid_file(tmp_path: Path) -> None:
file_path = tmp_path / "storage.json" file_path = tmp_path / "storage.json"
file_path.write_text(json.dumps(storage_data)) file_path.write_text(json.dumps(storage_data))
result = storage_json.StorageJSON.load(str(file_path)) result = storage_json.StorageJSON.load(file_path)
assert result is not None assert result is not None
assert result.name == "loaded_device" assert result.name == "loaded_device"
@@ -386,8 +386,8 @@ def test_storage_json_load_valid_file(tmp_path: Path) -> None:
assert result.address == "10.0.0.1" assert result.address == "10.0.0.1"
assert result.web_port == 8080 assert result.web_port == 8080
assert result.target_platform == "ESP32" assert result.target_platform == "ESP32"
assert result.build_path == "/loaded/build" assert result.build_path == Path("/loaded/build")
assert result.firmware_bin_path == "/loaded/firmware.bin" assert result.firmware_bin_path == Path("/loaded/firmware.bin")
assert result.loaded_integrations == {"wifi", "api"} assert result.loaded_integrations == {"wifi", "api"}
assert result.loaded_platforms == {"sensor"} assert result.loaded_platforms == {"sensor"}
assert result.no_mdns is True assert result.no_mdns is True
@@ -400,7 +400,7 @@ def test_storage_json_load_invalid_file(tmp_path: Path) -> None:
file_path = tmp_path / "invalid.json" file_path = tmp_path / "invalid.json"
file_path.write_text("not valid json{") file_path.write_text("not valid json{")
result = storage_json.StorageJSON.load(str(file_path)) result = storage_json.StorageJSON.load(file_path)
assert result is None assert result is None
@@ -654,7 +654,7 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
file_path = tmp_path / "legacy.json" file_path = tmp_path / "legacy.json"
file_path.write_text(json.dumps(storage_data)) file_path.write_text(json.dumps(storage_data))
result = storage_json.StorageJSON.load(str(file_path)) result = storage_json.StorageJSON.load(file_path)
assert result is not None assert result is not None
assert result.esphome_version == "1.14.0" # Should map to esphome_version assert result.esphome_version == "1.14.0" # Should map to esphome_version

View File

@@ -1,6 +1,6 @@
import glob import glob
import logging import logging
import os from pathlib import Path
from esphome import yaml_util from esphome import yaml_util
from esphome.components import substitutions from esphome.components import substitutions
@@ -52,9 +52,8 @@ def dict_diff(a, b, path=""):
return diffs return diffs
def write_yaml(path, data): def write_yaml(path: Path, data: dict) -> None:
with open(path, "w", encoding="utf-8") as f: path.write_text(yaml_util.dump(data), encoding="utf-8")
f.write(yaml_util.dump(data))
def test_substitutions_fixtures(fixture_path): def test_substitutions_fixtures(fixture_path):
@@ -64,11 +63,10 @@ def test_substitutions_fixtures(fixture_path):
failures = [] failures = []
for source_path in sources: for source_path in sources:
source_path = Path(source_path)
try: try:
expected_path = source_path.replace(".input.yaml", ".approved.yaml") expected_path = source_path.with_suffix("").with_suffix(".approved.yaml")
test_case = os.path.splitext(os.path.basename(source_path))[0].replace( test_case = source_path.with_suffix("").stem
".input", ""
)
# Load using ESPHome's YAML loader # Load using ESPHome's YAML loader
config = yaml_util.load_yaml(source_path) config = yaml_util.load_yaml(source_path)
@@ -81,12 +79,12 @@ def test_substitutions_fixtures(fixture_path):
substitutions.do_substitution_pass(config, None) substitutions.do_substitution_pass(config, None)
# Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE
if os.path.isfile(expected_path): if expected_path.is_file():
expected = yaml_util.load_yaml(expected_path) expected = yaml_util.load_yaml(expected_path)
elif DEV_MODE: elif DEV_MODE:
expected = {} expected = {}
else: else:
assert os.path.isfile(expected_path), ( assert expected_path.is_file(), (
f"Expected file missing: {expected_path}" f"Expected file missing: {expected_path}"
) )
@@ -97,16 +95,14 @@ def test_substitutions_fixtures(fixture_path):
if got_sorted != expected_sorted: if got_sorted != expected_sorted:
diff = "\n".join(dict_diff(got_sorted, expected_sorted)) diff = "\n".join(dict_diff(got_sorted, expected_sorted))
msg = ( msg = (
f"Substitution result mismatch for {os.path.basename(source_path)}\n" f"Substitution result mismatch for {source_path.name}\n"
f"Diff:\n{diff}\n\n" f"Diff:\n{diff}\n\n"
f"Got: {got_sorted}\n" f"Got: {got_sorted}\n"
f"Expected: {expected_sorted}" f"Expected: {expected_sorted}"
) )
# Write out the received file when test fails # Write out the received file when test fails
if DEV_MODE: if DEV_MODE:
received_path = os.path.join( received_path = source_path.with_name(f"{test_case}.received.yaml")
os.path.dirname(source_path), f"{test_case}.received.yaml"
)
write_yaml(received_path, config) write_yaml(received_path, config)
print(msg) print(msg)
failures.append(msg) failures.append(msg)

View File

@@ -32,21 +32,21 @@ def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None:
# Test with mixed input (directories and files) # Test with mixed input (directories and files)
configs = [ configs = [
str(dir1), dir1,
str(standalone1), standalone1,
str(dir2), dir2,
str(standalone2), standalone2,
] ]
result = util.list_yaml_files(configs) result = util.list_yaml_files(configs)
# Should include all YAML files but not the .txt file # Should include all YAML files but not the .txt file
assert set(result) == { assert set(result) == {
str(dir1 / "config1.yaml"), dir1 / "config1.yaml",
str(dir1 / "config2.yml"), dir1 / "config2.yml",
str(dir2 / "config3.yaml"), dir2 / "config3.yaml",
str(standalone1), standalone1,
str(standalone2), standalone2,
} }
# Check that results are sorted # Check that results are sorted
assert result == sorted(result) assert result == sorted(result)
@@ -63,12 +63,12 @@ def test_list_yaml_files_only_directories(tmp_path: Path) -> None:
(dir1 / "b.yml").write_text("test: b") (dir1 / "b.yml").write_text("test: b")
(dir2 / "c.yaml").write_text("test: c") (dir2 / "c.yaml").write_text("test: c")
result = util.list_yaml_files([str(dir1), str(dir2)]) result = util.list_yaml_files([dir1, dir2])
assert set(result) == { assert set(result) == {
str(dir1 / "a.yaml"), dir1 / "a.yaml",
str(dir1 / "b.yml"), dir1 / "b.yml",
str(dir2 / "c.yaml"), dir2 / "c.yaml",
} }
assert result == sorted(result) assert result == sorted(result)
@@ -88,17 +88,17 @@ def test_list_yaml_files_only_files(tmp_path: Path) -> None:
# Include a non-YAML file to test filtering # Include a non-YAML file to test filtering
result = util.list_yaml_files( result = util.list_yaml_files(
[ [
str(file1), file1,
str(file2), file2,
str(file3), file3,
str(non_yaml), non_yaml,
] ]
) )
assert set(result) == { assert set(result) == {
str(file1), file1,
str(file2), file2,
str(file3), file3,
} }
assert result == sorted(result) assert result == sorted(result)
@@ -108,7 +108,7 @@ def test_list_yaml_files_empty_directory(tmp_path: Path) -> None:
empty_dir = tmp_path / "empty" empty_dir = tmp_path / "empty"
empty_dir.mkdir() empty_dir.mkdir()
result = util.list_yaml_files([str(empty_dir)]) result = util.list_yaml_files([empty_dir])
assert result == [] assert result == []
@@ -121,7 +121,7 @@ def test_list_yaml_files_nonexistent_path(tmp_path: Path) -> None:
# Should raise an error for non-existent directory # Should raise an error for non-existent directory
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
util.list_yaml_files([str(nonexistent), str(existing)]) util.list_yaml_files([nonexistent, existing])
def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None: def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None:
@@ -137,11 +137,11 @@ def test_list_yaml_files_mixed_extensions(tmp_path: Path) -> None:
yml_file.write_text("test: yml") yml_file.write_text("test: yml")
other_file.write_text("test: txt") other_file.write_text("test: txt")
result = util.list_yaml_files([str(dir1)]) result = util.list_yaml_files([dir1])
assert set(result) == { assert set(result) == {
str(yaml_file), yaml_file,
str(yml_file), yml_file,
} }
@@ -174,17 +174,18 @@ def test_list_yaml_files_does_not_recurse_into_subdirectories(tmp_path: Path) ->
assert len(result) == 3 assert len(result) == 3
# Check that only root-level files are found # Check that only root-level files are found
assert str(root / "config1.yaml") in result assert root / "config1.yaml" in result
assert str(root / "config2.yml") in result assert root / "config2.yml" in result
assert str(root / "device.yaml") in result assert root / "device.yaml" in result
# Ensure nested files are NOT found # Ensure nested files are NOT found
for r in result: for r in result:
assert "subdir" not in r r_str = str(r)
assert "deeper" not in r assert "subdir" not in r_str
assert "nested1.yaml" not in r assert "deeper" not in r_str
assert "nested2.yml" not in r assert "nested1.yaml" not in r_str
assert "very_nested.yaml" not in r assert "nested2.yml" not in r_str
assert "very_nested.yaml" not in r_str
def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None: def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None:
@@ -202,10 +203,10 @@ def test_list_yaml_files_excludes_secrets(tmp_path: Path) -> None:
# Should find 2 files (config.yaml and device.yaml), not secrets # Should find 2 files (config.yaml and device.yaml), not secrets
assert len(result) == 2 assert len(result) == 2
assert str(root / "config.yaml") in result assert root / "config.yaml" in result
assert str(root / "device.yaml") in result assert root / "device.yaml" in result
assert str(root / "secrets.yaml") not in result assert root / "secrets.yaml" not in result
assert str(root / "secrets.yml") not in result assert root / "secrets.yml" not in result
def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None: def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None:
@@ -223,93 +224,102 @@ def test_list_yaml_files_excludes_hidden_files(tmp_path: Path) -> None:
# Should find only non-hidden files # Should find only non-hidden files
assert len(result) == 2 assert len(result) == 2
assert str(root / "config.yaml") in result assert root / "config.yaml" in result
assert str(root / "device.yaml") in result assert root / "device.yaml" in result
assert str(root / ".hidden.yaml") not in result assert root / ".hidden.yaml" not in result
assert str(root / ".backup.yml") not in result assert root / ".backup.yml" not in result
def test_filter_yaml_files_basic() -> None: def test_filter_yaml_files_basic() -> None:
"""Test filter_yaml_files function.""" """Test filter_yaml_files function."""
files = [ files = [
"/path/to/config.yaml", Path("/path/to/config.yaml"),
"/path/to/device.yml", Path("/path/to/device.yml"),
"/path/to/readme.txt", Path("/path/to/readme.txt"),
"/path/to/script.py", Path("/path/to/script.py"),
"/path/to/data.json", Path("/path/to/data.json"),
"/path/to/another.yaml", Path("/path/to/another.yaml"),
] ]
result = util.filter_yaml_files(files) result = util.filter_yaml_files(files)
assert len(result) == 3 assert len(result) == 3
assert "/path/to/config.yaml" in result assert Path("/path/to/config.yaml") in result
assert "/path/to/device.yml" in result assert Path("/path/to/device.yml") in result
assert "/path/to/another.yaml" in result assert Path("/path/to/another.yaml") in result
assert "/path/to/readme.txt" not in result assert Path("/path/to/readme.txt") not in result
assert "/path/to/script.py" not in result assert Path("/path/to/script.py") not in result
assert "/path/to/data.json" not in result assert Path("/path/to/data.json") not in result
def test_filter_yaml_files_excludes_secrets() -> None: def test_filter_yaml_files_excludes_secrets() -> None:
"""Test that filter_yaml_files excludes secrets files.""" """Test that filter_yaml_files excludes secrets files."""
files = [ files = [
"/path/to/config.yaml", Path("/path/to/config.yaml"),
"/path/to/secrets.yaml", Path("/path/to/secrets.yaml"),
"/path/to/secrets.yml", Path("/path/to/secrets.yml"),
"/path/to/device.yaml", Path("/path/to/device.yaml"),
"/some/dir/secrets.yaml", Path("/some/dir/secrets.yaml"),
] ]
result = util.filter_yaml_files(files) result = util.filter_yaml_files(files)
assert len(result) == 2 assert len(result) == 2
assert "/path/to/config.yaml" in result assert Path("/path/to/config.yaml") in result
assert "/path/to/device.yaml" in result assert Path("/path/to/device.yaml") in result
assert "/path/to/secrets.yaml" not in result assert Path("/path/to/secrets.yaml") not in result
assert "/path/to/secrets.yml" not in result assert Path("/path/to/secrets.yml") not in result
assert "/some/dir/secrets.yaml" not in result assert Path("/some/dir/secrets.yaml") not in result
def test_filter_yaml_files_excludes_hidden() -> None: def test_filter_yaml_files_excludes_hidden() -> None:
"""Test that filter_yaml_files excludes hidden files.""" """Test that filter_yaml_files excludes hidden files."""
files = [ files = [
"/path/to/config.yaml", Path("/path/to/config.yaml"),
"/path/to/.hidden.yaml", Path("/path/to/.hidden.yaml"),
"/path/to/.backup.yml", Path("/path/to/.backup.yml"),
"/path/to/device.yaml", Path("/path/to/device.yaml"),
"/some/dir/.config.yaml", Path("/some/dir/.config.yaml"),
] ]
result = util.filter_yaml_files(files) result = util.filter_yaml_files(files)
assert len(result) == 2 assert len(result) == 2
assert "/path/to/config.yaml" in result assert Path("/path/to/config.yaml") in result
assert "/path/to/device.yaml" in result assert Path("/path/to/device.yaml") in result
assert "/path/to/.hidden.yaml" not in result assert Path("/path/to/.hidden.yaml") not in result
assert "/path/to/.backup.yml" not in result assert Path("/path/to/.backup.yml") not in result
assert "/some/dir/.config.yaml" not in result assert Path("/some/dir/.config.yaml") not in result
def test_filter_yaml_files_case_sensitive() -> None: def test_filter_yaml_files_case_sensitive() -> None:
"""Test that filter_yaml_files is case-sensitive for extensions.""" """Test that filter_yaml_files is case-sensitive for extensions."""
files = [ files = [
"/path/to/config.yaml", Path("/path/to/config.yaml"),
"/path/to/config.YAML", Path("/path/to/config.YAML"),
"/path/to/config.YML", Path("/path/to/config.YML"),
"/path/to/config.Yaml", Path("/path/to/config.Yaml"),
"/path/to/config.yml", Path("/path/to/config.yml"),
] ]
result = util.filter_yaml_files(files) result = util.filter_yaml_files(files)
# Should only match lowercase .yaml and .yml # Should only match lowercase .yaml and .yml
assert len(result) == 2 assert len(result) == 2
assert "/path/to/config.yaml" in result
assert "/path/to/config.yml" in result # Check the actual suffixes to ensure case-sensitive filtering
assert "/path/to/config.YAML" not in result result_suffixes = [p.suffix for p in result]
assert "/path/to/config.YML" not in result assert ".yaml" in result_suffixes
assert "/path/to/config.Yaml" not in result assert ".yml" in result_suffixes
# Verify the filtered files have the expected names
result_names = [p.name for p in result]
assert "config.yaml" in result_names
assert "config.yml" in result_names
# Ensure uppercase extensions are NOT included
assert "config.YAML" not in result_names
assert "config.YML" not in result_names
assert "config.Yaml" not in result_names
@pytest.mark.parametrize( @pytest.mark.parametrize(

Some files were not shown because too many files have changed in this diff Show More