From 3c07ec2370ff769f730b13117263e6274d319fa8 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 15:36:23 +0200 Subject: [PATCH 1/9] Async function --- airos/discovery.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/airos/discovery.py b/airos/discovery.py index 93d772a..8e7393c 100644 --- a/airos/discovery.py +++ b/airos/discovery.py @@ -284,7 +284,7 @@ async def async_discover_devices(timeout: int) -> dict[str, dict[str, Any]]: _LOGGER.debug("Starting AirOS device discovery for %s seconds", timeout) discovered_devices: dict[str, dict[str, Any]] = {} - def _async_airos_device_found(device_info: dict[str, Any]) -> None: + async def _async_airos_device_found(device_info: dict[str, Any]) -> None: """Handle discovered device.""" mac_address = device_info.get("mac_address") if mac_address: @@ -321,5 +321,4 @@ def _async_airos_device_found(device_info: dict[str, Any]) -> None: _LOGGER.exception("An unexpected error occurred during discovery") raise AirOSListenerError("cannot_connect") from err - _LOGGER.debug("Discovery completed. Found %s devices.", len(discovered_devices)) return discovered_devices From f50513951a0ecab8b940508d5e9f302e251ad299 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 15:36:57 +0200 Subject: [PATCH 2/9] Async function --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5ae4eb1..5a2d574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.2" +version = "0.2.3a0" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" From b84f84dd21c974d2b20230a48cc645409d3418d3 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 21:29:15 +0200 Subject: [PATCH 3/9] Add changelog --- .markdownlint.yaml | 1 + CHANGELOG.md | 162 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 CHANGELOG.md diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 320406d..e01484b 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -1,2 +1,3 @@ default: true MD013: false +MD024: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ab4d1cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,162 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.2] - 2025-08-02 + +### Changed + +- Fixed callback function to async +- Added a method to control provisioning mode for AirOS devices. +- Introduced a high-level asynchronous device discovery function for AirOS devices. +- Standardized class, exception, and log naming from "Airos" to "AirOS" across the codebase. +- Renamed enum members in WirelessMode for improved clarity. +- Updated tests and fixtures to use new naming conventions and to cover new discovery functionality. + +## [0.2.1] - 2025-08-02 + +### Added + +- Added a new field to device status data showing the MAC address and interface name of the primary enabled interface. + +### Changed + +- Updated wireless fixture data to reflect the correct access point MAC address. + +## [0.2.0] - 2025-07-28 + +### Added + +- Added UDP-based discovery for Ubiquiti airOS devices, enabling automatic detection and information retrieval from devices on the network. +- Introduced detailed error handling and new exception types for discovery-related issues. +- Improved code consistency by standardizing logger variable naming. +- Added a script to generate mock discovery packet fixtures for testing. +- Introduced comprehensive tests for the new device discovery functionality. + +## [0.1.8] - 2025-07-28 + +### Added + +- Improved device connection status reporting with clearer distinction between connected and disconnected devices. +- Enhanced status information for UNMS connectivity. +- Clarified descriptions for connected and disconnected device states. + +## [0.1.7] - 2025-07-27 + +### Changed + +- Improved login error handling by providing a clear error message when authentication is denied. + +## [0.1.6] - 2025-07-26 + +### Changed + +- Renamed the AirOS data class to clarify its association with AirOS v8 devices. +- Updated documentation to specify support for AirOS v8 devices. +- Adjusted import statements to reflect the class renaming. + +## [0.1.5] - 2025-07-23 + +### Changed + +- Improved handling of unknown or invalid enum values in device data by logging and removing them during data processing, reducing the chance of errors. +- Streamlined warning logging for device status, ensuring warnings are logged immediately rather than being cached. +- Simplified internal data handling and validation logic for device configuration fields. + +## [0.1.4] - 2025-07-22 + +### Changed + +- Improved warning handling to ensure each unique warning is only logged once per session. +- Added support for a new wireless mode labeled "AUTO". +- Enhanced warning messages to prompt users to report unknown remote wireless modes. + +## [0.1.3] - 2025-07-22 + +### Changed + +- Updated device status retrieval to always return structured data instead of raw JSON. + +### Removed + +- Dropped JSON output +- Removed a redundant test related to JSON status output. + +## [0.1.2] - 2025-07-22 + +### Added + +- Introduced a comprehensive and strongly typed data model for AirOS device data, enabling structured parsing and validation. +- The device status method now supports returning either a structured object or raw JSON, with improved warning handling for unknown values. +- Updated the README to include an example that prints the wireless mode from the device status. +- Added new test to verify device status retrieval returns structured data objects alongside existing JSON-based tests. + +### Changed + +- Updated dependencies to include mashumaro and removed asyncio. +- Bumped project version to 0.1.2. +- Changed output/returns from JSON to mashumaro (tnx @joostlek) + +## [0.1.1] - 2025-07-21 + +### Added + +- Error/exception handling and raising + +## [0.1.0] - 2025-07-20 + +### Changed + +- Improve station reconnect + +## [0.0.9] - 2025-07-19 + +### Added + +- Add tests +- Add station reconnect (`stakick`) + +## [0.0.8] - 2025-07-16 + +### Changed + +- Reworked exceptions + +## [0.0.7] - 2025-07-16 + +### Changed + +- Adjust function returns + +## [0.0.6] - 2025-07-16 + +### Added + +- Revert setting verify_ssl, leaving it up to the ingestor to set session + +## [0.0.5] - 2025-07-15 + +### Add + +- Add basic testing +- Add renovate for chores + +## [0.0.4] - 2025-07-13 + +### Added + +- Improve session handling and ssl, bump version +- Add `pre-commit`, prep `uv` +- Add more actions and pypi publishing +- Switch pypi publishing to Trusted Publishing +- Ensure environment and permissions improving publishing +- Actions and pypi + +## [0.0.1] - 2025-07-13 + +### Added + +- Initial commits From e29e1fc0bc0c4af2605f80044b0d7858e2b581ae Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 21:44:01 +0200 Subject: [PATCH 4/9] Back to 85 --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ade99eb..f8e785a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -151,7 +151,7 @@ jobs: run: | . venv/bin/activate coverage combine coverage*/.coverage* - coverage report --fail-under=80 + coverage report --fail-under=85 coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 From 9e03d84eb2c75e7d8ce7277ca4c6c1b472129afd Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 21:46:49 +0200 Subject: [PATCH 5/9] Add tests and correct changelog --- CHANGELOG.md | 8 +- airos/airos8.py | 4 +- tests/test_airos8.py | 226 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 tests/test_airos8.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4d1cd..79ad32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.2.2] - 2025-08-02 +## [0.2.3] - 2025-08-02 ### Changed - Fixed callback function to async +- Added changelog + +## [0.2.2] - 2025-08-02 + +### Changed + - Added a method to control provisioning mode for AirOS devices. - Introduced a high-level asynchronous device discovery function for AirOS devices. - Standardized class, exception, and log naming from "Airos" to "AirOS" across the codebase. diff --git a/airos/airos8.py b/airos/airos8.py index a8915e0..7c2f6a7 100644 --- a/airos/airos8.py +++ b/airos/airos8.py @@ -240,9 +240,10 @@ async def status(self) -> AirOSData: self._status_cgi_url, headers=authenticated_get_headers, ) as response: + response_text = await response.text() + if response.status == 200: try: - response_text = await response.text() response_json = json.loads(response_text) try: adjusted_json = self.derived_data(response_json) @@ -260,6 +261,7 @@ async def status(self) -> AirOSData: else: log = f"Authenticated status.cgi failed: {response.status}. Response: {response_text}" _LOGGER.error(log) + raise AirOSDeviceConnectionError from None except ( aiohttp.ClientError, aiohttp.client_exceptions.ConnectionTimeoutError, diff --git a/tests/test_airos8.py b/tests/test_airos8.py new file mode 100644 index 0000000..986f155 --- /dev/null +++ b/tests/test_airos8.py @@ -0,0 +1,226 @@ +"""Additional tests for airos8 module.""" + +from http.cookies import SimpleCookie +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import airos.exceptions +import pytest + +import aiohttp + + +# --- Tests for Login and Connection Errors --- +@pytest.mark.asyncio +async def test_login_no_csrf_token(airos_device): + """Test login response without a CSRF token header.""" + cookie = SimpleCookie() + cookie["AIROS_TOKEN"] = "abc" + + mock_login_response = MagicMock() + mock_login_response.__aenter__.return_value = mock_login_response + mock_login_response.text = AsyncMock(return_value="{}") + mock_login_response.status = 200 + mock_login_response.cookies = cookie # Use the SimpleCookie object + mock_login_response.headers = {} # Simulate missing X-CSRF-ID + + with patch.object(airos_device.session, "post", return_value=mock_login_response): + # We expect a return of None as the CSRF token is missing + result = await airos_device.login() + assert result is None + + +@pytest.mark.asyncio +async def test_login_connection_error(airos_device): + """Test aiohttp ClientError during login attempt.""" + with ( + patch.object(airos_device.session, "post", side_effect=aiohttp.ClientError), + pytest.raises(airos.exceptions.AirOSDeviceConnectionError), + ): + await airos_device.login() + + +# --- Tests for status() and derived_data() logic --- +@pytest.mark.asyncio +async def test_status_when_not_connected(airos_device): + """Test calling status() before a successful login.""" + airos_device.connected = False # Ensure connected state is false + with pytest.raises(airos.exceptions.AirOSDeviceConnectionError): + await airos_device.status() + + +@pytest.mark.asyncio +async def test_status_non_200_response(airos_device): + """Test status() with a non-successful HTTP response.""" + airos_device.connected = True + mock_status_response = MagicMock() + mock_status_response.__aenter__.return_value = mock_status_response + mock_status_response.text = AsyncMock(return_value="Error") + mock_status_response.status = 500 # Simulate server error + + with ( + patch.object(airos_device.session, "get", return_value=mock_status_response), + pytest.raises(airos.exceptions.AirOSDeviceConnectionError), + ): + await airos_device.status() + + +@pytest.mark.asyncio +async def test_status_invalid_json_response(airos_device): + """Test status() with a response that is not valid JSON.""" + airos_device.connected = True + mock_status_response = MagicMock() + mock_status_response.__aenter__.return_value = mock_status_response + mock_status_response.text = AsyncMock(return_value="This is not JSON") + mock_status_response.status = 200 + + with ( + patch.object(airos_device.session, "get", return_value=mock_status_response), + pytest.raises(airos.exceptions.AirOSDataMissingError), + ): + await airos_device.status() + + +@pytest.mark.asyncio +async def test_status_missing_interface_key_data(airos_device): + """Test status() with a response missing critical data fields.""" + airos_device.connected = True + # The derived_data() function is called with a mocked response + mock_status_response = MagicMock() + mock_status_response.__aenter__.return_value = mock_status_response + mock_status_response.text = AsyncMock( + return_value=json.dumps({"system": {}}) + ) # Missing 'interfaces' + mock_status_response.status = 200 + + with ( + patch.object(airos_device.session, "get", return_value=mock_status_response), + pytest.raises(airos.exceptions.AirOSKeyDataMissingError), + ): + await airos_device.status() + + +@pytest.mark.asyncio +async def test_derived_data_no_interfaces_key(airos_device): + """Test derived_data() with a response that has no 'interfaces' key.""" + # This will directly test the 'if not interfaces:' branch (line 206) + with pytest.raises(airos.exceptions.AirOSKeyDataMissingError): + airos_device.derived_data({}) + + +@pytest.mark.asyncio +async def test_derived_data_no_br0_eth0_ath0(airos_device): + """Test derived_data() with an unexpected interface list, to test the fallback logic.""" + fixture_data = { + "interfaces": [ + {"ifname": "wan0", "enabled": True, "hwaddr": "11:22:33:44:55:66"} + ] + } + + adjusted_data = airos_device.derived_data(fixture_data) + assert adjusted_data["derived"]["mac_interface"] == "wan0" + assert adjusted_data["derived"]["mac"] == "11:22:33:44:55:66" + + +# --- Tests for stakick() --- +@pytest.mark.asyncio +async def test_stakick_when_not_connected(airos_device): + """Test stakick() before a successful login.""" + airos_device.connected = False + with pytest.raises(airos.exceptions.AirOSDeviceConnectionError): + await airos_device.stakick("01:23:45:67:89:aB") + + +@pytest.mark.asyncio +async def test_stakick_no_mac_address(airos_device): + """Test stakick() with a None mac_address.""" + airos_device.connected = True + with pytest.raises(airos.exceptions.AirOSDataMissingError): + await airos_device.stakick(None) + + +@pytest.mark.asyncio +async def test_stakick_non_200_response(airos_device): + """Test stakick() with a non-successful HTTP response.""" + airos_device.connected = True + mock_stakick_response = MagicMock() + mock_stakick_response.__aenter__.return_value = mock_stakick_response + mock_stakick_response.text = AsyncMock(return_value="Error") + mock_stakick_response.status = 500 + + with patch.object(airos_device.session, "post", return_value=mock_stakick_response): + assert not await airos_device.stakick("01:23:45:67:89:aB") + + +@pytest.mark.asyncio +async def test_stakick_connection_error(airos_device): + """Test aiohttp ClientError during stakick.""" + airos_device.connected = True + with ( + patch.object(airos_device.session, "post", side_effect=aiohttp.ClientError), + pytest.raises(airos.exceptions.AirOSDeviceConnectionError), + ): + await airos_device.stakick("01:23:45:67:89:aB") + + +# --- Tests for provmode() (Complete Coverage) --- +@pytest.mark.asyncio +async def test_provmode_when_not_connected(airos_device): + """Test provmode() before a successful login.""" + airos_device.connected = False + with pytest.raises(airos.exceptions.AirOSDeviceConnectionError): + await airos_device.provmode(active=True) + + +@pytest.mark.asyncio +async def test_provmode_activate_success(airos_device): + """Test successful activation of provisioning mode.""" + airos_device.connected = True + mock_provmode_response = MagicMock() + mock_provmode_response.__aenter__.return_value = mock_provmode_response + mock_provmode_response.status = 200 + + with patch.object( + airos_device.session, "post", return_value=mock_provmode_response + ): + assert await airos_device.provmode(active=True) + + +@pytest.mark.asyncio +async def test_provmode_deactivate_success(airos_device): + """Test successful deactivation of provisioning mode.""" + airos_device.connected = True + mock_provmode_response = MagicMock() + mock_provmode_response.__aenter__.return_value = mock_provmode_response + mock_provmode_response.status = 200 + + with patch.object( + airos_device.session, "post", return_value=mock_provmode_response + ): + assert await airos_device.provmode(active=False) + + +@pytest.mark.asyncio +async def test_provmode_non_200_response(airos_device): + """Test provmode() with a non-successful HTTP response.""" + airos_device.connected = True + mock_provmode_response = MagicMock() + mock_provmode_response.__aenter__.return_value = mock_provmode_response + mock_provmode_response.text = AsyncMock(return_value="Error") + mock_provmode_response.status = 500 + + with patch.object( + airos_device.session, "post", return_value=mock_provmode_response + ): + assert not await airos_device.provmode(active=True) + + +@pytest.mark.asyncio +async def test_provmode_connection_error(airos_device): + """Test aiohttp ClientError during provmode.""" + airos_device.connected = True + with ( + patch.object(airos_device.session, "post", side_effect=aiohttp.ClientError), + pytest.raises(airos.exceptions.AirOSDeviceConnectionError), + ): + await airos_device.provmode(active=True) From 8c6ac879cf6f8ee411ebd963f0c2aea0917e5a83 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 21:49:16 +0200 Subject: [PATCH 6/9] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5a2d574..b479282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.3a0" +version = "0.2.3a1" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" From 699aceae3dcfed892d88acbf397893edffaae552 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 22:22:14 +0200 Subject: [PATCH 7/9] Update changelog --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79ad32f..11a9d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,6 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [0.2.3] - 2025-08-02 ### Changed From d98975015aaad42d42dbab72a38025ded7f383e6 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 22:22:45 +0200 Subject: [PATCH 8/9] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b479282..d6a0181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "airos" -version = "0.2.3a1" +version = "0.2.3" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" From 869b27e48f4f44b348e46b2bbdfe6fee40762ae0 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sat, 2 Aug 2025 22:50:54 +0200 Subject: [PATCH 9/9] Add coverage with codecov --- .github/workflows/verify.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index f8e785a..3794cdd 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -157,6 +157,7 @@ jobs: uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + slug: CoMPaTech/python-airos test-publishing: name: Build and publish Python 🐍 distributions 📦 to TestPyPI