Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ jobs:
run: |
. venv/bin/activate
coverage combine artifacts/.coverage*
coverage report --fail-under=85
coverage report --fail-under=80
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
Expand Down
267 changes: 250 additions & 17 deletions airos/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
from abc import ABC
import asyncio
from collections.abc import Callable
import contextlib
from http.cookies import SimpleCookie
import json
import logging
import time
from typing import Any, Generic, TypeVar
from urllib.parse import urlparse

import aiohttp
from mashumaro.exceptions import InvalidFieldValue, MissingField
from yarl import URL

from .data import (
AirOSDataBaseClass,
Expand Down Expand Up @@ -65,7 +68,13 @@ def __init__(

self.base_url = f"{scheme}://{hostname}"

self.session = session
# self.session = session
self.session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(verify_ssl=False, force_close=True),
cookie_jar=aiohttp.CookieJar(),
)
Comment on lines +71 to +75
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Passed session parameter ignored, causing resource leak.

The code comments out the intended use of the passed session parameter and creates a new ClientSession that is never closed. This breaks the API contract and causes resource leaks.

Apply this diff to use the passed session:

-        # self.session = session
-        self.session = aiohttp.ClientSession(
-            connector=aiohttp.TCPConnector(verify_ssl=False, force_close=True),
-            cookie_jar=aiohttp.CookieJar(),
-        )
+        self.session = session

If you need to configure the session differently, do so at the call site where the session is created, not here.

🤖 Prompt for AI Agents
In airos/base.py around lines 71 to 75, the code ignores the passed-in session
and instantiates a new aiohttp.ClientSession (which is never closed), causing a
resource leak and breaking the API contract; change the assignment to use the
provided session (self.session = session) instead of creating a new
ClientSession here, and remove the inline ClientSession construction; if a
fallback is required, handle session creation at the call site or clearly
document/raise when session is None so ownership and closing responsibilities
remain unambiguous.


self.api_version: int = 8

self._use_json_for_login_post = False
self._auth_cookie: str | None = None
Expand All @@ -76,7 +85,7 @@ def __init__(
# Mostly 8.x API endpoints, login/status are the same in 6.x
self._login_urls = {
"default": f"{self.base_url}/api/auth",
"v6_alternative": f"{self.base_url}/login.cgi",
"v6_login": f"{self.base_url}/login.cgi",
}
self._status_cgi_url = f"{self.base_url}/status.cgi"
# Presumed 8.x only endpoints
Expand Down Expand Up @@ -201,10 +210,19 @@ def _get_authenticated_headers(
headers["Content-Type"] = "application/x-www-form-urlencoded"

if self._csrf_id: # pragma: no cover
_LOGGER.error("TESTv%s - CSRF ID found %s", self.api_version, self._csrf_id)
headers["X-CSRF-ID"] = self._csrf_id

"""
if self._auth_cookie: # pragma: no cover
headers["Cookie"] = f"AIROS_{self._auth_cookie}"
_LOGGER.error(
"TESTv%s - auth_cookie found: AIROS_%s",
self.api_version,
self._auth_cookie,
)
# headers["Cookie"] = f"AIROS_{self._auth_cookie}"
headers["Cookie"] = self._auth_cookie
"""

return headers

Expand All @@ -215,8 +233,17 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None:
# Parse all Set-Cookie headers to ensure we don't miss AIROS_* cookie
cookie = SimpleCookie()
for set_cookie in response.headers.getall("Set-Cookie", []):
_LOGGER.error(
"TESTv%s - regular cookie handling: %s", self.api_version, set_cookie
)
cookie.load(set_cookie)
for key, morsel in cookie.items():
_LOGGER.error(
"TESTv%s - AIROS_cookie handling: %s with %s",
self.api_version,
key,
morsel.value,
)
if key.startswith("AIROS_"):
self._auth_cookie = morsel.key[6:] + "=" + morsel.value
break
Expand All @@ -227,10 +254,11 @@ async def _request_json(
url: str,
headers: dict[str, Any] | None = None,
json_data: dict[str, Any] | None = None,
form_data: dict[str, Any] | None = None,
form_data: dict[str, Any] | aiohttp.FormData | None = None,
authenticated: bool = False,
ct_json: bool = False,
ct_form: bool = False,
allow_redirects: bool = True,
) -> dict[str, Any] | Any:
"""Make an authenticated API request and return JSON response."""
# Pass the content type flags to the header builder
Expand All @@ -242,27 +270,138 @@ async def _request_json(
if headers:
request_headers.update(headers)

# Potential XM fix - not sure, might have been login issue
if self.api_version == 6 and url.startswith(self._status_cgi_url):
# Ensure all HAR-matching headers are present
request_headers["Accept"] = "application/json, text/javascript, */*; q=0.01"
request_headers["Accept-Encoding"] = "gzip, deflate, br, zstd"
request_headers["Accept-Language"] = "pl"
request_headers["Cache-Control"] = "no-cache"
request_headers["Connection"] = "keep-alive"
request_headers["Host"] = (
urlparse(self.base_url).hostname or "192.168.1.142"
)
request_headers["Pragma"] = "no-cache"
request_headers["Referer"] = f"{self.base_url}/index.cgi"
request_headers["Sec-Fetch-Dest"] = "empty"
request_headers["Sec-Fetch-Mode"] = "cors"
request_headers["Sec-Fetch-Site"] = "same-origin"
request_headers["User-Agent"] = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
)
request_headers["X-Requested-With"] = "XMLHttpRequest"
request_headers["sec-ch-ua"] = (
'"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"'
)
request_headers["sec-ch-ua-mobile"] = "?0"
request_headers["sec-ch-ua-platform"] = '"Windows"'
Comment on lines +274 to +297
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: Hardcoded IP fallback in Host header.

Line 274 uses a hardcoded IP "192.168.1.142" as a fallback for the Host header. This appears to be a development artifact and should use a generic default or the actual parsed hostname.

Apply this diff:

             request_headers["Host"] = (
-                urlparse(self.base_url).hostname or "192.168.1.142"
+                urlparse(self.base_url).hostname or "device.local"
             )
🤖 Prompt for AI Agents
In airos/base.py around lines 266 to 289, the Host header fallback currently
uses a hardcoded IP ("192.168.1.142"); replace this development artifact with a
generic default by using the parsed hostname from urlparse(self.base_url) and
falling back to a neutral value such as "localhost" (or raise/log an error if
hostname is unexpectedly missing) so the Host header isn't tied to a specific
dev IP.

if url.startswith(self._login_urls["v6_login"]):
request_headers["Referrer"] = f"{self.base_url}/login.cgi"
request_headers["Origin"] = self.base_url
request_headers["Accept"] = (
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
)
request_headers["User-Agent"] = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
)
request_headers["Sec-Fetch-Dest"] = "document"
request_headers["Sec-Fetch-Mode"] = "navigate"
request_headers["Sec-Fetch-Site"] = "same-origin"
request_headers["Sec-Fetch-User"] = "?1"
request_headers["Cache-Control"] = "no-cache"
request_headers["Pragma"] = "no-cache"

try:
if url not in self._login_urls.values() and not self.connected:
if (
url not in self._login_urls.values()
and url != f"{self.base_url}/"
and not self.connected
):
_LOGGER.error("Not connected, login first")
raise AirOSDeviceConnectionError from None

Comment on lines 314 to 322
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Blocker: Auth gate and v6/root handling — wrong conditions, JSON decode, and version flip.

  • Gate should only fire for authenticated calls.
  • Don't raise on v6 login or root prefetch.
  • Don't set api_version=6 for every non-login URL.
  • Avoid JSON-decoding v6 login or root responses.
@@
-            if (
-                url not in self._login_urls.values()
-                and url != f"{self.base_url}/"
-                and not self.connected
-            ):
+            if (
+                authenticated
+                and url not in self._login_urls.values()
+                and url != f"{self.base_url}/"
+                and not self.connected
+            ):
                 _LOGGER.error("Not connected, login first")
                 raise AirOSDeviceConnectionError from None
@@
-                # v6 responds with a 302 redirect and empty body
-                if url != self._login_urls["v6_login"]:
-                    self.api_version = 6
-                    response.raise_for_status()
+                # v6 login (302/empty) and root prefetch may not be JSON; don't raise here
+                if url not in {self._login_urls["v6_login"], f"{self.base_url}/"}:
+                    response.raise_for_status()
@@
-                response_text = await response.text()
-                _LOGGER.error("Successfully fetched %s from %s", response_text, url)
+                response_text = await response.text()
+                _LOGGER.debug("Fetched response from %s", url)
@@
-                _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text)
-                # V6 responds with empty body on login, not JSON
-                if url.startswith(self._login_urls["v6_login"]):
-                    self._store_auth_data(response)
-                    self.connected = True
-                    return {}
+                _LOGGER.debug("Response body from %s: %s", url, response_text[:200])
+                # Root cookie prefetch: no JSON expected
+                if method == "GET" and url == f"{self.base_url}/":
+                    return {}
+                # v6 login: often empty body/redirect; do not JSON-decode
+                if url == self._login_urls["v6_login"]:
+                    return {} if not response_text.strip() else response_text

Also applies to: 308-315, 321-328

🤖 Prompt for AI Agents
In airos/base.py around lines 279-287 (and similarly at 308-315 and 321-328),
the auth/connection gate and v6/root handling are incorrect: only authenticated
calls should trigger the "not connected" error, v6 login and root prefetch
responses must not cause a raise, you must not unconditionally set api_version=6
for every non-login URL, and you must avoid JSON-decoding v6 login or root
responses. Fix by: check whether the request requires authentication before
raising AirOSDeviceConnectionError; skip the connection check and avoid raising
for v6 login and root-prefetch endpoints; only set api_version to 6 when you
have explicit evidence (e.g., response headers or a successful v6 login), not
for every non-login URL; and guard JSON decoding so you do not attempt to parse
v6 login/root responses (treat them as raw text or check content-type/version
first). Ensure these same fixes are applied at the other referenced line ranges.

if self.api_version == 6 and url.startswith(self._status_cgi_url):
_LOGGER.error(
"TESTv%s - adding timestamp to status url!", self.api_version
)
timestamp = int(time.time() * 1000)
url = f"{self._status_cgi_url}?_={timestamp}"

_LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url)
async with self.session.request(
method,
url,
json=json_data,
data=form_data,
headers=request_headers, # Pass the constructed headers
allow_redirects=allow_redirects,
) as response:
response.raise_for_status()
_LOGGER.error(
"TESTv%s - Response code: %s", self.api_version, response.status
)
_LOGGER.error(
"TESTv%s - Response headers: %s",
self.api_version,
dict(response.headers),
)
Comment on lines +330 to +346
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use debug level for diagnostic logs.

