diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad8562640c..dc22265f1f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black args: diff --git a/CODEOWNERS b/CODEOWNERS index 2dcef6c514..dd1586d039 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -246,6 +246,7 @@ esphome/components/radon_eye_rd200/* @jeffeb3 esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet +esphome/components/resistance_sampler/* @jesserockz esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index ba8d3df9d8..06fc55fc43 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -2,7 +2,7 @@ from math import log import esphome.config_validation as cv import esphome.codegen as cg -from esphome.components import sensor +from esphome.components import sensor, resistance_sampler from esphome.const import ( CONF_CALIBRATION, CONF_REFERENCE_RESISTANCE, @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_CELSIUS, ) +AUTO_LOAD = ["resistance_sampler"] + ntc_ns = cg.esphome_ns.namespace("ntc") NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor) @@ -124,7 +126,7 @@ CONFIG_SCHEMA = ( ) .extend( { - cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Required(CONF_SENSOR): cv.use_id(resistance_sampler.ResistanceSampler), cv.Required(CONF_CALIBRATION): process_calibration, } ) diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index b57f90b59c..8fa1f8b570 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -1,7 +1,8 @@ #pragma once -#include "esphome/core/component.h" +#include "esphome/components/resistance_sampler/resistance_sampler.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" namespace esphome { namespace resistance { @@ -11,7 +12,7 @@ enum ResistanceConfiguration { DOWNSTREAM, }; -class ResistanceSensor : public Component, public sensor::Sensor { +class ResistanceSensor : public Component, public sensor::Sensor, resistance_sampler::ResistanceSampler { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index 55e7ddfc81..a84b439497 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -1,6 +1,6 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.components import sensor +from esphome.components import sensor, resistance_sampler from esphome.const import ( CONF_SENSOR, STATE_CLASS_MEASUREMENT, @@ -8,8 +8,15 @@ from esphome.const import ( ICON_FLASH, ) +AUTO_LOAD = ["resistance_sampler"] + resistance_ns = cg.esphome_ns.namespace("resistance") -ResistanceSensor = resistance_ns.class_("ResistanceSensor", cg.Component, sensor.Sensor) +ResistanceSensor = resistance_ns.class_( + "ResistanceSensor", + cg.Component, + sensor.Sensor, + resistance_sampler.ResistanceSampler, +) CONF_REFERENCE_VOLTAGE = "reference_voltage" CONF_CONFIGURATION = "configuration" diff --git a/esphome/components/resistance_sampler/__init__.py b/esphome/components/resistance_sampler/__init__.py new file mode 100644 index 0000000000..d2032848aa --- /dev/null +++ b/esphome/components/resistance_sampler/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +resistance_sampler_ns = cg.esphome_ns.namespace("resistance_sampler") +ResistanceSampler = resistance_sampler_ns.class_("ResistanceSampler") + +CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/resistance_sampler/resistance_sampler.h b/esphome/components/resistance_sampler/resistance_sampler.h new file mode 100644 index 0000000000..9e300bebcc --- /dev/null +++ b/esphome/components/resistance_sampler/resistance_sampler.h @@ -0,0 +1,10 @@ +#pragma once + +namespace esphome { +namespace resistance_sampler { + +/// Abstract interface to mark components that provide resistance values. +class ResistanceSampler {}; + +} // namespace resistance_sampler +} // namespace esphome diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 7fcac50ba7..e6f8b28b46 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -43,11 +43,17 @@ def validate_mode(mode): return mode +def validate_pin(pin): + if pin in (8, 9): + raise cv.Invalid(f"pin {pin} doesn't exist") + return pin + + XL9535_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(XL9535GPIOPin), cv.Required(CONF_XL9535): cv.use_id(XL9535Component), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), + cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), cv.Optional(CONF_MODE, default={}): cv.All( { cv.Optional(CONF_INPUT, default=False): cv.boolean, diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 5967c95aba..577cf1dbc1 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -18,6 +18,7 @@ import shutil import subprocess import threading from pathlib import Path +from typing import Any import tornado import tornado.concurrent @@ -398,19 +399,40 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): raise NotImplementedError -class EsphomeLogsHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) +DASHBOARD_COMMAND = ["esphome", "--dashboard"] + + +class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): + """Base class for commands that require a port.""" + + def run_command(self, args: list[str], json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + configuration = json_message["configuration"] + config_file = settings.rel_path(configuration) + port = json_message["port"] + if ( + port == "OTA" + and (mdns := MDNS_CONTAINER.get_mdns()) + and (host_name := mdns.filename_to_host_name_thread_safe(configuration)) + and (address := mdns.resolve_host_thread_safe(host_name)) + ): + port = address + return [ - "esphome", - "--dashboard", - "logs", + *DASHBOARD_COMMAND, + *args, config_file, "--device", - json_message["port"], + port, ] +class EsphomeLogsHandler(EsphomePortCommandWebSocket): + def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return self.run_command(["logs"], json_message) + + class EsphomeRenameHandler(EsphomeCommandWebSocket): old_name: str @@ -418,8 +440,7 @@ class EsphomeRenameHandler(EsphomeCommandWebSocket): config_file = settings.rel_path(json_message["configuration"]) self.old_name = json_message["configuration"] return [ - "esphome", - "--dashboard", + *DASHBOARD_COMMAND, "rename", config_file, json_message["newName"], @@ -435,36 +456,22 @@ class EsphomeRenameHandler(EsphomeCommandWebSocket): PING_RESULT.pop(self.old_name, None) -class EsphomeUploadHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return [ - "esphome", - "--dashboard", - "upload", - config_file, - "--device", - json_message["port"], - ] +class EsphomeUploadHandler(EsphomePortCommandWebSocket): + def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return self.run_command(["upload"], json_message) -class EsphomeRunHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return [ - "esphome", - "--dashboard", - "run", - config_file, - "--device", - json_message["port"], - ] +class EsphomeRunHandler(EsphomePortCommandWebSocket): + def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return self.run_command(["run"], json_message) class EsphomeCompileHandler(EsphomeCommandWebSocket): def build_command(self, json_message): config_file = settings.rel_path(json_message["configuration"]) - command = ["esphome", "--dashboard", "compile"] + command = [*DASHBOARD_COMMAND, "compile"] if json_message.get("only_generate", False): command.append("--only-generate") command.append(config_file) @@ -474,7 +481,7 @@ class EsphomeCompileHandler(EsphomeCommandWebSocket): class EsphomeValidateHandler(EsphomeCommandWebSocket): def build_command(self, json_message): config_file = settings.rel_path(json_message["configuration"]) - command = ["esphome", "--dashboard", "config", config_file] + command = [*DASHBOARD_COMMAND, "config", config_file] if not settings.streamer_mode: command.append("--show-secrets") return command @@ -483,28 +490,28 @@ class EsphomeValidateHandler(EsphomeCommandWebSocket): class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): def build_command(self, json_message): config_file = settings.rel_path(json_message["configuration"]) - return ["esphome", "--dashboard", "clean-mqtt", config_file] + return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] class EsphomeCleanHandler(EsphomeCommandWebSocket): def build_command(self, json_message): config_file = settings.rel_path(json_message["configuration"]) - return ["esphome", "--dashboard", "clean", config_file] + return [*DASHBOARD_COMMAND, "clean", config_file] class EsphomeVscodeHandler(EsphomeCommandWebSocket): def build_command(self, json_message): - return ["esphome", "--dashboard", "-q", "vscode", "dummy"] + return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] class EsphomeAceEditorHandler(EsphomeCommandWebSocket): def build_command(self, json_message): - return ["esphome", "--dashboard", "-q", "vscode", "--ace", settings.config_dir] + return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): def build_command(self, json_message): - return ["esphome", "--dashboard", "update-all", settings.config_dir] + return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] class SerialPortRequestHandler(BaseHandler): @@ -964,23 +971,38 @@ class BoardsRequestHandler(BaseHandler): class MDNSStatusThread(threading.Thread): - def __init__(self): + """Thread that updates the mdns status.""" + + def __init__(self) -> None: """Initialize the MDNSStatusThread.""" super().__init__() + self.zeroconf: EsphomeZeroconf | None = None # This is the current mdns state for each host (True, False, None) self.host_mdns_state: dict[str, bool | None] = {} # This is the hostnames to filenames mapping self.host_name_to_filename: dict[str, str] = {} + self.filename_to_host_name: dict[str, str] = {} # This is a set of host names to track (i.e no_mdns = false) self.host_name_with_mdns_enabled: set[set] = set() self._refresh_hosts() + def filename_to_host_name_thread_safe(self, filename: str) -> str | None: + """Resolve a filename to an address in a thread-safe manner.""" + return self.filename_to_host_name.get(filename) + + def resolve_host_thread_safe(self, host_name: str) -> str | None: + """Resolve a host name to an address in a thread-safe manner.""" + if zc := self.zeroconf: + return zc.resolve_host(host_name) + return None + def _refresh_hosts(self): """Refresh the hosts to track.""" entries = _list_dashboard_entries() host_name_with_mdns_enabled = self.host_name_with_mdns_enabled host_mdns_state = self.host_mdns_state host_name_to_filename = self.host_name_to_filename + filename_to_host_name = self.filename_to_host_name for entry in entries: name = entry.name @@ -1003,11 +1025,13 @@ class MDNSStatusThread(threading.Thread): # so when we get an mdns update we can map it back # to the filename host_name_to_filename[name] = filename + filename_to_host_name[filename] = name def run(self): global IMPORT_RESULT zc = EsphomeZeroconf() + self.zeroconf = zc host_mdns_state = self.host_mdns_state host_name_to_filename = self.host_name_to_filename host_name_with_mdns_enabled = self.host_name_with_mdns_enabled @@ -1035,6 +1059,7 @@ class MDNSStatusThread(threading.Thread): browser.cancel() zc.close() + self.zeroconf = None class PingStatusThread(threading.Thread): @@ -1211,11 +1236,26 @@ class UndoDeleteRequestHandler(BaseHandler): shutil.move(os.path.join(trash_path, configuration), config_file) +class MDNSContainer: + def __init__(self) -> None: + """Initialize the MDNSContainer.""" + self._mdns: MDNSStatusThread | None = None + + def set_mdns(self, mdns: MDNSStatusThread) -> None: + """Set the MDNSStatusThread instance.""" + self._mdns = mdns + + def get_mdns(self) -> MDNSStatusThread | None: + """Return the MDNSStatusThread instance.""" + return self._mdns + + PING_RESULT: dict = {} IMPORT_RESULT = {} STOP_EVENT = threading.Event() PING_REQUEST = threading.Event() MQTT_PING_REQUEST = threading.Event() +MDNS_CONTAINER = MDNSContainer() class LoginHandler(BaseHandler): @@ -1506,6 +1546,7 @@ def start_web_server(args): status_thread = PingStatusThread() else: status_thread = MDNSStatusThread() + MDNS_CONTAINER.set_mdns(status_thread) status_thread.start() if settings.status_use_mqtt: @@ -1519,6 +1560,7 @@ def start_web_server(args): STOP_EVENT.set() PING_REQUEST.set() status_thread.join() + MDNS_CONTAINER.set_mdns(None) if settings.status_use_mqtt: status_thread_mqtt.join() MQTT_PING_REQUEST.set() diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index d20111ce20..f4cb7f080b 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -147,12 +147,13 @@ class DashboardImportDiscovery: class EsphomeZeroconf(Zeroconf): - def resolve_host(self, host: str, timeout=3.0): + def resolve_host(self, host: str, timeout: float = 3.0) -> str | None: """Resolve a host name to an IP address.""" name = host.partition(".")[0] - info = HostResolver(f"{name}.{ESPHOME_SERVICE_TYPE}", ESPHOME_SERVICE_TYPE) - if (info.load_from_cache(self) or info.request(self, timeout * 1000)) and ( - addresses := info.ip_addresses_by_version(IPVersion.V4Only) - ): + info = HostResolver(ESPHOME_SERVICE_TYPE, f"{name}.{ESPHOME_SERVICE_TYPE}") + if ( + info.load_from_cache(self) + or (timeout and info.request(self, timeout * 1000)) + ) and (addresses := info.ip_addresses_by_version(IPVersion.V4Only)): return str(addresses[0]) return None diff --git a/requirements_test.txt b/requirements_test.txt index fade3cda3e..d2ce98cc8c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==2.17.6 flake8==6.1.0 # also change in .pre-commit-config.yaml when updating -black==23.10.1 # also change in .pre-commit-config.yaml when updating +black==23.11.0 # also change in .pre-commit-config.yaml when updating pyupgrade==3.15.0 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/tests/test4.yaml b/tests/test4.yaml index a5e5b05e8b..c27dbb65ac 100644 --- a/tests/test4.yaml +++ b/tests/test4.yaml @@ -425,6 +425,15 @@ binary_sensor: input: true inverted: false + - platform: gpio + name: XL9535 Pin 17 + pin: + xl9535: xl9535_hub + number: 17 + mode: + input: true + inverted: false + climate: - platform: tuya id: tuya_climate