Skip to content

Commit 825b7e1

Browse files
authored
Merge pull request #134 from explosivo22/dev
Enhance Rinnai integration with reauthentication handling and configuration improvements
2 parents 60eb910 + cdbdebe commit 825b7e1

File tree

13 files changed

+1105
-68
lines changed

13 files changed

+1105
-68
lines changed

.github/workflows/test-with-homeassistant.yaml

Lines changed: 482 additions & 0 deletions
Large diffs are not rendered by default.

custom_components/rinnaicontrolr-ha/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from aiorinnai.errors import RequestError
77

88
from homeassistant.config_entries import ConfigEntry
9+
from homeassistant.helpers import issue_registry as ir
910
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
1011
from homeassistant.core import HomeAssistant
1112
from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed
1213
from homeassistant.helpers.aiohttp_client import async_get_clientsession
13-
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
14+
from homeassistant.components.water_heater.const import DOMAIN as WATER_HEATER_DOMAIN
1415
from homeassistant.helpers.device_registry import DeviceEntry
1516

1617
from .const import (
@@ -40,6 +41,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4041
_LOGGER.debug("User info retrieved: %s", user_info)
4142
except Unauthenticated as err:
4243
_LOGGER.error("Authentication error: %s", err)
44+
# Create actionable repair issue for user notification
45+
ir.async_create_issue(
46+
hass,
47+
DOMAIN,
48+
"reauth_required",
49+
is_fixable=True,
50+
is_persistent=True,
51+
severity=ir.IssueSeverity.ERROR,
52+
translation_key="reauth_required",
53+
)
4354
raise ConfigEntryAuthFailed from err
4455
except RequestError as err:
4556
_LOGGER.error("Request error: %s", err)
@@ -50,8 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5061
_LOGGER.error("No devices found in user info")
5162
raise ConfigEntryNotReady("No devices found")
5263

64+
# Convert MappingProxyType to dict for options
65+
options = dict(entry.options) if not isinstance(entry.options, dict) else entry.options
5366
hass.data[DOMAIN][entry.entry_id]["devices"] = [
54-
RinnaiDeviceDataUpdateCoordinator(hass, client, device["id"], entry.options)
67+
RinnaiDeviceDataUpdateCoordinator(hass, client, device["id"], options)
5568
for device in devices
5669
]
5770

custom_components/rinnaicontrolr-ha/config_flow.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
CONF_REFRESH_TOKEN,
2222
)
2323

24+
# The above class is a Python ConfigFlow class for a specific domain.
2425
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
2526
"""Handle a config flow for Rinnai."""
2627
VERSION = 2
@@ -164,12 +165,11 @@ async def async_step_reauth(self, user_input=None):
164165
@callback
165166
def async_get_options_flow(config_entry):
166167
"""Get the options flow for this handler."""
167-
return OptionsFlow(config_entry)
168+
return OptionsFlow()
169+
168170

169171
class OptionsFlow(config_entries.OptionsFlow):
170-
def __init__(self, config_entry: config_entries.ConfigEntry):
171-
"""Initialize options flow."""
172-
self.config_entry = config_entry
172+
# No __init__ needed; base class handles config_entry
173173

174174
async def async_step_init(self, user_input=None):
175175
if user_input is not None:

custom_components/rinnaicontrolr-ha/device.py

