axis 64__tar.gz → 66__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.
Files changed (106) hide show
  1. {axis-64 → axis-66}/PKG-INFO +19 -17
  2. {axis-64 → axis-66}/axis/__main__.py +14 -6
  3. {axis-64 → axis-66}/axis/interfaces/parameters/param_cgi.py +15 -2
  4. {axis-64 → axis-66}/axis/interfaces/vapix.py +7 -10
  5. {axis-64 → axis-66}/axis/models/configuration.py +1 -0
  6. {axis-64 → axis-66}/axis/models/event.py +7 -2
  7. {axis-64 → axis-66}/axis/models/event_instance.py +2 -2
  8. {axis-64 → axis-66}/axis/models/parameters/stream_profile.py +1 -1
  9. {axis-64 → axis-66}/axis/rtsp.py +2 -4
  10. {axis-64 → axis-66}/axis/stream_manager.py +4 -3
  11. {axis-64 → axis-66}/axis.egg-info/PKG-INFO +19 -17
  12. axis-66/axis.egg-info/requires.txt +24 -0
  13. {axis-64 → axis-66}/pyproject.toml +21 -21
  14. {axis-64 → axis-66}/tests/test_configuration.py +2 -0
  15. {axis-64 → axis-66}/tests/test_port_cgi.py +5 -5
  16. {axis-64 → axis-66}/tests/test_rtsp.py +1 -1
  17. {axis-64 → axis-66}/tests/test_stream_manager.py +24 -0
  18. {axis-64 → axis-66}/tests/test_vapix.py +58 -17
  19. axis-64/axis.egg-info/requires.txt +0 -23
  20. {axis-64 → axis-66}/LICENSE +0 -0
  21. {axis-64 → axis-66}/README.md +0 -0
  22. {axis-64 → axis-66}/axis/__init__.py +0 -0
  23. {axis-64 → axis-66}/axis/device.py +0 -0
  24. {axis-64 → axis-66}/axis/errors.py +0 -0
  25. {axis-64 → axis-66}/axis/interfaces/__init__.py +0 -0
  26. {axis-64 → axis-66}/axis/interfaces/api_discovery.py +0 -0
  27. {axis-64 → axis-66}/axis/interfaces/api_handler.py +0 -0
  28. {axis-64 → axis-66}/axis/interfaces/applications/__init__.py +0 -0
  29. {axis-64 → axis-66}/axis/interfaces/applications/application_handler.py +0 -0
  30. {axis-64 → axis-66}/axis/interfaces/applications/applications.py +0 -0
  31. {axis-64 → axis-66}/axis/interfaces/applications/fence_guard.py +0 -0
  32. {axis-64 → axis-66}/axis/interfaces/applications/loitering_guard.py +0 -0
  33. {axis-64 → axis-66}/axis/interfaces/applications/motion_guard.py +0 -0
  34. {axis-64 → axis-66}/axis/interfaces/applications/object_analytics.py +0 -0
  35. {axis-64 → axis-66}/axis/interfaces/applications/vmd4.py +0 -0
  36. {axis-64 → axis-66}/axis/interfaces/basic_device_info.py +0 -0
  37. {axis-64 → axis-66}/axis/interfaces/event_instances.py +0 -0
  38. {axis-64 → axis-66}/axis/interfaces/event_manager.py +0 -0
  39. {axis-64 → axis-66}/axis/interfaces/light_control.py +0 -0
  40. {axis-64 → axis-66}/axis/interfaces/mqtt.py +0 -0
  41. {axis-64 → axis-66}/axis/interfaces/parameters/__init__.py +0 -0
  42. {axis-64 → axis-66}/axis/interfaces/parameters/brand.py +0 -0
  43. {axis-64 → axis-66}/axis/interfaces/parameters/image.py +0 -0
  44. {axis-64 → axis-66}/axis/interfaces/parameters/io_port.py +0 -0
  45. {axis-64 → axis-66}/axis/interfaces/parameters/param_handler.py +0 -0
  46. {axis-64 → axis-66}/axis/interfaces/parameters/properties.py +0 -0
  47. {axis-64 → axis-66}/axis/interfaces/parameters/ptz.py +0 -0
  48. {axis-64 → axis-66}/axis/interfaces/parameters/stream_profile.py +0 -0
  49. {axis-64 → axis-66}/axis/interfaces/pir_sensor_configuration.py +0 -0
  50. {axis-64 → axis-66}/axis/interfaces/port_cgi.py +0 -0
  51. {axis-64 → axis-66}/axis/interfaces/port_management.py +0 -0
  52. {axis-64 → axis-66}/axis/interfaces/ptz.py +0 -0
  53. {axis-64 → axis-66}/axis/interfaces/pwdgrp_cgi.py +0 -0
  54. {axis-64 → axis-66}/axis/interfaces/stream_profiles.py +0 -0
  55. {axis-64 → axis-66}/axis/interfaces/user_groups.py +0 -0
  56. {axis-64 → axis-66}/axis/interfaces/view_areas.py +0 -0
  57. {axis-64 → axis-66}/axis/models/__init__.py +0 -0
  58. {axis-64 → axis-66}/axis/models/api.py +0 -0
  59. {axis-64 → axis-66}/axis/models/api_discovery.py +0 -0
  60. {axis-64 → axis-66}/axis/models/applications/__init__.py +0 -0
  61. {axis-64 → axis-66}/axis/models/applications/application.py +0 -0
  62. {axis-64 → axis-66}/axis/models/applications/fence_guard.py +0 -0
  63. {axis-64 → axis-66}/axis/models/applications/loitering_guard.py +0 -0
  64. {axis-64 → axis-66}/axis/models/applications/motion_guard.py +0 -0
  65. {axis-64 → axis-66}/axis/models/applications/object_analytics.py +0 -0
  66. {axis-64 → axis-66}/axis/models/applications/vmd4.py +0 -0
  67. {axis-64 → axis-66}/axis/models/basic_device_info.py +0 -0
  68. {axis-64 → axis-66}/axis/models/light_control.py +0 -0
  69. {axis-64 → axis-66}/axis/models/mqtt.py +0 -0
  70. {axis-64 → axis-66}/axis/models/parameters/__init__.py +0 -0
  71. {axis-64 → axis-66}/axis/models/parameters/brand.py +0 -0
  72. {axis-64 → axis-66}/axis/models/parameters/image.py +0 -0
  73. {axis-64 → axis-66}/axis/models/parameters/io_port.py +0 -0
  74. {axis-64 → axis-66}/axis/models/parameters/param_cgi.py +0 -0
  75. {axis-64 → axis-66}/axis/models/parameters/properties.py +0 -0
  76. {axis-64 → axis-66}/axis/models/parameters/ptz.py +0 -0
  77. {axis-64 → axis-66}/axis/models/pir_sensor_configuration.py +0 -0
  78. {axis-64 → axis-66}/axis/models/port_cgi.py +0 -0
  79. {axis-64 → axis-66}/axis/models/port_management.py +0 -0
  80. {axis-64 → axis-66}/axis/models/ptz_cgi.py +0 -0
  81. {axis-64 → axis-66}/axis/models/pwdgrp_cgi.py +0 -0
  82. {axis-64 → axis-66}/axis/models/stream_profile.py +0 -0
  83. {axis-64 → axis-66}/axis/models/user_group.py +0 -0
  84. {axis-64 → axis-66}/axis/models/view_area.py +0 -0
  85. {axis-64 → axis-66}/axis/py.typed +0 -0
  86. {axis-64 → axis-66}/axis.egg-info/SOURCES.txt +0 -0
  87. {axis-64 → axis-66}/axis.egg-info/dependency_links.txt +0 -0
  88. {axis-64 → axis-66}/axis.egg-info/entry_points.txt +0 -0
  89. {axis-64 → axis-66}/axis.egg-info/top_level.txt +0 -0
  90. {axis-64 → axis-66}/setup.cfg +0 -0
  91. {axis-64 → axis-66}/tests/test_api_discovery.py +0 -0
  92. {axis-64 → axis-66}/tests/test_api_handler.py +0 -0
  93. {axis-64 → axis-66}/tests/test_basic_device_info.py +0 -0
  94. {axis-64 → axis-66}/tests/test_device.py +0 -0
  95. {axis-64 → axis-66}/tests/test_event.py +0 -0
  96. {axis-64 → axis-66}/tests/test_event_instances.py +0 -0
  97. {axis-64 → axis-66}/tests/test_event_stream.py +0 -0
  98. {axis-64 → axis-66}/tests/test_light_control.py +0 -0
  99. {axis-64 → axis-66}/tests/test_mqtt.py +0 -0
  100. {axis-64 → axis-66}/tests/test_pir_sensor_configuration.py +0 -0
  101. {axis-64 → axis-66}/tests/test_port_management.py +0 -0
  102. {axis-64 → axis-66}/tests/test_ptz.py +0 -0
  103. {axis-64 → axis-66}/tests/test_pwdgrp_cgi.py +0 -0
  104. {axis-64 → axis-66}/tests/test_stream_profiles.py +0 -0
  105. {axis-64 → axis-66}/tests/test_user_groups.py +0 -0
  106. {axis-64 → axis-66}/tests/test_view_areas.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 64
