Compare commits

...

4 Commits

Author SHA1 Message Date
Franck Nijhof
8f2b1f0eff Bump version to 2026.1.0b0 2025-12-29 19:01:17 +00:00
Joost Lekkerkerker
a1a1d65ee4 Add Hood fan speed select entity to SmartThings (#157841)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 19:56:55 +01:00
Louis Christ
8778d4c704 Move actions to async_setup in bluesound (#159809) 2025-12-29 19:44:05 +01:00
Jeremiah Paige
7790a2ebdd Add config flow to wsdot (#149208)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 18:58:09 +01:00
25 changed files with 1254 additions and 107 deletions

View File

@@ -2,16 +2,25 @@
from pyblu import Player
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import (
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
@@ -28,6 +37,38 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_increase_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_CLEAR_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_clear_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_JOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_MASTER): cv.entity_id},
func="async_bluesound_join",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UNJOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_bluesound_unjoin",
)
return True

View File

@@ -4,3 +4,8 @@ DOMAIN = "bluesound"
INTEGRATION_TITLE = "Bluesound"
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"

View File

@@ -8,7 +8,6 @@ import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -22,12 +21,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -41,7 +35,15 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .const import (
ATTR_BLUESOUND_GROUP,
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .coordinator import BluesoundCoordinator
from .utils import (
dispatcher_join_signal,
@@ -60,11 +62,6 @@ SCAN_INTERVAL = timedelta(minutes=15)
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
POLL_TIMEOUT = 120
@@ -81,20 +78,6 @@ async def async_setup_entry(
config_entry.runtime_data.player,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_TIMER, None, "async_increase_timer"
)
platform.async_register_entity_service(
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
)
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_bluesound_join"
)
platform.async_register_entity_service(
SERVICE_UNJOIN, None, "async_bluesound_unjoin"
)
async_add_entities([bluesound_player], update_before_add=True)

View File

@@ -11,6 +11,8 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
@@ -22,6 +24,9 @@ from .entity import SmartThingsEntity
SPEED_RANGE = (1, 3) # off is not included
SMART = 14
PRESET_SMART = "smart"
async def async_setup_entry(
hass: HomeAssistant,
@@ -30,7 +35,7 @@ async def async_setup_entry(
) -> None:
"""Add fans for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
entities: list[FanEntity] = [
SmartThingsFan(entry_data.client, device)
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
@@ -42,7 +47,20 @@ async def async_setup_entry(
)
)
and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN]
]
entities.extend(
SmartThingsHood(entry_data.client, device)
for device in entry_data.devices.values()
if Capability.SWITCH in device.status[MAIN]
and Capability.SAMSUNG_CE_HOOD_FAN_SPEED in device.status[MAIN]
and (
device.status[MAIN][Capability.SAMSUNG_CE_HOOD_FAN_SPEED][
Attribute.SETTABLE_MIN_FAN_SPEED
].value
== SMART
)
)
async_add_entities(entities)
class SmartThingsFan(SmartThingsEntity, FanEntity):
@@ -149,3 +167,103 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
return self.get_attribute_value(
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
)
class SmartThingsHood(SmartThingsEntity, FanEntity):
"""Define a SmartThings Hood."""
_attr_name = None
_attr_supported_features = (
FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.SET_SPEED
)
_attr_preset_modes = [PRESET_SMART]
_attr_translation_key = "hood"
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
super().__init__(
client,
device,
{
Capability.SWITCH,
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
},
)
@property
def fan_speeds(self) -> list[int]:
"""Return a list of available fan speeds."""
return [
speed
for speed in self.get_attribute_value(
Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.SUPPORTED_HOOD_FAN_SPEED
)
if speed != SMART
]
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset_mode of the fan."""
await self.execute_device_command(
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
Command.SET_HOOD_FAN_SPEED,
argument=SMART,
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.execute_device_command(Capability.SWITCH, Command.OFF)
else:
await self.execute_device_command(
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
Command.SET_HOOD_FAN_SPEED,
argument=percentage_to_ordered_list_item(self.fan_speeds, percentage),
)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on."""
await self.execute_device_command(Capability.SWITCH, Command.ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.execute_device_command(Capability.SWITCH, Command.OFF)
@property
def is_on(self) -> bool:
"""Return true if fan is on."""
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if (
self.get_attribute_value(
Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED
)
== SMART
):
return PRESET_SMART
return None
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
fan_speed = self.get_attribute_value(
Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED
)
if fan_speed == SMART:
return None
return ordered_list_item_to_percentage(self.fan_speeds, fan_speed)
@property
def speed_count(self) -> int:
"""Return the number of available speeds."""
return len(self.fan_speeds)

View File

@@ -53,6 +53,17 @@
}
}
},
"fan": {
"hood": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "mdi:brain"
}
}
}
}
},
"number": {
"freezer_temperature": {
"default": "mdi:snowflake-thermometer"

View File

@@ -138,6 +138,17 @@
}
}
},
"fan": {
"hood": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "Smart"
}
}
}
}
},
"number": {
"cool_select_plus_temperature": {
"name": "CoolSelect+ temperature"

View File

@@ -1 +1,36 @@
"""The wsdot component."""
import wsdot as wsdot_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
PLATFORMS = [Platform.SENSOR]
type WsdotConfigEntry = ConfigEntry[wsdot_api.WsdotTravelTimes]
async def async_setup_entry(hass: HomeAssistant, entry: WsdotConfigEntry) -> bool:
"""Set up wsdot as config entry."""
session = async_get_clientsession(hass)
api_key = entry.data[CONF_API_KEY]
wsdot_travel_times = wsdot_api.WsdotTravelTimes(api_key=api_key, session=session)
try:
# The only way to validate the provided API key is to request data
# we don't need the data here, only the non-exception
await wsdot_travel_times.get_all_travel_times()
except wsdot_api.WsdotTravelError as api_error:
raise ConfigEntryError("Bad auth") from api_error
entry.runtime_data = wsdot_travel_times
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,157 @@
"""Adds config flow for wsdot."""
import logging
from types import MappingProxyType
from typing import Any
import voluptuous as vol
import wsdot as wsdot_api
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentry,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import CONF_TRAVEL_TIMES, DOMAIN, SUBENTRY_TRAVEL_TIMES
_LOGGER = logging.getLogger(__name__)
class WSDOTConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for WSDOT."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
data = {CONF_API_KEY: user_input[CONF_API_KEY]}
self._async_abort_entries_match(data)
wsdot_travel_times = wsdot_api.WsdotTravelTimes(user_input[CONF_API_KEY])
try:
await wsdot_travel_times.get_all_travel_times()
except wsdot_api.WsdotTravelError as ws_error:
if ws_error.status == 400:
errors[CONF_API_KEY] = "invalid_api_key"
else:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=DOMAIN,
data=data,
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
),
errors=errors,
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Handle a flow initialized by import."""
self._async_abort_entries_match({CONF_API_KEY: import_info[CONF_API_KEY]})
wsdot_travel_times = wsdot_api.WsdotTravelTimes(import_info[CONF_API_KEY])
try:
travel_time_routes = await wsdot_travel_times.get_all_travel_times()
except wsdot_api.WsdotTravelError as ws_error:
if ws_error.status == 400:
return self.async_abort(reason="invalid_api_key")
return self.async_abort(reason="cannot_connect")
subentries = []
for route in import_info[CONF_TRAVEL_TIMES]:
maybe_travel_time = [
tt
for tt in travel_time_routes
# old platform configs could store the id as either a str or an int
if str(tt.TravelTimeID) == str(route[CONF_ID])
]
if not maybe_travel_time:
return self.async_abort(reason="invalid_travel_time_id")
travel_time = maybe_travel_time[0]
route_name = travel_time.Name
unique_id = "_".join(travel_time.Name.split())
subentries.append(
ConfigSubentry(
subentry_type=SUBENTRY_TRAVEL_TIMES,
unique_id=unique_id,
title=route_name,
data=MappingProxyType(
{CONF_NAME: travel_time.Name, CONF_ID: travel_time.TravelTimeID}
),
).as_dict()
)
return self.async_create_entry(
title=DOMAIN,
data={
CONF_API_KEY: import_info[CONF_API_KEY],
},
subentries=subentries,
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by wsdot."""
return {SUBENTRY_TRAVEL_TIMES: TravelTimeSubentryFlowHandler}
class TravelTimeSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding WSDOT Travel Times."""
def __init__(self, *args, **kwargs) -> None:
"""Initialize TravelTimeSubentryFlowHandler."""
super().__init__(*args, **kwargs)
self.travel_times: dict[str, int] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a new Travel Time subentry."""
if self.travel_times is None:
client = self._get_entry().runtime_data
travel_times = await client.get_all_travel_times()
self.travel_times = {tt.Name: tt.TravelTimeID for tt in travel_times}
if user_input is not None:
name = user_input[CONF_NAME]
tt_id = self.travel_times[name]
unique_id = str(tt_id)
data = {CONF_NAME: name, CONF_ID: tt_id}
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(title=name, unique_id=unique_id, data=data)
names = SelectSelector(
SelectSelectorConfig(
options=list(self.travel_times.keys()),
sort=True,
)
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=vol.Schema({vol.Required(CONF_NAME): names}),
errors={},
)

View File

@@ -0,0 +1,11 @@
"""Constants for wsdot component."""
ATTRIBUTION = "Data provided by WSDOT"
CONF_DATA = "data"
CONF_TITLE = "title"
CONF_TRAVEL_TIMES = "travel_time"
DOMAIN = "wsdot"
SUBENTRY_TRAVEL_TIMES = "travel_time"

View File

@@ -2,7 +2,9 @@
"domain": "wsdot",
"name": "Washington State Department of Transportation (WSDOT)",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wsdot",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["wsdot"],
"quality_scale": "legacy",

View File

@@ -7,27 +7,29 @@ import logging
from typing import Any
import voluptuous as vol
from wsdot import TravelTime, WsdotTravelError, WsdotTravelTimes
import wsdot as wsdot_api
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import WsdotConfigEntry
from .const import ATTRIBUTION, CONF_TRAVEL_TIMES, DOMAIN
_LOGGER = logging.getLogger(__name__)
ATTRIBUTION = "Data provided by WSDOT"
CONF_TRAVEL_TIMES = "travel_time"
ICON = "mdi:car"
DOMAIN = "wsdot"
SCAN_INTERVAL = timedelta(minutes=3)
@@ -44,22 +46,61 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the WSDOT sensor."""
sensors = []
session = async_get_clientsession(hass)
api_key = config[CONF_API_KEY]
wsdot_travel = WsdotTravelTimes(api_key=api_key, session=session)
for travel_time in config[CONF_TRAVEL_TIMES]:
name = travel_time.get(CONF_NAME) or travel_time.get(CONF_ID)
travel_time_id = int(travel_time[CONF_ID])
sensors.append(
WashingtonStateTravelTimeSensor(name, wsdot_travel, travel_time_id)
"""Migrate a platform-style wsdot to entry-style."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.7.0",
is_fixable=False,
is_persistent=True,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={"domain": DOMAIN, "integration_title": "WSDOT"},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2026.7.0",
is_fixable=False,
is_persistent=True,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={"domain": DOMAIN, "integration_title": "WSDOT"},
)
add_entities(sensors, True)
async def async_setup_entry(
hass: HomeAssistant,
entry: WsdotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WSDOT sensor."""
for subentry_id, subentry in entry.subentries.items():
name = subentry.data[CONF_NAME]
travel_time_id = subentry.data[CONF_ID]
sensor = WashingtonStateTravelTimeSensor(
name, entry.runtime_data, travel_time_id
)
async_add_entities(
[sensor], config_subentry_id=subentry_id, update_before_add=True
)
class WashingtonStateTransportSensor(SensorEntity):
@@ -70,6 +111,7 @@ class WashingtonStateTransportSensor(SensorEntity):
can read them and make them available.
"""
_attr_attribution = ATTRIBUTION
_attr_icon = ICON
def __init__(self, name: str) -> None:
@@ -91,23 +133,23 @@ class WashingtonStateTransportSensor(SensorEntity):
class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor):
"""Travel time sensor from WSDOT."""
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
def __init__(
self, name: str, wsdot_travel: WsdotTravelTimes, travel_time_id: int
self, name: str, wsdot_travel: wsdot_api.WsdotTravelTimes, travel_time_id: int
) -> None:
"""Construct a travel time sensor."""
super().__init__(name)
self._data: TravelTime | None = None
self._data: wsdot_api.TravelTime | None = None
self._travel_time_id = travel_time_id
self._wsdot_travel = wsdot_travel
self._attr_unique_id = f"travel_time-{travel_time_id}"
async def async_update(self) -> None:
"""Get the latest data from WSDOT."""
try:
travel_time = await self._wsdot_travel.get_travel_time(self._travel_time_id)
except WsdotTravelError:
except wsdot_api.WsdotTravelError:
_LOGGER.warning("Invalid response from WSDOT API")
else:
self._data = travel_time

View File

@@ -0,0 +1,63 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"invalid_travel_time_id": "WSDOT TravelTimeId does not describe a valid travel time route {travel_time_id}",
"no_travel_time": "Travel time route created without a travel time entity"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your WSDOT API key."
},
"description": "Set up your Washington State Department of Transportation integration.",
"title": "WSDOT setup"
}
}
},
"config_subentries": {
"travel_time": {
"entry_type": "Travel time",
"initiate_flow": {
"user": "Travel time"
},
"step": {
"user": {
"data": {
"name": "Travel Route Description"
},
"data_description": {
"name": "The official WSDOT description of the route."
},
"description": "Select one of the provided the highway routes to monitor estimated driving time along that route",
"title": "WSDOT travel time setup"
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {domain} using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the {domain} API. Please check your internet connection and the status of the {domain} API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.",
"title": "[%key:component::wsdot::issues::deprecated_yaml_import_issue_invalid_auth::title%]"
},
"deprecated_yaml_import_issue_invalid_auth": {
"description": "Configuring {domain} using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an invalid API key was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI.",
"title": "{domain} YAML configuration deprecated"
},
"deprecated_yaml_import_issue_invalid_travel_time_id": {
"description": "Configuring {domain} using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an invalid travel_time_id was found. Please update your YAML configuration, or remove the existing YAML configuration and set the integration up via the UI.",
"title": "[%key:component::wsdot::issues::deprecated_yaml_import_issue_invalid_auth::title%]"
},
"deprecated_yaml_import_issue_unknown": {
"description": "Configuring {domain} using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an unknown error occurred. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.",
"title": "[%key:component::wsdot::issues::deprecated_yaml_import_issue_invalid_auth::title%]"
}
}
}

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@@ -776,6 +776,7 @@ FLOWS = {
"workday",
"worldclock",
"ws66i",
"wsdot",
"wyoming",
"xbox",
"xiaomi_aqara",

View File

@@ -7638,8 +7638,8 @@
},
"wsdot": {
"name": "Washington State Department of Transportation (WSDOT)",
"integration_type": "hub",
"config_flow": false,
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"wyoming": {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.1.0.dev0"
version = "2026.1.0b0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

View File

@@ -48,7 +48,7 @@
},
"switch": {
"switch": {
"value": "off",
"value": "on",
"timestamp": "2025-11-11T23:41:24.907Z"
}
},
@@ -305,7 +305,7 @@
"timestamp": "2025-11-11T23:41:21.525Z"
},
"hoodFanSpeed": {
"value": 14,
"value": 15,
"timestamp": "2025-11-11T23:41:21.525Z"
},
"supportedHoodFanSpeed": {

View File

@@ -1,4 +1,63 @@
# serializer version: 1
# name: test_all_entities[da_ks_hood_01001][fan.range_hood-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'preset_modes': list([
'smart',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'fan',
'entity_category': None,
'entity_id': 'fan.range_hood',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <FanEntityFeature: 57>,
'translation_key': 'hood',
'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ks_hood_01001][fan.range_hood-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Range hood',
'percentage': 25,
'percentage_step': 25.0,
'preset_mode': None,
'preset_modes': list([
'smart',
]),
'supported_features': <FanEntityFeature: 57>,
}),
'context': <ANY>,
'entity_id': 'fan.range_hood',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[fake_fan][fan.fake_fan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -236,7 +236,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
'state': 'on',
})
# ---
# name: test_all_entities[da_ks_walloven_0107x][switch.four_sabbath_mode-entry]

View File

@@ -2,7 +2,7 @@
from unittest.mock import AsyncMock
from pysmartthings import Capability, Command
from pysmartthings import Attribute, Capability, Command
from pysmartthings.models import HealthStatus
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -26,7 +26,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration, snapshot_smartthings_entities, trigger_health_update
from . import (
setup_integration,
snapshot_smartthings_entities,
trigger_health_update,
trigger_update,
)
from tests.common import MockConfigEntry
@@ -44,7 +49,13 @@ async def test_all_entities(
snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN)
@pytest.mark.parametrize("device_fixture", ["fake_fan"])
@pytest.mark.parametrize(
("device_fixture", "entity_id", "device_id"),
[
("fake_fan", "fan.fake_fan", "f1af21a2-d5a1-437c-b10a-b34a87394b71"),
("da_ks_hood_01001", "fan.range_hood", "fa5fca25-fa7a-1807-030a-2f72ee0f7bff"),
],
)
@pytest.mark.parametrize(
("action", "command"),
[
@@ -58,6 +69,8 @@ async def test_turn_on_off(
mock_config_entry: MockConfigEntry,
action: str,
command: Command,
entity_id: str,
device_id: str,
) -> None:
"""Test turning on and off."""
await setup_integration(hass, mock_config_entry)
@@ -65,11 +78,11 @@ async def test_turn_on_off(
await hass.services.async_call(
FAN_DOMAIN,
action,
{ATTR_ENTITY_ID: "fan.fake_fan"},
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"f1af21a2-d5a1-437c-b10a-b34a87394b71",
device_id,
Capability.SWITCH,
command,
MAIN,
@@ -100,11 +113,19 @@ async def test_set_percentage(
)
@pytest.mark.parametrize("device_fixture", ["fake_fan"])
@pytest.mark.parametrize(
("device_fixture", "entity_id", "device_id"),
[
("fake_fan", "fan.fake_fan", "f1af21a2-d5a1-437c-b10a-b34a87394b71"),
("da_ks_hood_01001", "fan.range_hood", "fa5fca25-fa7a-1807-030a-2f72ee0f7bff"),
],
)
async def test_set_percentage_off(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
device_id: str,
) -> None:
"""Test setting the speed percentage of the fan."""
await setup_integration(hass, mock_config_entry)
@@ -112,11 +133,11 @@ async def test_set_percentage_off(
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0},
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 0},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"f1af21a2-d5a1-437c-b10a-b34a87394b71",
device_id,
Capability.SWITCH,
Command.OFF,
MAIN,
@@ -204,3 +225,80 @@ async def test_availability_at_start(
"""Test unavailable at boot."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"])
async def test_set_hood_percentage(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting the speed percentage of the hood."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: "fan.range_hood", ATTR_PERCENTAGE: 50},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"fa5fca25-fa7a-1807-030a-2f72ee0f7bff",
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
Command.SET_HOOD_FAN_SPEED,
MAIN,
argument=16,
)
@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"])
async def test_set_hood_preset_mode(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting the preset mode of the hood."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: "fan.range_hood", ATTR_PRESET_MODE: "smart"},
blocking=True,
)
devices.execute_device_command.assert_called_once_with(
"fa5fca25-fa7a-1807-030a-2f72ee0f7bff",
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
Command.SET_HOOD_FAN_SPEED,
MAIN,
argument=14,
)
@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"])
async def test_updating_hood_preset_mode(
hass: HomeAssistant,
devices: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test updating the preset mode of the hood."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get("fan.range_hood")
assert state
assert state.attributes[ATTR_PRESET_MODE] is None
assert state.attributes[ATTR_PERCENTAGE] == 25
await trigger_update(
hass,
devices,
"fa5fca25-fa7a-1807-030a-2f72ee0f7bff",
Capability.SAMSUNG_CE_HOOD_FAN_SPEED,
Attribute.HOOD_FAN_SPEED,
14,
)
state = hass.states.get("fan.range_hood")
assert state
assert state.attributes[ATTR_PRESET_MODE] == "smart"
assert state.attributes[ATTR_PERCENTAGE] is None

View File

@@ -1,24 +1,113 @@
"""Provide common WSDOT fixtures."""
from collections.abc import AsyncGenerator
from unittest.mock import patch
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from wsdot import TravelTime
from wsdot import TravelTime, WsdotTravelError
from homeassistant.components.wsdot.sensor import DOMAIN
from homeassistant.components.wsdot.const import DOMAIN
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from tests.common import load_json_object_fixture
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def mock_travel_time() -> AsyncGenerator[TravelTime]:
def mock_travel_time() -> Generator[AsyncMock]:
"""WsdotTravelTimes.get_travel_time is mocked to return a TravelTime data based on test fixture payload."""
with patch(
"homeassistant.components.wsdot.sensor.WsdotTravelTimes", autospec=True
) as mock:
with (
patch(
"homeassistant.components.wsdot.wsdot_api.WsdotTravelTimes", autospec=True
) as mock,
patch(
"homeassistant.components.wsdot.config_flow.wsdot_api.WsdotTravelTimes",
new=mock,
),
):
client = mock.return_value
client.get_travel_time.return_value = TravelTime(
**load_json_object_fixture("wsdot.json", DOMAIN)
response = TravelTime(**load_json_object_fixture("wsdot.json", DOMAIN))
client.get_travel_time.return_value = response
client.get_all_travel_times.return_value = [response]
yield client
@pytest.fixture
def failed_travel_time_status() -> int:
"""Return the default status code for failed travel time requests."""
return 400
@pytest.fixture
def mock_failed_travel_time(
mock_travel_time: AsyncMock, failed_travel_time_status: int
) -> AsyncMock:
"""WsdotTravelTimes.get_travel_time is mocked to raise a WsdotTravelError."""
mock_travel_time.get_travel_time.side_effect = WsdotTravelError(
status=failed_travel_time_status
)
mock_travel_time.get_all_travel_times.side_effect = WsdotTravelError(
status=failed_travel_time_status
)
return mock_travel_time
@pytest.fixture
def mock_config_data() -> dict[str, Any]:
"""Return valid test config data."""
return {
CONF_API_KEY: "abcd-1234",
}
@pytest.fixture
def mock_subentries() -> list[ConfigSubentryData]:
"""Mock subentries."""
return [
ConfigSubentryData(
subentry_type="travel_time",
title="I-90 EB",
unique_id="96",
data={
CONF_ID: 96,
CONF_NAME: "Seattle-Bellevue via I-90 (EB AM)",
},
)
yield mock
]
@pytest.fixture
def mock_config_entry(
mock_config_data: dict[str, Any], mock_subentries: list[ConfigSubentryData]
) -> MockConfigEntry:
"""Mock a wsdot config entry."""
return MockConfigEntry(
domain=DOMAIN,
data=mock_config_data,
subentries_data=mock_subentries,
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> MockConfigEntry:
"""Set up wsdot integration with subentries for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock config entry setup."""
with patch(
"homeassistant.components.wsdot.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup

View File

@@ -0,0 +1,75 @@
# serializer version: 1
# name: test_travel_sensor_details[sensor.seattle_bellevue_via_i_90_eb_am-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.seattle_bellevue_via_i_90_eb_am',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:car',
'original_name': 'Seattle-Bellevue via I-90 (EB AM)',
'platform': 'wsdot',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'travel_time-96',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_travel_sensor_details[sensor.seattle_bellevue_via_i_90_eb_am-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'AverageTime': 11,
'CurrentTime': 11,
'Description': 'Downtown Seattle to Downtown Bellevue via I-90',
'Distance': 10.6,
'EndPoint': dict({
'Description': 'I-405 @ NE 8th St in Bellevue',
'Direction': 'N',
'Latitude': 47.61361,
'Longitude': -122.18797,
'MilePost': 13.6,
'RoadName': 'I-405',
}),
'Name': 'Seattle-Bellevue via I-90 (EB AM)',
'StartPoint': dict({
'Description': 'I-5 @ University St in Seattle',
'Direction': 'S',
'Latitude': 47.609294,
'Longitude': -122.331759,
'MilePost': 165.83,
'RoadName': 'I-5',
}),
'TimeUpdated': datetime.datetime(2017, 1, 21, 15, 10, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600))),
'TravelTimeID': 96,
'attribution': 'Data provided by WSDOT',
'friendly_name': 'Seattle-Bellevue via I-90 (EB AM)',
'icon': 'mdi:car',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.seattle_bellevue_via_i_90_eb_am',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '11',
})
# ---

View File

@@ -0,0 +1,282 @@
"""Define tests for the wsdot config flow."""
from typing import Any
from unittest.mock import AsyncMock
import pytest
from wsdot import WsdotTravelError
from homeassistant.components.wsdot.const import (
CONF_TRAVEL_TIMES,
DOMAIN,
SUBENTRY_TRAVEL_TIMES,
)
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
VALID_USER_CONFIG = {
CONF_API_KEY: "abcd-1234",
}
VALID_USER_TRAVEL_TIME_CONFIG = {
CONF_NAME: "Seattle-Bellevue via I-90 (EB AM)",
}
async def test_create_user_entry(
hass: HomeAssistant, mock_travel_time: AsyncMock
) -> None:
"""Test that the user step works."""
# No user data; form is being show for the first time
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# User data; the user entered data and hit submit
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=VALID_USER_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DOMAIN
assert result["data"][CONF_API_KEY] == "abcd-1234"
@pytest.mark.parametrize(
("failed_travel_time_status", "errors"),
[
(400, {CONF_API_KEY: "invalid_api_key"}),
(404, {"base": "cannot_connect"}),
],
)
async def test_errors(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
mock_setup_entry: AsyncMock,
failed_travel_time_status: int,
errors: dict[str, str],
) -> None:
"""Test that the user step works."""
mock_travel_time.get_all_travel_times.side_effect = WsdotTravelError(
status=failed_travel_time_status
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=VALID_USER_CONFIG,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == errors
mock_travel_time.get_all_travel_times.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=VALID_USER_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
"mock_subentries",
[
[],
],
)
async def test_create_travel_time_subentry(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
init_integration: MockConfigEntry,
) -> None:
"""Test that the user step for Travel Time works."""
# No user data; form is being show for the first time
result = await hass.config_entries.subentries.async_init(
(init_integration.entry_id, SUBENTRY_TRAVEL_TIMES),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# User data; the user made a choice and hit submit
result = await hass.config_entries.subentries.async_init(
(init_integration.entry_id, SUBENTRY_TRAVEL_TIMES),
context={"source": SOURCE_USER},
data=VALID_USER_TRAVEL_TIME_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_NAME] == "Seattle-Bellevue via I-90 (EB AM)"
assert result["data"][CONF_ID] == 96
@pytest.mark.parametrize(
"import_config",
[
{
CONF_API_KEY: "abcd-5678",
CONF_TRAVEL_TIMES: [{CONF_ID: 96, CONF_NAME: "I-90 EB"}],
},
{
CONF_API_KEY: "abcd-5678",
CONF_TRAVEL_TIMES: [{CONF_ID: "96", CONF_NAME: "I-90 EB"}],
},
],
ids=["with-int-id", "with-str-id"],
)
async def test_create_import_entry(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
import_config: dict[str, str | int],
) -> None:
"""Test that the yaml import works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=import_config,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "wsdot"
assert result["data"][CONF_API_KEY] == "abcd-5678"
entry = result["result"]
assert entry is not None
assert len(entry.subentries) == 1
subentry = next(iter(entry.subentries.values()))
assert subentry.subentry_type == SUBENTRY_TRAVEL_TIMES
assert subentry.title == "Seattle-Bellevue via I-90 (EB AM)"
assert subentry.data[CONF_NAME] == "Seattle-Bellevue via I-90 (EB AM)"
assert subentry.data[CONF_ID] == 96
@pytest.mark.parametrize(
("failed_travel_time_status", "abort_reason"),
[
(400, "invalid_api_key"),
(404, "cannot_connect"),
],
)
async def test_failed_import_entry(
hass: HomeAssistant,
mock_failed_travel_time: AsyncMock,
mock_config_data: dict[str, Any],
failed_travel_time_status: int,
abort_reason: str,
) -> None:
"""Test the failure modes of a yaml import."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=mock_config_data,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == abort_reason
async def test_incorrect_import_entry(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
mock_config_data: dict[str, Any],
) -> None:
"""Test a yaml import of a non-existent route."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_API_KEY: "abcd-5678",
CONF_TRAVEL_TIMES: [{CONF_ID: "100001", CONF_NAME: "nowhere"}],
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_travel_time_id"
async def test_import_integration_already_exists(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
init_integration: MockConfigEntry,
) -> None:
"""Test we only allow one entry per API key."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_API_KEY: "abcd-1234",
CONF_TRAVEL_TIMES: [{CONF_ID: "100001", CONF_NAME: "nowhere"}],
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_integration_already_exists(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
mock_config_entry: MockConfigEntry,
init_integration: MockConfigEntry,
) -> None:
"""Test we only allow one entry per API key."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=VALID_USER_CONFIG,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_travel_route_already_exists(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
mock_config_entry: MockConfigEntry,
init_integration: MockConfigEntry,
) -> None:
"""Test we only allow choosing a travel time route once."""
result = await hass.config_entries.subentries.async_init(
(init_integration.entry_id, SUBENTRY_TRAVEL_TIMES),
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input=VALID_USER_TRAVEL_TIME_CONFIG,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,21 @@
"""The tests for the WSDOT platform."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from tests.common import MockConfigEntry
async def test_travel_sensor_setup_no_auth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_failed_travel_time: None,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test the wsdot Travel Time sensor does not create an entry with a bad API key."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR

View File

@@ -1,41 +1,84 @@
"""The tests for the WSDOT platform."""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from homeassistant.components.wsdot.sensor import (
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.wsdot.const import CONF_TRAVEL_TIMES, DOMAIN
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_NAME,
CONF_TRAVEL_TIMES,
DOMAIN,
CONF_PLATFORM,
Platform,
)
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.setup import async_setup_component
config = {
CONF_API_KEY: "foo",
CONF_TRAVEL_TIMES: [{CONF_ID: 96, CONF_NAME: "I90 EB"}],
}
from tests.common import MockConfigEntry, snapshot_platform
async def test_setup_with_config(
hass: HomeAssistant, mock_travel_time: AsyncMock
async def test_travel_sensor_details(
hass: HomeAssistant,
mock_travel_time: AsyncMock,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the platform setup with configuration."""
assert await async_setup_component(
hass, "sensor", {"sensor": [{CONF_PLATFORM: DOMAIN, **config}]}
)
"""Test the wsdot Travel Time sensor details."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
state = hass.states.get("sensor.i90_eb")
assert state is not None
assert state.name == "I90 EB"
assert state.state == "11"
assert (
state.attributes["Description"]
== "Downtown Seattle to Downtown Bellevue via I-90"
async def test_travel_sensor_platform_setup(
hass: HomeAssistant, mock_travel_time: AsyncMock, issue_registry: ir.IssueRegistry
) -> None:
"""Test the wsdot Travel Time sensor still supports setup from platform config."""
assert await async_setup_component(
hass,
Platform.SENSOR,
{
Platform.SENSOR: [
{
CONF_PLATFORM: DOMAIN,
CONF_API_KEY: "foo",
CONF_TRAVEL_TIMES: [{CONF_ID: 96, CONF_NAME: "I90 EB"}],
}
]
},
)
assert state.attributes["TimeUpdated"] == datetime(
2017, 1, 21, 15, 10, tzinfo=timezone(timedelta(hours=-8))
await hass.async_block_till_done()
entry = next(iter(hass.config_entries.async_entries(DOMAIN)), None)
assert entry is not None
assert entry.data[CONF_API_KEY] == "foo"
assert len(entry.subentries) == 1
assert len(issue_registry.issues) == 1
async def test_travel_sensor_platform_setup_bad_routes(
hass: HomeAssistant, mock_travel_time: AsyncMock, issue_registry: ir.IssueRegistry
) -> None:
"""Test the wsdot Travel Time sensor platform upgrade skips unknown route ids."""
assert await async_setup_component(
hass,
Platform.SENSOR,
{
Platform.SENSOR: [
{
CONF_PLATFORM: DOMAIN,
CONF_API_KEY: "foo",
CONF_TRAVEL_TIMES: [{CONF_ID: 4096, CONF_NAME: "Mars Expressway"}],
}
]
},
)
await hass.async_block_till_done()
entry = next(iter(hass.config_entries.async_entries(DOMAIN)), None)
assert entry is None
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(
DOMAIN, "deprecated_yaml_import_issue_invalid_travel_time_id"
)
assert issue