Skip to content

Commit bb3eb7a

Browse files
fix: handle ResultCode in get_fire_overview to prevent crash when offline (#81)
* docs: handle ResultCode in get_fire_overview (plan 19) Add plan for fixing TypeError crash when fireplace is offline. The API returns ResultCode != 0 with WifiFireOverview: null for offline/failed/unavailable fireplaces, but our client unconditionally accesses WifiFireOverview causing a crash. The plan introduces a FireOverviewResultCode enum, FireUnavailableError exception, and a guard clause in get_fire_overview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle ResultCode in get_fire_overview to prevent crash when offline The GetFireOverview API returns a ResultCode field that indicates whether the fireplace is reachable. When ResultCode != 0 (offline, failed, no longer available, updating firmware), WifiFireOverview is null. Our client unconditionally accessed it, causing a TypeError crash. - Add FireOverviewResultCode enum mirroring the API's 5 result codes - Add FireUnavailableError exception carrying result_code and optional Fire metadata from FireDetails - Add guard clause in get_fire_overview to check ResultCode before accessing WifiFireOverview - Extract shared _parse_fire helper to deduplicate Fire construction across get_fires, get_fire_overview success path, and error path - Export new types from __init__.py - Add tests for all result code paths, backward compatibility, malformed FireDetails, turn_on/turn_off propagation Refs: deviantintegral/flame_connect_ha#55 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f67d20e commit bb3eb7a

File tree

7 files changed

+539
-31
lines changed

7 files changed

+539
-31
lines changed

.ai/task-manager/plans/19--handle-fire-overview-result-code/plan-19--handle-fire-overview-result-code.md

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

src/flameconnect/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from flameconnect.exceptions import (
2323
ApiError,
2424
AuthenticationError,
25+
FireUnavailableError,
2526
FlameConnectError,
2627
ProtocolError,
2728
)
@@ -34,6 +35,7 @@
3435
FireFeatures,
3536
FireMode,
3637
FireOverview,
38+
FireOverviewResultCode,
3739
FlameColor,
3840
FlameEffect,
3941
FlameEffectParam,
@@ -73,12 +75,14 @@
7375
# Exceptions
7476
"ApiError",
7577
"AuthenticationError",
78+
"FireUnavailableError",
7679
"FlameConnectError",
7780
"ProtocolError",
7881
# Enums
7982
"Brightness",
8083
"ConnectionState",
8184
"FireMode",
85+
"FireOverviewResultCode",
8286
"FlameColor",
8387
"FlameEffect",
8488
"HeatControl",

src/flameconnect/client.py

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919

2020
if TYPE_CHECKING:
2121
from flameconnect.auth import AbstractAuth
22-
from flameconnect.exceptions import ApiError
22+
from flameconnect.exceptions import ApiError, FireUnavailableError
2323
from flameconnect.models import (
2424
ConnectionState,
2525
Fire,
2626
FireFeatures,
2727
FireMode,
2828
FireOverview,
29+
FireOverviewResultCode,
2930
FlameEffect,
3031
FlameEffectParam,
3132
HeatModeParam,
@@ -77,6 +78,28 @@ def _parse_fire_features(data: dict[str, Any]) -> FireFeatures:
7778
)
7879

7980

81+
def _parse_fire(data: dict[str, Any], features: FireFeatures | None = None) -> Fire:
82+
"""Build a Fire dataclass from a JSON dict.
83+
84+
Used by get_fires, get_fire_overview (success path), and the
85+
get_fire_overview error path (FireDetails).
86+
"""
87+
if features is None:
88+
features = _parse_fire_features(data.get("FireFeature", {}))
89+
return Fire(
90+
fire_id=data["FireId"],
91+
friendly_name=data.get("FriendlyName", data["FireId"]),
92+
brand=data.get("Brand", ""),
93+
product_type=data.get("ProductType", ""),
94+
product_model=data.get("ProductModel", ""),
95+
item_code=data.get("ItemCode", ""),
96+
connection_state=ConnectionState(data.get("IoTConnectionState", 0)),
97+
with_heat=data.get("WithHeat", False),
98+
is_iot_fire=data.get("IsIotFire", False),
99+
features=features,
100+
)
101+
102+
80103
def _get_parameter_id(param: Parameter) -> int:
81104
"""Return the wire ParameterId integer for a parameter dataclass."""
82105
if isinstance(param, ModeParam):
@@ -195,20 +218,7 @@ async def get_fires(self) -> list[Fire]:
195218

196219
fires: list[Fire] = []
197220
for entry in data:
198-
features = _parse_fire_features(entry.get("FireFeature", {}))
199-
fire = Fire(
200-
fire_id=entry["FireId"],
201-
friendly_name=entry["FriendlyName"],
202-
brand=entry["Brand"],
203-
product_type=entry["ProductType"],
204-
product_model=entry["ProductModel"],
205-
item_code=entry["ItemCode"],
206-
connection_state=ConnectionState(entry["IoTConnectionState"]),
207-
with_heat=entry["WithHeat"],
208-
is_iot_fire=entry["IsIotFire"],
209-
features=features,
210-
)
211-
fires.append(fire)
221+
fires.append(_parse_fire(entry))
212222

213223
return fires
214224

@@ -220,30 +230,41 @@ async def get_fire_overview(self, fire_id: str) -> FireOverview:
220230
221231
Returns:
222232
A FireOverview containing the fire identity and decoded parameters.
233+
234+
Raises:
235+
FireUnavailableError: If the API reports the fireplace as offline,
236+
failed, no longer available, or updating firmware
237+
(``ResultCode != 0``). The exception carries the
238+
``result_code`` and, when available, the ``fire`` metadata.
223239
"""
224240
url = f"{API_BASE}/api/Fires/GetFireOverview?FireId={quote(fire_id, safe='')}"
225241
data: dict[str, Any] = await self._request("GET", url)
226242

243+
# Check the result code before accessing nullable fields.
244+
raw_code: int = data.get("ResultCode", 0)
245+
try:
246+
result_code = FireOverviewResultCode(raw_code)
247+
except ValueError:
248+
result_code = raw_code # type: ignore[assignment]
249+
250+
if result_code != FireOverviewResultCode.SUCCESSFUL:
251+
fire: Fire | None = None
252+
fire_details: dict[str, Any] | None = data.get("FireDetails")
253+
if fire_details is not None:
254+
try:
255+
fire = _parse_fire(fire_details)
256+
except Exception:
257+
_LOGGER.warning("Failed to parse FireDetails for fire %s", fire_id)
258+
raise FireUnavailableError(result_code, fire)
259+
227260
wifi: dict[str, Any] = data["WifiFireOverview"]
228-
fire_data: dict[str, Any] = wifi
229261

230262
feature_data = data.get("FireDetails", {}).get("FireFeature", {})
231263
if not feature_data:
232264
feature_data = wifi.get("FireFeature", {})
233265
features = _parse_fire_features(feature_data)
234266

235-
fire = Fire(
236-
fire_id=fire_data["FireId"],
237-
friendly_name=fire_data.get("FriendlyName", fire_data["FireId"]),
238-
brand=fire_data.get("Brand", ""),
239-
product_type=fire_data.get("ProductType", ""),
240-
product_model=fire_data.get("ProductModel", ""),
241-
item_code=fire_data.get("ItemCode", ""),
242-
connection_state=ConnectionState(fire_data.get("IoTConnectionState", 0)),
243-
with_heat=fire_data.get("WithHeat", False),
244-
is_iot_fire=fire_data.get("IsIotFire", False),
245-
features=features,
246-
)
267+
fire_obj = _parse_fire(wifi, features=features)
247268

248269
raw_params: list[dict[str, Any]] = wifi.get("Parameters", [])
249270
parameters: list[Parameter] = []
@@ -258,7 +279,7 @@ async def get_fire_overview(self, fire_id: str) -> FireOverview:
258279
continue
259280
parameters.append(param)
260281

261-
return FireOverview(fire=fire, parameters=parameters)
282+
return FireOverview(fire=fire_obj, parameters=parameters)
262283

263284
async def write_parameters(self, fire_id: str, params: list[Parameter]) -> None:
264285
"""Write control parameters to a fireplace.

src/flameconnect/exceptions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
7+
from flameconnect.models import FireOverviewResultCode
8+
9+
if TYPE_CHECKING:
10+
from flameconnect.models import Fire
11+
512

613
class FlameConnectError(Exception):
714
"""Base exception for all flameconnect errors."""
@@ -21,3 +28,27 @@ def __init__(self, status: int, message: str) -> None:
2128

2229
class ProtocolError(FlameConnectError):
2330
"""Raised when wire protocol encoding/decoding fails."""
31+
32+
33+
class FireUnavailableError(FlameConnectError):
34+
"""Raised when the fireplace is offline, failed, or otherwise unavailable.
35+
36+
The ``result_code`` attribute contains the specific
37+
:class:`~flameconnect.models.FireOverviewResultCode` returned by the API.
38+
When the API includes ``FireDetails`` in the response, the ``fire``
39+
attribute contains the parsed :class:`~flameconnect.models.Fire` metadata;
40+
otherwise it is ``None``.
41+
"""
42+
43+
def __init__(
44+
self,
45+
result_code: FireOverviewResultCode | int,
46+
fire: Fire | None = None,
47+
) -> None:
48+
self.result_code = result_code
49+
self.fire = fire
50+
try:
51+
label = FireOverviewResultCode(result_code).name
52+
except ValueError:
53+
label = f"UNKNOWN({result_code})"
54+
super().__init__(f"Fire unavailable: {label}")

src/flameconnect/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ class ConnectionState(IntEnum):
126126
UPDATING_FIRMWARE = 3
127127

128128

129+
class FireOverviewResultCode(IntEnum):
130+
"""Result code from the GetFireOverview API endpoint.
131+
132+
Maps to the C# ``EWifiFireOverviewResponseCode`` enum in the official app.
133+
"""
134+
135+
SUCCESSFUL = 0
136+
FIRE_OFFLINE = 1
137+
FAILED = 2
138+
FIRE_NO_LONGER_AVAILABLE = 3
139+
UPDATING_FIRMWARE = 4
140+
141+
129142
# ---------------------------------------------------------------------------
130143
# Value-object dataclasses
131144
# ---------------------------------------------------------------------------

tests/fixtures/get_fire_overview.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"ResultCode": 0,
23
"WifiFireOverview": {
34
"FireId": "test-fire-001",
45
"FriendlyName": "Living Room",

0 commit comments

Comments
 (0)