Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2

- name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.4
uses: github/codeql-action/init@v3.29.5
with:
languages: python

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.4
uses: github/codeql-action/analyze@v3.29.5
with:
category: "/language:python"
8 changes: 1 addition & 7 deletions homeassistant/components/ecovacs/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from deebot_client.capabilities import Capabilities, DeviceType
from deebot_client.device import Device
from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.models import CleanAction, CleanMode, Room, State
import sucks

Expand Down Expand Up @@ -216,7 +216,6 @@ class EcovacsVacuum(
VacuumEntityFeature.PAUSE
| VacuumEntityFeature.STOP
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATE
Expand All @@ -243,10 +242,6 @@ async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()

async def on_battery(event: BatteryEvent) -> None:
self._attr_battery_level = event.value
self.async_write_ha_state()

async def on_rooms(event: RoomsEvent) -> None:
self._rooms = event.rooms
self.async_write_ha_state()
Expand All @@ -255,7 +250,6 @@ async def on_status(event: StateEvent) -> None:
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()

self._subscribe(self._capability.battery.event, on_battery)
self._subscribe(self._capability.state.event, on_status)

if self._capability.fan_speed:
Expand Down
30 changes: 29 additions & 1 deletion homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .encryption_key_storage import async_get_encryption_key_storage
from .entry_data import ESPHomeConfigEntry
from .manager import async_replace_device

Expand Down Expand Up @@ -159,7 +160,10 @@ async def async_step_reauth_confirm(
"""Handle reauthorization flow."""
errors = {}

if await self._retrieve_encryption_key_from_dashboard():
if (
await self._retrieve_encryption_key_from_storage()
or await self._retrieve_encryption_key_from_dashboard()
):
error = await self.fetch_device_info()
if error is None:
return await self._async_authenticate_or_add()
Expand Down Expand Up @@ -226,9 +230,12 @@ async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
response = await self.fetch_device_info()
self._noise_psk = None

# Try to retrieve an existing key from dashboard or storage.
if (
self._device_name
and await self._retrieve_encryption_key_from_dashboard()
) or (
self._device_mac and await self._retrieve_encryption_key_from_storage()
):
response = await self.fetch_device_info()

Expand Down Expand Up @@ -284,6 +291,7 @@ async def async_step_zeroconf(
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host
self._port = discovery_info.port
self._device_mac = mac_address
self._noise_required = bool(discovery_info.properties.get("api_encryption"))

# Check if already configured
Expand Down Expand Up @@ -772,6 +780,26 @@ async def _retrieve_encryption_key_from_dashboard(self) -> bool:
self._noise_psk = noise_psk
return True

async def _retrieve_encryption_key_from_storage(self) -> bool:
"""Try to retrieve the encryption key from storage.

Return boolean if a key was retrieved.
"""
# Try to get MAC address from current flow state or reauth entry
mac_address = self._device_mac
if mac_address is None and self._reauth_entry is not None:
# In reauth flow, get MAC from the existing entry's unique_id
mac_address = self._reauth_entry.unique_id

assert mac_address is not None

storage = await async_get_encryption_key_storage(self.hass)
if stored_key := await storage.async_get_key(mac_address):
self._noise_psk = stored_key
return True

return False

@staticmethod
@callback
def async_get_options_flow(
Expand Down
94 changes: 94 additions & 0 deletions homeassistant/components/esphome/encryption_key_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Encryption key storage for ESPHome devices."""

from __future__ import annotations

import logging
from typing import TypedDict

from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey

_LOGGER = logging.getLogger(__name__)

ENCRYPTION_KEY_STORAGE_VERSION = 1
ENCRYPTION_KEY_STORAGE_KEY = "esphome.encryption_keys"


class EncryptionKeyData(TypedDict):
"""Encryption key storage data."""

keys: dict[str, str] # MAC address -> base64 encoded key


KEY_ENCRYPTION_STORAGE: HassKey[ESPHomeEncryptionKeyStorage] = HassKey(
"esphome_encryption_key_storage"
)


class ESPHomeEncryptionKeyStorage:
"""Storage for ESPHome encryption keys."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the encryption key storage."""
self.hass = hass
self._store = Store[EncryptionKeyData](
hass,
ENCRYPTION_KEY_STORAGE_VERSION,
ENCRYPTION_KEY_STORAGE_KEY,
encoder=JSONEncoder,
)
self._data: EncryptionKeyData | None = None

async def async_load(self) -> None:
"""Load encryption keys from storage."""
if self._data is None:
data = await self._store.async_load()
self._data = data or {"keys": {}}

async def async_save(self) -> None:
"""Save encryption keys to storage."""
if self._data is not None:
await self._store.async_save(self._data)

async def async_get_key(self, mac_address: str) -> str | None:
"""Get encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
return self._data["keys"].get(mac_address.lower())

async def async_store_key(self, mac_address: str, key: str) -> None:
"""Store encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
self._data["keys"][mac_address.lower()] = key
await self.async_save()
_LOGGER.debug(
"Stored encryption key for device with MAC %s",
mac_address,
)

async def async_remove_key(self, mac_address: str) -> None:
"""Remove encryption key for a MAC address."""
await self.async_load()
assert self._data is not None
lower_mac_address = mac_address.lower()
if lower_mac_address in self._data["keys"]:
del self._data["keys"][lower_mac_address]
await self.async_save()
_LOGGER.debug(
"Removed encryption key for device with MAC %s",
mac_address,
)


@singleton(KEY_ENCRYPTION_STORAGE, async_=True)
async def async_get_encryption_key_storage(
hass: HomeAssistant,
) -> ESPHomeEncryptionKeyStorage:
"""Get the encryption key storage instance."""
storage = ESPHomeEncryptionKeyStorage(hass)
await storage.async_load()
return storage
98 changes: 95 additions & 3 deletions homeassistant/components/esphome/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from __future__ import annotations

import asyncio
import base64
from functools import partial
import logging
import secrets
from typing import TYPE_CHECKING, Any, NamedTuple

from aioesphomeapi import (
Expand Down Expand Up @@ -68,6 +70,7 @@
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_MAC_ADDRESS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_URL,
Expand All @@ -78,16 +81,15 @@
)
from .dashboard import async_get_dashboard
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage

# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData

DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"

if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SubscribeLogsResponse,
)
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001


_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -515,6 +517,8 @@ async def _on_connect(self) -> None:
assert api_version is not None, "API version must be set"
entry_data.async_on_connect(device_info, api_version)

await self._handle_dynamic_encryption_key(device_info)

if device_info.name:
reconnect_logic.name = device_info.name

Expand Down Expand Up @@ -618,6 +622,7 @@ async def on_connect_error(self, err: Exception) -> None:
),
):
return

if isinstance(err, InvalidEncryptionKeyAPIError):
if (
(received_name := err.received_name)
Expand Down Expand Up @@ -648,6 +653,93 @@ async def on_connect_error(self, err: Exception) -> None:
return
self.entry.async_start_reauth(self.hass)

async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
) -> None:
"""Handle dynamic encryption keys.

If a device reports it supports encryption, but we connected without a key,
we need to generate and store one.
"""
noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK)
if noise_psk:
# we're already connected with a noise PSK - nothing to do
return