3
+ Version: 66
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,30 +12,32 @@ 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.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Home Automation
17
- Requires-Python: >=3.12.0
17
+ Requires-Python: >=3.13.0
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
+ Requires-Dist: faust-cchardet>=2.1.18
20
21
  Requires-Dist: httpx>=0.26
21
22
  Requires-Dist: orjson>3.9
22
23
  Requires-Dist: packaging>23
23
24
  Requires-Dist: xmltodict>=0.13.0
24
25
  Provides-Extra: requirements
25
- Requires-Dist: httpx==0.27.2; extra == "requirements"
26
- Requires-Dist: orjson==3.10.12; extra == "requirements"
27
- Requires-Dist: packaging==24.2; extra == "requirements"
28
- Requires-Dist: xmltodict==0.14.2; extra == "requirements"
26
+ Requires-Dist: httpx==0.28.1; extra == "requirements"
27
+ Requires-Dist: orjson==3.11.5; extra == "requirements"
28
+ Requires-Dist: packaging==25.0; extra == "requirements"
29
+ Requires-Dist: xmltodict==1.0.2; extra == "requirements"
29
30
  Provides-Extra: requirements-test
30
- Requires-Dist: mypy==1.13.0; extra == "requirements-test"
31
- Requires-Dist: pytest==8.3.4; extra == "requirements-test"
32
- Requires-Dist: pytest-aiohttp==1.0.5; extra == "requirements-test"
33
- Requires-Dist: pytest-asyncio==0.25.0; extra == "requirements-test"
34
- Requires-Dist: pytest-cov==6.0.0; extra == "requirements-test"
35
- Requires-Dist: respx==0.21.1; extra == "requirements-test"
36
- Requires-Dist: ruff==0.8.3; extra == "requirements-test"
37
- Requires-Dist: types-xmltodict==v0.14.0.20241009; extra == "requirements-test"
31
+ Requires-Dist: mypy==1.19.1; extra == "requirements-test"
32
+ Requires-Dist: pytest==9.0.2; extra == "requirements-test"
33
+ Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
34
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
35
+ Requires-Dist: pytest-cov==7.0.0; extra == "requirements-test"
36
+ Requires-Dist: respx==0.22.0; extra == "requirements-test"
37
+ Requires-Dist: ruff==0.14.10; extra == "requirements-test"
38
+ Requires-Dist: types-xmltodict==v1.0.1.20250920; extra == "requirements-test"
38
39
  Provides-Extra: requirements-dev
