zm-py 0.5.5.dev12__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.dev12 → zm_py-0.5.6.dev1}/PKG-INFO +16 -2
  2. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/README.md +15 -1
  3. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/pyproject.toml +1 -1
  4. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/README.md +4 -6
  5. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/conftest.py +2 -2
  6. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_auth.py +1 -1
  7. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_client.py +112 -0
  8. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_monitor.py +124 -0
  9. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/monitor.py +110 -11
  10. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/zm.py +32 -1
  11. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/LICENSE.md +0 -0
  12. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/__init__.py +0 -0
  13. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/__init__.py +0 -0
  14. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_api_probes.py +0 -0
  15. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_availability.py +0 -0
  16. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_monitors.py +0 -0
  17. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_ptz.py +0 -0
  18. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_servers.py +0 -0
  19. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_states.py +0 -0
  20. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_enums.py +0 -0
  21. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_exceptions.py +0 -0
  22. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_ptz.py +0 -0
  23. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_run_state.py +0 -0
  24. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_server.py +0 -0
  25. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_zm.py +0 -0
  26. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/__init__.py +0 -0
  27. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/exceptions.py +0 -0
  28. {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/run_state.py +0 -0
  29. {zm_py-0.5.5.dev12 → 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.dev12
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.dev12"
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
 
@@ -221,6 +221,53 @@ class TestMonitorImageUrls:
221
221
  assert "user=" not in mon.mjpeg_image_url
222
222
 
223
223
 
224
+ # ---------------------------------------------------------------------------
225
+ # Stream scale & maxfps URL params
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ class TestMonitorStreamScaleMaxfps:
230
+ def test_scale_in_mjpeg_url(self):
231
+ """scale=50 should appear in MJPEG URL."""
232
+ mon = Monitor(StubClient(), _make_raw(), scale=50)
233
+ assert "scale=50" in mon.mjpeg_image_url
234
+
235
+ def test_scale_in_still_url(self):
236
+ """scale=50 should appear in still URL."""
237
+ mon = Monitor(StubClient(), _make_raw(), scale=50)
238
+ assert "scale=50" in mon.still_image_url
239
+
240
+ def test_maxfps_in_mjpeg_url(self):
241
+ """maxfps=5.0 should appear in MJPEG URL (mode=jpeg)."""
242
+ mon = Monitor(StubClient(), _make_raw(), maxfps=5.0)
243
+ assert "maxfps=5.0" in mon.mjpeg_image_url
244
+
245
+ def test_maxfps_not_in_still_url(self):
246
+ """maxfps should NOT appear in still URL (mode=single)."""
247
+ mon = Monitor(StubClient(), _make_raw(), maxfps=5.0)
248
+ assert "maxfps" not in mon.still_image_url
249
+
250
+ def test_both_scale_and_maxfps_in_mjpeg(self):
251
+ """Both params in MJPEG URL when both set."""
252
+ mon = Monitor(StubClient(), _make_raw(), scale=75, maxfps=10.0)
253
+ assert "scale=75" in mon.mjpeg_image_url
254
+ assert "maxfps=10.0" in mon.mjpeg_image_url
255
+
256
+ def test_no_scale_maxfps_when_none(self):
257
+ """Neither param should appear when both are None (backwards compat)."""
258
+ mon = Monitor(StubClient(), _make_raw())
259
+ assert "scale=" not in mon.mjpeg_image_url
260
+ assert "maxfps=" not in mon.mjpeg_image_url
261
+ assert "scale=" not in mon.still_image_url
262
+ assert "maxfps=" not in mon.still_image_url
263
+
264
+ def test_only_scale_still_url(self):
265
+ """Still URL should have scale but not maxfps when both set."""
266
+ mon = Monitor(StubClient(), _make_raw(), scale=25, maxfps=3.0)
267
+ assert "scale=25" in mon.still_image_url
268
+ assert "maxfps" not in mon.still_image_url
269
+
270
+
224
271
  # ---------------------------------------------------------------------------
225
272
  # is_recording
226
273
  # ---------------------------------------------------------------------------
@@ -297,6 +344,83 @@ class TestMonitorIsRecording:
297
344
  mon = Monitor(client, _make_raw())
298
345
  assert mon.is_recording is True
299
346
 
347
+ def test_uses_alarm_command_helper(self):
348
+ """is_recording should use _alarm_command('status') internally."""
349
+ client = StubClient(get_state_return={"status": 2})
350
+ mon = Monitor(client, _make_raw())
351
+ assert mon.is_recording is True
352
+ # Verify get_state was called with the correct alarm status URL
353
+ assert client._get_state_call_count == 1
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # set_force_alarm_state
358
+ # ---------------------------------------------------------------------------
359
+
360
+ class TestForceAlarmState:
361
+ """Test force alarm on/off via the alarm command API."""
362
+
363
+ def test_force_alarm_on(self):
364
+ """set_force_alarm_state(True) should call command:on."""
365
+ calls = []
366
+ client = StubClient(get_state_return={"status": "1"})
367
+ original_get_state = client.get_state
368
+
369
+ def tracking_get_state(api_url):
370
+ calls.append(api_url)
371
+ return original_get_state(api_url)
372
+
373
+ client.get_state = tracking_get_state
374
+ mon = Monitor(client, _make_raw())
375
+ mon.set_force_alarm_state(True)
376
+ assert any("command:on" in c for c in calls)
377
+
378
+ def test_force_alarm_off(self):
379
+ """set_force_alarm_state(False) should call command:off."""
380
+ calls = []
381
+ client = StubClient(get_state_return={"status": "1"})
382
+ original_get_state = client.get_state
383
+
384
+ def tracking_get_state(api_url):
385
+ calls.append(api_url)
386
+ return original_get_state(api_url)
387
+
388
+ client.get_state = tracking_get_state
389
+ mon = Monitor(client, _make_raw())
390
+ mon.set_force_alarm_state(False)
391
+ assert any("command:off" in c for c in calls)
392
+
393
+ def test_force_alarm_on_url_contains_monitor_id(self):
394
+ """The alarm URL should contain the correct monitor ID."""
395
+ calls = []
396
+ client = StubClient(get_state_return={"status": "1"})
397
+ original_get_state = client.get_state
398
+
399
+ def tracking_get_state(api_url):
400
+ calls.append(api_url)
401
+ return original_get_state(api_url)
402
+
403
+ client.get_state = tracking_get_state
404
+ mon = Monitor(client, _make_raw(mid=42))
405
+ mon.set_force_alarm_state(True)
406
+ assert any("id:42" in c for c in calls)
407
+
408
+ def test_force_alarm_logs_on_failure(self, caplog):
409
+ """Warning should be logged when API returns falsy response."""
410
+ client = StubClient(get_state_return={})
411
+ mon = Monitor(client, _make_raw())
412
+ with caplog.at_level("WARNING"):
413
+ mon.set_force_alarm_state(True)
414
+ assert "Failed to set force alarm" in caplog.text
415
+
416
+ def test_force_alarm_no_warning_on_success(self, caplog):
417
+ """No warning when API returns a truthy response."""
418
+ client = StubClient(get_state_return={"status": "1"})
419
+ mon = Monitor(client, _make_raw())
420
+ with caplog.at_level("WARNING"):
421
+ mon.set_force_alarm_state(True)
422
+ assert "Failed to set force alarm" not in caplog.text
423
+
300
424
 
301
425
  # ---------------------------------------------------------------------------
302
426
  # is_available
@@ -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,10 +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):
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
213
+ self._scale = scale
214
+ self._maxfps = maxfps
202
215
  self._last_update = time.monotonic()
203
216
  raw_monitor = raw_result["Monitor"]
204
217
  self._monitor_id = int(raw_monitor["Id"])
@@ -349,12 +362,23 @@ class Monitor:
349
362
  """Get the still jpeg image url of this Monitor."""
350
363
  return self._still_image_url
351
364
 
365
+ def _alarm_command(self, command: str):
366
+ """Send an alarm command for this monitor.
367
+
368
+ Valid commands: 'status', 'on', 'off', 'cancel'.
369
+ Returns the raw API response dict (empty dict on failure).
370
+ """
371
+ return (
372
+ self._client.get_state(
373
+ f"api/monitors/alarm/id:{self._monitor_id}/command:{command}.json"
374
+ )
375
+ or {}
376
+ )
377
+
352
378
  @property
353
379
  def is_recording(self) -> bool | None:
354
380
  """Indicate if this Monitor is currently recording."""
355
- status_response = self._client.get_state(
356
- f"api/monitors/alarm/id:{self._monitor_id}/command:status.json"
357
- )
381
+ status_response = self._alarm_command("status")
358
382
 
359
383
  if not status_response:
360
384
  _LOGGER.warning("Could not get status for monitor %s.", self._monitor_id)
@@ -369,6 +393,22 @@ class Monitor:
369
393
  except (ValueError, TypeError):
370
394
  return False
371
395
 
396
+ def set_force_alarm_state(self, state: bool) -> None:
397
+ """Force a monitor into or out of alarm state.
398
+
399
+ When forced on, ZoneMinder will begin recording on this monitor
400
+ regardless of motion detection. When forced off, the forced alarm
401
+ is cancelled and the monitor returns to its normal detection mode.
402
+ """
403
+ command = "on" if state else "off"
404
+ response = self._alarm_command(command)
405
+ if not response:
406
+ _LOGGER.warning(
407
+ "Failed to set force alarm %s for monitor %s",
408
+ command,
409
+ self._monitor_id,
410
+ )
411
+
372
412
  @property
373
413
  def is_available(self) -> bool:
374
414
  """Indicate if this Monitor is currently available."""
@@ -404,13 +444,16 @@ class Monitor:
404
444
 
405
445
  def _build_image_url(self, monitor, mode) -> str:
406
446
  """Build and return a ZoneMinder camera image url."""
407
- query = urlencode(
408
- {
409
- "mode": mode,
410
- "buffer": monitor["StreamReplayBuffer"],
411
- "monitor": monitor["Id"],
412
- }
413
- )
447
+ params: dict[str, str | int | float] = {
448
+ "mode": mode,
449
+ "buffer": monitor["StreamReplayBuffer"],
450
+ "monitor": monitor["Id"],
451
+ }
452
+ if self._scale is not None:
453
+ params["scale"] = self._scale
454
+ if self._maxfps is not None and mode == "jpeg":
455
+ params["maxfps"] = self._maxfps
456
+ query = urlencode(params)
414
457
  zms_url = self._client.get_zms_url_for_monitor(monitor)
415
458
  url = f"{zms_url}?{query}"
416
459
  _LOGGER.debug("Monitor %s %s URL (without auth): %s", monitor["Id"], mode, url)
@@ -444,3 +487,59 @@ class Monitor:
444
487
  _LOGGER.exception("Unable to connect to ZoneMinder for PTZ control")
445
488
  return False
446
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)
@@ -33,6 +33,8 @@ class ZoneMinder:
33
33
  server_path=DEFAULT_SERVER_PATH,
