mirror of
https://github.com/home-assistant/core.git
synced 2026-03-06 22:14:58 +01:00
Compare commits
2 Commits
always_fai
...
denon-rs23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b5275b70a | ||
|
|
830e8f134b |
@@ -545,7 +545,6 @@ homeassistant.components.tcp.*
|
||||
homeassistant.components.technove.*
|
||||
homeassistant.components.tedee.*
|
||||
homeassistant.components.telegram_bot.*
|
||||
homeassistant.components.teslemetry.*
|
||||
homeassistant.components.text.*
|
||||
homeassistant.components.thethingsnetwork.*
|
||||
homeassistant.components.threshold.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -349,6 +349,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/deluge/ @tkdrob
|
||||
/homeassistant/components/demo/ @home-assistant/core
|
||||
/tests/components/demo/ @home-assistant/core
|
||||
/homeassistant/components/denon_rs232/ @balloob
|
||||
/tests/components/denon_rs232/ @balloob
|
||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||
/homeassistant/components/derivative/ @afaucogney @karwosts
|
||||
|
||||
@@ -93,6 +93,7 @@ class AirobotNumber(AirobotEntity, NumberEntity):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_value_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
"message": "Failed to set temperature to {temperature}."
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set value."
|
||||
"message": "Failed to set value: {error}"
|
||||
},
|
||||
"switch_turn_off_failed": {
|
||||
"message": "Failed to turn off {switch}."
|
||||
|
||||
@@ -117,7 +117,6 @@ class SharpAquosTVDevice(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
)
|
||||
_attr_volume_step = 2 / 60
|
||||
|
||||
def __init__(
|
||||
self, name: str, remote: sharp_aquos_rc.TV, power_on_enabled: bool = False
|
||||
@@ -162,6 +161,22 @@ class SharpAquosTVDevice(MediaPlayerEntity):
|
||||
"""Turn off tvplayer."""
|
||||
self._remote.power(0)
|
||||
|
||||
@_retry
|
||||
def volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
_LOGGER.debug("Unknown volume in volume_up")
|
||||
return
|
||||
self._remote.volume(int(self.volume_level * 60) + 2)
|
||||
|
||||
@_retry
|
||||
def volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
if self.volume_level is None:
|
||||
_LOGGER.debug("Unknown volume in volume_down")
|
||||
return
|
||||
self._remote.volume(int(self.volume_level * 60) - 2)
|
||||
|
||||
@_retry
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set Volume media player."""
|
||||
|
||||
@@ -85,7 +85,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
_attr_media_content_type = MediaType.MUSIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_volume_step = 0.01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -689,6 +688,24 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
|
||||
await self._player.play_url(url)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
|
||||
new_volume = self.volume_level + 0.01
|
||||
new_volume = min(1, new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down the media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
|
||||
new_volume = self.volume_level - 0.01
|
||||
new_volume = max(0, new_volume)
|
||||
await self.async_set_volume_level(new_volume)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
volume = int(round(volume * 100))
|
||||
|
||||
@@ -139,6 +139,18 @@ class AbstractDemoPlayer(MediaPlayerEntity):
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def volume_up(self) -> None:
|
||||
"""Increase volume."""
|
||||
assert self.volume_level is not None
|
||||
self._attr_volume_level = min(1.0, self.volume_level + 0.1)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def volume_down(self) -> None:
|
||||
"""Decrease volume."""
|
||||
assert self.volume_level is not None
|
||||
self._attr_volume_level = max(0.0, self.volume_level - 0.1)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level, range 0..1."""
|
||||
self._attr_volume_level = volume
|
||||
|
||||
61
homeassistant/components/denon_rs232/__init__.py
Normal file
61
homeassistant/components/denon_rs232/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""The Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from denon_rs232 import DenonReceiver, DenonState
|
||||
from denon_rs232.models import MODELS
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
CONF_MODEL,
|
||||
DOMAIN, # noqa: F401
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type DenonRS232ConfigEntry = ConfigEntry[DenonReceiver]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Set up Denon RS232 from a config entry."""
|
||||
port = entry.data[CONF_PORT]
|
||||
model = MODELS[entry.data[CONF_MODEL]]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
try:
|
||||
await receiver.connect()
|
||||
except (ConnectionError, OSError) as err:
|
||||
_LOGGER.error("Error connecting to Denon receiver at %s: %s", port, err)
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = receiver
|
||||
|
||||
@callback
|
||||
def _on_disconnect(state: DenonState | None) -> None:
|
||||
if state is None:
|
||||
_LOGGER.warning("Denon receiver disconnected, reloading config entry")
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(receiver.subscribe(_on_disconnect))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await entry.runtime_data.disconnect()
|
||||
|
||||
return unload_ok
|
||||
65
homeassistant/components/denon_rs232/config_flow.py
Normal file
65
homeassistant/components/denon_rs232/config_flow.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Config flow for the Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from denon_rs232 import DenonReceiver
|
||||
from denon_rs232.models import MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PORT
|
||||
|
||||
from .const import CONF_MODEL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MODEL_OPTIONS = {key: model.name for key, model in MODELS.items()}
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MODEL): vol.In(MODEL_OPTIONS),
|
||||
vol.Required(CONF_PORT): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon RS232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_PORT: user_input[CONF_PORT]})
|
||||
|
||||
model = MODELS[user_input[CONF_MODEL]]
|
||||
receiver = DenonReceiver(user_input[CONF_PORT], model=model)
|
||||
try:
|
||||
await receiver.connect()
|
||||
except ConnectionError, OSError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await receiver.disconnect()
|
||||
return self.async_create_entry(
|
||||
title=f"Denon {model.name} ({user_input[CONF_PORT]})",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, user_input or {}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
4
homeassistant/components/denon_rs232/const.py
Normal file
4
homeassistant/components/denon_rs232/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
|
||||
DOMAIN = "denon_rs232"
|
||||
CONF_MODEL = "model"
|
||||
12
homeassistant/components/denon_rs232/manifest.json
Normal file
12
homeassistant/components/denon_rs232/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/denon_rs232",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denon_rs232"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["denon-rs232==0.1.0"]
|
||||
}
|
||||
222
homeassistant/components/denon_rs232/media_player.py
Normal file
222
homeassistant/components/denon_rs232/media_player.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import re
|
||||
|
||||
from denon_rs232 import (
|
||||
MIN_VOLUME_DB,
|
||||
VOLUME_DB_RANGE,
|
||||
DenonReceiver,
|
||||
DenonState,
|
||||
InputSource,
|
||||
PowerState,
|
||||
)
|
||||
from denon_rs232.models import MODELS
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DenonRS232ConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_INVALID_KEY_CHARS = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _source_state_key(source: InputSource) -> str:
|
||||
"""Return a translation-safe state key for a source."""
|
||||
return source.name.lower()
|
||||
|
||||
|
||||
SOURCE_BY_NAME: dict[str, InputSource] = {
|
||||
_source_state_key(source): source for source in InputSource
|
||||
}
|
||||
# Backwards compatibility for direct service calls using raw protocol values.
|
||||
SOURCE_BY_NAME.update({source.value: source for source in InputSource})
|
||||
|
||||
|
||||
def _sound_mode_state_key(sound_mode: str) -> str:
|
||||
"""Return a translation-safe state key for a sound mode."""
|
||||
key = _INVALID_KEY_CHARS.sub("_", sound_mode.replace("+", " plus ").lower()).strip(
|
||||
"_"
|
||||
)
|
||||
if key and not key[0].isdigit():
|
||||
return key
|
||||
return f"mode_{key}"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon RS232 media player."""
|
||||
receiver = config_entry.runtime_data
|
||||
async_add_entities([DenonRS232MediaPlayer(receiver, config_entry)])
|
||||
|
||||
|
||||
class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon receiver controlled over RS232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
)
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "receiver"
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receiver: DenonReceiver,
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the media player."""
|
||||
self._receiver = receiver
|
||||
self._attr_unique_id = config_entry.entry_id
|
||||
|
||||
model = receiver.model
|
||||
model_name = model.name if model else None
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="Denon",
|
||||
model=model_name,
|
||||
name="Denon Receiver",
|
||||
)
|
||||
|
||||
known_sound_modes = (
|
||||
model.surround_modes
|
||||
if model
|
||||
else tuple(
|
||||
sorted(
|
||||
{
|
||||
surround_mode
|
||||
for receiver_model in MODELS.values()
|
||||
for surround_mode in receiver_model.surround_modes
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
self._sound_mode_by_state: dict[str, str] = {
|
||||
_sound_mode_state_key(sound_mode): sound_mode
|
||||
for sound_mode in known_sound_modes
|
||||
}
|
||||
|
||||
if model:
|
||||
self._attr_source_list = sorted(
|
||||
_source_state_key(source) for source in model.input_sources
|
||||
)
|
||||
self._attr_sound_mode_list = list(self._sound_mode_by_state)
|
||||
else:
|
||||
self._attr_source_list = sorted(
|
||||
_source_state_key(source) for source in InputSource
|
||||
)
|
||||
self._attr_sound_mode_list = list(self._sound_mode_by_state)
|
||||
|
||||
self._unsub: Callable[[], None] | None = None
|
||||
self._update_from_state(receiver.state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to receiver state updates."""
|
||||
self._unsub = self._receiver.subscribe(self._on_state_update)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from receiver state updates."""
|
||||
if self._unsub is not None:
|
||||
self._unsub()
|
||||
self._unsub = None
|
||||
|
||||
@callback
|
||||
def _on_state_update(self, state: DenonState | None) -> None:
|
||||
"""Handle a state update from the receiver."""
|
||||
if state is None:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _update_from_state(self, state: DenonState) -> None:
|
||||
"""Update entity attributes from a DenonState snapshot."""
|
||||
if state.power == PowerState.ON:
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
elif state.power == PowerState.STANDBY:
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
else:
|
||||
self._attr_state = None
|
||||
|
||||
if state.volume is not None:
|
||||
self._attr_volume_level = (state.volume - MIN_VOLUME_DB) / VOLUME_DB_RANGE
|
||||
else:
|
||||
self._attr_volume_level = None
|
||||
|
||||
self._attr_is_volume_muted = state.mute
|
||||
|
||||
if state.input_source is not None:
|
||||
self._attr_source = _source_state_key(state.input_source)
|
||||
else:
|
||||
self._attr_source = None
|
||||
|
||||
if state.surround_mode is not None:
|
||||
self._attr_sound_mode = _sound_mode_state_key(state.surround_mode)
|
||||
else:
|
||||
self._attr_sound_mode = None
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the receiver on."""
|
||||
await self._receiver.power_on()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the receiver off."""
|
||||
await self._receiver.power_standby()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
db = volume * VOLUME_DB_RANGE + MIN_VOLUME_DB
|
||||
await self._receiver.set_volume(db)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up."""
|
||||
await self._receiver.volume_up()
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down."""
|
||||
await self._receiver.volume_down()
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute."""
|
||||
if mute:
|
||||
await self._receiver.mute_on()
|
||||
else:
|
||||
await self._receiver.mute_off()
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if input_source := SOURCE_BY_NAME.get(source):
|
||||
await self._receiver.select_input_source(input_source)
|
||||
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Select sound mode."""
|
||||
await self._receiver.set_surround_mode(
|
||||
self._sound_mode_by_state.get(sound_mode, sound_mode)
|
||||
)
|
||||
60
homeassistant/components/denon_rs232/quality_scale.yaml
Normal file
60
homeassistant/components/denon_rs232/quality_scale.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
122
homeassistant/components/denon_rs232/strings.json
Normal file
122
homeassistant/components/denon_rs232/strings.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"model": "Receiver model",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"model": "Select the receiver model",
|
||||
"port": "Serial port path to connect to"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"media_player": {
|
||||
"receiver": {
|
||||
"state_attributes": {
|
||||
"sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
"auro2dsurr": "Auro 2D Surround",
|
||||
"auro3d": "Auro 3D",
|
||||
"auto": "Auto",
|
||||
"classic_concert": "Classic Concert",
|
||||
"direct": "Direct",
|
||||
"dolby_atmos": "Dolby Atmos",
|
||||
"dolby_d_ex": "Dolby Digital EX",
|
||||
"dolby_digital": "Dolby Digital",
|
||||
"dolby_digital_plus": "Dolby Digital+",
|
||||
"dolby_hd": "Dolby HD",
|
||||
"dolby_pl2": "Dolby PL2",
|
||||
"dolby_pl2x": "Dolby PL2x",
|
||||
"dolby_pro_logic": "Dolby Pro Logic",
|
||||
"dolby_surround": "Dolby Surround",
|
||||
"dts96_24": "DTS96/24",
|
||||
"dts_es_dscrt6_1": "DTS ES Discrete 6.1",
|
||||
"dts_es_mtrx6_1": "DTS ES Matrix 6.1",
|
||||
"dts_hd": "DTS HD",
|
||||
"dts_hd_mstr": "DTS HD Master",
|
||||
"dts_neo_6": "DTS Neo:6",
|
||||
"dts_surround": "DTS Surround",
|
||||
"dts_x": "DTS:X",
|
||||
"dts_x_mstr": "DTS:X Master",
|
||||
"jazz_club": "Jazz Club",
|
||||
"matrix": "Matrix",
|
||||
"mch_stereo": "Multichannel Stereo",
|
||||
"mode_5ch_stereo": "5ch Stereo",
|
||||
"mode_7ch_stereo": "7ch Stereo",
|
||||
"mono_movie": "Mono Movie",
|
||||
"multi_ch_direct": "Multi Ch Direct",
|
||||
"multi_ch_in": "Multi Ch In",
|
||||
"multi_ch_pure_d": "Multi Ch Pure Direct",
|
||||
"pure_direct": "Pure Direct",
|
||||
"rock_arena": "Rock Arena",
|
||||
"stereo": "Stereo",
|
||||
"super_stadium": "Super Stadium",
|
||||
"video_game": "Video Game",
|
||||
"virtual": "Virtual",
|
||||
"wide_screen": "Wide Screen"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"name": "Source",
|
||||
"state": {
|
||||
"aux1": "Aux 1",
|
||||
"aux2": "Aux 2",
|
||||
"bd": "BD Player",
|
||||
"bt": "Bluetooth",
|
||||
"cd": "CD",
|
||||
"cdr_tape1": "CDR/Tape 1",
|
||||
"dab": "DAB",
|
||||
"dbs_sat": "DBS/Sat",
|
||||
"dock": "Dock",
|
||||
"dvd": "DVD",
|
||||
"dvr": "DVR",
|
||||
"eight_k": "8K",
|
||||
"favorites": "Favorites",
|
||||
"flickr": "Flickr",
|
||||
"game": "Game",
|
||||
"hdp": "HDP",
|
||||
"hdradio": "HD Radio",
|
||||
"ipod": "iPod",
|
||||
"iradio": "Internet Radio",
|
||||
"lastfm": "Last.fm",
|
||||
"md_tape2": "MD/Tape 2",
|
||||
"mplay": "Media Player",
|
||||
"net": "HEOS Music",
|
||||
"net_usb": "Network/USB",
|
||||
"pandora": "Pandora",
|
||||
"phono": "Phono",
|
||||
"sat": "Sat",
|
||||
"sat_cbl": "Satellite/Cable",
|
||||
"server": "Server",
|
||||
"sirius": "Sirius",
|
||||
"siriusxm": "SiriusXM",
|
||||
"spotify": "Spotify",
|
||||
"tuner": "Tuner",
|
||||
"tv": "TV Audio",
|
||||
"tv_cbl": "TV/Cable",
|
||||
"usb_ipod": "USB/iPod",
|
||||
"v_aux": "V. Aux",
|
||||
"vcr_1": "VCR 1",
|
||||
"vcr_2": "VCR 2",
|
||||
"vcr_3": "VCR 3",
|
||||
"vdp": "VDP",
|
||||
"xm": "XM"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,8 +151,6 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
# If call to get_volume fails set to 0 and try again next time.
|
||||
if not self._max_volume:
|
||||
self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1
|
||||
if self._max_volume:
|
||||
self._attr_volume_step = 1 / self._max_volume
|
||||
|
||||
if self._attr_state != MediaPlayerState.OFF:
|
||||
info_name = await afsapi.get_play_name()
|
||||
@@ -241,6 +239,18 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
await self.fs_device.set_mute(mute)
|
||||
|
||||
# volume
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
volume = await self.fs_device.get_volume()
|
||||
volume = int(volume or 0) + 1
|
||||
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
volume = await self.fs_device.get_volume()
|
||||
volume = int(volume or 0) - 1
|
||||
await self.fs_device.set_volume(max(volume, 0))
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume command."""
|
||||
if self._max_volume: # Can't do anything sensible if not set
|
||||
|
||||
@@ -140,5 +140,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-ble==1.2.0"]
|
||||
"requirements": ["govee-ble==0.44.0"]
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ class MonopriceZone(MediaPlayerEntity):
|
||||
)
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_volume_step = 1 / MAX_VOLUME
|
||||
|
||||
def __init__(self, monoprice, sources, namespace, zone_id):
|
||||
"""Initialize new zone."""
|
||||
@@ -212,3 +211,17 @@ class MonopriceZone(MediaPlayerEntity):
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self._monoprice.set_volume(self._zone_id, round(volume * MAX_VOLUME))
|
||||
|
||||
def volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
volume = round(self.volume_level * MAX_VOLUME)
|
||||
self._monoprice.set_volume(self._zone_id, min(volume + 1, MAX_VOLUME))
|
||||
|
||||
def volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
if self.volume_level is None:
|
||||
return
|
||||
volume = round(self.volume_level * MAX_VOLUME)
|
||||
self._monoprice.set_volume(self._zone_id, max(volume - 1, 0))
|
||||
|
||||
@@ -93,7 +93,6 @@ class MpdDevice(MediaPlayerEntity):
|
||||
_attr_media_content_type = MediaType.MUSIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_volume_step = 0.05
|
||||
|
||||
def __init__(
|
||||
self, server: str, port: int, password: str | None, unique_id: str
|
||||
@@ -394,6 +393,24 @@ class MpdDevice(MediaPlayerEntity):
|
||||
if "volume" in self._status:
|
||||
await self._client.setvol(int(volume * 100))
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Service to send the MPD the command for volume up."""
|
||||
async with self.connection():
|
||||
if "volume" in self._status:
|
||||
current_volume = int(self._status["volume"])
|
||||
|
||||
if current_volume <= 100:
|
||||
self._client.setvol(current_volume + 5)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Service to send the MPD the command for volume down."""
|
||||
async with self.connection():
|
||||
if "volume" in self._status:
|
||||
current_volume = int(self._status["volume"])
|
||||
|
||||
if current_volume >= 0:
|
||||
await self._client.setvol(current_volume - 5)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Service to send the MPD the command for play/pause."""
|
||||
async with self.connection():
|
||||
|
||||
@@ -198,10 +198,8 @@ class NADtcp(MediaPlayerEntity):
|
||||
self._nad_receiver = NADReceiverTCP(config.get(CONF_HOST))
|
||||
self._min_vol = (config[CONF_MIN_VOLUME] + 90) * 2 # from dB to nad vol (0-200)
|
||||
self._max_vol = (config[CONF_MAX_VOLUME] + 90) * 2 # from dB to nad vol (0-200)
|
||||
self._volume_step = config[CONF_VOLUME_STEP]
|
||||
self._nad_volume = None
|
||||
vol_range = self._max_vol - self._min_vol
|
||||
if vol_range:
|
||||
self._attr_volume_step = 2 * config[CONF_VOLUME_STEP] / vol_range
|
||||
self._source_list = self._nad_receiver.available_sources()
|
||||
|
||||
def turn_off(self) -> None:
|
||||
@@ -212,6 +210,14 @@ class NADtcp(MediaPlayerEntity):
|
||||
"""Turn the media player on."""
|
||||
self._nad_receiver.power_on()
|
||||
|
||||
def volume_up(self) -> None:
|
||||
"""Step volume up in the configured increments."""
|
||||
self._nad_receiver.set_volume(self._nad_volume + 2 * self._volume_step)
|
||||
|
||||
def volume_down(self) -> None:
|
||||
"""Step volume down in the configured increments."""
|
||||
self._nad_receiver.set_volume(self._nad_volume - 2 * self._volume_step)
|
||||
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
nad_volume_to_set = int(
|
||||
|
||||
@@ -4,19 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from powerfox import (
|
||||
DeviceType,
|
||||
Powerfox,
|
||||
PowerfoxAuthenticationError,
|
||||
PowerfoxConnectionError,
|
||||
)
|
||||
from powerfox import DeviceType, Powerfox, PowerfoxConnectionError
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
PowerfoxConfigEntry,
|
||||
PowerfoxDataUpdateCoordinator,
|
||||
@@ -36,18 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) ->
|
||||
|
||||
try:
|
||||
devices = await client.all_devices()
|
||||
except PowerfoxAuthenticationError as err:
|
||||
await client.close()
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
except PowerfoxConnectionError as err:
|
||||
await client.close()
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinators: list[
|
||||
PowerfoxDataUpdateCoordinator | PowerfoxReportDataUpdateCoordinator
|
||||
|
||||
@@ -59,24 +59,18 @@ class PowerfoxBaseCoordinator[T](DataUpdateCoordinator[T]):
|
||||
except PowerfoxAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except PowerfoxConnectionError as err:
|
||||
except (
|
||||
PowerfoxConnectionError,
|
||||
PowerfoxNoDataError,
|
||||
PowerfoxPrivacyError,
|
||||
) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
except PowerfoxNoDataError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_data_error",
|
||||
translation_placeholders={"device_name": self.device.name},
|
||||
) from err
|
||||
except PowerfoxPrivacyError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="privacy_error",
|
||||
translation_placeholders={"device_name": self.device.name},
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def _async_fetch_data(self) -> T:
|
||||
|
||||
@@ -116,17 +116,11 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Authentication with the Powerfox service failed. Please re-authenticate your account."
|
||||
"invalid_auth": {
|
||||
"message": "Error while authenticating with the Powerfox service: {error}"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Could not connect to the Powerfox service. Please check your network connection."
|
||||
},
|
||||
"no_data_error": {
|
||||
"message": "No data available for device \"{device_name}\". The device may not have reported data yet."
|
||||
},
|
||||
"privacy_error": {
|
||||
"message": "Data for device \"{device_name}\" is restricted due to privacy settings in the Powerfox app."
|
||||
"update_failed": {
|
||||
"message": "Error while updating the Powerfox service: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Any, Final
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from tesla_fleet_api.const import Scope
|
||||
@@ -106,7 +106,7 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_ready_connection_error",
|
||||
) from err
|
||||
return str(oauth_session.token[CONF_ACCESS_TOKEN])
|
||||
return oauth_session.token[CONF_ACCESS_TOKEN]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
||||
@@ -227,7 +227,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
stream=stream,
|
||||
stream_vehicle=stream_vehicle,
|
||||
vin=vin,
|
||||
firmware=firmware or "",
|
||||
firmware=firmware,
|
||||
device=device,
|
||||
)
|
||||
)
|
||||
@@ -398,12 +398,10 @@ async def async_migrate_entry(
|
||||
return True
|
||||
|
||||
|
||||
def create_handle_vehicle_stream(
|
||||
vin: str, coordinator: TeslemetryVehicleDataCoordinator
|
||||
) -> Callable[[dict[str, Any]], None]:
|
||||
def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None]:
|
||||
"""Create a handle vehicle stream function."""
|
||||
|
||||
def handle_vehicle_stream(data: dict[str, Any]) -> None:
|
||||
def handle_vehicle_stream(data: dict) -> None:
|
||||
"""Handle vehicle data from the stream."""
|
||||
if "vehicle_data" in data:
|
||||
LOGGER.debug("Streaming received vehicle data from %s", vin)
|
||||
@@ -452,7 +450,7 @@ def async_setup_energy_device(
|
||||
|
||||
async def async_setup_stream(
|
||||
hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData
|
||||
) -> None:
|
||||
):
|
||||
"""Set up the stream for a vehicle."""
|
||||
|
||||
await vehicle.stream_vehicle.get_config()
|
||||
|
||||
@@ -329,11 +329,11 @@ class TeslemetryStreamingClimateEntity(
|
||||
)
|
||||
)
|
||||
|
||||
def _async_handle_inside_temp(self, data: float | None) -> None:
|
||||
def _async_handle_inside_temp(self, data: float | None):
|
||||
self._attr_current_temperature = data
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_hvac_power(self, data: str | None) -> None:
|
||||
def _async_handle_hvac_power(self, data: str | None):
|
||||
self._attr_hvac_mode = (
|
||||
None
|
||||
if data is None
|
||||
@@ -343,15 +343,15 @@ class TeslemetryStreamingClimateEntity(
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_climate_keeper_mode(self, data: str | None) -> None:
|
||||
def _async_handle_climate_keeper_mode(self, data: str | None):
|
||||
self._attr_preset_mode = PRESET_MODES.get(data) if data else None
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_hvac_temperature_request(self, data: float | None) -> None:
|
||||
def _async_handle_hvac_temperature_request(self, data: float | None):
|
||||
self._attr_target_temperature = data
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_rhd(self, data: bool | None) -> None:
|
||||
def _async_handle_rhd(self, data: bool | None):
|
||||
if data is not None:
|
||||
self.rhd = data
|
||||
|
||||
@@ -538,15 +538,15 @@ class TeslemetryStreamingCabinOverheatProtectionEntity(
|
||||
)
|
||||
)
|
||||
|
||||
def _async_handle_inside_temp(self, value: float | None) -> None:
|
||||
def _async_handle_inside_temp(self, value: float | None):
|
||||
self._attr_current_temperature = value
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_protection_mode(self, value: str | None) -> None:
|
||||
def _async_handle_protection_mode(self, value: str | None):
|
||||
self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_temperature_limit(self, value: str | None) -> None:
|
||||
def _async_handle_temperature_limit(self, value: str | None):
|
||||
self._attr_target_temperature = (
|
||||
COP_LEVELS.get(value) if value is not None else None
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: TeslemetryConfigEntry,
|
||||
api: Vehicle,
|
||||
product: dict[str, Any],
|
||||
product: dict,
|
||||
) -> None:
|
||||
"""Initialize Teslemetry Vehicle Update Coordinator."""
|
||||
super().__init__(
|
||||
@@ -119,7 +119,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
hass: HomeAssistant,
|
||||
config_entry: TeslemetryConfigEntry,
|
||||
api: EnergySite,
|
||||
data: dict[str, Any],
|
||||
data: dict,
|
||||
) -> None:
|
||||
"""Initialize Teslemetry Energy Site Live coordinator."""
|
||||
super().__init__(
|
||||
@@ -140,7 +140,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update energy site data using Teslemetry API."""
|
||||
try:
|
||||
data: dict[str, Any] = (await self.api.live_status())["response"]
|
||||
data = (await self.api.live_status())["response"]
|
||||
except (InvalidToken, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except RETRY_EXCEPTIONS as e:
|
||||
@@ -171,7 +171,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
hass: HomeAssistant,
|
||||
config_entry: TeslemetryConfigEntry,
|
||||
api: EnergySite,
|
||||
product: dict[str, Any],
|
||||
product: dict,
|
||||
) -> None:
|
||||
"""Initialize Teslemetry Energy Info coordinator."""
|
||||
super().__init__(
|
||||
|
||||
@@ -199,7 +199,7 @@ class TeslemetryStreamingWindowEntity(
|
||||
f"Adding field {signal} to {self.vehicle.vin}",
|
||||
)
|
||||
|
||||
def _handle_stream_update(self, data: dict[str, Any]) -> None:
|
||||
def _handle_stream_update(self, data) -> None:
|
||||
"""Update the entity attributes."""
|
||||
|
||||
change = False
|
||||
|
||||
@@ -28,7 +28,7 @@ class TeslemetryRootEntity(Entity):
|
||||
_attr_has_entity_name = True
|
||||
scoped: bool
|
||||
|
||||
def raise_for_scope(self, scope: Scope) -> None:
|
||||
def raise_for_scope(self, scope: Scope):
|
||||
"""Raise an error if a scope is not available."""
|
||||
if not self.scoped:
|
||||
raise ServiceValidationError(
|
||||
@@ -231,12 +231,11 @@ class TeslemetryWallConnectorEntity(TeslemetryPollingEntity):
|
||||
@property
|
||||
def _value(self) -> StateType:
|
||||
"""Return a specific wall connector value from coordinator data."""
|
||||
value: StateType = (
|
||||
return (
|
||||
self.coordinator.data.get("wall_connectors", {})
|
||||
.get(self.din, {})
|
||||
.get(self.key)
|
||||
)
|
||||
return value
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Teslemetry helper functions."""
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any
|
||||
|
||||
from tesla_fleet_api.exceptions import TeslaFleetError
|
||||
@@ -31,7 +30,7 @@ def flatten(
|
||||
return result
|
||||
|
||||
|
||||
async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]:
|
||||
async def handle_command(command) -> dict[str, Any]:
|
||||
"""Handle a command."""
|
||||
try:
|
||||
result = await command
|
||||
@@ -45,7 +44,7 @@ async def handle_command(command: Awaitable[dict[str, Any]]) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
async def handle_vehicle_command(command: Awaitable[dict[str, Any]]) -> Any:
|
||||
async def handle_vehicle_command(command) -> Any:
|
||||
"""Handle a vehicle command."""
|
||||
result = await handle_command(command)
|
||||
if (response := result.get("response")) is None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
|
||||
@@ -43,7 +43,7 @@ class TeslemetryVehicleData:
|
||||
vin: str
|
||||
firmware: str
|
||||
device: DeviceInfo
|
||||
wakelock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
wakelock = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -66,4 +66,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -188,7 +188,7 @@ class TeslemetryStreamingUpdateEntity(
|
||||
|
||||
def _async_handle_software_update_download_percent_complete(
|
||||
self, value: float | None
|
||||
) -> None:
|
||||
):
|
||||
"""Handle software update download percent complete."""
|
||||
|
||||
self._download_percentage = round(value) if value is not None else 0
|
||||
@@ -203,22 +203,20 @@ class TeslemetryStreamingUpdateEntity(
|
||||
|
||||
def _async_handle_software_update_installation_percent_complete(
|
||||
self, value: float | None
|
||||
) -> None:
|
||||
):
|
||||
"""Handle software update installation percent complete."""
|
||||
|
||||
self._install_percentage = round(value) if value is not None else 0
|
||||
self._async_update_progress()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_software_update_scheduled_start_time(
|
||||
self, value: str | None
|
||||
) -> None:
|
||||
def _async_handle_software_update_scheduled_start_time(self, value: str | None):
|
||||
"""Handle software update scheduled start time."""
|
||||
|
||||
self._attr_in_progress = value is not None
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_software_update_version(self, value: str | None) -> None:
|
||||
def _async_handle_software_update_version(self, value: str | None):
|
||||
"""Handle software update version."""
|
||||
|
||||
self._attr_latest_version = (
|
||||
@@ -226,7 +224,7 @@ class TeslemetryStreamingUpdateEntity(
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _async_handle_version(self, value: str | None) -> None:
|
||||
def _async_handle_version(self, value: str | None):
|
||||
"""Handle version."""
|
||||
|
||||
if value is not None:
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -139,6 +139,7 @@ FLOWS = {
|
||||
"deako",
|
||||
"deconz",
|
||||
"deluge",
|
||||
"denon_rs232",
|
||||
"denonavr",
|
||||
"devialet",
|
||||
"devolo_home_control",
|
||||
|
||||
@@ -1313,6 +1313,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"denon_rs232": {
|
||||
"name": "Denon RS232",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"devialet": {
|
||||
"name": "Devialet",
|
||||
"integration_type": "device",
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -5208,16 +5208,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.teslemetry.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.text.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
5
requirements_all.txt
generated
5
requirements_all.txt
generated
@@ -797,6 +797,9 @@ deluge-client==1.10.2
|
||||
# homeassistant.components.lametric
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denon_rs232
|
||||
denon-rs232==0.1.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.2
|
||||
|
||||
@@ -1116,7 +1119,7 @@ goslide-api==0.7.0
|
||||
gotailwind==0.3.0
|
||||
|
||||
# homeassistant.components.govee_ble
|
||||
govee-ble==1.2.0
|
||||
govee-ble==0.44.0
|
||||
|
||||
# homeassistant.components.govee_light_local
|
||||
govee-local-api==2.3.0
|
||||
|
||||
5
requirements_test_all.txt
generated
5
requirements_test_all.txt
generated
@@ -706,6 +706,9 @@ deluge-client==1.10.2
|
||||
# homeassistant.components.lametric
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denon_rs232
|
||||
denon-rs232==0.1.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.2
|
||||
|
||||
@@ -992,7 +995,7 @@ goslide-api==0.7.0
|
||||
gotailwind==0.3.0
|
||||
|
||||
# homeassistant.components.govee_ble
|
||||
govee-ble==1.2.0
|
||||
govee-ble==0.44.0
|
||||
|
||||
# homeassistant.components.govee_light_local
|
||||
govee-local-api==2.3.0
|
||||
|
||||
1
tests/components/denon_rs232/__init__.py
Normal file
1
tests/components/denon_rs232/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Denon RS232 integration."""
|
||||
70
tests/components/denon_rs232/conftest.py
Normal file
70
tests/components/denon_rs232/conftest.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Test fixtures for the Denon RS232 integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from denon_rs232 import (
|
||||
DenonReceiver,
|
||||
DenonState,
|
||||
DigitalInputMode,
|
||||
InputSource,
|
||||
PowerState,
|
||||
TunerBand,
|
||||
TunerMode,
|
||||
)
|
||||
from denon_rs232.models import MODELS
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.denon_rs232.const import CONF_MODEL, DOMAIN
|
||||
from homeassistant.const import CONF_PORT
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_PORT = "/dev/ttyUSB0"
|
||||
MOCK_MODEL = "avr_3805"
|
||||
|
||||
|
||||
def _default_state() -> DenonState:
|
||||
"""Return a DenonState with typical defaults."""
|
||||
return DenonState(
|
||||
power=PowerState.ON,
|
||||
main_zone=True,
|
||||
volume=-30.0,
|
||||
mute=False,
|
||||
input_source=InputSource.CD,
|
||||
surround_mode="STEREO",
|
||||
digital_input=DigitalInputMode.AUTO,
|
||||
tuner_band=TunerBand.FM,
|
||||
tuner_mode=TunerMode.AUTO,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_receiver() -> MagicMock:
|
||||
"""Create a mock DenonReceiver."""
|
||||
receiver = MagicMock(spec=DenonReceiver)
|
||||
receiver.connect = AsyncMock()
|
||||
receiver.disconnect = AsyncMock()
|
||||
receiver.power_on = AsyncMock()
|
||||
receiver.power_standby = AsyncMock()
|
||||
receiver.set_volume = AsyncMock()
|
||||
receiver.volume_up = AsyncMock()
|
||||
receiver.volume_down = AsyncMock()
|
||||
receiver.mute_on = AsyncMock()
|
||||
receiver.mute_off = AsyncMock()
|
||||
receiver.select_input_source = AsyncMock()
|
||||
receiver.set_surround_mode = AsyncMock()
|
||||
receiver.connected = True
|
||||
receiver.state = _default_state()
|
||||
receiver.model = MODELS[MOCK_MODEL]
|
||||
receiver.subscribe = MagicMock(return_value=MagicMock())
|
||||
return receiver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
title=f"Denon AVR-3805 / AVC-3890 ({MOCK_PORT})",
|
||||
)
|
||||
137
tests/components/denon_rs232/test_config_flow.py
Normal file
137
tests/components/denon_rs232/test_config_flow.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Tests for the Denon RS232 config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.components.denon_rs232.const import CONF_MODEL, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_PORT = "/dev/ttyUSB0"
|
||||
MOCK_MODEL = "avr_3805"
|
||||
|
||||
|
||||
async def test_user_form(hass: HomeAssistant) -> None:
|
||||
"""Test we show the user form."""
|
||||
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 result["errors"] == {}
|
||||
|
||||
|
||||
async def test_user_form_creates_entry(hass: HomeAssistant) -> None:
|
||||
"""Test successful config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_receiver = AsyncMock()
|
||||
mock_receiver.connect = AsyncMock()
|
||||
mock_receiver.disconnect = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"Denon AVR-3805 / AVC-3890 ({MOCK_PORT})"
|
||||
assert result["data"] == {CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL}
|
||||
mock_receiver.connect.assert_awaited_once()
|
||||
mock_receiver.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_user_form_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test we handle connection errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_receiver = AsyncMock()
|
||||
mock_receiver.connect = AsyncMock(side_effect=ConnectionError("No response"))
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_form_os_error(hass: HomeAssistant) -> None:
|
||||
"""Test we handle OS errors (e.g. serial port not found)."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_receiver = AsyncMock()
|
||||
mock_receiver.connect = AsyncMock(side_effect=OSError("No such device"))
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_form_unknown_error(hass: HomeAssistant) -> None:
|
||||
"""Test we handle unexpected errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_receiver = AsyncMock()
|
||||
mock_receiver.connect = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_duplicate_port_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if the same port is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_PORT: MOCK_PORT, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
443
tests/components/denon_rs232/test_media_player.py
Normal file
443
tests/components/denon_rs232/test_media_player.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""Tests for the Denon RS232 media player platform."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from denon_rs232 import DenonState, InputSource, PowerState
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_INPUT_SOURCE_LIST,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
ATTR_SOUND_MODE,
|
||||
ATTR_SOUND_MODE_LIST,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
SERVICE_SELECT_SOUND_MODE,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
SERVICE_VOLUME_DOWN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
SERVICE_VOLUME_UP,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import _default_state
|
||||
|
||||
ENTITY_ID = "media_player.denon_receiver"
|
||||
|
||||
|
||||
async def _setup_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MagicMock,
|
||||
mock_config_entry,
|
||||
) -> None:
|
||||
"""Set up the integration with a mock receiver."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_entity_created(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test media player entity is created with correct state."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_state_on(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test state is ON when receiver is powered on."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_state_off(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test state is OFF when receiver is in standby."""
|
||||
mock_receiver.state = DenonState(power=PowerState.STANDBY)
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_volume_level(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test volume level is correctly converted from dB to 0..1."""
|
||||
# -30 dB: ((-30) - (-80)) / 98 = 50 / 98 ≈ 0.5102
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
volume = state.attributes[ATTR_MEDIA_VOLUME_LEVEL]
|
||||
assert abs(volume - 50.0 / 98.0) < 0.001
|
||||
|
||||
|
||||
async def test_mute_state(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test mute state is reported."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False
|
||||
|
||||
|
||||
async def test_source(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test current source is reported."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "cd"
|
||||
|
||||
|
||||
async def test_source_net(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test NET source is reported with the translation key."""
|
||||
mock_receiver.state = DenonState(power=PowerState.ON, input_source=InputSource.NET)
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "net"
|
||||
|
||||
|
||||
async def test_source_bluetooth(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test BT source is reported with the translation key."""
|
||||
mock_receiver.state = DenonState(power=PowerState.ON, input_source=InputSource.BT)
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "bt"
|
||||
|
||||
|
||||
async def test_source_list(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test source list comes from the model definition."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
source_list = state.attributes[ATTR_INPUT_SOURCE_LIST]
|
||||
# AVR-3805 has the legacy sources (no VCR-3, no MD/TAPE2)
|
||||
assert "cd" in source_list
|
||||
assert "dvd" in source_list
|
||||
assert "tuner" in source_list
|
||||
# Should be sorted
|
||||
assert source_list == sorted(source_list)
|
||||
|
||||
|
||||
async def test_sound_mode(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test surround mode is reported as sound mode."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_SOUND_MODE] == "stereo"
|
||||
|
||||
|
||||
async def test_sound_mode_list(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test sound mode list comes from the model definition."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
mode_list = state.attributes[ATTR_SOUND_MODE_LIST]
|
||||
assert "direct" in mode_list
|
||||
assert "stereo" in mode_list
|
||||
assert "dolby_digital" in mode_list
|
||||
|
||||
|
||||
async def test_turn_on(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test turning on the receiver."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.power_on.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_turn_off(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test turning off the receiver."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.power_standby.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_set_volume(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test setting volume level converts from 0..1 to dB."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
# 0.5 * 98 + (-80) = -31.0
|
||||
mock_receiver.set_volume.assert_awaited_once_with(-31.0)
|
||||
|
||||
|
||||
async def test_volume_up(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test volume up."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_UP,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.volume_up.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_volume_down(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test volume down."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_DOWN,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.volume_down.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mute(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test muting."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.mute_on.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_unmute(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test unmuting."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.mute_off.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_select_source(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting input source."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "dvd"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.select_input_source.assert_awaited_once_with(InputSource.DVD)
|
||||
|
||||
|
||||
async def test_select_source_net(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting NET source."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "net"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.select_input_source.assert_awaited_once_with(InputSource.NET)
|
||||
|
||||
|
||||
async def test_select_source_bluetooth(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting BT source."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "bt"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.select_input_source.assert_awaited_once_with(InputSource.BT)
|
||||
|
||||
|
||||
async def test_select_source_raw_value(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting a raw protocol source value still works."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "DVD"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.select_input_source.assert_awaited_once_with(InputSource.DVD)
|
||||
|
||||
|
||||
async def test_select_source_unknown(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting an unknown source does nothing."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "NONEXISTENT"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.select_input_source.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_select_sound_mode(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting sound mode."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOUND_MODE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "dolby_digital"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.set_surround_mode.assert_awaited_once_with("DOLBY DIGITAL")
|
||||
|
||||
|
||||
async def test_select_sound_mode_raw_value(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test selecting a raw protocol sound mode value still works."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOUND_MODE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "DOLBY DIGITAL"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_receiver.set_surround_mode.assert_awaited_once_with("DOLBY DIGITAL")
|
||||
|
||||
|
||||
async def test_push_update(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test state updates from the receiver via subscribe callback."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
# Get the callback that was passed to subscribe()
|
||||
# The __init__.py subscribe is call [0], media_player subscribe is call [1]
|
||||
media_player_subscribe = mock_receiver.subscribe.call_args_list[1]
|
||||
callback = media_player_subscribe[0][0]
|
||||
|
||||
# Simulate a state change from the receiver
|
||||
new_state = _default_state()
|
||||
new_state.volume = -20.0
|
||||
new_state.input_source = InputSource.DVD
|
||||
new_state.surround_mode = "DOLBY DIGITAL"
|
||||
|
||||
callback(new_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.attributes[ATTR_INPUT_SOURCE] == "dvd"
|
||||
assert state.attributes[ATTR_SOUND_MODE] == "dolby_digital"
|
||||
expected_volume = ((-20.0) - (-80.0)) / 98.0
|
||||
assert abs(state.attributes[ATTR_MEDIA_VOLUME_LEVEL] - expected_volume) < 0.001
|
||||
|
||||
|
||||
async def test_push_disconnect(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test entity becomes unavailable on disconnect."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
media_player_subscribe = mock_receiver.subscribe.call_args_list[1]
|
||||
callback = media_player_subscribe[0][0]
|
||||
|
||||
# Simulate disconnect (callback receives None)
|
||||
callback(None)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == "unavailable"
|
||||
|
||||
|
||||
async def test_push_reconnect(
|
||||
hass: HomeAssistant, mock_receiver, mock_config_entry
|
||||
) -> None:
|
||||
"""Test entity becomes available again after disconnect and reconnect."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
media_player_subscribe = mock_receiver.subscribe.call_args_list[1]
|
||||
callback = media_player_subscribe[0][0]
|
||||
|
||||
# Disconnect
|
||||
callback(None)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(ENTITY_ID).state == "unavailable"
|
||||
|
||||
# Reconnect with new state
|
||||
callback(_default_state())
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_ON
|
||||
|
||||
|
||||
async def test_unload(hass: HomeAssistant, mock_receiver, mock_config_entry) -> None:
|
||||
"""Test unloading the integration disconnects the receiver."""
|
||||
await _setup_integration(hass, mock_receiver, mock_config_entry)
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_receiver.disconnect.assert_awaited_once()
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -46,20 +45,16 @@ async def test_config_entry_not_ready(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["all_devices", "device"])
|
||||
async def test_config_entry_auth_failed(
|
||||
async def test_setup_entry_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_powerfox_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Test ConfigEntryAuthFailed when authentication fails."""
|
||||
getattr(mock_powerfox_client, method).side_effect = PowerfoxAuthenticationError
|
||||
"""Test ConfigEntryNotReady when API raises an exception during entry setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_powerfox_client.device.side_effect = PowerfoxAuthenticationError
|
||||
|
||||
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
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
|
||||
@@ -6,12 +6,7 @@ from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from powerfox import (
|
||||
DeviceReport,
|
||||
PowerfoxConnectionError,
|
||||
PowerfoxNoDataError,
|
||||
PowerfoxPrivacyError,
|
||||
)
|
||||
from powerfox import DeviceReport, PowerfoxConnectionError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -40,16 +35,11 @@ async def test_all_sensors(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[PowerfoxConnectionError, PowerfoxNoDataError, PowerfoxPrivacyError],
|
||||
)
|
||||
async def test_update_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_powerfox_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test entities become unavailable after failed update."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
@@ -57,7 +47,7 @@ async def test_update_failed(
|
||||
|
||||
assert hass.states.get("sensor.poweropti_energy_usage").state is not None
|
||||
|
||||
mock_powerfox_client.device.side_effect = exception
|
||||
mock_powerfox_client.device.side_effect = PowerfoxConnectionError
|
||||
freezer.tick(timedelta(minutes=5))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -21,7 +21,6 @@ import threading
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch
|
||||
|
||||
import _pytest
|
||||
from aiohttp import client
|
||||
from aiohttp.resolver import AsyncResolver
|
||||
from aiohttp.test_utils import (
|
||||
@@ -182,27 +181,18 @@ def pytest_runtest_setup() -> None:
|
||||
"""Prepare pytest_socket and freezegun.
|
||||
|
||||
pytest_socket:
|
||||
- Throw if tests attempt to open sockets.
|
||||
Throw if tests attempt to open sockets.
|
||||
|
||||
- allow_unix_socket is set to True because it's needed by asyncio.
|
||||
Important: socket_allow_hosts must be called before disable_socket, otherwise all
|
||||
destinations will be allowed.
|
||||
|
||||
- Replace pytest_socket.SocketBlockedError with a variant which inherits from
|
||||
"pytest.Failed" instead of "RuntimeError" so that it is not caught when catching
|
||||
and logging "Exception".
|
||||
allow_unix_socket is set to True because it's needed by asyncio.
|
||||
Important: socket_allow_hosts must be called before disable_socket, otherwise all
|
||||
destinations will be allowed.
|
||||
|
||||
freezegun:
|
||||
- Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str.
|
||||
Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str.
|
||||
"""
|
||||
pytest_socket.socket_allow_hosts(["127.0.0.1"])
|
||||
pytest_socket.disable_socket(allow_unix_socket=True)
|
||||
|
||||
class SocketBlockedError(_pytest.outcomes.Failed):
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
super().__init__("A test tried to use socket.socket.")
|
||||
|
||||
pytest_socket.SocketBlockedError = SocketBlockedError
|
||||
freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined]
|
||||
|
||||
freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined]
|
||||
|
||||
Reference in New Issue
Block a user