axis 66__tar.gz → 67__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.
- {axis-66 → axis-67}/PKG-INFO +10 -8
- {axis-66 → axis-67}/axis/__main__.py +49 -11
- {axis-66 → axis-67}/axis/device.py +5 -1
- {axis-66 → axis-67}/axis/interfaces/api_handler.py +2 -2
- {axis-66 → axis-67}/axis/interfaces/parameters/param_cgi.py +2 -2
- {axis-66 → axis-67}/axis/interfaces/parameters/param_handler.py +3 -2
- {axis-66 → axis-67}/axis/interfaces/vapix.py +234 -14
- {axis-66 → axis-67}/axis/models/api_discovery.py +2 -2
- axis-67/axis/models/configuration.py +87 -0
- {axis-66 → axis-67}/axis/models/event.py +18 -19
- {axis-66 → axis-67}/axis/models/event_instance.py +1 -1
- {axis-66 → axis-67}/axis/models/parameters/io_port.py +2 -2
- {axis-66 → axis-67}/axis/models/parameters/param_cgi.py +1 -1
- {axis-66 → axis-67}/axis/models/view_area.py +2 -2
- {axis-66 → axis-67}/axis/rtsp.py +4 -2
- {axis-66 → axis-67}/axis/stream_manager.py +1 -1
- {axis-66 → axis-67}/axis.egg-info/PKG-INFO +10 -8
- {axis-66 → axis-67}/axis.egg-info/SOURCES.txt +3 -0
- {axis-66 → axis-67}/axis.egg-info/requires.txt +7 -5
- {axis-66 → axis-67}/pyproject.toml +16 -11
- {axis-66 → axis-67}/tests/test_api_discovery.py +5 -2
- axis-67/tests/test_auth_scheme.py +86 -0
- {axis-66 → axis-67}/tests/test_basic_device_info.py +4 -2
- axis-67/tests/test_configuration.py +149 -0
- {axis-66 → axis-67}/tests/test_event_instances.py +5 -1
- {axis-66 → axis-67}/tests/test_event_stream.py +5 -2
- axis-67/tests/test_http_client_compat.py +165 -0
- {axis-66 → axis-67}/tests/test_light_control.py +5 -2
- axis-67/tests/test_main_http_client.py +22 -0
- {axis-66 → axis-67}/tests/test_mqtt.py +4 -1
- {axis-66 → axis-67}/tests/test_pir_sensor_configuration.py +4 -2
- {axis-66 → axis-67}/tests/test_port_cgi.py +5 -1
- {axis-66 → axis-67}/tests/test_ptz.py +5 -2
- {axis-66 → axis-67}/tests/test_pwdgrp_cgi.py +4 -1
- {axis-66 → axis-67}/tests/test_stream_profiles.py +4 -2
- {axis-66 → axis-67}/tests/test_vapix.py +6 -2
- {axis-66 → axis-67}/tests/test_view_areas.py +4 -1
- axis-66/axis/models/configuration.py +0 -25
- axis-66/tests/test_configuration.py +0 -51
- {axis-66 → axis-67}/LICENSE +0 -0
- {axis-66 → axis-67}/README.md +0 -0
- {axis-66 → axis-67}/axis/__init__.py +0 -0
- {axis-66 → axis-67}/axis/errors.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/__init__.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/api_discovery.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/__init__.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/application_handler.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/applications.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/fence_guard.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/loitering_guard.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/motion_guard.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/object_analytics.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/applications/vmd4.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/basic_device_info.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/event_instances.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/event_manager.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/light_control.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/mqtt.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/__init__.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/brand.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/image.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/io_port.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/properties.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/ptz.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/parameters/stream_profile.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/pir_sensor_configuration.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/port_cgi.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/port_management.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/ptz.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/pwdgrp_cgi.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/stream_profiles.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/user_groups.py +0 -0
- {axis-66 → axis-67}/axis/interfaces/view_areas.py +0 -0
- {axis-66 → axis-67}/axis/models/__init__.py +0 -0
- {axis-66 → axis-67}/axis/models/api.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/__init__.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/application.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/fence_guard.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/loitering_guard.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/motion_guard.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/object_analytics.py +0 -0
- {axis-66 → axis-67}/axis/models/applications/vmd4.py +0 -0
- {axis-66 → axis-67}/axis/models/basic_device_info.py +0 -0
- {axis-66 → axis-67}/axis/models/light_control.py +0 -0
- {axis-66 → axis-67}/axis/models/mqtt.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/__init__.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/brand.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/image.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/properties.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/ptz.py +0 -0
- {axis-66 → axis-67}/axis/models/parameters/stream_profile.py +0 -0
- {axis-66 → axis-67}/axis/models/pir_sensor_configuration.py +0 -0
- {axis-66 → axis-67}/axis/models/port_cgi.py +0 -0
- {axis-66 → axis-67}/axis/models/port_management.py +0 -0
- {axis-66 → axis-67}/axis/models/ptz_cgi.py +0 -0
- {axis-66 → axis-67}/axis/models/pwdgrp_cgi.py +0 -0
- {axis-66 → axis-67}/axis/models/stream_profile.py +0 -0
- {axis-66 → axis-67}/axis/models/user_group.py +0 -0
- {axis-66 → axis-67}/axis/py.typed +0 -0
- {axis-66 → axis-67}/axis.egg-info/dependency_links.txt +0 -0
- {axis-66 → axis-67}/axis.egg-info/entry_points.txt +0 -0
- {axis-66 → axis-67}/axis.egg-info/top_level.txt +0 -0
- {axis-66 → axis-67}/setup.cfg +0 -0
- {axis-66 → axis-67}/tests/test_api_handler.py +0 -0
- {axis-66 → axis-67}/tests/test_device.py +0 -0
- {axis-66 → axis-67}/tests/test_event.py +0 -0
- {axis-66 → axis-67}/tests/test_port_management.py +0 -0
- {axis-66 → axis-67}/tests/test_rtsp.py +0 -0
- {axis-66 → axis-67}/tests/test_stream_manager.py +0 -0
- {axis-66 → axis-67}/tests/test_user_groups.py +0 -0
{axis-66 → axis-67}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axis
|
|
3
|
-
Version:
|
|
3
|
+
Version: 67
|
|
4
4
|
Summary: A Python library for communicating with devices from Axis Communications
|
|
5
5
|
Author-email: Robert Svensson <Kane610@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -12,21 +12,23 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
14
|
Classifier: Operating System :: OS Independent
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
16
|
Classifier: Topic :: Home Automation
|
|
17
|
-
Requires-Python: >=3.
|
|
17
|
+
Requires-Python: >=3.14.0
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp>=3.12
|
|
20
21
|
Requires-Dist: faust-cchardet>=2.1.18
|
|
21
22
|
Requires-Dist: httpx>=0.26
|
|
22
23
|
Requires-Dist: orjson>3.9
|
|
23
24
|
Requires-Dist: packaging>23
|
|
24
25
|
Requires-Dist: xmltodict>=0.13.0
|
|
25
26
|
Provides-Extra: requirements
|
|
27
|
+
Requires-Dist: aiohttp==3.13.1; extra == "requirements"
|
|
26
28
|
Requires-Dist: httpx==0.28.1; extra == "requirements"
|
|
27
|
-
Requires-Dist: orjson==3.11.
|
|
28
|
-
Requires-Dist: packaging==
|
|
29
|
-
Requires-Dist: xmltodict==1.0.
|
|
29
|
+
Requires-Dist: orjson==3.11.7; extra == "requirements"
|
|
30
|
+
Requires-Dist: packaging==26.0; extra == "requirements"
|
|
31
|
+
Requires-Dist: xmltodict==1.0.4; extra == "requirements"
|
|
30
32
|
Provides-Extra: requirements-test
|
|
31
33
|
Requires-Dist: mypy==1.19.1; extra == "requirements-test"
|
|
32
34
|
Requires-Dist: pytest==9.0.2; extra == "requirements-test"
|
|
@@ -34,8 +36,8 @@ Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
|
|
|
34
36
|
Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
|
|
35
37
|
Requires-Dist: pytest-cov==7.0.0; extra == "requirements-test"
|
|
36
38
|
Requires-Dist: respx==0.22.0; extra == "requirements-test"
|
|
37
|
-
Requires-Dist: ruff==0.
|
|
38
|
-
Requires-Dist: types-xmltodict==v1.0.1.
|
|
39
|
+
Requires-Dist: ruff==0.15.6; extra == "requirements-test"
|
|
40
|
+
Requires-Dist: types-xmltodict==v1.0.1.20260113; extra == "requirements-test"
|
|
39
41
|
Provides-Extra: requirements-dev
|
|
40
42
|
Requires-Dist: pre-commit==4.5.1; extra == "requirements-dev"
|
|
41
43
|
Dynamic: license-file
|
|
@@ -3,27 +3,35 @@
|
|
|
3
3
|
import argparse
|
|
4
4
|
import asyncio
|
|
5
5
|
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
import aiohttp
|
|
8
9
|
|
|
9
10
|
import axis
|
|
10
11
|
from axis.device import AxisDevice
|
|
11
|
-
from axis.models.configuration import Configuration
|
|
12
|
-
|
|
12
|
+
from axis.models.configuration import Configuration, WebProtocol
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from axis.models.event import Event
|
|
13
16
|
|
|
14
17
|
LOGGER = logging.getLogger(__name__)
|
|
15
18
|
|
|
16
19
|
|
|
17
|
-
def event_handler(event: Event) -> None:
|
|
20
|
+
def event_handler(event: "Event") -> None:
|
|
18
21
|
"""Receive and print events from RTSP stream."""
|
|
19
22
|
LOGGER.info(event)
|
|
20
23
|
|
|
21
24
|
|
|
22
25
|
async def axis_device(
|
|
23
|
-
host: str,
|
|
24
|
-
|
|
26
|
+
host: str,
|
|
27
|
+
port: int,
|
|
28
|
+
username: str,
|
|
29
|
+
password: str,
|
|
30
|
+
web_proto: WebProtocol,
|
|
31
|
+
is_companion: bool = False,
|
|
32
|
+
) -> axis.device.AxisDevice:
|
|
25
33
|
"""Create a Axis device."""
|
|
26
|
-
session =
|
|
34
|
+
session = create_session()
|
|
27
35
|
device = AxisDevice(
|
|
28
36
|
Configuration(
|
|
29
37
|
session,
|
|
@@ -32,6 +40,7 @@ async def axis_device(
|
|
|
32
40
|
username=username,
|
|
33
41
|
password=password,
|
|
34
42
|
is_companion=is_companion,
|
|
43
|
+
web_proto=web_proto,
|
|
35
44
|
)
|
|
36
45
|
)
|
|
37
46
|
|
|
@@ -58,12 +67,24 @@ async def axis_device(
|
|
|
58
67
|
|
|
59
68
|
|
|
60
69
|
async def main(
|
|
61
|
-
host: str,
|
|
70
|
+
host: str,
|
|
71
|
+
port: int,
|
|
72
|
+
username: str,
|
|
73
|
+
password: str,
|
|
74
|
+
params: bool,
|
|
75
|
+
events: bool,
|
|
76
|
+
web_proto: WebProtocol,
|
|
62
77
|
) -> None:
|
|
63
78
|
"""CLI method for library."""
|
|
64
79
|
LOGGER.info("Connecting to Axis device")
|
|
65
80
|
|
|
66
|
-
device = await axis_device(
|
|
81
|
+
device = await axis_device(
|
|
82
|
+
host,
|
|
83
|
+
port,
|
|
84
|
+
username,
|
|
85
|
+
password,
|
|
86
|
+
web_proto=web_proto,
|
|
87
|
+
)
|
|
67
88
|
|
|
68
89
|
if not device:
|
|
69
90
|
LOGGER.error("Couldn't connect to Axis device")
|
|
@@ -86,16 +107,32 @@ async def main(
|
|
|
86
107
|
device.stream.stop()
|
|
87
108
|
|
|
88
109
|
finally:
|
|
89
|
-
|
|
110
|
+
session = device.config.session
|
|
111
|
+
if not isinstance(session, aiohttp.ClientSession):
|
|
112
|
+
message = "Configured session is not an aiohttp ClientSession"
|
|
113
|
+
raise RuntimeError(message)
|
|
114
|
+
await close_session(session)
|
|
90
115
|
device.stream.stop()
|
|
91
116
|
|
|
92
117
|
|
|
118
|
+
def create_session() -> aiohttp.ClientSession:
|
|
119
|
+
"""Create aiohttp session used for HTTP requests."""
|
|
120
|
+
connector = aiohttp.TCPConnector(ssl=False)
|
|
121
|
+
return aiohttp.ClientSession(connector=connector)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def close_session(session: aiohttp.ClientSession) -> None:
|
|
125
|
+
"""Close aiohttp session."""
|
|
126
|
+
await session.close()
|
|
127
|
+
|
|
128
|
+
|
|
93
129
|
if __name__ == "__main__":
|
|
94
130
|
parser = argparse.ArgumentParser()
|
|
95
131
|
parser.add_argument("host", type=str)
|
|
96
132
|
parser.add_argument("username", type=str)
|
|
97
133
|
parser.add_argument("password", type=str)
|
|
98
|
-
parser.add_argument("-p", "--port", type=int, default=
|
|
134
|
+
parser.add_argument("-p", "--port", type=int, default=0)
|
|
135
|
+
parser.add_argument("--proto", type=str, default="http")
|
|
99
136
|
parser.add_argument("--events", action="store_true")
|
|
100
137
|
parser.add_argument("--params", action="store_true")
|
|
101
138
|
parser.add_argument("-D", "--debug", action="store_true")
|
|
@@ -125,6 +162,7 @@ if __name__ == "__main__":
|
|
|
125
162
|
port=args.port,
|
|
126
163
|
params=args.params,
|
|
127
164
|
events=args.events,
|
|
165
|
+
web_proto=WebProtocol(args.proto),
|
|
128
166
|
)
|
|
129
167
|
)
|
|
130
168
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"""Python library to enable Axis devices to integrate with Home Assistant."""
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
3
5
|
from .interfaces.event_manager import EventManager
|
|
4
6
|
from .interfaces.vapix import Vapix
|
|
5
|
-
from .models.configuration import Configuration
|
|
6
7
|
from .stream_manager import StreamManager
|
|
7
8
|
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .models.configuration import Configuration
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
class AxisDevice:
|
|
10
14
|
"""Creates a new Axis device.self."""
|
|
@@ -73,11 +73,11 @@ class SubscriptionHandler:
|
|
|
73
73
|
class ApiHandler(SubscriptionHandler, Generic[ApiItemT]):
|
|
74
74
|
"""Base class for a map of API Items."""
|
|
75
75
|
|
|
76
|
-
api_id:
|
|
76
|
+
api_id: ApiId | None = None
|
|
77
77
|
default_api_version: str | None = None
|
|
78
78
|
skip_support_check = False
|
|
79
79
|
|
|
80
|
-
def __init__(self, vapix:
|
|
80
|
+
def __init__(self, vapix: Vapix) -> None:
|
|
81
81
|
"""Initialize API items."""
|
|
82
82
|
super().__init__()
|
|
83
83
|
self.vapix = vapix
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Axis Vapix parameter management."""
|
|
2
2
|
|
|
3
|
-
from collections.abc import Sequence
|
|
4
3
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
5
4
|
|
|
6
5
|
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Sequence
|
|
7
7
|
|
|
8
8
|
class _DetectResultType(TypedDict):
|
|
9
9
|
encoding: str
|
|
@@ -34,7 +34,7 @@ class Params(ApiHandler[Any]):
|
|
|
34
34
|
|
|
35
35
|
api_id = ApiId.PARAM_CGI
|
|
36
36
|
|
|
37
|
-
def __init__(self, vapix:
|
|
37
|
+
def __init__(self, vapix: Vapix) -> None:
|
|
38
38
|
"""Initialize parameter classes."""
|
|
39
39
|
super().__init__(vapix)
|
|
40
40
|
|
|
@@ -5,10 +5,11 @@ Generalises parameter specific handling like
|
|
|
5
5
|
- Defining parameter group
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from collections.abc import Sequence
|
|
9
8
|
from typing import TYPE_CHECKING
|
|
10
9
|
|
|
11
10
|
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
12
13
|
from .param_cgi import Params
|
|
13
14
|
|
|
14
15
|
from ...models.parameters.param_cgi import ParameterGroup, ParamItemT
|
|
@@ -21,7 +22,7 @@ class ParamHandler(ApiHandler[ParamItemT]):
|
|
|
21
22
|
parameter_group: ParameterGroup
|
|
22
23
|
parameter_item: type[ParamItemT]
|
|
23
24
|
|
|
24
|
-
def __init__(self, param_handler:
|
|
25
|
+
def __init__(self, param_handler: Params) -> None:
|
|
25
26
|
"""Initialize API items."""
|
|
26
27
|
super().__init__(param_handler.vapix)
|
|
27
28
|
param_handler.subscribe(self._update_params_callback, self.parameter_group)
|
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
|
-
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
8
8
|
|
|
9
|
+
import aiohttp
|
|
9
10
|
import httpx
|
|
10
11
|
|
|
11
12
|
from ..errors import RequestError, raise_error
|
|
13
|
+
from ..models.configuration import AuthScheme
|
|
12
14
|
from ..models.pwdgrp_cgi import SecondaryGroup
|
|
13
15
|
from .api_discovery import ApiDiscoveryHandler
|
|
14
16
|
from .applications import ApplicationsHandler
|
|
@@ -32,6 +34,8 @@ from .user_groups import UserGroups
|
|
|
32
34
|
from .view_areas import ViewAreaHandler
|
|
33
35
|
|
|
34
36
|
if TYPE_CHECKING:
|
|
37
|
+
from aiohttp import BasicAuth as AiohttpBasicAuth, ClientSession
|
|
38
|
+
|
|
35
39
|
from ..device import AxisDevice
|
|
36
40
|
from ..models.api import ApiRequest
|
|
37
41
|
from ..models.stream_profile import StreamProfile
|
|
@@ -45,10 +49,24 @@ TIME_OUT = 15
|
|
|
45
49
|
class Vapix:
|
|
46
50
|
"""Vapix parameter request."""
|
|
47
51
|
|
|
52
|
+
auth: object
|
|
53
|
+
|
|
48
54
|
def __init__(self, device: AxisDevice) -> None:
|
|
49
55
|
"""Store local reference to device config."""
|
|
50
56
|
self.device = device
|
|
51
|
-
self.
|
|
57
|
+
self._http_client = self._client_name()
|
|
58
|
+
self._aiohttp_digest_middleware: Any | None = None
|
|
59
|
+
|
|
60
|
+
if self._http_client == "aiohttp":
|
|
61
|
+
if device.config.auth_scheme == AuthScheme.BASIC:
|
|
62
|
+
self.auth = self._aiohttp_basic_auth()
|
|
63
|
+
else:
|
|
64
|
+
self.auth = None
|
|
65
|
+
self._aiohttp_digest_middleware = self._aiohttp_digest_middleware_obj()
|
|
66
|
+
elif device.config.auth_scheme == AuthScheme.BASIC:
|
|
67
|
+
self.auth = httpx.BasicAuth(device.config.username, device.config.password)
|
|
68
|
+
else:
|
|
69
|
+
self.auth = httpx.DigestAuth(device.config.username, device.config.password)
|
|
52
70
|
|
|
53
71
|
self.users = Users(self)
|
|
54
72
|
self.user_groups = UserGroups(self)
|
|
@@ -256,21 +274,44 @@ class Vapix:
|
|
|
256
274
|
data: dict[str, str] | None = None,
|
|
257
275
|
headers: dict[str, str] | None = None,
|
|
258
276
|
params: dict[str, str] | None = None,
|
|
277
|
+
) -> bytes:
|
|
278
|
+
"""Make a request to the device."""
|
|
279
|
+
return await self._request(
|
|
280
|
+
method=method,
|
|
281
|
+
path=path,
|
|
282
|
+
content=content,
|
|
283
|
+
data=data,
|
|
284
|
+
headers=headers,
|
|
285
|
+
params=params,
|
|
286
|
+
allow_auto_basic_retry=True,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def _request(
|
|
290
|
+
self,
|
|
291
|
+
method: str,
|
|
292
|
+
path: str,
|
|
293
|
+
content: bytes | None = None,
|
|
294
|
+
data: dict[str, str] | None = None,
|
|
295
|
+
headers: dict[str, str] | None = None,
|
|
296
|
+
params: dict[str, str] | None = None,
|
|
297
|
+
allow_auto_basic_retry: bool = False,
|
|
259
298
|
) -> bytes:
|
|
260
299
|
"""Make a request to the device."""
|
|
261
300
|
url = self.device.config.url + path
|
|
262
301
|
LOGGER.debug("%s, %s, '%s', '%s', '%s'", method, url, content, data, params)
|
|
263
302
|
|
|
264
303
|
try:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
304
|
+
(
|
|
305
|
+
status_code,
|
|
306
|
+
response_headers,
|
|
307
|
+
response_content,
|
|
308
|
+
) = await self._perform_request(
|
|
309
|
+
method=method,
|
|
310
|
+
url=url,
|
|
268
311
|
content=content,
|
|
269
312
|
data=data,
|
|
270
313
|
headers=headers,
|
|
271
314
|
params=params,
|
|
272
|
-
auth=self.auth,
|
|
273
|
-
timeout=TIME_OUT,
|
|
274
315
|
)
|
|
275
316
|
|
|
276
317
|
except httpx.TimeoutException as errt:
|
|
@@ -287,18 +328,197 @@ class Vapix:
|
|
|
287
328
|
message = f"Unknown error: {err}"
|
|
288
329
|
raise RequestError(message) from err
|
|
289
330
|
|
|
290
|
-
|
|
291
|
-
|
|
331
|
+
except TimeoutError as errt:
|
|
332
|
+
message = "Timeout"
|
|
333
|
+
raise RequestError(message) from errt
|
|
292
334
|
|
|
293
|
-
except
|
|
294
|
-
|
|
295
|
-
|
|
335
|
+
except Exception as err:
|
|
336
|
+
if aiohttp is not None and isinstance(err, aiohttp.ClientConnectionError):
|
|
337
|
+
LOGGER.debug("%s", err)
|
|
338
|
+
message = f"Connection error: {err}"
|
|
339
|
+
raise RequestError(message) from err
|
|
340
|
+
if aiohttp is not None and isinstance(err, aiohttp.ClientError):
|
|
341
|
+
LOGGER.debug("%s", err)
|
|
342
|
+
message = f"Unknown error: {err}"
|
|
343
|
+
raise RequestError(message) from err
|
|
344
|
+
raise
|
|
345
|
+
|
|
346
|
+
if status_code >= 400:
|
|
347
|
+
if self._should_retry_with_basic(response_headers, allow_auto_basic_retry):
|
|
348
|
+
self.auth = self._basic_auth()
|
|
349
|
+
self._aiohttp_digest_middleware = None
|
|
350
|
+
return await self._request(
|
|
351
|
+
method=method,
|
|
352
|
+
path=path,
|
|
353
|
+
content=content,
|
|
354
|
+
data=data,
|
|
355
|
+
headers=headers,
|
|
356
|
+
params=params,
|
|
357
|
+
allow_auto_basic_retry=False,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
LOGGER.debug("status=%s headers=%s", status_code, response_headers)
|
|
361
|
+
raise_error(status_code)
|
|
296
362
|
|
|
297
363
|
LOGGER.debug(
|
|
298
364
|
"Response (from %s %s): %s",
|
|
299
365
|
self.device.config.host,
|
|
300
366
|
path,
|
|
301
|
-
|
|
367
|
+
response_content,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return response_content
|
|
371
|
+
|
|
372
|
+
async def _perform_request(
|
|
373
|
+
self,
|
|
374
|
+
method: str,
|
|
375
|
+
url: str,
|
|
376
|
+
content: bytes | None,
|
|
377
|
+
data: dict[str, str] | None,
|
|
378
|
+
headers: dict[str, str] | None,
|
|
379
|
+
params: dict[str, str] | None,
|
|
380
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
381
|
+
"""Execute request and normalize responses from supported HTTP clients."""
|
|
382
|
+
if self._http_client == "aiohttp":
|
|
383
|
+
return await self._perform_aiohttp_request(
|
|
384
|
+
method=method,
|
|
385
|
+
url=url,
|
|
386
|
+
content=content,
|
|
387
|
+
data=data,
|
|
388
|
+
headers=headers,
|
|
389
|
+
params=params,
|
|
390
|
+
)
|
|
391
|
+
return await self._perform_httpx_request(
|
|
392
|
+
method=method,
|
|
393
|
+
url=url,
|
|
394
|
+
content=content,
|
|
395
|
+
data=data,
|
|
396
|
+
headers=headers,
|
|
397
|
+
params=params,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
async def _perform_httpx_request(
|
|
401
|
+
self,
|
|
402
|
+
method: str,
|
|
403
|
+
url: str,
|
|
404
|
+
content: bytes | None,
|
|
405
|
+
data: dict[str, str] | None,
|
|
406
|
+
headers: dict[str, str] | None,
|
|
407
|
+
params: dict[str, str] | None,
|
|
408
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
409
|
+
"""Execute request with a httpx session."""
|
|
410
|
+
session = self._httpx_session()
|
|
411
|
+
response = await session.request(
|
|
412
|
+
method,
|
|
413
|
+
url,
|
|
414
|
+
content=content,
|
|
415
|
+
data=data,
|
|
416
|
+
headers=headers,
|
|
417
|
+
params=params,
|
|
418
|
+
auth=self._httpx_auth(),
|
|
419
|
+
timeout=TIME_OUT,
|
|
420
|
+
)
|
|
421
|
+
return response.status_code, dict(response.headers), response.content
|
|
422
|
+
|
|
423
|
+
async def _perform_aiohttp_request(
|
|
424
|
+
self,
|
|
425
|
+
method: str,
|
|
426
|
+
url: str,
|
|
427
|
+
content: bytes | None,
|
|
428
|
+
data: dict[str, str] | None,
|
|
429
|
+
headers: dict[str, str] | None,
|
|
430
|
+
params: dict[str, str] | None,
|
|
431
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
432
|
+
"""Execute request with an aiohttp session."""
|
|
433
|
+
request_data: bytes | dict[str, str] | None = (
|
|
434
|
+
content if content is not None else data
|
|
435
|
+
)
|
|
436
|
+
session = self._aiohttp_session()
|
|
437
|
+
request_kwargs: dict[str, Any] = {
|
|
438
|
+
"data": request_data,
|
|
439
|
+
"headers": headers,
|
|
440
|
+
"params": params,
|
|
441
|
+
"auth": self._aiohttp_auth(),
|
|
442
|
+
"timeout": TIME_OUT,
|
|
443
|
+
}
|
|
444
|
+
if middlewares := self._aiohttp_middlewares():
|
|
445
|
+
request_kwargs["middlewares"] = middlewares
|
|
446
|
+
|
|
447
|
+
async with session.request(method, url, **request_kwargs) as response:
|
|
448
|
+
response_content = await response.read()
|
|
449
|
+
return response.status, dict(response.headers), response_content
|
|
450
|
+
|
|
451
|
+
def _httpx_session(self) -> httpx.AsyncClient:
|
|
452
|
+
"""Return session cast to a httpx client."""
|
|
453
|
+
return cast("httpx.AsyncClient", self.device.config.session)
|
|
454
|
+
|
|
455
|
+
def _aiohttp_session(self) -> ClientSession:
|
|
456
|
+
"""Return session cast to an aiohttp client."""
|
|
457
|
+
return cast("ClientSession", self.device.config.session)
|
|
458
|
+
|
|
459
|
+
def _httpx_auth(self) -> httpx.Auth | None:
|
|
460
|
+
"""Return auth cast for httpx requests."""
|
|
461
|
+
return cast("httpx.Auth | None", self.auth)
|
|
462
|
+
|
|
463
|
+
def _aiohttp_auth(self) -> AiohttpBasicAuth | None:
|
|
464
|
+
"""Return auth cast for aiohttp requests."""
|
|
465
|
+
return cast("AiohttpBasicAuth | None", self.auth)
|
|
466
|
+
|
|
467
|
+
def _aiohttp_middlewares(self) -> tuple[Any, ...] | None:
|
|
468
|
+
"""Return aiohttp middlewares used for auth challenges."""
|
|
469
|
+
if self._aiohttp_digest_middleware is None:
|
|
470
|
+
return None
|
|
471
|
+
return (self._aiohttp_digest_middleware,)
|
|
472
|
+
|
|
473
|
+
def _aiohttp_digest_middleware_obj(self) -> Any | None:
|
|
474
|
+
"""Create aiohttp digest middleware when available and relevant."""
|
|
475
|
+
if self.device.config.auth_scheme == AuthScheme.BASIC:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
middleware_cls = getattr(aiohttp, "DigestAuthMiddleware", None)
|
|
479
|
+
if middleware_cls is None:
|
|
480
|
+
LOGGER.debug("aiohttp DigestAuthMiddleware unavailable, digest disabled")
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
return middleware_cls(
|
|
484
|
+
login=self.device.config.username,
|
|
485
|
+
password=self.device.config.password,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def _basic_auth(self) -> object:
|
|
489
|
+
"""Create basic auth object for configured HTTP client."""
|
|
490
|
+
if self._http_client == "aiohttp":
|
|
491
|
+
return self._aiohttp_basic_auth()
|
|
492
|
+
return httpx.BasicAuth(self.device.config.username, self.device.config.password)
|
|
493
|
+
|
|
494
|
+
def _aiohttp_basic_auth(self) -> object:
|
|
495
|
+
"""Create aiohttp basic auth object."""
|
|
496
|
+
return aiohttp.BasicAuth(
|
|
497
|
+
self.device.config.username, self.device.config.password
|
|
302
498
|
)
|
|
303
499
|
|
|
304
|
-
|
|
500
|
+
def _client_name(self) -> str:
|
|
501
|
+
"""Return normalized client name from configured session object."""
|
|
502
|
+
if isinstance(self.device.config.session, aiohttp.ClientSession):
|
|
503
|
+
return "aiohttp"
|
|
504
|
+
return "httpx"
|
|
505
|
+
|
|
506
|
+
def _should_retry_with_basic(
|
|
507
|
+
self, headers: dict[str, str], allow_auto_basic_retry: bool
|
|
508
|
+
) -> bool:
|
|
509
|
+
"""Return if request should retry once with basic authentication."""
|
|
510
|
+
if not allow_auto_basic_retry:
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
if self.device.config.auth_scheme != AuthScheme.AUTO:
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
if self._http_client == "httpx" and not isinstance(self.auth, httpx.DigestAuth):
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
expected_auth = ""
|
|
520
|
+
for header_name, header_value in headers.items():
|
|
521
|
+
if header_name.lower() == "www-authenticate":
|
|
522
|
+
expected_auth = header_value.lower()
|
|
523
|
+
break
|
|
524
|
+
return "basic" in expected_auth
|
|
@@ -77,7 +77,7 @@ class ApiId(enum.StrEnum):
|
|
|
77
77
|
UNKNOWN = "unknown"
|
|
78
78
|
|
|
79
79
|
@classmethod
|
|
80
|
-
def _missing_(cls, value: object) ->
|
|
80
|
+
def _missing_(cls, value: object) -> ApiId:
|
|
81
81
|
"""Set default enum member if an unknown value is provided."""
|
|
82
82
|
LOGGER.info("Unsupported API discovery ID '%s'", value)
|
|
83
83
|
return ApiId.UNKNOWN
|
|
@@ -95,7 +95,7 @@ class ApiStatus(enum.StrEnum):
|
|
|
95
95
|
UNKNOWN = "unknown"
|
|
96
96
|
|
|
97
97
|
@classmethod
|
|
98
|
-
def _missing_(cls, value: object) ->
|
|
98
|
+
def _missing_(cls, value: object) -> ApiStatus:
|
|
99
99
|
"""Set default enum member if an unknown value is provided."""
|
|
100
100
|
LOGGER.debug("Unsupported API discovery status '%s'", value)
|
|
101
101
|
return ApiStatus.UNKNOWN
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Python library to enable Axis devices to integrate with Home Assistant."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import KW_ONLY, dataclass
|
|
4
|
+
import enum
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from aiohttp import ClientSession
|
|
10
|
+
from httpx import AsyncClient
|
|
11
|
+
|
|
12
|
+
type HTTPSession = AsyncClient | ClientSession
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthScheme(enum.StrEnum):
|
|
19
|
+
"""Supported HTTP authentication schemes."""
|
|
20
|
+
|
|
21
|
+
AUTO = "auto"
|
|
22
|
+
BASIC = "basic"
|
|
23
|
+
DIGEST = "digest"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _missing_(cls, value: object) -> AuthScheme:
|
|
27
|
+
"""Set default enum member if an unknown value is provided."""
|
|
28
|
+
LOGGER.debug("Unsupported auth scheme '%s'", value)
|
|
29
|
+
return AuthScheme.AUTO
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WebProtocol(enum.StrEnum):
|
|
33
|
+
"""Supported web protocols."""
|
|
34
|
+
|
|
35
|
+
HTTP = "http"
|
|
36
|
+
HTTPS = "https"
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def _missing_(cls, value: object) -> WebProtocol:
|
|
40
|
+
"""Set default enum member if an unknown value is provided."""
|
|
41
|
+
LOGGER.debug("Unsupported web protocol '%s'", value)
|
|
42
|
+
return WebProtocol.HTTP
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Configuration:
|
|
47
|
+
"""Device configuration.
|
|
48
|
+
|
|
49
|
+
A port value of 0 means use the default port for the configured protocol.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
session: HTTPSession
|
|
53
|
+
host: str
|
|
54
|
+
_: KW_ONLY
|
|
55
|
+
username: str
|
|
56
|
+
password: str
|
|
57
|
+
port: int = 0
|
|
58
|
+
web_proto: WebProtocol = WebProtocol.HTTP
|
|
59
|
+
verify_ssl: bool = False
|
|
60
|
+
is_companion: bool = False
|
|
61
|
+
auth_scheme: AuthScheme = AuthScheme.AUTO
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
"""Normalize auth and protocol values to enums and resolve default port."""
|
|
65
|
+
self.web_proto = WebProtocol(self.web_proto)
|
|
66
|
+
self._validate_host()
|
|
67
|
+
if self.port == 0:
|
|
68
|
+
self.port = 443 if self.web_proto == WebProtocol.HTTPS else 80
|
|
69
|
+
self.auth_scheme = AuthScheme(self.auth_scheme)
|
|
70
|
+
|
|
71
|
+
def _validate_host(self) -> None:
|
|
72
|
+
"""Validate that host is a plain hostname or IP address."""
|
|
73
|
+
if not self.host or self.host.strip() != self.host:
|
|
74
|
+
msg = "Host must be a non-empty hostname or IP address"
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
|
|
77
|
+
if "://" in self.host or any(char in self.host for char in ("/", "?", "#")):
|
|
78
|
+
msg = (
|
|
79
|
+
"Host must not include scheme, path, query, or fragment; "
|
|
80
|
+
"use host, web_proto, and port separately"
|
|
81
|
+
)
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def url(self) -> str:
|
|
86
|
+
"""Represent device base url."""
|
|
87
|
+
return f"{self.web_proto}://{self.host}:{self.port}"
|