Skip to content

Commit 6a71b66

Browse files
refactor: add named constants, NamedTuples, TypedDicts, and display overrides
Phase 1 of dynamic typing hardening: - Add 11 named constants to const.py replacing magic numbers across client.py, cli.py, and TUI modules - Add kebab_name() utility and centralized display_name() overrides in models.py for consistent enum display across CLI and TUI - Replace unnamed tuples with NamedTuples in cli.py, tui/app.py, tui/widgets.py, tui/media_theme_screen.py, tui/flame_color_screen.py, and tui/color_screen.py - Replace dict return of _parse_login_page() with _B2CLoginFields NamedTuple in b2c_login.py - Add _WireParam TypedDict and Literal["GET","POST"] in client.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da0b86c commit 6a71b66

18 files changed

Lines changed: 345 additions & 172 deletions

src/flameconnect/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,19 @@
66

77
from flameconnect.auth import AbstractAuth, MsalAuth, TokenAuth
88
from flameconnect.client import FlameConnectClient
9+
from flameconnect.const import (
10+
DEFAULT_TARGET_TEMPERATURE,
11+
DEFAULT_TIMER_DURATION,
12+
MAX_BOOST_DURATION,
13+
MAX_FLAME_SPEED,
14+
MAX_TEMP_CELSIUS,
15+
MAX_TEMP_FAHRENHEIT,
16+
MAX_TIMER_DURATION,
17+
MIN_BOOST_DURATION,
18+
MIN_FLAME_SPEED,
19+
MIN_TEMP_CELSIUS,
20+
MIN_TEMP_FAHRENHEIT,
21+
)
922
from flameconnect.exceptions import (
1023
ApiError,
1124
AuthenticationError,
@@ -45,6 +58,7 @@
4558
TimerStatus,
4659
convert_temp,
4760
display_name,
61+
kebab_name,
4862
temp_suffix,
4963
)
5064

@@ -92,11 +106,23 @@
92106
"TempUnitParam",
93107
"TimerParam",
94108
# Constants
109+
"DEFAULT_TARGET_TEMPERATURE",
110+
"DEFAULT_TIMER_DURATION",
111+
"MAX_BOOST_DURATION",
112+
"MAX_FLAME_SPEED",
113+
"MAX_TEMP_CELSIUS",
114+
"MAX_TEMP_FAHRENHEIT",
115+
"MAX_TIMER_DURATION",
116+
"MIN_BOOST_DURATION",
117+
"MIN_FLAME_SPEED",
118+
"MIN_TEMP_CELSIUS",
119+
"MIN_TEMP_FAHRENHEIT",
95120
"NAMED_COLORS",
96121
# Type aliases
97122
"Parameter",
98123
# Utilities
99124
"convert_temp",
100125
"display_name",
126+
"kebab_name",
101127
"temp_suffix",
102128
]

src/flameconnect/b2c_login.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import re
11+
from typing import NamedTuple
1112
from urllib.parse import urljoin, urlparse
1213

1314
import aiohttp
@@ -46,14 +47,21 @@ def _extract_base_path(page_url: str) -> str:
4647
return "/"
4748

4849