39
- Requires-Dist: pre-commit==4.0.1; extra == "requirements-dev"
40
+ Requires-Dist: pre-commit==4.5.1; extra == "requirements-dev"
41
+ Dynamic: license-file
40
42
 
41
43
  Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
@@ -7,23 +7,31 @@ import logging
7
7
  from httpx import AsyncClient
8
8
 
9
9
  import axis
10
+ from axis.device import AxisDevice
11
+ from axis.models.configuration import Configuration
12
+ from axis.models.event import Event
10
13
 
11
14
  LOGGER = logging.getLogger(__name__)
12
15
 
13
16
 
14
- def event_handler(event: axis.models.event.Event) -> None:
17
+ def event_handler(event: Event) -> None:
15
18
  """Receive and print events from RTSP stream."""
16
19
  LOGGER.info(event)
17
20
 
18
21
 
19
22
  async def axis_device(
20
- host: str, port: int, username: str, password: str
21
- ) -> axis.device.AxisDevice:
23
+ host: str, port: int, username: str, password: str, is_companion: bool = False
24
+ ) -> AxisDevice:
22
25
  """Create a Axis device."""
23
26
  session = AsyncClient(verify=False) # noqa: S501
24
- device = axis.device.AxisDevice(
25
- axis.models.configuration.Configuration(
26
- session, host, port=port, username=username, password=password
27
+ device = AxisDevice(
28
+ Configuration(
29
+ session,
30
+ host,
31
+ port=port,
32
+ username=username,
33
+ password=password,
34
+ is_companion=is_companion,
27
35
  )
28
36
  )
29
37
 
@@ -1,7 +1,19 @@
1
1
  """Axis Vapix parameter management."""
2
2
 
3
3
  from collections.abc import Sequence
4
- from typing import TYPE_CHECKING, Any
4
+ from typing import TYPE_CHECKING, Any, TypedDict
5
+
6
+ if TYPE_CHECKING:
7
+
8
+ class _DetectResultType(TypedDict):
9
+ encoding: str
10
+ confidence: float
11
+
12
+ def detect(byte_str: bytes | bytearray) -> _DetectResultType:
13
+ """Typed interface for chardet detect method."""
14
+ ...
15
+ else:
16
+ from cchardet import detect
5
17
 
6
18
  from ...models.api_discovery import ApiId
7
19
  from ...models.parameters.param_cgi import ParameterGroup, ParamRequest, params_to_dict
@@ -36,7 +48,8 @@ class Params(ApiHandler[Any]):
36
48
  async def _api_request(self, group: ParameterGroup | None = None) -> dict[str, Any]:
37
49
  """Fetch parameter data and convert it into a dictionary."""
38
50
  bytes_data = await self.vapix.api_request(ParamRequest(group))
39
- return params_to_dict(bytes_data.decode()).get("root") or {}
51
+ encoding = detect(bytes_data)["encoding"] or "utf-8"
52
+ return params_to_dict(bytes_data.decode(encoding=encoding)).get("root") or {}
40
53
 
41
54
  async def _update(self, group: ParameterGroup | None = None) -> Sequence[str]:
42
55
  """Request parameter data and update items."""
@@ -11,17 +11,11 @@ import httpx
11
11
  from ..errors import RequestError, raise_error
12
12
  from ..models.pwdgrp_cgi import SecondaryGroup
13
13
  from .api_discovery import ApiDiscoveryHandler
14
- from .applications import (
15
- ApplicationsHandler,
16
- )
14
+ from .applications import ApplicationsHandler
17
15
  from .applications.fence_guard import FenceGuardHandler
18
- from .applications.loitering_guard import (
19
- LoiteringGuardHandler,
20
- )
16
+ from .applications.loitering_guard import LoiteringGuardHandler
21
17
  from .applications.motion_guard import MotionGuardHandler
22
- from .applications.object_analytics import (
23
- ObjectAnalyticsHandler,
24
- )
18
+ from .applications.object_analytics import ObjectAnalyticsHandler
25
19
  from .applications.vmd4 import Vmd4Handler
26
20
  from .basic_device_info import BasicDeviceInfoHandler
27
21
  from .event_instances import EventInstanceHandler
@@ -242,13 +236,16 @@ class Vapix:
242
236
 
243
237
  async def api_request(self, api_request: ApiRequest) -> bytes:
244
238
  """Make a request to the device."""
239
+ params = api_request.params or {}
240
+ if self.device.config.is_companion:
241
+ params["Axis-Orig-Sw"] = "true"
245
242
  return await self.request(
246
243
  method=api_request.method,
247
244
  path=api_request.path,
248
245
  content=api_request.content,
249
246
  data=api_request.data,
250
247
  headers=api_request.headers,
251
- params=api_request.params,
248
+ params=params,
252
249
  )
253
250
 
254
251
  async def request(
@@ -17,6 +17,7 @@ class Configuration:
17
17
  port: int = 80
18
18
  web_proto: str = "http"
19
19
  verify_ssl: bool = False
20
+ is_companion: bool = False
20
21
 
21
22
  @property
22
23
  def url(self) -> str:
@@ -195,10 +195,15 @@ class Event:
195
195
  data,
196
196
  # attr_prefix="",
197
197
  process_namespaces=True,
198
- namespaces=XML_NAMESPACES,
198
+ namespaces=XML_NAMESPACES, # type: ignore[arg-type]
199
199
  )
200
200
 
201
- if raw.get("MetadataStream") is None:
201
+ # Normalize the ONVIF metadata root: always use a dict, drop any stray
202
+ # XML namespace attribute ("@xmlns") added by xmltodict, and bail out
203
+ # early if the payload is empty.
204
+ stream = raw.get("MetadataStream") or {}
205
+ stream.pop("@xmlns", None)
206
+ if not stream:
202
207
  return cls._decode_from_dict({})
203
208
 
204
209
  topic = traverse(raw, TOPIC)
@@ -44,7 +44,7 @@ def get_events(data: dict[str, Any]) -> list[dict[str, Any]]:
44
44
  event_list = get_events(value) # Recursive call
45
45
 
46
46
  for event in event_list:
47
- event["topic"] = f'{key}/{event["topic"]}' # Compose the topic
47
+ event["topic"] = f"{key}/{event['topic']}" # Compose the topic
48
48
  events.append(event)
49
49
 
50
50
  return events
@@ -166,7 +166,7 @@ class ListEventInstancesResponse(ApiResponse[dict[str, Any]]):
166
166
  bytes_data,
167
167
  # attr_prefix="",
168
168
  dict_constructor=dict, # Use dict rather than ordered_dict
169
- namespaces=NAMESPACES, # Replace or remove defined namespaces
169
+ namespaces=NAMESPACES, # type: ignore[arg-type] # Replace or remove defined namespaces
170
170
  process_namespaces=True,
171
171
  )
172
172
  raw_events = traverse(data, EVENT_INSTANCE) # Move past the irrelevant keys
@@ -51,7 +51,7 @@ class StreamProfileParam(ParamItem):
51
51
  description=profile["Description"],
52
52
  parameters=profile["Parameters"],
53
53
  )