Lines changed: 100 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,35 +41,50 @@ def __init__(
4141
self._manufacturer: str = "Rinnai"
4242
self._device_information: Optional[Dict[str, Any]] = None
4343
self.options = options
44-
self._listeners = []
4544
super().__init__(
4645
hass,
4746
LOGGER,
4847
name=f"{RINNAI_DOMAIN}-{device_id}",
4948
update_interval=timedelta(seconds=60),
5049
)
5150

52-
async def _async_update_data(self) -> None:
53-
"""Update data via library"""
51+
async def _async_update_data(self) -> dict[str, Any]:
52+
"""Fetch data from device via API and return the fetched device information as a dict[str, Any]."""
5453
try:
5554
async with timeout(10):
56-
await asyncio.gather(
57-
self._update_device()
58-
)
55+
device_info = await self.api_client.device.get_info(self._rinnai_device_id)
5956
except Unauthenticated as error:
6057
LOGGER.error("Authentication error: %s", error)
6158
raise ConfigEntryAuthFailed from error
6259
except RequestError as error:
6360
raise UpdateFailed(error) from error
6461

62+
# Optionally perform maintenance retrieval if enabled (outside try block)
63+
if self.options.get(CONF_MAINT_INTERVAL_ENABLED, False):
64+
try:
65+
await self.async_do_maintenance_retrieval()
66+
except Unauthenticated as error:
67+
LOGGER.error("Authentication error during maintenance retrieval: %s", error)
68+
raise ConfigEntryAuthFailed from error
69+
except RequestError as error:
70+
LOGGER.warning("Maintenance retrieval failed due to request error: %s", error)
71+
else:
72+
LOGGER.debug("Skipping Maintenance retrieval since disabled inside of configuration")
73+
74+
LOGGER.debug("Rinnai device data: %s", device_info)
75+
self._device_information = device_info
76+
return device_info
77+
6578
@property
6679
def id(self) -> str:
6780
"""Return Rinnai thing name"""
6881
return self._rinnai_device_id
6982

7083
@property
71-
def device_name(self) -> str:
84+
def device_name(self) -> Optional[str]:
7285
"""Return device name."""
86+
if not self._device_information:
87+
return None
7388
return self._device_information["data"]["getDevice"]["device_name"]
7489

7590
@property
@@ -78,126 +93,171 @@ def manufacturer(self) -> str:
7893
return self._manufacturer
7994

8095
@property
81-
def model(self) -> str:
96+
def model(self) -> Optional[str]:
8297
"""Return model for device"""
98+
if not self._device_information:
99+
return None
83100
return self._device_information["data"]["getDevice"]["model"]
84101

85102
@property
86-
def firmware_version(self) -> str:
103+
def firmware_version(self) -> Optional[str]:
87104
"""Return the serial number for the device"""
105+
if not self._device_information:
106+
return None
88107
return self._device_information["data"]["getDevice"]["firmware"]
89108

90109
@property
91-
def thing_name(self) -> str:
110+
def thing_name(self) -> Optional[str]:
92111
"""Return model for device"""
112+
if not self._device_information:
113+
return None
93114
return self._device_information["data"]["getDevice"]["thing_name"]
94115

95116
@property
96-
def user_uuid(self) -> str:
117+
def user_uuid(self) -> Optional[str]:
97118
"""Return model for device"""
119+
if not self._device_information:
120+
return None
98121
return self._device_information["data"]["getDevice"]["user_uuid"]
99122

100123
@property
101-
def current_temperature(self) -> float:
124+
def current_temperature(self) -> Optional[float]:
102125
"""Return the current temperature in degrees F"""
126+
if not self._device_information:
127+
return None
103128
return float(self._device_information["data"]["getDevice"]["info"]["domestic_temperature"])
104129

105130
@property
106131
def target_temperature(self) -> Optional[float]:
107132
"""Return the current temperature in degrees F"""
133+
if not self._device_information:
134+
return None
108135
if self._device_information["data"]["getDevice"]["shadow"]["set_domestic_temperature"] is None:
109136
return None
110137
return float(self._device_information["data"]["getDevice"]["shadow"]["set_domestic_temperature"])
111138

112139
@property
113-
def serial_number(self) -> str:
140+
def serial_number(self) -> Optional[str]:
114141
"""Return the serial number for the device"""
142+
if not self._device_information:
143+
return None
115144
return self._device_information["data"]["getDevice"]["info"]["serial_id"]
116145

117146
@property
118-
def last_known_state(self) -> str:
147+
def last_known_state(self) -> Optional[str]:
148+
if not self._device_information:
149+
return None
119150
return self._device_information["data"]["getDevice"]["activity"]["eventType"]
120151

121152
@property
122-
def is_heating(self) -> bool:
153+
def is_heating(self) -> Optional[bool]:
154+
if not self._device_information:
155+
return None
123156
value = self._device_information["data"]["getDevice"]["info"]["domestic_combustion"]
124157
return _convert_to_bool(value)
125158

126159
@property
127-
def is_on(self) -> bool:
160+
def is_on(self) -> Optional[bool]:
161+
if not self._device_information:
162+
return None
128163
value = self._device_information["data"]["getDevice"]["shadow"]["set_operation_enabled"]
129164
return _convert_to_bool(value)
130165

131166
@property
132-
def is_recirculating(self) -> bool:
167+
def is_recirculating(self) -> Optional[bool]:
168+
if not self._device_information:
169+
return None
133170
value = self._device_information["data"]["getDevice"]["shadow"]["recirculation_enabled"]
134171
return _convert_to_bool(value)
135172

136173
@property
137174
def vacation_mode_on(self) -> Optional[bool]:
175+
if not self._device_information:
176+
return None
138177
value = self._device_information["data"]["getDevice"]["shadow"]["schedule_holiday"]
139178
if value is None:
140179
return None
141180
return _convert_to_bool(value)
142181

143182
@property
144-
def outlet_temperature(self) -> float:
183+
def outlet_temperature(self) -> Optional[float]:
184+
if not self._device_information:
185+
return None
145186
return float(self._device_information["data"]["getDevice"]["info"]["m02_outlet_temperature"])
146187

147188
@property
148-
def inlet_temperature(self) -> float:
189+
def inlet_temperature(self) -> Optional[float]:
190+
if not self._device_information:
191+
return None
149192
return float(self._device_information["data"]["getDevice"]["info"]["m08_inlet_temperature"])
150193

151194
@property
152195
def water_flow_rate(self) -> Optional[float]:
153196
"""Return the current temperature in degrees F"""
197+
if not self._device_information:
198+
return None
154199
if self._device_information["data"]["getDevice"]["info"]["m01_water_flow_rate_raw"] is None:
155200
return None
156201
return float(self._device_information["data"]["getDevice"]["info"]["m01_water_flow_rate_raw"])
157202

158203
@property
159204
def combustion_cycles(self) -> Optional[float]:
160205
"""Return the current temperature in degrees F"""
206+
if not self._device_information:
207+
return None
161208
if self._device_information["data"]["getDevice"]["info"]["m04_combustion_cycles"] is None:
162209
return None
163210
return float(self._device_information["data"]["getDevice"]["info"]["m04_combustion_cycles"])
164211

165212
@property
166213
def operation_hours(self) -> Optional[float]:
167214
"""Return the operation hours."""
215+
if not self._device_information:
216+
return None
168217
if self._device_information["data"]["getDevice"]["info"]["operation_hours"] is None:
169218
return None
170219
return float(self._device_information["data"]["getDevice"]["info"]["operation_hours"])
171220

172221
@property
173222
def pump_hours(self) -> Optional[float]:
174223
"""Return the pump hours."""
224+
if not self._device_information:
225+
return None
175226
if self._device_information["data"]["getDevice"]["info"]["m19_pump_hours"] is None:
176227
return None
177228
return float(self._device_information["data"]["getDevice"]["info"]["m19_pump_hours"])
178229

179230
@property
180231
def fan_current(self) -> Optional[float]:
181232
"""Return the fan current."""
233+
if not self._device_information:
234+
return None
182235
if self._device_information["data"]["getDevice"]["info"]["m09_fan_current"] is None:
183236
return None
184237
return float(self._device_information["data"]["getDevice"]["info"]["m09_fan_current"])
185238

186239
@property
187240
def fan_frequency(self) -> Optional[float]:
188241
"""Return the fan frequency."""
242+
if not self._device_information:
243+
return None
189244
if self._device_information["data"]["getDevice"]["info"]["m05_fan_frequency"] is None:
190245
return None
191246
return float(self._device_information["data"]["getDevice"]["info"]["m05_fan_frequency"])
192247

193248
@property
194249
def pump_cycles(self) -> Optional[float]:
195250
"""Return the pump cycles."""
251+
if not self._device_information:
252+
return None
196253
if self._device_information["data"]["getDevice"]["info"]["m20_pump_cycles"] is None:
197254
return None
198255
return float(self._device_information["data"]["getDevice"]["info"]["m20_pump_cycles"])
199256

200257
async def async_set_temperature(self, temperature: int) -> None:
258+
if not self._device_information:
259+
LOGGER.debug("Cannot set temperature: device information not yet loaded")
260+
return
201261
try:
202262
await self.api_client.device.set_temperature(self._device_information["data"]["getDevice"], temperature)
203263
except Unauthenticated as error:
@@ -207,6 +267,9 @@ async def async_set_temperature(self, temperature: int) -> None:
207267
raise UpdateFailed(error) from error
208268

209269
async def async_start_recirculation(self, duration: int) -> None:
270+
if not self._device_information:
271+
LOGGER.debug("Cannot start recirculation: device information not yet loaded")
272+
return
210273
try:
211274
await self.api_client.device.start_recirculation(self._device_information["data"]["getDevice"], duration)
212275
except Unauthenticated as error:
@@ -216,6 +279,9 @@ async def async_start_recirculation(self, duration: int) -> None:
216279
raise UpdateFailed(error) from error
217280

218281
async def async_stop_recirculation(self) -> None:
282+
if not self._device_information:
283+
LOGGER.debug("Cannot stop recirculation: device information not yet loaded")
284+
return
219285
try:
220286
await self.api_client.device.stop_recirculation(self._device_information["data"]["getDevice"])
221287
except Unauthenticated as error:
@@ -225,6 +291,9 @@ async def async_stop_recirculation(self) -> None:
225291
raise UpdateFailed(error) from error
226292

227293
async def async_enable_vacation_mode(self) -> None:
294+
if not self._device_information:
295+
LOGGER.debug("Cannot enable vacation mode: device information not yet loaded")
296+
return
228297
try:
229298
await self.api_client.device.enable_vacation_mode(self._device_information["data"]["getDevice"])
230299
except Unauthenticated as error:
@@ -234,6 +303,9 @@ async def async_enable_vacation_mode(self) -> None:
234303
raise UpdateFailed(error) from error
235304

236305
async def async_disable_vacation_mode(self) -> None:
306+
if not self._device_information:
307+
LOGGER.debug("Cannot disable vacation mode: device information not yet loaded")
308+
return
237309
try:
238310
await self.api_client.device.disable_vacation_mode(self._device_information["data"]["getDevice"])
239311
except Unauthenticated as error:
@@ -243,6 +315,9 @@ async def async_disable_vacation_mode(self) -> None:
243315
raise UpdateFailed(error) from error
244316

245317
async def async_turn_off(self) -> None:
318+
if not self._device_information:
319+
LOGGER.debug("Cannot turn off device: device information not yet loaded")
320+
return
246321
try:
247322
await self.api_client.device.turn_off(self._device_information["data"]["getDevice"])
248323
except Unauthenticated as error:
@@ -252,6 +327,9 @@ async def async_turn_off(self) -> None:
252327
raise UpdateFailed(error) from error
253328

254329
async def async_turn_on(self) -> None:
330+
if not self._device_information:
331+
LOGGER.debug("Cannot turn on device: device information not yet loaded")
332+
return
255333
try:
256334
await self.api_client.device.turn_on(self._device_information["data"]["getDevice"])
257335
except Unauthenticated as error:
@@ -262,6 +340,9 @@ async def async_turn_on(self) -> None:
262340

263341
@Throttle(MIN_TIME_BETWEEN_UPDATES)
264342
async def async_do_maintenance_retrieval(self) -> None:
343+
if not self._device_information:
344+
LOGGER.debug("Cannot perform maintenance retrieval: device information not yet loaded")
345+
return
265346
try:
266347
await self.api_client.device.do_maintenance_retrieval(self._device_information["data"]["getDevice"])
267348
LOGGER.debug("Rinnai Maintenance Retrieval Started")
@@ -270,16 +351,3 @@ async def async_do_maintenance_retrieval(self) -> None:
270351
raise ConfigEntryAuthFailed from error
271352
except RequestError as error:
272353
raise UpdateFailed(error) from error
273-
274-
async def _update_device(self, *_) -> None:
275-
"""Update the device information from the API"""
276-
self._device_information = await self.api_client.device.get_info(
277-
self._rinnai_device_id
278-
)
279-
280-
if self.options[CONF_MAINT_INTERVAL_ENABLED]:
281-
await self.async_do_maintenance_retrieval()
282-
else:
283-
LOGGER.debug("Skipping Maintenance retrieval since disabled inside of configuration")
284-
285-
LOGGER.debug("Rinnai device data: %s", self._device_information)

0 commit comments

Comments
 (0)