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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ on:
type: boolean

env:
CACHE_VERSION: 9
CACHE_VERSION: 1
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11"
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/lametric/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

from .const import DOMAIN, LOGGER

DEVICES_URL = "https://developer.lametric.com/user/devices"


class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a LaMetric config flow."""
Expand Down Expand Up @@ -164,6 +166,9 @@ async def async_step_manual_entry(
return self.async_show_form(
step_id="manual_entry",
data_schema=vol.Schema(schema),
description_placeholders={
"devices_url": DEVICES_URL,
},
errors=errors,
)

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/lametric/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"data_description": {
"host": "The IP address or hostname of your LaMetric TIME on your network.",
"api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
"api_key": "You can find this API key in the [devices page in your LaMetric developer account]({devices_url})."
}
},
"cloud_select_device": {
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/litterrobot/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "bronze",
"requirements": ["pylitterbot==2024.2.4"]
"requirements": ["pylitterbot==2024.2.6"]
}
7 changes: 6 additions & 1 deletion homeassistant/components/mcp/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"MCP-Protocol-Version": "2025-03-26",
}

EXAMPLE_URL = "http://example/sse"


@dataclass
class OAuthConfig:
Expand Down Expand Up @@ -182,7 +184,10 @@ async def async_step_user(
return self.async_create_entry(title=info["title"], data=user_input)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={"example_url": EXAMPLE_URL},
)

async def async_step_auth_discovery(
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/mcp/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
"url": "The remote MCP server URL for the SSE endpoint, for example {example_url}"
}
},
"credentials_choice": {
Expand Down Expand Up @@ -35,7 +35,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
"invalid_url": "Must be a valid MCP server URL"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
Expand Down
47 changes: 45 additions & 2 deletions homeassistant/components/xbox/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@

from __future__ import annotations

from xbox.webapi.api.provider.smartglass.models import ConsoleType, SmartglassConsole

from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import Person, XboxUpdateCoordinator
from .coordinator import ConsoleData, Person, XboxUpdateCoordinator

MAP_MODEL = {
ConsoleType.XboxOne: "Xbox One",
ConsoleType.XboxOneS: "Xbox One S",
ConsoleType.XboxOneSDigital: "Xbox One S All-Digital",
ConsoleType.XboxOneX: "Xbox One X",
ConsoleType.XboxSeriesS: "Xbox Series S",
ConsoleType.XboxSeriesX: "Xbox Series X",
}


class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
Expand All @@ -21,7 +32,7 @@ def __init__(
xuid: str,
entity_description: EntityDescription,
) -> None:
"""Initialize Xbox binary sensor."""
"""Initialize Xbox entity."""
super().__init__(coordinator)
self.xuid = xuid
self.entity_description = entity_description
Expand All @@ -40,3 +51,35 @@ def __init__(
def data(self) -> Person:
"""Return coordinator data for this console."""
return self.coordinator.data.presence[self.xuid]


class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
"""Console base entity for the Xbox integration."""

_attr_has_entity_name = True

def __init__(
self,
console: SmartglassConsole,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize the Xbox Console entity."""

super().__init__(coordinator)
self.client = coordinator.client
self._console = console

self._attr_name = None
self._attr_unique_id = console.id

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, console.id)},
manufacturer="Microsoft",
model=MAP_MODEL.get(self._console.console_type, "Unknown"),
name=console.name,
)

@property
def data(self) -> ConsoleData:
"""Return coordinator data for this console."""
return self.coordinator.data.consoles[self._console.id]
84 changes: 20 additions & 64 deletions homeassistant/components/xbox/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,28 @@

from __future__ import annotations

import re
from typing import Any

from xbox.webapi.api.provider.catalog.models import Image
from xbox.webapi.api.provider.smartglass.models import (
PlaybackState,
PowerState,
SmartglassConsole,
VolumeDirection,
)

from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .browse_media import build_item_response
from .const import DOMAIN
from .coordinator import ConsoleData, XboxConfigEntry, XboxUpdateCoordinator
from .coordinator import XboxConfigEntry
from .entity import XboxConsoleBaseEntity

SUPPORT_XBOX = (
MediaPlayerEntityFeature.TURN_ON
Expand Down Expand Up @@ -69,33 +66,10 @@ async def async_setup_entry(
)


class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntity):
class XboxMediaPlayer(XboxConsoleBaseEntity, MediaPlayerEntity):
"""Representation of an Xbox Media Player."""

def __init__(
self,
console: SmartglassConsole,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize the Xbox Media Player."""
super().__init__(coordinator)
self.client = coordinator.client
self._console = console

@property
def name(self):
"""Return the device name."""
return self._console.name

@property
def unique_id(self):
"""Console device ID."""
return self._console.id

@property
def data(self) -> ConsoleData:
"""Return coordinator data for this console."""
return self.coordinator.data.consoles[self._console.id]
_attr_media_image_remotely_accessible = True

@property
def state(self) -> MediaPlayerState | None:
Expand All @@ -117,15 +91,15 @@ def supported_features(self) -> MediaPlayerEntityFeature:
return SUPPORT_XBOX

@property
def media_content_type(self):
def media_content_type(self) -> MediaType:
"""Media content type."""
app_details = self.data.app_details
if app_details and app_details.product_family == "Games":
return MediaType.GAME
return MediaType.APP

@property
def media_title(self):
def media_title(self) -> str | None:
"""Title of current playing media."""
if not (app_details := self.data.app_details):
return None
Expand All @@ -135,25 +109,18 @@ def media_title(self):
)

@property
def media_image_url(self):
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if not (app_details := self.data.app_details):
return None
image = _find_media_image(app_details.localized_properties[0].images)

if not image:
if not (app_details := self.data.app_details) or not (
image := _find_media_image(app_details.localized_properties[0].images)
):
return None

url = image.uri
if url[0] == "/":
url = f"http:{url}"
return url

@property
def media_image_remotely_accessible(self) -> bool:
"""If the image url is remotely accessible."""
return True

async def async_turn_on(self) -> None:
"""Turn the media player on."""
await self.client.smartglass.wake_up(self._console.id)
Expand Down Expand Up @@ -193,15 +160,20 @@ async def async_media_next_track(self) -> None:
"""Send next track command."""
await self.client.smartglass.next(self._console.id)

async def async_browse_media(self, media_content_type=None, media_content_id=None):
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""

return await build_item_response(
self.client,
self._console.id,
self.data.status.is_tv_configured,
media_content_type,
media_content_id,
)
media_content_type or "",
media_content_id or "",
) # type: ignore[return-value]

async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
Expand All @@ -214,22 +186,6 @@ async def async_play_media(
else:
await self.client.smartglass.launch_app(self._console.id, media_id)

@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
# Turns "XboxOneX" into "Xbox One X" for display
matches = re.finditer(
".+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)",
self._console.console_type,
)

return DeviceInfo(
identifiers={(DOMAIN, self._console.id)},
manufacturer="Microsoft",
model=" ".join([m.group(0) for m in matches]),
name=self._console.name,
)


def _find_media_image(images: list[Image]) -> Image | None:
purpose_order = ["FeaturePromotionalSquareArt", "Tile", "Logo", "BoxArt"]
Expand Down
Loading
Loading