Skip to content

Commit a2243d4

Browse files
committed
Refactor Rinnai integration for improved error handling, maintenance timers, and service definitions
1 parent 71089b3 commit a2243d4

File tree

8 files changed

+338
-157
lines changed

8 files changed

+338
-157
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Validate Local Polling Integration (dev-local)
2+
3+
on:
4+
push:
5+
branches:
6+
- dev-local
7+
pull_request:
8+
branches:
9+
- dev-local
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
validate-hacs:
17+
name: HACS Validation
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: HACS validation
24+
uses: hacs/action@main
25+
with:
26+
category: integration
27+
28+
validate-hassfest:
29+
name: Hassfest Validation
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Checkout repository
33+
uses: actions/checkout@v4
34+
35+
- name: Rename directory to match domain
36+
run: |
37+
mv custom_components/rinnaicontrolr-ha custom_components/rinnai
38+
39+
- name: Set up Python
40+
uses: actions/setup-python@v5
41+
with:
42+
python-version: "3.12"
43+
cache: "pip"
44+
45+
- name: Install Home Assistant
46+
run: |
47+
pip install homeassistant
48+
49+
- name: Run Hassfest
50+
run: |
51+
python -m script.hassfest --integration-path custom_components/rinnai

custom_components/rinnaicontrolr-ha/__init__.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from homeassistant.core import HomeAssistant
1212
from homeassistant.exceptions import ConfigEntryNotReady
1313
from homeassistant.helpers.aiohttp_client import async_get_clientsession
14-
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
1514

1615
from .const import (
1716
CONF_MAINT_REFRESH_INTERVAL,
@@ -30,36 +29,85 @@
3029
PLATFORMS = ["water_heater","binary_sensor", "sensor"]
3130

3231
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
33-
"""Set up Rinnai from config entry"""
32+
"""Set up Rinnai from config entry."""
3433
session = async_get_clientsession(hass)
3534
hass.data.setdefault(DOMAIN, {})
3635
hass.data[DOMAIN][entry.entry_id] = {}
3736

3837
waterHeater = WaterHeater(entry.data[CONF_HOST])
38+
39+
# Attempt to fetch system information from the controller
40+
# This is required during setup to obtain device metadata (serial number, model)
41+
# needed for device registry and coordinator initialization
3942
sysinfo = await waterHeater.get_sysinfo()
4043

41-
# Gracefully handle controller offline or unreachable
42-
if not sysinfo or "sysinfo" not in sysinfo or "serial-number" not in sysinfo["sysinfo"]:
43-
_LOGGER.error("Could not connect to Rinnai controller or missing sysinfo; will retry later.")
44-
raise ConfigEntryNotReady("Rinnai controller is offline or unreachable.")
44+
# Validate sysinfo response structure following HA best practices
45+
# Raising ConfigEntryNotReady will trigger automatic retry by Home Assistant
46+
if sysinfo is None:
47+
_LOGGER.warning(
48+
"Unable to connect to Rinnai controller at %s; setup will be retried automatically",
49+
entry.data[CONF_HOST],
50+
)
51+
raise ConfigEntryNotReady(
52+
f"Unable to connect to Rinnai controller at {entry.data[CONF_HOST]}"
53+
)
54+
55+
if not isinstance(sysinfo, dict):
56+
_LOGGER.warning(
57+
"Invalid response from Rinnai controller at %s; setup will be retried automatically",
58+
entry.data[CONF_HOST],
59+
)
60+
raise ConfigEntryNotReady(
61+
f"Invalid response from Rinnai controller at {entry.data[CONF_HOST]}"
62+
)
63+
64+
# Safely check nested structure
65+
sysinfo_data = sysinfo.get("sysinfo")
66+
if not isinstance(sysinfo_data, dict):
67+
_LOGGER.warning(
68+
"Missing system information from Rinnai controller at %s; setup will be retried automatically",
69+
entry.data[CONF_HOST],
70+
)
71+
raise ConfigEntryNotReady(
72+
f"Missing system information from Rinnai controller at {entry.data[CONF_HOST]}"
73+
)
74+
75+
serial_number = sysinfo_data.get("serial-number")
76+
if not serial_number:
77+
_LOGGER.warning(
78+
"Missing serial number from Rinnai controller at %s; setup will be retried automatically",
79+
entry.data[CONF_HOST],
80+
)
81+
raise ConfigEntryNotReady(
82+
f"Missing serial number from Rinnai controller at {entry.data[CONF_HOST]}"
83+
)
84+
85+
_LOGGER.info(
86+
"Successfully connected to Rinnai controller at %s (Serial: %s)",
87+
entry.data[CONF_HOST],
88+
serial_number,
89+
)
4590

4691
update_interval = entry.options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL)
4792

4893
maint_refresh_interval = entry.options.get(CONF_MAINT_REFRESH_INTERVAL, DEFAULT_MAINT_REFRESH_INTERVAL)
4994