49-
def _parse_login_page(html: str, page_url: str) -> dict[str, str]:
50+
class _B2CLoginFields(NamedTuple):
51+
csrf: str
52+
tx: str
53+
p: str
54+
post_url: str
55+
confirmed_url: str
56+
57+
58+
def _parse_login_page(html: str, page_url: str) -> _B2CLoginFields:
5059
"""Extract B2C form fields from the login page HTML.
5160
5261
Looks for the SETTINGS JavaScript object which contains transId and
5362
csrf, plus derives the SelfAsserted POST URL from the page URL.
5463
55-
Returns a dict with keys: csrf, tx, p, base_url, post_url,
56-
confirmed_url.
64+
Returns a _B2CLoginFields named tuple.
5765
5866
Raises:
5967
AuthenticationError: If required fields cannot be found.
@@ -81,13 +89,13 @@ def _parse_login_page(html: str, page_url: str) -> dict[str, str]:
8189
post_url = f"{origin}{base}SelfAsserted?{qs}"
8290
confirmed_url = f"{origin}{base}api/CombinedSigninAndSignup/confirmed"
8391

84-
return {
85-
"csrf": csrf,
86-
"tx": tx,
87-
"p": p,
88-
"post_url": post_url,
89-
"confirmed_url": confirmed_url,
90-
}
92+
return _B2CLoginFields(
93+
csrf=csrf,
94+
tx=tx,
95+
p=p,
96+
post_url=post_url,
97+
confirmed_url=confirmed_url,
98+
)
9199

92100

93101
def _build_cookie_header(
@@ -184,9 +192,9 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
184192
fields = _parse_login_page(login_html, page_url)
185193
_LOGGER.debug(
186194
"Parsed login page: csrf=%s, tx=%s, p=%s",
187-
fields["csrf"][:16] + "...",
188-
fields["tx"][:40] + "...",
189-
fields["p"],
195+
fields.csrf[:16] + "...",
196+
fields.tx[:40] + "...",
197+
fields.p,
190198
)
191199

192200
# Step 3: POST credentials to SelfAsserted endpoint
@@ -198,7 +206,7 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
198206
parsed_page = urlparse(page_url)
199207
origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
200208
post_headers = {
201-
"X-CSRF-TOKEN": fields["csrf"],
209+
"X-CSRF-TOKEN": fields.csrf,
202210
"X-Requested-With": "XMLHttpRequest",
203211
"Referer": auth_uri,
204212
"Origin": origin,
@@ -209,12 +217,12 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
209217
# Build an unquoted Cookie header — aiohttp's cookie jar
210218
# wraps values containing +/= in double-quotes, but B2C
211219
# requires plain unquoted values.
212-
cookie_header = _build_cookie_header(jar, fields["post_url"])
220+
cookie_header = _build_cookie_header(jar, fields.post_url)
213221
post_headers["Cookie"] = cookie_header
214222
_LOGGER.debug(">>> cookies: %s", cookie_header[:200])
215223
_log_request(
216224
"POST",
217-
fields["post_url"],
225+
fields.post_url,
218226
headers=post_headers,
219227
data=post_data,
220228
)
@@ -227,7 +235,7 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
227235
cookie_jar=aiohttp.DummyCookieJar(),
228236
) as raw_session:
229237
async with raw_session.post(
230-
yarl.URL(fields["post_url"], encoded=True),
238+
yarl.URL(fields.post_url, encoded=True),
231239
data=post_data,
232240
headers=post_headers,
233241
allow_redirects=False,
@@ -265,13 +273,13 @@ async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -
265273
# (csrf_token, tx) as browsers send them.
266274
confirmed_qs = (
267275
f"rememberMe=false"
268-
f"&csrf_token={fields['csrf']}"
269-
f"&tx={fields['tx']}"
270-
f"&p={fields['p']}"
276+
f"&csrf_token={fields.csrf}"
277+
f"&tx={fields.tx}"
278+
f"&p={fields.p}"
271279
)
272280

273281
# Follow redirects manually to catch custom-scheme one
274-
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
282+
next_url: str = fields.confirmed_url + "?" + confirmed_qs
275283
confirmed_headers = {
276284
"Cookie": cookie_header,
277285
}

src/flameconnect/cli.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@
88
import sys
99
import webbrowser
1010
from dataclasses import replace
11-
from typing import TYPE_CHECKING
11+
from typing import TYPE_CHECKING, NamedTuple
1212

1313
if TYPE_CHECKING:
1414
from collections.abc import Awaitable, Callable
1515

1616
from flameconnect.auth import MsalAuth
1717
from flameconnect.client import FlameConnectClient
18+
from flameconnect.const import (
19+
DEFAULT_TARGET_TEMPERATURE,
20+
MAX_BOOST_DURATION,
21+
MAX_FLAME_SPEED,
22+
MIN_BOOST_DURATION,
23+
MIN_FLAME_SPEED,
24+
)
1825
from flameconnect.models import (
1926
NAMED_COLORS,
2027
Brightness,
@@ -97,6 +104,13 @@
97104
"celsius": TempUnit.CELSIUS,
98105
}
99106

107+
108+
class _FlameEffectSetter(NamedTuple):
109+
field: str
110+
lookup: dict[str, object]
111+
label: str
112+
113+
100114
_SET_PARAM_NAMES = (
101115
"mode, flame-speed, brightness, pulsating, flame-color,"
102116
" media-theme, heat-status, heat-mode, heat-temp, timer,"
@@ -149,7 +163,7 @@ def _display_flame_effect(param: FlameEffectParam) -> None:
149163
print(f" {'─' * 40}")
150164
flame = display_name(param.flame_effect)
151165
print(f" Flame: {flame}")
152-
print(f" Flame Speed: {param.flame_speed} / 5")
166+
print(f" Flame Speed: {param.flame_speed} / {MAX_FLAME_SPEED}")
153167
brightness = display_name(param.brightness)
154168
pulsating = display_name(param.pulsating_effect)
155169
print(f" Brightness: {brightness}")
@@ -374,43 +388,43 @@ async def cmd_status(client: FlameConnectClient, fire_id: str) -> None:
374388
_display_parameter(param, temp_unit)
375389

376390

377-
_FLAME_EFFECT_SETTERS: dict[str, tuple[str, dict[str, object], str]] = {
378-
"brightness": (
391+
_FLAME_EFFECT_SETTERS: dict[str, _FlameEffectSetter] = {
392+
"brightness": _FlameEffectSetter(
379393
"brightness",
380394
{"low": Brightness.LOW, "high": Brightness.HIGH},
381395
"Brightness",
382396
),
383-
"pulsating": (
397+
"pulsating": _FlameEffectSetter(
384398
"pulsating_effect",
385399
dict[str, object](_PULSATING_LOOKUP),
386400
"Pulsating effect",
387401
),
388-
"flame-color": (
402+
"flame-color": _FlameEffectSetter(
389403
"flame_color",
390404
dict[str, object](_FLAME_COLOR_LOOKUP),
391405
"Flame color",
392406
),
393-
"media-theme": (
407+
"media-theme": _FlameEffectSetter(
394408
"media_theme",
395409
dict[str, object](_MEDIA_THEME_LOOKUP),
396410
"Media theme",
397411
),
398-
"flame-effect": (
412+
"flame-effect": _FlameEffectSetter(
399413
"flame_effect",
400414
{"on": FlameEffect.ON, "off": FlameEffect.OFF},
401415
"Flame effect",
402416
),
403-
"media-light": (
417+
"media-light": _FlameEffectSetter(
404418
"media_light",
405419
{"on": LightStatus.ON, "off": LightStatus.OFF},
406420
"Media light",
407421
),
408-
"overhead-light": (
422+
"overhead-light": _FlameEffectSetter(
409423
"light_status",
410424
{"on": LightStatus.ON, "off": LightStatus.OFF},
411425
"Overhead light",
412426
),
413-
"ambient-sensor": (
427+
"ambient-sensor": _FlameEffectSetter(
414428
"ambient_sensor",
415429
{"on": LightStatus.ON, "off": LightStatus.OFF},
416430
"Ambient sensor",
@@ -477,7 +491,9 @@ async def _set_mode(client: FlameConnectClient, fire_id: str, value: str) -> Non
477491
current_mode = param
478492
break
479493

480-
temperature = current_mode.target_temperature if current_mode else 22.0
494+
temperature = (
495+
current_mode.target_temperature if current_mode else DEFAULT_TARGET_TEMPERATURE
496+
)
481497
mode = FireMode.STANDBY if value == "standby" else FireMode.MANUAL
482498
mode_param = ModeParam(mode=mode, target_temperature=temperature)
483499
await client.write_parameters(fire_id, [mode_param])
@@ -489,8 +505,11 @@ async def _set_flame_speed(
489505
) -> None:
490506
"""Set flame speed (1-5)."""
491507
speed = int(value)
492-
if speed < 1 or speed > 5:
493-
print("Error: flame-speed must be between 1 and 5.")
508+
if speed < MIN_FLAME_SPEED or speed > MAX_FLAME_SPEED:
509+
print(
510+
f"Error: flame-speed must be between"
511+
f" {MIN_FLAME_SPEED} and {MAX_FLAME_SPEED}."
512+
)
494513
sys.exit(1)
495514
overview = await client.get_fire_overview(fire_id)
496515
current = _find_param(overview.parameters, FlameEffectParam)
@@ -540,8 +559,12 @@ async def _set_heat_mode(client: FlameConnectClient, fire_id: str, value: str) -
540559
except (ValueError, IndexError):
541560
print("Error: boost format is boost:<minutes> (e.g., boost:15).")
542561
sys.exit(1)
543-
if not 1 <= boost_minutes <= 20:
544-
print("Error: boost duration must be 1-20 minutes.")
562+
if not MIN_BOOST_DURATION <= boost_minutes <= MAX_BOOST_DURATION:
563+
print(
564+
f"Error: boost duration must be"
565+
f" {MIN_BOOST_DURATION}-{MAX_BOOST_DURATION}"
566+
" minutes."
567+
)
545568
sys.exit(1)
546569
heat_mode = HeatMode.BOOST
547570
elif value in _HEAT_MODE_LOOKUP:

src/flameconnect/client.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
import base64
66
import logging
77
from dataclasses import replace
8-
from typing import TYPE_CHECKING, Any
8+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
99
from urllib.parse import quote
1010

1111
import aiohttp
1212

13-
from flameconnect.const import API_BASE, DEFAULT_HEADERS, ParameterId
13+
from flameconnect.const import (
14+
API_BASE,
15+
DEFAULT_HEADERS,
16+
DEFAULT_TARGET_TEMPERATURE,
17+
ParameterId,
18+
)
1419

1520
if TYPE_CHECKING:
1621
from flameconnect.auth import AbstractAuth
@@ -37,6 +42,11 @@
3742
_LOGGER = logging.getLogger(__name__)
3843

3944

45+
class _WireParam(TypedDict):
46+
ParameterId: int
47+
Value: str
48+
49+
4050
def _parse_fire_features(data: dict[str, Any]) -> FireFeatures:
4151
"""Parse a FireFeature JSON object into a FireFeatures dataclass."""
4252
return FireFeatures(
@@ -124,7 +134,7 @@ async def __aexit__(self, *exc: object) -> None:
124134

125135
async def _request(
126136
self,
127-
method: str,
137+
method: Literal["GET", "POST"],
128138
url: str,
129139
json: dict[str, Any] | None = None,
130140
) -> Any:
@@ -259,11 +269,11 @@ async def write_parameters(self, fire_id: str, params: list[Parameter]) -> None:
259269
"""
260270
url = f"{API_BASE}/api/Fires/WriteWifiParameters"
261271