54
- for profile in cast(dict[str, ProfileParamT], raw_profiles).values()
54
+ for profile in cast("dict[str, ProfileParamT]", raw_profiles).values()
55
55
  ]
56
56
 
57
57
  return cls(
@@ -5,9 +5,11 @@
5
5
  # https://github.com/perexg/satip-axe/blob/master/tools/multicast-rtp
6
6
 
7
7
  import asyncio
8
+ from base64 import b64encode
8
9
  from collections import deque
9
10
  from collections.abc import Callable
10
11
  import enum
12
+ from hashlib import md5
11
13
  import logging
12
14
  import socket
13
15
  from typing import Any
@@ -372,8 +374,6 @@ class RTSPSession:
372
374
 
373
375
  def generate_digest(self) -> str:
374
376
  """RFC 2617."""
375
- from hashlib import md5
376
-
377
377
  _ha1 = f"{self.username}:{self.realm}:{self.password}"
378
378
  ha1 = md5(_ha1.encode("UTF-8")).hexdigest()
379
379
  _ha2 = f"{self.method}:{self.url}"
@@ -392,8 +392,6 @@ class RTSPSession:
392
392
 
393
393
  def generate_basic(self) -> str:
394
394
  """RFC 2617."""
395
- from base64 import b64encode
396
-
397
395
  if not self._basic_auth:
398
396
  creds = f"{self.username}:{self.password}"
399
397
  self._basic_auth = "Basic "
@@ -13,9 +13,7 @@ if TYPE_CHECKING:
13
13
 
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
- RTSP_URL = (
17
- "rtsp://{host}/axis-media/media.amp?video={video}&audio={audio}&event={event}"
18
- )
16
+ RTSP_URL = "rtsp://{host}/axis-media/media.amp?video={video}&audio={audio}&event={event}{axis_orig_sw}"
19
17
 
20
18
  RETRY_TIMER = 15
21
19
 
@@ -43,6 +41,9 @@ class StreamManager:
43
41
  video=self.video_query,
44
42
  audio=self.audio_query,
45
43
  event=self.event_query,
44
+ axis_orig_sw="&Axis-Orig-Sw=true"
45
+ if self.device.config.is_companion
46
+ else "",
46
47
  )
