Add diagnostics platform to Hot Spring (#179778)

Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com>
This commit is contained in:
Christophe Gagnier
2026-08-22 07:54:19 +02:00
committed by GitHub
co-authored by Moustachauve
parent 49259e3426
commit 839bb52aba
6 changed files with 523 additions and 8 deletions
@@ -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),
},
}
@@ -49,7 +49,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
+91 -6
View File
@@ -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
@@ -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': "<enum 'SpaFailureState'>",
'repr': "<SpaFailureState.OK: 'Spa_Ok'>",
}),
}),
'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': "<enum 'HeatingMode'>",
'repr': "<HeatingMode.HEAT_SAVER: 'heatSaver'>",
}),
'heatpump_installed': False,
'is_on': True,
'set_temperature': 104.0,
'temperature_unit': dict({
'__type': "<enum 'TemperatureUnit'>",
'repr': "<TemperatureUnit.FAHRENHEIT: 'DegF'>",
}),
}),
'info': dict({
'brand': dict({
'__type': "<enum 'SpaBrand'>",
'repr': "<SpaBrand.HOTSPRING: 'HotSpring'>",
}),
'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': "<enum 'JetSpeed'>",
'repr': "<JetSpeed.OFF: 'off'>",
}),
}),
dict({
'is_enabled': True,
'jet_id': 2,
'on_seconds': 0,
'speed': dict({
'__type': "<enum 'JetSpeed'>",
'repr': "<JetSpeed.OFF: 'off'>",
}),
}),
]),
'light_zones': list([
dict({
'color': dict({
'__type': "<enum 'LightColor'>",
'repr': "<LightColor.OFF: 'WHEEL_OFF'>",
}),
'intensity': 0,
'is_enabled': True,
'is_on': False,
'light_wheel': dict({
'__type': "<enum 'LightWheelMode'>",
'repr': "<LightWheelMode.OFF: 'off'>",
}),
'loop_speed': 0,
'zone_id': 1,
}),
]),
'logo_light': dict({
'brightness': dict({
'__type': "<enum 'BrightnessLevel'>",
'repr': "<BrightnessLevel.LEVEL_1: 'brightness_level_1'>",
}),
}),
'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': "<enum 'SpaFailureState'>",
'repr': "<SpaFailureState.OK: 'Spa_Ok'>",
}),
}),
'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': "<enum 'HeatingMode'>",
'repr': "<HeatingMode.HEAT_SAVER: 'heatSaver'>",
}),
'heatpump_installed': False,
'is_on': True,
'set_temperature': 104.0,
'temperature_unit': dict({
'__type': "<enum 'TemperatureUnit'>",
'repr': "<TemperatureUnit.FAHRENHEIT: 'DegF'>",
}),
}),
'info': dict({
'brand': dict({
'__type': "<enum 'SpaBrand'>",
'repr': "<SpaBrand.HOTSPRING: 'HotSpring'>",
}),
'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': "<enum 'JetSpeed'>",
'repr': "<JetSpeed.OFF: 'off'>",
}),
}),
dict({
'is_enabled': True,
'jet_id': 2,
'on_seconds': 0,
'speed': dict({
'__type': "<enum 'JetSpeed'>",
'repr': "<JetSpeed.OFF: 'off'>",
}),
}),
]),
'light_zones': list([
dict({
'color': dict({
'__type': "<enum 'LightColor'>",
'repr': "<LightColor.OFF: 'WHEEL_OFF'>",
}),
'intensity': 0,
'is_enabled': True,
'is_on': False,
'light_wheel': dict({
'__type': "<enum 'LightWheelMode'>",
'repr': "<LightWheelMode.OFF: 'off'>",
}),
'loop_speed': 0,
'zone_id': 1,
}),
]),
'logo_light': dict({
'brightness': dict({
'__type': "<enum 'BrightnessLevel'>",
'repr': "<BrightnessLevel.LEVEL_1: 'brightness_level_1'>",
}),
}),
'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**',
}),
})
# ---
@@ -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
)
+2 -1
View File
@@ -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(