if not device_info.api_encryption_supported:
# device does not support encryption - nothing to do
return

# Connected to device without key and the device supports encryption
storage = await async_get_encryption_key_storage(self.hass)

# First check if we have a key in storage for this device
from_storage: bool = False
if self.entry.unique_id and (
stored_key := await storage.async_get_key(self.entry.unique_id)
):
_LOGGER.debug(
"Retrieved encryption key from storage for device %s",
self.entry.unique_id,
)
# Use the stored key
new_key = stored_key.encode()
new_key_str = stored_key
from_storage = True
else:
# No stored key found, generate a new one
_LOGGER.debug(
"Generating new encryption key for device %s", self.entry.unique_id
)
new_key = base64.b64encode(secrets.token_bytes(32))
new_key_str = new_key.decode()

try:
# Store the key on the device using the existing connection
result = await self.cli.noise_encryption_set_key(new_key)
except APIConnectionError as ex:
_LOGGER.error(
"Connection error while storing encryption key for device %s (%s): %s",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
ex,
)
return
else:
if not result:
_LOGGER.error(
"Failed to set dynamic encryption key on device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)
return

# Key stored successfully on device
assert self.entry.unique_id is not None

# Only store in storage if it was newly generated
if not from_storage:
await storage.async_store_key(self.entry.unique_id, new_key_str)

# Always update config entry
self.hass.config_entries.async_update_entry(
self.entry,
data={**self.entry.data, CONF_NOISE_PSK: new_key_str},
)

if from_storage:
_LOGGER.info(
"Set encryption key from storage on device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)
else:
_LOGGER.info(
"Generated and stored encryption key for device %s (%s)",
self.entry.data.get(CONF_DEVICE_NAME, self.host),
self.entry.unique_id,
)

@callback
def _async_handle_logging_changed(self, _event: Event) -> None:
"""Handle when the logging level changes."""
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/leaone/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
}
},
"abort": {
"no_devices_found": "No supported LeaOne devices found in range; If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.",
"no_devices_found": "No supported LeaOne devices found in range. If the device is in range, ensure it has been activated in the last few minutes. If you need clarification on whether the device is in range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the LeaOne device is present.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
Expand Down
9 changes: 9 additions & 0 deletions homeassistant/components/mqtt/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ def process_write_state_requests(self, msg: MQTTMessage) -> None:
entity_id, entity = self.subscribe_calls.popitem()
try:
entity.async_write_ha_state()
except ValueError as exc:
_LOGGER.error(
"Value error while updating state of %s, topic: "
"'%s' with payload: %s: %s",
entity_id,
msg.topic,
msg.payload,
exc,
)
except Exception:
_LOGGER.exception(
"Exception raised while updating state of %s, topic: "
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/scrape/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/scrape",
"iot_class": "cloud_polling",
"requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"]
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.0"]
}
Loading
Loading