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@v5.0.0

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

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.8
uses: github/codeql-action/analyze@v3.29.9
with:
category: "/language:python"
53 changes: 28 additions & 25 deletions homeassistant/components/cloud/alexa_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
from collections.abc import Callable
from contextlib import suppress
from datetime import datetime, timedelta
from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any

import aiohttp
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa import AlexaApiError, Cloud
from hass_nabucasa.alexa_api import (
AlexaAccessTokenDetails,
AlexaApiNeedsRelinkError,
AlexaApiNoTokenError,
)
from yarl import URL

from homeassistant.components import persistent_notification
Expand Down Expand Up @@ -146,7 +150,7 @@ def __init__(
self._cloud_user = cloud_user
self._prefs = prefs
self._cloud = cloud
self._token = None
self._token: str | None = None
self._token_valid: datetime | None = None
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None
Expand Down Expand Up @@ -318,32 +322,31 @@ def async_invalidate_access_token(self) -> None:

async def async_get_access_token(self) -> str | None:
"""Get an access token."""
details: AlexaAccessTokenDetails | None
if self._token_valid is not None and self._token_valid > utcnow():
return self._token

resp = await cloud_api.async_alexa_access_token(self._cloud)
body = await resp.json()

if resp.status == HTTPStatus.BAD_REQUEST:
if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"):
if self.should_report_state:
persistent_notification.async_create(
self.hass,
(
"There was an error reporting state to Alexa"
f" ({body['reason']}). Please re-link your Alexa skill via"
" the Alexa app to continue using it."
),
"Alexa state reporting disabled",
"cloud_alexa_report",
)
raise alexa_errors.RequireRelink

raise alexa_errors.NoTokenAvailable
try:
details = await self._cloud.alexa_api.access_token()
except AlexaApiNeedsRelinkError as exception:
if self.should_report_state:
persistent_notification.async_create(
self.hass,
(
"There was an error reporting state to Alexa"
f" ({exception.reason}). Please re-link your Alexa skill via"
" the Alexa app to continue using it."
),
"Alexa state reporting disabled",
"cloud_alexa_report",
)
raise alexa_errors.RequireRelink from exception
except (AlexaApiNoTokenError, AlexaApiError) as exception:
raise alexa_errors.NoTokenAvailable from exception

self._token = body["access_token"]
self._endpoint = body["event_endpoint"]
self._token_valid = utcnow() + timedelta(seconds=body["expires_in"])
self._token = details["access_token"]
self._endpoint = details["event_endpoint"]
self._token_valid = utcnow() + timedelta(seconds=details["expires_in"])
return self._token

async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/foscam/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
STREAMS = ["Main", "Sub"]

DEFAULT_PORT = 88
DEFAULT_RTSP_PORT = 554
DEFAULT_RTSP_PORT = 88


DATA_SCHEMA = vol.Schema(
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/foscam/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
"stream": "Stream"
},
"data_description": {
"host": "The hostname or IP address of your Foscam camera."
"host": "The hostname or IP address of your Foscam camera.",
"port": "The port of your Foscam camera, default is 88.",
"username": "The username to log in to your Foscam camera.",
"password": "The password to log in to your Foscam camera.",
"rtsp_port": "The RTSP protocol port of the camera, used to pull the camera's real-time video stream. New model cameras only support RTSP ports 88 and 554, while old model cameras only support ports 88 and 65534.",
"stream": "Select the video stream type to pull. The main stream offers higher clarity but requires a better network environment."
}
}
},
Expand Down
18 changes: 16 additions & 2 deletions homeassistant/components/group/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ async def light_switch_options_schema(
)


LIGHT_CONFIG_SCHEMA = basic_group_config_schema("light").extend(
{
vol.Required(CONF_ALL, default=False): selector.BooleanSelector(),
}
)


SWITCH_CONFIG_SCHEMA = basic_group_config_schema("switch").extend(
{
vol.Required(CONF_ALL, default=False): selector.BooleanSelector(),
}
)


