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.
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/PKG-INFO +1 -1
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/pyproject.toml +1 -1
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_monitor.py +124 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/monitor.py +43 -11
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/zm.py +25 -2
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/LICENSE.md +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/README.md +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/README.md +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/__init__.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/__init__.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/conftest.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_api_probes.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_auth.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_availability.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_monitors.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_ptz.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_servers.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/e2e/test_e2e_states.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_client.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_enums.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_exceptions.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_ptz.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_run_state.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_server.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/tests/test_zm.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/__init__.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/exceptions.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/run_state.py +0 -0
- {zm_py-0.5.5.dev11 → zm_py-0.5.5.dev13}/zoneminder/server.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|