Skip to content

Commit 2d85339

Browse files
authored
Merge pull request #211 from Wackymax/fix-airos6-loco-m2-login
Fix airOS 6 loco M2 support
2 parents be6e626 + 4c9d22f commit 2d85339

6 files changed

Lines changed: 25 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

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

5+
## [0.6.6] - 2026-05-21
6+
7+
### Added
8+
9+
- Add support for NanoStation loco M2
10+
511
## [0.6.5] - 2026-04-28
612

713
### Added

‎airos/airos6.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def _derived_wireless_data(
5353

5454
return derived
5555

56+
async def login(self) -> None:
57+
"""Login to airOS v6 devices."""
58+
await self._login_v6()
59+
5660
async def update_check(self, force: bool = False) -> dict[str, Any]:
5761
"""Check for firmware updates. Not supported on AirOS6."""
5862
raise AirOSNotSupportedError("Firmware update check not supported on AirOS6.")

‎airos/model_map.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"LiteAP AC": "LAP-120", # Shortened name for airMAX Lite Access Point AC, Issue 137
8585
"LiteAP GPS": "LAP-GPS", # Shortened name for airMAX Lite Access Point GPS
8686
"LiteBeam 5AC 23": "LBE-5AC-23",
87+
"NanoStation loco M2": "LocoM2", # XW firmware version 6 - note the reversed names
8788
"NanoStation loco M5": "LocoM5", # XM firmware version 6 - note the reversed names
8889
"NanoStation loco M900": "LocoM900", # XM firmware version 6, 900 MHz variant
8990
}

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "airos"
7-
version = "0.6.5"
7+
version = "0.6.6"
88
license = "MIT"
99
description = "Ubiquiti airOS module(s) for Python 3."
1010
readme = "README.md"

‎tests/test_model_map.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ def test_get_sku_by_devmodel_case_insensitivity(self):
2828
sku = self.mapper.get_sku_by_devmodel("nanostation 5ac loco")
2929
assert sku == "Loco5AC"
3030

31+
def test_get_sku_by_devmodel_airos6_reversed_loco_m2(self):
32+
"""Test the reversed loco M2 model name reported by airOS 6."""
33+
sku = self.mapper.get_sku_by_devmodel("NanoStation loco M2 ")
34+
assert sku == "LocoM2"
35+
3136
def test_get_sku_by_devmodel_not_found_raises_keyerror(self):
3237
"""Test to raise KeyError when no match (exact or partial) is found."""
3338
with pytest.raises(

‎tests/test_stations6.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,14 @@ async def test_ap_object(
4444
# Create an async mock that can return different values for different calls
4545
mock_request_json = AsyncMock(
4646
side_effect=[
47-
{}, # First call for login()
48-
fixture_data, # Second call for status()
47+
fixture_data, # status()
4948
]
5049
)
5150

5251
with (
5352
# Patch the internal method, not the session object
5453
patch.object(airos6_device, "_request_json", new=mock_request_json),
54+
patch.object(airos6_device, "login", new=AsyncMock()),
5555
# You need to manually set the connected state since login() is mocked
5656
patch.object(airos6_device, "connected", True),
5757
):
@@ -70,7 +70,7 @@ async def test_ap_object(
7070

7171

7272
@pytest.mark.asyncio
73-
async def test_login_v6_flow() -> None:
73+
async def test_login_v6_flow(base_url: str) -> None:
7474
"""Test AirOS v6 XM login flow with manual cookie handling."""
7575

7676
# Create a mock session
@@ -93,7 +93,7 @@ async def test_login_v6_flow() -> None:
9393
get_index_response = MagicMock()
9494
get_index_response.__aenter__.return_value = get_index_response
9595
get_index_response.status = 200
96-
get_index_response.url = "http://192.168.1.3/index.cgi"
96+
get_index_response.url = f"{base_url}/index.cgi"
9797

9898
# Set side effects for session.request
9999
session.request.side_effect = [
@@ -104,16 +104,18 @@ async def test_login_v6_flow() -> None:
104104

105105
# Create device
106106
airos6_device = AirOS6(
107-
host="http://192.168.1.3",
107+
host=base_url,
108108
username="ubnt",
109109
password="ubnt",
110110
session=session,
111111
)
112112

113-
await airos6_device._login_v6() # noqa: SLF001
113+
await airos6_device.login()
114114

115115
# Assertions
116116
assert airos6_device.connected is True
117117
assert airos6_device.api_version == 6
118118
assert airos6_device._auth_cookie == "AIROS_ABC123=xyz789" # noqa: SLF001
119119
assert session.request.call_count == 3
120+
called_urls = [call.args[1] for call in session.request.call_args_list]
121+
assert f"{base_url}/api/auth" not in called_urls

0 commit comments

Comments
 (0)