95+
# Extract safely validated values
96+
ayla_dsn = sysinfo_data.get("ayla-dsn", "unknown")
97+
5098
coordinator = RinnaiDeviceDataUpdateCoordinator(
5199
hass,
52100
entry.data[CONF_HOST],
53-
sysinfo["sysinfo"]["serial-number"],
54-
sysinfo["sysinfo"]["serial-number"],
55-
sysinfo["sysinfo"].get("ayla-dsn", "unknown"), # Use get method with default value
101+
serial_number,
102+
serial_number,
103+
ayla_dsn,
56104
update_interval,
57105
entry.options
58106
)
59107

60108
coordinator.maint_refresh_interval = timedelta(seconds=maint_refresh_interval)
61109

62-
await coordinator.async_refresh()
110+
await coordinator.async_config_entry_first_refresh()
63111

64112
if not entry.options:
65113
await _async_options_updated(hass, entry)
@@ -68,18 +116,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
68116
COORDINATOR: coordinator
69117
}
70118

119+
# Start maintenance timer after coordinator is fully initialized
120+
coordinator.start_maintenance_timer()
121+
71122
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
72123

73124
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
74125

126+
# Ensure maintenance timer is stopped when entry is unloaded
127+
entry.async_on_unload(coordinator.stop_maintenance_timer)
128+
75129
return True
76130

77131
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry):
78132
"""Update options."""
133+
# Reload the integration to apply new settings including maintenance interval changes
79134
await hass.config_entries.async_reload(entry.entry_id)
80135

81136
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
82137
"""Unload a config entry."""
138+
# Stop maintenance timer before unloading
139+
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
140+
coordinator.stop_maintenance_timer()
141+
83142
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
84143
if unload_ok:
85144
hass.data[DOMAIN].pop(entry.entry_id)

custom_components/rinnaicontrolr-ha/device.py

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
import async_timeout
66

7-
from homeassistant.core import HomeAssistant
7+
from homeassistant.core import HomeAssistant, callback
88
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
99
from homeassistant.util import Throttle
10-
from homeassistant.helpers.event import async_track_time_interval
10+
from homeassistant.helpers.event import async_call_later, async_track_time_interval
1111

1212
from .rinnai import WaterHeater
1313

