mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge remote-tracking branch 'upstream/dev' into zwave_proxy
This commit is contained in:
		| @@ -226,7 +226,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: | |||||||
|                         .replace(b"\n", b"") |                         .replace(b"\n", b"") | ||||||
|                         .decode("utf8", "backslashreplace") |                         .decode("utf8", "backslashreplace") | ||||||
|                     ) |                     ) | ||||||
|                     time_str = datetime.now().time().strftime("[%H:%M:%S]") |                     time_ = datetime.now() | ||||||
|  |                     nanoseconds = time_.microsecond // 1000 | ||||||
|  |                     time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" | ||||||
|                     safe_print(parser.parse_line(line, time_str)) |                     safe_print(parser.parse_line(line, time_str)) | ||||||
|  |  | ||||||
|                     backtrace_state = platformio_api.process_stacktrace( |                     backtrace_state = platformio_api.process_stacktrace( | ||||||
|   | |||||||
| @@ -62,9 +62,11 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: | |||||||
|         time_ = datetime.now() |         time_ = datetime.now() | ||||||
|         message: bytes = msg.message |         message: bytes = msg.message | ||||||
|         text = message.decode("utf8", "backslashreplace") |         text = message.decode("utf8", "backslashreplace") | ||||||
|         for parsed_msg in parse_log_message( |         nanoseconds = time_.microsecond // 1000 | ||||||
|             text, f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]" |         timestamp = ( | ||||||
|         ): |             f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" | ||||||
|  |         ) | ||||||
|  |         for parsed_msg in parse_log_message(text, timestamp): | ||||||
|             print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) |             print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) | ||||||
|  |  | ||||||
|     stop = await async_run(cli, on_log, name=name) |     stop = await async_run(cli, on_log, name=name) | ||||||
|   | |||||||
| @@ -353,6 +353,7 @@ SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ | |||||||
| # pioarduino versions that don't require a release number | # pioarduino versions that don't require a release number | ||||||
| # List based on https://github.com/pioarduino/esp-idf/releases | # List based on https://github.com/pioarduino/esp-idf/releases | ||||||
| SUPPORTED_PIOARDUINO_ESP_IDF_5X = [ | SUPPORTED_PIOARDUINO_ESP_IDF_5X = [ | ||||||
|  |     cv.Version(5, 5, 1), | ||||||
|     cv.Version(5, 5, 0), |     cv.Version(5, 5, 0), | ||||||
|     cv.Version(5, 4, 2), |     cv.Version(5, 4, 2), | ||||||
|     cv.Version(5, 4, 1), |     cv.Version(5, 4, 1), | ||||||
|   | |||||||
| @@ -43,13 +43,6 @@ void BLEClientBase::setup() { | |||||||
| void BLEClientBase::set_state(espbt::ClientState st) { | void BLEClientBase::set_state(espbt::ClientState st) { | ||||||
|   ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); |   ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); | ||||||
|   ESPBTClient::set_state(st); |   ESPBTClient::set_state(st); | ||||||
|  |  | ||||||
|   if (st == espbt::ClientState::READY_TO_CONNECT) { |  | ||||||
|     // Enable loop for state processing |  | ||||||
|     this->enable_loop(); |  | ||||||
|     // Connect immediately instead of waiting for next loop |  | ||||||
|     this->connect(); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| void BLEClientBase::loop() { | void BLEClientBase::loop() { | ||||||
| @@ -65,8 +58,8 @@ void BLEClientBase::loop() { | |||||||
|     } |     } | ||||||
|     this->set_state(espbt::ClientState::IDLE); |     this->set_state(espbt::ClientState::IDLE); | ||||||
|   } |   } | ||||||
|   // If its idle, we can disable the loop as set_state |   // If idle, we can disable the loop as connect() | ||||||
|   // will enable it again when we need to connect. |   // will enable it again when a connection is needed. | ||||||
|   else if (this->state_ == espbt::ClientState::IDLE) { |   else if (this->state_ == espbt::ClientState::IDLE) { | ||||||
|     this->disable_loop(); |     this->disable_loop(); | ||||||
|   } |   } | ||||||
| @@ -108,9 +101,20 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { | |||||||
| #endif | #endif | ||||||
|  |  | ||||||
| void BLEClientBase::connect() { | void BLEClientBase::connect() { | ||||||
|  |   // Prevent duplicate connection attempts | ||||||
|  |   if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || | ||||||
|  |       this->state_ == espbt::ClientState::ESTABLISHED) { | ||||||
|  |     ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, | ||||||
|  |              this->address_str_.c_str(), espbt::client_state_to_string(this->state_)); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|   ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), |   ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), | ||||||
|            this->remote_addr_type_); |            this->remote_addr_type_); | ||||||
|   this->paired_ = false; |   this->paired_ = false; | ||||||
|  |   // Enable loop for state processing | ||||||
|  |   this->enable_loop(); | ||||||
|  |   // Immediately transition to CONNECTING to prevent duplicate connection attempts | ||||||
|  |   this->set_state(espbt::ClientState::CONNECTING); | ||||||
|  |  | ||||||
|   // Determine connection parameters based on connection type |   // Determine connection parameters based on connection type | ||||||
|   if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { |   if (this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { | ||||||
| @@ -168,7 +172,7 @@ void BLEClientBase::unconditional_disconnect() { | |||||||
|     this->log_gattc_warning_("esp_ble_gattc_close", err); |     this->log_gattc_warning_("esp_ble_gattc_close", err); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (this->state_ == espbt::ClientState::READY_TO_CONNECT || this->state_ == espbt::ClientState::DISCOVERED) { |   if (this->state_ == espbt::ClientState::DISCOVERED) { | ||||||
|     this->set_address(0); |     this->set_address(0); | ||||||
|     this->set_state(espbt::ClientState::IDLE); |     this->set_state(espbt::ClientState::IDLE); | ||||||
|   } else { |   } else { | ||||||
| @@ -212,8 +216,6 @@ void BLEClientBase::handle_connection_result_(esp_err_t ret) { | |||||||
|   if (ret) { |   if (ret) { | ||||||
|     this->log_gattc_warning_("esp_ble_gattc_open", ret); |     this->log_gattc_warning_("esp_ble_gattc_open", ret); | ||||||
|     this->set_state(espbt::ClientState::IDLE); |     this->set_state(espbt::ClientState::IDLE); | ||||||
|   } else { |  | ||||||
|     this->set_state(espbt::ClientState::CONNECTING); |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -51,8 +51,6 @@ const char *client_state_to_string(ClientState state) { | |||||||
|       return "IDLE"; |       return "IDLE"; | ||||||
|     case ClientState::DISCOVERED: |     case ClientState::DISCOVERED: | ||||||
|       return "DISCOVERED"; |       return "DISCOVERED"; | ||||||
|     case ClientState::READY_TO_CONNECT: |  | ||||||
|       return "READY_TO_CONNECT"; |  | ||||||
|     case ClientState::CONNECTING: |     case ClientState::CONNECTING: | ||||||
|       return "CONNECTING"; |       return "CONNECTING"; | ||||||
|     case ClientState::CONNECTED: |     case ClientState::CONNECTED: | ||||||
| @@ -795,7 +793,7 @@ void ESP32BLETracker::try_promote_discovered_clients_() { | |||||||
| #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE | #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE | ||||||
|     this->update_coex_preference_(true); |     this->update_coex_preference_(true); | ||||||
| #endif | #endif | ||||||
|     client->set_state(ClientState::READY_TO_CONNECT); |     client->connect(); | ||||||
|     break; |     break; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -159,8 +159,6 @@ enum class ClientState : uint8_t { | |||||||
|   IDLE, |   IDLE, | ||||||
|   // Device advertisement found. |   // Device advertisement found. | ||||||
|   DISCOVERED, |   DISCOVERED, | ||||||
|   // Device is discovered and the scanner is stopped |  | ||||||
|   READY_TO_CONNECT, |  | ||||||
|   // Connection in progress. |   // Connection in progress. | ||||||
|   CONNECTING, |   CONNECTING, | ||||||
|   // Initial connection established. |   // Initial connection established. | ||||||
| @@ -313,7 +311,6 @@ class ESP32BLETracker : public Component, | |||||||
|           counts.discovered++; |           counts.discovered++; | ||||||
|           break; |           break; | ||||||
|         case ClientState::CONNECTING: |         case ClientState::CONNECTING: | ||||||
|         case ClientState::READY_TO_CONNECT: |  | ||||||
|           counts.connecting++; |           counts.connecting++; | ||||||
|           break; |           break; | ||||||
|         default: |         default: | ||||||
|   | |||||||
| @@ -1,13 +1,16 @@ | |||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| import asyncio | import asyncio | ||||||
|  | from collections.abc import Generator | ||||||
|  | import gzip | ||||||
| import json | import json | ||||||
| import os | import os | ||||||
| from unittest.mock import Mock | from pathlib import Path | ||||||
|  | from unittest.mock import MagicMock, Mock, patch | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
| import pytest_asyncio | import pytest_asyncio | ||||||
| from tornado.httpclient import AsyncHTTPClient, HTTPResponse | from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse | ||||||
| from tornado.httpserver import HTTPServer | from tornado.httpserver import HTTPServer | ||||||
| from tornado.ioloop import IOLoop | from tornado.ioloop import IOLoop | ||||||
| from tornado.testing import bind_unused_port | from tornado.testing import bind_unused_port | ||||||
| @@ -34,6 +37,66 @@ class DashboardTestHelper: | |||||||
|         return await future |         return await future | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_async_run_system_command() -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock async_run_system_command.""" | ||||||
|  |     with patch("esphome.dashboard.web_server.async_run_system_command") as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock trash_storage_path.""" | ||||||
|  |     trash_dir = tmp_path / "trash" | ||||||
|  |     with patch( | ||||||
|  |         "esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) | ||||||
|  |     ) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock archive_storage_path.""" | ||||||
|  |     archive_dir = tmp_path / "archive" | ||||||
|  |     with patch( | ||||||
|  |         "esphome.dashboard.web_server.archive_storage_path", | ||||||
|  |         return_value=str(archive_dir), | ||||||
|  |     ) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_dashboard_settings() -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock dashboard settings.""" | ||||||
|  |     with patch("esphome.dashboard.web_server.settings") as mock_settings: | ||||||
|  |         # Set default auth settings to avoid authentication issues | ||||||
|  |         mock_settings.using_auth = False | ||||||
|  |         mock_settings.on_ha_addon = False | ||||||
|  |         yield mock_settings | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock ext_storage_path.""" | ||||||
|  |     with patch("esphome.dashboard.web_server.ext_storage_path") as mock: | ||||||
|  |         mock.return_value = str(tmp_path / "storage.json") | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_storage_json() -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock StorageJSON.""" | ||||||
|  |     with patch("esphome.dashboard.web_server.StorageJSON") as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_idedata() -> Generator[MagicMock]: | ||||||
|  |     """Fixture to mock platformio_api.IDEData.""" | ||||||
|  |     with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest_asyncio.fixture() | @pytest_asyncio.fixture() | ||||||
| async def dashboard() -> DashboardTestHelper: | async def dashboard() -> DashboardTestHelper: | ||||||
|     sock, port = bind_unused_port() |     sock, port = bind_unused_port() | ||||||
| @@ -80,3 +143,439 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: | |||||||
|     first_device = configured_devices[0] |     first_device = configured_devices[0] | ||||||
|     assert first_device["name"] == "pico" |     assert first_device["name"] == "pico" | ||||||
|     assert first_device["configuration"] == "pico.yaml" |     assert first_device["configuration"] == "pico.yaml" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: | ||||||
|  |     """Test the WizardRequestHandler.post method with invalid inputs.""" | ||||||
|  |     # Test with missing name (should fail with 422) | ||||||
|  |     body_no_name = json.dumps( | ||||||
|  |         { | ||||||
|  |             "name": "",  # Empty name | ||||||
|  |             "platform": "ESP32", | ||||||
|  |             "board": "esp32dev", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/wizard", | ||||||
|  |             method="POST", | ||||||
|  |             body=body_no_name, | ||||||
|  |             headers={"Content-Type": "application/json"}, | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 422 | ||||||
|  |  | ||||||
|  |     # Test with invalid wizard type (should fail with 422) | ||||||
|  |     body_invalid_type = json.dumps( | ||||||
|  |         { | ||||||
|  |             "name": "test_device", | ||||||
|  |             "type": "invalid_type", | ||||||
|  |             "platform": "ESP32", | ||||||
|  |             "board": "esp32dev", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/wizard", | ||||||
|  |             method="POST", | ||||||
|  |             body=body_invalid_type, | ||||||
|  |             headers={"Content-Type": "application/json"}, | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 422 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: | ||||||
|  |     """Test the WizardRequestHandler.post when config already exists.""" | ||||||
|  |     # Try to create a wizard for existing pico.yaml (should conflict) | ||||||
|  |     body = json.dumps( | ||||||
|  |         { | ||||||
|  |             "name": "pico",  # This already exists in fixtures | ||||||
|  |             "platform": "ESP32", | ||||||
|  |             "board": "esp32dev", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/wizard", | ||||||
|  |             method="POST", | ||||||
|  |             body=body, | ||||||
|  |             headers={"Content-Type": "application/json"}, | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 409 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_download_binary_handler_not_found( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get with non-existent config.""" | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/download.bin?configuration=nonexistent.yaml", | ||||||
|  |             method="GET", | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.usefixtures("mock_ext_storage_path") | ||||||
|  | async def test_download_binary_handler_no_file_param( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_storage_json: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get without file parameter.""" | ||||||
|  |     # Mock storage to exist, but still should fail without file param | ||||||
|  |     mock_storage = Mock() | ||||||
|  |     mock_storage.name = "test_device" | ||||||
|  |     mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") | ||||||
|  |     mock_storage_json.load.return_value = mock_storage | ||||||
|  |  | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/download.bin?configuration=pico.yaml", | ||||||
|  |             method="GET", | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 400 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.usefixtures("mock_ext_storage_path") | ||||||
|  | async def test_download_binary_handler_with_file( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_storage_json: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get with existing binary file.""" | ||||||
|  |     # Create a fake binary file | ||||||
|  |     build_dir = tmp_path / ".esphome" / "build" / "test" | ||||||
|  |     build_dir.mkdir(parents=True) | ||||||
|  |     firmware_file = build_dir / "firmware.bin" | ||||||
|  |     firmware_file.write_bytes(b"fake firmware content") | ||||||
|  |  | ||||||
|  |     # Mock storage JSON | ||||||
|  |     mock_storage = Mock() | ||||||
|  |     mock_storage.name = "test_device" | ||||||
|  |     mock_storage.firmware_bin_path = str(firmware_file) | ||||||
|  |     mock_storage_json.load.return_value = mock_storage | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/download.bin?configuration=test.yaml&file=firmware.bin", | ||||||
|  |         method="GET", | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |     assert response.body == b"fake firmware content" | ||||||
|  |     assert response.headers["Content-Type"] == "application/octet-stream" | ||||||
|  |     assert "attachment" in response.headers["Content-Disposition"] | ||||||
|  |     assert "test_device-firmware.bin" in response.headers["Content-Disposition"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.usefixtures("mock_ext_storage_path") | ||||||
|  | async def test_download_binary_handler_compressed( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_storage_json: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get with compression.""" | ||||||
|  |     # Create a fake binary file | ||||||
|  |     build_dir = tmp_path / ".esphome" / "build" / "test" | ||||||
|  |     build_dir.mkdir(parents=True) | ||||||
|  |     firmware_file = build_dir / "firmware.bin" | ||||||
|  |     original_content = b"fake firmware content for compression test" | ||||||
|  |     firmware_file.write_bytes(original_content) | ||||||
|  |  | ||||||
|  |     # Mock storage JSON | ||||||
|  |     mock_storage = Mock() | ||||||
|  |     mock_storage.name = "test_device" | ||||||
|  |     mock_storage.firmware_bin_path = str(firmware_file) | ||||||
|  |     mock_storage_json.load.return_value = mock_storage | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", | ||||||
|  |         method="GET", | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |     # Decompress and verify content | ||||||
|  |     decompressed = gzip.decompress(response.body) | ||||||
|  |     assert decompressed == original_content | ||||||
|  |     assert response.headers["Content-Type"] == "application/octet-stream" | ||||||
|  |     assert "firmware.bin.gz" in response.headers["Content-Disposition"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.usefixtures("mock_ext_storage_path") | ||||||
|  | async def test_download_binary_handler_custom_download_name( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_storage_json: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get with custom download name.""" | ||||||
|  |     # Create a fake binary file | ||||||
|  |     build_dir = tmp_path / ".esphome" / "build" / "test" | ||||||
|  |     build_dir.mkdir(parents=True) | ||||||
|  |     firmware_file = build_dir / "firmware.bin" | ||||||
|  |     firmware_file.write_bytes(b"content") | ||||||
|  |  | ||||||
|  |     # Mock storage JSON | ||||||
|  |     mock_storage = Mock() | ||||||
|  |     mock_storage.name = "test_device" | ||||||
|  |     mock_storage.firmware_bin_path = str(firmware_file) | ||||||
|  |     mock_storage_json.load.return_value = mock_storage | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", | ||||||
|  |         method="GET", | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |     assert "custom_name.bin" in response.headers["Content-Disposition"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.usefixtures("mock_ext_storage_path") | ||||||
|  | async def test_download_binary_handler_idedata_fallback( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_async_run_system_command: MagicMock, | ||||||
|  |     mock_storage_json: MagicMock, | ||||||
|  |     mock_idedata: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" | ||||||
|  |     # Create build directory but no bootloader file initially | ||||||
|  |     build_dir = tmp_path / ".esphome" / "build" / "test" | ||||||
|  |     build_dir.mkdir(parents=True) | ||||||
|  |     firmware_file = build_dir / "firmware.bin" | ||||||
|  |     firmware_file.write_bytes(b"firmware") | ||||||
|  |  | ||||||
|  |     # Create bootloader file that idedata will find | ||||||
|  |     bootloader_file = tmp_path / "bootloader.bin" | ||||||
|  |     bootloader_file.write_bytes(b"bootloader content") | ||||||
|  |  | ||||||
|  |     # Mock storage JSON | ||||||
|  |     mock_storage = Mock() | ||||||
|  |     mock_storage.name = "test_device" | ||||||
|  |     mock_storage.firmware_bin_path = str(firmware_file) | ||||||
|  |     mock_storage_json.load.return_value = mock_storage | ||||||
|  |  | ||||||
|  |     # Mock idedata response | ||||||
|  |     mock_image = Mock() | ||||||
|  |     mock_image.path = str(bootloader_file) | ||||||
|  |     mock_idedata_instance = Mock() | ||||||
|  |     mock_idedata_instance.extra_flash_images = [mock_image] | ||||||
|  |     mock_idedata.return_value = mock_idedata_instance | ||||||
|  |  | ||||||
|  |     # Mock async_run_system_command to return idedata JSON | ||||||
|  |     mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/download.bin?configuration=test.yaml&file=bootloader.bin", | ||||||
|  |         method="GET", | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |     assert response.body == b"bootloader content" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_edit_request_handler_post_invalid_file( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the EditRequestHandler.post with non-yaml file.""" | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/edit?configuration=test.txt", | ||||||
|  |             method="POST", | ||||||
|  |             body=b"content", | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_edit_request_handler_post_existing( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_dashboard_settings: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the EditRequestHandler.post with existing yaml file.""" | ||||||
|  |     # Create a temporary yaml file to edit (don't modify fixtures) | ||||||
|  |     test_file = tmp_path / "test_edit.yaml" | ||||||
|  |     test_file.write_text("esphome:\n  name: original\n") | ||||||
|  |  | ||||||
|  |     # Configure the mock settings | ||||||
|  |     mock_dashboard_settings.rel_path.return_value = str(test_file) | ||||||
|  |     mock_dashboard_settings.absolute_config_dir = test_file.parent | ||||||
|  |  | ||||||
|  |     new_content = "esphome:\n  name: modified\n" | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/edit?configuration=test_edit.yaml", | ||||||
|  |         method="POST", | ||||||
|  |         body=new_content.encode(), | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |  | ||||||
|  |     # Verify the file was actually modified | ||||||
|  |     assert test_file.read_text() == new_content | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_unarchive_request_handler( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     mock_archive_storage_path: MagicMock, | ||||||
|  |     mock_dashboard_settings: MagicMock, | ||||||
|  |     tmp_path: Path, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the UnArchiveRequestHandler.post method.""" | ||||||
|  |     # Set up an archived file | ||||||
|  |     archive_dir = Path(mock_archive_storage_path.return_value) | ||||||
|  |     archive_dir.mkdir(parents=True, exist_ok=True) | ||||||
|  |     archived_file = archive_dir / "archived.yaml" | ||||||
|  |     archived_file.write_text("test content") | ||||||
|  |  | ||||||
|  |     # Set up the destination path where the file should be moved | ||||||
|  |     config_dir = tmp_path / "config" | ||||||
|  |     config_dir.mkdir(parents=True, exist_ok=True) | ||||||
|  |     destination_file = config_dir / "archived.yaml" | ||||||
|  |     mock_dashboard_settings.rel_path.return_value = str(destination_file) | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/unarchive?configuration=archived.yaml", | ||||||
|  |         method="POST", | ||||||
|  |         body=b"", | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |  | ||||||
|  |     # Verify the file was actually moved from archive to config | ||||||
|  |     assert not archived_file.exists()  # File should be gone from archive | ||||||
|  |     assert destination_file.exists()  # File should now be in config | ||||||
|  |     assert destination_file.read_text() == "test content"  # Content preserved | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: | ||||||
|  |     """Test the SecretKeysRequestHandler.get when no secrets file exists.""" | ||||||
|  |     # By default, there's no secrets file in the test fixtures | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch("/secret_keys", method="GET") | ||||||
|  |     assert exc_info.value.code == 404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_secret_keys_handler_with_file( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_dashboard_settings: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the SecretKeysRequestHandler.get when secrets file exists.""" | ||||||
|  |     # Create a secrets file in temp directory | ||||||
|  |     secrets_file = tmp_path / "secrets.yaml" | ||||||
|  |     secrets_file.write_text( | ||||||
|  |         "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Configure mock to return our temp secrets file | ||||||
|  |     # Since the file actually exists, os.path.isfile will return True naturally | ||||||
|  |     mock_dashboard_settings.rel_path.return_value = str(secrets_file) | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch("/secret_keys", method="GET") | ||||||
|  |     assert response.code == 200 | ||||||
|  |     data = json.loads(response.body.decode()) | ||||||
|  |     assert "wifi_ssid" in data | ||||||
|  |     assert "wifi_password" in data | ||||||
|  |     assert "api_key" in data | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_json_config_handler( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     mock_async_run_system_command: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the JsonConfigRequestHandler.get method.""" | ||||||
|  |     # This will actually run the esphome config command on pico.yaml | ||||||
|  |     mock_output = json.dumps( | ||||||
|  |         { | ||||||
|  |             "esphome": {"name": "pico"}, | ||||||
|  |             "esp32": {"board": "esp32dev"}, | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     mock_async_run_system_command.return_value = (0, mock_output, "") | ||||||
|  |  | ||||||
|  |     response = await dashboard.fetch( | ||||||
|  |         "/json-config?configuration=pico.yaml", method="GET" | ||||||
|  |     ) | ||||||
|  |     assert response.code == 200 | ||||||
|  |     data = json.loads(response.body.decode()) | ||||||
|  |     assert data["esphome"]["name"] == "pico" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_json_config_handler_invalid_config( | ||||||
|  |     dashboard: DashboardTestHelper, | ||||||
|  |     mock_async_run_system_command: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the JsonConfigRequestHandler.get with invalid config.""" | ||||||
|  |     # Simulate esphome config command failure | ||||||
|  |     mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") | ||||||
|  |  | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") | ||||||
|  |     assert exc_info.value.code == 422 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: | ||||||
|  |     """Test the JsonConfigRequestHandler.get with non-existent file.""" | ||||||
|  |     with pytest.raises(HTTPClientError) as exc_info: | ||||||
|  |         await dashboard.fetch( | ||||||
|  |             "/json-config?configuration=nonexistent.yaml", method="GET" | ||||||
|  |         ) | ||||||
|  |     assert exc_info.value.code == 404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_start_web_server_with_address_port( | ||||||
|  |     tmp_path: Path, | ||||||
|  |     mock_trash_storage_path: MagicMock, | ||||||
|  |     mock_archive_storage_path: MagicMock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test the start_web_server function with address and port.""" | ||||||
|  |     app = Mock() | ||||||
|  |     trash_dir = Path(mock_trash_storage_path.return_value) | ||||||
|  |     archive_dir = Path(mock_archive_storage_path.return_value) | ||||||
|  |  | ||||||
|  |     # Create trash dir to test migration | ||||||
|  |     trash_dir.mkdir() | ||||||
|  |     (trash_dir / "old.yaml").write_text("old") | ||||||
|  |  | ||||||
|  |     web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) | ||||||
|  |  | ||||||
|  |     # The function calls app.listen directly for non-socket mode | ||||||
|  |     app.listen.assert_called_once_with(6052, "127.0.0.1") | ||||||
|  |  | ||||||
|  |     # Verify trash was moved to archive | ||||||
|  |     assert not trash_dir.exists() | ||||||
|  |     assert archive_dir.exists() | ||||||
|  |     assert (archive_dir / "old.yaml").exists() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") | ||||||
|  | @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") | ||||||
|  | def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: | ||||||
|  |     """Test the start_web_server function with unix socket.""" | ||||||
|  |     app = Mock() | ||||||
|  |     socket_path = tmp_path / "test.sock" | ||||||
|  |  | ||||||
|  |     # Don't create trash_dir - it doesn't exist, so no migration needed | ||||||
|  |     with ( | ||||||
|  |         patch("tornado.httpserver.HTTPServer") as mock_server_class, | ||||||
|  |         patch("tornado.netutil.bind_unix_socket") as mock_bind, | ||||||
|  |     ): | ||||||
|  |         server = Mock() | ||||||
|  |         mock_server_class.return_value = server | ||||||
|  |         mock_bind.return_value = Mock() | ||||||
|  |  | ||||||
|  |         web_server.start_web_server( | ||||||
|  |             app, str(socket_path), None, None, str(tmp_path / "config") | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         mock_server_class.assert_called_once_with(app) | ||||||
|  |         mock_bind.assert_called_once_with(str(socket_path), mode=0o666) | ||||||
|  |         server.add_socket.assert_called_once() | ||||||
|   | |||||||
							
								
								
									
										512
									
								
								tests/unit_tests/test_main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										512
									
								
								tests/unit_tests/test_main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,512 @@ | |||||||
|  | """Unit tests for esphome.__main__ module.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from collections.abc import Generator | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from typing import Any | ||||||
|  | from unittest.mock import Mock, patch | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from esphome.__main__ import choose_upload_log_host | ||||||
|  | from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI | ||||||
|  | from esphome.core import CORE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class MockSerialPort: | ||||||
|  |     """Mock serial port for testing. | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         path (str): The device path of the mock serial port (e.g., '/dev/ttyUSB0'). | ||||||
|  |         description (str): A human-readable description of the mock serial port. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     path: str | ||||||
|  |     description: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def setup_core( | ||||||
|  |     config: dict[str, Any] | None = None, address: str | None = None | ||||||
|  | ) -> None: | ||||||
|  |     """ | ||||||
|  |     Helper to set up CORE configuration with optional address. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. | ||||||
|  |         address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. | ||||||
|  |     """ | ||||||
|  |     if config is None: | ||||||
|  |         config = {} | ||||||
|  |  | ||||||
|  |     if address is not None: | ||||||
|  |         # Set address via wifi config (could also use ethernet) | ||||||
|  |         config[CONF_WIFI] = {CONF_USE_ADDRESS: address} | ||||||
|  |  | ||||||
|  |     CORE.config = config | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_no_serial_ports() -> Generator[Mock]: | ||||||
|  |     """Mock get_serial_ports to return no ports.""" | ||||||
|  |     with patch("esphome.__main__.get_serial_ports", return_value=[]) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_serial_ports() -> Generator[Mock]: | ||||||
|  |     """Mock get_serial_ports to return test ports.""" | ||||||
|  |     mock_ports = [ | ||||||
|  |         MockSerialPort("/dev/ttyUSB0", "USB Serial"), | ||||||
|  |         MockSerialPort("/dev/ttyUSB1", "Another USB Serial"), | ||||||
|  |     ] | ||||||
|  |     with patch("esphome.__main__.get_serial_ports", return_value=mock_ports) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_choose_prompt() -> Generator[Mock]: | ||||||
|  |     """Mock choose_prompt to return default selection.""" | ||||||
|  |     with patch("esphome.__main__.choose_prompt", return_value="/dev/ttyUSB0") as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_no_mqtt_logging() -> Generator[Mock]: | ||||||
|  |     """Mock has_mqtt_logging to return False.""" | ||||||
|  |     with patch("esphome.__main__.has_mqtt_logging", return_value=False) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def mock_has_mqtt_logging() -> Generator[Mock]: | ||||||
|  |     """Mock has_mqtt_logging to return True.""" | ||||||
|  |     with patch("esphome.__main__.has_mqtt_logging", return_value=True) as mock: | ||||||
|  |         yield mock | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_string_default() -> None: | ||||||
|  |     """Test with a single string default device.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="192.168.1.100", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_list_default() -> None: | ||||||
|  |     """Test with a list of default devices.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["192.168.1.100", "192.168.1.101"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100", "192.168.1.101"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: | ||||||
|  |     """Test with multiple IP addresses as defaults.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["1.2.3.4", "4.5.5.6"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["1.2.3.4", "4.5.5.6"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: | ||||||
|  |     """Test with a mix of hostnames and IP addresses.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["host.one", "host.one.local", "1.2.3.4"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["host.one", "host.one.local", "1.2.3.4"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_ota_list() -> None: | ||||||
|  |     """Test with OTA as the only item in the list.""" | ||||||
|  |     setup_core(config={"ota": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["OTA"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_has_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: | ||||||
|  |     """Test with OTA list falling back to MQTT when no address.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["OTA"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=True, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["MQTT"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_with_serial_device_no_ports( | ||||||
|  |     caplog: pytest.LogCaptureFixture, | ||||||
|  | ) -> None: | ||||||
|  |     """Test SERIAL device when no serial ports are found.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="SERIAL", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == [] | ||||||
|  |     assert "No serial ports found, skipping SERIAL device" in caplog.text | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_serial_ports") | ||||||
|  | def test_choose_upload_log_host_with_serial_device_with_ports( | ||||||
|  |     mock_choose_prompt: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test SERIAL device when serial ports are available.""" | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="SERIAL", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |         purpose="testing", | ||||||
|  |     ) | ||||||
|  |     assert result == ["/dev/ttyUSB0"] | ||||||
|  |     mock_choose_prompt.assert_called_once_with( | ||||||
|  |         [ | ||||||
|  |             ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), | ||||||
|  |             ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), | ||||||
|  |         ], | ||||||
|  |         purpose="testing", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: | ||||||
|  |     """Test OTA device when OTA is configured.""" | ||||||
|  |     setup_core(config={"ota": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: | ||||||
|  |     """Test OTA device when API is configured.""" | ||||||
|  |     setup_core(config={"api": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=True, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_has_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: | ||||||
|  |     """Test OTA device fallback to MQTT when no OTA/API config.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=True, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["MQTT"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: | ||||||
|  |     """Test OTA device with no valid fallback options.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=True, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_choose_prompt") | ||||||
|  | def test_choose_upload_log_host_multiple_devices() -> None: | ||||||
|  |     """Test with multiple devices including special identifiers.""" | ||||||
|  |     setup_core(config={"ota": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] | ||||||
|  |  | ||||||
|  |     with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=["192.168.1.50", "OTA", "SERIAL"], | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=True, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=False, | ||||||
|  |         ) | ||||||
|  |         assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_no_defaults_with_serial_ports( | ||||||
|  |     mock_choose_prompt: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test interactive mode with serial ports available.""" | ||||||
|  |     mock_ports = [ | ||||||
|  |         MockSerialPort("/dev/ttyUSB0", "USB Serial"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=False, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=False, | ||||||
|  |             purpose="uploading", | ||||||
|  |         ) | ||||||
|  |         assert result == ["/dev/ttyUSB0"] | ||||||
|  |         mock_choose_prompt.assert_called_once_with( | ||||||
|  |             [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], | ||||||
|  |             purpose="uploading", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_no_defaults_with_ota() -> None: | ||||||
|  |     """Test interactive mode with OTA option.""" | ||||||
|  |     setup_core(config={"ota": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     with patch( | ||||||
|  |         "esphome.__main__.choose_prompt", return_value="192.168.1.100" | ||||||
|  |     ) as mock_prompt: | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=True, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=False, | ||||||
|  |         ) | ||||||
|  |         assert result == ["192.168.1.100"] | ||||||
|  |         mock_prompt.assert_called_once_with( | ||||||
|  |             [("Over The Air (192.168.1.100)", "192.168.1.100")], | ||||||
|  |             purpose=None, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_no_defaults_with_api() -> None: | ||||||
|  |     """Test interactive mode with API option.""" | ||||||
|  |     setup_core(config={"api": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     with patch( | ||||||
|  |         "esphome.__main__.choose_prompt", return_value="192.168.1.100" | ||||||
|  |     ) as mock_prompt: | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=False, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=True, | ||||||
|  |         ) | ||||||
|  |         assert result == ["192.168.1.100"] | ||||||
|  |         mock_prompt.assert_called_once_with( | ||||||
|  |             [("Over The Air (192.168.1.100)", "192.168.1.100")], | ||||||
|  |             purpose=None, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports", "mock_has_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: | ||||||
|  |     """Test interactive mode with MQTT option.""" | ||||||
|  |     setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) | ||||||
|  |  | ||||||
|  |     with patch("esphome.__main__.choose_prompt", return_value="MQTT") as mock_prompt: | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=False, | ||||||
|  |             show_mqtt=True, | ||||||
|  |             show_api=False, | ||||||
|  |         ) | ||||||
|  |         assert result == ["MQTT"] | ||||||
|  |         mock_prompt.assert_called_once_with( | ||||||
|  |             [("MQTT (mqtt.local)", "MQTT")], | ||||||
|  |             purpose=None, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_has_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_no_defaults_with_all_options( | ||||||
|  |     mock_choose_prompt: Mock, | ||||||
|  | ) -> None: | ||||||
|  |     """Test interactive mode with all options available.""" | ||||||
|  |     setup_core( | ||||||
|  |         config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, | ||||||
|  |         address="192.168.1.100", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] | ||||||
|  |  | ||||||
|  |     with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=True, | ||||||
|  |             show_mqtt=True, | ||||||
|  |             show_api=True, | ||||||
|  |             purpose="testing", | ||||||
|  |         ) | ||||||
|  |         assert result == ["/dev/ttyUSB0"] | ||||||
|  |  | ||||||
|  |         expected_options = [ | ||||||
|  |             ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), | ||||||
|  |             ("Over The Air (192.168.1.100)", "192.168.1.100"), | ||||||
|  |             ("MQTT (mqtt.local)", "MQTT"), | ||||||
|  |         ] | ||||||
|  |         mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_check_default_matches() -> None: | ||||||
|  |     """Test when check_default matches an available option.""" | ||||||
|  |     setup_core(config={"ota": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=None, | ||||||
|  |         check_default="192.168.1.100", | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_check_default_no_match() -> None: | ||||||
|  |     """Test when check_default doesn't match any available option.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     with patch( | ||||||
|  |         "esphome.__main__.choose_prompt", return_value="fallback" | ||||||
|  |     ) as mock_prompt: | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=None, | ||||||
|  |             check_default="192.168.1.100", | ||||||
|  |             show_ota=False, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=False, | ||||||
|  |         ) | ||||||
|  |         assert result == ["fallback"] | ||||||
|  |         mock_prompt.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports") | ||||||
|  | def test_choose_upload_log_host_empty_defaults_list() -> None: | ||||||
|  |     """Test with an empty list as default.""" | ||||||
|  |     with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: | ||||||
|  |         result = choose_upload_log_host( | ||||||
|  |             default=[], | ||||||
|  |             check_default=None, | ||||||
|  |             show_ota=False, | ||||||
|  |             show_mqtt=False, | ||||||
|  |             show_api=False, | ||||||
|  |         ) | ||||||
|  |         assert result == ["chosen"] | ||||||
|  |         mock_prompt.assert_called_once() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_all_devices_unresolved( | ||||||
|  |     caplog: pytest.LogCaptureFixture, | ||||||
|  | ) -> None: | ||||||
|  |     """Test when all specified devices cannot be resolved.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["SERIAL", "OTA"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == [] | ||||||
|  |     assert ( | ||||||
|  |         "All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: | ||||||
|  |     """Test with a mix of resolved and unresolved devices.""" | ||||||
|  |     setup_core() | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default=["192.168.1.50", "SERIAL", "OTA"], | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=False, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.50"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_choose_upload_log_host_ota_both_conditions() -> None: | ||||||
|  |     """Test OTA device when both OTA and API are configured and enabled.""" | ||||||
|  |     setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=True, | ||||||
|  |     ) | ||||||
|  |     assert result == ["192.168.1.100"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.usefixtures("mock_no_mqtt_logging") | ||||||
|  | def test_choose_upload_log_host_no_address_with_ota_config() -> None: | ||||||
|  |     """Test OTA device when OTA is configured but no address is set.""" | ||||||
|  |     setup_core(config={"ota": {}}) | ||||||
|  |  | ||||||
|  |     result = choose_upload_log_host( | ||||||
|  |         default="OTA", | ||||||
|  |         check_default=None, | ||||||
|  |         show_ota=True, | ||||||
|  |         show_mqtt=False, | ||||||
|  |         show_api=False, | ||||||
|  |     ) | ||||||
|  |     assert result == [] | ||||||
		Reference in New Issue
	
	Block a user