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.
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/PKG-INFO +16 -2
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/README.md +15 -1
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/pyproject.toml +1 -1
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/README.md +4 -6
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/conftest.py +2 -2
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_auth.py +1 -1
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_client.py +112 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_monitor.py +124 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/monitor.py +110 -11
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/zm.py +32 -1
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/LICENSE.md +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/__init__.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/__init__.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_api_probes.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_availability.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_monitors.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_ptz.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_servers.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/e2e/test_e2e_states.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_enums.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_exceptions.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_ptz.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_run_state.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_server.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/tests/test_zm.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/__init__.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/exceptions.py +0 -0
- {zm_py-0.5.5.dev12 → zm_py-0.5.6.dev1}/zoneminder/run_state.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
| `
|
|
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.
|
|
206
|
-
s.cookies = zm_client.
|
|
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.
|
|
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__(
|
|
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.
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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(
|
|
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
|
|
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
|