zm-py 0.5.5.dev11__tar.gz → 0.5.5.dev13__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.

Potentially problematic release.


This version of zm-py might be problematic. Click here for more details.

Files changed (29) hide show
  1. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/PKG-INFO +1 -1
  2. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/pyproject.toml +1 -1
  3. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_monitor.py +124 -0
  4. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/monitor.py +43 -11
  5. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/zm.py +25 -2
  6. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/LICENSE.md +0 -0
  7. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/README.md +0 -0
  8. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/README.md +0 -0
  9. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/__init__.py +0 -0
  10. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/__init__.py +0 -0
  11. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/conftest.py +0 -0
  12. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_api_probes.py +0 -0
  13. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_auth.py +0 -0
  14. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_availability.py +0 -0
  15. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_monitors.py +0 -0
  16. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_ptz.py +0 -0
  17. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_servers.py +0 -0
  18. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_states.py +0 -0
  19. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_client.py +0 -0
  20. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_enums.py +0 -0
  21. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_exceptions.py +0 -0
  22. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_ptz.py +0 -0
  23. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_run_state.py +0 -0
  24. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_server.py +0 -0
  25. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_zm.py +0 -0
  26. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/__init__.py +0 -0
  27. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/exceptions.py +0 -0
  28. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/run_state.py +0 -0
  29. {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/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.dev11
3
+ Version: 0.5.5.dev13
4
4
  Summary: A loose python wrapper around the ZoneMinder REST API.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE.md
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "zm-py"
3
- version = "0.5.5.dev11"
3
+ version = "0.5.5.dev13"
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>"]
@@ -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
@@ -195,10 +195,12 @@ class TimePeriod(Enum):
195
195
  class Monitor:
196
196
  """Represents a Monitor from ZoneMinder."""
197
197
 
198
- def __init__(self, client, raw_result):
198
+ def __init__(self, client, raw_result, *, scale=None, maxfps=None):
199
199
  """Create a new Monitor."""
200
200
  self._client = client
201
201
  self._raw_result = raw_result
202
+ self._scale: int | None = scale
203
+ self._maxfps: float | None = maxfps
202
204
  self._last_update = time.monotonic()
203
205
  raw_monitor = raw_result["Monitor"]
204
206
  self._monitor_id = int(raw_monitor["Id"])
@@ -349,12 +351,23 @@ class Monitor:
349
351
  """Get the still jpeg image url of this Monitor."""
350
352
  return self._still_image_url
351
353
 
354
+ def _alarm_command(self, command: str):
355
+ """Send an alarm command for this monitor.
356
+
357
+ Valid commands: 'status', 'on', 'off', 'cancel'.
358
+ Returns the raw API response dict (empty dict on failure).
359
+ """
360
+ return (
361
+ self._client.get_state(
362
+ f"api/monitors/alarm/id:{self._monitor_id}/command:{command}.json"
363
+ )
364
+ or {}
365
+ )
366
+
352
367
  @property
353
368
  def is_recording(self) -> bool | None:
354
369
  """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
- )
370
+ status_response = self._alarm_command("status")
358
371
 
359
372
  if not status_response:
360
373
  _LOGGER.warning("Could not get status for monitor %s.", self._monitor_id)
@@ -369,6 +382,22 @@ class Monitor:
369
382
  except (ValueError, TypeError):
370
383
  return False
371
384
 
385
+ def set_force_alarm_state(self, state: bool) -> None:
386
+ """Force a monitor into or out of alarm state.
387
+
388
+ When forced on, ZoneMinder will begin recording on this monitor
389
+ regardless of motion detection. When forced off, the forced alarm
390
+ is cancelled and the monitor returns to its normal detection mode.
391
+ """
392
+ command = "on" if state else "off"
393
+ response = self._alarm_command(command)
394
+ if not response:
395
+ _LOGGER.warning(
396
+ "Failed to set force alarm %s for monitor %s",
397
+ command,
398
+ self._monitor_id,
399
+ )
400
+
372
401
  @property
373
402
  def is_available(self) -> bool:
374
403
  """Indicate if this Monitor is currently available."""
@@ -404,13 +433,16 @@ class Monitor:
404
433
 
405
434
  def _build_image_url(self, monitor, mode) -> str:
406
435
  """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
- )
436
+ params: dict[str, str | int | float] = {
437
+ "mode": mode,
438
+ "buffer": monitor["StreamReplayBuffer"],
439
+ "monitor": monitor["Id"],
440
+ }
441
+ if self._scale is not None:
442
+ params["scale"] = self._scale
443
+ if self._maxfps is not None and mode == "jpeg":
444
+ params["maxfps"] = self._maxfps
445
+ query = urlencode(params)
414
446
  zms_url = self._client.get_zms_url_for_monitor(monitor)
415
447
  url = f"{zms_url}?{query}"
416
448
  _LOGGER.debug("Monitor %s %s URL (without auth): %s", monitor["Id"], mode, url)
@@ -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
@@ -51,6 +55,11 @@ class ZoneMinder:
51
55
  def login(self) -> bool:
52
56
  """Login to the ZoneMinder API."""
53
57
  _LOGGER.debug("Attempting to login to ZoneMinder")
58
+ # Clear any stale token before re-auth. If JWT auth fails and we
59
+ # fall back to legacy session cookies, a leftover token would be
60
+ # sent as ?token=… on every subsequent request, causing ZM to reject
61
+ # the stale JWT with 401 even though the session cookie is valid.
62
+ self._auth_token = None
54
63
 
55
64
  login_post = {}
56
65
  if self._username:
@@ -152,7 +161,14 @@ class ZoneMinder:
152
161
  if req.ok:
153
162
  break
154
163
  if attempt < ZoneMinder.LOGIN_RETRIES - 1:
155
- self.login()
164
+ _LOGGER.debug(
165
+ "API call %s %s returned HTTP %s, re-authenticating",
166
+ method.upper(),
167
+ api_url,
168
+ req.status_code,
169
+ )
170
+ login_ok = self.login()
171
+ _LOGGER.debug("Re-login succeeded: %s", login_ok)
156
172
 
157
173
  else:
158
174
  _LOGGER.error(
@@ -208,7 +224,14 @@ class ZoneMinder:
208
224
  monitors = []
209
225
  for raw_result in raw_monitors["monitors"]:
210
226
  _LOGGER.debug("Initializing camera %s", raw_result["Monitor"]["Id"])
211
- 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
+ )
212
235
 
213
236
  return monitors
214
237
 
File without changes
File without changes
File without changes