Lines 310, 319-320, and 322-326 log diagnostic information at error level. These are not errors but informational messages for debugging request flow.

Apply this diff:

-            _LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url)
+            _LOGGER.debug("Requesting URL: %s (api_version=%s)", url, self.api_version)
             async with self.session.request(
                 ...
             ) as response:
-                _LOGGER.error(
-                    "TESTv%s - Response code: %s", self.api_version, response.status
-                )
-                _LOGGER.error(
-                    "TESTv%s - Response headers: %s",
-                    self.api_version,
-                    dict(response.headers),
-                )
+                _LOGGER.debug("Response status: %s (api_version=%s)", response.status, self.api_version)
+                _LOGGER.debug("Response headers: %s (api_version=%s)", dict(response.headers), self.api_version)
🤖 Prompt for AI Agents
In airos/base.py around lines 310 to 326 the diagnostic logs use _LOGGER.error
for non-error information (attempted URL, response code, and response headers);
change those _LOGGER.error calls to _LOGGER.debug so these are logged at debug
level instead of error level, preserving the same message text and interpolation
but using debug() for each of the occurrences on lines ~310, ~319-320, and
~322-326.

_LOGGER.error(
"TESTv%s - Response history: %s", self.api_version, response.history
)
_LOGGER.error(
"TESTv%s - Session cookies: %s",
self.api_version,
self.session.cookie_jar.filter_cookies(URL(url)),
)
Comment on lines +323 to +354
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use debug level for diagnostic logs.

