zm-py 0.5.5.dev13__tar.gz → 0.5.6.dev1__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 (29) hide show
  1. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/PKG-INFO +16 -2
  2. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/README.md +15 -1
  3. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/pyproject.toml +1 -1
  4. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/README.md +4 -6
  5. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/conftest.py +2 -2
  6. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_auth.py +1 -1
  7. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_client.py +112 -0
  8. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/monitor.py +70 -3
  9. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/zm.py +20 -0
  10. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/LICENSE.md +0 -0
  11. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/__init__.py +0 -0
  12. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/__init__.py +0 -0
  13. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_api_probes.py +0 -0
  14. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_availability.py +0 -0
  15. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_monitors.py +0 -0
  16. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_ptz.py +0 -0
  17. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_servers.py +0 -0
  18. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_states.py +0 -0
  19. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_enums.py +0 -0
  20. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_exceptions.py +0 -0
  21. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_monitor.py +0 -0
  22. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_ptz.py +0 -0
  23. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_run_state.py +0 -0
  24. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_server.py +0 -0
  25. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/tests/test_zm.py +0 -0
  26. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/__init__.py +0 -0
  27. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/exceptions.py +0 -0
  28. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/run_state.py +0 -0
  29. {zm_py-0.5.5.dev13 → zm_py-0.5.6.dev1}/zoneminder/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zm-py
3
- Version: 0.5.5.dev13
3
+ Version: 0.5.6.dev1
4
4
  Summary: A loose python wrapper around the ZoneMinder REST API.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE.md