@@ -35,7 +35,9 @@ def __init__(
3535
self._device_info: Optional[Dict[str, Any]] | None = None
3636
self.options = options
3737
self.maint_refresh_interval = timedelta(seconds=self.options.get(CONF_MAINT_REFRESH_INTERVAL, DEFAULT_MAINT_REFRESH_INTERVAL))
38-
self._unsub_maintenance_timer = None # Track the maintenance timer
38+
self._unsub_maintenance_timer = None # Track the recurring maintenance timer
39+
self._unsub_maintenance_delay = None # Track the initial delay timer
40+
self._maintenance_enabled = self.options.get(CONF_MAINT_INTERVAL_ENABLED, False)
3941
super().__init__(
4042
hass,
4143
LOGGER,
@@ -397,33 +399,79 @@ async def async_do_maintenance_retrieval(self, _event=None):
397399
async def _update_device(self, *_) -> None:
398400
"""Update the device information from the API"""
399401
self._device_info = await self.waterHeater.get_status()
402+
LOGGER.debug("Rinnai device data: %s", self._device_info)
403+
404+
def start_maintenance_timer(self):
405+
"""Start the maintenance retrieval timer if enabled.
406+
407+
Note: The first maintenance retrieval will occur after the configured interval,
408+
not immediately. This prevents unnecessary API calls during initialization.
409+
"""
410+
# Cancel any existing timers first
411+
if self._unsub_maintenance_delay:
412+
self._unsub_maintenance_delay()
413+
self._unsub_maintenance_delay = None
414+
if self._unsub_maintenance_timer:
415+
self._unsub_maintenance_timer()
416+
self._unsub_maintenance_timer = None
400417

401-
# Handle dynamic maintenance update interval
402-
if self.options[CONF_MAINT_INTERVAL_ENABLED]:
403-
# Cancel previous maintenance update timer if it exists
404-
if self._unsub_maintenance_timer:
405-
self._unsub_maintenance_timer()
418+
# Only set up timer if maintenance is enabled
419+
if self.options.get(CONF_MAINT_INTERVAL_ENABLED, False):
420+
from datetime import datetime
421+
next_run = datetime.now() + self.maint_refresh_interval
422+
LOGGER.info(
423+
"Maintenance retrieval scheduled. First run will occur at approximately %s (in %s seconds)",
424+
next_run.strftime("%H:%M:%S"),
425+
self.maint_refresh_interval.total_seconds(),
426+
)
406427

407-
# Set new maintenance update interval
408-
self._unsub_maintenance_timer = async_track_time_interval(
409-
self.hass, self.async_do_maintenance_retrieval, self.maint_refresh_interval
428+
# Schedule the first call after the interval delay
429+
# This prevents immediate execution on startup
430+
self._unsub_maintenance_delay = async_call_later(
431+
self.hass,
432+
self.maint_refresh_interval.total_seconds(),
433+
self._start_maintenance_interval,
410434
)
411435
else:
412-
LOGGER.debug("Skipping Maintenance retrieval since disabled inside of configuration")
436+
LOGGER.debug("Maintenance retrieval disabled in configuration")
437+
438+
@callback
439+
def _start_maintenance_interval(self, _now=None):
440+
"""Start the recurring maintenance interval timer.
413441
414-
LOGGER.debug("Rinnai device data: %s", self._device_info)
415-
416-
async def async_added_to_hass(self):
417-
"""Called when the device is added to Home Assistant"""
418-
# If maintenance retrieval is enabled, initialize the timer
419-
if self.options[CONF_MAINT_INTERVAL_ENABLED]:
420-
self._unsub_maintenance_timer = async_track_time_interval(
421-
self.hass, self.async_do_maintenance_retrieval, self.maint_refresh_interval
422-
)
423-
424-
async def async_will_remove_from_hass(self):
425-
"""Called when the device is removed from Home Assistant"""
426-
# Unsubscribe from any existing maintenance update timer
442+
This is called after the initial delay to set up the recurring timer.
443+
"""
444+
# Clear the delay timer reference since it's now complete
445+
self._unsub_maintenance_delay = None
446+
447+
LOGGER.info(
448+
"Starting maintenance retrieval cycle. Will run every %s",
449+
self.maint_refresh_interval,
450+
)
451+
452+
# Perform the first maintenance retrieval
453+
self.hass.async_create_task(self.async_do_maintenance_retrieval())
454+
455+
# Set up recurring timer for subsequent calls
456+
self._unsub_maintenance_timer = async_track_time_interval(
457+
self.hass, self.async_do_maintenance_retrieval, self.maint_refresh_interval
458+
)
459+
460+
def stop_maintenance_timer(self):
461+
"""Stop the maintenance retrieval timer and any pending delayed calls"""
462+
# Cancel the delayed initial call if it hasn't fired yet
463+
if self._unsub_maintenance_delay:
464+
self._unsub_maintenance_delay()
465+
self._unsub_maintenance_delay = None
466+
LOGGER.debug("Cancelled pending maintenance retrieval delay")
467+
468+
# Cancel the recurring timer if it exists
427469
if self._unsub_maintenance_timer:
428470
self._unsub_maintenance_timer()
429-
self._unsub_maintenance_timer = None
471+
self._unsub_maintenance_timer = None
472+
LOGGER.debug("Maintenance retrieval timer stopped")
473+
474+
async def async_shutdown(self):
475+
"""Called when the coordinator is being shut down"""
476+
# Clean up the maintenance timer
477+
self.stop_maintenance_timer()

custom_components/rinnaicontrolr-ha/entity.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""Base entity class for Flo entities."""
1+
"""Base entity class for Rinnai entities."""
22
from __future__ import annotations
33

44
from typing import Any
55

6-
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
7-
from homeassistant.helpers.entity import DeviceInfo, Entity
6+
from homeassistant.helpers.device_registry import DeviceInfo
7+
from homeassistant.helpers.entity import Entity
88

99
from .const import DOMAIN as RINNAI_DOMAIN
1010
from .device import RinnaiDeviceDataUpdateCoordinator
@@ -19,7 +19,7 @@ def __init__(
1919
self,
2020
entity_type: str,
2121
name: str,
22-
device: RinnaiDeviceUpdateCoordinator,
22+
device: RinnaiDeviceDataUpdateCoordinator,
2323
**kwargs,
2424
) -> None:
2525
"""Init Rinnai entity."""
@@ -28,11 +28,8 @@ def __init__(
2828

2929
self._device: RinnaiDeviceDataUpdateCoordinator = device
3030
self._state: Any = None
31-
32-
@property
33-
def device_info(self) -> DeviceInfo:
34-
"""Return a device description for device registry."""
35-
return DeviceInfo(
31+
32+
self._attr_device_info = DeviceInfo(
3633
identifiers={(RINNAI_DOMAIN, self._device.serial_number)},
3734
manufacturer=self._device.manufacturer,
3835
model=self._device.model,

custom_components/rinnaicontrolr-ha/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"codeowners": [ "@explosivo22" ],
55
"config_flow": true,
66
"documentation": "https://github.com/explosivo22/rinnaicontrolr-ha/",
7-
"iot_class": "cloud_polling",
7+
"iot_class": "local_polling",
88
"issue_tracker": "https://github.com/explosivo22/rinnaicontrolr-ha/issues",
99
"version": "2.1.8"
1010
}

0 commit comments

Comments
 (0)