GROUP_TYPES = [
"binary_sensor",
"button",
Expand Down Expand Up @@ -210,7 +224,7 @@ async def _set_group_type(
validate_user_input=set_group_type("fan"),
),
"light": SchemaFlowFormStep(
basic_group_config_schema("light"),
LIGHT_CONFIG_SCHEMA,
preview="group",
validate_user_input=set_group_type("light"),
),
Expand All @@ -235,7 +249,7 @@ async def _set_group_type(
validate_user_input=set_group_type("sensor"),
),
"switch": SchemaFlowFormStep(
basic_group_config_schema("switch"),
SWITCH_CONFIG_SCHEMA,
preview="group",
validate_user_input=set_group_type("switch"),
),
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/group/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@
"light": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"lock": {
Expand Down Expand Up @@ -115,9 +119,13 @@
"switch": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/kitchen_sink/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async def async_step_reconfigure_sensor(
"""Reconfigure a sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
Expand Down
132 changes: 126 additions & 6 deletions homeassistant/components/media_player/intent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Intents for the media_player integration."""

import asyncio
from collections.abc import Iterable
from dataclasses import dataclass, field
import logging
Expand All @@ -14,21 +15,21 @@
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_VOLUME_SET,
STATE_PLAYING,
)
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent

from . import (
from . import MediaPlayerDeviceClass, MediaPlayerEntity
from .browse_media import SearchMedia
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_VOLUME_LEVEL,
DOMAIN,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
MediaPlayerDeviceClass,
SearchMedia,
)
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
MediaClass,
MediaPlayerEntityFeature,
MediaPlayerState,
Expand All @@ -39,6 +40,7 @@
INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
INTENT_SET_VOLUME = "HassSetVolume"
INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative"
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
device_classes={MediaPlayerDeviceClass},
),
)
intent.async_register(hass, MediaSetVolumeRelativeHandler())
intent.async_register(hass, MediaSearchAndPlayHandler())


Expand Down Expand Up @@ -354,3 +357,120 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse
response.async_set_speech_slots({"media": first_result.as_dict()})
response.response_type = intent.IntentResponseType.ACTION_DONE
return response


class MediaSetVolumeRelativeHandler(intent.IntentHandler):
"""Handler for setting relative volume."""

description = "Increases or decreases the volume of a media player"

intent_type = INTENT_SET_VOLUME_RELATIVE
slot_schema = {
vol.Required("volume_step"): vol.Any(
"up",
"down",
vol.All(
vol.Coerce(int),
vol.Range(min=-100, max=100),
lambda val: val / 100,
),
),
# Optional name/area/floor slots handled by intent matcher
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
vol.Optional("floor"): cv.string,
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): cv.string,
}
platforms = {DOMAIN}

async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN]

slots = self.async_validate_slots(intent_obj.slots)
volume_step = slots["volume_step"]["value"]

# Entity name to match
name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")

# Get area/floor info
area_slot = slots.get("area", {})
area_id = area_slot.get("value")

floor_slot = slots.get("floor", {})
floor_id = floor_slot.get("value")

# Find matching entities
match_constraints = intent.MatchTargetsConstraints(
name=entity_name,
area_name=area_id,
floor_name=floor_id,
domains={DOMAIN},
assistant=intent_obj.assistant,
features=MediaPlayerEntityFeature.VOLUME_SET,
)
match_preferences = intent.MatchTargetsPreferences(
area_id=slots.get("preferred_area_id", {}).get("value"),
floor_id=slots.get("preferred_floor_id", {}).get("value"),
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)

if not match_result.is_match:
# No targets
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)

if (
match_result.is_match
and (len(match_result.states) > 1)
and ("name" not in intent_obj.slots)
):
# Multiple targets not by name, so we need to check state
match_result.states = [
s for s in match_result.states if s.state == STATE_PLAYING
]
if not match_result.states:
# No media players are playing
raise intent.MatchFailedError(
result=intent.MatchTargetsResult(
is_match=False, no_match_reason=intent.MatchFailedReason.STATE
),
constraints=match_constraints,
preferences=match_preferences,
)

target_entity_ids = {s.entity_id for s in match_result.states}
target_entities = [
e for e in component.entities if e.entity_id in target_entity_ids
]

if volume_step == "up":
coros = [e.async_volume_up() for e in target_entities]
elif volume_step == "down":
coros = [e.async_volume_down() for e in target_entities]
else:
coros = [
e.async_set_volume_level(
max(0.0, min(1.0, e.volume_level + volume_step))
)
for e in target_entities
]

try:
await asyncio.gather(*coros)
except HomeAssistantError as err:
_LOGGER.error("Error setting relative volume: %s", err)
raise intent.IntentHandleError(
f"Error setting relative volume: {err}"
) from err

response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_states(match_result.states)
return response
2 changes: 1 addition & 1 deletion homeassistant/components/number/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class NumberDeviceClass(StrEnum):
APPARENT_POWER = "apparent_power"
"""Apparent power.

Unit of measurement: `VA`
Unit of measurement: `mVA`, `VA`
"""

AQI = "aqi"
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/recorder/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.unit_conversion import (
ApparentPowerConverter,
AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
Expand Down Expand Up @@ -193,6 +194,7 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]:


STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter),
**dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter),
**dict.fromkeys(
BloodGlucoseConcentrationConverter.VALID_UNITS,
Expand Down
Loading
Loading