Lines 318-320, 324, and 333-348 log diagnostic information (timestamp addition, request URL, response status/headers/history/cookies) at error level. These are informational debug messages, not errors.

Apply this diff:

             if self.api_version == 6 and url.startswith(self._status_cgi_url):
-                _LOGGER.error(
-                    "TESTv%s - adding timestamp to status url!", self.api_version
-                )
+                _LOGGER.debug("Adding timestamp to status URL (api_version=%s)", self.api_version)
                 timestamp = int(time.time() * 1000)
                 url = f"{self._status_cgi_url}?_={timestamp}"
 
-            _LOGGER.error("TESTv%s - Trying with URL: %s", self.api_version, url)
+            _LOGGER.debug("Requesting URL: %s (api_version=%s)", url, self.api_version)
             async with self.session.request(
                 ...
             ) as response:
-                _LOGGER.error(
-                    "TESTv%s - Response code: %s", self.api_version, response.status
-                )
-                _LOGGER.error(
-                    "TESTv%s - Response headers: %s",
-                    self.api_version,
-                    dict(response.headers),
-                )
-                _LOGGER.error(
-                    "TESTv%s - Response history: %s", self.api_version, response.history
-                )
-                _LOGGER.error(
-                    "TESTv%s - Session cookies: %s",
-                    self.api_version,
-                    self.session.cookie_jar.filter_cookies(URL(url)),
-                )
+                _LOGGER.debug("Response status: %s (api_version=%s)", response.status, self.api_version)
+                _LOGGER.debug("Response headers: %s (api_version=%s)", dict(response.headers), self.api_version)
+                _LOGGER.debug("Response history: %s (api_version=%s)", response.history, self.api_version)
+                _LOGGER.debug("Session cookies: %s (api_version=%s)", 
+                    self.session.cookie_jar.filter_cookies(URL(url)), self.api_version)

