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.
- jnap-0.1.0/.github/dependabot.yml +11 -0
- jnap-0.1.0/.github/workflows/ci.yml +27 -0
- jnap-0.1.0/.github/workflows/publish.yml +34 -0
- jnap-0.1.0/.gitignore +10 -0
- jnap-0.1.0/.idea/.gitignore +10 -0
- jnap-0.1.0/.idea/inspectionProfiles/Project_Default.xml +6 -0
- jnap-0.1.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- jnap-0.1.0/.idea/jnap.iml +19 -0
- jnap-0.1.0/.idea/misc.xml +10 -0
- jnap-0.1.0/.idea/modules.xml +8 -0
- jnap-0.1.0/.idea/pyLspTools.xml +6 -0
- jnap-0.1.0/.idea/pyProjectModel.xml +7 -0
- jnap-0.1.0/.idea/vcs.xml +12 -0
- jnap-0.1.0/.python-version +1 -0
- jnap-0.1.0/LICENSE +21 -0
- jnap-0.1.0/PKG-INFO +93 -0
- jnap-0.1.0/README.md +68 -0
- jnap-0.1.0/pyproject.toml +53 -0
- jnap-0.1.0/src/jnap/__init__.py +29 -0
- jnap-0.1.0/src/jnap/client.py +88 -0
- jnap-0.1.0/src/jnap/exceptions.py +17 -0
- jnap-0.1.0/src/jnap/models.py +93 -0
- jnap-0.1.0/src/jnap/py.typed +0 -0
- jnap-0.1.0/tests/__init__.py +0 -0
- jnap-0.1.0/tests/test_client.py +290 -0
- jnap-0.1.0/tests/test_models.py +126 -0
- jnap-0.1.0/uv.lock +642 -0
|
@@ -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,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>
|
jnap-0.1.0/.idea/vcs.xml
ADDED
|
@@ -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
|