diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ade99eb..3794cdd 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -151,12 +151,13 @@ 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 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + slug: CoMPaTech/python-airos test-publishing: name: Build and publish Python 🐍 distributions 📦 to TestPyPI 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..11a9d71 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [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. +- 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 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/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 diff --git a/pyproject.toml b/pyproject.toml index 5ae4eb1..d6a0181 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.3" license = "MIT" description = "Ubiquity airOS module(s) for Python 3." readme = "README.md" 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)