47
48
  _LOGGER.debug(rtsp_url)
48
49
  return rtsp_url
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: axis
3
- Version: 64
3
+ Version: 66
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,30 +12,32 @@ 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.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Home Automation
17
- Requires-Python: >=3.12.0
17
+ Requires-Python: >=3.13.0
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
+ Requires-Dist: faust-cchardet>=2.1.18
20
21
  Requires-Dist: httpx>=0.26
21
22
  Requires-Dist: orjson>3.9
22
23
  Requires-Dist: packaging>23
23
24
  Requires-Dist: xmltodict>=0.13.0
24
25
  Provides-Extra: requirements
25
- Requires-Dist: httpx==0.27.2; extra == "requirements"
26
- Requires-Dist: orjson==3.10.12; extra == "requirements"
27
- Requires-Dist: packaging==24.2; extra == "requirements"
28
- Requires-Dist: xmltodict==0.14.2; extra == "requirements"
26
+ Requires-Dist: httpx==0.28.1; extra == "requirements"
27
+ Requires-Dist: orjson==3.11.5; extra == "requirements"
28
+ Requires-Dist: packaging==25.0; extra == "requirements"
29
+ Requires-Dist: xmltodict==1.0.2; extra == "requirements"
29
30
  Provides-Extra: requirements-test
30
- Requires-Dist: mypy==1.13.0; extra == "requirements-test"
31
- Requires-Dist: pytest==8.3.4; extra == "requirements-test"
32
- Requires-Dist: pytest-aiohttp==1.0.5; extra == "requirements-test"
33
- Requires-Dist: pytest-asyncio==0.25.0; extra == "requirements-test"
34
- Requires-Dist: pytest-cov==6.0.0; extra == "requirements-test"
35
- Requires-Dist: respx==0.21.1; extra == "requirements-test"
36
- Requires-Dist: ruff==0.8.3; extra == "requirements-test"
37
- Requires-Dist: types-xmltodict==v0.14.0.20241009; extra == "requirements-test"
31
+ Requires-Dist: mypy==1.19.1; extra == "requirements-test"
32
+ Requires-Dist: pytest==9.0.2; extra == "requirements-test"
33
+ Requires-Dist: pytest-aiohttp==1.1.0; extra == "requirements-test"
34
+ Requires-Dist: pytest-asyncio==1.3.0; extra == "requirements-test"
35
+ Requires-Dist: pytest-cov==7.0.0; extra == "requirements-test"
36
+ Requires-Dist: respx==0.22.0; extra == "requirements-test"
37
+ Requires-Dist: ruff==0.14.10; extra == "requirements-test"
38
+ Requires-Dist: types-xmltodict==v1.0.1.20250920; extra == "requirements-test"
38
39
  Provides-Extra: requirements-dev
39
- Requires-Dist: pre-commit==4.0.1; extra == "requirements-dev"
40
+ Requires-Dist: pre-commit==4.5.1; extra == "requirements-dev"
41
+ Dynamic: license-file
40
42
 
41
43
  Python project to set up a connection towards Axis Communications devices and to subscribe to specific events on the metadatastream.
