Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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)

## [0.5.6] - 2025-10-11

### Added
Expand Down
94 changes: 69 additions & 25 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,6 +92,8 @@ 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(
derived: dict[str, Any], response: dict[str, Any]
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 Down Expand Up @@ -204,7 +210,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 +225,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 +251,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 +267,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,32 +291,68 @@ 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 status(self) -> AirOSDataModel:
"""Retrieve status from the device."""
response = await self._request_json(
Expand Down
13 changes: 10 additions & 3 deletions airos/model_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from .exceptions import AirOSMultipleMatchesFoundException

MODELS: dict[str, str] = {
# Generated list from https://store.ui.com/us/en/category/wireless
# Generated list from https://store.ui.com/us/en/category/wireless
SITE_MODELS: dict[str, str] = {
"Wave MLO5": "Wave-MLO5",
"airMAX Rocket Prism 5AC": "RP-5AC-Gen2",
"airFiber 5XHD": "AF-5XHD",
Expand Down Expand Up @@ -76,10 +76,16 @@
"airMAX 5 GHz, 19/20 dBi Sector": "AM-5G2",
"airMAX 2.4 GHz, 10 dBi Omni": "AMO-2G10",
"airMAX 2.4 GHz, 15 dBi, 120º Sector": "AM-2G15-120",
# Manually added entries for common unofficial names
}

# Manually added entries for common unofficial names
MANUAL_MODELS: dict[str, str] = {
"LiteAP GPS": "LAP-GPS", # Shortened name for airMAX Lite Access Point GPS
"NanoStation loco M5": "LocoM5", # XM firmware version 6 - note the reversed names
}

MODELS: dict[str, str] = {**SITE_MODELS, **MANUAL_MODELS}


class UispAirOSProductMapper:
"""Utility class to map product model names to SKUs and vice versa."""
Expand All @@ -90,6 +96,7 @@ def __init__(self) -> None:

def get_sku_by_devmodel(self, devmodel: str) -> str:
"""Retrieves the SKU for a given device model name."""
devmodel = devmodel.strip()
if devmodel in MODELS:
return MODELS[devmodel]

Expand Down
2 changes: 1 addition & 1 deletion fixtures/airos_NanoStation_M5_sta_v6.3.16.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"ptmp": false,
"ptp": true,
"role": "station",
"sku": "LocoM5",
"sku": "NSM5",
"station": true
},
"firewall": {
Expand Down
145 changes: 145 additions & 0 deletions fixtures/airos_NanoStation_loco_M5_v6.3.16_XM_ap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{
"airview": {
"enabled": 0
},
"derived": {
"access_point": true,
"mac": "XX:XX:XX:XX:XX:XX",
"mac_interface": "br0",
"mode": "point_to_point",
"ptmp": false,
"ptp": true,
"role": "access_point",
"sku": "LocoM5",
"station": false
},
"firewall": {
"eb6tables": false,
"ebtables": true,
"ip6tables": false,
"iptables": false
},
"genuine": "/images/genuine.png",
"host": {
"cpuload": 100.0,
"devmodel": "NanoStation loco M5 ",
"freeram": 8429568,
"fwprefix": "XM",
"fwversion": "v6.3.16",
"hostname": "NanoStation loco M5 AP",
"netrole": "bridge",
"totalram": 30220288,
"uptime": 1953224
},
"interfaces": [
{
"enabled": true,
"hwaddr": "00:00:00:00:00:00",
"ifname": "lo",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "eth0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 100
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "wifi0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "ath0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 300
}
},
{
"enabled": true,
"hwaddr": "XX:XX:XX:XX:XX:XX",
"ifname": "br0",
"mtu": null,
"status": {
"cable_len": null,
"duplex": true,
"ip6addr": null,
"plugged": true,
"snr": null,
"speed": 0
}
}
],
"services": {
"dhcpc": false,
"dhcpd": false,
"pppoe": false
},
"unms": {
"status": 0,
"timestamp": ""
},
"wireless": {
"ack": 28,
"antenna": "Built in - 13 dBi",
"apmac": "XX:XX:XX:XX:XX:XX",
"aprepeater": 0,
"cac_nol": 0,
"ccq": 870,
"chains": "2X2",
"chanbw": 40,
"channel": 140,
"countrycode": 616,
"dfs": 1,
"distance": 600,
"essid": "SOMETHING",
"frequency": "5700 MHz",
"hide_essid": 1,
"mode": "ap",
"noisef": -91,
"nol_chans": 0,
"opmode": "11naht40minus",
"qos": "No QoS",
"rssi": 51,
"rstatus": 5,
"rxrate": "300",
"security": "WPA2",
"signal": -45,
"txpower": 2,
"txrate": "270",
"wds": 1
}
}
Loading