@@ -55,6 +55,18 @@ Historical sources and authorship information is available as part of the Home A
55
55
  - [ZoneMinder Sensor](https://github.com/home-assistant/home-assistant/commits/dev/homeassistant/components/sensor/zoneminder.py)
56
56
  - [ZoneMinder Switch](https://github.com/home-assistant/home-assistant/commits/dev/homeassistant/components/switch/zoneminder.py)
57
57
 
58
+ ## Documentation
59
+
60
+ - **[API Reference](docs/api.md)** — all public classes, methods, properties, and enums
61
+ - **[Architecture](docs/architecture.md)** — package structure, design patterns, caching strategy, Mermaid diagrams
62
+ - Architecture diagrams:
63
+ - [Package architecture](docs/architecture-package-architecture.png)
64
+ - [Class relationships](docs/architecture-class-relationships.png)
65
+ - [Auth & request flow](docs/architecture-auth-amp-request-flow.png)
66
+ - [Multi-server URL routing](docs/architecture-multi-server-url-routing.png)
67
+ - [ZoneMinder API endpoints](docs/api-overview-zoneminder-api-endpoints.png)
68
+ - [Monitor state model](docs/api-overview-monitor-state-model.png)
69
+
58
70
  ## Installation
59
71
 
60
72
  ### PyPI
@@ -78,7 +90,9 @@ zm_client = ZoneMinder(
78
90
  server_path=SERVER_PATH,
79
91
  username=USER,
80
92
  password=PASS,
81
- verify_ssl=False
93
+ verify_ssl=False,
94
+ stream_scale=50, # optional: scale streams to 50%
95
+ stream_maxfps=5.0, # optional: cap MJPEG at 5 FPS
82
96
  )
83
97
 
84
98
  # Zoneminder authentication
@@ -30,6 +30,18 @@ Historical sources and authorship information is available as part of the Home A
30
30
  - [ZoneMinder Sensor](https://github.com/home-assistant/home-assistant/commits/dev/homeassistant/components/sensor/zoneminder.py)
31
31
  - [ZoneMinder Switch](https://github.com/home-assistant/home-assistant/commits/dev/homeassistant/components/switch/zoneminder.py)
32
32
 
33
+ ## Documentation
34
+
35
+ - **[API Reference](docs/api.md)** — all public classes, methods, properties, and enums
36
+ - **[Architecture](docs/architecture.md)** — package structure, design patterns, caching strategy, Mermaid diagrams
37
+ - Architecture diagrams:
38
+ - [Package architecture](docs/architecture-package-architecture.png)
39
+ - [Class relationships](docs/architecture-class-relationships.png)
40
+ - [Auth & request flow](docs/architecture-auth-amp-request-flow.png)
41
+ - [Multi-server URL routing](docs/architecture-multi-server-url-routing.png)
42
+ - [ZoneMinder API endpoints](docs/api-overview-zoneminder-api-endpoints.png)
43
+ - [Monitor state model](docs/api-overview-monitor-state-model.png)
44
+
33
45
  ## Installation
34
46
 
35
47
  ### PyPI
@@ -53,7 +65,9 @@ zm_client = ZoneMinder(
53
65
  server_path=SERVER_PATH,
54
66
  username=USER,
55
67
  password=PASS,
56
- verify_ssl=False
68
+ verify_ssl=False,
69
+ stream_scale=50, # optional: scale streams to 50%
70
+ stream_maxfps=5.0, # optional: cap MJPEG at 5 FPS
57
71
  )
58
72
 
59
73
  # Zoneminder authentication
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "zm-py"
3
- version = "0.5.5.dev13"
3
+ version = "0.5.6.dev1"
4
4
  description = "A loose python wrapper around the ZoneMinder REST API."
5
5
  authors = ["Rohan Kapoor <rohan@rohankapoor.com>"]
6
6
  maintainers = ["Nic Boet <nic@boet.cc>"]
@@ -87,6 +87,7 @@ ZM_E2E_WRITE=1 pytest -m zm_e2e_write tests/e2e/ -v
87
87
  | `test_run_state.py` | RunState construction, properties, `active` parsing, `activate` |
88
88
  | `test_enums.py` | `TimePeriod` (period/title/get_time_period), `MonitorState`, `ControlType` values |
89
89
  | `test_exceptions.py` | Exception hierarchy, `__str__` formatting |
90
+ | `test_server.py` | Server construction, properties, ZMS URL building, base URL building |
90
91
  | `test_client.py` | `verify_ssl`, `is_available` parsing, `get_monitors`/`get_run_states`/`get_active_state` response handling, `move_monitor` exception swallowing |
91
92
 
92
93
  ## E2E Test Modules
@@ -98,7 +99,8 @@ ZM_E2E_WRITE=1 pytest -m zm_e2e_write tests/e2e/ -v
98
99
  | `test_e2e_states.py` | Run states listing, active state, state switching | `api/states.json`, `api/states/change/{name}.json` |
99
100
  | `test_e2e_availability.py` | Daemon check, get_state/change_state plumbing, ZMS URL, auth URL helpers | `api/host/daemonCheck.json`, `api/host/getVersion.json` |
100
101
  | `test_e2e_ptz.py` | PTZ control on controllable/non-controllable monitors | `index.php` (control view) |
101
- | `test_e2e_api_probes.py` | Raw API response inspection -- documents actual types/shapes from ZM 1.38.x to validate BUGS.md | All endpoints |
102
+ | `test_e2e_servers.py` | Server listing, server URLs, monitor ServerId probe | `api/servers.json` |
103
+ | `test_e2e_api_probes.py` | Raw API response inspection -- documents actual types/shapes from ZM 1.38.x to validate bugs | All endpoints |
102
104
 
103
105
  ## API Coverage
104
106
 
@@ -117,6 +119,7 @@ GET api/states/change/{name}.json -> ZoneMinder.set_active_state() [write]
117
119
  GET api/monitors/alarm/... -> Monitor.is_recording
118
120
  GET api/monitors/daemonStatus/... -> Monitor.is_available
119
121
  GET api/events/consoleEvents/... -> Monitor.get_events()
122
+ GET api/servers.json -> ZoneMinder.get_servers()
120
123
  POST index.php (PTZ control) -> Monitor.ptz_control_command() [write]
121
124
  ```
122
125
 
@@ -146,11 +149,6 @@ After the test run, a summary section is printed showing:
146
149
  - Test target monitors
147
150
  - Skip reasons and counts
148
151
 
149
- ## Known Bugs
150
-
151
- See [BUGS.md](BUGS.md) for bugs discovered in zm-py during test development.
152
- These are **not fixed** -- they are documented to guide future refactoring.
153
-
154
152
  ## Comparison with Sister Projects
155
153
 
156
154
  This test suite follows the same E2E patterns established in:
@@ -202,8 +202,8 @@ def raw_session(zm_client: ZoneMinder):
202
202
  s.verify = zm_client._verify_ssl
203
203
  if zm_client._auth_token:
204
204
  s.params = {"token": zm_client._auth_token}
205
- elif zm_client._cookies:
206
- s.cookies = zm_client._cookies
205
+ elif zm_client._session.cookies:
206
+ s.cookies = zm_client._session.cookies
207
207
  return s
208
208
 
209
209
 
@@ -27,7 +27,7 @@ class TestLogin:
27
27
  """After login(), the client should have an auth token or cookies."""
28
28
  zm_client_fresh.login()
29
29
  has_token = zm_client_fresh._auth_token is not None
30
- has_cookies = zm_client_fresh._cookies is not None
30
+ has_cookies = len(zm_client_fresh._session.cookies) > 0
31
31
  assert has_token or has_cookies, (
32
32
  "Expected either JWT token or session cookies after login"
33
33
  )
@@ -331,6 +331,118 @@ class TestMoveMonitor:
331
331
  c._session.post.assert_called_once()
332
332
 
333
333
 
334
+ # ---------------------------------------------------------------------------
335
+ # goto_preset
336
+ # ---------------------------------------------------------------------------
337
+
338
+ class TestGotoPreset:
339
+ def _make_monitor(self, controllable=True):
340
+ raw = _monitor_raw(controllable="1" if controllable else "0")
341
+ return Monitor(_client(), raw)
342
+
343
+ def test_delegates_to_preset_command(self):
344
+ c = _client()
345
+ c._auth_token = "test-token"
346
+ c._session = MagicMock()
347
+ c._session.post.return_value = MagicMock(ok=True)
348
+ mon = Monitor(c, _monitor_raw(controllable="1"))
349
+ c.goto_preset(mon, 3)
350
+ c._session.post.assert_called_once()
351
+
352
+ def test_preset_params_contain_preset_number(self):
353
+ c = _client()
354
+ c._auth_token = "tok"
355
+ c._session = MagicMock()
356
+ c._session.post.return_value = MagicMock(ok=True)
357
+ mon = Monitor(c, _monitor_raw(controllable="1"))
358
+ c.goto_preset(mon, 3)
359
+ call_kwargs = c._session.post.call_args
360
+ assert call_kwargs.kwargs["params"]["control"] == "presetGoto3"
361
+
362
+ def test_raises_on_non_controllable(self):
363
+ c = _client()
364
+ c._auth_token = "tok"
365
+ mon = self._make_monitor(controllable=False)
366
+ with pytest.raises(MonitorControlTypeError):
367
+ c.goto_preset(mon, 1)
368
+
369
+ def test_returns_bool_on_success(self):
370
+ c = _client()
371
+ c._auth_token = "tok"
372
+ c._session = MagicMock()
373
+ c._session.post.return_value = MagicMock(ok=True)
374
+ mon = Monitor(c, _monitor_raw(controllable="1"))
375
+ result = c.goto_preset(mon, 5)
376
+ assert result is True
377
+
378
+ def test_returns_false_on_connection_error(self):
379
+ c = _client()
380
+ c._auth_token = "tok"
381
+ c._session = MagicMock()
382
+ c._session.post.side_effect = requests.exceptions.ConnectionError("refused")
383
+ mon = Monitor(c, _monitor_raw(controllable="1"))
384
+ result = c.goto_preset(mon, 1)
385
+ assert result is False
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # goto_home
390
+ # ---------------------------------------------------------------------------
391
+
392
+ class TestGotoHome:
393
+ def _make_monitor(self, controllable=True):
394
+ raw = _monitor_raw(controllable="1" if controllable else "0")
395
+ return Monitor(_client(), raw)
396
+
397
+ def test_home_command_sends_preset_home(self):
398
+ c = _client()
399
+ c._auth_token = "tok"
400
+ c._session = MagicMock()
401
+ c._session.post.return_value = MagicMock(ok=True)
402
+ mon = Monitor(c, _monitor_raw(controllable="1"))
403
+ c.goto_home(mon)
404
+ call_kwargs = c._session.post.call_args
405
+ assert call_kwargs.kwargs["params"]["control"] == "presetHome"
406
+
407
+ def test_raises_on_non_controllable(self):
408
+ c = _client()
409
+ c._auth_token = "tok"
410
+ mon = self._make_monitor(controllable=False)
411
+ with pytest.raises(MonitorControlTypeError):
412
+ c.goto_home(mon)
413
+
414
+ def test_returns_bool_on_success(self):
415
+ c = _client()
416
+ c._auth_token = "tok"
417
+ c._session = MagicMock()
418
+ c._session.post.return_value = MagicMock(ok=True)
419
+ mon = Monitor(c, _monitor_raw(controllable="1"))
420
+ result = c.goto_home(mon)
421
+ assert result is True
422
+
423
+ def test_returns_false_on_connection_error(self):
424
+ c = _client()
425
+ c._auth_token = "tok"
426
+ c._session = MagicMock()
427
+ c._session.post.side_effect = requests.exceptions.ConnectionError("refused")
428
+ mon = Monitor(c, _monitor_raw(controllable="1"))
429
+ result = c.goto_home(mon)
430
+ assert result is False
431
+
432
+ def test_uses_server_url_for_multi_server(self):
433
+ """goto_home should resolve the per-server URL."""
434
+ c = _client()
435
+ c._auth_token = "tok"
436
+ c._session = MagicMock()
437
+ c._session.post.return_value = MagicMock(ok=True)
438
+ raw = {"servers": [_server_raw(2, "Srv2", hostname="zm2.test", protocol="https")]}
439
+ with patch.object(c, "get_state", return_value=raw):
440
+ mon = Monitor(c, _monitor_raw(controllable="1", server_id="2"))
441
+ c.goto_home(mon)
442
+ call_kwargs = c._session.post.call_args
443
+ assert call_kwargs.kwargs["url"] == "https://zm2.test/zm/index.php"
444
+
445
+
334
446
  class TestStaleTokenRetry:
335
447
  """Verify _zm_request recomputes token suffix after login() refreshes the token."""
336
448
 
@@ -5,12 +5,16 @@ from __future__ import annotations
5
5
  from enum import Enum
6
6
  import logging
7
7
  import time
8
+ from typing import TYPE_CHECKING
8
9
  from urllib.parse import urlencode
9
10
 
10
11
  import requests
11
12
 
12
13
  from .exceptions import ControlTypeError, MonitorControlTypeError
13
14
 
15
+ if TYPE_CHECKING:
16
+ from zoneminder.zm import ZoneMinder
17
+
14
18
  _LOGGER = logging.getLogger(__name__)
15
19
 
16
20
  # Alarm state values as returned by the ZM API alarm status endpoint.
@@ -195,12 +199,19 @@ class TimePeriod(Enum):
195
199
  class Monitor:
196
200
  """Represents a Monitor from ZoneMinder."""
197
201
 
198
- def __init__(self, client, raw_result, *, scale=None, maxfps=None):
202
+ def __init__(
203
+ self,
204
+ client: ZoneMinder,
205
+ raw_result: dict,
206
+ *,
207
+ scale: int | None = None,
208
+ maxfps: float | None = None,
209
+ ) -> None:
199
210
  """Create a new Monitor."""
200
211
  self._client = client
201
212
  self._raw_result = raw_result
202
- self._scale: int | None = scale
203
- self._maxfps: float | None = maxfps
213
+ self._scale = scale
214
+ self._maxfps = maxfps
204
215
  self._last_update = time.monotonic()
205
216
  raw_monitor = raw_result["Monitor"]
206
217
  self._monitor_id = int(raw_monitor["Id"])
@@ -476,3 +487,59 @@ class Monitor:
476
487
  _LOGGER.exception("Unable to connect to ZoneMinder for PTZ control")
477
488
  return False
478
489
  return bool(req.ok)
490
+
491
+ def preset_command(
492
+ self, preset: int, token: str | None, base_url: str, cookies: dict | None = None
493
+ ) -> bool:
494
+ """Move camera to a numbered preset position."""
495
+ if not self.controllable:
496
+ raise MonitorControlTypeError()
497
+
498
+ ptz_url = f"{base_url}index.php"
499
+ params: dict[str, str | int] = {
500
+ "view": "request",
501
+ "request": "control",
502
+ "id": self.id,
503
+ "control": f"presetGoto{preset}",
504
+ }
505
+ if token:
506
+ params["token"] = token
507
+
508
+ try:
509
+ req = self._client._session.post( # pylint: disable=protected-access
510
+ url=ptz_url,
511
+ params=params,
512
+ cookies=cookies,
513
+ timeout=10,
514
+ )
515
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
516
+ _LOGGER.exception("Unable to connect to ZoneMinder for preset control")
517
+ return False
518
+ return bool(req.ok)
519
+
520
+ def home_command(self, token: str | None, base_url: str, cookies: dict | None = None) -> bool:
521
+ """Move camera to the home position."""
522
+ if not self.controllable:
523
+ raise MonitorControlTypeError()
524
+
525
+ ptz_url = f"{base_url}index.php"
526
+ params: dict[str, str | int] = {
527
+ "view": "request",
528
+ "request": "control",
529
+ "id": self.id,
530
+ "control": "presetHome",
531
+ }
532
+ if token:
533
+ params["token"] = token
534
+
535
+ try:
536
+ req = self._client._session.post( # pylint: disable=protected-access
537
+ url=ptz_url,
538
+ params=params,
539
+ cookies=cookies,
540
+ timeout=10,
541
+ )
542
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
543
+ _LOGGER.exception("Unable to connect to ZoneMinder for home control")
544
+ return False
545
+ return bool(req.ok)
@@ -474,3 +474,23 @@ class ZoneMinder:
474
474
  else:
475
475
  _LOGGER.error("Failed to move camera to %s", direction)
476
476
  return result
477
+
478
+ def goto_preset(self, monitor: Monitor, preset: int) -> bool:
479
+ """Move camera to a numbered preset position."""
480
+ base_url = self.get_server_url_for_monitor(monitor.raw_monitor)
481
+ result = monitor.preset_command(preset, self._auth_token, base_url)
482
+ if result:
483
+ _LOGGER.info("Successfully moved camera to preset %d", preset)
484
+ else:
485
+ _LOGGER.error("Failed to move camera to preset %d", preset)
486
+ return result
487
+
488
+ def goto_home(self, monitor: Monitor) -> bool:
489
+ """Move camera to the home position."""
490
+ base_url = self.get_server_url_for_monitor(monitor.raw_monitor)
491
+ result = monitor.home_command(self._auth_token, base_url)
492
+ if result:
493
+ _LOGGER.info("Successfully moved camera to home position")
494
+ else:
495
+ _LOGGER.error("Failed to move camera to home position")
496
+ return result
File without changes
File without changes