jnap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+
8
+ - package-ecosystem: github-actions
9
+ directory: /
10
+ schedule:
11
+ interval: weekly
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ matrix:
17
+ python-version: ["3.12", "3.13", "3.14"]
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: astral-sh/setup-uv@v7
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ enable-cache: true
24
+ - run: uv sync
25
+ - run: uv run pytest
26
+ - run: uv run ruff check
27
+ - run: uv run ruff format --check
@@ -0,0 +1,34 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v7
17
+ - run: uv build
18
+ - uses: actions/upload-artifact@v7
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ permissions:
28
+ id-token: write
29
+ steps:
30
+ - uses: actions/download-artifact@v8
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
jnap-0.1.0/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1,10 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Ignored default folder with query files
7
+ /queries/
8
+ # Datasource local storage ignored files
9
+ /dataSources/
10
+ /dataSources.local.xml
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
5
+ </profile>
6
+ </component>
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
6
+ <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
7
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
8
+ </content>
9
+ <orderEntry type="jdk" jdkName="uv (jnap)" jdkType="Python SDK" />
10
+ <orderEntry type="sourceFolder" forTests="false" />
11
+ </component>
12
+ <component name="PyDocumentationSettings">
13
+ <option name="format" value="PLAIN" />
14
+ <option name="myDocStringFormat" value="Plain" />
15
+ </component>
16
+ <component name="TestRunnerService">
17
+ <option name="PROJECT_TEST_RUNNER" value="py.test" />
18
+ </component>
19
+ </module>
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="uv (jnap)" />
5
+ </component>
6
+ <component name="KubernetesApiPersistence">{}</component>
7
+ <component name="KubernetesApiProvider"><![CDATA[{
8
+ "isMigrated": true
9
+ }]]></component>
10
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/jnap.iml" filepath="$PROJECT_DIR$/.idea/jnap.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="RuffConfiguration">
4
+ <option name="enabled" value="true" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="PyProjectModelSettings">
4
+ <option name="showConfigurationNotification" value="false" />
5
+ <option name="usePyprojectToml" value="true" />
6
+ </component>
7
+ </project>
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="GitSharedSettings">
4
+ <option name="FORCE_PUSH_PROHIBITED_PATTERNS">
5
+ <list />
6
+ </option>
7
+ <option name="synchronizeBranchProtectionRules" value="false" />
8
+ </component>
9
+ <component name="VcsDirectoryMappings">
10
+ <mapping directory="" vcs="Git" />
11
+ </component>
12
+ </project>
@@ -0,0 +1 @@
1
+ 3.14
jnap-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Justin Malčić
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jnap-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: jnap
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Linksys JNAP API
5
+ Project-URL: Repository, https://github.com/jmalcic/jnap
6
+ Project-URL: Bug Tracker, https://github.com/jmalcic/jnap/issues
7
+ Author-email: Justin Malčić <j.malcic@me.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: aiohttp,async,home-automation,jnap,linksys,router
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: aiohttp>=3.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ # jnap
27
+
28
+ Async Python client for the [Linksys JNAP API](http://linksys.com/jnap/).
29
+
30
+ ## Installation
31
+
32
+ ```
33
+ pip install jnap
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ import asyncio
40
+ import aiohttp
41
+ from jnap import JNAPClient
42
+
43
+ async def main():
44
+ async with aiohttp.ClientSession() as session:
45
+ client = JNAPClient("192.168.1.1", session, password="your-password")
46
+
47
+ info = await client.get_device_info()
48
+ print(info.description, info.serial_number)
49
+
50
+ response = await client.get_devices()
51
+ for device in response.devices:
52
+ print(device.name, device.mac, device.ip_address)
53
+
54
+ asyncio.run(main())
55
+ ```
56
+
57
+ ## API
58
+
59
+ ### `JNAPClient(host, session, password, *, username="admin")`
60
+
61
+ The main client class. Accepts an existing `aiohttp.ClientSession` so you can manage connection pooling and lifecycle yourself.
62
+
63
+ #### Methods
64
+
65
+ - `await client.get_device_info()` → `GetDeviceInfoResponse`
66
+ Returns the router's description and serial number.
67
+
68
+ - `await client.get_devices()` → `GetDevicesResponse`
69
+ Returns all devices currently connected to the router. Only devices with a known MAC address and an active connection are included.
70
+
71
+ ### Models
72
+
73
+ | Class | Fields |
74
+ |---|---|
75
+ | `GetDeviceInfoResponse` | `description`, `serial_number` |
76
+ | `GetDevicesResponse` | `devices: list[JNAPDevice]` |
77
+ | `JNAPDevice` | `mac`, `name`, `ip_address`, `hostname` |
78
+
79
+ Device names are resolved in priority order: user-assigned name → friendly name → device ID.
80
+
81
+ ### Exceptions
82
+
83
+ All exceptions inherit from `JNAPError`.
84
+
85
+ | Exception | Raised when |
86
+ |---|---|
87
+ | `JNAPUnauthorizedError` | Authentication failed |
88
+ | `JNAPUnknownActionError` | The device does not recognise the action |
89
+ | `JNAPInvalidInputError` | The request was rejected as invalid |
90
+
91
+ ## License
92
+
93
+ MIT
jnap-0.1.0/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # jnap
2
+
3
+ Async Python client for the [Linksys JNAP API](http://linksys.com/jnap/).
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ pip install jnap
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import asyncio
15
+ import aiohttp
16
+ from jnap import JNAPClient
17
+
18
+ async def main():
19
+ async with aiohttp.ClientSession() as session:
20
+ client = JNAPClient("192.168.1.1", session, password="your-password")
21
+
22
+ info = await client.get_device_info()
23
+ print(info.description, info.serial_number)
24
+
25
+ response = await client.get_devices()
26
+ for device in response.devices:
27
+ print(device.name, device.mac, device.ip_address)
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ ## API
33
+
34
+ ### `JNAPClient(host, session, password, *, username="admin")`
35
+
36
+ The main client class. Accepts an existing `aiohttp.ClientSession` so you can manage connection pooling and lifecycle yourself.
37
+
38
+ #### Methods
39
+
40
+ - `await client.get_device_info()` → `GetDeviceInfoResponse`
41
+ Returns the router's description and serial number.
42
+
43
+ - `await client.get_devices()` → `GetDevicesResponse`
44
+ Returns all devices currently connected to the router. Only devices with a known MAC address and an active connection are included.
45
+
46
+ ### Models
47
+
48
+ | Class | Fields |
49
+ |---|---|
50
+ | `GetDeviceInfoResponse` | `description`, `serial_number` |
51
+ | `GetDevicesResponse` | `devices: list[JNAPDevice]` |
52
+ | `JNAPDevice` | `mac`, `name`, `ip_address`, `hostname` |
53
+
54
+ Device names are resolved in priority order: user-assigned name → friendly name → device ID.
55
+
56
+ ### Exceptions
57
+
58
+ All exceptions inherit from `JNAPError`.
59
+
60
+ | Exception | Raised when |
61
+ |---|---|
62
+ | `JNAPUnauthorizedError` | Authentication failed |
63
+ | `JNAPUnknownActionError` | The device does not recognise the action |
64
+ | `JNAPInvalidInputError` | The request was rejected as invalid |
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "jnap"
3
+ version = "0.1.0"
4
+ description = "Async Python client for the Linksys JNAP API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Justin Malčić", email = "j.malcic@me.com" }
9
+ ]
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "aiohttp>=3.9",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Home Automation",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Typing :: Typed",
26
+ ]
27
+ keywords = ["linksys", "jnap", "router", "async", "home-automation", "aiohttp"]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/jmalcic/jnap"
31
+ "Bug Tracker" = "https://github.com/jmalcic/jnap/issues"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest>=8",
40
+ "pytest-asyncio>=0.24",
41
+ "aioresponses>=0.7",
42
+ "aiohttp>=3.9,<3.14", # aioresponses 0.7.x is not yet compatible with aiohttp 3.14
43
+ "ruff>=0.8",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+
49
+ [tool.ruff]
50
+ target-version = "py312"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "I", "UP", "ANN"]
@@ -0,0 +1,29 @@
1
+ """Async Python client for the Linksys JNAP API."""
2
+
3
+ from .client import JNAPClient
4
+ from .exceptions import (
5
+ JNAPError,
6
+ JNAPInvalidInputError,
7
+ JNAPUnauthorizedError,
8
+ JNAPUnknownActionError,
9
+ )
10
+ from .models import (
11
+ GetDeviceInfoResponse,
12
+ GetDevicesResponse,
13
+ JNAPAction,
14
+ JNAPDevice,
15
+ JNAPRequest,
16
+ )
17
+
18
+ __all__ = [
19
+ "GetDeviceInfoResponse",
20
+ "GetDevicesResponse",
21
+ "JNAPAction",
22
+ "JNAPClient",
23
+ "JNAPDevice",
24
+ "JNAPError",
25
+ "JNAPInvalidInputError",
26
+ "JNAPRequest",
27
+ "JNAPUnauthorizedError",
28
+ "JNAPUnknownActionError",
29
+ ]
@@ -0,0 +1,88 @@
1
+ """JNAP HTTP client."""
2
+
3
+ from base64 import b64encode
4
+ from dataclasses import asdict
5
+
6
+ import aiohttp
7
+
8
+ from .exceptions import (
9
+ JNAPError,
10
+ JNAPInvalidInputError,
11
+ JNAPUnauthorizedError,
12
+ JNAPUnknownActionError,
13
+ )
14
+ from .models import GetDeviceInfoResponse, GetDevicesResponse, JNAPAction, JNAPRequest
15
+
16
+
17
+ class JNAPClient:
18
+ """Async client for the Linksys JNAP API."""
19
+
20
+ DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=10)
21
+ _JNAP_HEADER = "X-JNAP-Action"
22
+ _JNAP_AUTH_HEADER = "X-JNAP-Authorization"
23
+ _JNAP_PATH = "/JNAP/"
24
+ _DEFAULT_USERNAME = "admin"
25
+ _JNAP_ERRORS: dict[str, type[JNAPError]] = {
26
+ "_ErrorUnknownAction": JNAPUnknownActionError,
27
+ "_ErrorUnauthorized": JNAPUnauthorizedError,
28
+ "_ErrorInvalidInput": JNAPInvalidInputError,
29
+ }
30
+
31
+ def __init__(
32
+ self,
33
+ host: str,
34
+ session: aiohttp.ClientSession,
35
+ password: str,
36
+ *,
37
+ username: str = _DEFAULT_USERNAME,
38
+ ) -> None:
39
+ """Initialise the client."""
40
+ self._url = f"http://{host}{self._JNAP_PATH}"
41
+ self._session = session
42
+ credentials = b64encode(f"{username}:{password}".encode()).decode()
43
+ self._auth_header = f"Basic {credentials}"
44
+
45
+ async def get_device_info(self) -> GetDeviceInfoResponse:
46
+ """Return information about the router."""
47
+ responses = await self._transaction(
48
+ [JNAPRequest(action=JNAPAction.GET_DEVICE_INFO)]
49
+ )
50
+ try:
51
+ return GetDeviceInfoResponse.from_dict(responses[0])
52
+ except (KeyError, IndexError) as err:
53
+ raise JNAPError("Unexpected response structure") from err
54
+
55
+ async def get_devices(self) -> GetDevicesResponse:
56
+ """Return all devices currently connected to the router."""
57
+ responses = await self._transaction(
58
+ [JNAPRequest(action=JNAPAction.GET_DEVICES, request={"sinceRevision": 0})]
59
+ )
60
+ try:
61
+ return GetDevicesResponse.from_dict(responses[0])
62
+ except (KeyError, IndexError) as err:
63
+ raise JNAPError("Unexpected response structure") from err
64
+
65
+ async def _transaction(self, actions: list[JNAPRequest]) -> list[dict]:
66
+ """POST a JNAP transaction and return the responses list."""
67
+ try:
68
+ async with self._session.post(
69
+ self._url,
70
+ headers={
71
+ self._JNAP_HEADER: JNAPAction.TRANSACTION,
72
+ self._JNAP_AUTH_HEADER: self._auth_header,
73
+ },
74
+ json=[asdict(a) for a in actions],
75
+ timeout=self.DEFAULT_TIMEOUT,
76
+ ) as response:
77
+ response.raise_for_status()
78
+ data = await response.json()
79
+ except aiohttp.ClientError as err:
80
+ raise JNAPError("Communication error") from err
81
+ result = data.get("result")
82
+ if error_cls := self._JNAP_ERRORS.get(result):
83
+ raise error_cls
84
+ if result == "Error":
85
+ response_result = data.get("responses", [{}])[0].get("result")
86
+ if error_cls := self._JNAP_ERRORS.get(response_result):
87
+ raise error_cls
88
+ return data["responses"]
@@ -0,0 +1,17 @@
1
+ """Exceptions for the JNAP client."""
2
+
3
+
4
+ class JNAPError(Exception):
5
+ """Raised when the JNAP API returns an unexpected response."""
6
+
7
+
8
+ class JNAPUnknownActionError(JNAPError):
9
+ """Raised when the JNAP action is not recognised by the device."""
10
+
11
+
12
+ class JNAPUnauthorizedError(JNAPError):
13
+ """Raised when JNAP authentication fails."""
14
+
15
+
16
+ class JNAPInvalidInputError(JNAPError):
17
+ """Raised when JNAP request input is rejected by the device."""
@@ -0,0 +1,93 @@
1
+ """Data models for the JNAP API."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+
6
+
7
+ class JNAPAction(StrEnum):
8
+ """JNAP action URIs."""
9
+
10
+ _JNAP_BASE_URL = "http://linksys.com/jnap/"
11
+
12
+ TRANSACTION = f"{_JNAP_BASE_URL}core/Transaction"
13
+ GET_DEVICE_INFO = f"{_JNAP_BASE_URL}core/GetDeviceInfo"
14
+ GET_DEVICES = f"{_JNAP_BASE_URL}devicelist/GetDevices"
15
+
16
+
17
+ @dataclass
18
+ class JNAPRequest:
19
+ """A single action within a JNAP transaction."""
20
+
21
+ action: JNAPAction
22
+ request: dict = field(default_factory=dict)
23
+
24
+
25
+ @dataclass
26
+ class JNAPDevice:
27
+ """A device seen by the router."""
28
+
29
+ mac: str
30
+ name: str
31
+ ip_address: str | None = None
32
+ hostname: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class GetDeviceInfoResponse:
37
+ """Parsed response from core/GetDeviceInfo."""
38
+
39
+ description: str
40
+ serial_number: str
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict) -> "GetDeviceInfoResponse":
44
+ """Parse a JNAP response dict."""
45
+ output = data["output"]
46
+ return cls(
47
+ description=output["description"], serial_number=output["serialNumber"]
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class GetDevicesResponse:
53
+ """Parsed response from devicelist/GetDevices."""
54
+
55
+ _KNOWN_MACS = "knownMACAddresses"
56
+ _CONNECTIONS = "connections"
57
+ _PROPERTIES = "properties"
58
+ _FRIENDLY_NAME = "friendlyName"
59
+ _DEVICE_ID = "deviceID"
60
+ _USER_DEVICE_NAME = "userDeviceName"
61
+ _IP_ADDRESS = "ipAddress"
62
+
63
+ devices: list[JNAPDevice]
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict) -> "GetDevicesResponse":
67
+ """Parse a JNAP response dict."""
68
+ devices = []
69
+ for item in data["output"]["devices"]:
70
+ if not (macs := item.get(cls._KNOWN_MACS)):
71
+ continue
72
+ connections = item.get(cls._CONNECTIONS, [])
73
+ if not connections:
74
+ continue
75
+ mac = macs[-1]
76
+ hostname = item.get(cls._FRIENDLY_NAME)
77
+ name = (
78
+ next(
79
+ (
80
+ p["value"]
81
+ for p in item.get(cls._PROPERTIES, [])
82
+ if p["name"] == cls._USER_DEVICE_NAME
83
+ ),
84
+ None,
85
+ )
86
+ or hostname
87
+ or item[cls._DEVICE_ID]
88
+ )
89
+ ip_address = connections[0].get(cls._IP_ADDRESS)
90
+ devices.append(
91
+ JNAPDevice(mac=mac, name=name, ip_address=ip_address, hostname=hostname)
92
+ )
93
+ return cls(devices=devices)
File without changes
File without changes