Based on learnings.

🤖 Prompt for AI Agents
In airos/base.py around lines 317 to 348, the diagnostic logs that currently use
_LOGGER.error (timestamp addition, request URL, response status, response
headers, response history, and session cookies) should be changed to use
_LOGGER.debug instead; replace those _LOGGER.error calls with _LOGGER.debug so
these informational messages are logged at debug level, preserving the message
text and interpolation arguments, and leave any actual error logging untouched.


# v6 responds with a 302 redirect and empty body
if not url.startswith(self._login_urls["v6_login"]):
self.api_version = 6
response.raise_for_status()

response_text = await response.text()
_LOGGER.debug("Successfully fetched JSON from %s", url)
_LOGGER.error("Successfully fetched %s from %s", response_text, url)
if not response_text.strip():
_LOGGER.error(
"TESTv%s - Empty response from %s despite %s",
self.api_version,
url,
response.status,
)

# If this is the login request, we need to store the new auth data
if url in self._login_urls.values():
self._store_auth_data(response)
self.connected = True

_LOGGER.error("TESTv%s - response: %s", self.api_version, response_text)

location = response.headers.get("Location")
if location and isinstance(location, str) and location.startswith("/"):
_LOGGER.error(
"TESTv%s - Following redirect to: %s",
self.api_version,
location,
)
await self._request_json(
"GET",
f"{self.base_url}{location}",
headers={
"Referer": self._login_urls["v6_login"],
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
},
authenticated=True,
allow_redirects=False,
)
else:
_LOGGER.error(
"TESTv%s - no location header found to follow in response to %s",
self.api_version,
url,
)
# V6 responds with empty body on login, not JSON
if url.startswith(self._login_urls["v6_login"]):
return {}

Comment on lines +376 to +404
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use debug level for redirect and response logging.

Lines 370, 374-378, 390-394 log normal redirect-following flow at error level. These are informational debug messages.

Apply this diff:

-                _LOGGER.error("TESTv%s - response: %s", self.api_version, response_text)
+                _LOGGER.debug("Response body (api_version=%s): %s", self.api_version, response_text[:200])
 
                 location = response.headers.get("Location")
                 if location and isinstance(location, str) and location.startswith("/"):
-                    _LOGGER.error(
-                        "TESTv%s - Following redirect to: %s",
-                        self.api_version,
-                        location,
-                    )
+                    _LOGGER.debug("Following redirect to: %s (api_version=%s)", location, self.api_version)
                     await self._request_json(
                         ...
                     )
                 else:
