Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ tests/__pycache__
tmp
todo
.DS_Store
test.py
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to this project will be documented in this file.

## [0.5.7] - 2025-10-20

### Added

- Support for v6 firmware XM models using a different login path (XW already was successful)
- Calculated cpuload on v6 if values available (to prevent reporting close to 100%)
- Fix frequency on v6 firmware (if value is cast as a string ending in MHz)
- Added tx/rx rates for v6
- Fixed ieeemode (v8) vs opmode (v6) mapped back to IeeeMode enum
- Derived antenna_gain (v8) from antenna (v6 string)
- Improved internal workings and firmware detection

## [0.5.6] - 2025-10-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion airos/airos6.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __init__(
)

@staticmethod
def derived_wireless_data(
def _derived_wireless_data(
derived: dict[str, Any], response: dict[str, Any]
) -> dict[str, Any]:
"""Add derived wireless data to the device response."""
Expand Down
166 changes: 122 additions & 44 deletions airos/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def __init__(
self.username = username
self.password = password

self.api_version: int = 8

parsed_host = urlparse(host)
scheme = (
parsed_host.scheme
Expand All @@ -74,11 +76,13 @@ def __init__(
self.current_csrf_token: str | None = None

# 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",
}
self._login_url = f"{self.base_url}/api/auth"
self._status_cgi_url = f"{self.base_url}/status.cgi"

# Presumed 6.x XM only endpoint
self._v6_xm_login_url = f"{self.base_url}/login.cgi"
self._v6_form_url = "/index.cgi"

# Presumed 8.x only endpoints
self._stakick_cgi_url = f"{self.base_url}/stakick.cgi"
self._provmode_url = f"{self.base_url}/api/provmode"
Expand All @@ -88,8 +92,10 @@ def __init__(
self._download_progress_url = f"{self.base_url}/api/fw/download-progress"
self._install_url = f"{self.base_url}/fwflash.cgi"

self._login_urls = [self._login_url, self._v6_xm_login_url]

@staticmethod
def derived_wireless_data(
def _derived_wireless_data(
derived: dict[str, Any], response: dict[str, Any]
) -> dict[str, Any]:
"""Add derived wireless data to the device response."""
Expand Down Expand Up @@ -129,7 +135,7 @@ def _derived_data_helper(
sku = UispAirOSProductMapper().get_sku_by_devmodel(devmodel)
except KeyError:
_LOGGER.warning(
"Unknown SKU/Model ID for %s. Please report at "
"Unknown SKU/Model ID for '%s'. Please report at "
"https://github.com/CoMPaTech/python-airos/issues so we can add support.",
devmodel,
)
Expand All @@ -152,41 +158,71 @@ def _derived_data_helper(
"mode": DerivedWirelessMode.PTP,
"sku": sku,
}

# WIRELESS
derived = derived_wireless_data_func(derived, response)

# INTERFACES
addresses = {}
interface_order = ["br0", "eth0", "ath0"]

# Interfaces / MAC (for unique id)
interfaces = response.get("interfaces", [])

# No interfaces, no mac, no usability
if not interfaces:
_LOGGER.error("Failed to determine interfaces from AirOS data")
raise AirOSKeyDataMissingError from None

for interface in interfaces:
if interface["enabled"]: # Only consider if enabled
addresses[interface["ifname"]] = interface["hwaddr"]

# Fallback take fist alternate interface found
derived["mac"] = interfaces[0]["hwaddr"]
derived["mac_interface"] = interfaces[0]["ifname"]
derived["mac"] = AirOS.get_mac(interfaces)["mac"]
derived["mac_interface"] = AirOS.get_mac(interfaces)["mac_interface"]

for interface in interface_order:
if interface in addresses:
derived["mac"] = addresses[interface]
derived["mac_interface"] = interface
break
# Firmware Major Version
fwversion = (response.get("host") or {}).get("fwversion", "invalid")
derived["fw_major"] = AirOS.get_fw_major(fwversion)

response["derived"] = derived

return response

def derived_data(self, response: dict[str, Any]) -> dict[str, Any]:
@staticmethod
def get_fw_major(fwversion: str) -> int:
"""Extract major firmware version from fwversion string."""
try:
return int(fwversion.lstrip("v").split(".", 1)[0])
except (ValueError, AttributeError) as err:
_LOGGER.error("Invalid firmware version '%s'", fwversion)
raise AirOSKeyDataMissingError("invalid fwversion") from err

@staticmethod
def get_mac(interfaces: list[dict[str, Any]]) -> dict[str, str]:
"""Extract MAC address from interfaces."""
result: dict[str, str] = {"mac": "", "mac_interface": ""}

if not interfaces:
return result

addresses: dict[str, str] = {}
interface_order = ["br0", "eth0", "ath0"]

for interface in interfaces:
if (
interface.get("enabled")
and interface.get("hwaddr")
and interface.get("ifname")
):
addresses[interface["ifname"]] = interface["hwaddr"]

for preferred in interface_order:
if preferred in addresses:
result["mac"] = addresses[preferred]
result["mac_interface"] = preferred
break
else:
result["mac"] = interfaces[0].get("hwaddr", "")
result["mac_interface"] = interfaces[0].get("ifname", "")

return result

@classmethod
def derived_data(cls, response: dict[str, Any]) -> dict[str, Any]:
"""Add derived data to the device response (instance method for polymorphism)."""
return self._derived_data_helper(response, self.derived_wireless_data)
return cls._derived_data_helper(response, cls._derived_wireless_data)

def _get_authenticated_headers(
self,
Expand All @@ -204,7 +240,8 @@ def _get_authenticated_headers(
headers["X-CSRF-ID"] = self._csrf_id

if self._auth_cookie: # pragma: no cover
headers["Cookie"] = f"AIROS_{self._auth_cookie}"
# headers["Cookie"] = f"AIROS_{self._auth_cookie}"
headers["Cookie"] = self._auth_cookie

return headers

Expand All @@ -218,7 +255,8 @@ def _store_auth_data(self, response: aiohttp.ClientResponse) -> None:
cookie.load(set_cookie)
for key, morsel in cookie.items():
if key.startswith("AIROS_"):
self._auth_cookie = morsel.key[6:] + "=" + morsel.value
# self._auth_cookie = morsel.key[6:] + "=" + morsel.value
self._auth_cookie = f"{morsel.key}={morsel.value}"
break

async def _request_json(
Expand All @@ -243,7 +281,7 @@ async def _request_json(
request_headers.update(headers)

try:
if url not in self._login_urls.values() and not self.connected:
if url not in self._login_urls and not self.connected:
_LOGGER.error("Not connected, login first")
raise AirOSDeviceConnectionError from None

Expand All @@ -259,7 +297,7 @@ async def _request_json(
_LOGGER.debug("Successfully fetched JSON from %s", url)

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

Expand All @@ -283,31 +321,71 @@ async def _request_json(
_LOGGER.warning("Request to %s was cancelled", url)
raise

async def _login_v6(self) -> None:
"""Login to airOS v6 (XM) devices."""
# Handle session cookie from login url
async with self.session.request(
"GET",
self._v6_xm_login_url,
allow_redirects=False,
) as response:
session_cookie = next(
(c for n, c in response.cookies.items() if n.startswith("AIROS")), None
)
if not session_cookie:
raise AirOSDeviceConnectionError("No session cookie received.")
self._auth_cookie = f"{session_cookie.key}={session_cookie.value}"

# Handle login expecting 302 redirect
payload = {
"username": self.username,
"password": self.password,
"uri": self._v6_form_url,
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base_url,
"Referer": self._v6_xm_login_url,
"Cookie": self._auth_cookie,
}
async with self.session.request(
"POST",
self._v6_xm_login_url,
data=payload,
headers=headers,
allow_redirects=False,
) as response:
if response.status != 302:
raise AirOSConnectionAuthenticationError("Login failed.")

# Activate session by accessing the form URL
headers = {"Referer": self._v6_xm_login_url, "Cookie": self._auth_cookie}
async with self.session.request(
"GET",
f"{self.base_url}{self._v6_form_url}",
headers=headers,
allow_redirects=True,
) as response:
if "login.cgi" in str(response.url):
raise AirOSConnectionAuthenticationError("Session activation failed.")
self.connected = True
self.api_version = 6

async def login(self) -> None:
"""Login to AirOS device."""
payload = {"username": self.username, "password": self.password}
try:
await self._request_json(
"POST", self._login_urls["default"], json_data=payload
)
await self._request_json("POST", self._login_url, json_data=payload)
except AirOSUrlNotFoundError:
pass # Try next URL
await self._login_v6()
except AirOSConnectionSetupError as err:
raise AirOSConnectionSetupError("Failed to login to AirOS device") from err
else:
return

try: # Alternative URL
await self._request_json(
"POST",
self._login_urls["v6_alternative"],
form_data=payload,
ct_form=True,
)
except AirOSConnectionSetupError as err:
raise AirOSConnectionSetupError(
"Failed to login to default and alternate AirOS device urls"
) from err
async def raw_status(self) -> dict[str, Any]:
"""Retrieve raw status from the device."""
return await self._request_json("GET", self._status_cgi_url, authenticated=True)

async def status(self) -> AirOSDataModel:
"""Retrieve status from the device."""
Expand Down
Loading