Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 27 additions & 69 deletions src/flameconnect/b2c_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,13 @@ def _parse_login_page(html: str, page_url: str) -> dict[str, str]:
# Extract CSRF token from var SETTINGS = {..., "csrf":"...", ...}
csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
if not csrf_match:
raise AuthenticationError(
"Could not find CSRF token in B2C login page"
)
raise AuthenticationError("Could not find CSRF token in B2C login page")
csrf = csrf_match.group(1)

# Extract transId from SETTINGS
tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
if not tx_match:
raise AuthenticationError(
"Could not find transId in B2C login page"
)
raise AuthenticationError("Could not find transId in B2C login page")
tx = tx_match.group(1)

p = _B2C_POLICY
Expand All @@ -84,10 +80,7 @@ def _parse_login_page(html: str, page_url: str) -> dict[str, str]:
qs = f"tx={tx}&p={p}"

post_url = f"{origin}{base}SelfAsserted?{qs}"
confirmed_url = (
f"{origin}{base}"
f"api/CombinedSigninAndSignup/confirmed"
)
confirmed_url = f"{origin}{base}api/CombinedSigninAndSignup/confirmed"

return {
"csrf": csrf,
Expand All @@ -109,10 +102,8 @@ def _build_cookie_header(
browsers send them). This function formats cookies in the plain
``name=value; name2=value2`` style that B2C requires.
"""
filtered = cookie_jar.filter_cookies(url)
return "; ".join(
f"{m.key}={m.value}" for m in filtered.values()
)
filtered = cookie_jar.filter_cookies(yarl.URL(url))
return "; ".join(f"{m.key}={m.value}" for m in filtered.values())


def _log_request(
Expand All @@ -130,10 +121,7 @@ def _log_request(
if headers:
_LOGGER.debug(">>> headers: %s", headers)
if data:
safe = {
k: ("***" if k == "password" else v)
for k, v in data.items()
}
safe = {k: ("***" if k == "password" else v) for k, v in data.items()}
_LOGGER.debug(">>> body: %s", safe)


Expand All @@ -143,7 +131,9 @@ def _log_response(
) -> None:
"""Log an incoming HTTP response at DEBUG level."""
_LOGGER.debug(
"<<< %s %s", resp.status, resp.url,
"<<< %s %s",
resp.status,
resp.url,
)
_LOGGER.debug("<<< headers: %s", dict(resp.headers))
if body is not None:
Expand All @@ -153,9 +143,7 @@ def _log_response(
_LOGGER.debug("<<< body: %s", preview)


async def b2c_login_with_credentials(
auth_uri: str, email: str, password: str
) -> str:
async def b2c_login_with_credentials(auth_uri: str, email: str, password: str) -> str:
"""Submit credentials directly to Azure AD B2C and return the redirect URL.

Performs the same HTTP flow a browser would:
Expand Down Expand Up @@ -216,12 +204,8 @@ async def b2c_login_with_credentials(
"X-Requested-With": "XMLHttpRequest",
"Referer": auth_uri,
"Origin": origin,
"Accept": (
"application/json, text/javascript, */*; q=0.01"
),
"Content-Type": (
"application/x-www-form-urlencoded; charset=UTF-8"
),
"Accept": ("application/json, text/javascript, */*; q=0.01"),
"Content-Type": ("application/x-www-form-urlencoded; charset=UTF-8"),
}

# Build an unquoted Cookie header — aiohttp's cookie jar
Expand Down Expand Up @@ -255,17 +239,11 @@ async def b2c_login_with_credentials(
_log_response(resp, body)
if resp.status != 200:
raise AuthenticationError(
f"Credential submission returned HTTP"
f" {resp.status}"
f"Credential submission returned HTTP {resp.status}"
)
# Check for error in the JSON-like response
if (
'"status":"400"' in body
or '"status": "400"' in body
):
raise AuthenticationError(
"Invalid email or password"
)
if '"status":"400"' in body or '"status": "400"' in body:
raise AuthenticationError("Invalid email or password")
# Merge cookies set by the POST response (e.g.
# updated x-ms-cpim-cache and x-ms-cpim-trans)
# into the cookie header for the confirmed GET.
Expand All @@ -276,16 +254,12 @@ async def b2c_login_with_credentials(
if "=" in part:
n, v = part.split("=", 1)
cookies[n] = v
for raw_sc in resp.headers.getall(
"Set-Cookie", []
):
for raw_sc in resp.headers.getall("Set-Cookie", []):
sc_pair = raw_sc.split(";", 1)[0]
if "=" in sc_pair:
n, v = sc_pair.split("=", 1)
cookies[n] = v
cookie_header = "; ".join(
f"{n}={v}" for n, v in cookies.items()
)
cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())

# Step 4: GET the confirmed endpoint — follows redirects
# until we hit the custom-scheme redirect
Expand All @@ -300,9 +274,7 @@ async def b2c_login_with_credentials(
)

# Follow redirects manually to catch custom-scheme one
next_url: str = (
fields["confirmed_url"] + "?" + confirmed_qs
)
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
confirmed_headers = {
"Cookie": cookie_header,
}
Expand All @@ -316,53 +288,39 @@ async def b2c_login_with_credentials(
resp_body = await resp.text()
_log_response(resp, resp_body)
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get(
"Location", ""
)
location = resp.headers.get("Location", "")
if not location:
raise AuthenticationError(
"Redirect without Location"
" header"
"Redirect without Location header"
)
# Custom-scheme redirect (msal{id}://auth)
if (
location.startswith("msal")
and "://auth" in location
):
if location.startswith("msal") and "://auth" in location:
_LOGGER.debug(
"Captured custom-scheme"
" redirect: %s",
"Captured custom-scheme redirect: %s",
location[:120] + "...",
)
return location
# Resolve relative URLs
if not location.startswith("http"):
location = urljoin(
next_url, location
)
location = urljoin(next_url, location)
next_url = location
continue
if resp.status == 200:
redirect_match = re.search(
r'(msal[a-f0-9-]+://auth'
r"(msal[a-f0-9-]+://auth"
r'\?[^\s"\'<]+)',
resp_body,
)
if redirect_match:
return redirect_match.group(1)
raise AuthenticationError(
"Reached 200 response without"
" finding redirect URL"
"Reached 200 response without finding redirect URL"
)
raise AuthenticationError(
"Unexpected HTTP"
f" {resp.status} during"
" redirect chain"
f"Unexpected HTTP {resp.status} during redirect chain"
)

raise AuthenticationError(
"Too many redirects during B2C login"
)
raise AuthenticationError("Too many redirects during B2C login")
except AuthenticationError:
raise
except aiohttp.ClientError as exc:
Expand Down
26 changes: 7 additions & 19 deletions src/flameconnect/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,8 @@ def _display_mode(
) -> None:
"""Display Mode parameter."""
unit = temp_unit.unit if temp_unit else TempUnit.CELSIUS
unit_suffix = (
"C" if unit == TempUnit.CELSIUS else "F"
) if temp_unit else ""
display_temp = _convert_temp(
param.target_temperature, unit
)
unit_suffix = ("C" if unit == TempUnit.CELSIUS else "F") if temp_unit else ""
display_temp = _convert_temp(param.target_temperature, unit)
print("\n [321] Mode")
print(f" {'─' * 40}")
mode = _enum_name(_FIRE_MODE_NAMES, param.mode)
Expand Down Expand Up @@ -253,12 +249,8 @@ def _display_heat(
) -> None:
"""Display HeatSettings parameter."""
unit = temp_unit.unit if temp_unit else TempUnit.CELSIUS
unit_suffix = (
"C" if unit == TempUnit.CELSIUS else "F"
) if temp_unit else ""
display_temp = _convert_temp(
param.setpoint_temperature, unit
)
unit_suffix = ("C" if unit == TempUnit.CELSIUS else "F") if temp_unit else ""
display_temp = _convert_temp(param.setpoint_temperature, unit)
print("\n [323] Heat Settings")
print(f" {'─' * 40}")
status = _enum_name(_HEAT_STATUS_NAMES, param.heat_status)
Expand Down Expand Up @@ -566,9 +558,7 @@ async def _set_brightness(client: FlameConnectClient, fire_id: str, value: str)
print(f"Brightness set to {value}.")


async def _set_pulsating(
client: FlameConnectClient, fire_id: str, value: str
) -> None:
async def _set_pulsating(client: FlameConnectClient, fire_id: str, value: str) -> None:
"""Set pulsating effect on or off."""
if value not in _PULSATING_LOOKUP:
valid = ", ".join(_PULSATING_LOOKUP)
Expand Down Expand Up @@ -690,9 +680,7 @@ async def _set_timer(client: FlameConnectClient, fire_id: str, value: str) -> No
print("Timer disabled.")


async def _set_temp_unit(
client: FlameConnectClient, fire_id: str, value: str
) -> None:
async def _set_temp_unit(client: FlameConnectClient, fire_id: str, value: str) -> None:
"""Set the temperature display unit."""
if value not in _TEMP_UNIT_LOOKUP:
valid = ", ".join(_TEMP_UNIT_LOOKUP)
Expand All @@ -704,7 +692,6 @@ async def _set_temp_unit(
print(f"Temperature unit set to {value}.")



async def _set_flame_effect(
client: FlameConnectClient, fire_id: str, value: str
) -> None:
Expand Down Expand Up @@ -822,6 +809,7 @@ async def _set_ambient_sensor(
await client.write_parameters(fire_id, [new_param])
print(f"Ambient sensor set to {value}.")


async def cmd_tui(*, verbose: bool = False) -> None:
"""Launch the TUI, showing install message if missing."""
try:
Expand Down
1 change: 0 additions & 1 deletion src/flameconnect/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ class RGBWColor:
white: int



NAMED_COLORS: dict[str, RGBWColor] = {
"dark-red": RGBWColor(red=180, green=0, blue=0, white=0),
"light-red": RGBWColor(red=255, green=0, blue=0, white=80),
Expand Down
9 changes: 7 additions & 2 deletions src/flameconnect/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ def _decode_mode(raw: bytes) -> ModeParam:
_check_length(raw, 6, "Mode")
mode = FireMode(raw[3])
target_temperature = _decode_temperature(raw, 4)
_LOGGER.debug("Decoded Mode: mode=%s target_temperature=%.1f", mode, target_temperature)
_LOGGER.debug(
"Decoded Mode: mode=%s target_temperature=%.1f",
mode,
target_temperature,
)
return ModeParam(mode=mode, target_temperature=target_temperature)


Expand Down Expand Up @@ -318,7 +322,8 @@ def _encode_heat_settings(param: HeatParam) -> bytes:
param.setpoint_temperature,
param.boost_duration,
)
wire_boost = max(0, param.boost_duration - 1) # model is 1-indexed, wire is 0-indexed
# model is 1-indexed, wire is 0-indexed
wire_boost = max(0, param.boost_duration - 1)
payload = (
bytes([param.heat_status, param.heat_mode])
+ _encode_temperature(param.setpoint_temperature)
Expand Down
Loading