-                    _LOGGER.error(
-                        "TESTv%s - no location header found to follow in response to %s",
-                        self.api_version,
-                        url,
-                    )
+                    _LOGGER.debug("No location header in response to %s (api_version=%s)", url, self.api_version)

Based on learnings.

🤖 Prompt for AI Agents
In airos/base.py around lines 370 to 398, the code logs normal
redirect-following and response details at error level; change those
informational logs to use _LOGGER.debug instead of _LOGGER.error for the
response_text log, the "Following redirect to" block, and the "no location
header found" message so these become debug-level messages; keep behavior and
message text the same (just swap the logging method), ensuring
formatting/arguments remain unchanged and no other control flow is modified.

return json.loads(response_text)
except aiohttp.ClientResponseError as err:
_LOGGER.error(
Expand All @@ -287,32 +426,126 @@ async def login(self) -> None:
"""Login to AirOS device."""
payload = {"username": self.username, "password": self.password}
try:
_LOGGER.error("TESTv%s - Trying default v8 login URL", self.api_version)
await self._request_json(
"POST", self._login_urls["default"], json_data=payload
)
except AirOSUrlNotFoundError:
pass # Try next URL
_LOGGER.error(
"TESTv%s - gives URL not found, trying alternative v6 URL",
self.api_version,
)
# Try next URL
except AirOSConnectionSetupError as err:
_LOGGER.error("TESTv%s - failed to login to v8 URL", self.api_version)
raise AirOSConnectionSetupError("Failed to login to AirOS device") from err
else:
_LOGGER.error("TESTv%s - returning from v8 login", self.api_version)
Comment on lines +429 to +443
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

MINOR: Use debug level for login flow logs.

Lines 394, 399-402, 405, and 408 log normal login flow information at error level. These should be debug level.

Apply this diff:

-            _LOGGER.error("TESTv%s - Trying default v8 login URL", self.api_version)
+            _LOGGER.debug("Attempting v8 login (api_version=%s)", self.api_version)
             await self._request_json(
                 "POST", self._login_urls["default"], json_data=payload
             )
         except AirOSUrlNotFoundError:
-            _LOGGER.error(
-                "TESTv%s - gives URL not found, trying alternative v6 URL",
-                self.api_version,
-            )
+            _LOGGER.debug("v8 login not found, attempting v6 login (api_version=%s)", self.api_version)
             # Try next URL
         except AirOSConnectionSetupError as err:
-            _LOGGER.error("TESTv%s - failed to login to v8 URL", self.api_version)
+            _LOGGER.debug("v8 login failed (api_version=%s)", self.api_version)
             raise AirOSConnectionSetupError("Failed to login to AirOS device") from err
         else:
-            _LOGGER.error("TESTv%s - returning from v8 login", self.api_version)
+            _LOGGER.debug("v8 login succeeded (api_version=%s)", self.api_version)
             return
🤖 Prompt for AI Agents
In airos/base.py around lines 394 to 408 the normal login flow messages are
logged at error level; change those _LOGGER.error calls to _LOGGER.debug so
routine flow (trying default v8 URL, v6 fallback notice, failed-to-login message
that is part of flow tracing, and returning-from-v8-login) are logged at debug
level instead of error; keep the messages and exception raise behavior intact,
only replace the logging calls' level to debug.

return

try: # Alternative URL
# Start of v6, go for cookies
_LOGGER.error(
"TESTv%s - Trying to get /index.cgi first for cookies", self.api_version
)
with contextlib.suppress(Exception):
cookieresponse = await self._request_json(
"GET",
f"{self.base_url}/index.cgi",
authenticated=True,
headers={
"Referer": f"{self.base_url}/login.cgi",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
},
)
_LOGGER.error(
"TESTv%s - Cookie response: %s", self.api_version, cookieresponse
)
if isinstance(cookieresponse, aiohttp.ClientResponse):
_LOGGER.debug(
"TESTv%s - Finalization redirect chain: %s",
self.api_version,
cookieresponse.history,
)
else:
_LOGGER.debug(
"TESTv%s - Finalization response is not a ClientResponse: %s",
self.api_version,
type(cookieresponse),
)
Comment on lines +446 to +474
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Major: Overly broad exception suppression in cookie prefetch.

Line 450 uses contextlib.suppress(Exception) which is too broad and will hide programming errors, network issues, and other unexpected failures.

Apply this diff:

         # Start of v6, go for cookies
-        _LOGGER.error(
-            "TESTv%s - Trying to get /index.cgi first for cookies", self.api_version
-        )
-        with contextlib.suppress(Exception):
+        _LOGGER.debug("Prefetching cookies from /index.cgi (api_version=%s)", self.api_version)
+        try:
             cookieresponse = await self._request_json(
                 "GET",
                 f"{self.base_url}/index.cgi",
                 authenticated=True,
                 headers={
                     "Referer": f"{self.base_url}/login.cgi",
                     "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
                 },
             )
-            _LOGGER.error(
-                "TESTv%s - Cookie response: %s", self.api_version, cookieresponse
-            )
-            if isinstance(cookieresponse, aiohttp.ClientResponse):
-                _LOGGER.debug(
-                    "TESTv%s - Finalization redirect chain: %s",
-                    self.api_version,
-                    cookieresponse.history,
-                )
-            else:
-                _LOGGER.debug(
-                    "TESTv%s - Finalization response is not a ClientResponse: %s",
-                    self.api_version,
-                    type(cookieresponse),
-                )
+            _LOGGER.debug("Cookie prefetch response: %s (api_version=%s)", cookieresponse, self.api_version)
+        except (aiohttp.ClientError, TimeoutError, AirOSDeviceConnectionError) as err:
+            _LOGGER.debug("Cookie prefetch failed (non-fatal): %s", err)
🤖 Prompt for AI Agents
In airos/base.py around lines 446 to 474, the code currently wraps the cookie
prefetch in contextlib.suppress(Exception) which is too broad; replace the
suppress with a try/except that only catches expected runtime/network errors
(e.g., aiohttp.ClientError, asyncio.TimeoutError) and logs the exception details
before continuing, and let unexpected exceptions propagate; ensure you also log
the exception type/message (use _LOGGER.exception or _LOGGER.error with
exc_info) so failures are visible instead of silently swallowed.


v6_simple_multipart_form_data = aiohttp.FormData()
v6_simple_multipart_form_data.add_field("uri", "/index.cgi")
v6_simple_multipart_form_data.add_field("username", self.username)
v6_simple_multipart_form_data.add_field("password", self.password)

_LOGGER.debug(
"TESTv%s !!!REDACT THIS!!!! Form payload: %s",
self.api_version,
v6_simple_multipart_form_data(),
)
Comment on lines +481 to +485
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CRITICAL: Form payload with password logged.

Lines 434-438 log the form payload at debug level. Even though it says "REDACT THIS", the code still executes v6_simple_multipart_form_data() which will expose the password in logs.

Apply this diff to remove the password logging:

-        _LOGGER.debug(
-            "TESTv%s !!!REDACT THIS!!!! Form payload: %s",
-            self.api_version,
-            v6_simple_multipart_form_data(),
-        )
+        _LOGGER.debug("Prepared v6 login form data (api_version=%s)", self.api_version)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_LOGGER.debug(
"TESTv%s !!!REDACT THIS!!!! Form payload: %s",
self.api_version,
v6_simple_multipart_form_data(),
)
_LOGGER.debug("Prepared v6 login form data (api_version=%s)", self.api_version)
🤖 Prompt for AI Agents
In airos/base.py around lines 434 to 438 the debug log calls
v6_simple_multipart_form_data() which constructs and exposes the password;
remove the sensitive call from the logging path so the password is never
computed or logged. Replace the log with a non-sensitive message (e.g., log that
the form payload was redacted or omitted) or, if you must log payload structure,
compute a sanitized copy that strips or masks the password before logging;
ensure the original v6_simple_multipart_form_data() is not invoked as part of
the log statement.


login_headers = {
"Referer": self._login_urls["v6_login"],
}

_LOGGER.error("TESTv%s - start v6 attempts", self.api_version)
# --- ATTEMPT B: Simple Payload (multipart/form-data) ---
try:
_LOGGER.error(
"TESTv%s - Trying V6 POST to %s with SIMPLE multipart/form-data",
self.api_version,
self._login_urls["v6_login"],
)
await self._request_json(
"POST",
self._login_urls["v6_alternative"],
form_data=payload,
ct_form=True,
self._login_urls["v6_login"],
headers=login_headers,
form_data=v6_simple_multipart_form_data,
ct_form=False,
ct_json=False,
authenticated=False,
allow_redirects=False,
)
except AirOSConnectionSetupError as err:
raise AirOSConnectionSetupError(
"Failed to login to default and alternate AirOS device urls"
) from err
except (AirOSUrlNotFoundError, AirOSConnectionSetupError) as err:
_LOGGER.error(
"TESTv%s - V6 simple multipart failed (%s) on %s. Error: %s",
self.api_version,
type(err).__name__,
self._login_urls["v6_login"],
err,
)
except AirOSConnectionAuthenticationError:
_LOGGER.error(
"TESTv%s - autherror during extended multipart", self.api_version
)
raise
Comment on lines +517 to +521
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Message mismatch: says “extended multipart” inside simple attempt.

Adjust text to match the attempt type.

-            _LOGGER.error(
-                "TESTv%s - autherror during extended multipart", self.api_version
-            )
+            _LOGGER.debug("v6 login auth error (simple multipart) (v%s)", self.api_version)
🤖 Prompt for AI Agents
In airos/base.py around lines 412 to 416, the error log message incorrectly says
"autherror during extended multipart" while this except block is handling the
simple attempt; update the log string to reflect the correct attempt type (e.g.,
"autherror during simple attempt") and keep the same API version interpolation
and raise behavior.

else:
_LOGGER.error("TESTv%s - returning from simple multipart", self.api_version)
# Finalize session by visiting /index.cgi
_LOGGER.error(
"TESTv%s - Finalizing session with GET to /index.cgi", self.api_version
)
with contextlib.suppress(Exception):
await self._request_json(
"GET",
f"{self.base_url}/index.cgi",
headers={
"Referer": f"{self.base_url}/login.cgi",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
},
authenticated=True,
allow_redirects=True,
)
return # Success
Comment on lines +522 to +539
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Major: Overly broad exception suppression in finalization.

Line 443 uses contextlib.suppress(Exception) which is too broad. Lines 438-441 log at error level.

Apply this diff:

         else:
-            _LOGGER.error("TESTv%s - returning from simple multipart", self.api_version)
+            _LOGGER.debug("v6 simple multipart succeeded (api_version=%s)", self.api_version)
             # Finalize session by visiting /index.cgi
-            _LOGGER.error(
-                "TESTv%s - Finalizing session with GET to /index.cgi", self.api_version
-            )
-            with contextlib.suppress(Exception):
+            _LOGGER.debug("Finalizing v6 session (api_version=%s)", self.api_version)
+            try:
                 await self._request_json(
                     "GET",
                     f"{self.base_url}/index.cgi",
                     headers={
                         "Referer": f"{self.base_url}/login.cgi",
                         "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
                     },
                     authenticated=True,
                     allow_redirects=True,
                 )
+            except (aiohttp.ClientError, TimeoutError, AirOSDeviceConnectionError) as err:
+                _LOGGER.debug("Session finalization failed (non-fatal): %s", err)
             return  # Success
🤖 Prompt for AI Agents
In airos/base.py around lines 437 to 454, the finalization block currently
suppresses all Exceptions with contextlib.suppress(Exception) which is too
broad; replace it with a narrower try/except that only handles expected
network/HTTP errors (for example aiohttp.ClientError, asyncio.TimeoutError, and
any JSON/parse errors your _request_json may raise), log the caught exception
details using _LOGGER.error with contextual message (include self.api_version
and the exception), and re-raise any unexpected exceptions so they aren’t
silently swallowed; keep the GET to /index.cgi and the final return on success
unchanged.


async def status(self) -> AirOSDataModel:
"""Retrieve status from the device."""
status_headers = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
}
response = await self._request_json(
"GET", self._status_cgi_url, authenticated=True
"GET", self._status_cgi_url, authenticated=True, headers=status_headers
)

try:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "airos"
version = "0.5.6"
version = "0.5.7a7"
license = "MIT"
description = "Ubiquiti airOS module(s) for Python 3."
readme = "README.md"
Expand Down
Loading
Loading