262-
wire_params: list[dict[str, Any]] = []
272+
wire_params: list[_WireParam] = []
263273
for param in params:
264274
param_id = _get_parameter_id(param)
265275
value = encode_parameter(param)
266-
wire_params.append({"ParameterId": param_id, "Value": value})
276+
wire_params.append(_WireParam(ParameterId=param_id, Value=value))
267277

268278
payload: dict[str, Any] = {
269279
"FireId": fire_id,
@@ -293,7 +303,11 @@ async def turn_on(self, fire_id: str) -> None:
293303
elif isinstance(param, FlameEffectParam):
294304
current_flame = param
295305

296-
temperature = current_mode.target_temperature if current_mode else 22.0
306+
temperature = (
307+
current_mode.target_temperature
308+
if current_mode
309+
else DEFAULT_TARGET_TEMPERATURE
310+
)
297311

298312
new_mode = ModeParam(mode=FireMode.MANUAL, target_temperature=temperature)
299313

@@ -322,7 +336,11 @@ async def turn_off(self, fire_id: str) -> None:
322336
current_mode = param
323337
break
324338

325-
temperature = current_mode.target_temperature if current_mode else 22.0
339+
temperature = (
340+
current_mode.target_temperature
341+
if current_mode
342+
else DEFAULT_TARGET_TEMPERATURE
343+
)
326344

327345
mode_param = ModeParam(mode=FireMode.STANDBY, target_temperature=temperature)
328346
await self.write_parameters(fire_id, [mode_param])

0 commit comments

Comments
 (0)