34
34
  zms_path=DEFAULT_ZMS_PATH,
35
35
  verify_ssl=True,
36
+ stream_scale=None,
37
+ stream_maxfps=None,
36
38
  ) -> None:
37
39
  """Create a ZoneMinder API Client."""
38
40
  self._server_url = ZoneMinder._build_server_url(server_host, server_path)
@@ -40,6 +42,8 @@ class ZoneMinder:
40
42
  self._username = username
41
43
  self._password = password
42
44
  self._verify_ssl = verify_ssl
45
+ self._stream_scale: int | None = stream_scale
46
+ self._stream_maxfps: float | None = stream_maxfps
43
47
  self._session = requests.Session()
44
48
  self._session.verify = verify_ssl
45
49
  self._auth_token: str | None = None
@@ -220,7 +224,14 @@ class ZoneMinder:
220
224
  monitors = []
221
225
  for raw_result in raw_monitors["monitors"]:
222
226
  _LOGGER.debug("Initializing camera %s", raw_result["Monitor"]["Id"])
223
- monitors.append(Monitor(self, raw_result))
227
+ monitors.append(
228
+ Monitor(
229
+ self,
230
+ raw_result,
231
+ scale=self._stream_scale,
232
+ maxfps=self._stream_maxfps,
233
+ )
234
+ )
224
235
 
225
236
  return monitors
226
237
 
@@ -463,3 +474,23 @@ class ZoneMinder:
463
474
  else:
464
475
  _LOGGER.error("Failed to move camera to %s", direction)
465
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