diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 9e6864cf1c69ad..87112199b0fd94 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", "loggers": ["libpyfoscamcgi"], - "requirements": ["libpyfoscamcgi==0.0.6"] + "requirements": ["libpyfoscamcgi==0.0.7"] } diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index dde50da1af3d72..5ea0d217f141f5 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.78", "babel==2.15.0"] + "requirements": ["holidays==0.79", "babel==2.15.0"] } diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index d7b21ad3a0cdc2..99bb9801183a7a 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==2.0.1"] + "quality_scale": "bronze", + "requirements": ["Mastodon.py==2.1.0"] } diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index c5a928bac59be6..ff3d4ad3db0bb4 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -6,10 +6,7 @@ rules: common-modules: done config-flow-test-coverage: done config-flow: done - dependency-transparency: - status: todo - comment: | - Mastodon.py does not have CI build/publish. + dependency-transparency: done docs-actions: done docs-high-level-description: done docs-installation-instructions: done diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 1f883435dee18e..5e14328eb7cb43 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -156,6 +156,7 @@ async def async_initialise_vehicle( name=vehicle.device_info[ATTR_NAME], model=vehicle.device_info[ATTR_MODEL], model_id=vehicle.device_info[ATTR_MODEL_ID], + sw_version=None, # cleanup from PR #125399 ) self._vehicles[vehicle_link.vin] = vehicle diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index d2309702728653..0e336632b2ebf0 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.78"] + "requirements": ["holidays==0.79"] } diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 2efb8c8e67c649..23b906a9d16078 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,15 +4,15 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Any +from typing import Any, cast import voluptuous as vol -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, RssiError from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, RssiErrorReceived from zwave_js_server.model.controller import Controller from zwave_js_server.model.controller.statistics import ControllerStatistics from zwave_js_server.model.driver import Driver @@ -1049,7 +1049,7 @@ def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, - statistics_src: ZwaveNode | Controller, + statistics_src: Controller | ZwaveNode, description: ZWaveJSStatisticsSensorEntityDescription, ) -> None: """Initialize a Z-Wave statistics entity.""" @@ -1080,13 +1080,31 @@ async def async_poll_value(self, _: bool) -> None: ) @callback - def statistics_updated(self, event_data: dict) -> None: + def _statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" - self._attr_native_value = self.entity_description.convert( - event_data["statistics_updated"], self.entity_description.key + statistics = cast( + ControllerStatistics | NodeStatistics, event_data["statistics_updated"] ) + self._set_statistics(statistics) self.async_write_ha_state() + @callback + def _set_statistics( + self, statistics: ControllerStatistics | NodeStatistics + ) -> None: + """Set updated statistics.""" + try: + self._attr_native_value = self.entity_description.convert( + statistics, self.entity_description.key + ) + except RssiErrorReceived as err: + if err.error is RssiError.NOT_AVAILABLE: + self._attr_available = False + return + self._attr_native_value = None + # Reset available state. + self._attr_available = True + async def async_added_to_hass(self) -> None: """Call when entity is added.""" self.async_on_remove( @@ -1104,10 +1122,8 @@ async def async_added_to_hass(self) -> None: ) ) self.async_on_remove( - self.statistics_src.on("statistics updated", self.statistics_updated) + self.statistics_src.on("statistics updated", self._statistics_updated) ) # Set initial state - self._attr_native_value = self.entity_description.convert( - self.statistics_src.statistics, self.entity_description.key - ) + self._set_statistics(self.statistics_src.statistics) diff --git a/requirements_all.txt b/requirements_all.txt index cba0201941be12..ffd754f994bd69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.0 # homeassistant.components.playstation_network PSNAWP==3.0.0 @@ -1171,7 +1171,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.78 +holidays==0.79 # homeassistant.components.frontend home-assistant-frontend==20250811.0 @@ -1343,7 +1343,7 @@ lektricowifi==0.1 letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.vivotek libpyvivotek==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9fd9d9e5f53bb..a9af4dbb605edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==2.0.1 +Mastodon.py==2.1.0 # homeassistant.components.playstation_network PSNAWP==3.0.0 @@ -1020,7 +1020,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.78 +holidays==0.79 # homeassistant.components.frontend home-assistant-frontend==20250811.0 @@ -1162,7 +1162,7 @@ lektricowifi==0.1 letpot==0.6.1 # homeassistant.components.foscam -libpyfoscamcgi==0.0.6 +libpyfoscamcgi==0.0.7 # homeassistant.components.mikrotik librouteros==3.2.0 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 97d510ce4caeec..6501aee0733fb1 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1651,7 +1651,6 @@ class Rule: "manual", "manual_mqtt", "map", - "mastodon", "marytts", "matrix", "matter", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index d8aa383cfec6b0..a2d305f76efdab 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -11,7 +11,7 @@ import re import subprocess import sys -from typing import Any +from typing import Any, TypedDict from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from tqdm import tqdm @@ -295,6 +295,9 @@ }, } +FORBIDDEN_FILE_NAMES: set[str] = { + "py.typed", # should be placed inside a package +} FORBIDDEN_PACKAGE_NAMES: set[str] = { "doc", "docs", @@ -364,7 +367,15 @@ }, } -_packages_checked_files_cache: dict[str, set[str]] = {} + +class _PackageFilesCheckResult(TypedDict): + """Data structure to store results of package files check.""" + + top_level: set[str] + file_names: set[str] + + +_packages_checked_files_cache: dict[str, _PackageFilesCheckResult] = {} def validate(integrations: dict[str, Integration], config: Config) -> None: @@ -733,24 +744,33 @@ def check_dependency_files( pkg: str, package_exceptions: Collection[str], ) -> bool: - """Check dependency files for forbidden package names.""" + """Check dependency files for forbidden files and forbidden package names.""" if (results := _packages_checked_files_cache.get(pkg)) is None: top_level: set[str] = set() + file_names: set[str] = set() for file in files(pkg) or (): - top = file.parts[0].lower() - if top.endswith((".dist-info", ".py")): - continue - top_level.add(top) - results = FORBIDDEN_PACKAGE_NAMES & top_level + if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")): + top_level.add(top) + if (name := str(file)).lower() in FORBIDDEN_FILE_NAMES: + file_names.add(name) + results = _PackageFilesCheckResult( + top_level=FORBIDDEN_PACKAGE_NAMES & top_level, + file_names=file_names, + ) _packages_checked_files_cache[pkg] = results - if not results: + if not (results["top_level"] or results["file_names"]): return True - for dir_name in results: + for dir_name in results["top_level"]: integration.add_warning_or_error( pkg in package_exceptions, "requirements", - f"Package {pkg} has a forbidden top level directory {dir_name} in {package}", + f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}", + ) + for file_name in results["file_names"]: + integration.add_error( + "requirements", + f"Package {pkg} has a forbidden file '{file_name}' in {package}", ) return False diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 9198410f066744..ec9da1836bc637 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -45,6 +45,7 @@ 'limited': None, 'locked': False, 'memorial': None, + 'moved': None, 'moved_to_account': None, 'mute_expires_at': None, 'noindex': False, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c7b41449d43df2..e287c9e988fcf0 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1045,6 +1045,183 @@ async def test_last_seen_statistics_sensors( assert state.state == "2024-01-01T12:00:00+00:00" +async def test_rssi_sensor_error( + hass: HomeAssistant, + zp3111: Node, + integration: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test rssi sensor error.""" + entity_id = "sensor.4_in_1_sensor_signal_strength" + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, # baseline + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "7" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 125, # no signal detected + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 127, # not available + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unavailable" + + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 126, # receiver saturated + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "lastSeen": "2024-01-01T00:00:00+0000", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + ENERGY_PRODUCTION_ENTITY_MAP = { "energy_production_power": { "state": 1.23, diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index 944b06d3c9083d..329357bfca4ffb 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -170,8 +170,8 @@ def test_dependency_version_range_prepare_update( @pytest.mark.usefixtures("mock_forbidden_package_names") -def test_check_dependency_files(integration: Integration) -> None: - """Test dependency files check for forbidden package names is working correctly.""" +def test_check_dependency_package_names(integration: Integration) -> None: + """Test dependency package names check for forbidden package names is working correctly.""" package = "homeassistant" pkg = "my_package" @@ -190,17 +190,15 @@ def test_check_dependency_files(integration: Integration) -> None: ): assert not _packages_checked_files_cache assert check_dependency_files(integration, package, pkg, ()) is False - assert _packages_checked_files_cache[pkg] == {"tests", "test"} + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests", "test"} assert len(integration.errors) == 2 assert ( - f"Package {pkg} has a forbidden top level directory tests in {package}" - in x.error - for x in integration.errors + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.errors] ) assert ( - f"Package {pkg} has a forbidden top level directory test in {package}" - in x.error - for x in integration.errors + f"Package {pkg} has a forbidden top level directory 'test' in {package}" + in [x.error for x in integration.errors] ) integration.errors.clear() @@ -227,13 +225,12 @@ def test_check_dependency_files(integration: Integration) -> None: check_dependency_files(integration, package, pkg, package_exceptions={pkg}) is False ) - assert _packages_checked_files_cache[pkg] == {"tests"} + assert _packages_checked_files_cache[pkg]["top_level"] == {"tests"} assert len(integration.errors) == 0 assert len(integration.warnings) == 1 assert ( - f"Package {pkg} has a forbidden top level directory tests in {package}" - in x.error - for x in integration.warnings + f"Package {pkg} has a forbidden top level directory 'tests' in {package}" + in [x.error for x in integration.warnings] ) integration.warnings.clear() @@ -260,7 +257,62 @@ def test_check_dependency_files(integration: Integration) -> None: ): assert not _packages_checked_files_cache assert check_dependency_files(integration, package, pkg, ()) is True - assert _packages_checked_files_cache[pkg] == set() + assert _packages_checked_files_cache[pkg]["top_level"] == set() + assert len(integration.errors) == 0 + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert mock_files.call_count == 1 + assert len(integration.errors) == 0 + + +def test_check_dependency_file_names(integration: Integration) -> None: + """Test dependency file name check for forbidden files is working correctly.""" + package = "homeassistant" + pkg = "my_package" + + # Forbidden file: 'py.typed' at top level + pkg_files = [ + PackagePath("py.typed"), + PackagePath("my_package.py"), + PackagePath("my_package-1.0.0.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert _packages_checked_files_cache[pkg]["file_names"] == {"py.typed"} + assert len(integration.errors) == 1 + assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [ + x.error for x in integration.errors + ] + integration.errors.clear() + + # Repeated call should use cache + assert check_dependency_files(integration, package, pkg, ()) is False + assert mock_files.call_count == 1 + assert len(integration.errors) == 1 + integration.errors.clear() + + # All good + pkg_files = [ + PackagePath("my_package/__init__.py"), + PackagePath("my_package/py.typed"), + PackagePath("my_package.dist-info/METADATA"), + ] + with ( + patch( + "script.hassfest.requirements.files", return_value=pkg_files + ) as mock_files, + patch.dict(_packages_checked_files_cache, {}, clear=True), + ): + assert not _packages_checked_files_cache + assert check_dependency_files(integration, package, pkg, ()) is True + assert _packages_checked_files_cache[pkg]["file_names"] == set() assert len(integration.errors) == 0 # Repeated call should use cache