mirror of
https://github.com/esphome/esphome.git
synced 2025-09-22 13:12:22 +01:00
Merge remote-tracking branch 'upstream/dev' into sha256_ota
This commit is contained in:
@@ -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
|
||||||
|
@@ -114,6 +114,14 @@ class Purpose(StrEnum):
|
|||||||
LOGGING = "logging"
|
LOGGING = "logging"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
|
||||||
|
"""Resolve an address using cache if available, otherwise return the address itself."""
|
||||||
|
if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)):
|
||||||
|
_LOGGER.debug("Using cached addresses for %s: %s", purpose.value, cached)
|
||||||
|
return cached
|
||||||
|
return [address]
|
||||||
|
|
||||||
|
|
||||||
def choose_upload_log_host(
|
def choose_upload_log_host(
|
||||||
default: list[str] | str | None,
|
default: list[str] | str | None,
|
||||||
check_default: str | None,
|
check_default: str | None,
|
||||||
@@ -142,7 +150,7 @@ def choose_upload_log_host(
|
|||||||
(purpose == Purpose.LOGGING and has_api())
|
(purpose == Purpose.LOGGING and has_api())
|
||||||
or (purpose == Purpose.UPLOADING and has_ota())
|
or (purpose == Purpose.UPLOADING and has_ota())
|
||||||
):
|
):
|
||||||
resolved.append(CORE.address)
|
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||||
|
|
||||||
if purpose == Purpose.LOGGING:
|
if purpose == Purpose.LOGGING:
|
||||||
if has_api() and has_mqtt_ip_lookup():
|
if has_api() and has_mqtt_ip_lookup():
|
||||||
@@ -152,15 +160,14 @@ def choose_upload_log_host(
|
|||||||
resolved.append("MQTT")
|
resolved.append("MQTT")
|
||||||
|
|
||||||
if has_api() and has_non_ip_address():
|
if has_api() and has_non_ip_address():
|
||||||
resolved.append(CORE.address)
|
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||||
|
|
||||||
elif purpose == Purpose.UPLOADING:
|
elif purpose == Purpose.UPLOADING:
|
||||||
if has_ota() and has_mqtt_ip_lookup():
|
if has_ota() and has_mqtt_ip_lookup():
|
||||||
resolved.append("MQTTIP")
|
resolved.append("MQTTIP")
|
||||||
|
|
||||||
if has_ota() and has_non_ip_address():
|
if has_ota() and has_non_ip_address():
|
||||||
resolved.append(CORE.address)
|
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
resolved.append(device)
|
resolved.append(device)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
@@ -965,6 +972,18 @@ def parse_args(argv):
|
|||||||
help="Add a substitution",
|
help="Add a substitution",
|
||||||
metavar=("key", "value"),
|
metavar=("key", "value"),
|
||||||
)
|
)
|
||||||
|
options_parser.add_argument(
|
||||||
|
"--mdns-address-cache",
|
||||||
|
help="mDNS address cache mapping in format 'hostname=ip1,ip2'",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
)
|
||||||
|
options_parser.add_argument(
|
||||||
|
"--dns-address-cache",
|
||||||
|
help="DNS address cache mapping in format 'hostname=ip1,ip2'",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description=f"ESPHome {const.__version__}", parents=[options_parser]
|
description=f"ESPHome {const.__version__}", parents=[options_parser]
|
||||||
@@ -1212,9 +1231,15 @@ def parse_args(argv):
|
|||||||
|
|
||||||
|
|
||||||
def run_esphome(argv):
|
def run_esphome(argv):
|
||||||
|
from esphome.address_cache import AddressCache
|
||||||
|
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
CORE.dashboard = args.dashboard
|
CORE.dashboard = args.dashboard
|
||||||
|
|
||||||
|
# Create address cache from command-line arguments
|
||||||
|
CORE.address_cache = AddressCache.from_cli_args(
|
||||||
|
args.mdns_address_cache, args.dns_address_cache
|
||||||
|
)
|
||||||
# Override log level if verbose is set
|
# Override log level if verbose is set
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
args.log_level = "DEBUG"
|
args.log_level = "DEBUG"
|
||||||
|
142
esphome/address_cache.py
Normal file
142
esphome/address_cache.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""Address cache for DNS and mDNS lookups."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_hostname(hostname: str) -> str:
|
||||||
|
"""Normalize hostname for cache lookups.
|
||||||
|
|
||||||
|
Removes trailing dots and converts to lowercase.
|
||||||
|
"""
|
||||||
|
return hostname.rstrip(".").lower()
|
||||||
|
|
||||||
|
|
||||||
|
class AddressCache:
|
||||||
|
"""Cache for DNS and mDNS address lookups.
|
||||||
|
|
||||||
|
This cache stores pre-resolved addresses from command-line arguments
|
||||||
|
to avoid slow DNS/mDNS lookups during builds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mdns_cache: dict[str, list[str]] | None = None,
|
||||||
|
dns_cache: dict[str, list[str]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the address cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mdns_cache: Pre-populated mDNS addresses (hostname -> IPs)
|
||||||
|
dns_cache: Pre-populated DNS addresses (hostname -> IPs)
|
||||||
|
"""
|
||||||
|
self.mdns_cache = mdns_cache or {}
|
||||||
|
self.dns_cache = dns_cache or {}
|
||||||
|
|
||||||
|
def _get_cached_addresses(
|
||||||
|
self, hostname: str, cache: dict[str, list[str]], cache_type: str
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Get cached addresses from a specific cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: The hostname to look up
|
||||||
|
cache: The cache dictionary to check
|
||||||
|
cache_type: Type of cache for logging ("mDNS" or "DNS")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of IP addresses if found in cache, None otherwise
|
||||||
|
"""
|
||||||
|
normalized = normalize_hostname(hostname)
|
||||||
|
if addresses := cache.get(normalized):
|
||||||
|
_LOGGER.debug("Using %s cache for %s: %s", cache_type, hostname, addresses)
|
||||||
|
return addresses
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_mdns_addresses(self, hostname: str) -> list[str] | None:
|
||||||
|
"""Get cached mDNS addresses for a hostname.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: The hostname to look up (should end with .local)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of IP addresses if found in cache, None otherwise
|
||||||
|
"""
|
||||||
|
return self._get_cached_addresses(hostname, self.mdns_cache, "mDNS")
|
||||||
|
|
||||||
|
def get_dns_addresses(self, hostname: str) -> list[str] | None:
|
||||||
|
"""Get cached DNS addresses for a hostname.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: The hostname to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of IP addresses if found in cache, None otherwise
|
||||||
|
"""
|
||||||
|
return self._get_cached_addresses(hostname, self.dns_cache, "DNS")
|
||||||
|
|
||||||
|
def get_addresses(self, hostname: str) -> list[str] | None:
|
||||||
|
"""Get cached addresses for a hostname.
|
||||||
|
|
||||||
|
Checks mDNS cache for .local domains, DNS cache otherwise.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: The hostname to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of IP addresses if found in cache, None otherwise
|
||||||
|
"""
|
||||||
|
normalized = normalize_hostname(hostname)
|
||||||
|
if normalized.endswith(".local"):
|
||||||
|
return self.get_mdns_addresses(hostname)
|
||||||
|
return self.get_dns_addresses(hostname)
|
||||||
|
|
||||||
|
def has_cache(self) -> bool:
|
||||||
|
"""Check if any cache entries exist."""
|
||||||
|
return bool(self.mdns_cache or self.dns_cache)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cli_args(
|
||||||
|
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
|
||||||
|
) -> AddressCache:
|
||||||
|
"""Create cache from command-line arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mdns_args: List of mDNS cache entries like ['host=ip1,ip2']
|
||||||
|
dns_args: List of DNS cache entries like ['host=ip1,ip2']
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured AddressCache instance
|
||||||
|
"""
|
||||||
|
mdns_cache = cls._parse_cache_args(mdns_args)
|
||||||
|
dns_cache = cls._parse_cache_args(dns_args)
|
||||||
|
return cls(mdns_cache=mdns_cache, dns_cache=dns_cache)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_cache_args(cache_args: Iterable[str]) -> dict[str, list[str]]:
|
||||||
|
"""Parse cache arguments into a dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_args: List of cache mappings like ['host1=ip1,ip2', 'host2=ip3']
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping normalized hostnames to list of IP addresses
|
||||||
|
"""
|
||||||
|
cache: dict[str, list[str]] = {}
|
||||||
|
for arg in cache_args:
|
||||||
|
if "=" not in arg:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Invalid cache format: %s (expected 'hostname=ip1,ip2')", arg
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
hostname, ips = arg.split("=", 1)
|
||||||
|
# Normalize hostname for consistent lookups
|
||||||
|
normalized = normalize_hostname(hostname)
|
||||||
|
cache[normalized] = [ip.strip() for ip in ips.split(",")]
|
||||||
|
return cache
|
@@ -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,9 @@ 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"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListEntitiesRequest {
|
message ListEntitiesRequest {
|
||||||
@@ -2276,3 +2282,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;
|
||||||
|
}
|
||||||
|
@@ -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,9 @@ 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();
|
||||||
|
#endif
|
||||||
#ifdef USE_API_NOISE
|
#ifdef USE_API_NOISE
|
||||||
resp.api_encryption_supported = true;
|
resp.api_encryption_supported = true;
|
||||||
#endif
|
#endif
|
||||||
|
@@ -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;
|
||||||
|
@@ -129,6 +129,9 @@ 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
|
||||||
}
|
}
|
||||||
void DeviceInfoResponse::calculate_size(ProtoSize &size) const {
|
void DeviceInfoResponse::calculate_size(ProtoSize &size) const {
|
||||||
#ifdef USE_API_PASSWORD
|
#ifdef USE_API_PASSWORD
|
||||||
@@ -181,6 +184,9 @@ 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_BINARY_SENSOR
|
#ifdef USE_BINARY_SENSOR
|
||||||
void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const {
|
void ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer buffer) const {
|
||||||
@@ -3013,5 +3019,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
|
||||||
|
@@ -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 uint8_t ESTIMATED_SIZE = 252;
|
||||||
#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,9 @@ 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
|
#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 +2922,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
|
||||||
|
@@ -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,9 @@ 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
|
||||||
}
|
}
|
||||||
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 +2122,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
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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))
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
43
esphome/components/zwave_proxy/__init__.py
Normal file
43
esphome/components/zwave_proxy/__init__.py
Normal 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")
|
224
esphome/components/zwave_proxy/zwave_proxy.cpp
Normal file
224
esphome/components/zwave_proxy/zwave_proxy.cpp
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#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";
|
||||||
|
|
||||||
|
ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; }
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
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_, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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->checksum_ ^= 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->checksum_ ^= 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->checksum_ ^= byte;
|
||||||
|
this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD;
|
||||||
|
break;
|
||||||
|
case ZWAVE_PARSING_STATE_WAIT_PAYLOAD:
|
||||||
|
this->buffer_[this->buffer_index_++] = byte;
|
||||||
|
this->checksum_ ^= 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;
|
||||||
|
ESP_LOGVV(TAG, "Received CHECKSUM: 0x%02X", byte);
|
||||||
|
ESP_LOGV(TAG, "Calculated CHECKSUM: 0x%02X", this->checksum_);
|
||||||
|
if (this->checksum_ != byte) {
|
||||||
|
ESP_LOGW(TAG, "Bad checksum: expected 0x%02X, got 0x%02X", this->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_, 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->checksum_ = 0xFF;
|
||||||
|
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
|
73
esphome/components/zwave_proxy/zwave_proxy.h
Normal file
73
esphome/components/zwave_proxy/zwave_proxy.h
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/components/api/api_connection.h"
|
||||||
|
#include "esphome/components/api/api_pb2.h"
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/components/uart/uart.h"
|
||||||
|
|
||||||
|
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 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; }
|
||||||
|
|
||||||
|
void send_frame(const uint8_t *data, size_t length);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
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
|
||||||
|
|
||||||
|
uint8_t buffer_[sizeof(api::ZWaveProxyFrame::data)]; // Fixed buffer for incoming data
|
||||||
|
uint8_t buffer_index_{0}; // Index for populating the data buffer
|
||||||
|
uint8_t checksum_{0}; // Checksum of the frame being parsed
|
||||||
|
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
|
@@ -39,6 +39,8 @@ from esphome.helpers import ensure_unique_string, get_str_env, is_ha_addon
|
|||||||
from esphome.util import OrderedDict
|
from esphome.util import OrderedDict
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from esphome.address_cache import AddressCache
|
||||||
|
|
||||||
from ..cpp_generator import MockObj, MockObjClass, Statement
|
from ..cpp_generator import MockObj, MockObjClass, Statement
|
||||||
from ..types import ConfigType, EntityMetadata
|
from ..types import ConfigType, EntityMetadata
|
||||||
|
|
||||||
@@ -583,6 +585,8 @@ class EsphomeCore:
|
|||||||
self.id_classes = {}
|
self.id_classes = {}
|
||||||
# The current component being processed during validation
|
# The current component being processed during validation
|
||||||
self.current_component: str | None = None
|
self.current_component: str | None = None
|
||||||
|
# Address cache for DNS and mDNS lookups from command line arguments
|
||||||
|
self.address_cache: AddressCache | None = None
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||||
@@ -610,6 +614,7 @@ class EsphomeCore:
|
|||||||
self.platform_counts = defaultdict(int)
|
self.platform_counts = defaultdict(int)
|
||||||
self.unique_ids = {}
|
self.unique_ids = {}
|
||||||
self.current_component = None
|
self.current_component = None
|
||||||
|
self.address_cache = None
|
||||||
PIN_SCHEMA_REGISTRY.reset()
|
PIN_SCHEMA_REGISTRY.reset()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
@@ -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
|
||||||
|
@@ -28,6 +28,21 @@ class DNSCache:
|
|||||||
self._cache: dict[str, tuple[float, list[str] | Exception]] = {}
|
self._cache: dict[str, tuple[float, list[str] | Exception]] = {}
|
||||||
self._ttl = ttl
|
self._ttl = ttl
|
||||||
|
|
||||||
|
def get_cached_addresses(
|
||||||
|
self, hostname: str, now_monotonic: float
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Get cached addresses without triggering resolution.
|
||||||
|
|
||||||
|
Returns None if not in cache, list of addresses if found.
|
||||||
|
"""
|
||||||
|
# Normalize hostname for consistent lookups
|
||||||
|
normalized = hostname.rstrip(".").lower()
|
||||||
|
if expire_time_addresses := self._cache.get(normalized):
|
||||||
|
expire_time, addresses = expire_time_addresses
|
||||||
|
if expire_time > now_monotonic and not isinstance(addresses, Exception):
|
||||||
|
return addresses
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_resolve(
|
async def async_resolve(
|
||||||
self, hostname: str, now_monotonic: float
|
self, hostname: str, now_monotonic: float
|
||||||
) -> list[str] | Exception:
|
) -> list[str] | Exception:
|
||||||
|
@@ -4,6 +4,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
from zeroconf import AddressResolver, IPVersion
|
||||||
|
|
||||||
|
from esphome.address_cache import normalize_hostname
|
||||||
from esphome.zeroconf import (
|
from esphome.zeroconf import (
|
||||||
ESPHOME_SERVICE_TYPE,
|
ESPHOME_SERVICE_TYPE,
|
||||||
AsyncEsphomeZeroconf,
|
AsyncEsphomeZeroconf,
|
||||||
@@ -50,6 +53,30 @@ class MDNSStatus:
|
|||||||
return await aiozc.async_resolve_host(host_name)
|
return await aiozc.async_resolve_host(host_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_cached_addresses(self, host_name: str) -> list[str] | None:
|
||||||
|
"""Get cached addresses for a host without triggering resolution.
|
||||||
|
|
||||||
|
Returns None if not in cache or no zeroconf available.
|
||||||
|
"""
|
||||||
|
if not self.aiozc:
|
||||||
|
_LOGGER.debug("No zeroconf instance available for %s", host_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize hostname and get the base name
|
||||||
|
normalized = normalize_hostname(host_name)
|
||||||
|
base_name = normalized.partition(".")[0]
|
||||||
|
|
||||||
|
# Try to load from zeroconf cache without triggering resolution
|
||||||
|
resolver_name = f"{base_name}.local."
|
||||||
|
info = AddressResolver(resolver_name)
|
||||||
|
# Let zeroconf use its own current time for cache checking
|
||||||
|
if info.load_from_cache(self.aiozc.zeroconf):
|
||||||
|
addresses = info.parsed_scoped_addresses(IPVersion.All)
|
||||||
|
_LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses)
|
||||||
|
return addresses
|
||||||
|
_LOGGER.debug("Not found in zeroconf cache: %s", resolver_name)
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_refresh_hosts(self) -> None:
|
async def async_refresh_hosts(self) -> None:
|
||||||
"""Refresh the hosts to track."""
|
"""Refresh the hosts to track."""
|
||||||
dashboard = self.dashboard
|
dashboard = self.dashboard
|
||||||
|
@@ -50,8 +50,8 @@ from esphome.util import get_serial_ports, shlex_quote
|
|||||||
from esphome.yaml_util import FastestAvailableSafeLoader
|
from esphome.yaml_util import FastestAvailableSafeLoader
|
||||||
|
|
||||||
from .const import DASHBOARD_COMMAND
|
from .const import DASHBOARD_COMMAND
|
||||||
from .core import DASHBOARD
|
from .core import DASHBOARD, ESPHomeDashboard
|
||||||
from .entries import UNKNOWN_STATE, entry_state_to_bool
|
from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool
|
||||||
from .util.file import write_file
|
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
|
||||||
@@ -314,6 +314,73 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def build_cache_arguments(
|
||||||
|
entry: DashboardEntry | None,
|
||||||
|
dashboard: ESPHomeDashboard,
|
||||||
|
now: float,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Build cache arguments for passing to CLI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: Dashboard entry for the configuration
|
||||||
|
dashboard: Dashboard instance with cache access
|
||||||
|
now: Current monotonic time for DNS cache expiry checks
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cache arguments to pass to CLI
|
||||||
|
"""
|
||||||
|
cache_args: list[str] = []
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
return cache_args
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Building cache for entry (address=%s, name=%s)",
|
||||||
|
entry.address,
|
||||||
|
entry.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None:
|
||||||
|
"""Add a cache entry to the command arguments."""
|
||||||
|
if not addresses:
|
||||||
|
return
|
||||||
|
normalized = hostname.rstrip(".").lower()
|
||||||
|
cache_args.extend(
|
||||||
|
[
|
||||||
|
f"--{cache_type}-address-cache",
|
||||||
|
f"{normalized}={','.join(sort_ip_addresses(addresses))}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check entry.address for cached addresses
|
||||||
|
if use_address := entry.address:
|
||||||
|
if use_address.endswith(".local"):
|
||||||
|
# mDNS cache for .local addresses
|
||||||
|
if (mdns := dashboard.mdns_status) and (
|
||||||
|
cached := mdns.get_cached_addresses(use_address)
|
||||||
|
):
|
||||||
|
_LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached)
|
||||||
|
add_cache_entry(use_address, cached, "mdns")
|
||||||
|
# DNS cache for non-.local addresses
|
||||||
|
elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now):
|
||||||
|
_LOGGER.debug("DNS cache hit for %s: %s", use_address, cached)
|
||||||
|
add_cache_entry(use_address, cached, "dns")
|
||||||
|
|
||||||
|
# Check entry.name if we haven't already cached via address
|
||||||
|
# For mDNS devices, entry.name typically doesn't have .local suffix
|
||||||
|
if entry.name and not use_address:
|
||||||
|
mdns_name = (
|
||||||
|
f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name
|
||||||
|
)
|
||||||
|
if (mdns := dashboard.mdns_status) and (
|
||||||
|
cached := mdns.get_cached_addresses(mdns_name)
|
||||||
|
):
|
||||||
|
_LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached)
|
||||||
|
add_cache_entry(mdns_name, cached, "mdns")
|
||||||
|
|
||||||
|
return cache_args
|
||||||
|
|
||||||
|
|
||||||
class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
|
class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
|
||||||
"""Base class for commands that require a port."""
|
"""Base class for commands that require a port."""
|
||||||
|
|
||||||
@@ -326,52 +393,22 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket):
|
|||||||
configuration = json_message["configuration"]
|
configuration = json_message["configuration"]
|
||||||
config_file = settings.rel_path(configuration)
|
config_file = settings.rel_path(configuration)
|
||||||
port = json_message["port"]
|
port = json_message["port"]
|
||||||
addresses: list[str] = []
|
|
||||||
|
# Build cache arguments to pass to CLI
|
||||||
|
cache_args: list[str] = []
|
||||||
|
|
||||||
if (
|
if (
|
||||||
port == "OTA" # pylint: disable=too-many-boolean-expressions
|
port == "OTA" # pylint: disable=too-many-boolean-expressions
|
||||||
and (entry := entries.get(config_file))
|
and (entry := entries.get(config_file))
|
||||||
and entry.loaded_integrations
|
and entry.loaded_integrations
|
||||||
and "api" in entry.loaded_integrations
|
and "api" in entry.loaded_integrations
|
||||||
):
|
):
|
||||||
# First priority: entry.address AKA use_address
|
cache_args = build_cache_arguments(entry, dashboard, time.monotonic())
|
||||||
if (
|
|
||||||
(use_address := entry.address)
|
|
||||||
and (
|
|
||||||
address_list := await dashboard.dns_cache.async_resolve(
|
|
||||||
use_address, time.monotonic()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and not isinstance(address_list, Exception)
|
|
||||||
):
|
|
||||||
addresses.extend(sort_ip_addresses(address_list))
|
|
||||||
|
|
||||||
# Second priority: mDNS
|
# Cache arguments must come before the subcommand
|
||||||
if (
|
cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port]
|
||||||
(mdns := dashboard.mdns_status)
|
_LOGGER.debug("Built command: %s", cmd)
|
||||||
and (address_list := await mdns.async_resolve_host(entry.name))
|
return cmd
|
||||||
and (
|
|
||||||
new_addresses := [
|
|
||||||
addr for addr in address_list if addr not in addresses
|
|
||||||
]
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# Use the IP address if available but only
|
|
||||||
# if the API is loaded and the device is online
|
|
||||||
# since MQTT logging will not work otherwise
|
|
||||||
addresses.extend(sort_ip_addresses(new_addresses))
|
|
||||||
|
|
||||||
if not addresses:
|
|
||||||
# If no address was found, use the port directly
|
|
||||||
# as otherwise they will get the chooser which
|
|
||||||
# does not work with the dashboard as there is no
|
|
||||||
# interactive way to get keyboard input
|
|
||||||
addresses = [port]
|
|
||||||
|
|
||||||
device_args: list[str] = [
|
|
||||||
arg for address in addresses for arg in ("--device", address)
|
|
||||||
]
|
|
||||||
|
|
||||||
return [*DASHBOARD_COMMAND, *args, config_file, *device_args]
|
|
||||||
|
|
||||||
|
|
||||||
class EsphomeLogsHandler(EsphomePortCommandWebSocket):
|
class EsphomeLogsHandler(EsphomePortCommandWebSocket):
|
||||||
|
@@ -332,10 +332,14 @@ 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: str
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
|
from esphome.core import CORE
|
||||||
|
|
||||||
# Handle both single host and list of hosts
|
# Handle both single host and list of hosts
|
||||||
try:
|
try:
|
||||||
# Resolve all hosts at once for parallel DNS resolution
|
# Resolve all hosts at once for parallel DNS resolution
|
||||||
res = resolve_ip_address(remote_host, remote_port)
|
res = resolve_ip_address(
|
||||||
|
remote_host, remote_port, address_cache=CORE.address_cache
|
||||||
|
)
|
||||||
except EsphomeError as err:
|
except EsphomeError as err:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error resolving IP address of %s. Is it connected to WiFi?",
|
"Error resolving IP address of %s. Is it connected to WiFi?",
|
||||||
|
@@ -9,10 +9,14 @@ from pathlib import Path
|
|||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from esphome.const import __version__ as ESPHOME_VERSION
|
from esphome.const import __version__ as ESPHOME_VERSION
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from esphome.address_cache import AddressCache
|
||||||
|
|
||||||
# Type aliases for socket address information
|
# Type aliases for socket address information
|
||||||
AddrInfo = tuple[
|
AddrInfo = tuple[
|
||||||
int, # family (AF_INET, AF_INET6, etc.)
|
int, # family (AF_INET, AF_INET6, etc.)
|
||||||
@@ -173,7 +177,24 @@ def addr_preference_(res: AddrInfo) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]:
|
def _add_ip_addresses_to_addrinfo(
|
||||||
|
addresses: list[str], port: int, res: list[AddrInfo]
|
||||||
|
) -> None:
|
||||||
|
"""Helper to add IP addresses to addrinfo results with error handling."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
for addr in addresses:
|
||||||
|
try:
|
||||||
|
res += socket.getaddrinfo(
|
||||||
|
addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
_LOGGER.debug("Failed to parse IP address '%s'", addr)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ip_address(
|
||||||
|
host: str | list[str], port: int, address_cache: AddressCache | None = None
|
||||||
|
) -> list[AddrInfo]:
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
# There are five cases here. The host argument could be one of:
|
# There are five cases here. The host argument could be one of:
|
||||||
@@ -194,47 +215,69 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]:
|
|||||||
hosts = [host]
|
hosts = [host]
|
||||||
|
|
||||||
res: list[AddrInfo] = []
|
res: list[AddrInfo] = []
|
||||||
|
|
||||||
|
# Fast path: if all hosts are already IP addresses
|
||||||
if all(is_ip_address(h) for h in hosts):
|
if all(is_ip_address(h) for h in hosts):
|
||||||
# Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST
|
_add_ip_addresses_to_addrinfo(hosts, port, res)
|
||||||
for addr in hosts:
|
|
||||||
try:
|
|
||||||
res += socket.getaddrinfo(
|
|
||||||
addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
_LOGGER.debug("Failed to parse IP address '%s'", addr)
|
|
||||||
# Sort by preference
|
# Sort by preference
|
||||||
res.sort(key=addr_preference_)
|
res.sort(key=addr_preference_)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
from esphome.resolver import AsyncResolver
|
# Process hosts
|
||||||
|
cached_addresses: list[str] = []
|
||||||
|
uncached_hosts: list[str] = []
|
||||||
|
has_cache = address_cache is not None
|
||||||
|
|
||||||
resolver = AsyncResolver(hosts, port)
|
for h in hosts:
|
||||||
addr_infos = resolver.resolve()
|
if is_ip_address(h):
|
||||||
# Convert aioesphomeapi AddrInfo to our format
|
if has_cache:
|
||||||
for addr_info in addr_infos:
|
# If we have a cache, treat IPs as cached
|
||||||
sockaddr = addr_info.sockaddr
|
cached_addresses.append(h)
|
||||||
if addr_info.family == socket.AF_INET6:
|
else:
|
||||||
# IPv6
|
# If no cache, pass IPs through to resolver with hostnames
|
||||||
sockaddr_tuple = (
|
uncached_hosts.append(h)
|
||||||
sockaddr.address,
|
elif address_cache and (cached := address_cache.get_addresses(h)):
|
||||||
sockaddr.port,
|
# Found in cache
|
||||||
sockaddr.flowinfo,
|
cached_addresses.extend(cached)
|
||||||
sockaddr.scope_id,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# IPv4
|
# Not cached, need to resolve
|
||||||
sockaddr_tuple = (sockaddr.address, sockaddr.port)
|
if address_cache and address_cache.has_cache():
|
||||||
|
_LOGGER.info("Host %s not in cache, will need to resolve", h)
|
||||||
|
uncached_hosts.append(h)
|
||||||
|
|
||||||
res.append(
|
# Process cached addresses (includes direct IPs and cached lookups)
|
||||||
(
|
_add_ip_addresses_to_addrinfo(cached_addresses, port, res)
|
||||||
addr_info.family,
|
|
||||||
addr_info.type,
|
# If we have uncached hosts (only non-IP hostnames), resolve them
|
||||||
addr_info.proto,
|
if uncached_hosts:
|
||||||
"", # canonname
|
from esphome.resolver import AsyncResolver
|
||||||
sockaddr_tuple,
|
|
||||||
|
resolver = AsyncResolver(uncached_hosts, port)
|
||||||
|
addr_infos = resolver.resolve()
|
||||||
|
# Convert aioesphomeapi AddrInfo to our format
|
||||||
|
for addr_info in addr_infos:
|
||||||
|
sockaddr = addr_info.sockaddr
|
||||||
|
if addr_info.family == socket.AF_INET6:
|
||||||
|
# IPv6
|
||||||
|
sockaddr_tuple = (
|
||||||
|
sockaddr.address,
|
||||||
|
sockaddr.port,
|
||||||
|
sockaddr.flowinfo,
|
||||||
|
sockaddr.scope_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# IPv4
|
||||||
|
sockaddr_tuple = (sockaddr.address, sockaddr.port)
|
||||||
|
|
||||||
|
res.append(
|
||||||
|
(
|
||||||
|
addr_info.family,
|
||||||
|
addr_info.type,
|
||||||
|
addr_info.proto,
|
||||||
|
"", # canonname
|
||||||
|
sockaddr_tuple,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by preference
|
# Sort by preference
|
||||||
res.sort(key=addr_preference_)
|
res.sort(key=addr_preference_)
|
||||||
@@ -256,14 +299,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]:
|
|||||||
# First "resolve" all the IP addresses to getaddrinfo() tuples of the form
|
# First "resolve" all the IP addresses to getaddrinfo() tuples of the form
|
||||||
# (family, type, proto, canonname, sockaddr)
|
# (family, type, proto, canonname, sockaddr)
|
||||||
res: list[AddrInfo] = []
|
res: list[AddrInfo] = []
|
||||||
for addr in address_list:
|
_add_ip_addresses_to_addrinfo(address_list, 0, res)
|
||||||
# This should always work as these are supposed to be IP addresses
|
|
||||||
try:
|
|
||||||
res += socket.getaddrinfo(
|
|
||||||
addr, 0, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
_LOGGER.info("Failed to parse IP address '%s'", addr)
|
|
||||||
|
|
||||||
# Now use that information to sort them.
|
# Now use that information to sort them.
|
||||||
res.sort(key=addr_preference_)
|
res.sort(key=addr_preference_)
|
||||||
|
@@ -1750,13 +1750,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
|
||||||
|
@@ -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:
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
15
tests/components/zwave_proxy/common.yaml
Normal file
15
tests/components/zwave_proxy/common.yaml
Normal 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
|
5
tests/components/zwave_proxy/test.esp32-ard.yaml
Normal file
5
tests/components/zwave_proxy/test.esp32-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
5
tests/components/zwave_proxy/test.esp32-c3-ard.yaml
Normal file
5
tests/components/zwave_proxy/test.esp32-c3-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO4
|
||||||
|
rx_pin: GPIO5
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
5
tests/components/zwave_proxy/test.esp32-c3-idf.yaml
Normal file
5
tests/components/zwave_proxy/test.esp32-c3-idf.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO4
|
||||||
|
rx_pin: GPIO5
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
5
tests/components/zwave_proxy/test.esp32-idf.yaml
Normal file
5
tests/components/zwave_proxy/test.esp32-idf.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
5
tests/components/zwave_proxy/test.esp8266-ard.yaml
Normal file
5
tests/components/zwave_proxy/test.esp8266-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO4
|
||||||
|
rx_pin: GPIO5
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
5
tests/components/zwave_proxy/test.rp2040-ard.yaml
Normal file
5
tests/components/zwave_proxy/test.rp2040-ard.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO4
|
||||||
|
rx_pin: GPIO5
|
||||||
|
|
||||||
|
<<: !include common.yaml
|
21
tests/dashboard/conftest.py
Normal file
21
tests/dashboard/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Common fixtures for dashboard tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome.dashboard.core import ESPHomeDashboard
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_dashboard() -> Mock:
|
||||||
|
"""Create a mock dashboard."""
|
||||||
|
dashboard = Mock(spec=ESPHomeDashboard)
|
||||||
|
dashboard.entries = Mock()
|
||||||
|
dashboard.entries.async_all.return_value = []
|
||||||
|
dashboard.stop_event = Mock()
|
||||||
|
dashboard.stop_event.is_set.return_value = True
|
||||||
|
dashboard.ping_request = Mock()
|
||||||
|
return dashboard
|
0
tests/dashboard/status/__init__.py
Normal file
0
tests/dashboard/status/__init__.py
Normal file
121
tests/dashboard/status/test_dns.py
Normal file
121
tests/dashboard/status/test_dns.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Unit tests for esphome.dashboard.dns module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome.dashboard.dns import DNSCache
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dns_cache_fixture() -> DNSCache:
|
||||||
|
"""Create a DNSCache instance."""
|
||||||
|
return DNSCache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses when hostname is not in cache."""
|
||||||
|
now = time.monotonic()
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses when cache entry is expired."""
|
||||||
|
now = time.monotonic()
|
||||||
|
# Add entry that's already expired
|
||||||
|
dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"])
|
||||||
|
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||||
|
assert result is None
|
||||||
|
# Expired entry should still be in cache (not removed by get_cached_addresses)
|
||||||
|
assert "example.com" in dns_cache_fixture._cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses with valid cache entry."""
|
||||||
|
now = time.monotonic()
|
||||||
|
# Add entry that expires in 60 seconds
|
||||||
|
dns_cache_fixture._cache["example.com"] = (
|
||||||
|
now + 60,
|
||||||
|
["192.168.1.10", "192.168.1.11"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||||
|
assert result == ["192.168.1.10", "192.168.1.11"]
|
||||||
|
# Entry should still be in cache
|
||||||
|
assert "example.com" in dns_cache_fixture._cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_hostname_normalization(
|
||||||
|
dns_cache_fixture: DNSCache,
|
||||||
|
) -> None:
|
||||||
|
"""Test get_cached_addresses normalizes hostname."""
|
||||||
|
now = time.monotonic()
|
||||||
|
# Add entry with lowercase hostname
|
||||||
|
dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"])
|
||||||
|
|
||||||
|
# Test with various forms
|
||||||
|
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [
|
||||||
|
"192.168.1.10"
|
||||||
|
]
|
||||||
|
assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [
|
||||||
|
"192.168.1.10"
|
||||||
|
]
|
||||||
|
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [
|
||||||
|
"192.168.1.10"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses with IPv6 addresses."""
|
||||||
|
now = time.monotonic()
|
||||||
|
dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"])
|
||||||
|
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||||
|
assert result == ["2001:db8::1", "fe80::1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses with empty address list."""
|
||||||
|
now = time.monotonic()
|
||||||
|
dns_cache_fixture._cache["example.com"] = (now + 60, [])
|
||||||
|
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test get_cached_addresses when cache contains an exception."""
|
||||||
|
now = time.monotonic()
|
||||||
|
# Store an exception (from failed resolution)
|
||||||
|
dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed"))
|
||||||
|
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("example.com", now)
|
||||||
|
assert result is None # Should return None for exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
|
||||||
|
"""Test that get_cached_addresses never calls async_resolve."""
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve:
|
||||||
|
# Test non-cached
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("uncached.com", now)
|
||||||
|
assert result is None
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
# Test expired
|
||||||
|
dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"])
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("expired.com", now)
|
||||||
|
assert result is None
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
# Test valid
|
||||||
|
dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"])
|
||||||
|
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
|
||||||
|
assert result == ["192.168.1.10"]
|
||||||
|
mock_resolve.assert_not_called()
|
168
tests/dashboard/status/test_mdns.py
Normal file
168
tests/dashboard/status/test_mdns.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Unit tests for esphome.dashboard.status.mdns module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from zeroconf import AddressResolver, IPVersion
|
||||||
|
|
||||||
|
from esphome.dashboard.status.mdns import MDNSStatus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def mdns_status(mock_dashboard: Mock) -> MDNSStatus:
|
||||||
|
"""Create an MDNSStatus instance in async context."""
|
||||||
|
# We're in an async context so get_running_loop will work
|
||||||
|
return MDNSStatus(mock_dashboard)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses when no zeroconf instance is available."""
|
||||||
|
mdns_status.aiozc = None
|
||||||
|
result = mdns_status.get_cached_addresses("device.local")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses when address is not in cache."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = False
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device.local")
|
||||||
|
assert result is None
|
||||||
|
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses when address is found in cache."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"]
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device.local")
|
||||||
|
assert result == ["192.168.1.10", "fe80::1"]
|
||||||
|
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
|
||||||
|
mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses with hostname having trailing dot."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device.local.")
|
||||||
|
assert result == ["192.168.1.10"]
|
||||||
|
# Should normalize to device.local. for zeroconf
|
||||||
|
mock_resolver.assert_called_once_with("device.local.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses with uppercase hostname."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("DEVICE.LOCAL")
|
||||||
|
assert result == ["192.168.1.10"]
|
||||||
|
# Should normalize to device.local. for zeroconf
|
||||||
|
mock_resolver.assert_called_once_with("device.local.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses with simple hostname (no domain)."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device")
|
||||||
|
assert result == ["192.168.1.10"]
|
||||||
|
# Should append .local. for zeroconf
|
||||||
|
mock_resolver.assert_called_once_with("device.local.")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses returning only IPv6 addresses."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"]
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device.local")
|
||||||
|
assert result == ["fe80::1", "2001:db8::1"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None:
|
||||||
|
"""Test get_cached_addresses returning empty list from cache."""
|
||||||
|
mdns_status.aiozc = Mock()
|
||||||
|
mdns_status.aiozc.zeroconf = Mock()
|
||||||
|
|
||||||
|
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
|
||||||
|
mock_info = Mock(spec=AddressResolver)
|
||||||
|
mock_info.load_from_cache.return_value = True
|
||||||
|
mock_info.parsed_scoped_addresses.return_value = []
|
||||||
|
mock_resolver.return_value = mock_info
|
||||||
|
|
||||||
|
result = mdns_status.get_cached_addresses("device.local")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_setup_success(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test successful async_setup."""
|
||||||
|
mdns_status = MDNSStatus(mock_dashboard)
|
||||||
|
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||||
|
mock_zc.return_value = Mock()
|
||||||
|
result = mdns_status.async_setup()
|
||||||
|
assert result is True
|
||||||
|
assert mdns_status.aiozc is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_setup_failure(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test async_setup with OSError."""
|
||||||
|
mdns_status = MDNSStatus(mock_dashboard)
|
||||||
|
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
|
||||||
|
mock_zc.side_effect = OSError("Network error")
|
||||||
|
result = mdns_status.async_setup()
|
||||||
|
assert result is False
|
||||||
|
assert mdns_status.aiozc is None
|
@@ -730,3 +730,83 @@ def test_start_web_server_with_unix_socket(tmp_path: Path) -> None:
|
|||||||
mock_server_class.assert_called_once_with(app)
|
mock_server_class.assert_called_once_with(app)
|
||||||
mock_bind.assert_called_once_with(str(socket_path), mode=0o666)
|
mock_bind.assert_called_once_with(str(socket_path), mode=0o666)
|
||||||
server.add_socket.assert_called_once()
|
server.add_socket.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test with no entry returns empty list."""
|
||||||
|
result = web_server.build_cache_arguments(None, mock_dashboard, 0.0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test with entry but no address or name."""
|
||||||
|
entry = Mock(spec=web_server.DashboardEntry)
|
||||||
|
entry.address = None
|
||||||
|
entry.name = None
|
||||||
|
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test with .local address that has cached mDNS results."""
|
||||||
|
entry = Mock(spec=web_server.DashboardEntry)
|
||||||
|
entry.address = "device.local"
|
||||||
|
entry.name = None
|
||||||
|
mock_dashboard.mdns_status = Mock()
|
||||||
|
mock_dashboard.mdns_status.get_cached_addresses.return_value = [
|
||||||
|
"192.168.1.10",
|
||||||
|
"fe80::1",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
"--mdns-address-cache",
|
||||||
|
"device.local=192.168.1.10,fe80::1",
|
||||||
|
]
|
||||||
|
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
|
||||||
|
"device.local"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test with non-.local address that has cached DNS results."""
|
||||||
|
entry = Mock(spec=web_server.DashboardEntry)
|
||||||
|
entry.address = "example.com"
|
||||||
|
entry.name = None
|
||||||
|
mock_dashboard.dns_cache = Mock()
|
||||||
|
mock_dashboard.dns_cache.get_cached_addresses.return_value = [
|
||||||
|
"93.184.216.34",
|
||||||
|
"2606:2800:220:1:248:1893:25c8:1946",
|
||||||
|
]
|
||||||
|
|
||||||
|
now = 100.0
|
||||||
|
result = web_server.build_cache_arguments(entry, mock_dashboard, now)
|
||||||
|
|
||||||
|
# IPv6 addresses are sorted before IPv4
|
||||||
|
assert result == [
|
||||||
|
"--dns-address-cache",
|
||||||
|
"example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34",
|
||||||
|
]
|
||||||
|
mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with(
|
||||||
|
"example.com", now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None:
|
||||||
|
"""Test with name but no address - should check mDNS with .local suffix."""
|
||||||
|
entry = Mock(spec=web_server.DashboardEntry)
|
||||||
|
entry.name = "my-device"
|
||||||
|
entry.address = None
|
||||||
|
mock_dashboard.mdns_status = Mock()
|
||||||
|
mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"]
|
||||||
|
|
||||||
|
result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
"--mdns-address-cache",
|
||||||
|
"my-device.local=192.168.1.20",
|
||||||
|
]
|
||||||
|
mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with(
|
||||||
|
"my-device.local"
|
||||||
|
)
|
||||||
|
305
tests/unit_tests/test_address_cache.py
Normal file
305
tests/unit_tests/test_address_cache.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"""Tests for the address_cache module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest import LogCaptureFixture
|
||||||
|
|
||||||
|
from esphome.address_cache import AddressCache, normalize_hostname
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_simple_hostname() -> None:
|
||||||
|
"""Test normalizing a simple hostname."""
|
||||||
|
assert normalize_hostname("device") == "device"
|
||||||
|
assert normalize_hostname("device.local") == "device.local"
|
||||||
|
assert normalize_hostname("server.example.com") == "server.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_removes_trailing_dots() -> None:
|
||||||
|
"""Test that trailing dots are removed."""
|
||||||
|
assert normalize_hostname("device.") == "device"
|
||||||
|
assert normalize_hostname("device.local.") == "device.local"
|
||||||
|
assert normalize_hostname("server.example.com.") == "server.example.com"
|
||||||
|
assert normalize_hostname("device...") == "device"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_converts_to_lowercase() -> None:
|
||||||
|
"""Test that hostnames are converted to lowercase."""
|
||||||
|
assert normalize_hostname("DEVICE") == "device"
|
||||||
|
assert normalize_hostname("Device.Local") == "device.local"
|
||||||
|
assert normalize_hostname("Server.Example.COM") == "server.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_combined() -> None:
|
||||||
|
"""Test combination of trailing dots and case conversion."""
|
||||||
|
assert normalize_hostname("DEVICE.LOCAL.") == "device.local"
|
||||||
|
assert normalize_hostname("Server.Example.COM...") == "server.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_empty() -> None:
|
||||||
|
"""Test initialization with empty caches."""
|
||||||
|
cache = AddressCache()
|
||||||
|
assert cache.mdns_cache == {}
|
||||||
|
assert cache.dns_cache == {}
|
||||||
|
assert not cache.has_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_with_caches() -> None:
|
||||||
|
"""Test initialization with provided caches."""
|
||||||
|
mdns_cache: dict[str, list[str]] = {"device.local": ["192.168.1.10"]}
|
||||||
|
dns_cache: dict[str, list[str]] = {"server.com": ["10.0.0.1"]}
|
||||||
|
cache = AddressCache(mdns_cache=mdns_cache, dns_cache=dns_cache)
|
||||||
|
assert cache.mdns_cache == mdns_cache
|
||||||
|
assert cache.dns_cache == dns_cache
|
||||||
|
assert cache.has_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_mdns_addresses() -> None:
|
||||||
|
"""Test getting mDNS addresses."""
|
||||||
|
cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10", "192.168.1.11"]})
|
||||||
|
|
||||||
|
# Direct lookup
|
||||||
|
assert cache.get_mdns_addresses("device.local") == [
|
||||||
|
"192.168.1.10",
|
||||||
|
"192.168.1.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Case insensitive lookup
|
||||||
|
assert cache.get_mdns_addresses("Device.Local") == [
|
||||||
|
"192.168.1.10",
|
||||||
|
"192.168.1.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
# With trailing dot
|
||||||
|
assert cache.get_mdns_addresses("device.local.") == [
|
||||||
|
"192.168.1.10",
|
||||||
|
"192.168.1.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
assert cache.get_mdns_addresses("unknown.local") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_dns_addresses() -> None:
|
||||||
|
"""Test getting DNS addresses."""
|
||||||
|
cache = AddressCache(dns_cache={"server.com": ["10.0.0.1", "10.0.0.2"]})
|
||||||
|
|
||||||
|
# Direct lookup
|
||||||
|
assert cache.get_dns_addresses("server.com") == ["10.0.0.1", "10.0.0.2"]
|
||||||
|
|
||||||
|
# Case insensitive lookup
|
||||||
|
assert cache.get_dns_addresses("Server.COM") == ["10.0.0.1", "10.0.0.2"]
|
||||||
|
|
||||||
|
# With trailing dot
|
||||||
|
assert cache.get_dns_addresses("server.com.") == ["10.0.0.1", "10.0.0.2"]
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
assert cache.get_dns_addresses("unknown.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_addresses_auto_detection() -> None:
|
||||||
|
"""Test automatic cache selection based on hostname."""
|
||||||
|
cache = AddressCache(
|
||||||
|
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||||
|
dns_cache={"server.com": ["10.0.0.1"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should use mDNS cache for .local domains
|
||||||
|
assert cache.get_addresses("device.local") == ["192.168.1.10"]
|
||||||
|
assert cache.get_addresses("device.local.") == ["192.168.1.10"]
|
||||||
|
assert cache.get_addresses("Device.Local") == ["192.168.1.10"]
|
||||||
|
|
||||||
|
# Should use DNS cache for non-.local domains
|
||||||
|
assert cache.get_addresses("server.com") == ["10.0.0.1"]
|
||||||
|
assert cache.get_addresses("server.com.") == ["10.0.0.1"]
|
||||||
|
assert cache.get_addresses("Server.COM") == ["10.0.0.1"]
|
||||||
|
|
||||||
|
# Not found
|
||||||
|
assert cache.get_addresses("unknown.local") is None
|
||||||
|
assert cache.get_addresses("unknown.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_cache() -> None:
|
||||||
|
"""Test checking if cache has entries."""
|
||||||
|
# Empty cache
|
||||||
|
cache = AddressCache()
|
||||||
|
assert not cache.has_cache()
|
||||||
|
|
||||||
|
# Only mDNS cache
|
||||||
|
cache = AddressCache(mdns_cache={"device.local": ["192.168.1.10"]})
|
||||||
|
assert cache.has_cache()
|
||||||
|
|
||||||
|
# Only DNS cache
|
||||||
|
cache = AddressCache(dns_cache={"server.com": ["10.0.0.1"]})
|
||||||
|
assert cache.has_cache()
|
||||||
|
|
||||||
|
# Both caches
|
||||||
|
cache = AddressCache(
|
||||||
|
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||||
|
dns_cache={"server.com": ["10.0.0.1"]},
|
||||||
|
)
|
||||||
|
assert cache.has_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_empty() -> None:
|
||||||
|
"""Test creating cache from empty CLI arguments."""
|
||||||
|
cache = AddressCache.from_cli_args([], [])
|
||||||
|
assert cache.mdns_cache == {}
|
||||||
|
assert cache.dns_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_single_entry() -> None:
|
||||||
|
"""Test creating cache from single CLI argument."""
|
||||||
|
mdns_args: list[str] = ["device.local=192.168.1.10"]
|
||||||
|
dns_args: list[str] = ["server.com=10.0.0.1"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
assert cache.mdns_cache == {"device.local": ["192.168.1.10"]}
|
||||||
|
assert cache.dns_cache == {"server.com": ["10.0.0.1"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_multiple_ips() -> None:
|
||||||
|
"""Test creating cache with multiple IPs per host."""
|
||||||
|
mdns_args: list[str] = ["device.local=192.168.1.10,192.168.1.11"]
|
||||||
|
dns_args: list[str] = ["server.com=10.0.0.1,10.0.0.2,10.0.0.3"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]}
|
||||||
|
assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2", "10.0.0.3"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_multiple_entries() -> None:
|
||||||
|
"""Test creating cache with multiple host entries."""
|
||||||
|
mdns_args: list[str] = [
|
||||||
|
"device1.local=192.168.1.10",
|
||||||
|
"device2.local=192.168.1.20,192.168.1.21",
|
||||||
|
]
|
||||||
|
dns_args: list[str] = ["server1.com=10.0.0.1", "server2.com=10.0.0.2"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
assert cache.mdns_cache == {
|
||||||
|
"device1.local": ["192.168.1.10"],
|
||||||
|
"device2.local": ["192.168.1.20", "192.168.1.21"],
|
||||||
|
}
|
||||||
|
assert cache.dns_cache == {
|
||||||
|
"server1.com": ["10.0.0.1"],
|
||||||
|
"server2.com": ["10.0.0.2"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_normalization() -> None:
|
||||||
|
"""Test that CLI arguments are normalized."""
|
||||||
|
mdns_args: list[str] = ["Device1.Local.=192.168.1.10", "DEVICE2.LOCAL=192.168.1.20"]
|
||||||
|
dns_args: list[str] = ["Server1.COM.=10.0.0.1", "SERVER2.com=10.0.0.2"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
# Hostnames should be normalized (lowercase, no trailing dots)
|
||||||
|
assert cache.mdns_cache == {
|
||||||
|
"device1.local": ["192.168.1.10"],
|
||||||
|
"device2.local": ["192.168.1.20"],
|
||||||
|
}
|
||||||
|
assert cache.dns_cache == {
|
||||||
|
"server1.com": ["10.0.0.1"],
|
||||||
|
"server2.com": ["10.0.0.2"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_whitespace_handling() -> None:
|
||||||
|
"""Test that whitespace in IPs is handled."""
|
||||||
|
mdns_args: list[str] = ["device.local= 192.168.1.10 , 192.168.1.11 "]
|
||||||
|
dns_args: list[str] = ["server.com= 10.0.0.1 , 10.0.0.2 "]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
assert cache.mdns_cache == {"device.local": ["192.168.1.10", "192.168.1.11"]}
|
||||||
|
assert cache.dns_cache == {"server.com": ["10.0.0.1", "10.0.0.2"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_invalid_format(caplog: LogCaptureFixture) -> None:
|
||||||
|
"""Test handling of invalid argument format."""
|
||||||
|
mdns_args: list[str] = ["invalid_format", "device.local=192.168.1.10"]
|
||||||
|
dns_args: list[str] = ["server.com=10.0.0.1", "also_invalid"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
# Valid entries should still be processed
|
||||||
|
assert cache.mdns_cache == {"device.local": ["192.168.1.10"]}
|
||||||
|
assert cache.dns_cache == {"server.com": ["10.0.0.1"]}
|
||||||
|
|
||||||
|
# Check that warnings were logged for invalid entries
|
||||||
|
assert "Invalid cache format: invalid_format" in caplog.text
|
||||||
|
assert "Invalid cache format: also_invalid" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_cli_args_ipv6() -> None:
|
||||||
|
"""Test handling of IPv6 addresses."""
|
||||||
|
mdns_args: list[str] = ["device.local=fe80::1,2001:db8::1"]
|
||||||
|
dns_args: list[str] = ["server.com=2001:db8::2,::1"]
|
||||||
|
|
||||||
|
cache = AddressCache.from_cli_args(mdns_args, dns_args)
|
||||||
|
|
||||||
|
assert cache.mdns_cache == {"device.local": ["fe80::1", "2001:db8::1"]}
|
||||||
|
assert cache.dns_cache == {"server.com": ["2001:db8::2", "::1"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_logging_output(caplog: LogCaptureFixture) -> None:
|
||||||
|
"""Test that appropriate debug logging occurs."""
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
cache = AddressCache(
|
||||||
|
mdns_cache={"device.local": ["192.168.1.10"]},
|
||||||
|
dns_cache={"server.com": ["10.0.0.1"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test successful lookups log at debug level
|
||||||
|
result: list[str] | None = cache.get_mdns_addresses("device.local")
|
||||||
|
assert result == ["192.168.1.10"]
|
||||||
|
assert "Using mDNS cache for device.local" in caplog.text
|
||||||
|
|
||||||
|
caplog.clear()
|
||||||
|
result = cache.get_dns_addresses("server.com")
|
||||||
|
assert result == ["10.0.0.1"]
|
||||||
|
assert "Using DNS cache for server.com" in caplog.text
|
||||||
|
|
||||||
|
# Test that failed lookups don't log
|
||||||
|
caplog.clear()
|
||||||
|
result = cache.get_mdns_addresses("unknown.local")
|
||||||
|
assert result is None
|
||||||
|
assert "Using mDNS cache" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"hostname,expected",
|
||||||
|
[
|
||||||
|
("test.local", "test.local"),
|
||||||
|
("Test.Local.", "test.local"),
|
||||||
|
("TEST.LOCAL...", "test.local"),
|
||||||
|
("example.com", "example.com"),
|
||||||
|
("EXAMPLE.COM.", "example.com"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_hostname_parametrized(hostname: str, expected: str) -> None:
|
||||||
|
"""Test hostname normalization with various inputs."""
|
||||||
|
assert normalize_hostname(hostname) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mdns_arg,expected",
|
||||||
|
[
|
||||||
|
("host=1.2.3.4", {"host": ["1.2.3.4"]}),
|
||||||
|
("Host.Local=1.2.3.4,5.6.7.8", {"host.local": ["1.2.3.4", "5.6.7.8"]}),
|
||||||
|
("HOST.LOCAL.=::1", {"host.local": ["::1"]}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_cache_args_parametrized(
|
||||||
|
mdns_arg: str, expected: dict[str, list[str]]
|
||||||
|
) -> None:
|
||||||
|
"""Test parsing of cache arguments with various formats."""
|
||||||
|
cache = AddressCache.from_cli_args([mdns_arg], [])
|
||||||
|
assert cache.mdns_cache == expected
|
@@ -11,6 +11,7 @@ from hypothesis.strategies import ip_addresses
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome import helpers
|
from esphome import helpers
|
||||||
|
from esphome.address_cache import AddressCache
|
||||||
from esphome.core import EsphomeError
|
from esphome.core import EsphomeError
|
||||||
|
|
||||||
|
|
||||||
@@ -830,3 +831,84 @@ def test_resolve_ip_address_sorting() -> None:
|
|||||||
assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1)
|
assert result[0][4][0] == "2001:db8::1" # IPv6 (preference 1)
|
||||||
assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2)
|
assert result[1][4][0] == "192.168.1.100" # IPv4 (preference 2)
|
||||||
assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3)
|
assert result[2][4][0] == "fe80::1" # Link-local no scope (preference 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ip_address_with_cache() -> None:
|
||||||
|
"""Test that the cache is used when provided."""
|
||||||
|
cache = AddressCache(
|
||||||
|
mdns_cache={"test.local": ["192.168.1.100", "192.168.1.101"]},
|
||||||
|
dns_cache={
|
||||||
|
"example.com": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test mDNS cache hit
|
||||||
|
result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache)
|
||||||
|
|
||||||
|
# Should return cached addresses without calling resolver
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0][4][0] == "192.168.1.100"
|
||||||
|
assert result[1][4][0] == "192.168.1.101"
|
||||||
|
|
||||||
|
# Test DNS cache hit
|
||||||
|
result = helpers.resolve_ip_address("example.com", 6053, address_cache=cache)
|
||||||
|
|
||||||
|
# Should return cached addresses with IPv6 first due to preference
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0][4][0] == "2606:2800:220:1:248:1893:25c8:1946" # IPv6 first
|
||||||
|
assert result[1][4][0] == "93.184.216.34" # IPv4 second
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ip_address_cache_miss() -> None:
|
||||||
|
"""Test that resolver is called when not in cache."""
|
||||||
|
cache = AddressCache(mdns_cache={"other.local": ["192.168.1.200"]})
|
||||||
|
|
||||||
|
mock_addr_info = AddrInfo(
|
||||||
|
family=socket.AF_INET,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
proto=socket.IPPROTO_TCP,
|
||||||
|
sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("esphome.resolver.AsyncResolver") as MockResolver:
|
||||||
|
mock_resolver = MockResolver.return_value
|
||||||
|
mock_resolver.resolve.return_value = [mock_addr_info]
|
||||||
|
|
||||||
|
result = helpers.resolve_ip_address("test.local", 6053, address_cache=cache)
|
||||||
|
|
||||||
|
# Should call resolver since test.local is not in cache
|
||||||
|
MockResolver.assert_called_once_with(["test.local"], 6053)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][4][0] == "192.168.1.100"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_ip_address_mixed_cached_uncached() -> None:
|
||||||
|
"""Test resolution with mix of cached and uncached hosts."""
|
||||||
|
cache = AddressCache(mdns_cache={"cached.local": ["192.168.1.50"]})
|
||||||
|
|
||||||
|
mock_addr_info = AddrInfo(
|
||||||
|
family=socket.AF_INET,
|
||||||
|
type=socket.SOCK_STREAM,
|
||||||
|
proto=socket.IPPROTO_TCP,
|
||||||
|
sockaddr=IPv4Sockaddr(address="192.168.1.100", port=6053),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("esphome.resolver.AsyncResolver") as MockResolver:
|
||||||
|
mock_resolver = MockResolver.return_value
|
||||||
|
mock_resolver.resolve.return_value = [mock_addr_info]
|
||||||
|
|
||||||
|
# Pass a list with cached IP, cached hostname, and uncached hostname
|
||||||
|
result = helpers.resolve_ip_address(
|
||||||
|
["192.168.1.10", "cached.local", "uncached.local"],
|
||||||
|
6053,
|
||||||
|
address_cache=cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only resolve uncached.local
|
||||||
|
MockResolver.assert_called_once_with(["uncached.local"], 6053)
|
||||||
|
|
||||||
|
# Results should include all addresses
|
||||||
|
addresses = [r[4][0] for r in result]
|
||||||
|
assert "192.168.1.10" in addresses # Direct IP
|
||||||
|
assert "192.168.1.50" in addresses # From cache
|
||||||
|
assert "192.168.1.100" in addresses # From resolver
|
||||||
|
Reference in New Issue
Block a user