Compare commits

..

47 Commits

Author SHA1 Message Date
Bernát Gábor 409ac3fd82 Cast SwitchBot Cloud device sw_version to string (#174167) 2026-06-18 09:38:45 +02:00
Erik Montnemery 605f69f056 Improve tests of helpers.trigger.extract_devices/entities (#172407) 2026-06-18 09:36:54 +02:00
Erik Montnemery 2f9d39827d Open repair issue when deprecated trigger behavior is used (#173259)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-18 09:36:02 +02:00
Paulus Schoutsen c268610f7d Add radio frequency entity name translation (#174173)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:34:02 +02:00
Robert Resch c6de1e9c5d Fix summary for aw check requirements (#174160) 2026-06-18 09:31:59 +02:00
Ronald van der Meer cd872c4b1c Fix Duco error placeholders and test typing (#174177) 2026-06-18 09:27:05 +02:00
Franck Nijhof c7dd266d07 Suppress SMTPException from quit() during SMTP notify retry (#174104) 2026-06-18 09:25:23 +02:00
Ronald van der Meer 0adc26cec8 Add ventilation state select to Duco box nodes (#173807)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-18 09:10:40 +02:00
Simone Chemelli 645caea76e Fix stale routine entities removal for Alexa Devices (#174138) 2026-06-18 08:20:56 +02:00
Paulus Schoutsen 944301a9e0 Raise ConfigEntryNotReady on Ollama connection errors (#174175)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 08:19:45 +02:00
jasonjhofmann 7a1e91e47a Add network MAC connection to AsusWRT router (#173679) 2026-06-18 07:09:12 +02:00
Paulus Schoutsen 64c6ef4a74 Document uv upgrade workaround in AGENTS.md (#174176) 2026-06-18 07:03:35 +02:00
Paulus Schoutsen 4d7a2358fa Add API to query radio frequency information (#173134)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 22:38:56 -04:00
Simone Chemelli 9430e84506 Bump aioamazondevices to 14.1.3 (#174158) 2026-06-17 23:46:50 +02:00
Jan Čermák ebe80afbb8 Remove radio firmware version from device info in homeassistant_yellow (#174124) 2026-06-17 23:36:09 +02:00
Jan Čermák 8bd19b6a30 Add Raspberry Pi Firmware update entity (#172929) 2026-06-17 23:36:01 +02:00
Ville Skyttä 34434e8508 Docstring and comment grammar etc fixes (#174144) 2026-06-17 23:06:10 +02:00
Manu b26fcc523e Fix tests in Onboarding integration (#174147) 2026-06-17 22:11:55 +02:00
Franck Nijhof f045b68493 Cast numeric firmware to string for squeezebox hw_version (#174076) 2026-06-17 21:31:43 +02:00
Manu 48ba38f5f5 Refactor Raspberry Pi Power Checker integration (#174135) 2026-06-17 18:33:26 +02:00
Nikolai Rahimi 44f5ad84b9 Bump mitsubishi-comfort to 0.3.2 (#174100)
Co-authored-by: Nikolai Rahimi <nikolairahimi@users.noreply.github.com>
2026-06-17 16:22:08 +02:00
some-random-climber 7330c25685 Remove battery_level attribute from icloud device tracker (#174117) 2026-06-17 16:09:15 +02:00
Leo Periou e59086d299 Change myneomitis codeowner (#174130) 2026-06-17 15:54:54 +02:00
Manu 27f44f83fb Add reauthentication flow to SMTP (#174092)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-17 13:59:43 +02:00
Simone Chemelli 05c94fa578 Bump aioamazondevices to 14.1.2 (#174114)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-06-17 13:48:42 +02:00
Tom 64b608b439 Set parallel updates for ProxmoxVE buttons (#174125) 2026-06-17 13:33:13 +02:00
Dellle c5b90cf8d1 Bump elevenlabs to 2.51.0 (#174112)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-17 13:11:30 +02:00
Marc Hörsken 31259725ec Add support for slat-based WMS covers like venetian blinds (#145005)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-17 13:10:53 +02:00
Jan Bouwhuis 8ad7c12405 Fix MQTT discovery option unjustly added to entry data (#174073) 2026-06-17 12:16:29 +02:00
Martin Hjelmare 66f0f170b7 Add pylint naive_now checker (#174053)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-17 11:44:42 +02:00
some-random-climber da91865130 Remove battery_level attribute from starline device tracker (#174118) 2026-06-17 11:10:36 +02:00
Mark 3437bcfb42 Add Rabbit Air air quality sensor (#172993) 2026-06-17 10:49:53 +02:00
Åke Strandberg 1fd5d0a5fd Aqvify reaches Platinum tier (#174111) 2026-06-17 10:49:41 +02:00
Dellle 404c58435a Bump sentence-stream to 1.3.0 (#174113) 2026-06-17 10:48:42 +02:00
Erik Montnemery 7aba1daa16 Adjust language in condition history manager comments (#174106) 2026-06-17 09:23:32 +02:00
Jan Bouwhuis 12397cc4c1 Rename advanced settings/options in MQTT subentry translation strings (#174071) 2026-06-17 09:22:25 +02:00
Josef Zweck 73cdf7e067 Revert "Add pyserial-asyncio and pyserial-asyncio-fast to deprecated packages" (#174110) 2026-06-17 09:18:44 +02:00
Franck Nijhof 4e2cfecd96 Filter out closed sites in Amber Electric config flow (#174084) 2026-06-17 09:18:19 +02:00
Åke Strandberg 4625f7de27 Aqvify has reached gold tier (#174018) 2026-06-17 09:11:42 +02:00
Brett Adams 53a1db405c Improve test coverage of Teslemetry offline polling (#174108)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:47:08 +02:00
Paul Bottein ff7262d36f Fix Yoto quality scale comments (#174088) 2026-06-17 08:43:08 +02:00
tronikos 54feb95b76 Gemini: Update TTS model to gemini-3.1 and adjust configuration options (#174094) 2026-06-17 08:42:00 +02:00
epenet d9e2b49c0c Fix incorrect use of entity component constants in template (#172532) 2026-06-17 07:55:57 +02:00
renovate[bot] 4f9051464d Update cryptography to 48.0.1 (#174096) 2026-06-17 07:34:00 +02:00
Paulus Schoutsen 87894fd623 Activate venv before running python commands (#174093) 2026-06-17 07:32:22 +02:00
Franck Nijhof 34a70a9210 Clean up deprecated solar_rising entity from sun integration (#174079) 2026-06-17 06:44:16 +02:00
Paulus Schoutsen c9fb6a13fb Remove stale requirements_test_all.txt reference (#174095) 2026-06-17 05:08:20 +02:00
256 changed files with 5222 additions and 2933 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Development Commands
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
+10
View File
@@ -164,6 +164,9 @@ Read the JSON directly for the full schema. Key fields:
- `{{CHECK_DETAIL:<pkg>:<kind>}}``<icon> <one-line explanation>`
(the bullet's `- **<label>**:` prefix is already rendered; replace
only the placeholder).
- `{{SUMMARY}}` → the single top-of-comment summary line, present only
when at least one check needed resolving. Fill it **after** resolving
every check, based on the final cell verdicts (see Step 3).
Do not modify other content in `rendered_comment`, do not re-evaluate
deterministic checks, do not add or remove packages. If `needs_agent`
@@ -192,6 +195,13 @@ Replace every placeholder with the resolved value and emit
`<!-- requirements-check -->` marker. The PR target is already wired;
do not pass `item_number`.
If a `{{SUMMARY}}` placeholder is present, replace it last, once every
`{{CHECK_CELL:…}}` is resolved:
- `All requirements checks passed. ✅` — when every check cell across all
packages is `✅` or `☑️` (treat `—`/skipped as not a problem).
- `⚠️ Some checks require attention — see the details below.` — when any
cell is `⚠️` or `❌`.
## Check instructions
### Check kind: `repo_public`
+1 -1
View File
@@ -13,7 +13,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Development Commands
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing. If uv reports that no download was found for the required Python version, the environment is running an outdated version of uv; upgrade it with `curl -LsSf https://astral.sh/uv/install.sh | sh` and run `script/setup` again.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
Generated
+2 -4
View File
@@ -1063,8 +1063,6 @@ CLAUDE.md @home-assistant/core
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/marantz_rs232/ @balloob
/tests/components/marantz_rs232/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
@@ -1169,8 +1167,8 @@ CLAUDE.md @home-assistant/core
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/myneomitis/ @Epyes
/tests/components/myneomitis/ @Epyes
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
+1 -1
View File
@@ -210,7 +210,7 @@ async def async_generate_image(
source = hass.data[DATA_MEDIA_SOURCE]
current_time = datetime.now()
current_time = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
@@ -190,8 +190,11 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
await self._async_remove_device_stale(stale_devices)
self.previous_devices = current_devices
current_routines = {slugify(routine) for routine in self.api.routines}
if stale_routines := self.previous_routines - current_routines:
current_routines = {
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}"
for routine in self.api.routines
}
if stale_routines := (self.previous_routines - current_routines):
await self._async_remove_routine_stale(stale_routines)
self.previous_routines = current_routines
@@ -225,17 +228,19 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Remove stale routine."""
entity_registry = er.async_get(self.hass)
for routine in stale_routines:
_LOGGER.debug(
"Detected change in routines: routine %s removed",
routine,
)
for routine_unique_id in stale_routines:
entity_id = entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}",
routine_unique_id,
)
if entity_id:
_LOGGER.debug(
"Detected change in routines: routine %s removed",
routine_unique_id.replace(
f"{slugify(self.config_entry.unique_id)}-", ""
),
)
entity_registry.async_remove(entity_id)
async def sync_history_state(self) -> None:
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.4"]
"requirements": ["aioamazondevices==14.1.3"]
}
@@ -34,11 +34,13 @@ def generate_site_selector_name(site: Site) -> str:
def filter_sites(sites: list[Site]) -> list[Site]:
"""Deduplicates the list of sites."""
"""Filter out closed sites and deduplicate the list of sites."""
filtered: list[Site] = []
filtered_nmi: set[str] = set()
for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.CLOSED:
continue
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -75,7 +75,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
CONF_BRAND: user_input[CONF_BRAND],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
},
)
@@ -119,7 +119,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
},
)
@@ -71,7 +71,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
+ REFRESH_TOKEN_EXPIRY_TIME.total_seconds()
)
try:
if datetime.now().timestamp() >= expiry_time:
if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now
await self._reauthenticate()
else:
await self.aquacell_api.authenticate_refresh(self.refresh_token)
@@ -92,7 +92,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
data = {
**self.config_entry.data,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["pyaqvify==0.0.11"]
}
@@ -53,29 +53,43 @@ rules:
test-coverage: done
# 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
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
discovery:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: done
comment: |
No known limitations
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category:
status: done
comment: |
None of current sensors should be set as diagnostic
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
No repair issues are created.
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
async-dependency: done
inject-websession: done
strict-typing: done
+7 -1
View File
@@ -16,7 +16,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -392,6 +396,8 @@ class AsusWrtRouter:
serial_number=self._api.serial_number,
manufacturer="Asus",
)
if label_mac := self._api.label_mac:
info["connections"] = {(CONNECTION_NETWORK_MAC, label_mac)}
if self._api.firmware:
info["sw_version"] = self._api.firmware
+1 -1
View File
@@ -61,7 +61,7 @@ def _activity_time_based(latest: Activity) -> Activity | None:
"""Get the latest state of the sensor."""
start = latest.activity_start_time
end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION
if start <= datetime.now() <= end:
if start <= datetime.now() <= end: # pylint: disable=home-assistant-enforce-naive-now
return latest
return None
@@ -239,11 +239,11 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
self.source = None
if start_datetime := playing_info.get("startDateTime"):
start_datetime = datetime.fromisoformat(start_datetime)
current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo)
current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo) # pylint: disable=home-assistant-enforce-naive-now
self.media_position = int(
(current_datetime - start_datetime).total_seconds()
)
self.media_position_updated_at = datetime.now()
self.media_position_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
else:
self.media_position = None
self.media_position_updated_at = None
@@ -31,7 +31,7 @@ class BroadlinkHeartbeat:
async def async_setup(self) -> None:
"""Set up the heartbeat."""
if self._unsubscribe is None:
await self.async_heartbeat(dt.datetime.now())
await self.async_heartbeat(dt.datetime.now()) # pylint: disable=home-assistant-enforce-naive-now
self._unsubscribe = event.async_track_time_interval(
self._hass, self.async_heartbeat, self.HEARTBEAT_INTERVAL
)
+1 -1
View File
@@ -158,7 +158,7 @@ class BrData:
_LOGGER.debug("Buienradar parsed data: %s", result)
if result.get(SUCCESS) is not True:
if int(datetime.now().strftime("%H")) > 0:
if int(datetime.now().strftime("%H")) > 0: # pylint: disable=home-assistant-enforce-naive-now
_LOGGER.warning(
"Unable to parse data from Buienradar. (Msg: %s)",
result.get(MESSAGE),
+1 -1
View File
@@ -5,6 +5,6 @@ from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=10)
BOX_NODE_ID = 1
+21 -6
View File
@@ -2,6 +2,7 @@
from dataclasses import dataclass
import logging
from typing import cast
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import (
@@ -9,7 +10,7 @@ from duco_connectivity.exceptions import (
DucoError,
DucoResponseError,
)
from duco_connectivity.models import BoardInfo, Node
from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -29,6 +30,7 @@ class DucoData:
"""Data returned by the Duco coordinator."""
nodes: dict[int, Node]
node_actions: NodeListActionItemList
rssi_wifi: int | None
@@ -67,19 +69,16 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
except DucoConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> DucoData:
@@ -90,15 +89,30 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except DucoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
try:
node_actions = await self.client.async_get_node_actions()
except DucoError as err:
previous_data = cast(DucoData | None, self.data)
node_actions = (
previous_data.node_actions
if previous_data is not None
else NodeListActionItemList(nodes=[])
)
_LOGGER.warning(
"Could not fetch Duco node actions; %s",
"keeping previous select discovery data"
if previous_data is not None
else "starting with empty select discovery data",
exc_info=err,
)
# LAN info only backs the diagnostic RSSI sensor, so failures on this
# supplemental endpoint, including connection failures, should not make
# the primary node entities unavailable.
@@ -112,5 +126,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
return DucoData(
nodes={node.node_id: node for node in nodes},
node_actions=node_actions,
rssi_wifi=rssi_wifi,
)
@@ -64,7 +64,6 @@ async def async_get_config_entry_diagnostics(
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
-1
View File
@@ -132,6 +132,5 @@ class DucoVentilationFanEntity(DucoEntity, FanEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
translation_placeholders={"error": repr(err)},
) from err
await self.coordinator.async_refresh()
+145
View File
@@ -0,0 +1,145 @@
"""Select platform for the Duco integration."""
import logging
from duco_connectivity import (
ActionItem,
DucoError,
DucoRateLimitError,
KnownActionName,
Node,
NodeListActionItemList,
NodeType,
VentilationState,
)
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
def _get_ventilation_options(action: ActionItem) -> tuple[str, ...] | None:
"""Return ventilation options advertised by a node action."""
if action.action.known_value is not KnownActionName.SET_VENTILATION_STATE:
return None
options = tuple(str(value) for value in action.enum_values if value)
return options or None
def _discover_ventilation_options(
node_actions: NodeListActionItemList,
) -> dict[int, tuple[str, ...]]:
"""Build a node-to-options map from the node action metadata."""
options_by_node: dict[int, tuple[str, ...]] = {}
for node_action in node_actions.nodes:
for action in node_action.actions:
if options := _get_ventilation_options(action):
options_by_node[node_action.node_id] = options
break
return options_by_node
async def async_setup_entry(
hass: HomeAssistant,
entry: DucoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duco select entities."""
coordinator = entry.runtime_data
known_nodes: set[int] = set()
@callback
def _async_add_new_entities() -> None:
"""Add select entities for newly discovered controllable nodes."""
options_by_node = _discover_ventilation_options(coordinator.data.node_actions)
new_entities: list[DucoVentilationStateSelect] = []
for node in coordinator.data.nodes.values():
if node.node_id in known_nodes:
continue
if node.general.node_type is not NodeType.BOX:
continue
options = options_by_node.get(node.node_id)
if options is None:
continue
known_nodes.add(node.node_id)
new_entities.append(DucoVentilationStateSelect(coordinator, node, options))
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
_async_add_new_entities()
class DucoVentilationStateSelect(DucoEntity, SelectEntity):
"""Select entity for node ventilation states."""
_attr_translation_key = "ventilation_state"
def __init__(
self,
coordinator: DucoCoordinator,
node: Node,
options: tuple[str, ...],
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator, node)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{node.node_id}_ventilation_state"
)
self._attr_options = list(options)
@property
def current_option(self) -> str | None:
"""Return the current ventilation state when it is selectable."""
if (ventilation := self._node.ventilation) is None:
return None
if ventilation.state is VentilationState.UNKNOWN:
return None
state = ventilation.state.value
if state not in self.options:
return None
return state
async def async_select_option(self, option: str) -> None:
"""Set a new ventilation state on the node."""
try:
# SelectEntity exposes string options, and passing the raw API value
# through keeps newly added Duco states forward-compatible.
await self.coordinator.client.async_set_ventilation_state(
self._node_id, option
)
except DucoRateLimitError as err:
_LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="rate_limit_exceeded",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
) from err
# Duco may normalize the requested action on readback, such as
# MAN1x2 -> MAN1 or AUTO -> CNT1, so refresh the authoritative state.
await self.coordinator.async_refresh()
+27 -3
View File
@@ -48,6 +48,30 @@
}
}
},
"select": {
"ventilation_state": {
"name": "Ventilation state",
"state": {
"aut1": "AUT1",
"aut2": "AUT2",
"aut3": "AUT3",
"auto": "AUTO",
"cnt1": "CNT1",
"cnt2": "CNT2",
"cnt3": "CNT3",
"empt": "EMPT",
"man1": "MAN1",
"man1x2": "MAN1x2",
"man1x3": "MAN1x3",
"man2": "MAN2",
"man2x2": "MAN2x2",
"man2x3": "MAN2x3",
"man3": "MAN3",
"man3x2": "MAN3x2",
"man3x3": "MAN3x3"
}
}
},
"sensor": {
"iaq_co2": {
"name": "CO2 air quality index"
@@ -87,16 +111,16 @@
},
"exceptions": {
"api_error": {
"message": "Unexpected error from the Duco API: {error}"
"message": "Unexpected error from the Duco API."
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Duco instance: {error}"
"message": "Could not connect to the Duco device."
},
"connection_error": {
"message": "Could not connect to the Duco device."
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
"message": "Failed to set ventilation state."
},
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.2.0"]
"requirements": ["elevenlabs==2.51.0", "sentence-stream==1.3.0"]
}
@@ -91,7 +91,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now()
now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
+1 -1
View File
@@ -200,7 +200,7 @@ class FreeboxRouter:
"IPv6": connection_datas.get("ipv6"),
"connection_type": connection_datas["media"],
"uptime": datetime.fromtimestamp(
round(datetime.now().timestamp()) - syst_datas["uptime_val"]
round(datetime.now().timestamp()) - syst_datas["uptime_val"] # pylint: disable=home-assistant-enforce-naive-now
),
"firmware_version": self._sw_v,
"serial": syst_datas["serial"],
+1 -1
View File
@@ -54,7 +54,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
if (
self.fyta.expiration is None
or self.fyta.expiration.timestamp() < datetime.now().timestamp()
or self.fyta.expiration.timestamp() < datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now
):
await self.renew_authentication()
+1 -1
View File
@@ -117,5 +117,5 @@ class FytaPlantImageEntity(FytaPlantEntity, ImageEntity):
if url != self._attr_image_url:
self._cached_image = None
self._attr_image_last_updated = datetime.now()
self._attr_image_last_updated = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
return url
+2 -2
View File
@@ -154,12 +154,12 @@ class GenericCamera(Camera):
self._last_image is not None
and url == self._last_url
and self._last_update + timedelta(0, self._attr_frame_interval)
> datetime.now()
> datetime.now() # pylint: disable=home-assistant-enforce-naive-now
):
return self._last_image
try:
update_time = datetime.now()
update_time = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl)
response = await async_client.get(
url,
@@ -584,7 +584,7 @@ async def ws_start_preview(
if user_input.get(CONF_STILL_IMAGE_URL):
ha_still_url = (
"/api/generic/preview_flow_image"
f"/{msg['flow_id']}?t={datetime.now().isoformat()}"
f"/{msg['flow_id']}?t={datetime.now().isoformat()}" # pylint: disable=home-assistant-enforce-naive-now
)
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
+1 -1
View File
@@ -29,7 +29,7 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription(
key="synchronize_clock",
translation_key="synchronize_clock",
entity_category=EntityCategory.CONFIG,
action=lambda inv: inv.write_setting("time", datetime.now()),
action=lambda inv: inv.write_setting("time", datetime.now()), # pylint: disable=home-assistant-enforce-naive-now
)
+1 -1
View File
@@ -104,7 +104,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
# Force a token refresh to fix a bug where tokens were persisted with
# expires_in (relative time delta) and expires_at (absolute time) swapped.
# A google session token typically only lasts a few days between refresh.
now = datetime.now()
now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
if session.token["expires_at"] >= (now + timedelta(days=365)).timestamp():
session.token["expires_in"] = 0
session.token["expires_at"] = now.timestamp()
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
payload: dict[str, Any] = {
"now": dt_util.now().isoformat(),
"timezone": str(dt_util.get_default_time_zone()),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo), # pylint: disable=home-assistant-enforce-naive-now
}
store = config_entry.runtime_data.store
@@ -434,49 +434,56 @@ async def google_generative_ai_config_option_schema(
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type != "tts":
schema.update(
{
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type == "conversation":
schema.update(
{
@@ -21,7 +21,7 @@ CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-3.1-flash-tts-preview"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
@@ -18,7 +18,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .const import (
CONF_CHAT_MODEL,
CONF_TEMPERATURE,
LOGGER,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TTS_MODEL,
)
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
@@ -191,7 +197,10 @@ class GoogleGenerativeAITextToSpeechEntity(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
config = self.create_generate_content_config()
config = types.GenerateContentConfig()
config.temperature = self.subentry.data.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
@@ -69,7 +69,7 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
add_created_column = call.data[ADD_CREATED_COLUMN]
now = str(datetime.now())
now = str(datetime.now()) # pylint: disable=home-assistant-enforce-naive-now
rows = []
for d in call.data[DATA]:
row_data = ({"created": now} | d) if add_created_column else d
@@ -128,7 +128,7 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
"""
if not super().available:
return False
return datetime.now() - self._device.lastseen < DEVICE_TIMEOUT
return datetime.now() - self._device.lastseen < DEVICE_TIMEOUT # pylint: disable=home-assistant-enforce-naive-now
@property
def is_on(self) -> bool:
@@ -387,6 +387,6 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None:
def next_datetime(simple_time: time) -> datetime:
"""Take a time like 08:00:00 and combine it with the current date."""
combined = datetime.combine(dt_util.start_of_local_day(), simple_time)
if combined < datetime.now():
if combined < datetime.now(): # pylint: disable=home-assistant-enforce-naive-now
combined = combined + timedelta(days=1)
return combined
@@ -135,6 +135,10 @@
"description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant.",
"title": "The {integration_title} integration is being removed"
},
"deprecated_trigger_behavior": {
"description": "An automation, script or template entity uses the trigger behavior option `{deprecated_behavior}`, which has been renamed to `{new_behavior}`. The old value still works for now, but support for it will be removed in a future release.\n\nTo fix this issue, edit the affected automations and scripts and change the behavior option from `behavior: {deprecated_behavior}` to `behavior: {new_behavior}`, then restart Home Assistant.",
"title": "Deprecated trigger behavior option in use"
},
"deprecated_yaml": {
"description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "The {integration_title} YAML configuration is being removed"
@@ -5,17 +5,21 @@ from dataclasses import dataclass
import logging
from typing import Any, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import RaspberryPiFirmwareInfo
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
from universal_silabs_flasher.flasher import DeviceSpecificFlasher
from yarl import URL
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.restore_state import ExtraStoredData
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -26,6 +30,10 @@ from .util import (
FirmwareInfo,
async_firmware_flashing_context,
async_flash_silabs_firmware,
async_get_raspberry_pi_firmware_info,
async_update_raspberry_pi_firmware,
humanize_rpi_firmware_version,
rpi_firmware_release_url,
)
_LOGGER = logging.getLogger(__name__)
@@ -288,3 +296,91 @@ class BaseFirmwareUpdateEntity(
self.async_write_ha_state()
self._firmware_info_callback(firmware_info)
class RaspberryPiFirmwareUpdateEntity(UpdateEntity):
"""Update entity for the Raspberry Pi firmware (bootloader EEPROM and VL805).
There is no coordinator. The firmware state only changes after a reboot
(which restarts Core and re-fetches at setup) or right after the install
action (re-fetched in async_install), so polling would never show anything
new. The board integration passes in the DeviceInfo so the entity ends up
on that board's device.
"""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.RELEASE_NOTES
)
_attr_translation_key = "rpi_firmware"
def __init__(
self,
firmware: RaspberryPiFirmwareInfo,
device_info: DeviceInfo,
unique_id: str,
board: str,
) -> None:
"""Initialize entity."""
self._firmware = firmware
self._attr_device_info = device_info
self._attr_unique_id = unique_id
self._board = board
@property
def installed_version(self) -> str | None:
"""Composite installed firmware version.
Once an update is applied (update_pending), report the new version as
installed so the entity reads "up to date". The running firmware only
changes after the reboot, which the Supervisor flags with a
REBOOT_REQUIRED repair.
"""
if self._firmware.update_pending:
return humanize_rpi_firmware_version(self._firmware.latest_version)
return humanize_rpi_firmware_version(self._firmware.current_version)
@property
def latest_version(self) -> str | None:
"""Composite available firmware version."""
return humanize_rpi_firmware_version(self._firmware.latest_version)
@property
def release_url(self) -> str | None:
"""Return the EEPROM release notes for this board's SoC."""
return rpi_firmware_release_url(self._board)
async def async_release_notes(self) -> str | None:
"""Return the pre-install warning and reboot notice as ha-alert boxes."""
return (
"<ha-alert alert-type='warning'>"
"Do not interrupt the firmware flash. "
"Power loss during the EEPROM update can render your device "
"inoperable."
"</ha-alert>\n\n"
"<ha-alert alert-type='info'>"
"A reboot is required after install for the new firmware to "
"take effect."
"</ha-alert>\n"
)
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
await async_update_raspberry_pi_firmware(self.hass)
# Re-fetch so the entity picks up update_pending and reads "up to date".
try:
refreshed = await async_get_raspberry_pi_firmware_info(self.hass)
# pylint: disable-next=home-assistant-action-swallowed-exception
except SupervisorError:
# The update succeeded; keep the previous info until the next fetch.
_LOGGER.exception(
"Failed to refresh Raspberry Pi firmware info after update"
)
refreshed = None
if refreshed is not None:
self._firmware = refreshed
self.async_write_ha_state()
@@ -8,6 +8,8 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import RaspberryPiFirmwareInfo
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import BaseFlasher, DeviceSpecificFlasher, Flasher
@@ -18,12 +20,14 @@ from homeassistant.components.hassio import (
AddonState,
HassioNotReadyError,
get_apps_list,
get_supervisor_client,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.util import dt as dt_util
from . import DATA_COMPONENT
from .const import (
@@ -515,3 +519,81 @@ async def async_flash_silabs_firmware(
raise HomeAssistantError("Failed to probe the firmware after flashing")
return probed_firmware_info
# Supervisor os_info.board values whose os-agent exposes
# io.hass.os.Boards.RaspberryPi.Firmware (Raspberry Pi 4/5 and Yellow CM4/CM5).
# RPi 2/3 have no SPI EEPROM bootloader. These boards belong to different
# integrations (raspberry_pi and homeassistant_yellow), so the shared Supervisor
# plumbing lives here.
BOARDS_WITH_RASPBERRYPI_FIRMWARE = frozenset({"rpi4-64", "rpi5-64", "yellow"})
RPI_FIRMWARE_RELEASE_URL_DEFAULT = (
"https://github.com/raspberrypi/rpi-eeprom/blob/master/releases.md"
)
# Per-SoC release notes. The Yellow can run both with a CM4 or CM5, so we don't
# know the exact SoC.
_RPI_FIRMWARE_RELEASE_URLS = {
"rpi4-64": "https://github.com/raspberrypi/rpi-eeprom/blob/master/firmware-2711/release-notes.md",
"rpi5-64": "https://github.com/raspberrypi/rpi-eeprom/blob/master/firmware-2712/release-notes.md",
}
def rpi_firmware_release_url(board: str) -> str:
"""Return the RPi firmware release notes URL for a board."""
return _RPI_FIRMWARE_RELEASE_URLS.get(board, RPI_FIRMWARE_RELEASE_URL_DEFAULT)
def humanize_rpi_firmware_version(version: str | None) -> str | None:
"""Turn a raw firmware version into a human-readable string.
The Supervisor reports the bootloader EEPROM build as a Unix timestamp,
optionally suffixed with the VL805 EEPROM revision (timestamp-hexstring).
Render the timestamp as a UTC YYYY-MM-DD date and append (VL805 hexstring)
when a VL805 revision is present.
"""
if version is None:
return None
timestamp, _, vl805 = version.partition("-")
try:
date = dt_util.utc_from_timestamp(int(timestamp)).strftime("%Y-%m-%d")
except ValueError:
return version
if vl805:
return f"{date} (VL805 {vl805})"
return date
async def async_get_raspberry_pi_firmware_info(
hass: HomeAssistant,
) -> RaspberryPiFirmwareInfo | None:
"""Return the firmware info, or None if the Supervisor doesn't expose it.
A 404 (SupervisorNotFoundError) means the endpoint is unavailable (older
Supervisor, pre OS 18) and the feature is skipped. Any other SupervisorError
is a real communication failure and is left to propagate so the caller can
retry.
"""
client = get_supervisor_client(hass)
try:
return await client.os.raspberry_pi_firmware_info()
except SupervisorNotFoundError:
_LOGGER.debug("Raspberry Pi firmware endpoint unavailable")
return None
async def async_update_raspberry_pi_firmware(hass: HomeAssistant) -> None:
"""Trigger the Raspberry Pi firmware (bootloader EEPROM and VL805) update.
The Supervisor always raises a reboot-required issue and suggestion on
success. The new firmware only runs after the next reboot, whether the
flash was live (RPi5/CM5) or staged (RPi4/CM4/Yellow).
"""
client = get_supervisor_client(hass)
try:
await client.os.update_raspberry_pi_firmware()
except SupervisorError as err:
raise HomeAssistantError(
f"Error updating Raspberry Pi firmware: {err}"
) from err
@@ -8,6 +8,9 @@
"update": {
"radio_firmware": {
"name": "Radio firmware"
},
"rpi_firmware": {
"name": "Firmware"
}
}
},
@@ -2,6 +2,7 @@
import logging
from aiohasupervisor import SupervisorError
from universal_silabs_flasher.flasher import YellowFlasher
from homeassistant.components.homeassistant_hardware.coordinator import (
@@ -10,15 +11,17 @@ from homeassistant.components.homeassistant_hardware.coordinator import (
from homeassistant.components.homeassistant_hardware.update import (
BaseFirmwareUpdateEntity,
FirmwareUpdateEntityDescription,
RaspberryPiFirmwareUpdateEntity,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
async_get_raspberry_pi_firmware_info,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -141,9 +144,34 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the firmware update config entry."""
entity = _async_create_update_entity(hass, config_entry, async_add_entities)
entities: list[UpdateEntity] = [
_async_create_update_entity(hass, config_entry, async_add_entities)
]
async_add_entities([entity])
try:
rpi_firmware = await async_get_raspberry_pi_firmware_info(hass)
except SupervisorError as err:
# async_get_raspberry_pi_firmware_info handles 404 gracefully, anything
# else is a genuine Supervisor error that we should log.
_LOGGER.warning("Raspberry Pi firmware info unavailable: %s", err)
rpi_firmware = None
if rpi_firmware is not None and not rpi_firmware.update_blocked:
entities.append(
RaspberryPiFirmwareUpdateEntity(
rpi_firmware,
DeviceInfo(
identifiers={(DOMAIN, "yellow")},
name=MODEL,
model=MODEL,
manufacturer=MANUFACTURER,
),
unique_id="yellow_rpi_firmware",
board="yellow",
)
)
async_add_entities(entities)
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
@@ -166,6 +194,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
name=MODEL,
model=MODEL,
manufacturer=MANUFACTURER,
sw_version=None, # Radio FW exposed by the update entity, removed in 2026.7.0
)
# Use the cached firmware info if it exists
@@ -178,20 +207,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
source="homeassistant_yellow",
)
def _update_attributes(self) -> None:
"""Recompute the attributes of the entity."""
super()._update_attributes()
assert self.device_entry is not None
device_registry = dr.async_get(self.hass)
device_registry.async_update_device(
device_id=self.device_entry.id,
sw_version=(
f"{self.entity_description.firmware_name}"
f" {self._attr_installed_version}"
),
)
@callback
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
"""Handle updated firmware info being pushed by an integration."""
@@ -75,7 +75,7 @@ def format_last_reset_elapsed_seconds(value: str | None) -> datetime | None:
if value is None:
return None
try:
last_reset = datetime.now() - timedelta(seconds=int(value))
last_reset = datetime.now() - timedelta(seconds=int(value)) # pylint: disable=home-assistant-enforce-naive-now
last_reset.replace(microsecond=0)
except ValueError:
return None
@@ -87,11 +87,6 @@ class IcloudTrackerEntity(TrackerEntity):
assert self._device.location is not None
return self._device.location[DEVICE_LOCATION_LONGITUDE]
@property
def battery_level(self) -> int | None:
"""Return the battery level of the device."""
return self._device.battery_level
@property
def icon(self) -> str:
"""Return the icon."""
+1 -1
View File
@@ -342,7 +342,7 @@ class iOSIdentifyDeviceView(HomeAssistantView):
hass = request.app[KEY_HASS]
data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat()
data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat() # pylint: disable=home-assistant-enforce-naive-now
device_id = data[ATTR_DEVICE_ID]
@@ -71,8 +71,8 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]
self._train_schedule.query,
self._start,
self._destination,
datetime.now().strftime("%Y-%m-%d"),
datetime.now().strftime("%H:%M"),
datetime.now().strftime("%Y-%m-%d"), # pylint: disable=home-assistant-enforce-naive-now
datetime.now().strftime("%H:%M"), # pylint: disable=home-assistant-enforce-naive-now
)
except Exception as e:
raise UpdateFailed(
@@ -214,7 +214,7 @@ class LgTVDevice(MediaPlayerEntity):
def media_image_url(self):
"""URL for obtaining a screen capture."""
return (
f"{self._client.url}data?target=screen_image&_={datetime.now().timestamp()}"
f"{self._client.url}data?target=screen_image&_={datetime.now().timestamp()}" # pylint: disable=home-assistant-enforce-naive-now
)
def turn_off(self) -> None:
@@ -18,7 +18,7 @@ async def async_get_config_entry_diagnostics(
payload: dict[str, Any] = {
"now": dt_util.now().isoformat(),
"timezone": str(dt_util.get_default_time_zone()),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo), # pylint: disable=home-assistant-enforce-naive-now
}
store = config_entry.runtime_data
ics = await store.async_load()
@@ -1,75 +0,0 @@
"""The Marantz RS-232 integration."""
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2003ReceiverState,
V2007ReceiverState,
V2015ReceiverState,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .config_flow import MODEL_MODERN, V2003_MODELS, V2007_MODELS
from .const import LOGGER, MarantzReceiver, MarantzRS232ConfigEntry
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(
hass: HomeAssistant, entry: MarantzRS232ConfigEntry
) -> bool:
"""Set up Marantz RS-232 from a config entry."""
port = entry.data[CONF_DEVICE]
model_key = entry.data[CONF_MODEL]
receiver: MarantzReceiver
if model_key == MODEL_MODERN:
receiver = MarantzV2015Receiver(port)
elif model_key in V2003_MODELS:
receiver = MarantzV2003Receiver(port)
else:
receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key])
try:
await receiver.connect()
await receiver.query_state()
except (ConnectionError, OSError, TimeoutError) as err:
LOGGER.error("Error connecting to Marantz receiver at %s: %s", port, err)
if receiver.connected:
await receiver.disconnect()
raise ConfigEntryNotReady from err
entry.runtime_data = receiver
@callback
def _on_disconnect(
state: V2015ReceiverState | V2007ReceiverState | V2003ReceiverState | None,
) -> None:
# Only reload if the entry is still loaded. During entry removal,
# disconnect() fires this callback but the entry is already gone.
if state is None and entry.state is ConfigEntryState.LOADED:
LOGGER.warning("Marantz 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: MarantzRS232ConfigEntry
) -> 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
@@ -1,122 +0,0 @@
"""Config flow for the Marantz RS-232 integration."""
from typing import Any
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2007Model,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_MODEL
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
SerialPortSelector,
)
from .const import DOMAIN, LOGGER
MODEL_MODERN = "modern"
MODEL_SR7002 = "sr7002"
MODEL_SR8002 = "sr8002"
MODEL_SR9300 = "sr9300"
MODEL_SR8300 = "sr8300"
MODEL_NAMES: dict[str, str] = {
MODEL_MODERN: "Modern",
MODEL_SR7002: "SR7002",
MODEL_SR8002: "SR8002",
MODEL_SR9300: "SR9300",
MODEL_SR8300: "SR8300",
}
V2007_MODELS: dict[str, V2007Model] = {
MODEL_SR7002: V2007Model.SR7002,
MODEL_SR8002: V2007Model.SR8002,
}
V2003_MODELS = frozenset({MODEL_SR9300, MODEL_SR8300})
async def _async_attempt_connect(port: str, model_key: str) -> str | None:
"""Attempt to connect to the receiver at the given port.
Returns None on success, error on failure.
"""
receiver: MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver
if model_key == MODEL_MODERN:
receiver = MarantzV2015Receiver(port)
elif model_key in V2003_MODELS:
receiver = MarantzV2003Receiver(port)
else:
receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key])
try:
await receiver.connect()
except (
# When the port contains invalid connection data
ValueError,
# If it is a remote port, and we cannot connect
ConnectionError,
OSError,
TimeoutError,
):
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
else:
await receiver.disconnect()
return None
class MarantzRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Marantz RS-232."""
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:
model_key = user_input[CONF_MODEL]
self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
error = await _async_attempt_connect(user_input[CONF_DEVICE], model_key)
if not error:
return self.async_create_entry(
title=MODEL_NAMES[model_key],
data={
CONF_DEVICE: user_input[CONF_DEVICE],
CONF_MODEL: model_key,
},
)
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_MODEL, default=MODEL_MODERN): SelectSelector(
SelectSelectorConfig(
options=list(MODEL_NAMES),
mode=SelectSelectorMode.DROPDOWN,
translation_key="model",
)
),
vol.Required(CONF_DEVICE): SerialPortSelector(),
}
),
user_input or {},
),
errors=errors,
)
@@ -1,19 +0,0 @@
"""Constants for the Marantz RS-232 integration."""
import logging
from marantz_rs232 import (
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
)
from homeassistant.config_entries import ConfigEntry
LOGGER = logging.getLogger(__package__)
DOMAIN = "marantz_rs232"
type MarantzReceiver = (
MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver
)
type MarantzRS232ConfigEntry = ConfigEntry[MarantzReceiver]
@@ -1,13 +0,0 @@
{
"domain": "marantz_rs232",
"name": "Marantz RS-232",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/marantz_rs232",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["marantz_rs232"],
"quality_scale": "bronze",
"requirements": ["marantz-rs232==2.0.0"]
}
@@ -1,567 +0,0 @@
"""Media player platform for the Marantz RS-232 integration."""
import math
from typing import cast
from marantz_rs232 import (
V2015_MIN_VOLUME_DB,
V2015_VOLUME_DB_RANGE,
MarantzV2003Receiver,
MarantzV2007Receiver,
MarantzV2015Receiver,
V2003MainPlayer,
V2003MultiRoomPlayer,
V2003ReceiverState,
V2003Source,
V2007MainPlayer,
V2007MultiRoomPlayer,
V2007ReceiverState,
V2007Source,
V2015InputSource,
V2015MainPlayer,
V2015ReceiverState,
V2015ZonePlayer,
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .config_flow import MODEL_NAMES
from .const import DOMAIN, MarantzRS232ConfigEntry
V2003_MIN_VOLUME_DB = -90.0
V2003_VOLUME_DB_RANGE = 189.0 # -90..+99
INPUT_SOURCE_V2015_TO_HA: dict[V2015InputSource, str] = {
V2015InputSource.PHONO: "phono",
V2015InputSource.CD: "cd",
V2015InputSource.TUNER: "tuner",
V2015InputSource.DVD: "dvd",
V2015InputSource.BD: "bd",
V2015InputSource.TV: "tv",
V2015InputSource.SAT_CBL: "sat_cbl",
V2015InputSource.SAT: "sat",
V2015InputSource.MPLAY: "mplay",
V2015InputSource.VCR: "vcr",
V2015InputSource.GAME: "game",
V2015InputSource.V_AUX: "v_aux",
V2015InputSource.HDRADIO: "hdradio",
V2015InputSource.SIRIUS: "sirius",
V2015InputSource.SPOTIFY: "spotify",
V2015InputSource.SIRIUSXM: "siriusxm",
V2015InputSource.RHAPSODY: "rhapsody",
V2015InputSource.PANDORA: "pandora",
V2015InputSource.NAPSTER: "napster",
V2015InputSource.LASTFM: "lastfm",
V2015InputSource.FLICKR: "flickr",
V2015InputSource.IRADIO: "iradio",
V2015InputSource.SERVER: "server",
V2015InputSource.FAVORITES: "favorites",
V2015InputSource.CDR: "cdr",
V2015InputSource.AUX1: "aux1",
V2015InputSource.AUX2: "aux2",
V2015InputSource.AUX3: "aux3",
V2015InputSource.AUX4: "aux4",
V2015InputSource.AUX5: "aux5",
V2015InputSource.AUX6: "aux6",
V2015InputSource.AUX7: "aux7",
V2015InputSource.NET: "net",
V2015InputSource.NET_USB: "net_usb",
V2015InputSource.BT: "bt",
V2015InputSource.M_XPORT: "m_xport",
V2015InputSource.USB_IPOD: "usb_ipod",
}
INPUT_SOURCE_V2007_TO_HA: dict[V2007Source, str] = {
V2007Source.TV: "tv",
V2007Source.DVD: "dvd",
V2007Source.VCR1: "vcr1",
V2007Source.DSS_VCR2: "dss_vcr2",
V2007Source.AUX1: "aux1",
V2007Source.AUX2: "aux2",
V2007Source.CD_CDR: "cd_cdr",
V2007Source.TAPE: "tape",
V2007Source.TUNER1: "tuner",
V2007Source.FM1: "fm",
V2007Source.AM1: "am",
V2007Source.XM1: "xm",
}
INPUT_SOURCE_V2003_TO_HA: dict[V2003Source, str] = {
V2003Source.DSS: "dss",
V2003Source.TV: "tv",
V2003Source.LD: "ld",
V2003Source.DVD: "dvd",
V2003Source.VCR1: "vcr1",
V2003Source.VCR2_DVDR: "vcr2_dvdr",
V2003Source.AUX1: "aux1",
V2003Source.AUX2: "aux2",
V2003Source.DVDR: "dvdr",
V2003Source.CD: "cd",
V2003Source.TAPE: "tape",
V2003Source.CDR: "cdr",
V2003Source.FM: "fm",
V2003Source.AM: "am",
V2003Source.MW: "mw",
V2003Source.LW: "lw",
V2003Source.TUNER: "tuner",
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MarantzRS232ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Marantz RS-232 media player."""
receiver = config_entry.runtime_data
entities: list[MediaPlayerEntity]
if isinstance(receiver, MarantzV2015Receiver):
entities = [
MarantzV2015MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.zone_2.power is not None:
entities.append(
MarantzV2015MediaPlayer(
receiver, receiver.zone_2, config_entry, "zone_2"
)
)
if receiver.zone_3.power is not None:
entities.append(
MarantzV2015MediaPlayer(
receiver, receiver.zone_3, config_entry, "zone_3"
)
)
elif isinstance(receiver, MarantzV2003Receiver):
entities = [
MarantzV2003MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.multi_room.power is not None:
entities.append(
MarantzV2003MediaPlayer(
receiver, receiver.multi_room, config_entry, "multi_room"
)
)
else:
entities = [
MarantzV2007MediaPlayer(receiver, receiver.main, config_entry, "main")
]
if receiver.multi_room_a.power is not None:
entities.append(
MarantzV2007MediaPlayer(
receiver, receiver.multi_room_a, config_entry, "multi_room_a"
)
)
async_add_entities(entities)
class MarantzV2015MediaPlayer(MediaPlayerEntity):
"""Representation of a modern Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2015_MIN_VOLUME_DB
_volume_range = V2015_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2015Receiver,
player: V2015MainPlayer | V2015ZonePlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the media player."""
self._receiver = receiver
self._player = player
self._is_main = zone == "main"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2015_TO_HA.values())
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
)
if zone == "main":
self._attr_name = None
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
elif zone == "zone_2":
self._attr_name = "Zone 2"
else:
self._attr_name = "Zone 3"
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2015ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
if self._player.power is None:
self._attr_state = None
else:
self._attr_state = (
MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF
)
source = self._player.input_source
self._attr_source = INPUT_SOURCE_V2015_TO_HA.get(source) if source else None
volume_min = self._player.volume_min
volume_max = self._player.volume_max
if volume_min is not None:
self._volume_min = volume_min
if volume_max is not None and volume_max > volume_min:
self._volume_range = volume_max - volume_min
volume = self._player.volume
if volume is not None:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
if self._is_main:
self._attr_is_volume_muted = cast(V2015MainPlayer, self._player).mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
db = volume * self._volume_range + self._volume_min
await self._player.set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._player.volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._player.volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
player = cast(V2015MainPlayer, self._player)
if mute:
await player.mute_on()
else:
await player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
input_source = next(
(
input_source
for input_source, ha_source in INPUT_SOURCE_V2015_TO_HA.items()
if ha_source == source
),
None,
)
if input_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(input_source)
class MarantzV2007MediaPlayer(MediaPlayerEntity):
"""Representation of a 2007-era Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2015_MIN_VOLUME_DB
_volume_range = V2015_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2007Receiver,
player: V2007MainPlayer | V2007MultiRoomPlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the v2007 media player."""
self._receiver = receiver
self._player = player
if isinstance(player, V2007MainPlayer):
self._set_volume = player.set_volume
self._volume_up = player.volume_up
self._volume_down = player.volume_down
else:
self._set_volume = player.set_line_volume
self._volume_up = player.line_volume_up
self._volume_down = player.line_volume_down
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2007_TO_HA.values())
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.SELECT_SOURCE
)
if zone == "main":
self._attr_name = None
else:
self._attr_name = "Multi Room"
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2007ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
if self._player.power is None:
self._attr_state = None
else:
self._attr_state = (
MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF
)
source = self._player.input_source
self._attr_source = INPUT_SOURCE_V2007_TO_HA.get(source) if source else None
if isinstance(self._player, V2007MainPlayer):
volume = self._player.volume
else:
volume = self._player.line_volume
if volume is not None:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
self._attr_is_volume_muted = self._player.mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
db = volume * self._volume_range + self._volume_min
await self._set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
if mute:
await self._player.mute_on()
else:
await self._player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
v2007_source = next(
(
ls
for ls, ha_source in INPUT_SOURCE_V2007_TO_HA.items()
if ha_source == source
),
None,
)
if v2007_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(v2007_source)
class MarantzV2003MediaPlayer(MediaPlayerEntity):
"""Representation of a 2003-era Marantz receiver controlled over RS-232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
_attr_translation_key = "receiver"
_attr_should_poll = False
_volume_min = V2003_MIN_VOLUME_DB
_volume_range = V2003_VOLUME_DB_RANGE
def __init__(
self,
receiver: MarantzV2003Receiver,
player: V2003MainPlayer | V2003MultiRoomPlayer,
config_entry: MarantzRS232ConfigEntry,
zone: str,
) -> None:
"""Initialize the v2003 media player."""
self._receiver = receiver
self._player = player
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Marantz",
model_id=MODEL_NAMES.get(config_entry.data["model"]),
)
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
self._attr_source_list = sorted(INPUT_SOURCE_V2003_TO_HA.values())
if zone == "main":
self._attr_name = None
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.SELECT_SOURCE
)
else:
self._attr_name = "Multi Room"
self._attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.SELECT_SOURCE
)
self._async_update_from_player()
async def async_added_to_hass(self) -> None:
"""Subscribe to receiver state updates."""
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
@callback
def _async_on_state_update(self, state: V2003ReceiverState | None) -> None:
if state is None:
self._attr_available = False
else:
self._attr_available = True
self._async_update_from_player()
self.async_write_ha_state()
@callback
def _async_update_from_player(self) -> None:
power = self._player.power
if power is None:
self._attr_state = None
else:
self._attr_state = MediaPlayerState.ON if power else MediaPlayerState.OFF
source = self._player.input_source
self._attr_source = (
INPUT_SOURCE_V2003_TO_HA.get(source) if source is not None else None
)
volume = self._player.volume
if volume is not None and volume != -math.inf:
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
else:
self._attr_volume_level = None
if isinstance(self._player, V2003MainPlayer):
self._attr_is_volume_muted = self._player.mute
async def async_turn_on(self) -> None:
"""Turn the receiver on."""
await self._player.power_on()
async def async_turn_off(self) -> None:
"""Turn the receiver off."""
await self._player.power_off()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1. Main zone only."""
db = round(volume * self._volume_range + self._volume_min)
await cast(V2003MainPlayer, self._player).set_volume(db)
async def async_volume_up(self) -> None:
"""Volume up."""
await self._player.volume_up()
async def async_volume_down(self) -> None:
"""Volume down."""
await self._player.volume_down()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute. Main zone only."""
player = cast(V2003MainPlayer, self._player)
if mute:
await player.mute_on()
else:
await player.mute_off()
async def async_select_source(self, source: str) -> None:
"""Select input source."""
v2003_source = next(
(
vs
for vs, ha_source in INPUT_SOURCE_V2003_TO_HA.items()
if ha_source == source
),
None,
)
if v2003_source is None:
raise HomeAssistantError("Invalid source")
await self._player.select_source(v2003_source)
@@ -1,64 +0,0 @@
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: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
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:
status: exempt
comment: "The integration does not create dynamic devices."
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:
status: exempt
comment: "The integration does not create devices that can become stale."
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -1,96 +0,0 @@
{
"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": {
"device": "[%key:common::config_flow::data::port%]",
"model": "Receiver model"
},
"data_description": {
"device": "Serial port path to connect to",
"model": "Determines the protocol used to communicate with the receiver"
}
}
}
},
"entity": {
"media_player": {
"receiver": {
"state_attributes": {
"source": {
"state": {
"am": "AM",
"aux1": "Aux 1",
"aux2": "Aux 2",
"aux3": "Aux 3",
"aux4": "Aux 4",
"aux5": "Aux 5",
"aux6": "Aux 6",
"aux7": "Aux 7",
"bd": "BD Player",
"bt": "Bluetooth",
"cd": "CD",
"cd_cdr": "CD/CDR",
"cdr": "CDR",
"dss": "DSS",
"dss_vcr2": "DSS/VCR 2",
"dvd": "DVD",
"dvdr": "DVDR",
"favorites": "Favorites",
"flickr": "Flickr",
"fm": "FM",
"game": "Game",
"hdradio": "HD Radio",
"iradio": "Internet Radio",
"lastfm": "Last.fm",
"ld": "LaserDisc",
"lw": "LW",
"m_xport": "M-XPort",
"mplay": "Media Player",
"mw": "MW",
"napster": "Napster",
"net": "Network",
"net_usb": "Network/USB",
"pandora": "Pandora",
"phono": "Phono",
"rhapsody": "Rhapsody",
"sat": "Sat",
"sat_cbl": "Satellite/Cable",
"server": "Server",
"sirius": "Sirius",
"siriusxm": "SiriusXM",
"spotify": "Spotify",
"tape": "Tape",
"tuner": "Tuner",
"tv": "TV Audio",
"usb_ipod": "USB/iPod",
"v_aux": "V. Aux",
"vcr": "VCR",
"vcr1": "VCR 1",
"vcr2_dvdr": "VCR 2/DVDR",
"xm": "XM"
}
}
}
}
}
},
"selector": {
"model": {
"options": {
"modern": "Modern",
"sr7002": "SR7002",
"sr8002": "SR8002",
"sr8300": "SR8300",
"sr9300": "SR9300"
}
}
}
}
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["mitsubishi-comfort==0.3.1"]
"requirements": ["mitsubishi-comfort==0.3.2"]
}
@@ -68,7 +68,7 @@ class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]):
async def _async_update_data(self) -> MonarchData:
"""Fetch data for all accounts."""
now = datetime.now()
now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
account_data, cashflow_summary = await asyncio.gather(
self.client.get_accounts_as_dict_with_id_key(),
+21 -23
View File
@@ -1124,7 +1124,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
):
errors["advanced_settings"] = "max_below_min_kelvin"
errors["other_settings"] = "max_below_min_kelvin"
return errors
@@ -1217,7 +1217,7 @@ def validate_text_platform_config(
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors["text_advanced_settings"] = "max_below_min"
errors["text_other_settings"] = "max_below_min"
return errors
@@ -1506,7 +1506,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OPTIONS: PlatformField(
selector=OPTIONS_SELECTOR,
@@ -1678,13 +1678,13 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OFF_DELAY: PlatformField(
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.BUTTON: {
@@ -3125,7 +3125,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_SHORT: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3133,7 +3133,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=2,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_LONG: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3141,7 +3141,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=10,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_TRANSITION: PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -3149,21 +3149,21 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_MAX_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MAX_KELVIN,
section="advanced_settings",
section="other_settings",
),
CONF_MIN_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MIN_KELVIN,
section="advanced_settings",
section="other_settings",
),
},
Platform.LOCK: {
@@ -3372,7 +3372,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.SIREN: {
@@ -3437,7 +3437,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
required=False,
validator=validate(cv.template),
error="invalid_template",
section="siren_advanced_settings",
section="siren_other_settings",
),
},
Platform.SWITCH: {
@@ -3516,26 +3516,26 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TEXT_SIZE_SELECTOR,
required=True,
default=0,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MAX: PlatformField(
selector=TEXT_SIZE_SELECTOR,
required=True,
default=255,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MODE: PlatformField(
selector=TEXT_MODE_SELECTOR,
required=True,
default=TextSelectorType.TEXT.value,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_PATTERN: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=validate(cv.is_regex),
error="invalid_regular_expression",
section="text_advanced_settings",
section="text_other_settings",
),
},
Platform.TIME: {
@@ -3798,10 +3798,10 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
MQTT_DEVICE_PLATFORM_FIELDS = {
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
@@ -4178,7 +4178,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
}
except AddonError:
# We do not have discovery information yet
@@ -4419,7 +4418,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: data.get(CONF_USERNAME),
CONF_PASSWORD: data.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
},
)
@@ -4686,8 +4684,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
new_device_data: dict[str, Any] = user_input.copy()
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
if "advanced_settings" in new_device_data:
new_device_data |= new_device_data.pop("advanced_settings")
if "other_settings" in new_device_data:
new_device_data |= new_device_data.pop("other_settings")
if not errors:
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
if self.source == SOURCE_RECONFIGURE:
+40 -40
View File
@@ -184,17 +184,6 @@
},
"description": "Enter the MQTT device details:",
"sections": {
"advanced_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Advanced device settings"
},
"mqtt_settings": {
"data": {
"message_expiry_interval": "Message Expiry Interval",
@@ -205,6 +194,17 @@
"qos": "The Quality of Service value the device's entities should use."
},
"name": "MQTT settings"
},
"other_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Other device settings"
}
},
"title": "Configure MQTT device details"
@@ -286,14 +286,14 @@
},
"description": "Please configure specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"other_settings": {
"data": {
"suggested_display_precision": "Suggested display precision"
},
"data_description": {
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)"
},
"name": "Advanced options"
"name": "Other settings"
}
},
"title": "Configure MQTT device \"{mqtt_device}\""
@@ -438,29 +438,6 @@
},
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Advanced settings"
},
"alarm_control_panel_payload_settings": {
"data": {
"payload_arm_away": "Payload \"arm away\"",
@@ -916,14 +893,37 @@
},
"name": "Lock payload settings"
},
"siren_advanced_settings": {
"other_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Other settings"
},
"siren_other_settings": {
"data": {
"command_off_template": "Command \"off\" template"
},
"data_description": {
"command_off_template": "The [template]({command_templating_url}) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)"
},
"name": "Advanced siren settings"
"name": "Other siren settings"
},
"target_humidity_settings": {
"data": {
@@ -985,7 +985,7 @@
},
"name": "Target temperature settings"
},
"text_advanced_settings": {
"text_other_settings": {
"data": {
"max": "Maximum length",
"min": "Minimum length",
@@ -998,7 +998,7 @@
"mode": "Mode of the text input",
"pattern": "A valid regex pattern"
},
"name": "Advanced text entity settings"
"name": "Other text entity settings"
},
"valve_payload_settings": {
"data": {
@@ -1,7 +1,7 @@
{
"domain": "myneomitis",
"name": "MyNeomitis",
"codeowners": ["@l-pr"],
"codeowners": ["@Epyes"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/myneomitis",
"integration_type": "hub",
@@ -70,5 +70,8 @@ class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]):
points[device_id] = point_info
return CoordinatorData(
systems=systems, devices=devices, points=points, time=datetime.now()
systems=systems,
devices=devices,
points=points,
time=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now
)
@@ -67,7 +67,7 @@ class NextBusDataUpdateCoordinator(
# But only if we have a reset time to unthrottle
and self.client.rate_limit_reset is not None
# Unless we are after the reset time
and datetime.now() < self.client.rate_limit_reset
and datetime.now() < self.client.rate_limit_reset # pylint: disable=home-assistant-enforce-naive-now
):
self.logger.debug(
"Rate limit threshold reached. Skipping updates for. Routes: %s",
@@ -58,7 +58,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
},
)
@@ -99,7 +99,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN):
data={
**user_input,
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
},
unique_id=user_input[CONF_EMAIL],
)
@@ -150,7 +150,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
+ REFRESH_TOKEN_EXPIRY_TIME.total_seconds()
)
try:
if datetime.now().timestamp() >= expiry_time:
if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now
await self.update_refresh_token()
else:
await self.api.authenticate_refresh(
@@ -194,7 +194,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
data = {
**self.config_entry.data,
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
@@ -154,7 +154,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity):
def update(self) -> None:
"""Get the latest data from NOAA Tides and Currents API."""
begin = datetime.now()
begin = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
end = begin + DEFAULT_PREDICTION_LENGTH
try:
df_predictions = self._station.get_data(
+1 -1
View File
@@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo
# in the UI, instead of ConfigEntryNotReady which would
# just keep retrying.
raise ConfigEntryError(err) from err
except (TimeoutError, httpx.ConnectError) as err:
except (TimeoutError, httpx.ConnectError, ConnectionError) as err:
raise ConfigEntryNotReady(err) from err
entry.runtime_data = client
+1 -1
View File
@@ -385,7 +385,7 @@ class DailyHistory:
def add_measurement(self, value, timestamp=None):
"""Add a new measurement for a certain day."""
day = (timestamp or datetime.now()).date()
day = (timestamp or datetime.now()).date() # pylint: disable=home-assistant-enforce-naive-now
if not isinstance(value, (int, float)):
return
if self._days is None:
@@ -26,6 +26,8 @@ from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
from .helpers import is_granted
PARALLEL_UPDATES = 1
NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power"
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.FAN]
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: RabbitAirConfigEntry) -> bool:
+2 -1
View File
@@ -25,6 +25,8 @@ MODELS = {
class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
"""Base class for Rabbit Air entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
@@ -32,7 +34,6 @@ class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]):
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_name = entry.title
self._attr_unique_id = entry.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_MAC])},
@@ -46,6 +46,7 @@ async def async_setup_entry(
class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity):
"""Fan control functions of the Rabbit Air air purifier."""
_attr_name = None
_attr_translation_key = "rabbitair"
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
@@ -12,6 +12,11 @@
}
}
}
},
"sensor": {
"air_quality": {
"default": "mdi:air-filter"
}
}
}
}
@@ -0,0 +1,60 @@
"""Support for Rabbit Air sensors."""
from rabbitair import Quality
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import RabbitAirConfigEntry, RabbitAirDataUpdateCoordinator
from .entity import RabbitAirBaseEntity
def _quality_value(quality: Quality | None) -> StateType:
"""Return the air quality state."""
return None if quality is None else quality.name.lower()
AIR_QUALITY_OPTIONS = [quality.name.lower() for quality in Quality]
AIR_QUALITY_DESCRIPTION = SensorEntityDescription(
key="air_quality",
translation_key="air_quality",
device_class=SensorDeviceClass.ENUM,
options=AIR_QUALITY_OPTIONS,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: RabbitAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Rabbit Air sensors."""
if entry.runtime_data.data.quality is not None:
async_add_entities([RabbitAirAirQualitySensor(entry.runtime_data, entry)])
class RabbitAirAirQualitySensor(RabbitAirBaseEntity, SensorEntity):
"""Rabbit Air air quality sensor."""
entity_description = AIR_QUALITY_DESCRIPTION
def __init__(
self,
coordinator: RabbitAirDataUpdateCoordinator,
entry: RabbitAirConfigEntry,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.unique_id}_{self.entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the air quality state."""
return _quality_value(self.coordinator.data.quality)
@@ -32,6 +32,18 @@
}
}
}
},
"sensor": {
"air_quality": {
"name": "Air quality",
"state": {
"high": "[%key:common::state::high%]",
"highest": "Highest",
"low": "[%key:common::state::low%]",
"lowest": "Lowest",
"medium": "[%key:common::state::medium%]"
}
}
}
}
}
@@ -184,7 +184,7 @@ class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
self._events = [
e
for e in self._events
if e.start >= datetime.now().date() - timedelta(days=30)
if e.start >= datetime.now().date() - timedelta(days=30) # pylint: disable=home-assistant-enforce-naive-now
]
_days = (end_date - start_date).days
await asyncio.gather(
@@ -11,15 +11,16 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from . import websocket_api
from .const import DATA_COMPONENT, DOMAIN
from .entity import (
RadioFrequencyTransmitterEntity,
RadioFrequencyTransmitterEntityDescription,
)
__all__ = [
"DATA_COMPONENT",
"DOMAIN",
"ModulationType",
"RadioFrequencyTransmitterEntity",
@@ -30,9 +31,6 @@ __all__ = [
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey(
DOMAIN
)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
@@ -46,6 +44,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
await component.async_setup(config)
websocket_api.async_setup(hass)
return True
@@ -2,4 +2,13 @@
from typing import Final
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.util.hass_dict import HassKey
from .entity import RadioFrequencyTransmitterEntity
DOMAIN: Final = "radio_frequency"
DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey(
DOMAIN
)
@@ -2,6 +2,7 @@
"domain": "radio_frequency",
"name": "Radio Frequency",
"codeowners": ["@home-assistant/core"],
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
@@ -1,4 +1,9 @@
{
"entity_component": {
"_": {
"name": "Radio frequency transmitter"
}
},
"exceptions": {
"component_not_loaded": {
"message": "Radio Frequency component not loaded"
@@ -0,0 +1,57 @@
"""The Radio Frequency websocket API."""
from typing import Any
from rf_protocols import ModulationType
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from .const import DATA_COMPONENT
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the radio frequency websocket API."""
websocket_api.async_register_command(hass, ws_list_transmitters)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "radio_frequency/list"})
@callback
def ws_list_transmitters(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the available radio frequency transmitters.
Each transmitter is described by its entity id, the device and config
entry it belongs to (when registered), the frequency ranges it can
operate on and the modulation types it supports.
"""
component = hass.data[DATA_COMPONENT]
ent_reg = er.async_get(hass)
transmitters: list[dict[str, Any]] = []
for entity in component.entities:
entry = ent_reg.async_get(entity.entity_id)
transmitters.append(
{
"entity_id": entity.entity_id,
"device_id": entry.device_id if entry else None,
"config_entry_id": entry.config_entry_id if entry else None,
"supported_frequency_ranges": [
[low, high] for low, high in entity.supported_frequency_ranges
],
"supported_modulations": [
modulation.value
for modulation in ModulationType
if entity.supports_modulation(modulation)
],
}
)
connection.send_result(msg["id"], {"transmitters": transmitters})
@@ -2,10 +2,13 @@
from homeassistant.components.hassio import HassioNotReadyError, get_os_info
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.hassio import is_hassio
PLATFORMS = [Platform.UPDATE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Raspberry Pi config entry."""
@@ -29,4 +32,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"rpi_power", context={"source": "onboarding"}
)
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)
@@ -4,7 +4,7 @@
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"config_flow": false,
"dependencies": ["hardware"],
"dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/raspberry_pi",
"integration_type": "hardware",
"quality_scale": "legacy"
@@ -0,0 +1,14 @@
{
"entity": {
"update": {
"rpi_firmware": {
"name": "Firmware"
}
}
},
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor is not ready"
}
}
}
@@ -0,0 +1,68 @@
"""Raspberry Pi firmware update entity."""
from aiohasupervisor import SupervisorError
from homeassistant.components.hassio import HassioNotReadyError, get_os_info
from homeassistant.components.homeassistant_hardware.update import (
RaspberryPiFirmwareUpdateEntity,
)
from homeassistant.components.homeassistant_hardware.util import (
BOARDS_WITH_RASPBERRYPI_FIRMWARE,
async_get_raspberry_pi_firmware_info,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .hardware import BOARD_NAMES, MODELS
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Raspberry Pi firmware update entity."""
try:
os_info = get_os_info(hass)
except HassioNotReadyError as err:
raise PlatformNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
board = os_info.get("board")
# Only RPi 4/5 expose the bootloader EEPROM. The Yellow's CM4/CM5 is handled
# by the homeassistant_yellow integration on its own device.
if board is None or board not in BOARDS_WITH_RASPBERRYPI_FIRMWARE:
return
try:
firmware = await async_get_raspberry_pi_firmware_info(hass)
except SupervisorError as err:
raise PlatformNotReady(
f"Error fetching Raspberry Pi firmware info: {err}"
) from err
# Skip when the update is blocked on this boot device (e.g. CM4 without
# flashrom updates enabled). The blocked state is surfaced as a repair.
if firmware is None or firmware.update_blocked:
return
device_info = DeviceInfo(
identifiers={(DOMAIN, board)},
manufacturer="Raspberry Pi",
model=MODELS.get(board),
name=BOARD_NAMES.get(board, "Raspberry Pi"),
)
async_add_entities(
[
RaspberryPiFirmwareUpdateEntity(
firmware, device_info, unique_id=f"{board}_rpi_firmware", board=board
)
]
)
@@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics(
payload: dict[str, Any] = {
"now": dt_util.now().isoformat(),
"timezone": str(dt_util.get_default_time_zone()),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo),
"system_timezone": str(datetime.datetime.now().astimezone().tzinfo), # pylint: disable=home-assistant-enforce-naive-now
}
payload["ics"] = "\n".join(redact_ics(coordinator.ics))
return payload
+1 -1
View File
@@ -31,7 +31,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
time_to_send = time
if time is None:
time_to_send = datetime.now()
time_to_send = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
await local_data.system.set_time(time_to_send)
+18 -2
View File
@@ -1,18 +1,34 @@
"""The Raspberry Pi Power Supply Checker integration."""
from rpi_bad_power import UnderVoltage, new_under_voltage
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .const import DOMAIN
PLATFORMS = [Platform.BINARY_SENSOR]
type RpiPowerConfigEntry = ConfigEntry[UnderVoltage]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: RpiPowerConfigEntry) -> bool:
"""Set up Raspberry Pi Power Supply Checker from a config entry."""
if (client := await hass.async_add_executor_job(new_under_voltage)) is None:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="under_voltage_not_supported",
)
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: RpiPowerConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -5,17 +5,20 @@ Minimal Kernel needed is 4.14+
import logging
from rpi_bad_power import UnderVoltage, new_under_voltage
from rpi_bad_power import UnderVoltage
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RpiPowerConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DESCRIPTION_NORMALIZED = "Voltage normalized. Everything is working as intended."
@@ -27,11 +30,11 @@ DESCRIPTION_UNDER_VOLTAGE = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: RpiPowerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rpi_power binary sensor."""
under_voltage = await hass.async_add_executor_job(new_under_voltage)
under_voltage = config_entry.runtime_data
async_add_entities([RaspberryChargerBinarySensor(under_voltage)], True)
@@ -40,14 +43,20 @@ class RaspberryChargerBinarySensor(BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:raspberry-pi"
_attr_name = "RPi Power status"
_attr_translation_key = "rpi_power"
_attr_has_entity_name = True
_attr_unique_id = "rpi_power" # only one sensor possible
def __init__(self, under_voltage: UnderVoltage) -> None:
"""Initialize the binary sensor."""
self._under_voltage = under_voltage
self._attr_device_info = DeviceInfo(
manufacturer="Raspberry Pi",
identifiers={(DOMAIN, "rpi_power")},
name="Raspberry Pi",
)
def update(self) -> None:
"""Update the state."""
value = self._under_voltage.get()
@@ -0,0 +1,9 @@
{
"entity": {
"binary_sensor": {
"rpi_power": {
"default": "mdi:raspberry-pi"
}
}
}
}
@@ -9,5 +9,17 @@
}
}
},
"entity": {
"binary_sensor": {
"rpi_power": {
"name": "Power status"
}
}
},
"exceptions": {
"under_voltage_not_supported": {
"message": "Under-voltage monitoring is not supported on this device."
}
},
"title": "Raspberry Pi Power Supply Checker"
}
@@ -46,7 +46,7 @@ class SensoterraConfigFlow(ConfigFlow, domain=DOMAIN):
api = CustomerApi(user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
# We need a unique tag per HA instance
uuid = self.hass.data["core.uuid"]
expiration = datetime.now() + timedelta(TOKEN_EXPIRATION_DAYS)
expiration = datetime.now() + timedelta(TOKEN_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-naive-now
try:
token: str = await api.get_token(
@@ -70,7 +70,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]):
try:
if (
self.ayla_api.token_expiring_soon
or datetime.now()
or datetime.now() # pylint: disable=home-assistant-enforce-naive-now
> self.ayla_api.auth_expiration - timedelta(seconds=600)
):
await self.ayla_api.async_refresh_auth()
+2 -2
View File
@@ -17,7 +17,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import discovery
from homeassistant.util.ssl import create_client_context
@@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool
try:
await hass.async_add_executor_job(lambda: client.connect().quit())
except SMTPAuthenticationError as e:
raise ConfigEntryError(
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
@@ -1,5 +1,6 @@
"""Config flow for the SMTP integration."""
from collections.abc import Mapping
import logging
from smtplib import SMTP, SMTP_SSL, SMTPAuthenticationError
import socket
@@ -95,6 +96,22 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_VERIFY_SSL, default=True): cv.boolean,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT,
autocomplete="username",
),
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
),
),
}
)
OPTIONS_SCHEMA = vol.Schema(
{
@@ -201,6 +218,39 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
if user_input is not None:
errors = await self.hass.async_add_executor_job(
validate_input, {**entry.data, **user_input}
)
if not errors:
return self.async_update_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA,
suggested_values=user_input
or {CONF_USERNAME: entry.data.get(CONF_USERNAME)},
),
errors=errors,
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
+11 -6
View File
@@ -1,5 +1,6 @@
"""Mail (SMTP) notification service."""
from contextlib import suppress
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import email.utils
@@ -41,7 +42,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -208,7 +209,7 @@ class MailNotifyEntity(NotifyEntity):
try:
client = self._client.connect()
except SMTPAuthenticationError as e:
raise HomeAssistantError(
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
@@ -236,7 +237,8 @@ class MailNotifyEntity(NotifyEntity):
translation_key="send_mail_connection_error",
) from e
finally:
client.quit()
with suppress(SMTPException):
client.quit()
class MailNotificationService(SmtpClient, BaseNotificationService):
@@ -316,10 +318,13 @@ class MailNotificationService(SmtpClient, BaseNotificationService):
_LOGGER.warning(
"SMTPServerDisconnected sending mail: retrying connection"
)
mail.quit()
with suppress(SMTPException):
mail.quit()
mail = self.connect()
except SMTPException:
_LOGGER.warning("SMTPException sending mail: retrying connection")
mail.quit()
with suppress(SMTPException):
mail.quit()
mail = self.connect()
mail.quit()
with suppress(SMTPException):
mail.quit()
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
@@ -11,6 +12,17 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::smtp::config::step::user::data_description::password%]",
"username": "[%key:component::smtp::config::step::user::data_description::username%]"
},
"title": "Re-authenticate SMTP"
},
"reconfigure": {
"data": {
"encryption": "[%key:component::smtp::config::step::user::data::encryption%]",
@@ -160,7 +160,7 @@ async def async_setup_entry(
model=model,
manufacturer=manufacturer,
model_id=model_id,
hw_version=player.firmware,
hw_version=str(player.firmware) if player.firmware is not None else None,
sw_version=sw_version,
via_device=(DOMAIN, coordinator.server_uuid),
)
+1 -1
View File
@@ -47,7 +47,7 @@ class StarlineAccount:
def _check_slnet_token(self, interval: int) -> None:
"""Check SLNet token expiration and update if needed."""
now = datetime.now().timestamp()
now = datetime.now().timestamp() # pylint: disable=home-assistant-enforce-naive-now
slnet_token_expires = self._config_entry.data[DATA_EXPIRES]
if now + interval > slnet_token_expires:

Some files were not shown because too many files have changed in this diff Show More