diff --git a/homeassistant/components/hotspring/diagnostics.py b/homeassistant/components/hotspring/diagnostics.py new file mode 100644 index 000000000000..889fe04fded9 --- /dev/null +++ b/homeassistant/components/hotspring/diagnostics.py @@ -0,0 +1,58 @@ +"""Diagnostics support for Hot Spring.""" + +from dataclasses import asdict +import re +from typing import Any + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .coordinator import HotSpringConfigEntry + +TO_REDACT = { + CONF_HOST, +} + + +def _redact_mac(value: str, patterns: list[str]) -> str: + """Redact MAC address patterns from a string.""" + for pattern in patterns: + value = re.sub(re.escape(pattern), REDACTED, value, flags=re.IGNORECASE) + return value + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: HotSpringConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + spa = coordinator.data + + info = asdict(spa.info) + if mac_address := spa.info.mac_address: + clean_mac = mac_address.replace(":", "") + patterns = [mac_address, clean_mac, clean_mac[-6:]] + info["root_topic"] = _redact_mac(info["root_topic"], patterns) + info["hostname"] = _redact_mac(info["hostname"], patterns) + + return { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "info": info, + "heater": asdict(spa.heater), + "jets": [asdict(jet) for jet in spa.jets], + "blower": asdict(spa.blower), + "light_zones": [asdict(zone) for zone in spa.light_zones], + "logo_light": asdict(spa.logo_light), + "clean_cycle": asdict(spa.clean_cycle), + "spa_lock": asdict(spa.spa_lock), + "water_care": asdict(spa.water_care), + "freshwater_iq": asdict(spa.freshwater_iq), + "energy_savings": [asdict(schedule) for schedule in spa.energy_savings], + "versions": asdict(spa.versions), + "connection_status": asdict(spa.connection_status), + "diagnostics": asdict(spa.diagnostics), + "test_metrics": asdict(spa.test_metrics), + }, + } diff --git a/homeassistant/components/hotspring/quality_scale.yaml b/homeassistant/components/hotspring/quality_scale.yaml index 171e6cfae2f1..fba94802633d 100644 --- a/homeassistant/components/hotspring/quality_scale.yaml +++ b/homeassistant/components/hotspring/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: done diff --git a/tests/components/hotspring/conftest.py b/tests/components/hotspring/conftest.py index a9f2d96c96ac..ccfb454f6a7d 100644 --- a/tests/components/hotspring/conftest.py +++ b/tests/components/hotspring/conftest.py @@ -3,7 +3,32 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from hotspring import Heater, Spa, SpaBrand, SpaInfo, Versions, WaterCare +from hotspring import ( + Blower, + BrightnessLevel, + CleanCycle, + ConnectionStatus, + Diagnostics, + EnergySaving, + FreshWaterIQ, + Heater, + HeatingMode, + Jet, + JetSpeed, + LightColor, + LightWheelMode, + LightZone, + LogoLight, + Spa, + SpaBrand, + SpaFailureState, + SpaInfo, + SpaLock, + TemperatureUnit, + Versions, + WaterCare, +) +from hotspring.models import SpaTestData import pytest from homeassistant.components.hotspring.const import DOMAIN @@ -61,11 +86,17 @@ def device_fixture() -> Spa: dosing="", logolight="", ) - heater = MagicMock(spec=Heater) - heater.current_temperature = 102.0 - heater.set_temperature = 104.0 - heater.is_on = True - spa.heater = heater + spa.heater = Heater( + is_on=True, + heater_lock=False, + heatpump_installed=False, + heating_mode=HeatingMode.HEAT_SAVER, + heater_current=5.0, + heater_on_seconds=3600, + set_temperature=104.0, + current_temperature=102.0, + temperature_unit=TemperatureUnit.FAHRENHEIT, + ) spa.water_care = WaterCare( cartridge_installed=True, ten_day_timer=0, @@ -76,6 +107,60 @@ def device_fixture() -> Spa: boost_active=False, salt_value=12, ) + spa.jets = [ + Jet(jet_id=1, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + Jet(jet_id=2, speed=JetSpeed.OFF, is_enabled=True, on_seconds=0), + ] + spa.blower = Blower(is_enabled=False, is_on=False) + spa.light_zones = [ + LightZone( + zone_id=1, + is_enabled=True, + is_on=False, + color=LightColor.OFF, + light_wheel=LightWheelMode.OFF, + intensity=0, + loop_speed=0, + ), + ] + spa.logo_light = LogoLight(brightness=BrightnessLevel.LEVEL_1) + spa.clean_cycle = CleanCycle(is_enabled=False, vanishing_act=False) + spa.spa_lock = SpaLock(is_locked=False) + spa.freshwater_iq = FreshWaterIQ( + conductivity=0, + orp=0, + chlorine=0.0, + ph=7.2, + sensor_life_percentage=100.0, + installed=False, + ) + spa.energy_savings = [ + EnergySaving(schedule_id=1, mode=0, start_hour=0, start_minute=0, duration=0), + ] + spa.connection_status = ConnectionStatus(spa_connected=True) + spa.diagnostics = Diagnostics( + spa_failure_state=SpaFailureState.OK, + heater_error="0", + power_frequency="60", + pressure_switch_status="0", + l1_n_volts=120.0, + l2_n_volts=120.0, + heater_volts=240.0, + jet3_volts=0.0, + jet1_jet2_blower_power="0", + small_loads_power="0", + heater_power="0", + jet3_power="0", + ) + spa.test_metrics = SpaTestData( + heater_test_status="off", + temp_offset=0.0, + vsense_cal=0.0, + jet1_jet2_blower_current=0.0, + small_loads_current=0.0, + heater_current=0.0, + jet3_current=0.0, + ) return spa diff --git a/tests/components/hotspring/snapshots/test_diagnostics.ambr b/tests/components/hotspring/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..7cbf41189e48 --- /dev/null +++ b/tests/components/hotspring/snapshots/test_diagnostics.ambr @@ -0,0 +1,329 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'ConnectedSpa_**REDACTED**', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'mySpa**REDACTED**', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- +# name: test_diagnostics_custom_topic + dict({ + 'data': dict({ + 'blower': dict({ + 'is_enabled': False, + 'is_on': False, + }), + 'clean_cycle': dict({ + 'is_enabled': False, + 'vanishing_act': False, + }), + 'connection_status': dict({ + 'spa_connected': True, + }), + 'diagnostics': dict({ + 'heater_error': '0', + 'heater_power': '0', + 'heater_volts': 240.0, + 'jet1_jet2_blower_power': '0', + 'jet3_power': '0', + 'jet3_volts': 0.0, + 'l1_n_volts': 120.0, + 'l2_n_volts': 120.0, + 'power_frequency': '60', + 'pressure_switch_status': '0', + 'small_loads_power': '0', + 'spa_failure_state': dict({ + '__type': "", + 'repr': "", + }), + }), + 'energy_savings': list([ + dict({ + 'duration': 0, + 'mode': 0, + 'schedule_id': 1, + 'start_hour': 0, + 'start_minute': 0, + }), + ]), + 'freshwater_iq': dict({ + 'chlorine': 0.0, + 'conductivity': 0, + 'installed': False, + 'orp': 0, + 'ph': 7.2, + 'sensor_life_percentage': 100.0, + }), + 'heater': dict({ + 'current_temperature': 102.0, + 'heater_current': 5.0, + 'heater_lock': False, + 'heater_on_seconds': 3600, + 'heating_mode': dict({ + '__type': "", + 'repr': "", + }), + 'heatpump_installed': False, + 'is_on': True, + 'set_temperature': 104.0, + 'temperature_unit': dict({ + '__type': "", + 'repr': "", + }), + }), + 'info': dict({ + 'brand': dict({ + '__type': "", + 'repr': "", + }), + 'brand_id': '1', + 'brand_name': 'Hot Spring', + 'collection': 'Highlife', + 'collection_id': '1', + 'hostname': 'customHost', + 'model_id': '1', + 'model_name': 'Relay', + 'root_topic': 'customTopic', + 'sna_ready': True, + 'volume': 335, + }), + 'jets': list([ + dict({ + 'is_enabled': True, + 'jet_id': 1, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + dict({ + 'is_enabled': True, + 'jet_id': 2, + 'on_seconds': 0, + 'speed': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'light_zones': list([ + dict({ + 'color': dict({ + '__type': "", + 'repr': "", + }), + 'intensity': 0, + 'is_enabled': True, + 'is_on': False, + 'light_wheel': dict({ + '__type': "", + 'repr': "", + }), + 'loop_speed': 0, + 'zone_id': 1, + }), + ]), + 'logo_light': dict({ + 'brightness': dict({ + '__type': "", + 'repr': "", + }), + }), + 'spa_lock': dict({ + 'is_locked': False, + }), + 'test_metrics': dict({ + 'heater_current': 0.0, + 'heater_test_status': 'off', + 'jet1_jet2_blower_current': 0.0, + 'jet3_current': 0.0, + 'small_loads_current': 0.0, + 'temp_offset': 0.0, + 'vsense_cal': 0.0, + }), + 'versions': dict({ + 'amp': '', + 'btxr': '', + 'control_box': '3.0.0', + 'control_panel': '2.0.0', + 'cool_zone': '', + 'dosing': '', + 'fwiq': '', + 'fwss': '1.0.0', + 'logolight': '', + 'wifi_dongle': '1.0.0', + }), + 'water_care': dict({ + 'ace_mode': 'inactive', + 'boost_active': False, + 'cartridge_installed': True, + 'level': 2, + 'one_twenty_day_timer': 117, + 'salt_value': 12, + 'system_enabled': True, + 'ten_day_timer': 0, + }), + }), + 'entry': dict({ + 'host': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/hotspring/test_diagnostics.py b/tests/components/hotspring/test_diagnostics.py new file mode 100644 index 000000000000..ea2742869a83 --- /dev/null +++ b/tests/components/hotspring/test_diagnostics.py @@ -0,0 +1,42 @@ +"""Tests for the diagnostics data provided by the Hot Spring integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) + + +async def test_diagnostics_custom_topic( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics to ensure root_topic without MAC address is not redacted. + + This preserves diagnosing capabilities in case a spa model acts differently than expected. + """ + coordinator = init_integration.runtime_data + coordinator.data.info.root_topic = "customTopic" + coordinator.data.info.hostname = "customHost" + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/hotspring/test_init.py b/tests/components/hotspring/test_init.py index e87c42851cf7..99230fc06a33 100644 --- a/tests/components/hotspring/test_init.py +++ b/tests/components/hotspring/test_init.py @@ -1,5 +1,6 @@ """Tests for the Hot Spring integration.""" +from typing import cast from unittest.mock import MagicMock from hotspring import HotSpringConnectionError, HotSpringError, Spa @@ -22,7 +23,7 @@ async def test_async_setup_entry( assert await hass.config_entries.async_unload(init_integration.entry_id) await hass.async_block_till_done() - assert init_integration.state is ConfigEntryState.NOT_LOADED + assert cast(ConfigEntryState, init_integration.state) is ConfigEntryState.NOT_LOADED async def test_device_info(