@@ -0,0 +1,24 @@
1
+ faust-cchardet>=2.1.18
2
+ httpx>=0.26
3
+ orjson>3.9
4
+ packaging>23
5
+ xmltodict>=0.13.0
6
+
7
+ [requirements]
8
+ httpx==0.28.1
9
+ orjson==3.11.5
10
+ packaging==25.0
11
+ xmltodict==1.0.2
12
+
13
+ [requirements-dev]
14
+ pre-commit==4.5.1
15
+
16
+ [requirements-test]
17
+ mypy==1.19.1
18
+ pytest==9.0.2
19
+ pytest-aiohttp==1.1.0
20
+ pytest-asyncio==1.3.0
21
+ pytest-cov==7.0.0
22
+ respx==0.22.0
23
+ ruff==0.14.10
24
+ types-xmltodict==v1.0.1.20250920
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools==75.6.0", "wheel==0.45.1"]
2
+ requires = ["setuptools==80.9.0", "wheel==0.46.1"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "axis"
7
- version = "64"
7
+ version = "66"
8
8
  license = {text = "MIT"}
9
9
  description = "A Python library for communicating with devices from Axis Communications"
10
10
  readme = "README.md"
@@ -15,11 +15,12 @@ classifiers = [
15
15
  "Intended Audience :: Developers",
16
16
  "License :: OSI Approved :: MIT License",
17
17
  "Operating System :: OS Independent",
18
- "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
19
  "Topic :: Home Automation",
20
20
  ]
21
- requires-python = ">=3.12.0"
21
+ requires-python = ">=3.13.0"
22
22
  dependencies = [
23
+ "faust-cchardet>=2.1.18",
23
24
  "httpx>=0.26",
24
25
  "orjson>3.9",
25
26
  "packaging>23",
@@ -28,23 +29,23 @@ dependencies = [
28
29
 
29
30
  [project.optional-dependencies]
30
31
  requirements = [
31
- "httpx==0.27.2",
32
- "orjson==3.10.12",
33
- "packaging==24.2",
34
- "xmltodict==0.14.2",
32
+ "httpx==0.28.1",
33
+ "orjson==3.11.5",
34
+ "packaging==25.0",
35
+ "xmltodict==1.0.2",
35
36
  ]
36
37
  requirements-test = [
37
- "mypy==1.13.0",
38
- "pytest==8.3.4",
39
- "pytest-aiohttp==1.0.5",
40
- "pytest-asyncio==0.25.0",
41
- "pytest-cov==6.0.0",
42
- "respx==0.21.1",
43
- "ruff==0.8.3",
44
- "types-xmltodict==v0.14.0.20241009",
38
+ "mypy==1.19.1",
39
+ "pytest==9.0.2",
40
+ "pytest-aiohttp==1.1.0",
41
+ "pytest-asyncio==1.3.0",
42
+ "pytest-cov==7.0.0",
43
+ "respx==0.22.0",
44
+ "ruff==0.14.10",
45
+ "types-xmltodict==v1.0.1.20250920",
45
46
  ]
46
47
  requirements-dev = [
47
- "pre-commit==4.0.1"
48
+ "pre-commit==4.5.1"
48
49
  ]
49
50
 
50
51
  [project.urls]
@@ -68,7 +69,7 @@ include = ["axis*"]
68
69
  "axis" = ["py.typed"]
69
70
 
70
71
  [tool.mypy]
71
- python_version = "3.12"
72
+ python_version = "3.13"
72
73
  check_untyped_defs = true
73
74
  disallow_any_generics = true
74
75
  disallow_incomplete_defs = true
@@ -93,7 +94,7 @@ log_cli_level = "DEBUG"
93
94
  testpaths = ["tests"]
94
95
 
95
96
  [tool.ruff]
96
- target-version = "py312"
97
+ target-version = "py313"
97
98
  lint.select = [
98
99
  # "A", # flake8-builtins
99
100
  "ANN", # flake8-annotations
@@ -147,8 +148,6 @@ lint.select = [
147
148
  ]
148
149
 
149
150
  lint.ignore = [
150
- "ANN101", # Missing type annotation for {name} in method
151
- "ANN102", # Missing type annotation for {name} in classmethod
152
151
  "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name}
153
152
  "COM812", # Trailing comma missing
154
153
  "D203", # 1 blank line required before class docstring
@@ -161,6 +160,7 @@ lint.ignore = [
161
160
  "PLR0915", # Too many statements ({statements} > {max_statements})
162
161
  "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
163
162
  "S324", # Probable use of insecure hash functions in {library}: {string}
163
+ "UP046", # 82 | class APIHandler(SubscriptionHandler, Generic[ApiItemT]):
164
164
  ]
165
165
 
166
166
  [tool.ruff.lint.flake8-pytest-style]
@@ -28,6 +28,7 @@ def test_configuration():
28
28
  assert config.web_proto == "https"
29
29
  assert config.verify_ssl is True
30
30
  assert config.url == "https://192.168.0.1:443"
31
+ assert config.is_companion is False
31
32
 
32
33
 
33
34
  def test_minimal_configuration():
@@ -47,3 +48,4 @@ def test_minimal_configuration():
47
48
  assert config.web_proto == "http"
48
49
  assert config.verify_ssl is False
49
50
  assert config.url == "http://192.168.1.1:80"
51
+ assert config.is_companion is False
@@ -20,7 +20,7 @@ def ports(axis_device) -> Ports:
20
20
  async def test_ports(respx_mock, ports: Ports) -> None:
21
21
  """Test that different types of ports work."""
22
22
  update_ports_route = respx_mock.post(f"http://{HOST}/axis-cgi/param.cgi").respond(
23
- text="""root.Input.NbrOfInputs=3
23
+ content="""root.Input.NbrOfInputs=3
24
24
  root.IOPort.I0.Direction=input
25
25
  root.IOPort.I0.Usage=Button
26
26
  root.IOPort.I1.Configurable=no
@@ -44,8 +44,8 @@ root.IOPort.I3.Output.Mode=bistable
44
44
  root.IOPort.I3.Output.Name=Tampering
45
45
  root.IOPort.I3.Output.PulseTime=0
46
46
  root.Output.NbrOfOutputs=1
47
- """,
48
- headers={"Content-Type": "text/plain"},
47
+ """.encode("iso-8859-1"),
48
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
49
49
  )
50
50
 
51
51
  await ports.update()
@@ -99,8 +99,8 @@ root.Output.NbrOfOutputs=1
99
99
  async def test_no_ports(respx_mock, ports: Ports) -> None:
100
100
  """Test that no ports also work."""
101
101
  route = respx_mock.post(f"http://{HOST}/axis-cgi/param.cgi").respond(
102
- text="",
103
- headers={"Content-Type": "text/plain"},
102
+ content="".encode("iso-8859-1"),
103
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
104
104
  )
105
105
 
106
106
  await ports.update()
@@ -674,7 +674,7 @@ def test_session_generate_digest_auth(rtsp_client):
674
674
  )
675
675
 
676
676
 
677
- def test_session_generate_basic_auth(event_loop, rtsp_client):
677
+ def test_session_generate_basic_auth(rtsp_client):
678
678
  """Verify generate basic auth method."""
679
679
  session = rtsp_client.session
680
680
  session.update('WWW-Authenticate: Basic realm="AXIS_ACCC8E012345"\r\n')
@@ -20,6 +20,12 @@ def stream_manager(axis_device) -> StreamManager:
20
20
  return axis_device.stream
21
21
 
22
22
 
23
+ @pytest.fixture
24
+ def stream_manager_companion(axis_companion_device) -> StreamManager:
25
+ """Return the StreamManager mock object."""
26
+ return axis_companion_device.stream
27
+
28
+
23
29
  async def test_stream_url(stream_manager):
24
30
  """Verify stream url."""
25
31
  assert stream_manager.video_query == 0
@@ -38,6 +44,24 @@ async def test_stream_url(stream_manager):
38
44
  )
39
45
 
40
46
 
47
+ async def test_stream_url_companion(stream_manager_companion):
48
+ """Verify stream url."""
49
+ assert stream_manager_companion.video_query == 0
50
+ assert stream_manager_companion.audio_query == 0
51
+ assert stream_manager_companion.event_query == "off"
52
+ assert (
53
+ stream_manager_companion.stream_url
54
+ == f"rtsp://{HOST}/axis-media/media.amp?video=0&audio=0&event=off&Axis-Orig-Sw=true"
55
+ )
56
+
57
+ stream_manager_companion.event = True
58
+ assert stream_manager_companion.event_query == "on"
59
+ assert (
60
+ stream_manager_companion.stream_url
61
+ == f"rtsp://{HOST}/axis-media/media.amp?video=0&audio=0&event=on&Axis-Orig-Sw=true"
62
+ )
63
+
64
+
41
65
  @patch("axis.stream_manager.RTSPClient")
42
66
  async def test_initialize_stream(rtsp_client, stream_manager):
43
67
  """Test stream commands."""
@@ -49,6 +49,12 @@ def vapix(axis_device: AxisDevice) -> Vapix:
49
49
  return axis_device.vapix
50
50
 
51
51
 
52
+ @pytest.fixture
53
+ def vapix_companion_device(axis_companion_device: AxisDevice) -> Vapix:
54
+ """Return the vapix object."""
55
+ return axis_companion_device.vapix
56
+
57
+
52
58
  def test_vapix_not_initialized(vapix: Vapix) -> None:
53
59
  """Test Vapix class without initialising any data."""
54
60
  assert dict(vapix.basic_device_info.items()) == {}
@@ -92,8 +98,10 @@ async def test_initialize(respx_mock, vapix: Vapix):
92
98
  }
93
99
  )
94
100
 
95
- respx_mock.post("/axis-cgi/param.cgi").respond(text=PARAM_CGI_RESPONSE)
96
-
101
+ respx_mock.post("/axis-cgi/param.cgi").respond(
102
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
103
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
104
+ )
97
105
  respx_mock.post("/axis-cgi/applications/list.cgi").respond(
98
106
  text=APPLICATIONS_RESPONSE,
99
107
  headers={"Content-Type": "text/xml"},
@@ -219,14 +227,15 @@ async def test_initialize_api_discovery_unsupported(respx_mock, vapix: Vapix):
219
227
  async def test_initialize_param_cgi(respx_mock, vapix: Vapix):
220
228
  """Verify that you can list parameters."""
221
229
  respx_mock.post("/axis-cgi/param.cgi").respond(
222
- text=PARAM_CGI_RESPONSE,
223
- headers={"Content-Type": "text/plain"},
230
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
231
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
224
232
  )
225
233
  respx_mock.post("/axis-cgi/lightcontrol.cgi").respond(
226
234
  json=LIGHT_CONTROL_RESPONSE,
227
235
  )
228
236
  await vapix.initialize_param_cgi()
229
237
 
238
+ assert "Axis-Orig-Sw" not in respx_mock.calls.last.request.url.params
230
239
  assert vapix.firmware_version == "9.10.1"
231
240
  assert vapix.product_number == "M1065-LW"
232
241
  assert vapix.product_type == "Network Camera"
@@ -243,11 +252,43 @@ async def test_initialize_param_cgi(respx_mock, vapix: Vapix):
243
252
  assert vapix.users.supported
244
253
 
245
254
 
255
+ async def test_initialize_param_cgi_for_companion_device(
256
+ respx_mock, vapix_companion_device: Vapix
257
+ ):
258
+ """Verify that you can list parameters."""
259
+ respx_mock.post("/axis-cgi/param.cgi").respond(
260
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
261
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
262
+ )
263
+ respx_mock.post("/axis-cgi/lightcontrol.cgi").respond(
264
+ json=LIGHT_CONTROL_RESPONSE,
265
+ )
266
+ await vapix_companion_device.initialize_param_cgi()
267
+
268
+ assert "Axis-Orig-Sw" in respx_mock.calls.last.request.url.params
269
+
270
+ assert vapix_companion_device.firmware_version == "9.10.1"
271
+ assert vapix_companion_device.product_number == "M1065-LW"
272
+ assert vapix_companion_device.product_type == "Network Camera"
273
+ assert vapix_companion_device.serial_number == "ACCC12345678"
274
+ assert len(vapix_companion_device.streaming_profiles) == 2
275
+
276
+ assert len(vapix_companion_device.basic_device_info) == 0
277
+ assert len(vapix_companion_device.ports.values()) == 1
278
+ assert len(vapix_companion_device.light_control.values()) == 1
279
+ assert len(vapix_companion_device.mqtt) == 0
280
+ assert len(vapix_companion_device.stream_profiles) == 0
281
+ assert len(vapix_companion_device.params.stream_profile_handler) == 1
282
+
283
+ assert vapix_companion_device.users.supported
284
+
285
+
246
286
  async def test_initialize_params_no_data(respx_mock, vapix: Vapix):
247
287
  """Verify that you can list parameters."""
248
- param_route = respx_mock.post(
249
- "/axis-cgi/param.cgi",
250
- ).respond(text="")
288
+ param_route = respx_mock.post("/axis-cgi/param.cgi").respond(
289
+ content="".encode("iso-8859-1"),
290
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
291
+ )
251
292
  await vapix.initialize_param_cgi(preload_data=False)
252
293
 
253
294
  assert param_route.call_count == 4
@@ -256,8 +297,8 @@ async def test_initialize_params_no_data(respx_mock, vapix: Vapix):
256
297
  async def test_initialize_applications(respx_mock, vapix: Vapix):
257
298
  """Verify you can list and retrieve descriptions of applications."""
258
299
  respx_mock.post("/axis-cgi/param.cgi").respond(
259
- text=PARAM_CGI_RESPONSE,
260
- headers={"Content-Type": "text/plain"},
300
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
301
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
261
302
  )
262
303
  respx_mock.post("/axis-cgi/lightcontrol.cgi").respond(
263
304
  json=LIGHT_CONTROL_RESPONSE,
@@ -296,8 +337,8 @@ async def test_initialize_applications(respx_mock, vapix: Vapix):
296
337
  async def test_initialize_applications_unauthorized(respx_mock, vapix: Vapix, code):
297
338
  """Verify initialize applications doesnt break on too low credentials."""
298
339
  respx_mock.post("/axis-cgi/param.cgi").respond(
299
- text=PARAM_CGI_RESPONSE,
300
- headers={"Content-Type": "text/plain"},
340
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
341
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
301
342
  )
302
343
  respx_mock.post("/axis-cgi/lightcontrol.cgi").respond(
303
344
  json=LIGHT_CONTROL_RESPONSE,
@@ -313,8 +354,8 @@ async def test_initialize_applications_unauthorized(respx_mock, vapix: Vapix, co
313
354
  async def test_initialize_applications_not_running(respx_mock, vapix: Vapix):
314
355
  """Verify you can list and retrieve descriptions of applications."""
315
356
  respx_mock.post("/axis-cgi/param.cgi").respond(
316
- text=PARAM_CGI_RESPONSE,
317
- headers={"Content-Type": "text/plain"},
357
+ content=PARAM_CGI_RESPONSE.encode("iso-8859-1"),
358
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
318
359
  )
319
360
  respx_mock.post("/axis-cgi/lightcontrol.cgi").respond(
320
361
  json=LIGHT_CONTROL_RESPONSE,
@@ -351,10 +392,10 @@ async def test_initialize_event_instances(respx_mock, vapix: Vapix):
351
392
 
352
393
  async def test_applications_dont_load_without_params(respx_mock, vapix: Vapix):
353
394
  """Applications depends on param cgi to be loaded first."""
354
- param_route = respx_mock.post(
355
- "/axis-cgi/param.cgi",
356
- ).respond(text="key=value")
357
-
395
+ param_route = respx_mock.post("/axis-cgi/param.cgi").respond(
396
+ content="key=value".encode("iso-8859-1"),
397
+ headers={"Content-Type": "text/plain; charset=iso-8859-1"},
398
+ )
358
399
  applications_route = respx_mock.post("/axis-cgi/applications/list.cgi")
359
400
 
360
401
  await vapix.initialize_param_cgi(preload_data=False)
@@ -1,23 +0,0 @@
1
- httpx>=0.26
2
- orjson>3.9
3
- packaging>23
4
- xmltodict>=0.13.0
5
-
6
- [requirements]
7
- httpx==0.27.2
8
- orjson==3.10.12
9
- packaging==24.2
10
- xmltodict==0.14.2
11
-
12
- [requirements-dev]
13
- pre-commit==4.0.1
14
-
15
- [requirements-test]
16
- mypy==1.13.0
17
- pytest==8.3.4
18
- pytest-aiohttp==1.0.5
19
- pytest-asyncio==0.25.0
20
- pytest-cov==6.0.0
21
- respx==0.21.1
22
- ruff==0.8.3
23
- types-xmltodict==v0.14.0.20241009
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes